1import { describe, it, expect } from "vitest";
2import { base32Encode, base32Decode, totpAt, totpVerify, generateTotpSeed } from "./totp.js";
3
4
10
11
12
13
14
15
16const RFC4226_HOTP_6DIGIT: Array<[number, string]> = [
17
18 [0, "755224"],
19 [1, "287082"],
20 [2, "359152"],
21 [3, "969429"],
22 [4, "338314"],
23 [5, "254676"]
24];
25const RFC6238_TOTP_6DIGIT: Array<[number, string]> = [
26
27 [1111111109, "081804"],
28 [1111111111, "050471"],
29 [1234567890, "005924"],
30 [2000000000, "279037"],
31 [20000000000, "353130"]
32];
33
34describe("base32", () => {
35 it("round-trips arbitrary buffers", () => {
36 for (const len of [1, 5, 10, 20, 32]) {
37 const buf = Buffer.from(Array.from({ length: len }, (_, i) => (i * 37) % 256));
38 const enc = base32Encode(buf);
39 expect(base32Decode(enc).equals(buf)).toBe(true);
40 }
41 });
42
43 it("matches the RFC 4648 vector (1f2e3d4c5b → MFZWX43D2O6N for the leading bytes)", () => {
44
45 expect(base32Encode(Buffer.from("f"))).toBe("MY");
46 expect(base32Encode(Buffer.from("fo"))).toBe("MZXQ");
47 expect(base32Encode(Buffer.from("foo"))).toBe("MZXW6");
48 });
49
50 it("decode rejects invalid characters", () => {
51 expect(() => base32Decode("01OIL")).toThrow();
52 });
53});
54
55describe("TOTP core (RFC 4226 + RFC 6238 oracles)", () => {
56 it("matches the RFC 4226 §B.1 HOTP-SHA-1 vectors at 30 s steps", () => {
57 const secret = Buffer.from("12345678901234567890", "ascii");
58 for (const [counter, expected] of RFC4226_HOTP_6DIGIT) {
59
60 const code = totpAt(secret, counter * 30_000);
61 expect(code, `counter ${counter}`).toBe(expected);
62 }
63 });
64
65 it("matches the RFC 6238 §6.2 TOTP-SHA-1 vectors (6-digit form)", () => {
66 const secret = Buffer.from("12345678901234567890", "ascii");
67 for (const [T, expected] of RFC6238_TOTP_6DIGIT) {
68 const code = totpAt(secret, T * 1000);
69 expect(code, `T=${T}`).toBe(expected);
70 }
71 });
72
73 it("produces a 6-digit zero-padded string", () => {
74 const code = totpAt(Buffer.alloc(20, 7), 0);
75 expect(code).toMatch(/^\d{6}$/);
76 });
77});
78
79describe("totpVerify", () => {
80 const secret = Buffer.alloc(20, 9);
81 const T0 = 1_700_000_000_000;
82
83 it("accepts the current step", () => {
84 expect(totpVerify(secret, totpAt(secret, T0), T0)).toBe(true);
85 });
86
87 it("accepts ±1 step (60 s clock-drift tolerance)", () => {
88 expect(totpVerify(secret, totpAt(secret, T0 + 30_000), T0)).toBe(true);
89 expect(totpVerify(secret, totpAt(secret, T0 - 30_000), T0)).toBe(true);
90 });
91
92 it("rejects 2+ steps away", () => {
93 expect(totpVerify(secret, totpAt(secret, T0 + 60_000), T0)).toBe(false);
94 expect(totpVerify(secret, totpAt(secret, T0 - 60_000), T0)).toBe(false);
95 });
96
97 it("rejects malformed codes (wrong length / non-numeric)", () => {
98 expect(totpVerify(secret, "12345", T0)).toBe(false);
99 expect(totpVerify(secret, "12a456", T0)).toBe(false);
100 expect(totpVerify(secret, "", T0)).toBe(false);
101 });
102
103 it("tolerates whitespace in the code", () => {
104 expect(totpVerify(secret, ` ${totpAt(secret, T0)} `, T0)).toBe(true);
105 });
106
107 it("rejects a code for a DIFFERENT seed", () => {
108 const other = Buffer.alloc(20, 1);
109 expect(totpVerify(secret, totpAt(other, T0), T0)).toBe(false);
110 });
111});
112
113describe("generateTotpSeed", () => {
114 it("returns a 20-byte seed + its base32 form (round-trippable)", () => {
115 const { seed, secret } = generateTotpSeed();
116 expect(seed.length).toBe(20);
117 expect(base32Decode(secret).equals(seed)).toBe(true);
118 });
119
120 it("seeds are unique (randomness)", () => {
121 const a = generateTotpSeed().seed;
122 const b = generateTotpSeed().seed;
123 expect(a.equals(b)).toBe(false);
124 });
125});
126