totp.ts

3.6 KB · raw

1import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";23// RFC 6238 with the parameters every authenticator app assumes: SHA-1, a 30-second step, six4// digits. Kepr 2's operator login carried a base32 secret the same way.5const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";6const STEP_SECONDS = 30;7export const DIGITS = 6;8// The step either side of now: a clock a little out of step is not a lockout.9const STEPS_EITHER_SIDE = 1;10// 160 bits, the length RFC 4226 recommends and what authenticators generate.11const SECRET_BYTES = 20;1213function base32Encode(bytes: Uint8Array): string {14	let bits = 0;15	let value = 0;16	let text = "";17	for (const byte of bytes) {18		value = (value << 8) | byte;19		bits += 8;20		while (bits >= 5) {21			text += ALPHABET[(value >>> (bits - 5)) & 31];22			bits -= 5;23		}24	}25	if (bits > 0) text += ALPHABET[(value << (5 - bits)) & 31];26	return text;27}2829/** The bytes a base32 secret stands for, or null when it is not base32 at all. */30function base32Decode(text: string): Uint8Array | null {31	const trimmed = text.trim().toUpperCase().replace(/=+$/, "");32	if (trimmed === "" || /[^A-Z2-7]/.test(trimmed)) return null;33	const bytes: number[] = [];34	let bits = 0;35	let value = 0;36	for (const character of trimmed) {37		value = (value << 5) | ALPHABET.indexOf(character);38		bits += 5;39		if (bits >= 8) {40			bytes.push((value >>> (bits - 8)) & 0xff);41			bits -= 8;42		}43	}44	return Uint8Array.from(bytes);45}4647/** A fresh secret, in the base32 an authenticator app is given. */48export function newSecret(): string {49	return base32Encode(randomBytes(SECRET_BYTES));50}5152/** True for base32 that decodes to at least 16 bytes: shorter is not worth offering. */53export function isSecret(value: string): boolean {54	const bytes = base32Decode(value);55	return bytes !== null && bytes.length >= 16;56}5758function codeAtStep(key: Uint8Array, step: number): string {59	const counter = Buffer.alloc(8);60	counter.writeBigUInt64BE(BigInt(step));61	const mac = createHmac("sha1", key).update(counter).digest();62	const offset = mac[mac.length - 1]! & 0x0f;63	const value = mac.readUInt32BE(offset) & 0x7fffffff;64	return String(value % 10 ** DIGITS).padStart(DIGITS, "0");65}6667/** The code an authenticator app shows at `now`, or null when the secret cannot be read. */68export function codeAt(secret: string, now: number): string | null {69	const key = base32Decode(secret);70	if (key === null) return null;71	return codeAtStep(key, Math.floor(now / STEP_SECONDS));72}7374/** Six digits, and nothing else: what an authenticator app shows. */75const CODE = new RegExp(`^\\d{${DIGITS}}$`);7677/**78 * The step a code belongs to, or null when it is not the code for a step near `now`. Callers79 * remember the step they accepted, so the same code cannot sign in twice (§24).80 */81export function stepFor(secret: string, code: string, now: number): number | null {82	const key = base32Decode(secret);83	if (key === null || !CODE.test(code)) return null;84	const given = Buffer.from(code);85	const here = Math.floor(now / STEP_SECONDS);86	for (let step = here - STEPS_EITHER_SIDE; step <= here + STEPS_EITHER_SIDE; step++) {87		if (step < 0) continue;88		const expected = Buffer.from(codeAtStep(key, step));89		if (timingSafeEqual(given, expected)) return step;90	}91	return null;92}9394/** What an authenticator app reads, for the console to show once while setting up. */95export function otpauthUri(secret: string, domain: string): string {96	const issuer = encodeURIComponent("kepr");97	const label = `${issuer}:${encodeURIComponent(domain)}`;98	const query = new URLSearchParams({99		secret,100		issuer: "kepr",101		algorithm: "SHA1",102		digits: String(DIGITS),103		period: String(STEP_SECONDS),104	});105	return `otpauth://totp/${label}?${query.toString()}`;106}