1import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";2345const ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";6const STEP_SECONDS = 30;7export const DIGITS = 6;89const STEPS_EITHER_SIDE = 1;1011const 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}282930function 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}464748export function newSecret(): string {49 return base32Encode(randomBytes(SECRET_BYTES));50}515253export 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}666768export 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}737475const CODE = new RegExp(`^\\d{${DIGITS}}$`);767778798081export 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}939495export 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}