'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 v2 secrets. const HKDF_INFO = 'bill-tracker-token-encryption-v1'; // Prefix that identifies ciphertext produced with HKDF key derivation. const V2_PREFIX = 'v2:'; let _warnedAutoKey = false; // Returns the raw key material (IKM) without derivation — shared by both paths. function getIkm() { const envRaw = process.env.TOKEN_ENCRYPTION_KEY || ''; if (envRaw) { const buf = Buffer.from(envRaw, 'utf8'); if (buf.length < 32) throw new Error('TOKEN_ENCRYPTION_KEY must be at least 32 bytes'); return buf; } if (!_warnedAutoKey) { _warnedAutoKey = true; console.warn( '[security] TOKEN_ENCRYPTION_KEY is not set. Using an auto-generated key ' + 'stored in the database alongside the encrypted data. Set TOKEN_ENCRYPTION_KEY ' + 'as an environment variable to keep the key separate from the data it protects.', ); } // Lazy-require to avoid circular dependency at module load time 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'); } // 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. function deriveLegacyKey(ikm) { return crypto.createHash('sha256').update(ikm).digest(); } function getKey() { return deriveKey(getIkm()); } function getLegacyKey() { return deriveLegacyKey(getIkm()); } // 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. function decryptSecret(stored) { const isV2 = stored.startsWith(V2_PREFIX); const payload = isV2 ? stored.slice(V2_PREFIX.length) : stored; 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(); 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'); } module.exports = { assertEncryptionReady, encryptSecret, decryptSecret };