SPB Git

Repository Public

totp.ts 2.8 KB 73 lines typescript

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
Files main
Menu

Theme