← back to Butlr
lib/crypto.js
97 lines
// AES-256-GCM field-level encryption for sensitive submitted data.
//
// Each sensitive field gets its own ciphertext envelope:
// v1.gcm.<iv_b64>.<tag_b64>.<ciphertext_b64>
//
// The version prefix lets us migrate algorithms later without re-encrypting.
//
// Key handling:
// - HFM_ENC_KEY env var holds the base64-encoded 32-byte key
// - If unset in dev → log a one-time warning, store plaintext (so dev workflow stays easy)
// - If unset in prod (NODE_ENV=production) → throw at startup (fail-closed)
//
// Generate a fresh key:
// node -e "console.log(require('crypto').randomBytes(32).toString('base64'))"
// Then add to .env:
// HFM_ENC_KEY=<that-base64>
const crypto = require('crypto');
const ALG = 'aes-256-gcm';
const VERSION = 'v1';
const PREFIX = `${VERSION}.gcm.`;
let _keyBuf = null;
let _warned = false;
function getKey() {
if (_keyBuf) return _keyBuf;
const raw = process.env.HFM_ENC_KEY;
if (!raw) {
if (process.env.NODE_ENV === 'production') {
throw new Error('HFM_ENC_KEY is required in production. Generate with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'base64\'))"');
}
if (!_warned) {
console.warn('[crypto] HFM_ENC_KEY not set — sensitive fields will be stored PLAINTEXT (dev mode only).');
_warned = true;
}
return null; // plaintext mode
}
const buf = Buffer.from(raw, 'base64');
if (buf.length !== 32) throw new Error(`HFM_ENC_KEY must decode to 32 bytes, got ${buf.length}`);
_keyBuf = buf;
return _keyBuf;
}
function encrypt(plain) {
if (plain == null || plain === '') return '';
const key = getKey();
if (!key) return String(plain); // plaintext fallback in dev
const iv = crypto.randomBytes(12); // 96-bit IV per NIST for GCM
const cipher = crypto.createCipheriv(ALG, key, iv);
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return PREFIX + iv.toString('base64') + '.' + tag.toString('base64') + '.' + ct.toString('base64');
}
function decrypt(envelope) {
if (envelope == null || envelope === '') return '';
const s = String(envelope);
if (!s.startsWith(PREFIX)) return s; // plaintext (pre-key-rollout data)
const key = getKey();
if (!key) {
console.error('[crypto] decrypt called but no HFM_ENC_KEY available — returning empty');
return '';
}
const parts = s.slice(PREFIX.length).split('.');
if (parts.length !== 3) {
console.error('[crypto] malformed envelope (expected 3 segments)');
return '';
}
const [ivB64, tagB64, ctB64] = parts;
try {
const decipher = crypto.createDecipheriv(ALG, key, Buffer.from(ivB64, 'base64'));
decipher.setAuthTag(Buffer.from(tagB64, 'base64'));
return Buffer.concat([decipher.update(Buffer.from(ctB64, 'base64')), decipher.final()]).toString('utf8');
} catch (e) {
console.error('[crypto] decrypt failed:', e.message);
return '';
}
}
// Convenience: encrypt/decrypt a whole object's sensitive keys.
function encryptFields(obj, fields) {
const out = { ...obj };
for (const f of fields) if (out[f]) out[f] = encrypt(out[f]);
return out;
}
function decryptFields(obj, fields) {
const out = { ...obj };
for (const f of fields) if (out[f]) out[f] = decrypt(out[f]);
return out;
}
module.exports = { encrypt, decrypt, encryptFields, decryptFields, PREFIX };