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.
112 lines
4.8 KiB
TypeScript
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
|
|
})
|
|
})
|