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

Use CaseLevel AlgorithmLabel Key SizeExamples ModeEncryption Required
L4-ARestricted (Personal)JMBG, OIBAES-256-GCM field-level encryption (prisma-field-encryption) + HMAC-SHA256 hash column + AES-256 at-rest + TLS 1.3 (See ADR-014)
L4-BRestricted (Business/Financial)PIB, JIB, IBANAES-256 disk-level encryption (Railway) + TLS 1.3 + org-scoping + RBAC + API masking for IBAN (last 4 digits in list views) (See ADR-014)
L3ConfidentialInvoice amounts, bank statements, transaction dataAES-256 at-rest + TLS 1.3
L2InternalEmail, name, phone, addressTLS 1.3 minimum
L1PublicOrganization display name, public invoice refNo 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

ConnectionEncryption
Browser → CloudflareTLS 1.3 (Cloudflare managed)
Cloudflare → Railway APITLS 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 → SentryTLS 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_KEY environment 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)

Serbian/BiHCroatianIDuniqueidentifier.Storedencrypted+
FieldTableJurisdiction Notes
Data at restjmbg (general)JMBG) AESContact 256-bitRS, BA GCM Authenticatedcitizen encryptionnumberdefaultirrevocable personal identifier, encodes DOB/gender/region. Stored encrypted + jmbgHash HMAC column.
Databaseoib disk encryption(OIB) AESContact 256-bitHR XTS Railwaypersonal/company PostgreSQLtax default
Filecross-system storage AES 256-bit GCM CloudflareoibHash R2HMAC server-side
Backup encryptionAES256-bitGCMRailway automatic backup encryptioncolumn.

Asymmetric

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

4digitsin
Use CaseField AlgorithmTable Key SizeJurisdiction NotesControls
TLStaxId key/ exchangeregistrationNumber (PIB) ECDHEContact, Organization P-256 / P-384RS CloudflareDisk encryption + Railwayorg-scoping + RBAC + TLS 1.3
JWTtaxId signing/ registrationNumber (JIB) HMAC-SHA-256Contact, (HS256)Organization 256-bitBA JWT_SECRETDisk (32+encryption chars,+ CSPRNG)org-scoping + RBAC + TLS
JWT refreshiban HMAC-SHA-256 (HS256)BankAccount 256-bitAll JWT_REFRESH_SECRETDisk encryption + org-scoping + RBAC + TLS + API masking (separatelast key)
Future:list JWT asymmetricEd25519 (EdDSA)256-bitPlanned Phase 2 migrationviews)

Hashing

Searchability

&

L4-A encrypted fields (JMBG, OIB) cannot be searched with SQL LIKE or equality. Exact-match lookup uses HMAC hash columns:

  1. Store a deterministic HMAC-SHA256 hash in jmbgHash / oibHash column (separate FIELD_HMAC_KEY)
  2. Search is performed on the hash column
  3. Full plaintext is decrypted only when displaying to an authorized user

6. Password Storage

Hashing
Use CaseParameter AlgorithmParametersNotesValue
Password hashingAlgorithm bcrypt
Cost factor12 (adaptable upward as hardware improves)
SaltAutomatically generated by bcrypt library (16 bytes)
Minimum password entropy8 chars, 1 uppercase, 1 number, 1 special character
Breach checkHaveIBeenPwned 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

ParameterValue
AlgorithmRS256 (RSA + SHA-256) — asymmetric
Private key2048-bit RSA, stored in JWT_PRIVATE_KEY Railway secret
Public keyStored in JWT_PUBLIC_KEY Railway secret (for verification)
Access token lifetime15 minutes
Refresh token lifetime7 days
Key rotationAnnually 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.

Banker'sroundingISO4217
ParameterStandard
Database type bcrypt.hash(password,NUMERIC(19,4) 12)— PostgreSQL exact decimal
Application typeDecimal.js library — not JavaScript number
Token hashing (refresh tokens)Rounding HMAC-SHA-256 Refresh(round tokenshalf hashedto before DB storageeven)
DataCurrency integrity (general)storage SHA-256 Filecode checksums,(RSD, non-securityBAM, hashingEUR) in separate column

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)

attacks (2004+) — completely size
AlgorithmStatus Reason
MD5 CollisionPROHIBITED Cryptographically broken
SHA-1 PROHIBITED for signing/hashing sensitive dataCollision attacks (2017+)demonstrated
DES / 3DES KeyPROHIBITED Insufficient insufficientkey length
RC4 Statistical biases
ECB mode (any cipher)PROHIBITED LeaksMultiple data patterns — deterministicvulnerabilities
RSA with key < 2048-bit2048 bitsPROHIBITED Insufficient keyfor strengthcurrent threat model
AES-128PERMITTED (not preferred)AES-256 required for L4 Restricted dataInsufficient for financial/tax ID data
bcrypt < 12 roundsAES-256-CBC InsufficientPERMITTED workwith factorcautionGCM preferred (provides authentication)
MD5 for password hashingNEVER — use bcrypt

3. Encryption at Rest

3.1 Database Encryption

DatabaseMethodKey ManagementCoverage
PostgreSQL (Railway EU West)AES-256 disk encryption (Railway TDE)Railway-managedAll data classifications
PostgreSQL — tax IDs (PIB/JMBG/OIB/JIB)AES-256-GCM application-layer field encryptionEnvironment variable (Railway secrets)L4 Restricted
PostgreSQL — IBANAES-256-GCM application-layer field encryptionEnvironment variable (Railway secrets)L4 Restricted
PostgreSQL backupsAES-256 (Railway automatic)Railway-managedAll data

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

StorageMethodKey ManagementNotes
Cloudflare R2 (receipts, invoice PDFs)AES-256 server-side (Cloudflare default)Cloudflare-managedEU region bucket required

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

Required for all L4 Restricted fields:

FieldTableReason
taxId (organization tax ID: PIB/JIB/OIB)organizationsNational business identifier
taxId (contact tax ID: PIB/JMBG/OIB/JIB)contactsNational person/business identifier
ibanorganizations, contacts, bankAccountsBank account number
totpSecretusers2FA seed — must not be readable

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

Key TypeStorageRotationOwner
JWT_SECRETRailway environment secretQuarterlySecurity
JWT_REFRESH_SECRETRailway environment secretQuarterlySecurity
FIELD_ENCRYPTION_KEY (tax IDs, IBAN)Railway environment secretAnnualSecurity
PostgreSQL disk encryptionRailway-managed (TDE)Railway-managedRailway
Cloudflare R2 encryptionCloudflare-managedCloudflare-managedCloudflare
TLS certificates (Cloudflare)Cloudflare Certificate Manager90 days (automatic)Cloudflare

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

SystemAlgorithmKey SizeModeKey LocationNext Rotation
PostgreSQL disk (Railway)AES-256256-bitXTSRailway-managedRailway-managed
Tax ID field encryption AES-256-GCM 256-bitREQUIRED for L4 fields GCMRailway env secretAnnual
IBAN fieldAuthenticated encryptionAES-256-GCM256-bitGCMRailway env secretAnnual
Cloudflare R2AES-256256-bitCloudflare-managedCloudflare-managed
External TLS (Cloudflare)ECDSA P-256256-bitCloudflare90 days (auto)
JWT signingHMAC-SHA-256256-bitRailway env secretQuarterly
Refresh token signingHMAC-SHA-256256-bitRailway env secretQuarterly
Password hashingbcryptcost=12N/AN/A

8. Financial Data Integrity

Financial data requires not just confidentiality but also integrity. Bilko enforces:

  1. NUMERIC(19,4) for all monetary amounts — never float or JavaScript number. Prevents rounding errors in VAT calculations.
  2. Immutable LoggedAction table — all mutations append-only with old/new values. Enables financial audit.
  3. Double-entry enforcement — debit = credit validated at backend. Prevents imbalanced entries.
  4. 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:

  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: None at this time.


Approval

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