From b95f771d18ca70cf371de0b1f12cd88e6e49feb9 Mon Sep 17 00:00:00 2001 From: null Date: Sun, 5 Jul 2026 12:55:51 -0500 Subject: [PATCH] =?UTF-8?q?test(security):=20lock=20in=20encryptionService?= =?UTF-8?q?=20=E2=80=94=20the=20secret-at-rest=20crown=20jewel=20(Phase=20?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero coverage on the AES-256-GCM + HKDF crypto that protects SimpleFIN bank tokens, TOTP/recovery secrets, SMTP/OIDC creds and login-history PII. A silent break in decryptSecret or the startup reEncryptWithEnvKey migration would render every stored secret unrecoverable, invisibly. Covers: round-trip on both key paths (env e2: / db v2:), unicode/empty/ large payloads, GCM tamper rejection, legacy (pre-v0.78 SHA-256) decrypt, the env-key-required error, and the migration (migrate/skip/idempotent/ corrupt-tolerant). Suite 200 green. Co-Authored-By: Claude Opus 4.8 --- tests/encryptionService.test.js | 99 +++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 tests/encryptionService.test.js diff --git a/tests/encryptionService.test.js b/tests/encryptionService.test.js new file mode 100644 index 0000000..326c974 --- /dev/null +++ b/tests/encryptionService.test.js @@ -0,0 +1,99 @@ +'use strict'; + +// Phase 1 — encryptionService is the crown jewel: it encrypts SimpleFIN bank +// tokens, TOTP secrets, recovery codes, SMTP/OIDC credentials, and login-history +// PII. A silent regression in decryptSecret or the startup reEncryptWithEnvKey +// migration would render EVERY stored secret unrecoverable, invisibly. These +// tests lock in: round-trip on both key paths, GCM tamper rejection, legacy +// (pre-v0.78) decrypt, the env-key-required error, and the migration behaviour +// (migrate / skip / idempotent / corrupt-tolerant). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const os = require('node:os'); +const path = require('node:path'); +const fs = require('node:fs'); +const crypto = require('node:crypto'); + +const dbPath = path.join(os.tmpdir(), `bill-tracker-encsvc-${process.pid}.sqlite`); +process.env.DB_PATH = dbPath; +delete process.env.TOKEN_ENCRYPTION_KEY; // start in DB-key mode + +const { getDb, closeDb, getSetting, setSetting } = require('../db/database'); +const enc = require('../services/encryptionService'); + +test.before(() => { getDb(); }); // init schema + migrations (also creates the initial admin) +test.after(() => { + closeDb(); + for (const s of ['', '-wal', '-shm']) { try { fs.rmSync(dbPath + s); } catch {} } +}); +// Every test starts from DB-key mode unless it opts in to the env key. +test.afterEach(() => { delete process.env.TOKEN_ENCRYPTION_KEY; }); + +test('DB-key path: round-trips and tags ciphertext with the v2: prefix', () => { + const ct = enc.encryptSecret('simplefin-token-abc'); + assert.ok(ct.startsWith('v2:'), 'db-key ciphertext carries the v2: prefix'); + assert.equal(enc.decryptSecret(ct), 'simplefin-token-abc'); +}); + +test('env-key path: round-trips and tags ciphertext with the e2: prefix', () => { + process.env.TOKEN_ENCRYPTION_KEY = 'a-strong-env-ikm-0123456789abcdef'; + const ct = enc.encryptSecret('smtp-password'); + assert.ok(ct.startsWith('e2:'), 'env-key ciphertext carries the e2: prefix'); + assert.equal(enc.decryptSecret(ct), 'smtp-password'); +}); + +test('unicode, empty, and large payloads all round-trip (db key)', () => { + for (const s of ['', 'café ☕ 日本語 — עברית', 'x'.repeat(8192)]) { + assert.equal(enc.decryptSecret(enc.encryptSecret(s)), s); + } +}); + +test('GCM tamper rejection: flipping a ciphertext byte makes decrypt throw', () => { + const ct = enc.encryptSecret('tamper-me'); // v2:IV:TAG:CT + const [prefix, iv, tag, ctHex] = ct.split(':'); + const flipped = (ctHex[0] === 'a' ? 'b' : 'a') + ctHex.slice(1); + assert.throws(() => enc.decryptSecret(`${prefix}:${iv}:${tag}:${flipped}`), + 'authenticated encryption must reject a modified ciphertext'); +}); + +test('legacy (no-prefix, SHA-256 key) ciphertext still decrypts — upgrade safety', () => { + // Reproduce the pre-v0.78 scheme exactly: key = sha256(dbIkm), aes-256-gcm, NO prefix. + enc.encryptSecret('seed'); // ensure _auto_encryption_key exists + const dbIkm = Buffer.from(getSetting('_auto_encryption_key'), 'utf8'); + const key = crypto.createHash('sha256').update(dbIkm).digest(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv, { authTagLength: 16 }); + const body = Buffer.concat([cipher.update('legacy-secret', 'utf8'), cipher.final()]); + const legacy = `${iv.toString('hex')}:${cipher.getAuthTag().toString('hex')}:${body.toString('hex')}`; + assert.equal(enc.decryptSecret(legacy), 'legacy-secret'); +}); + +test('an e2: secret cannot be decrypted without the env key — clear, explicit error', () => { + process.env.TOKEN_ENCRYPTION_KEY = 'env-ikm-for-required-test'; + const ct = enc.encryptSecret('bank-token'); + assert.ok(ct.startsWith('e2:')); + delete process.env.TOKEN_ENCRYPTION_KEY; + assert.throws(() => enc.decryptSecret(ct), /TOKEN_ENCRYPTION_KEY is required/); +}); + +test('reEncryptWithEnvKey migrates db-key secrets to env-key, decrypts, and is idempotent + corrupt-tolerant', () => { + const db = getDb(); + delete process.env.TOKEN_ENCRYPTION_KEY; + + const original = 'smtp-pw-secret'; + setSetting('notify_smtp_password', enc.encryptSecret(original)); // v2: (db key) + setSetting('oidc_client_secret', 'not-a-valid-ciphertext'); // corrupt / unknown format + assert.ok(getSetting('notify_smtp_password').startsWith('v2:')); + + process.env.TOKEN_ENCRYPTION_KEY = 'env-ikm-for-migration'; + enc.reEncryptWithEnvKey(db); + + const migrated = getSetting('notify_smtp_password'); + assert.ok(migrated.startsWith('e2:'), 'db-key secret migrated to env-key'); + assert.equal(enc.decryptSecret(migrated), original, 'migrated secret still decrypts to the original'); + assert.equal(getSetting('oidc_client_secret'), 'not-a-valid-ciphertext', 'corrupt value left untouched, not thrown'); + + // Idempotent: a second run must not touch the already-migrated value. + enc.reEncryptWithEnvKey(db); + assert.equal(getSetting('notify_smtp_password'), migrated, 'second run is a no-op for e2: values'); +});