BillTracker/services/encryptionService.cts

201 lines
6.9 KiB
TypeScript

'use strict';
import type { Db } from '../types/db';
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(): Buffer | null {
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(): Buffer {
const { getSetting, setSetting } = require('../db/database.cts');
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: Buffer): Buffer {
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: Buffer): Buffer {
return crypto.createHash('sha256').update(ikm).digest();
}
// True when TOKEN_ENCRYPTION_KEY is present in the environment.
function isEnvKeyActive(): boolean {
return !!process.env.TOKEN_ENCRYPTION_KEY?.trim();
}
// A non-reversible fingerprint of the ACTIVE key (env if set, else the stored DB
// key). Lets an operator verify which key is live and that a saved backup
// matches — without ever exposing the key. Domain-separated so it shares nothing
// with the cipher key derivation, and it never force-creates a key (returns null
// when none exists yet). A 16-hex prefix of SHA-256 over a 48-byte random key is
// not reversible or guessable.
function keyFingerprint(): string | null {
let ikm = getEnvIkm();
if (!ikm) {
const { getSetting } = require('../db/database.cts');
const stored = getSetting('_auto_encryption_key');
if (!stored) return null;
ikm = Buffer.from(stored, 'utf8');
}
return crypto
.createHash('sha256')
.update('bill-tracker-key-fingerprint-v1')
.update(ikm)
.digest('hex')
.slice(0, 16);
}
// No-op kept for call-site compatibility.
function assertEncryptionReady(): void {}
// Encrypts with the env key (e2: prefix) when TOKEN_ENCRYPTION_KEY is set,
// otherwise with the DB key (v2: prefix).
function encryptSecret(plaintext: string): string {
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: string): string {
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: Buffer;
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: Db): void {
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: string | null): string | null {
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,
keyFingerprint,
reEncryptWithEnvKey,
};