diff --git a/client/components/admin/BankSyncAdminCard.tsx b/client/components/admin/BankSyncAdminCard.tsx
index 65dbee2..53f3182 100644
--- a/client/components/admin/BankSyncAdminCard.tsx
+++ b/client/components/admin/BankSyncAdminCard.tsx
@@ -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.
+ {config?.key_fingerprint && (
+
+ Active key fingerprint{' '}
+ {config.key_fingerprint}
+ {' '}— a one-way check of the current key (never the key itself). Keep your{' '}
+ TOKEN_ENCRYPTION_KEY 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).
+
+ )}
+
{saving ? 'Saving…' : 'Save'}
diff --git a/routes/admin.js b/routes/admin.js
index 70e3a1f..6160841 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -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
diff --git a/services/encryptionService.js b/services/encryptionService.js
index 770ef3b..fe4250b 100644
--- a/services/encryptionService.js
+++ b/services/encryptionService.js
@@ -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 };
diff --git a/tests/encryptionService.test.js b/tests/encryptionService.test.js
index 326c974..2991bad 100644
--- a/tests/encryptionService.test.js
+++ b/tests/encryptionService.test.js
@@ -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));
+});