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
| AES-256-GCM | ||||
Asymmetric Encryption
Hashing & Password Storage
| |||
2.2 Prohibited Algorithms (NEVER USE)
3. Encryption at RestEncryption-in-Transit
3.1Standards
- 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
| API → PostgreSQL |
ssl=require | connection ||
| API → FINA/HR-FISK (Croatia) | |||
| API → Sentry |
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
L4publiclyRestrictedavailabledata: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
// Tax IDs and bank account numbers encrypted at application layer
// In addition to Railway disk encryption
import crypto{ createCipheriv, createDecipheriv, randomBytes } from 'crypto';
asyncconst ALGORITHM = 'aes-256-gcm'
function encryptRestrictedField(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): Promise<string> {
const key = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex'getKey(); // 32 bytes
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv('aes-256-gcm',ALGORITHM, key, iv);
const ciphertextencrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tagauthTag = cipher.getAuthTag();
// Store: base64(iv || tag || ciphertext)
return Buffer.concat([iv, tag,authTag, ciphertext])encrypted].map((b) => b.toString('base64');).join(':')
}
asyncexport function decryptRestrictedField(stored:decryptField(ciphertext: string): Promise<string> {
const key = Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex'getKey();
const buf[ivB64, tagB64, encB64] = Buffer.from(stored, ciphertext.split('base64':');
const iv = buf.subarray(0,Buffer.from(ivB64, 12);'base64')
const tagauthTag = buf.subarray(12,Buffer.from(tagB64, 28);'base64')
const ciphertextencrypted = buf.subarray(28);Buffer.from(encB64, 'base64')
const decipher = crypto.createDecipheriv('aes-256-gcm',ALGORITHM, key, iv);
decipher.setAuthTag(tag);authTag)
return Buffer.concat([decipher.update(ciphertext) +encrypted), decipher.final()]).toString('utf8');
}
3.2Fields FileSubject Storageto Field-Level Encryption (L4-A)
| Notes | |||
|---|---|---|---|
jmbg ( |
jmbgHash HMAC column. |
||
oib (OIB) |
Contact | HR | Croatian personal/company tax ID — unique cross-system identifier. Stored encrypted + oibHash HMAC column. |
3.3Fields BackupNOT Encryption
Railway provides automatic daily PostgreSQL backups, encrypted with AES-256. Backup retention: 30 days (Railway default). Custom backup strategySubject 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(L4-B — Disk-Level + Controls)
RequiredPer 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 allthese L4fields Restrictedis fields:disproportionate: PIB and JIB are publicly searchable on government registries; IBAN is routinely shared for payment.
| Field | Table | Controls | |
|---|---|---|---|
taxId / registrationNumber ( |
Contact, Organization |
Disk |
|
taxId / registrationNumber ( |
Contact, Organization |
Disk |
|
iban |
BankAccount |
||
All |
| Disk
5.2 JWT Token EncryptionSearchability
JWTsL4-A signedencrypted fields (JMBG, OIB) cannot be searched with HMAC-SHA-256SQL (HS256)LIKE usingor JWT_SECRET:equality. Exact-match lookup uses HMAC hash columns:
JWT_SECRET:Store32+acharacter 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 asdeterministic HMAC-SHA-256SHA256 hash in database — raw token never stored.
5.3 Password Hashing
/import bcrypt from 'bcrypt';jmbgHash/RegistrationoibHashconstcolumn (separateFIELD_HMAC_KEY)
bcrypt parameters: cost factor 12. Never downgrade below 12.
6. KeyPassword Management SummaryHashing
Full policy: key-management-policy.md
Algorithm |
|||
Cost factor |
|||
|
|||
Secrets never committed to git.Never: Store .envfilesplaintext inpasswords, .gitignore.use All secrets managed via Railway environment variables (production)MD5 or SHA1 .env.local(development,for git-ignored).passwords, use bcrypt cost factor below 10.
7. CryptographicJWT InventorySigning
in JWT_PRIVATE_KEY Railway | |||||
| Railway | (for |||||
| Refresh token |
|||||
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 | |
| Currency storage |
8.9. FinancialKey DataManagement IntegritySummary
FinancialKeys dataare requiresmanaged notper justthe confidentialityKey butManagement alsoPolicy integrity.(key-management-policy.md). BilkoEncryption enforces:
NUMERIC(19,4) for all monetary amounts—must neverfloatbe:- Committed to source code repositories
- Logged in application logs
- Sent over unencrypted channels
- Stored outside Railway environment variables or
JavaScript number. Prevents rounding errors in VAT calculations.Vaultwarden 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.10. ExceptionProhibited ProcessAlgorithms
Exceptions
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
permittedpreferred)for:AES-256 required for L4 Restricted data
AES-256-CBC
PERMITTED with caution
GCM preferred (
taxprovides IDs,authentication)IBAN,TOTP
secrets,AES-256-GCM
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: CTO
Log: /docs/security/exceptions.md
Review: Quarterly
Active exceptions:REQUIRED Nonefor atL4 thisfieldstime.Authenticated encryption
Approval
Role
Name
DateSignatureSignatureDate
Author
ComplianceCTOArchitect
2026-02-23
CTOReviewer (DPO)
DPOReviewer (Engineering Lead)
Engineering LeadApproverCEO