2026-07-05 12:55:51 -05:00
|
|
|
'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');
|
refactor(server): migrate 10 services to TypeScript (.cts)
Continues the incremental server TS migration (after utils/*.mts and
paymentValidation.cts): converts 10 services to .cts (CommonJS TypeScript,
run natively via Node type-stripping — no build step), type-checked by
tsconfig.server.json.
Migrated: totpService, amountSuggestionService, encryptionService,
paymentAccountingService, statusService, aprService, driftService,
analyticsService, spendingService, billMerchantRuleService.
- types/db.d.ts: shared minimal better-sqlite3 Db/Statement types (the lib
ships none), reused across the migrated modules.
- Every require of a migrated service updated to the explicit .cts extension
(extensionless require does not resolve .cts) across routes / services /
db migrations / server.js / workers / tests — require-only changes.
- package.json: check:server find pattern now includes *.cts (it silently
skipped .cts before, so paymentValidation.cts had no node --check gate).
Behavior-preserving (types only). Verified: typecheck:server 0,
check:server 0, full server suite 226/226, real boot → /api/health 200.
Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-07-06 10:35:29 -05:00
|
|
|
const enc = require('../services/encryptionService.cts');
|
2026-07-05 12:55:51 -05:00
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
});
|
2026-07-05 19:15:32 -05:00
|
|
|
|
|
|
|
|
test('keyFingerprint: stable per key, differs by key, and never reveals the key', () => {
|
|
|
|
|
const rawEnv = 'env-fingerprint-key-material-1234567890';
|
|
|
|
|
|
|
|
|
|
// DB-key mode fingerprint (env unset)
|
|
|
|
|
delete process.env.TOKEN_ENCRYPTION_KEY;
|
|
|
|
|
const dbFp1 = enc.keyFingerprint();
|
|
|
|
|
const dbFp2 = enc.keyFingerprint();
|
|
|
|
|
assert.equal(dbFp1, dbFp2, 'db-key fingerprint is stable across calls');
|
|
|
|
|
assert.match(dbFp1, /^[0-9a-f]{16}$/, 'fingerprint is a 16-hex prefix');
|
|
|
|
|
|
|
|
|
|
// Env-key mode fingerprint
|
|
|
|
|
process.env.TOKEN_ENCRYPTION_KEY = rawEnv;
|
|
|
|
|
const envFp = enc.keyFingerprint();
|
|
|
|
|
assert.match(envFp, /^[0-9a-f]{16}$/);
|
|
|
|
|
assert.notEqual(envFp, dbFp1, 'env-key fingerprint differs from db-key fingerprint');
|
|
|
|
|
|
|
|
|
|
// A different env key yields a different fingerprint (change detection)
|
|
|
|
|
process.env.TOKEN_ENCRYPTION_KEY = rawEnv + '-changed';
|
|
|
|
|
assert.notEqual(enc.keyFingerprint(), envFp, 'changing the key changes the fingerprint');
|
|
|
|
|
|
|
|
|
|
// The fingerprint must not be reversible/contain the key
|
|
|
|
|
process.env.TOKEN_ENCRYPTION_KEY = rawEnv;
|
|
|
|
|
const fp = enc.keyFingerprint();
|
|
|
|
|
assert.ok(!rawEnv.includes(fp) && !fp.includes(rawEnv), 'fingerprint does not embed the key');
|
|
|
|
|
// It is NOT a plain sha256 of the key (domain-separated), so it can't be recomputed without the label
|
|
|
|
|
assert.notEqual(fp, crypto.createHash('sha256').update(Buffer.from(rawEnv, 'utf8')).digest('hex').slice(0, 16));
|
|
|
|
|
});
|