Data Encryption Policy
Data Encryption Policy
Project / Organization: Bilko — Balkan Accounting SaaS Policy Number: POL-SEC-ENC-001 Version: 1.0 Date: 2026-02-23 Author: CTO / Security Architect Status: Draft Reviewers: DPO, Engineering Lead Classification: Confidential
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | CTO | Initial encryption policy for Bilko accounting SaaS |
1. Purpose & Scope
This policy defines encryption standards for all data processed by Bilko. Bilko handles regulated financial data across three jurisdictions (Serbia, Bosnia & Herzegovina, Croatia) including tax IDs, IBAN numbers, and financial transaction records that must meet GDPR, ZZPL, and ZZLP BiH requirements.
Scope: All Bilko systems, databases, APIs, backups, and data in transit.
2. Data Classification & Encryption Requirements
| Level | Label | Examples | Encryption Required |
|---|---|---|---|
| L4 | Restricted | PIB, JMBG, OIB, JIB (tax IDs), IBAN | AES-256-GCM field-level encryption + AES-256 at-rest + TLS 1.3 |
| L3 | Confidential | Invoice amounts, bank statements, transaction data | AES-256 at-rest + TLS 1.3 |
| L2 | Internal | Email, name, phone, address | TLS 1.3 minimum |
| L1 | Public | Organization display name, public invoice ref | No encryption required (but TLS in transit) |
3. Encryption-in-Transit
Standards
- Minimum: TLS 1.2 (legacy client support only)
- Preferred: TLS 1.3 — enforced via Cloudflare SSL Full (Strict) mode
- Cipher suites (TLS 1.3): TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256
- Certificate: Let's Encrypt via Cloudflare (auto-renew) for *.bilko.io
- HSTS:
Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
Scope
| Connection | Encryption |
|---|---|
| Browser → Cloudflare | TLS 1.3 (Cloudflare managed) |
| Cloudflare → Railway API | TLS 1.2+ (Full Strict mode) |
| API → PostgreSQL (Railway) | ssl=require in DATABASE_URL connection string |
| API → SEF portal (Serbia) | TLS 1.2+ (Serbian government portal) |
| API → FINA/HR-FISK (Croatia) | TLS 1.2+ (FINA PKI) |
| API → Sentry | TLS 1.3 |
What is NOT acceptable
- HTTP (unencrypted) for any Bilko endpoint — Cloudflare redirects HTTP → HTTPS
- Self-signed certificates in production
- TLS 1.0 or TLS 1.1 connections
- Disabling certificate verification in API clients
4. Encryption-at-Rest
Database (PostgreSQL on Railway)
- Algorithm: AES-256 (Railway managed disk encryption)
- Level: Full disk encryption — Railway EU West
- Backups: Encrypted using same key before upload to Railway backup storage
- Connection:
ssl=require— plaintext connection not allowed even from same host
Backup Files
- Bilko does not manage its own database backups — Railway handles this
- If manual exports are taken for disaster recovery: encrypt with GPG (AES-256) before storing
- GPG key stored in Vaultwarden, not in same location as backup files
5. Field-Level Encryption (L4 Restricted)
Field-level encryption is applied to Restricted (L4) data BEFORE writing to the database. The database column stores only ciphertext. The application decrypts on read.
Algorithm
- Algorithm: AES-256-GCM (authenticated encryption — provides confidentiality + integrity)
- IV: 12 bytes, randomly generated per encryption operation
- Key: 32 bytes (256 bits), stored in
FIELD_ENCRYPTION_KEYenvironment variable (Railway secret) - Output format:
base64(iv):base64(authTag):base64(ciphertext)stored as TEXT column
Implementation
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
function getKey(): Buffer {
const hex = process.env.FIELD_ENCRYPTION_KEY;
if (!hex || hex.length !== 64) {
throw new Error("FIELD_ENCRYPTION_KEY must be a 64-character hex string (32 bytes)");
}
return Buffer.from(hex, "hex");
}
export function encryptField(plaintext: string): string {
const key = getKey();
const iv = randomBytes(12);
const cipher = createCipheriv(ALGORITHM, key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted].map(b => b.toString("base64")).join(":");
}
export function decryptField(ciphertext: string): string {
const key = getKey();
const [ivB64, tagB64, encB64] = ciphertext.split(":");
const iv = Buffer.from(ivB64, "base64");
const authTag = Buffer.from(tagB64, "base64");
const encrypted = Buffer.from(encB64, "base64");
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8");
}
Fields Subject to Field-Level Encryption
| Field | Table | Jurisdiction | Notes |
|---|---|---|---|
taxId (PIB) |
Contact, Organization | RS | Serbian company tax ID |
personalId (JMBG) |
Contact | RS | Only for natural persons — requires consent gate |
taxId (OIB) |
Contact, Organization | HR | Croatian personal/company tax ID |
taxId (JIB) |
Contact, Organization | BA | BiH tax identification |
iban |
BankAccount | All | International Bank Account Number |
Searchability
Encrypted fields cannot be searched with SQL LIKE or equality. Where search is needed (e.g., find invoice by buyer PIB):
- Store a deterministic HMAC-SHA256 hash in a separate
taxIdHashcolumn (using a separate HMAC key) - Search is performed on
taxIdHashcolumn - Full value is decrypted only when displaying to authorized user
6. Password Hashing
| Parameter | Value |
|---|---|
| Algorithm | bcrypt |
| Cost factor | 12 (adaptable upward as hardware improves) |
| Salt | Automatically generated by bcrypt library (16 bytes) |
| Minimum password entropy | 8 chars, 1 uppercase, 1 number, 1 special character |
| Breach check | HaveIBeenPwned API (k-anonymity — only first 5 chars of SHA1 hash sent) |
Never: Store plaintext passwords, use MD5 or SHA1 for passwords, use bcrypt cost factor below 10.
7. JWT Signing
| Parameter | Value |
|---|---|
| Algorithm | RS256 (RSA + SHA-256) — asymmetric |
| Private key | 2048-bit RSA, stored in JWT_PRIVATE_KEY Railway secret |
| Public key | Stored in JWT_PUBLIC_KEY Railway secret (for verification) |
| Access token lifetime | 15 minutes |
| Refresh token lifetime | 7 days |
| Key rotation | Annually or on compromise |
Why RS256 over HS256: RS256 allows future token verification by external services without sharing the signing secret.
8. Monetary Data Integrity
Financial amounts must never be stored as floating-point types due to rounding errors in financial calculations.
| Parameter | Standard |
|---|---|
| Database type | NUMERIC(19,4) — PostgreSQL exact decimal |
| Application type | Decimal.js library — not JavaScript number |
| Rounding | Banker's rounding (round half to even) |
| Currency storage | ISO 4217 code (RSD, BAM, EUR) in separate column |
9. Key Management Summary
Keys are managed per the Key Management Policy (key-management-policy.md). Encryption keys must never be:
- Committed to source code repositories
- Logged in application logs
- Sent over unencrypted channels
- Stored outside Railway environment variables or Vaultwarden
10. Prohibited Algorithms
| Algorithm | Status | Reason |
|---|---|---|
| MD5 | PROHIBITED | Cryptographically broken |
| SHA-1 | PROHIBITED for signing/hashing sensitive data | Collision attacks demonstrated |
| DES / 3DES | PROHIBITED | Insufficient key length |
| RC4 | PROHIBITED | Multiple vulnerabilities |
| RSA with key < 2048 bits | PROHIBITED | Insufficient for current threat model |
| AES-128 | PERMITTED (not preferred) | AES-256 required for L4 Restricted data |
| AES-256-CBC | PERMITTED with caution | GCM preferred (provides authentication) |
| AES-256-GCM | REQUIRED for L4 fields | Authenticated encryption |
Approval
| Role | Name | Signature | Date |
|---|---|---|---|
| Author | CTO | 2026-02-23 | |
| Reviewer (DPO) | |||
| Reviewer (Engineering Lead) | |||
| Approver | CEO |