Repository Public
1/** RFC 6238 TOTP (HMAC-SHA1, 6 digits, 30 s step, ±1 step window) + RFC 4648 base32. */2import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';34const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';5export const TOTP_STEP_SEC = 30;6export const TOTP_DIGITS = 6;78export function base32Encode(buf: Buffer): string {9 let bits = 0;10 let value = 0;11 let out = '';12 for (const byte of buf) {13 value = (value << 8) | byte;14 bits += 8;15 while (bits >= 5) {16 out += ALPHABET[(value >>> (bits - 5)) & 31];17 bits -= 5;18 }19 }20 if (bits > 0) out += ALPHABET[(value << (5 - bits)) & 31];21 return out;22}23export function base32Decode(s: string): Buffer {24 const clean = s.replace(/=+$/, '').replace(/\s+/g, '').toUpperCase();25 let bits = 0;26 let value = 0;27 const out: number[] = [];28 for (const ch of clean) {29 const idx = ALPHABET.indexOf(ch);30 if (idx < 0) throw new Error('invalid base32');31 value = (value << 5) | idx;32 bits += 5;33 if (bits >= 8) {34 out.push((value >>> (bits - 8)) & 0xff);35 bits -= 8;36 }37 }38 return Buffer.from(out);39}4041export function hotp(secret: Buffer, counter: number, digits = TOTP_DIGITS): string {42 const msg = Buffer.alloc(8);43 msg.writeBigUInt64BE(BigInt(counter));44 const mac = createHmac('sha1', secret).update(msg).digest();45 const offset = (mac[mac.length - 1] ?? 0) & 0x0f;46 const bin = mac.readUInt32BE(offset) & 0x7fffffff;47 return (bin % 10 ** digits).toString().padStart(digits, '0');48}49export const totpCounter = (nowMs: number): number => Math.floor(nowMs / 1000 / TOTP_STEP_SEC);50export const totpAt = (secret: Buffer, nowMs: number, digits = TOTP_DIGITS): string => hotp(secret, totpCounter(nowMs), digits);5152/**53 * Returns the matched time-step counter, or null. A counter <= `lastCounter` is refused54 * (replay protection: each code is accepted at most once). All candidates are always compared.55 */56export function verifyTotp(secret: Buffer, code: string, nowMs: number, lastCounter: number | null): number | null {57 if (!/^\d{6}$/.test(code)) return null;58 const now = totpCounter(nowMs);59 let matched: number | null = null;60 for (const c of [now - 1, now, now + 1]) {61 const ok = timingSafeEqual(Buffer.from(hotp(secret, c)), Buffer.from(code));62 if (ok && matched === null && (lastCounter === null || c > lastCounter)) matched = c;63 }64 return matched;65}6667export const generateTotpSecret = (): string => base32Encode(randomBytes(20));68export function otpauthUrl(secretB32: string, account: string, issuer = 'Agent OS'): string {69 const label = `${encodeURIComponent(issuer)}:${encodeURIComponent(account)}`;70 const q = new URLSearchParams({ secret: secretB32, issuer, algorithm: 'SHA1', digits: String(TOTP_DIGITS), period: String(TOTP_STEP_SEC) });71 return `otpauth://totp/${label}?${q.toString()}`;72}73