feat(encryption): support TOKEN_ENCRYPTION_KEY env var with startup migration
This commit is contained in:
parent
9e38a6b252
commit
a73d0afe07
|
|
@ -241,7 +241,10 @@ export default function BankSyncAdminCard() {
|
|||
|
||||
{/* Encryption note */}
|
||||
<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.
|
||||
</p>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -161,6 +161,14 @@ process.on('unhandledRejection', (reason) => {
|
|||
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 {
|
||||
|
|
|
|||
|
|
@ -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,40 +32,58 @@ 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()); }
|
||||
// True when TOKEN_ENCRYPTION_KEY is present in the environment.
|
||||
function isEnvKeyActive() {
|
||||
return !!process.env.TOKEN_ENCRYPTION_KEY?.trim();
|
||||
}
|
||||
|
||||
// No-op now that the key is always available — kept for call-site compatibility
|
||||
// No-op kept for call-site compatibility.
|
||||
function assertEncryptionReady() {}
|
||||
|
||||
// Always produces v2-format ciphertext (HKDF key derivation).
|
||||
// Encrypts with the env key (e2: prefix) when TOKEN_ENCRYPTION_KEY is set,
|
||||
// otherwise with the DB key (v2: prefix).
|
||||
function encryptSecret(plaintext) {
|
||||
const key = getKey();
|
||||
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 `${V2_PREFIX}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
||||
return `${prefix}${iv.toString('hex')}:${tag.toString('hex')}:${ct.toString('hex')}`;
|
||||
}
|
||||
|
||||
// Decrypts both v2 (HKDF) and legacy (SHA-256) ciphertext transparently.
|
||||
// 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 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();
|
||||
|
||||
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');
|
||||
|
|
@ -67,4 +92,70 @@ function decryptSecret(stored) {
|
|||
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 };
|
||||
|
|
|
|||
Loading…
Reference in New Issue