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:
ComplianceCTO / Security Architect Status: Draft Reviewers:CTO,DPO, Engineering LeadNext Review:2026-08-23Classification: Confidential
Document History
| Version | Date | Author | Changes |
|---|---|---|---|
| 0.1 | 2026-02-23 | Initial |
1. Purpose & Scope
Purpose: This policy defines minimum encryption standards for all data at rest, in transit, and at the application level across all systems operatedprocessed 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
systems operated byBilko(api.bilko.io,systems,bilko.io,databases,PostgreSQL,APIs,Cloudflare R2) All employees, contractors,backups, andthird parties with access to Bilko systemsAlldataclassifiedinInternal, Confidential, or Restricted (see compliance-framework.md §6)Railway PostgreSQL (EU West), Vercel (frontend), Cloudflare R2 (file storage)
Regulatory basis:transit.
GDPR Art. 32 — Appropriate technical measures including encryptionZZPL Art. 50 (Serbia) — Security of personal data processingZZLP Art. 14 (BiH) — Technical data protection measuresZakon o računovodstvu (RS/HR/BA) — Integrity of financial records
2. EncryptionData StandardsClassification & ApprovedEncryption AlgorithmsRequirements
2.1 Approved Algorithms
Symmetric Encryption
| 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";
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 (L4-A)
| Field | Table | Jurisdiction | Notes | |
|---|---|---|---|---|
jmbg ( |
jmbgHash HMAC column. |
|||
oib |
||||
oibHash | ||||
AsymmetricFields 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.
taxId registrationNumber (PIB) |
|||
taxId registrationNumber (JIB) |
|||
iban |
|||
HashingSearchability
&
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 StorageHashing
| 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 1210.
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 | |
||
| Application type | Decimal.js library — not JavaScript number |
||
2.2
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 (NEVER USE)
| Algorithm | Status | Reason | |
|---|---|---|---|
| MD5 | Cryptographically broken | ||
| SHA-1 | PROHIBITED for signing/hashing sensitive data | Collision attacks |
|
| DES / 3DES | Insufficient |
||
| RC4 | |||
| RSA with key < |
PROHIBITED | Insufficient |
|
| AES-128 | PERMITTED (not preferred) | AES-256 required for L4 Restricted | |
| GCM preferred (provides authentication) | |||
3. Encryption at Rest
3.1 Database Encryption
Field-level encryption for L4 Restricted data:
// Tax IDs and bank account numbers encrypted at application layer
// In addition to Railway disk encryption
import crypto from 'crypto';
async function encryptRestrictedField(plaintext: string): Promise<string> {
const key = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex'); // 32 bytes
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
// Store: base64(iv || tag || ciphertext)
return Buffer.concat([iv, tag, ciphertext]).toString('base64');
}
async function decryptRestrictedField(stored: string): Promise<string> {
const key = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex');
const buf = Buffer.from(stored, 'base64');
const iv = buf.subarray(0, 12);
const tag = buf.subarray(12, 28);
const ciphertext = buf.subarray(28);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return decipher.update(ciphertext) + decipher.final('utf8');
}
3.2 File Storage Encryption
3.3 Backup Encryption
Railway provides automatic daily PostgreSQL backups, encrypted with AES-256. Backup retention: 30 days (Railway default). Custom backup strategy to be implemented in Phase 2 with separate backup encryption key stored in Railway environment secrets.
4. Encryption in Transit
4.1 TLS Configuration
Minimum TLS version:
External-facing (api.bilko.io, bilko.io): TLS 1.3 (enforced via Cloudflare)Railway internal: TLS 1.3
Approved cipher suites (TLS 1.3):
TLS_AES_256_GCM_SHA384
TLS_CHACHA20_POLY1305_SHA256
TLS_AES_128_GCM_SHA256 (minimum)
HSTS configuration:
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
HTTP redirect: All HTTP traffic redirected to HTTPS (301). No HTTP allowed in production.
Prohibited:
TLS 1.0 and TLS 1.1 — prohibitedSSL 2.0 / SSL 3.0 — prohibitedRC4 cipher suites — prohibited
4.2 Cookie Security
res.cookie('refreshToken', token, {
httpOnly: true, // Not accessible to JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // CSRF protection
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
4.3 API Security Headers (Helmet.js)
app.use(helmet({
hsts: { maxAge: 63072000, includeSubDomains: true, preload: true },
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", "data:", "https:"],
},
},
}));
5. Application-Level Encryption
5.1 Field-Level Encryption Requirements
Required for all L4 Restricted fields:
| | |
| | |
| | |
| |
5.2 JWT Token Encryption
JWTs signed with HMAC-SHA-256 (HS256) using JWT_SECRET:
JWT_SECRET: 32+ character random string (CSPRNG), stored in Railway secretsJWT_REFRESH_SECRET: Separate 32+ character key — never same as JWT_SECRETAccess token payload:{ sub: userId, org: organizationId, role, iat, exp }NO PII in JWT payload — no email, name, tax ID
Refresh tokens stored as HMAC-SHA-256 hash in database — raw token never stored.
5.3 Password Hashing
import bcrypt from 'bcrypt';
// Registration
const hash = await bcrypt.hash(plainPassword, 12);
// Login verification
const isValid = await bcrypt.compare(plainPassword, storedHash);
bcrypt parameters: cost factor 12. Never downgrade below 12.
6. Key Management Summary
Full policy: key-management-policy.md
| |||
| |||
| |||
Secrets never committed to git. .env files in .gitignore. All secrets managed via Railway environment variables (production) or .env.local (development, git-ignored).
7. Cryptographic Inventory
| AES-256-GCM | |||||
8. Financial Data Integrity
Financial data requires not just confidentiality but also integrity. Bilko enforces:
NUMERIC(19,4) for all monetary amounts— never float or JavaScript number. Prevents rounding errors in VAT calculations.Immutable LoggedAction table— all mutations append-only with old/new values. Enables financial audit.Double-entry enforcement— debit = credit validated at backend. Prevents imbalanced entries.Exchange rate locking— rates stored at transaction date. Historical accuracy preserved.
9. Exception Process
Exceptions not permitted for: L4 Restricted data (tax IDs, IBAN, TOTP secrets, password hashes) — no exceptions.
Exception request process:
Submit to: [email protected]Required: system affected, algorithm excepted from, business justification, risk assessment, compensating controls, proposed duration (max 12 months)Approval: CTOLog:/docs/security/exceptions.mdReview: Quarterly
Active exceptions: None at this time.
Approval
| Role | Name | |||
|---|---|---|---|---|
| Author | 2026-02-23 | |||
| CEO |