diff --git a/client/components/admin/BankSyncAdminCard.jsx b/client/components/admin/BankSyncAdminCard.jsx
index 78186cf..283e070 100644
--- a/client/components/admin/BankSyncAdminCard.jsx
+++ b/client/components/admin/BankSyncAdminCard.jsx
@@ -241,7 +241,10 @@ export default function BankSyncAdminCard() {
{/* Encryption note */}
- SimpleFIN credentials are encrypted with a key stored in your database.
+ {config?.encryption_key_source === 'env'
+ ? <>SimpleFIN credentials are encrypted at rest. Encryption key is loaded from TOKEN_ENCRYPTION_KEY — stored separately from the database, so a database backup alone cannot decrypt credentials.>
+ : <>SimpleFIN credentials are encrypted, but the key is stored in the same database as the data. A database backup or file-level read includes everything needed to decrypt credentials. Set TOKEN_ENCRYPTION_KEY in your environment to keep the key separate.>
+ }{' '}
Regular database backups preserve all user connections.
diff --git a/routes/admin.js b/routes/admin.js
index 5528955..165b467 100644
--- a/routes/admin.js
+++ b/routes/admin.js
@@ -3,6 +3,7 @@ const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
+const { isEnvKeyActive } = require('../services/encryptionService');
const { hashPassword } = require('../services/authService');
const { logAudit } = require('../services/auditService');
const {
@@ -439,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() });
+ res.json({ ...getBankSyncConfig(), worker: getBankSyncWorkerStatus(), encryption_key_source: isEnvKeyActive() ? 'env' : 'db' });
});
// PUT /api/admin/bank-sync-config
diff --git a/server.js b/server.js
index f112bb5..658002e 100644
--- a/server.js
+++ b/server.js
@@ -160,7 +160,15 @@ process.on('unhandledRejection', (reason) => {
// ── Bootstrap ─────────────────────────────────────────────────────────────────
async function main() {
const db = getDb();
-
+
+ // Migrate DB-key-encrypted secrets to env key when TOKEN_ENCRYPTION_KEY is set
+ const { reEncryptWithEnvKey } = require('./services/encryptionService');
+ try {
+ reEncryptWithEnvKey(db);
+ } catch (err) {
+ console.error('[encryption] Startup key migration failed:', err.message);
+ }
+
// Run session cleanup on startup
const { cleanupExpiredSessions } = require('./db/database');
try {
diff --git a/services/encryptionService.js b/services/encryptionService.js
index a8b75ab..770ef3b 100644
--- a/services/encryptionService.js
+++ b/services/encryptionService.js
@@ -5,17 +5,24 @@ const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const TAG_BYTES = 16;
-// Domain-separation label for HKDF. Changing this invalidates all stored v2 secrets.
+// Domain-separation label for HKDF. Changing this invalidates all stored secrets.
const HKDF_INFO = 'bill-tracker-token-encryption-v1';
-// Prefix that identifies ciphertext produced with HKDF key derivation.
+
+// Ciphertext prefixes identify which key was used to encrypt.
+// e2: = env key (TOKEN_ENCRYPTION_KEY)
+// v2: = db key (HKDF derivation)
+// no prefix = legacy db key (SHA-256 — pre-v0.78 only)
+const ENV_PREFIX = 'e2:';
const V2_PREFIX = 'v2:';
-// Returns the raw key material (IKM) without derivation.
-// The encryption key is auto-generated on first run and stored in the database
-// under `_auto_encryption_key`. No environment variable required — all settings
-// live in the app. The key is created once and reused across restarts.
-function getIkm() {
- // Lazy-require to avoid circular dependency at module load time
+// Returns the env-provided IKM, or null if the var is not set.
+function getEnvIkm() {
+ const k = process.env.TOKEN_ENCRYPTION_KEY?.trim();
+ return k ? Buffer.from(k, 'utf8') : null;
+}
+
+// Returns the auto-generated DB IKM, creating it on first call.
+function getDbIkm() {
const { getSetting, setSetting } = require('../db/database');
let stored = getSetting('_auto_encryption_key');
if (!stored) {
@@ -25,46 +32,130 @@ function getIkm() {
return Buffer.from(stored, 'utf8');
}
-// Current derivation: HKDF-SHA-256 (RFC 5869). Used for all new encryptions.
function deriveKey(ikm) {
return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32));
}
-// Legacy derivation: raw SHA-256. Used only to decrypt pre-v0.78 ciphertext.
+// Legacy derivation — used only to decrypt pre-v0.78 ciphertext (no prefix).
function deriveLegacyKey(ikm) {
return crypto.createHash('sha256').update(ikm).digest();
}
-function getKey() { return deriveKey(getIkm()); }
-function getLegacyKey() { return deriveLegacyKey(getIkm()); }
-
-// No-op now that the key is always available — kept for call-site compatibility
-function assertEncryptionReady() {}
-
-// Always produces v2-format ciphertext (HKDF key derivation).
-function encryptSecret(plaintext) {
- const key = getKey();
- const iv = crypto.randomBytes(IV_BYTES);
- const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
- const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
- const tag = cipher.getAuthTag();
- return `${V2_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
+// True when TOKEN_ENCRYPTION_KEY is present in the environment.
+function isEnvKeyActive() {
+ return !!process.env.TOKEN_ENCRYPTION_KEY?.trim();
}
-// Decrypts both v2 (HKDF) and legacy (SHA-256) ciphertext transparently.
+// No-op kept for call-site compatibility.
+function assertEncryptionReady() {}
+
+// Encrypts with the env key (e2: prefix) when TOKEN_ENCRYPTION_KEY is set,
+// otherwise with the DB key (v2: prefix).
+function encryptSecret(plaintext) {
+ const envIkm = getEnvIkm();
+ const ikm = envIkm ?? getDbIkm();
+ const prefix = envIkm ? ENV_PREFIX : V2_PREFIX;
+ const key = deriveKey(ikm);
+ const iv = crypto.randomBytes(IV_BYTES);
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
+ const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
+ const tag = cipher.getAuthTag();
+ return `${prefix}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
+}
+
+// Decrypts both e2: (env key), v2: (db key HKDF), and legacy (db key SHA-256) ciphertext.
function decryptSecret(stored) {
- const isV2 = stored.startsWith(V2_PREFIX);
- const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored;
- const parts = payload.split(':');
+ const isEnv = stored.startsWith(ENV_PREFIX);
+ const isV2 = !isEnv && stored.startsWith(V2_PREFIX);
+ const payload = stored.slice(isEnv ? ENV_PREFIX.length : isV2 ? V2_PREFIX.length : 0);
+
+ const parts = payload.split(':');
if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts;
- const key = isV2 ? getKey() : getLegacyKey();
- const iv = Buffer.from(ivHex, 'hex');
- const tag = Buffer.from(tagHex, 'hex');
- const ct = Buffer.from(ctHex, 'hex');
+
+ let key;
+ if (isEnv) {
+ const envIkm = getEnvIkm();
+ if (!envIkm) throw new Error('TOKEN_ENCRYPTION_KEY is required to decrypt this secret but is not set');
+ key = deriveKey(envIkm);
+ } else if (isV2) {
+ key = deriveKey(getDbIkm());
+ } else {
+ key = deriveLegacyKey(getDbIkm());
+ }
+
+ const iv = Buffer.from(ivHex, 'hex');
+ const tag = Buffer.from(tagHex, 'hex');
+ const ct = Buffer.from(ctHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
}
-module.exports = { assertEncryptionReady, encryptSecret, decryptSecret };
+// Re-encrypts all DB-key-encrypted secrets with the env key.
+// Called at startup when TOKEN_ENCRYPTION_KEY is present — idempotent.
+// Already-migrated (e2:) values are skipped.
+function reEncryptWithEnvKey(db) {
+ if (!isEnvKeyActive()) return;
+
+ // All table+column pairs that store encrypted values.
+ // Table names are hardcoded — never user-supplied.
+ const TABLE_COLS = [
+ { table: 'data_sources', col: 'encrypted_secret' },
+ { table: 'users', col: 'totp_secret' },
+ { table: 'users', col: 'totp_recovery_codes' },
+ { table: 'users', col: 'push_url' },
+ { table: 'users', col: 'push_token' },
+ { table: 'user_login_history', col: 'ip_address' },
+ { table: 'user_login_history', col: 'user_agent' },
+ { table: 'user_login_history', col: 'location_city' },
+ { table: 'user_login_history', col: 'location_country' },
+ { table: 'user_login_history', col: 'location_region' },
+ { table: 'user_login_history', col: 'location_isp' },
+ ];
+ const SETTINGS_KEYS = ['notify_smtp_password', 'oidc_client_secret'];
+
+ function reEnc(val) {
+ if (!val || val.startsWith(ENV_PREFIX)) return val; // null or already migrated
+ try {
+ return encryptSecret(decryptSecret(val)); // decrypt with old key, encrypt with new
+ } catch {
+ return val; // corrupted or unknown format — leave as-is
+ }
+ }
+
+ let count = 0;
+
+ db.transaction(() => {
+ for (const { table, col } of TABLE_COLS) {
+ // Guard against schema differences across upgrade paths
+ const existing = db.prepare(`PRAGMA table_info(${table})`).all().map(r => r.name);
+ if (!existing.includes(col)) continue;
+
+ const rows = db.prepare(`SELECT id, ${col} FROM ${table} WHERE ${col} IS NOT NULL`).all();
+ for (const row of rows) {
+ const migrated = reEnc(row[col]);
+ if (migrated !== row[col]) {
+ db.prepare(`UPDATE ${table} SET ${col} = ? WHERE id = ?`).run(migrated, row.id);
+ count++;
+ }
+ }
+ }
+
+ for (const key of SETTINGS_KEYS) {
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key);
+ if (!row?.value) continue;
+ const migrated = reEnc(row.value);
+ if (migrated !== row.value) {
+ db.prepare("INSERT OR REPLACE INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))").run(key, migrated);
+ count++;
+ }
+ }
+ })();
+
+ if (count > 0) {
+ console.log(`[encryption] Migrated ${count} secret(s) from db-key to env-key (TOKEN_ENCRYPTION_KEY)`);
+ }
+}
+
+module.exports = { assertEncryptionReady, encryptSecret, decryptSecret, isEnvKeyActive, reEncryptWithEnvKey };