import { afterEach, describe, expect, it } from 'vitest' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { openDb, type DB } from '../src/db' /** * H3 (red-team): the store DB is encrypted at rest with SQLCipher (better-sqlite3-multiple-ciphers) * when SIMS_DB_KEY is set. These tests write a customer PII row (a phone number) through the real * openDb() path, then prove against the ON-DISK bytes that: * - a keyed file's header is NOT the plaintext "SQLite format 3\0" magic, and the phone number * never appears in cleartext ANYWHERE in the file (main db + WAL); * - the keyed data round-trips (reopen WITH the key reads the phone back); * - opening WITHOUT the key, or with the WRONG key, fails ("file is not a database"); * - with SIMS_DB_KEY UNSET the file IS plaintext (the dev fallback) AND the phone IS present in * cleartext — a positive control proving the byte-scan above genuinely detects plaintext. * Tamper-evidence (H2) proves the file wasn't edited; H3 proves a stolen copy is unreadable. */ const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1') // 16 bytes const PHONE = '9998887776' // stand-in customer PII we hunt for in the raw file const GSTIN = '27ABCDE1234F1Z0' const tmpFiles: string[] = [] function tmpDbPath(): string { const p = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'sims-enc-')), 'sims.db') tmpFiles.push(p) return p } afterEach(() => { for (const p of tmpFiles.splice(0)) { for (const f of [p, `${p}-wal`, `${p}-shm`]) { try { fs.unlinkSync(f) } catch { /* ignore */ } } try { fs.rmdirSync(path.dirname(p)) } catch { /* ignore */ } } }) /** Run fn with SIMS_DB_KEY = key (or unset when key === null), restoring the prior env after. */ function withKey(key: string | null, fn: () => T): T { const prev = process.env['SIMS_DB_KEY'] if (key === null) delete process.env['SIMS_DB_KEY'] else process.env['SIMS_DB_KEY'] = key try { return fn() } finally { if (prev === undefined) delete process.env['SIMS_DB_KEY'] else process.env['SIMS_DB_KEY'] = prev } } /** Insert one party carrying PII, checkpoint so it lands on disk, close. */ function writePartyAndClose(db: DB): void { db.prepare( "INSERT INTO party (id, tenant_id, code, name, kind, phone, gstin, state_code) " + "VALUES ('p-enc', 't1', 'C-ENC', 'Encrypted Customer', 'customer', ?, ?, '27')", ).run(PHONE, GSTIN) db.pragma('wal_checkpoint(TRUNCATE)') // force the row out of the WAL into the main file we read db.close() } /** Concatenated raw bytes of the main db file and any WAL sidecar. */ function rawBytes(file: string): Buffer { const parts = [fs.readFileSync(file)] if (fs.existsSync(`${file}-wal`)) parts.push(fs.readFileSync(`${file}-wal`)) return Buffer.concat(parts) } describe('H3 — full-DB encryption at rest (SQLCipher)', () => { it('a keyed DB is ciphertext on disk: no SQLite magic header, no cleartext PII', () => { const file = tmpDbPath() withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) }) const head = fs.readFileSync(file).subarray(0, 16) expect(head.equals(SQLITE_MAGIC)).toBe(false) // not "SQLite format 3\0" const raw = rawBytes(file) expect(raw.includes(Buffer.from(PHONE, 'latin1'))).toBe(false) // phone never in cleartext expect(raw.includes(Buffer.from(GSTIN, 'latin1'))).toBe(false) // nor the GSTIN }) it('the keyed data round-trips — reopening WITH the key reads the PII back', () => { const file = tmpDbPath() withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) const db2 = openDb(file) const row = db2.prepare("SELECT phone, gstin FROM party WHERE id='p-enc'").get() as { phone: string; gstin: string } | undefined db2.close() expect(row?.phone).toBe(PHONE) expect(row?.gstin).toBe(GSTIN) }) }) it('opening a keyed file WITHOUT the key fails (a stolen copy is unreadable)', () => { const file = tmpDbPath() withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) }) expect(() => withKey(null, () => openDb(file))).toThrow(/not a database/i) }) it('opening a keyed file with the WRONG key fails', () => { const file = tmpDbPath() withKey('unit-test-secret-key', () => { writePartyAndClose(openDb(file)) }) expect(() => withKey('the-wrong-key', () => openDb(file))).toThrow(/not a database/i) }) it('dev fallback (key UNSET) writes plaintext — positive control for the byte-scan', () => { const file = tmpDbPath() withKey(null, () => { writePartyAndClose(openDb(file)) }) const head = fs.readFileSync(file).subarray(0, 16) expect(head.equals(SQLITE_MAGIC)).toBe(true) // unencrypted: real SQLite magic expect(rawBytes(file).includes(Buffer.from(PHONE, 'latin1'))).toBe(true) // scan really finds plaintext }) })