1
12import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
13
14const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
15const STEP_SECONDS = 30;
16const DIGITS = 6;
17
18export function base32Encode(buffer: Buffer): string {
19 let bits = 0;
20 let value = 0;
21 let out = "";
22 for (const byte of buffer) {
23 value = (value << 8) | byte;
24 bits += 8;
25 while (bits >= 5) {
26 out += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
27 bits -= 5;
28 }
29 }
30 if (bits > 0) out += BASE32_ALPHABET[(value << (5 - bits)) & 31];
31 return out;
32}
33
34export function base32Decode(input: string): Buffer {
35 const clean = input.toUpperCase().replace(/=+$/g, "").replace(/\s/g, "");
36 let bits = 0;
37 let value = 0;
38 const out: number[] = [];
39 for (const ch of clean) {
40 const idx = BASE32_ALPHABET.indexOf(ch);
41 if (idx === -1) throw new Error("invalid base32 character");
42 value = (value << 5) | idx;
43 bits += 5;
44 if (bits >= 8) {
45 out.push((value >>> (bits - 8)) & 0xff);
46 bits -= 8;
47 }
48 }
49 return Buffer.from(out);
50}
51
52function hotp(secret: Buffer, counter: bigint): string {
53 const buf = Buffer.alloc(8);
54 buf.writeBigUInt64BE(counter);
55 const hmac = createHmac("sha1", secret).update(buf).digest();
56 const offset = hmac[hmac.length - 1] & 0x0f;
57 const code =
58 ((hmac[offset] & 0x7f) << 24) |
59 (hmac[offset + 1] << 16) |
60 (hmac[offset + 2] << 8) |
61 hmac[offset + 3];
62 return (code % 10 ** DIGITS).toString().padStart(DIGITS, "0");
63}
64
65
66export function totpAt(secret: Buffer, nowMs: number = Date.now()): string {
67 return hotp(secret, BigInt(Math.floor(nowMs / 1000 / STEP_SECONDS)));
68}
69
70
71export function totpVerify(secret: Buffer, code: string, nowMs: number = Date.now()): boolean {
72 const cleaned = code.replace(/\s+/g, "");
73 if (!/^\d{6}$/.test(cleaned)) return false;
74 const step = Math.floor(nowMs / 1000 / STEP_SECONDS);
75 for (const offset of [0, -1, 1]) {
76 const expected = hotp(secret, BigInt(step + offset));
77 if (
78 cleaned.length === expected.length &&
79 timingSafeEqual(Buffer.from(cleaned), Buffer.from(expected))
80 ) {
81 return true;
82 }
83 }
84 return false;
85}
86
87
88export function generateTotpSeed(): { seed: Buffer; secret: string } {
89 const seed = randomBytes(20);
90 return { seed, secret: base32Encode(seed) };
91}
92