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-A | Restricted (Personal) | JMBG, OIB | AES-256-GCM field-level encryption (prisma-field-encryption) + HMAC-SHA256 hash column + AES-256 at-rest + TLS 1.3 (See ADR-014) |
| L4-B | Restricted (Business/Financial) | PIB, JIB, IBAN | AES-256 disk-level encryption (Railway) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits in list views) (See ADR-014) |
| 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-A: JMBG and OIB Only)
Tiered L4 approach per ADR-014: Field-level encryption applies ONLY to L4-A personal identifiers (JMBG, OIB). L4-B fields (PIB, JIB, IBAN) rely on disk-level encryption and application controls — see Section 5b. Field-level encryption for publicly available business tax IDs (PIB, JIB) is disproportionate per GDPR Article 32.
Field-level encryption is applied to JMBG and OIB 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";'crypto'
const ALGORITHM = "'aes-256-gcm";gcm'
function getKey(): Buffer {
const hex = process.env.FIELD_ENCRYPTION_KEY;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"'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"'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return [iv, authTag, encrypted].map(b(b) => b.toString("base64"'base64')).join("':"');
}
export function decryptField(ciphertext: string): string {
const key = getKey();
const [ivB64, tagB64, encB64] = ciphertext.split("':"');
const iv = Buffer.from(ivB64, "base64"'base64');
const authTag = Buffer.from(tagB64, "base64"'base64');
const encrypted = Buffer.from(encB64, "base64"'base64');
const decipher = createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(encrypted), decipher.final()]).toString("utf8"'utf8');
}
Fields Subject to Field-Level Encryption (L4-A)
| Field | Table | Jurisdiction | Notes |
|---|---|---|---|
jmbg (JMBG) |
Contact | RS, BA | Serbian/BiH citizen number — irrevocable personal identifier, encodes DOB/gender/region. Stored encrypted + jmbgHash HMAC column. |
oib (OIB) |
Contact | HR | Croatian personal/company tax ID — unique cross-system identifier. Stored encrypted + oibHash HMAC column. |
Fields NOT Subject to Field-Level Encryption (L4-B — Disk-Level + Controls)
Per ADR-014, the following L4 fields are protected by disk-level encryption (Railway AES-256) plus application-layer controls rather than field-level encryption. Field-level encryption for these fields is disproportionate: PIB and JIB are publicly searchable on government registries; IBAN is routinely shared for payment.
| Field | Table | Jurisdiction | Controls |
|---|---|---|---|
taxId / registrationNumber (PIB) |
Contact, Organization | RS | Disk encryption + org-scoping + RBAC + TLS |
taxId / registrationNumber (JIB) |
Contact, Organization | BA | Disk encryption + org-scoping + RBAC + TLS |
iban |
BankAccount | All | Disk encryption + org-scoping + RBAC + TLS + API masking (last 4 digits in list views) |
Searchability
L4-A encrypted fields (JMBG, OIB) cannot be searched with SQL LIKE or equality. Exact-match lookup uses HMAC hash columns:
- Store a deterministic HMAC-SHA256 hash in
jmbgHash/oibHashcolumn (separateFIELD_HMAC_KEY) - Search is performed on the hash column
- Full plaintext is decrypted only when displaying to an 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 |