You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/store-server/test/encryption.test.ts

112 lines
4.8 KiB
TypeScript

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<T>(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
})
})