import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; // RFC 6238 with the parameters every authenticator app assumes: SHA-1, a 30-second step, six // digits. Kepr 2's operator login carried a base32 secret the same way. const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; const STEP_SECONDS = 30; export const DIGITS = 6; // The step either side of now: a clock a little out of step is not a lockout. const STEPS_EITHER_SIDE = 1; // 160 bits, the length RFC 4226 recommends and what authenticators generate. const SECRET_BYTES = 20; function base32Encode(bytes: Uint8Array): string { let bits = 0; let value = 0; let text = ""; for (const byte of bytes) { value = (value << 8) | byte; bits += 8; while (bits >= 5) { text += ALPHABET[(value >>> (bits - 5)) & 31]; bits -= 5; } } if (bits > 0) text += ALPHABET[(value << (5 - bits)) & 31]; return text; } /** The bytes a base32 secret stands for, or null when it is not base32 at all. */ function base32Decode(text: string): Uint8Array | null { const trimmed = text.trim().toUpperCase().replace(/=+$/, ""); if (trimmed === "" || /[^A-Z2-7]/.test(trimmed)) return null; const bytes: number[] = []; let bits = 0; let value = 0; for (const character of trimmed) { value = (value << 5) | ALPHABET.indexOf(character); bits += 5; if (bits >= 8) { bytes.push((value >>> (bits - 8)) & 0xff); bits -= 8; } } return Uint8Array.from(bytes); } /** A fresh secret, in the base32 an authenticator app is given. */ export function newSecret(): string { return base32Encode(randomBytes(SECRET_BYTES)); } /** True for base32 that decodes to at least 16 bytes: shorter is not worth offering. */ export function isSecret(value: string): boolean { const bytes = base32Decode(value); return bytes !== null && bytes.length >= 16; } function codeAtStep(key: Uint8Array, step: number): string { const counter = Buffer.alloc(8); counter.writeBigUInt64BE(BigInt(step)); const mac = createHmac("sha1", key).update(counter).digest(); const offset = mac[mac.length - 1]! & 0x0f; const value = mac.readUInt32BE(offset) & 0x7fffffff; return String(value % 10 ** DIGITS).padStart(DIGITS, "0"); } /** The code an authenticator app shows at `now`, or null when the secret cannot be read. */ export function codeAt(secret: string, now: number): string | null { const key = base32Decode(secret); if (key === null) return null; return codeAtStep(key, Math.floor(now / STEP_SECONDS)); } /** Six digits, and nothing else: what an authenticator app shows. */ const CODE = new RegExp(`^\\d{${DIGITS}}$`); /** * The step a code belongs to, or null when it is not the code for a step near `now`. Callers * remember the step they accepted, so the same code cannot sign in twice (ยง24). */ export function stepFor(secret: string, code: string, now: number): number | null { const key = base32Decode(secret); if (key === null || !CODE.test(code)) return null; const given = Buffer.from(code); const here = Math.floor(now / STEP_SECONDS); for (let step = here - STEPS_EITHER_SIDE; step <= here + STEPS_EITHER_SIDE; step++) { if (step < 0) continue; const expected = Buffer.from(codeAtStep(key, step)); if (timingSafeEqual(given, expected)) return step; } return null; } /** What an authenticator app reads, for the console to show once while setting up. */ export function otpauthUri(secret: string, domain: string): string { const issuer = encodeURIComponent("kepr"); const label = `${issuer}:${encodeURIComponent(domain)}`; const query = new URLSearchParams({ secret, issuer: "kepr", algorithm: "SHA1", digits: String(DIGITS), period: String(STEP_SECONDS), }); return `otpauth://totp/${label}?${query.toString()}`; }