feat(encryption): support TOKEN_ENCRYPTION_KEY env var with startup migration

This commit is contained in:
null 2026-06-06 15:27:45 -05:00
parent 9e38a6b252
commit a73d0afe07
4 changed files with 139 additions and 36 deletions

View File

@ -241,7 +241,10 @@ export default function BankSyncAdminCard() {
{/* Encryption note */} {/* Encryption note */}
<p className="text-xs text-muted-foreground border-t border-border/50 pt-3"> <p className="text-xs text-muted-foreground border-t border-border/50 pt-3">
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 <code className="font-mono">TOKEN_ENCRYPTION_KEY</code> 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 <code className="font-mono">TOKEN_ENCRYPTION_KEY</code> in your environment to keep the key separate.</>
}{' '}
Regular database backups preserve all user connections. Regular database backups preserve all user connections.
</p> </p>

View File

@ -3,6 +3,7 @@ const router = express.Router();
const { getDb, rollbackMigration } = require('../db/database'); const { getDb, rollbackMigration } = require('../db/database');
const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService'); const { getBankSyncConfig, setBankSyncEnabled, setSyncIntervalHours, setSyncDays } = require('../services/bankSyncConfigService');
const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker'); const { getStatus: getBankSyncWorkerStatus } = require('../services/bankSyncWorker');
const { isEnvKeyActive } = require('../services/encryptionService');
const { hashPassword } = require('../services/authService'); const { hashPassword } = require('../services/authService');
const { logAudit } = require('../services/auditService'); const { logAudit } = require('../services/auditService');
const { const {
@ -439,7 +440,7 @@ router.put('/auth-mode', (req, res) => {
// GET /api/admin/bank-sync-config // GET /api/admin/bank-sync-config
router.get('/bank-sync-config', (req, res) => { 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 // PUT /api/admin/bank-sync-config

View File

@ -161,6 +161,14 @@ process.on('unhandledRejection', (reason) => {
async function main() { async function main() {
const db = getDb(); 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 // Run session cleanup on startup
const { cleanupExpiredSessions } = require('./db/database'); const { cleanupExpiredSessions } = require('./db/database');
try { try {

View File

@ -5,17 +5,24 @@ const crypto = require('crypto');
const ALGORITHM = 'aes-256-gcm'; const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12; const IV_BYTES = 12;
const TAG_BYTES = 16; 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'; 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:'; const V2_PREFIX = 'v2:';
// Returns the raw key material (IKM) without derivation. // Returns the env-provided IKM, or null if the var is not set.
// The encryption key is auto-generated on first run and stored in the database function getEnvIkm() {
// under `_auto_encryption_key`. No environment variable required — all settings const k = process.env.TOKEN_ENCRYPTION_KEY?.trim();
// live in the app. The key is created once and reused across restarts. return k ? Buffer.from(k, 'utf8') : null;
function getIkm() { }
// Lazy-require to avoid circular dependency at module load time
// Returns the auto-generated DB IKM, creating it on first call.
function getDbIkm() {
const { getSetting, setSetting } = require('../db/database'); const { getSetting, setSetting } = require('../db/database');
let stored = getSetting('_auto_encryption_key'); let stored = getSetting('_auto_encryption_key');
if (!stored) { if (!stored) {
@ -25,46 +32,130 @@ function getIkm() {
return Buffer.from(stored, 'utf8'); return Buffer.from(stored, 'utf8');
} }
// Current derivation: HKDF-SHA-256 (RFC 5869). Used for all new encryptions.
function deriveKey(ikm) { function deriveKey(ikm) {
return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32)); 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) { function deriveLegacyKey(ikm) {
return crypto.createHash('sha256').update(ikm).digest(); return crypto.createHash('sha256').update(ikm).digest();
} }
function getKey() { return deriveKey(getIkm()); } // True when TOKEN_ENCRYPTION_KEY is present in the environment.
function getLegacyKey() { return deriveLegacyKey(getIkm()); } function isEnvKeyActive() {
return !!process.env.TOKEN_ENCRYPTION_KEY?.trim();
// 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')}`;
} }
// 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) { function decryptSecret(stored) {
const isV2 = stored.startsWith(V2_PREFIX); const isEnv = stored.startsWith(ENV_PREFIX);
const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored; const isV2 = !isEnv && stored.startsWith(V2_PREFIX);
const parts = payload.split(':'); 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'); if (parts.length !== 3) throw new Error('Invalid encrypted secret format');
const [ivHex, tagHex, ctHex] = parts; const [ivHex, tagHex, ctHex] = parts;
const key = isV2 ? getKey() : getLegacyKey();
const iv = Buffer.from(ivHex, 'hex'); let key;
const tag = Buffer.from(tagHex, 'hex'); if (isEnv) {
const ct = Buffer.from(ctHex, 'hex'); 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 }); const decipher = crypto.createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_BYTES });
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8'); 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 };