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 / SecurityCompliance Architect Status: Draft Reviewers: CTO, DPO, Engineering Lead Next Review: 2026-08-23 Classification: 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 processedat rest, in transit, and at the application level across all systems operated 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 by Bilko
systems,(api.bilko.io,databases,bilko.io,APIs,PostgreSQL,backups,Cloudflare R2) - All employees, contractors, and third parties with access to Bilko systems
- All data
inclassifiedtransit.Internal, Confidential, or Restricted (see compliance-framework.md §6) - Railway PostgreSQL (EU West), Vercel (frontend), Cloudflare R2 (file storage)
Regulatory basis:
- GDPR Art. 32 — Appropriate technical measures including encryption
- ZZPL Art. 50 (Serbia) — Security of personal data processing
- ZZLP Art. 14 (BiH) — Technical data protection measures
- Zakon o računovodstvu (RS/HR/BA) — Integrity of financial records
2. DataEncryption ClassificationStandards & Approved Algorithms
2.1 Approved Algorithms
Symmetric Encryption Requirements
| Notes | ||||
|---|---|---|---|---|
| Authenticated encryption |
||||
| Railway |
||||
| Cloudflare |
||||
| GCM | Railway automatic backup encryption |
Asymmetric Encryption
| Use Case | Algorithm | Key Size | Notes |
|---|---|---|---|
| TLS key exchange | ECDHE | P-256 / P-384 | Cloudflare + Railway TLS 1.3 |
| JWT refresh | HMAC-SHA-256 (HS256) | 256-bit | JWT_REFRESH_SECRET (separate key) |
| Future: JWT asymmetric | Ed25519 (EdDSA) | 256-bit | Planned Phase 2 migration |
Hashing & Password Storage
| Use Case | Algorithm | Parameters | Notes |
|---|---|---|---|
| Password hashing | bcrypt | cost factor = 12 | bcrypt.hash(password, 12) |
| Token hashing (refresh tokens) | HMAC-SHA-256 | — | Refresh tokens hashed before DB storage |
| Data integrity (general) | SHA-256 | — | File checksums, non-security hashing |
2.2 Prohibited Algorithms (NEVER USE)
| Algorithm | Reason |
|---|---|
| MD5 | Collision attacks (2004+) — completely broken |
| SHA-1 | Collision attacks (2017+) |
| DES / 3DES | Key size insufficient |
| RC4 | Statistical biases |
| ECB mode (any cipher) | Leaks data patterns — deterministic |
| RSA < 2048-bit | Insufficient key strength |
| AES-128 for Restricted data | Insufficient for financial/tax ID data |
| bcrypt < 12 rounds | Insufficient work factor |
| MD5 for password hashing | NEVER — use bcrypt |
3. Encryption-in-TransitEncryption at Rest
Standards
3.1 - Database
Minimum:TLS 1.2 (legacy client support only)Preferred:TLS 1.3 — enforced via Cloudflare SSL Full (Strict) modeCipher suites (TLS 1.3):TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256Certificate:Let's Encrypt via Cloudflare (auto-renew) for *.bilko.ioHSTS:Strict-Transport-Security: max-age=31536000; includeSubDomains; preload
ScopeEncryption
| Key Management | Coverage | ||
|---|---|---|---|
| Railway-managed | All data classifications | ||
| Environment variable ( |
L4 Restricted | ||
AES-256-GCM |
Environment variable (Railway secrets) | L4 Restricted | |
What is NOT acceptable
HTTP (unencrypted) for any Bilko endpoint — Cloudflare redirects HTTP → HTTPSSelf-signed certificates in productionTLS 1.0 or TLS 1.1 connectionsDisabling 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 WestBackups:Encrypted using same key before upload to Railway backup storageConnection:ssl=require— plaintext connection not allowed even from same host
Backup Files
Bilko does not manage its own database backups — Railway handles thisIf manual exports are taken for disaster recovery: encrypt with GPG (AES-256) before storingGPG 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 forpubliclyL4availableRestrictedbusinessdata:tax// Tax IDs(PIB,andJIB)bankisaccountdisproportionatenumbersperencryptedGDPRatArticleapplication32.layer
Field-levelIn addition to Railway disk 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 operationKey:32 bytes (256 bits), stored inFIELD_ENCRYPTION_KEYenvironment variable (Railway secret)Output format:base64(iv):base64(authTag):base64(ciphertext)stored as TEXT column
Implementation
import { createCipheriv, createDecipheriv, randomBytes }crypto from 'crypto';
const ALGORITHM = 'aes-256-gcm'async 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(encryptRestrictedField(plaintext: string): Promise<string> {
const key = getKey(Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex'); // 32 bytes
const iv = crypto.randomBytes(12); // 96-bit IV for GCM
const cipher = crypto.createCipheriv(ALGORITHM,'aes-256-gcm', key, iv);
const encryptedciphertext = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTagtag = cipher.getAuthTag();
// Store: base64(iv || tag || ciphertext)
return Buffer.concat([iv, authTag,tag, encrypted]ciphertext]).map((b) => b.toString('base64')).join(':');
}
exportasync function decryptField(ciphertext:decryptRestrictedField(stored: string): Promise<string> {
const key = getKey(Buffer.from(process.env.FIELD_ENCRYPTION_KEY!, 'hex');
const [ivB64, tagB64, encB64]buf = ciphertext.split(Buffer.from(stored, ':'base64');
const iv = Buffer.from(ivB64,buf.subarray(0, 'base64')12);
const authTagtag = Buffer.from(tagB64,buf.subarray(12, 'base64')28);
const encryptedciphertext = Buffer.from(encB64, 'base64')buf.subarray(28);
const decipher = crypto.createDecipheriv(ALGORITHM,'aes-256-gcm', key, iv);
decipher.setAuthTag(authTag)tag);
return Buffer.concat([decipher.update(encrypted),ciphertext) + decipher.final()]).toString('utf8');
}
Fields3.2 SubjectFile to Field-LevelStorage Encryption (L4-A)
| Notes | |||
|---|---|---|---|
Cloudflare R2 ( |
| ||
| |
Fields3.3 NOTBackup SubjectEncryption
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 — prohibited
- SSL 2.0 / SSL 3.0 — prohibited
- RC4 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 (L4-B — Disk-Level + Controls)Requirements
PerRequired ADR-014,for the followingall L4 fieldsRestricted 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.fields:
| Field | Table | ||
|---|---|---|---|
taxId tax |
organizations |
||
taxId tax |
contacts |
||
iban |
organizations, contacts, bankAccounts |
||
totpSecret |
users |
2FA |
Searchability5.2 JWT Token Encryption
L4-AJWTs encrypted fields (JMBG, OIB) cannot be searchedsigned with SQLHMAC-SHA-256 LIKE(HS256) orusing equality. Exact-match lookup uses HMAC hash columns:JWT_SECRET:
StoreJWT_SECRET:a32+deterministiccharacter random string (CSPRNG), stored in Railway secretsJWT_REFRESH_SECRET: Separate 32+ character key — never same as JWT_SECRET- Access token payload:
{ sub: userId, org: organizationId, role, iat, exp } - NO PII in JWT payload — no email, name, tax ID
Search is performed on theconst hashcolumn= Fullawaitplaintextbcrypt.hash(plainPassword,is12);decrypted//onlyLoginwhenverificationdisplayingconsttoisValidan=authorizedawaituserbcrypt.compare(plainPassword,
Refresh tokens stored as HMAC-SHA256SHA-256 hash in database — raw token never stored.
5.3 Password Hashing
import bcrypt from 'bcrypt'; //jmbgHashRegistrationoibHashcolumn (separateFIELD_HMAC_KEY)
bcrypt parameters: cost factor 12. Never downgrade below 12.
6. PasswordKey HashingManagement Summary
Full policy: key-management-policy.md
| Rotation | Owner | ||
|---|---|---|---|
JWT_SECRET |
Quarterly | Security | |
JWT_REFRESH_SECRET |
Quarterly | Security | |
FIELD_ENCRYPTION_KEY (tax IDs, IBAN) |
Annual | Security | |
| Railway-managed | Railway | ||
| Cloudflare-managed | Cloudflare | ||
| TLS certificates ( |
Cloudflare |
90 |
Cloudflare |
Never:Secrets never committed to git. Store.env plaintextfiles passwords,in use.gitignore. MD5All secrets managed via Railway environment variables (production) or SHA1.env.local for(development, passwords, use bcrypt cost factor below 10.git-ignored).
7. JWTCryptographic SigningInventory
| Key Size | Mode | Key Location | Next Rotation | ||
|---|---|---|---|---|---|
| 256-bit | XTS | Railway-managed | Railway-managed | ||
| 256-bit | GCM | Railway env secret | Annual | ||
| 256-bit | GCM | Railway env secret | Annual | ||
| 256-bit | — | Cloudflare-managed | Cloudflare-managed | ||
| External TLS (Cloudflare) | ECDSA P-256 | 256-bit | — | Cloudflare | 90 days (auto) |
| JWT signing | HMAC-SHA-256 | 256-bit | — | Railway env secret | Quarterly |
| Refresh token |
256-bit | — | Railway env secret | Quarterly | |
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.
| |
| |
9.8. KeyFinancial ManagementData SummaryIntegrity
KeysFinancial aredata managedrequires pernot thejust Keyconfidentiality Managementbut Policyalso (key-management-policy.md).integrity. EncryptionBilko keysenforces:
- NUMERIC(19,4) for all monetary amounts — never
be:float- or
CommittedJavaScripttonumber.sourcePreventscoderoundingrepositorieserrors in VAT calculations.LoggedImmutableinLoggedActionapplicationtablelogs— all mutations append-only with old/new values. Enables financial audit.SentDouble-entryoverenforcementunencrypted—channelsdebit = credit validated at backend. Prevents imbalanced entries.StoredExchangeoutsiderateRailwaylockingenvironment—variablesratesorstoredVaultwardenat transaction date. Historical accuracy preserved.
10.9. ProhibitedException AlgorithmsProcess
| password hashes) — no exceptions.
Active exceptions: | this
Approval
| Role | Name | |||
|---|---|---|---|---|
| Author | Architect | 2026-02-23 | ||