import { createHash, createHmac, randomBytes } from "node:crypto"; // Time-based one-time passwords (RFC 6238) for authenticator apps: HMAC-SHA1, 6 digits, a new code // every 30 seconds. The secret is shared once, as a QR code of an otpauth:// URI. const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const PERIOD = 30; const DIGITS = 6; export function base32Encode(bytes: Uint8Array) { let out = ""; let bits = 0; let value = 0; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { out += BASE32[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += BASE32[(value << (5 - bits)) & 31]; return out; } export function base32Decode(text: string) { const clean = text.toUpperCase().replace(/[\s=-]/g, ""); const bytes: number[] = []; let bits = 0; let value = 0; for (const char of clean) { const index = BASE32.indexOf(char); if (index < 0) throw new Error("Invalid base32."); value = (value << 5) | index; bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 255); bits -= 8; } } return Buffer.from(bytes); } /** A new secret: 160 random bits, the size RFC 4226 recommends for SHA-1. */ export const newTotpSecret = () => base32Encode(randomBytes(20)); /** The HOTP code (RFC 4226) for a counter. */ export function hotp(key: Uint8Array, counter: number, digits = DIGITS) { const message = Buffer.alloc(8); message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0); message.writeUInt32BE(counter >>> 0, 4); const mac = createHmac("sha1", key).update(message).digest(); const offset = mac[mac.length - 1] & 15; const number = mac.readUInt32BE(offset) & 0x7fffffff; return String(number % 10 ** digits).padStart(digits, "0"); } export const totpStep = (now = Date.now()) => Math.floor(now / 1000 / PERIOD); /** * The time step a code belongs to, or null if it's wrong. Codes from the step before and after * count too, for clocks that are a little off and codes typed just as they changed. */ export function matchTotp(secret: string, code: string, now = Date.now()) { if (!/^\d{6}$/.test(code)) return null; const key = base32Decode(secret); const current = totpStep(now); for (const step of [current, current - 1, current + 1]) { if (hotp(key, step) === code) return step; } return null; } /** What authenticator apps scan: otpauth://totp/Issuer:account?secret=…&issuer=Issuer */ export function otpauthUri(secret: string, issuer: string, account: string) { const label = encodeURIComponent(`${issuer}:${account}`); const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(PERIOD) }); return `otpauth://totp/${label}?${params}`; } /** The secret in groups of four, easier to type into an app by hand. */ export const groupSecret = (secret: string) => secret.match(/.{1,4}/g)?.join(" ") ?? secret; /** What the user typed, without the spaces and hyphens people add, lowercased. */ export const normalizeCode = (input: string) => input.replace(/[\s-]/g, "").toLowerCase(); /** One-time recovery codes, like "k3m9q-7xw2p": 50 random bits each. */ export function newRecoveryCodes(count = 10) { return Array.from({ length: count }, () => { const code = base32Encode(randomBytes(7)).slice(0, 10).toLowerCase(); return `${code.slice(0, 5)}-${code.slice(5)}`; }); } /** Recovery codes are stored as hashes of their normalized form. */ export const hashRecoveryCode = (code: string) => createHash("sha256").update(normalizeCode(code)).digest("hex");