All files / lib crypto.ts

95.23% Statements 40/42
77.77% Branches 14/18
100% Functions 3/3
95.23% Lines 40/42

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 611x                             1x 1x 1x 1x   1x   7x 7x 1x 7x 1x 7x     1x 1x 1x   1x 8x 5x 5x 5x 5x 5x 5x   1x 3x 3x 2x 2x 3x 2x 2x 2x 2x 2x 2x 2x 3x 1x 1x 3x  
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
 
// AES-256-GCM encryption for sensitive at-rest data.
//
// Format:  enc:v1:<base64(iv||ciphertext||authTag)>
//
// • The "enc:v1:" prefix lets us detect already-encrypted values and roll
//   over to v2 later without breaking old rows.
// • decrypt() tolerates plaintext (returns input as-is) so the system can
//   read rows that pre-date encryption.
// • encrypt() always produces a tagged ciphertext.
//
// Generate a fresh key with:
//   openssl rand -base64 32
 
const ALGO = 'aes-256-gcm';
const PREFIX = 'enc:v1:';
const IV_LEN = 12;
const TAG_LEN = 16;
 
let cachedKey: Buffer | null = null;
 
function getKey(): Buffer {
  if (cachedKey) return cachedKey;
  const raw = process.env.ENCRYPTION_KEY;
  if (!raw) throw new Error('ENCRYPTION_KEY is not set');
  const buf = Buffer.from(raw, 'base64');
  if (buf.length !== 32) {
    throw new Error(`ENCRYPTION_KEY must decode to 32 bytes (got ${buf.length})`);
  }
  cachedKey = buf;
  return buf;
}
 
export function encrypt(plain: string | null | undefined): string | null {
  if (plain == null || plain === '') return null;
  const iv = randomBytes(IV_LEN);
  const cipher = createCipheriv(ALGO, getKey(), iv);
  const ciphertext = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
  const tag = cipher.getAuthTag();
  return PREFIX + Buffer.concat([iv, ciphertext, tag]).toString('base64');
}
 
export function decrypt(value: string | null | undefined): string | null {
  if (value == null || value === '') return null;
  if (!value.startsWith(PREFIX)) return value; // legacy plaintext → pass through
  try {
    const raw = Buffer.from(value.slice(PREFIX.length), 'base64');
    if (raw.length < IV_LEN + TAG_LEN) return null;
    const iv = raw.subarray(0, IV_LEN);
    const tag = raw.subarray(raw.length - TAG_LEN);
    const ciphertext = raw.subarray(IV_LEN, raw.length - TAG_LEN);
    const decipher = createDecipheriv(ALGO, getKey(), iv);
    decipher.setAuthTag(tag);
    const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
    return dec.toString('utf8');
  } catch {
    return null; // tampered or wrong key — fail closed
  }
}