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: ComplianceCTO / Security 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 Compliance ArchitectCTO Initial draftencryption policy for Bilko encryptionaccounting standardsSaaS

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 by Bilko (api.bilko.io,systems, bilko.io,databases, PostgreSQL,APIs, Cloudflare R2)
  • All employees, contractors,backups, and third parties with access to Bilko systems
  • All data classifiedin Internal, 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 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. EncryptionData StandardsClassification & ApprovedEncryption AlgorithmsRequirements

2.1 Approved Algorithms

Symmetric Encryption

EncryptionAES-256 AES-256
Use CaseLevel AlgorithmLabel Key SizeExamples Mode NotesRequired
Data at rest (general)L4-A AESRestricted (Personal) 256-bitJMBG, OIB AES-256-GCM Authenticatedfield-level encryption (prisma-field-encryption) default+ HMAC-SHA256 hash column + AES-256 at-rest + TLS 1.3 (See ADR-014)
Database disk encryptionL4-B AESRestricted (Business/Financial) 256-bitPIB, JIB, IBAN XTSRailwaydisk-level PostgreSQLencryption default(Railway) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits in list views) (See ADR-014)
File storageL3 AESConfidential 256-bitInvoice amounts, bank statements, transaction data GCMCloudflareat-rest R2+ server-sideTLS 1.3
Backup encryptionL2 AESInternal 256-bitEmail, name, phone, address GCMRailway automatic backup encryption

Asymmetric Encryption

in
Use CaseAlgorithmKey SizeNotes
TLS key exchangeECDHEP-256 / P-384Cloudflare + Railway TLS 1.3 minimum
JWT signingL1 HMAC-SHA-256 (HS256)Public 256-bitOrganization display name, public invoice ref JWT_SECRETNo encryption required (32+but chars,TLS CSPRNG)
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 bcrypttransit)

3. Encryption at RestEncryption-in-Transit

3.1Standards

Database
    Encryption
  • 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

Strictconnection
DatabaseConnection MethodKey ManagementCoverageEncryption
PostgreSQLBrowser (Railway EU West)Cloudflare AES-256TLS disk encryption1.3 (RailwayCloudflare TDE)Railway-managedAll data classificationsmanaged)
PostgreSQLCloudflare taxRailway IDs (PIB/JMBG/OIB/JIB)API AES-256-GCMTLS application-layer field encryptionEnvironment variable1.2+ (RailwayFull secrets) L4 Restrictedmode)
API → PostgreSQL — IBAN(Railway) AES-256-GCMssl=require application-layerin fieldDATABASE_URL encryption Environment variable (Railway secrets)L4 Restrictedstring
PostgreSQLAPI backups→ SEF portal (Serbia) AES-256TLS 1.2+ (RailwaySerbian automatic)government portal)
API → FINA/HR-FISK (Croatia) Railway-managedTLS 1.2+ (FINA PKI)
API → Sentry AllTLS data1.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 L4publicly Restrictedavailable data: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_KEY environment 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)

StorageField MethodTable Key ManagementJurisdiction Notes
Cloudflare R2jmbg (receipts, invoice PDFs)JMBG) AES-256 server-side (Cloudflare default)Contact Cloudflare-managedRS, BA EUSerbian/BiH regioncitizen bucketnumber required— 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.

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 — 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 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.

businessperson/business Disk
Field Table ReasonJurisdictionControls
taxId / registrationNumber (organization tax ID: PIB/JIB/OIB)PIB) organizationsContact, Organization NationalRS Disk identifierencryption + org-scoping + RBAC + TLS
taxId / registrationNumber (contact tax ID: PIB/JMBG/OIB/JIB) contactsContact, Organization NationalBA Disk identifierencryption + org-scoping + RBAC + TLS
iban organizations, contacts, bankAccountsBankAccount Bank account number
totpSecretAll users 2FAencryption seed+ org-scoping must+ notRBAC be+ readableTLS + API masking (last 4 digits in list views)

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:

    1. JWT_SECRET:Store 32+a character random string (CSPRNG), stored in Railway secrets
    2. JWT_REFRESH_SECRET: Separate 32+ character key — never same as JWT_SECRET
    3. Access token payload: { sub: userId, org: organizationId, role, iat, exp }
    4. 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 // RegistrationoibHash constcolumn (separate FIELD_HMAC_KEY)
  • Search is performed on the hash =column
  • await
  • Full bcrypt.hash(plainPassword,plaintext 12);is //decrypted Loginonly verificationwhen constdisplaying isValidto =an awaitauthorized bcrypt.compare(plainPassword,user
  • storedHash);

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


    6. KeyPassword Management SummaryHashing

    Full policy: key-management-policy.md

    upwardasbybcryptchars,1HaveIBeenPwned k-anonymityfirstof
    Key TypeParameter StorageRotationOwnerValue
    JWT_SECRETAlgorithm Railway environment secretQuarterlySecuritybcrypt
    JWT_REFRESH_SECRETCost factor Railway12 environment(adaptable secret Quarterly Securityhardware improves)
    FIELD_ENCRYPTION_KEY (tax IDs, IBAN)Salt RailwayAutomatically environmentgenerated secret Annual Securitylibrary (16 bytes)
    PostgreSQLMinimum diskpassword encryptionentropy Railway-managed8 (TDE) Railway-managed Railwayuppercase, 1 number, 1 special character
    CloudflareBreach R2 encryptioncheck Cloudflare-managed Cloudflare-managedCloudflare
    TLS certificatesAPI (Cloudflare) Cloudflare Certificateonly Manager 905 dayschars (automatic) CloudflareSHA1 hash sent)

    Secrets never committed to git.Never: .envStore filesplaintext inpasswords, .gitignore.use All secrets managed via Railway environment variables (production)MD5 or .env.localSHA1 (development,for git-ignored).passwords, use bcrypt cost factor below 10.


    7. CryptographicJWT InventorySigning

    RS256(RSA+SHA-256)2048-bitRSA,stored StoredinJWT_PUBLIC_KEY(for15 7
    SystemParameter AlgorithmKey SizeModeKey LocationNext RotationValue
    PostgreSQL disk (Railway)Algorithm AES-256 256-bit XTS Railway-managed Railway-managed— asymmetric
    TaxPrivate ID field encryptionkey AES-256-GCM 256-bit GCM in JWT_PRIVATE_KEY Railway env secretAnnual
    IBANPublic field encryptionkey AES-256-GCM 256-bit GCM Railway envsecret secret Annualverification)
    CloudflareAccess R2token lifetime AES-256 256-bitCloudflare-managedCloudflare-managed
    External TLS (Cloudflare)ECDSA P-256256-bitCloudflare90 days (auto)
    JWT signingHMAC-SHA-256256-bitRailway env secretQuarterlyminutes
    Refresh token signinglifetime HMAC-SHA-256 256-bitRailway env secretQuarterlydays
    PasswordKey hashingrotation bcryptAnnually 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 type cost=12NUMERIC(19,4) — PostgreSQL exact decimal
    Application type Decimal.js library  not JavaScript number
    Rounding N/ABanker's rounding (round half to even)
    Currency storage N/AISO 4217 code (RSD, BAM, EUR) in separate column

    8.9. FinancialKey DataManagement IntegritySummary

    FinancialKeys dataare requiresmanaged notper justthe confidentialityKey butManagement alsoPolicy integrity.(key-management-policy.md). BilkoEncryption enforces:

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

      • 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

    for: IBAN,TOTPsecrets,password hashes) — no exceptions.

    time.

    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 permittedpreferred) AES-256 required for L4 Restricted data
    AES-256-CBCPERMITTED with cautionGCM preferred (taxprovides IDs,authentication)
    AES-256-GCM Exception 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:REQUIRED Nonefor atL4 thisfields

    Authenticated encryption

    Approval

    Architect
    Role Name DateSignature SignatureDate
    Author ComplianceCTO 2026-02-23
    CTOReviewer (DPO)
    DPOReviewer (Engineering Lead)
    Engineering LeadApprover CEO