From a73d0afe07c61153f8cd45f0b879f1dd37bfce3c Mon Sep 17 00:00:00 2001 From: null Date: Sat, 6 Jun 2026 15:27:45 -0500 Subject: [PATCH] feat(encryption): support TOKEN_ENCRYPTION_KEY env var with startup migration --- client/components/admin/BankSyncAdminCard.jsx | 5 +- routes/admin.js | 3 +- server.js | 10 +- services/encryptionService.js | 157 ++++++++++++++---- 4 files changed, 139 insertions(+), 36 deletions(-) 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 };