import { describe, expect, it } from "@suaveplan/testing/runner"; import { hotp } from "./hotp.js"; const ENCODER = new TextEncoder(); // RFC 4226 Appendix D shared secret: ASCII "12345678901234567890". const RFC4226_SECRET = ENCODER.encode("12345678901234567890"); // RFC 4226 Appendix D test vectors (counter, expected 6-digit HOTP at SHA-1). const RFC4226_VECTORS: ReadonlyArray<[bigint, string]> = [ [0n, "755224"], [1n, "287082"], [2n, "359152"], [3n, "969429"], [4n, "338314"], [5n, "254676"], [6n, "287922"], [7n, "162583"], [8n, "399871"], [9n, "520489"], ]; // RFC 6238 Appendix B uses a wider 32 / 64-byte secret for SHA-256 / SHA-512. const RFC6238_SHA256_SECRET = ENCODER.encode( "12345678901234567890123456789012" ); const RFC6238_SHA512_SECRET = ENCODER.encode( "1234567890123456789012345678901234567890123456789012345678901234" ); describe("hotp — RFC 4226 Appendix D vectors", () => { for (const [counter, expected] of RFC4226_VECTORS) { it(`RFC 4226 vector counter=${counter}`, { timeout: 5000 }, () => { expect(hotp(RFC4226_SECRET, counter)).toBe(expected); }); } }); describe("hotp — algorithm widening", () => { it("SHA-256 produces 8-digit code distinct from SHA-1", { timeout: 5000, }, () => { const a = hotp(RFC6238_SHA256_SECRET, 1n, { algorithm: "SHA256", digits: 8, }); const b = hotp(RFC4226_SECRET, 1n, { algorithm: "SHA1", digits: 8, }); expect(a).toHaveLength(8); expect(b).toHaveLength(8); expect(a).not.toBe(b); }); it("SHA-512 produces 8-digit code", { timeout: 5000 }, () => { const code = hotp(RFC6238_SHA512_SECRET, 1n, { algorithm: "SHA512", digits: 8, }); expect(code).toHaveLength(8); expect(/^\d{8}$/u.test(code)).toBe(true); }); }); describe("hotp — digits widening", () => { it("digits=7 returns 7 chars", { timeout: 5000 }, () => { expect(hotp(RFC4226_SECRET, 0n, { digits: 7 })).toHaveLength(7); }); it("digits=8 returns 8 chars", { timeout: 5000 }, () => { expect(hotp(RFC4226_SECRET, 0n, { digits: 8 })).toHaveLength(8); }); }); describe("hotp — bigint counter beyond Number.MAX_SAFE_INTEGER", () => { it("counter=2^53+1 round-trips through encode-counter", { timeout: 5000, }, () => { const counter = (1n << 53n) + 1n; const code = hotp(RFC4226_SECRET, counter); expect(/^\d{6}$/u.test(code)).toBe(true); }); }); describe("hotp — option / secret validation", () => { it("rejects empty secret", { timeout: 5000 }, () => { expect(() => hotp(new Uint8Array(0), 0n)).toThrow(/INVALID_SECRET/); }); it("rejects unknown algorithm via Zod boundary", { timeout: 5000, }, () => { expect(() => hotp(RFC4226_SECRET, 0n, { algorithm: "MD5" as unknown as "SHA1", }) ).toThrow(/INVALID_OPTIONS/); }); it("rejects digits=5 via Zod boundary (mapped to INVALID_DIGITS)", { timeout: 5000, }, () => { expect(() => hotp(RFC4226_SECRET, 0n, { digits: 5 as unknown as 6, }) ).toThrow(/INVALID_DIGITS/); }); });