BillTracker/services/encryptionService.js

162 lines
6.0 KiB
JavaScript
Raw Permalink Normal View History

'use strict';
const crypto = require('crypto');
2026-05-31 15:52:50 -05:00
const ALGORITHM = 'aes-256-gcm';
const IV_BYTES = 12;
const TAG_BYTES = 16;
// Domain-separation label for HKDF. Changing this invalidates all stored secrets.
2026-05-31 15:52:50 -05:00
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:';
2026-05-31 15:52:50 -05:00
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);
}
2026-05-31 15:52:50 -05:00
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).
2026-05-31 15:52:50 -05:00
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();
}
2026-05-31 15:52:50 -05:00
// 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 };