← 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 };