import { describe, it, expect } from "vitest"; import { base32Encode, base32Decode, totpAt, totpVerify, generateTotpSeed } from "./totp.js"; /** * TOTP (RFC 6238) — the 2FA primitive. Verified against: * - the RFC 6238 §6.2 test vectors (20-byte seed "12345678901234567890", * SHA-1, 8 digits — re-computed here for 6 digits via the same HOTP core); * - structural properties (step window, constant shape, seed round-trip). */ // Independent oracles (secret = 20 ASCII "12345678901234567890", SHA-1): // - RFC 4226 §B.1 HOTP-SHA-1, counters 0..5 (the 6-digit form; the RFC's // 8-digit values all happen to be < 10^6 here). // - RFC 6238 §6.2 TOTP-SHA-1 (30 s step), the 6-digit form (last 6 of the // RFC's 8-digit values — the HOTP core is mod-10^d, so this is exact). const RFC4226_HOTP_6DIGIT: Array<[number, string]> = [ // [counter, 6-digit HOTP] [0, "755224"], [1, "287082"], [2, "359152"], [3, "969429"], [4, "338314"], [5, "254676"] ]; const RFC6238_TOTP_6DIGIT: Array<[number, string]> = [ // [T seconds, 6-digit TOTP = last 6 of the RFC's 8-digit values] [1111111109, "081804"], // 8-digit: 07081804 [1111111111, "050471"], // 8-digit: 14050471 [1234567890, "005924"], // 8-digit: 89005924 [2000000000, "279037"], // 8-digit: 69279037 [20000000000, "353130"] // 8-digit: 65353130 ]; describe("base32", () => { it("round-trips arbitrary buffers", () => { for (const len of [1, 5, 10, 20, 32]) { const buf = Buffer.from(Array.from({ length: len }, (_, i) => (i * 37) % 256)); const enc = base32Encode(buf); expect(base32Decode(enc).equals(buf)).toBe(true); } }); it("matches the RFC 4648 vector (1f2e3d4c5b → MFZWX43D2O6N for the leading bytes)", () => { // RFC 4648: "" → "", "f" → "MY", "fo" → "MZXQ", "foo" → "MZXW6". expect(base32Encode(Buffer.from("f"))).toBe("MY"); expect(base32Encode(Buffer.from("fo"))).toBe("MZXQ"); expect(base32Encode(Buffer.from("foo"))).toBe("MZXW6"); }); it("decode rejects invalid characters", () => { expect(() => base32Decode("01OIL")).toThrow(); // 0, 1, I, L are not in the alphabet }); }); describe("TOTP core (RFC 4226 + RFC 6238 oracles)", () => { it("matches the RFC 4226 §B.1 HOTP-SHA-1 vectors at 30 s steps", () => { const secret = Buffer.from("12345678901234567890", "ascii"); for (const [counter, expected] of RFC4226_HOTP_6DIGIT) { // counter c = time c*30s (step 30) → totpAt at t = c*30_000 ms. const code = totpAt(secret, counter * 30_000); expect(code, `counter ${counter}`).toBe(expected); } }); it("matches the RFC 6238 §6.2 TOTP-SHA-1 vectors (6-digit form)", () => { const secret = Buffer.from("12345678901234567890", "ascii"); for (const [T, expected] of RFC6238_TOTP_6DIGIT) { const code = totpAt(secret, T * 1000); expect(code, `T=${T}`).toBe(expected); } }); it("produces a 6-digit zero-padded string", () => { const code = totpAt(Buffer.alloc(20, 7), 0); expect(code).toMatch(/^\d{6}$/); }); }); describe("totpVerify", () => { const secret = Buffer.alloc(20, 9); const T0 = 1_700_000_000_000; // aligned to a 30s step it("accepts the current step", () => { expect(totpVerify(secret, totpAt(secret, T0), T0)).toBe(true); }); it("accepts ±1 step (60 s clock-drift tolerance)", () => { expect(totpVerify(secret, totpAt(secret, T0 + 30_000), T0)).toBe(true); expect(totpVerify(secret, totpAt(secret, T0 - 30_000), T0)).toBe(true); }); it("rejects 2+ steps away", () => { expect(totpVerify(secret, totpAt(secret, T0 + 60_000), T0)).toBe(false); expect(totpVerify(secret, totpAt(secret, T0 - 60_000), T0)).toBe(false); }); it("rejects malformed codes (wrong length / non-numeric)", () => { expect(totpVerify(secret, "12345", T0)).toBe(false); expect(totpVerify(secret, "12a456", T0)).toBe(false); expect(totpVerify(secret, "", T0)).toBe(false); }); it("tolerates whitespace in the code", () => { expect(totpVerify(secret, ` ${totpAt(secret, T0)} `, T0)).toBe(true); }); it("rejects a code for a DIFFERENT seed", () => { const other = Buffer.alloc(20, 1); expect(totpVerify(secret, totpAt(other, T0), T0)).toBe(false); }); }); describe("generateTotpSeed", () => { it("returns a 20-byte seed + its base32 form (round-trippable)", () => { const { seed, secret } = generateTotpSeed(); expect(seed.length).toBe(20); expect(base32Decode(secret).equals(seed)).toBe(true); }); it("seeds are unique (randomness)", () => { const a = generateTotpSeed().seed; const b = generateTotpSeed().seed; expect(a.equals(b)).toBe(false); }); });