import { timingSafeEqualStrings } from "./crypto"; const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const SECRET_BYTES = 20; const STEP_SECONDS = 30; const DIGITS = 6; const DRIFT_STEPS = 1; const BASE32_BITS = 5; const BYTE_BITS = 8; export function base32Encode(bytes: Uint8Array): string { let bits = 0; let value = 0; let out = ""; for (const byte of bytes) { value = (value << BYTE_BITS) | byte; bits += BYTE_BITS; while (bits >= BASE32_BITS) { out += BASE32_ALPHABET[(value >>> (bits - BASE32_BITS)) & 31]; bits -= BASE32_BITS; } } if (bits > 0) out += BASE32_ALPHABET[(value << (BASE32_BITS - bits)) & 31]; return out; } export function base32Decode(input: string): Uint8Array { const clean = input.toUpperCase().replace(/[\s=]/g, ""); const out: number[] = []; let bits = 0; let value = 0; for (const char of clean) { const index = BASE32_ALPHABET.indexOf(char); if (index === -1) throw new Error("Invalid base32"); value = (value << BASE32_BITS) | index; bits += BASE32_BITS; if (bits >= BYTE_BITS) { out.push((value >>> (bits - BYTE_BITS)) & 255); bits -= BYTE_BITS; } } return new Uint8Array(out); } export function generateTotpSecret(): string { return base32Encode(crypto.getRandomValues(new Uint8Array(SECRET_BYTES))); } /** RFC 4226 HOTP with HMAC-SHA1 and dynamic truncation. */ export async function hotp(secret: Uint8Array, counter: number): Promise { const message = new ArrayBuffer(8); const view = new DataView(message); view.setUint32(0, Math.floor(counter / 2 ** 32)); view.setUint32(4, counter >>> 0); const key = await crypto.subtle.importKey("raw", secret, { name: "HMAC", hash: "SHA-1" }, false, ["sign"]); const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, message)); const offset = mac[mac.length - 1] & 0x0f; const binary = ((mac[offset] & 0x7f) << 24) | (mac[offset + 1] << 16) | (mac[offset + 2] << 8) | mac[offset + 3]; return String(binary % 10 ** DIGITS).padStart(DIGITS, "0"); } export function totpStep(nowMs = Date.now()): number { return Math.floor(nowMs / 1000 / STEP_SECONDS); } /** * Returns the matched time step (for replay protection) or null. Accepts one step of clock drift and * rejects steps at or before `lastStep`. */ export async function verifyTotp( secretBase32: string, code: string, lastStep: number | null, nowMs = Date.now(), ): Promise { const normalized = code.replace(/\s/g, ""); if (!/^\d{6}$/.test(normalized)) return null; const secret = base32Decode(secretBase32); const current = totpStep(nowMs); let matched: number | null = null; for (let step = current - DRIFT_STEPS; step <= current + DRIFT_STEPS; step++) { const expected = await hotp(secret, step); if (timingSafeEqualStrings(expected, normalized) && (lastStep === null || step > lastStep)) matched = step; } return matched; } export function totpUri(issuer: string, account: string, secretBase32: string): string { const label = encodeURIComponent(`${issuer}:${account}`); const params = new URLSearchParams({ secret: secretBase32, issuer, digits: String(DIGITS), period: String(STEP_SECONDS), }); return `otpauth://totp/${label}?${params.toString()}`; }