BillTracker/services/encryptionService.js

71 lines
2.9 KiB
JavaScript

'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:';
// 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
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 };