Skip to main content

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 CTOCompliance Architect Initial encryptiondraft policy for Bilko accountingencryption SaaSstandards

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 inclassified transit.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

Requiredfield-leveldisk-levelat-rest
LevelUse Case LabelAlgorithm ExamplesKey Size EncryptionMode Notes
L4-AData at rest (general) Restricted (Personal)AES JMBG, OIB256-bit AES-256-GCM Authenticated encryption (prisma-field-encryption) + HMAC-SHA256 hash column + AES-256 at-rest + TLS 1.3 (See ADR-014)default
L4-BDatabase disk encryption Restricted (Business/Financial)AES PIB, JIB, IBAN256-bit AES-256XTS Railway encryptionPostgreSQL (Railway) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits in list views) (See ADR-014)default
L3File storage ConfidentialAES Invoice amounts, bank statements, transaction data256-bit AES-256GCM Cloudflare +R2 TLS 1.3server-side
L2Backup encryption InternalAES Email, name, phone, address256-bit GCMRailway automatic backup encryption

Asymmetric Encryption

transit)
Use CaseAlgorithmKey SizeNotes
TLS key exchangeECDHEP-256 / P-384Cloudflare + Railway TLS 1.3 minimum
L1JWT signing PublicHMAC-SHA-256 (HS256) Organization display name, public invoice ref256-bit No encryption requiredJWT_SECRET (but32+ TLSchars, inCSPRNG)
JWT refreshHMAC-SHA-256 (HS256)256-bitJWT_REFRESH_SECRET (separate key)
Future: JWT asymmetricEd25519 (EdDSA)256-bitPlanned Phase 2 migration

Hashing & Password Storage

Use CaseAlgorithmParametersNotes
Password hashingbcryptcost factor = 12bcrypt.hash(password, 12)
Token hashing (refresh tokens)HMAC-SHA-256Refresh tokens hashed before DB storage
Data integrity (general)SHA-256File checksums, non-security hashing

2.2 Prohibited Algorithms (NEVER USE)

AlgorithmReason
MD5Collision attacks (2004+) — completely broken
SHA-1Collision attacks (2017+)
DES / 3DESKey size insufficient
RC4Statistical biases
ECB mode (any cipher)Leaks data patterns — deterministic
RSA < 2048-bitInsufficient key strength
AES-128 for Restricted dataInsufficient for financial/tax ID data
bcrypt < 12 roundsInsufficient work factor
MD5 for password hashingNEVER — 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) 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

ScopeEncryption

mode)string
ConnectionDatabase EncryptionMethodKey ManagementCoverage
BrowserPostgreSQL (Railway CloudflareEU West) TLSAES-256 1.3disk encryption (CloudflareRailway managed)TDE)Railway-managedAll data classifications
CloudflarePostgreSQL Railwaytax APIIDs (PIB/JMBG/OIB/JIB) TLSAES-256-GCM 1.2+application-layer field encryptionEnvironment variable (FullRailway Strictsecrets) L4 Restricted
API → PostgreSQL (Railway)— IBAN ssl=requireAES-256-GCM inapplication-layer DATABASE_URLfield connectionencryption Environment variable (Railway secrets)L4 Restricted
APIPostgreSQL → SEF portal (Serbia)backups TLS 1.2+AES-256 (SerbianRailway government portal)
API → FINA/HR-FISK (Croatia)automatic) TLS 1.2+ (FINA PKI)
API → SentryRailway-managed TLSAll 1.3data

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 publiclyL4 availableRestricted businessdata:

tax
// Tax IDs (PIB,and JIB)bank isaccount disproportionatenumbers perencrypted GDPRat Articleapplication 32.

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 operation
  • Key: 32 bytes (256 bits), stored in FIELD_ENCRYPTION_KEY environment 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)

FieldStorage TableMethod JurisdictionKey Management Notes
jmbgCloudflare R2 (JMBG)receipts, invoice PDFs) ContactAES-256 server-side (Cloudflare default) RS, BACloudflare-managed Serbian/BiHEU citizenregion numberbucket — irrevocable personal identifier, encodes DOB/gender/region. Stored encrypted + jmbgHash HMAC column.
oib (OIB)ContactHRCroatian personal/company tax ID — unique cross-system identifier. Stored encrypted + oibHash HMAC column.required

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

All session cookies set with:

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:

NationalNational encryption
Field Table JurisdictionControlsReason
taxId /(organization registrationNumbertax (PIB)ID: PIB/JIB/OIB) Contact, Organizationorganizations RS Diskbusiness encryption + org-scoping + RBAC + TLSidentifier
taxId /(contact registrationNumbertax (ID: PIB/JMBG/OIB/JIB) Contact, Organizationcontacts BA Diskperson/business encryption + org-scoping + RBAC + TLSidentifier
iban BankAccountorganizations, contacts, bankAccounts AllBank account number
totpSecret Diskusers 2FA +seed org-scoping +must RBACnot +be TLS + API masking (last 4 digits in list views)readable

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 secrets
    • JWT_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

    Refresh tokens stored as HMAC-SHA256SHA-256 hash in database — raw token never stored.

    5.3 Password Hashing

    jmbgHashimport bcrypt from 'bcrypt';
    
    // oibHashRegistration
    column (separate FIELD_HMAC_KEY)
    
  1. Search is performed on theconst hash column
  2. =
  3. Fullawait plaintextbcrypt.hash(plainPassword, is12); decrypted// onlyLogin whenverification displayingconst toisValid an= authorizedawait user
  4. bcrypt.compare(plainPassword,
storedHash);

bcrypt parameters: cost factor 12. Never downgrade below 12.


6. PasswordKey HashingManagement Summary

Full policy: key-management-policy.md

ashardware improves)bcryptlibrary (16 bytes)1uppercase, 1 number, 1 special characterAPI 5SHA1 hash sent)
ParameterKey Type ValueStorageRotationOwner
AlgorithmJWT_SECRET bcryptRailway environment secretQuarterlySecurity
Cost factorJWT_REFRESH_SECRET 12Railway (adaptableenvironment upwardsecret Quarterly Security
SaltFIELD_ENCRYPTION_KEY (tax IDs, IBAN) AutomaticallyRailway generatedenvironment bysecret Annual Security
MinimumPostgreSQL passworddisk entropyencryption 8Railway-managed chars,(TDE) Railway-managed Railway
BreachCloudflare checkR2 encryption HaveIBeenPwnedCloudflare-managed Cloudflare-managedCloudflare
TLS certificates (k-anonymityCloudflare) Cloudflare onlyCertificate firstManager 90 charsdays of(automatic) 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

(RSA+SHA-256)— asymmetric RSA,stored in JWT_PRIVATE_KEY  inJWT_PUBLIC_KEY(for verification)minutes days
ParameterSystem ValueAlgorithmKey SizeModeKey LocationNext Rotation
AlgorithmPostgreSQL disk (Railway) RS256AES-256 256-bit XTS Railway-managed Railway-managed
PrivateTax keyID field encryption 2048-AES-256-GCM256-bit GCM Railway env secretAnnual
PublicIBAN keyfield encryption StoredAES-256-GCM 256-bit GCM Railway env secret Annual
AccessCloudflare token lifetimeR2 15AES-256 256-bitCloudflare-managedCloudflare-managed
External TLS (Cloudflare)ECDSA P-256256-bitCloudflare90 days (auto)
JWT signingHMAC-SHA-256256-bitRailway env secretQuarterly
Refresh token lifetimesigning 7HMAC-SHA-256 256-bitRailway env secretQuarterly
KeyPassword rotationhashing 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.

ParameterStandard
Database typebcrypt NUMERIC(19,4) — PostgreSQL exact decimal
Application typecost=12 Decimal.js library  not JavaScript number
Rounding Banker's rounding (round half to even)
Currency storageN/A ISO 4217 code (RSD, BAM, EUR) in separate columnN/A

9.8. KeyFinancial ManagementData SummaryIntegrity

KeysFinancial aredata managedrequires pernot thejust Keyconfidentiality Managementbut Policyalso (key-management-policy.md).integrity. EncryptionBilko keysenforces:

must
  1. NUMERIC(19,4) for all monetary amounts — never be:

    float
      or
    • CommittedJavaScript tonumber. sourcePrevents coderounding repositorieserrors in VAT calculations.
    • LoggedImmutable inLoggedAction applicationtable logs— all mutations append-only with old/new values. Enables financial audit.
    • SentDouble-entry overenforcement unencrypted channelsdebit = credit validated at backend. Prevents imbalanced entries.
    • StoredExchange outsiderate Railwaylocking environment variablesrates orstored Vaultwardenat transaction date. Historical accuracy preserved.

10.9. ProhibitedException AlgorithmsProcess

Exceptions

permitted IDs, IBAN, TOTP secrets, this
AlgorithmStatusReason
MD5PROHIBITEDCryptographically broken
SHA-1PROHIBITED for signing/hashing sensitive dataCollision attacks demonstrated
DES / 3DESPROHIBITEDInsufficient key length
RC4PROHIBITEDMultiple vulnerabilities
RSA with key < 2048 bitsPROHIBITEDInsufficient for current threat model
AES-128PERMITTED (not preferred)AES-256 required forfor: L4 Restricted data
AES-256-CBCPERMITTED with cautionGCM preferred (providestax authentication)
AES-256-GCMpassword hashes) — no exceptions.

REQUIREDException request process:

  1. Submit to: [email protected]
  2. Required: system affected, algorithm excepted from, business justification, risk assessment, compensating controls, proposed duration (max 12 months)
  3. Approval: CTO
  4. Log: /docs/security/exceptions.md
  5. Review: Quarterly

Active exceptions: forNone L4at fields

Authenticated encryption
time.


Approval

Compliance
Role Name SignatureDate DateSignature
Author CTO Architect 2026-02-23
Reviewer (DPO)CTO
Reviewer (Engineering Lead)DPO
ApproverEngineering Lead CEO