'use strict'; 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 secrets. const HKDF_INFO = 'bill-tracker-token-encryption-v1'; // 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 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) { stored = crypto.randomBytes(48).toString('hex'); setSetting('_auto_encryption_key', stored); } return Buffer.from(stored, 'utf8'); } function deriveKey(ikm) { return Buffer.from(crypto.hkdfSync('sha256', ikm, /* salt */ '', HKDF_INFO, 32)); } // Legacy derivation — used only to decrypt pre-v0.78 ciphertext (no prefix). function deriveLegacyKey(ikm) { return crypto.createHash('sha256').update(ikm).digest(); } // True when TOKEN_ENCRYPTION_KEY is present in the environment. function isEnvKeyActive() { return !!process.env.TOKEN_ENCRYPTION_KEY?.trim(); } // 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 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; 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'); } // 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 };