/** * @factory/shared — symmetric encryption + secret helpers (TASK-0130). * * Single canonical implementation of the AES-256-GCM envelope used for * secrets at rest (webhook signing secrets, API-key material, …): * * output = base64( iv(12) || tag(16) || ciphertext ) * key = 64-hex-char master key used RAW (32 bytes), otherwise * SHA-256(master) — one derivation for every app. * * The API (apps/api/src/lib/crypto.ts) and the worker * (apps/worker/src/lib/encrypt.ts) both RE-EXPORT this module: the API * WRITES `webhooks.secretEncrypted` and the worker READS it for delivery * signing, so a single format + a single key derivation is mandatory * (C-13.8a — the two apps previously diverged: the worker used a * 3-part base64 string and a different key derivation). * * GCM authenticates: tampering with the ciphertext or the tag makes * decryption throw. See crypto.test.ts (known-vector + tamper tests). */ import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto"; const ALGO = "aes-256-gcm"; const IV_LEN = 12; const TAG_LEN = 16; /** Derive a 32-byte key from a secret string (hex if 64 hex chars, else SHA-256). */ export function keyFromSecret(secret: string): Buffer { if (secret.length === 64 && /^[0-9a-fA-F]{64}$/.test(secret)) { return Buffer.from(secret, "hex"); } return createHash("sha256").update(secret, "utf8").digest(); } /** Encrypt UTF-8 plaintext → base64(iv || tag || ciphertext). */ export function aes256gcmEncrypt(plaintext: string, key: string | Buffer): string { const k = typeof key === "string" ? keyFromSecret(key) : key; if (k.length !== 32) throw new Error("AES-256 key must be 32 bytes"); const iv = randomBytes(IV_LEN); const cipher = createCipheriv(ALGO, k, iv); const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); const tag = cipher.getAuthTag(); return Buffer.concat([iv, tag, ct]).toString("base64"); } /** * Decrypt base64(iv || tag || ciphertext) → UTF-8 plaintext. * Throws on tamper / wrong key / malformed input. */ export function aes256gcmDecrypt(payload: string, key: string | Buffer): string { const k = typeof key === "string" ? keyFromSecret(key) : key; if (k.length !== 32) throw new Error("AES-256 key must be 32 bytes"); const raw = Buffer.from(payload, "base64"); if (raw.length < IV_LEN + TAG_LEN) throw new Error("ciphertext too short"); const iv = raw.subarray(0, IV_LEN); const tag = raw.subarray(IV_LEN, IV_LEN + TAG_LEN); const ct = raw.subarray(IV_LEN + TAG_LEN); const decipher = createDecipheriv(ALGO, k, iv); decipher.setAuthTag(tag); return Buffer.concat([decipher.update(ct), decipher.final()]).toString("utf8"); } /** SHA-256 hex digest. */ export function sha256hex(value: string): string { return createHash("sha256").update(value, "utf8").digest("hex"); } /** HMAC-SHA256 hex. */ export function hmacSha256hex(key: string, message: string): string { return createHmac("sha256", key).update(message, "utf8").digest("hex"); } /** Cryptographic random token (base64url, no padding). */ export function randomToken(bytes = 32): string { return randomBytes(bytes) .toString("base64") .replace(/\+/g, "-") .replace(/\//g, "_") .replace(/=+$/, ""); } /** * Constant-time string comparison. (Length is not hidden — callers compare * same-shape values, e.g. two 64-hex signatures.) */ export function safeEqual(a: string, b: string): boolean { const ba = Buffer.from(a, "utf8"); const bb = Buffer.from(b, "utf8"); if (ba.length !== bb.length) return false; return timingSafeEqual(ba, bb); }