/** * TASK-0133 — TOTP (RFC 6238) for the org `require2FA` policy. * * Pure `node:crypto` (no dependency — rule 17): HMAC-SHA1, 30 s step, 6 * digits (the industry default; the shared `otpauth` URI is built in * `two-factor.service.ts`). The SEED is a 20-byte random value, stored * AES-256-GCM encrypted on `User.twoFactorSecret` (base64 envelope, same * scheme as SSO/webhook secrets) — `null` = not enrolled. * * Verification accepts a ±1 step window (60 s clock drift tolerance). */ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const STEP_SECONDS = 30; const DIGITS = 6; export function base32Encode(buffer: Buffer): string { let bits = 0; let value = 0; let out = ""; for (const byte of buffer) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31]; return out; } export function base32Decode(input: string): Buffer { const clean = input.toUpperCase().replace(/=+$/g, "").replace(/\s/g, ""); let bits = 0; let value = 0; const out: number[] = []; for (const ch of clean) { const idx = BASE32_ALPHABET.indexOf(ch); if (idx === -1) throw new Error("invalid base32 character"); value = (value << 5) | idx; bits += 5; if (bits >= 8) { out.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Buffer.from(out); } function hotp(secret: Buffer, counter: bigint): string { const buf = Buffer.alloc(8); buf.writeBigUInt64BE(counter); const hmac = createHmac("sha1", secret).update(buf).digest(); const offset = hmac[hmac.length - 1] & 0x0f; const code = ((hmac[offset] & 0x7f) << 24) | (hmac[offset + 1] << 16) | (hmac[offset + 2] << 8) | hmac[offset + 3]; return (code % 10 ** DIGITS).toString().padStart(DIGITS, "0"); } /** The code for `nowMs` (default: wall clock). */ export function totpAt(secret: Buffer, nowMs: number = Date.now()): string { return hotp(secret, BigInt(Math.floor(nowMs / 1000 / STEP_SECONDS))); } /** Verify a 6-digit code against the seed (±1 step window, constant-time). */ export function totpVerify(secret: Buffer, code: string, nowMs: number = Date.now()): boolean { const cleaned = code.replace(/\s+/g, ""); if (!/^\d{6}$/.test(cleaned)) return false; const step = Math.floor(nowMs / 1000 / STEP_SECONDS); for (const offset of [0, -1, 1]) { const expected = hotp(secret, BigInt(step + offset)); if ( cleaned.length === expected.length && timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected)) ) { return true; } } return false; } /** Fresh 20-byte seed + its base32 string (revealed once at enrollment). */ export function generateTotpSeed(): { seed: Buffer; secret: string } { const seed = randomBytes(20); return { seed, secret: base32Encode(seed) }; }