// One-time passwords: HOTP (RFC 4226) and TOTP (RFC 6238) with Web Crypto HMAC, Base32 secrets // (RFC 4648) and otpauth:// URIs (Google Authenticator's Key URI format). import { randomBytes } from './random'; export const OTP_ALGORITHMS = ['SHA-1', 'SHA-256', 'SHA-512'] as const; export type OtpAlgorithm = (typeof OTP_ALGORITHMS)[number]; export type OtpOptions = { /** 6 is what almost every app uses; RFC 4226 allows 6-8. */ digits?: number; algorithm?: OtpAlgorithm; }; export type TotpOptions = OtpOptions & { /** Time step in seconds (RFC 6238 default: 30). */ period?: number; /** Unix time in seconds the counting starts from (RFC 6238 default: 0). */ t0?: number; }; /** RFC 4226 asks for at least 128 bits and recommends 160 - the size this tool generates. */ export const OTP_SECRET_BYTES = { min: 16, default: 20 } as const; const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; export class OtpSecretError extends Error { override name = 'OtpSecretError'; } /** RFC 4648 Base32. Authenticator apps expect it without "=" padding. */ export function base32Encode(bytes: Uint8Array, padding = false): string { let out = ''; let buffer = 0; let bits = 0; for (const byte of bytes) { buffer = (buffer << 8) | byte; bits += 8; while (bits >= 5) { out += BASE32[(buffer >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) out += BASE32[(buffer << (5 - bits)) & 31]; return padding ? out.padEnd(Math.ceil(out.length / 8) * 8, '=') : out; } /** Decodes Base32 the way people paste it: any case, with spaces, dashes or padding. */ export function base32Decode(text: string): Uint8Array { const clean = text.replace(/[\s-]/g, '').replace(/=+$/, '').toUpperCase(); const out: number[] = []; let buffer = 0; let bits = 0; for (const char of clean) { const value = BASE32.indexOf(char); if (value === -1) { throw new OtpSecretError(`"${char}" is not a Base32 character (A-Z and 2-7).`); } buffer = ((buffer << 5) | value) & 0xffff; bits += 5; if (bits >= 8) { out.push((buffer >>> (bits - 8)) & 0xff); bits -= 8; } } return Uint8Array.from(out); } /** A new random secret from the CSPRNG, Base32 without padding. */ export function generateOtpSecret(bytes: number = OTP_SECRET_BYTES.default): string { if (!Number.isInteger(bytes) || bytes < OTP_SECRET_BYTES.min) { throw new OtpSecretError(`A secret needs at least ${OTP_SECRET_BYTES.min} bytes (128 bits).`); } return base32Encode(randomBytes(bytes)); } function checkDigits(digits: number) { if (!Number.isInteger(digits) || digits < 6 || digits > 8) { throw new RangeError('Codes have 6, 7 or 8 digits.'); } } /** HOTP (RFC 4226, section 5.3): HMAC of the 8-byte counter, dynamic truncation, modulo 10^digits. */ export async function hotp( secret: Uint8Array, counter: number, { digits = 6, algorithm = 'SHA-1' }: OtpOptions = {}, ): Promise { checkDigits(digits); if (!Number.isSafeInteger(counter) || counter < 0) { throw new RangeError('The counter must be a non-negative integer.'); } const message = new Uint8Array(8); new DataView(message.buffer).setBigUint64(0, BigInt(counter)); const key = await crypto.subtle.importKey( 'raw', secret as Uint8Array, { name: 'HMAC', hash: algorithm }, 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'); } /** The TOTP time step T for a Unix time in seconds (RFC 6238, section 4.2). */ export function totpCounter(unixSeconds: number, period = 30, t0 = 0): number { return Math.floor((unixSeconds - t0) / period); } /** Seconds left before the code for this moment changes. */ export function totpRemaining(unixSeconds: number, period = 30, t0 = 0): number { return period - ((((unixSeconds - t0) % period) + period) % period); } /** The TOTP code at a Unix time in seconds. */ export function totp(secret: Uint8Array, unixSeconds: number, options: TotpOptions = {}) { const { period = 30, t0 = 0, ...otp } = options; return hotp(secret, totpCounter(unixSeconds, period, t0), otp); } /** * Checks a code against the current step and `window` steps either side (clock drift), as RFC * 6238 section 5.2 suggests. Returns the step offset that matched, or null. */ export async function verifyTotp( secret: Uint8Array, code: string, unixSeconds: number, options: TotpOptions & { window?: number } = {}, ): Promise { const { window = 1, ...rest } = options; const clean = code.replace(/\s/g, ''); const { period = 30, t0 = 0 } = rest; const counter = totpCounter(unixSeconds, period, t0); for (let offset = -window; offset <= window; offset += 1) { if (counter + offset < 0) continue; if ((await hotp(secret, counter + offset, rest)) === clean) return offset; } return null; } export type OtpauthParams = { secret: string; account: string; issuer?: string; algorithm?: OtpAlgorithm; digits?: number; period?: number; }; /** otpauth://totp/Issuer:account?secret=...&issuer=... - the content of the QR code apps scan. */ export function otpauthUri({ secret, account, issuer = '', algorithm = 'SHA-1', digits = 6, period = 30, }: OtpauthParams): string { if (account.includes(':') || issuer.includes(':')) { throw new OtpSecretError('The account and issuer cannot contain a colon.'); } const label = issuer ? `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}` : encodeURIComponent(account); const params = new URLSearchParams({ secret: secret.replace(/[\s=-]/g, '').toUpperCase() }); if (issuer) params.set('issuer', issuer); // Only non-default values: some apps ignore these parameters, so defaults stay implicit. if (algorithm !== 'SHA-1') params.set('algorithm', algorithm.replace('-', '')); if (digits !== 6) params.set('digits', String(digits)); if (period !== 30) params.set('period', String(period)); return `otpauth://totp/${label}?${params.toString().replace(/\+/g, '%20')}`; }