import { constantTimeEqual } from "../crypto.ts"; const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const MIN_DIGITS = 6; const MAX_DIGITS = 10; const MAX_WINDOW = 20; function assertPeriod(period: number): number { if (!Number.isInteger(period) || period <= 0 || period > 86_400) { throw new RangeError("TOTP period must be an integer between 1 and 86400 seconds"); } return period; } function assertDigits(digits: number): number { if (!Number.isInteger(digits) || digits < MIN_DIGITS || digits > MAX_DIGITS) { throw new RangeError(`TOTP digits must be an integer between ${MIN_DIGITS} and ${MAX_DIGITS}`); } return digits; } function assertTimestamp(timestamp: number): number { if (!Number.isFinite(timestamp) || timestamp < 0) { throw new RangeError("TOTP timestamp must be a finite non-negative number"); } return timestamp; } function assertWindow(window: number): number { if (!Number.isInteger(window) || window < 0 || window > MAX_WINDOW) { throw new RangeError(`TOTP window must be an integer between 0 and ${MAX_WINDOW}`); } return window; } export function encodeBase32(bytes: Uint8Array): string { let bits = 0; let value = 0; let output = ""; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { output += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) output += ALPHABET[(value << (5 - bits)) & 31]; return output; } export function decodeBase32(value: string): Uint8Array { const compact = value.toUpperCase().replace(/[\s-]/g, ""); if (!compact || !/^[A-Z2-7]+={0,6}$/.test(compact)) { throw new TypeError("Invalid base32 secret"); } const firstPadding = compact.indexOf("="); const normalized = firstPadding < 0 ? compact : compact.slice(0, firstPadding); if (!normalized) throw new TypeError("Invalid base32 secret"); let bits = 0; let buffer = 0; const output: number[] = []; for (const character of normalized) { const index = ALPHABET.indexOf(character); if (index < 0) throw new TypeError("Invalid base32 secret"); buffer = (buffer << 5) | index; bits += 5; if (bits >= 8) { output.push((buffer >>> (bits - 8)) & 255); bits -= 8; } } if (!output.length) throw new TypeError("Invalid base32 secret"); return new Uint8Array(output); } function counterBytes(counter: number): Uint8Array { if (!Number.isSafeInteger(counter) || counter < 0) { throw new RangeError("HOTP counter must be a non-negative safe integer"); } const bytes = new Uint8Array(8); let value = BigInt(counter); for (let index = 7; index >= 0; index -= 1) { bytes[index] = Number(value & 255n); value >>= 8n; } return bytes; } async function hotp(secret: string, counter: number, digits = 6): Promise { const normalizedDigits = assertDigits(digits); const key = await crypto.subtle.importKey( "raw", decodeBase32(secret) as BufferSource, { name: "HMAC", hash: "SHA-1" }, false, ["sign"], ); const digest = new Uint8Array( await crypto.subtle.sign("HMAC", key, counterBytes(counter) as BufferSource), ); const offset = digest[digest.length - 1] & 15; const binary = ((digest[offset] & 127) << 24) | ((digest[offset + 1] & 255) << 16) | ((digest[offset + 2] & 255) << 8) | (digest[offset + 3] & 255); return String(binary % 10 ** normalizedDigits).padStart(normalizedDigits, "0"); } export interface TotpOptions { period?: number; digits?: number; timestamp?: number; } export function generateTotpSecret(randomBytes?: (length: number) => Uint8Array): string { const bytes = randomBytes ? randomBytes(20) : crypto.getRandomValues(new Uint8Array(20)); if (!(bytes instanceof Uint8Array) || bytes.length !== 20) { throw new TypeError("TOTP random byte provider must return exactly 20 bytes"); } return encodeBase32(bytes); } export async function generateTotp(secret: string, options: TotpOptions = {}): Promise { const period = assertPeriod(options.period ?? 30); const timestamp = assertTimestamp(options.timestamp ?? Date.now()); const digits = assertDigits(options.digits ?? 6); return hotp(secret, Math.floor(timestamp / 1000 / period), digits); } export async function verifyTotp( secret: string, token: string, options: TotpOptions & { window?: number; lastCounter?: number } = {}, ): Promise<{ valid: boolean; counter?: number }> { const period = assertPeriod(options.period ?? 30); const timestamp = assertTimestamp(options.timestamp ?? Date.now()); const digits = assertDigits(options.digits ?? 6); const window = assertWindow(options.window ?? 1); const normalizedToken = token.replace(/\s/g, ""); if (!new RegExp(`^\\d{${digits}}$`).test(normalizedToken)) return { valid: false }; const counter = Math.floor(timestamp / 1000 / period); const lastCounter = options.lastCounter ?? -1; if (!Number.isSafeInteger(lastCounter) || lastCounter < -1) { throw new RangeError("TOTP lastCounter must be a safe integer greater than or equal to -1"); } for (let offset = -window; offset <= window; offset += 1) { const candidateCounter = counter + offset; if (candidateCounter < 0 || candidateCounter <= lastCounter) continue; const candidate = await hotp(secret, candidateCounter, digits); if (await constantTimeEqual(candidate, normalizedToken)) { return { valid: true, counter: candidateCounter }; } } return { valid: false }; } export function totpUri(input: { issuer: string; accountName: string; secret: string; period?: number; digits?: number; }): string { const issuer = input.issuer.trim(); const accountName = input.accountName.trim(); if (!issuer || !accountName) { throw new TypeError("TOTP issuer and account name are required"); } decodeBase32(input.secret); const period = assertPeriod(input.period ?? 30); const digits = assertDigits(input.digits ?? 6); const label = encodeURIComponent(`${issuer}:${accountName}`); const params = new URLSearchParams({ secret: input.secret.toUpperCase().replace(/[\s-]/g, "").replace(/=+$/g, ""), issuer, period: String(period), digits: String(digits), algorithm: "SHA1", }); return `otpauth://totp/${label}?${params.toString()}`; }