feat(security): show a key fingerprint (not the key) in the encryption status

The encryption key must never be retrievable through the app (that would undo
the whole point of TOKEN_ENCRYPTION_KEY — a stolen session/XSS could then read
the master key). Instead surface a non-reversible fingerprint so operators can:
- verify which key is currently active,
- confirm a running instance matches the key they backed up, and
- spot an accidental key change (which would make existing secrets unrecoverable).

- encryptionService.keyFingerprint(): domain-separated SHA-256 prefix of the
  active key (env if set, else the stored DB key); never force-creates a key
  (returns null when none exists), shares nothing with the cipher derivation.
- admin bank-sync-config exposes key_fingerprint alongside encryption_key_source.
- Admin Bank Sync card renders it with backup guidance.
- Test: stable per key, differs by key, not reversible / not the raw key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
null 2026-07-05 19:15:32 -05:00
parent f3f46a73f0
commit 1457de487f
4 changed files with 64 additions and 3 deletions

View File

@ -23,6 +23,7 @@ interface BankSyncConfig {
seed_days?: number;
worker?: BankSyncWorker;
encryption_key_source?: string;
key_fingerprint?: string | null;
}
function timeAgo(iso: string | null | undefined): string | null {
@ -293,6 +294,17 @@ export default function BankSyncAdminCard() {
Regular database backups preserve all user connections.
</p>
{config?.key_fingerprint && (
<p className="text-xs text-muted-foreground">
Active key fingerprint{' '}
<code className="font-mono text-foreground">{config.key_fingerprint}</code>
{' '} a one-way check of the current key (never the key itself). Keep your{' '}
<code className="font-mono">TOKEN_ENCRYPTION_KEY</code> in a password manager; this fingerprint lets you
confirm a running instance matches the key you saved, and spot an accidental key change (which would make
existing secrets unrecoverable).
</p>
)}
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving || !changed}>
{saving ? 'Saving…' : 'Save'}

View File

@ -3,7 +3,7 @@ const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays, setDebugLogging } = require('../services/bankSyncConfigService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { isEnvKeyActive } = require('../services/encryptionService');
const { isEnvKeyActive, keyFingerprint } = require('../services/encryptionService');
const { hashPassword } = require('../services/authService');
const { logAudit } = require('../services/auditService');
const {
@ -440,7 +440,7 @@ router.put('/auth-mode', (req, res) => {
// GET /api/admin/bank-sync-config
router.get('/bank-sync-config', (req, res) => {
res.json({ ...getBankSyncConfig(), worker: getBankSyncWorkerStatus(), encryption_key_source: isEnvKeyActive() ? 'env' : 'db' });
res.json({ ...getBankSyncConfig(), worker: getBankSyncWorkerStatus(), encryption_key_source: isEnvKeyActive() ? 'env' : 'db', key_fingerprint: keyFingerprint() });
});
// PUT /api/admin/bank-sync-config

View File

@ -46,6 +46,27 @@ function isEnvKeyActive() {
return !!process.env.TOKEN_ENCRYPTION_KEY?.trim();
}
// A non-reversible fingerprint of the ACTIVE key (env if set, else the stored DB
// key). Lets an operator verify which key is live and that a saved backup
// matches — without ever exposing the key. Domain-separated so it shares nothing
// with the cipher key derivation, and it never force-creates a key (returns null
// when none exists yet). A 16-hex prefix of SHA-256 over a 48-byte random key is
// not reversible or guessable.
function keyFingerprint() {
let ikm = getEnvIkm();
if (!ikm) {
const { getSetting } = require('../db/database');
const stored = getSetting('_auto_encryption_key');
if (!stored) return null;
ikm = Buffer.from(stored, 'utf8');
}
return crypto.createHash('sha256')
.update('bill-tracker-key-fingerprint-v1')
.update(ikm)
.digest('hex')
.slice(0, 16);
}
// No-op kept for call-site compatibility.
function assertEncryptionReady() {}
@ -158,4 +179,4 @@ function reEncryptWithEnvKey(db) {
}
}
module.exports = { assertEncryptionReady, encryptSecret, decryptSecret, isEnvKeyActive, reEncryptWithEnvKey };
module.exports = { assertEncryptionReady, encryptSecret, decryptSecret, isEnvKeyActive, keyFingerprint, reEncryptWithEnvKey };

View File

@ -97,3 +97,31 @@ test('reEncryptWithEnvKey migrates db-key secrets to env-key, decrypts, and is i
enc.reEncryptWithEnvKey(db);
assert.equal(getSetting('notify_smtp_password'), migrated, 'second run is a no-op for e2: values');
});
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));
});