import { createCipheriv, createDecipheriv, createHash, randomBytes } from "crypto"; import { totpVerify } from "../totp"; /* An operator's second factor, stored under the console's own key. * * lib/totp.ts encrypts a coordinator's TOTP secret with a key derived from SESSION_SECRET, and * that is the right blast radius there: rotating the product's secret invalidates customer * sessions and customer second factors together. It is the wrong blast radius here. An * operator's secret is kept under a key derived from OPS_SESSION_SECRET, so rotating either * secret touches only its own side. The cipher, the format and the verification arithmetic are * the product's own — nothing new is invented, only the key underneath it. */ function key(): Buffer { const s = process.env.OPS_SESSION_SECRET; if (!s) throw new Error("OPS_SESSION_SECRET is required to store an operator's TOTP secret"); return createHash("sha256").update(`ops-totp:${s}`).digest(); } export function encryptOpsSecret(plain: string): string { const iv = randomBytes(12); const c = createCipheriv("aes-256-gcm", key(), iv); const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]); return `v1.${iv.toString("base64url")}.${c.getAuthTag().toString("base64url")}.${enc.toString("base64url")}`; } export function decryptOpsSecret(stored: string): string | null { try { const [v, iv, tag, enc] = stored.split("."); if (v !== "v1") return null; const d = createDecipheriv("aes-256-gcm", key(), Buffer.from(iv, "base64url")); d.setAuthTag(Buffer.from(tag, "base64url")); return Buffer.concat([d.update(Buffer.from(enc, "base64url")), d.final()]).toString("utf8"); } catch { return null; } } /** True only when the operator has a second factor enrolled AND the code matches it. */ export function verifyOperatorCode(op: { totpSecret: string; totpEnabledAt: Date | null }, code: string): boolean { if (!op.totpEnabledAt) return false; const secret = decryptOpsSecret(op.totpSecret); if (!secret) return false; return totpVerify(secret, String(code || "").replace(/\s+/g, "")); }