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:
parent
f3f46a73f0
commit
1457de487f
|
|
@ -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'}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue