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 */}
|
{/* 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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue