// T-004: TOTP RFC 6238 known-answer vectors // // Why this test exists: the TOTP implementation delegates to the `otpauth` // library. Without independent RFC vectors, a library regression (wrong // byte-order, wrong HMAC variant, wrong time-step math) ships a wrong 2FA // code silently — users are locked out of every TOTP-protected account with // no failing test to catch it. RFC 6238 Appendix B vectors are the canonical // ground truth for this check. import { describe, expect, it } from "vitest"; import { TOTP, Secret } from "otpauth"; // RFC 6238 Appendix B test secrets are ASCII byte strings, not base32. // otpauth treats plain strings as base32, so we use Secret.fromHex with // the hex encoding of the ASCII bytes to pass raw bytes unambiguously. function asciiToHex(s: string): string { return Array.from(s).map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join(""); } const RFC_SECRET_SHA1 = Secret.fromHex(asciiToHex("12345678901234567890")); const RFC_SECRET_SHA256 = Secret.fromHex(asciiToHex("12345678901234567890123456789012")); const RFC_SECRET_SHA512 = Secret.fromHex(asciiToHex("1234567890123456789012345678901234567890123456789012345678901234")); // Helper: call otpauth's generate with a pinned Unix timestamp (in seconds). function generateAt(secret: Secret, algorithm: string, digits: number, period: number, t: number): string { const totp = new TOTP({ secret, algorithm, digits, period }); return totp.generate({ timestamp: t * 1000 }); } describe("T-004: TOTP RFC 6238 known-answer vectors", () => { // T-004a: RFC 6238 Appendix B SHA-1 vectors. // The secret is the 20-byte ASCII string "12345678901234567890". // Each vector uses 8 digits and a 30-second period. // // Bug this catches: wrong HMAC variant, wrong byte-order in counter, // truncation off-by-one, or library API regression. describe("T-004a: RFC 6238 SHA-1 vectors (8 digits, 30s period)", () => { const sha1Vectors: Array<{ t: number; expected: string }> = [ { t: 59, expected: "94287082" }, { t: 1111111109, expected: "07081804" }, { t: 1111111111, expected: "14050471" }, { t: 1234567890, expected: "89005924" }, { t: 2000000000, expected: "69279037" }, { t: 20000000000, expected: "65353130" }, ]; for (const { t, expected } of sha1Vectors) { it(`t=${t} → ${expected}`, () => { const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, t); expect(code).toBe(expected); }); } }); // T-004a (SHA-256): RFC 6238 Appendix B SHA-256 vectors. // The SHA-256 secret is the 32-byte ASCII string repeated from the same base. describe("T-004a: RFC 6238 SHA-256 vectors (8 digits, 30s period)", () => { const sha256Vectors: Array<{ t: number; expected: string }> = [ { t: 59, expected: "46119246" }, { t: 1111111109, expected: "68084774" }, { t: 1111111111, expected: "67062674" }, { t: 1234567890, expected: "91819424" }, { t: 2000000000, expected: "90698825" }, { t: 20000000000, expected: "77737706" }, ]; for (const { t, expected } of sha256Vectors) { it(`t=${t} → ${expected}`, () => { const code = generateAt(RFC_SECRET_SHA256, "SHA256", 8, 30, t); expect(code).toBe(expected); }); } }); // T-004a (SHA-512): RFC 6238 Appendix B SHA-512 vectors. describe("T-004a: RFC 6238 SHA-512 vectors (8 digits, 30s period)", () => { const sha512Vectors: Array<{ t: number; expected: string }> = [ { t: 59, expected: "90693936" }, { t: 1111111109, expected: "25091201" }, { t: 1111111111, expected: "99943326" }, { t: 1234567890, expected: "93441116" }, { t: 2000000000, expected: "38618901" }, { t: 20000000000, expected: "47863826" }, ]; for (const { t, expected } of sha512Vectors) { it(`t=${t} → ${expected}`, () => { const code = generateAt(RFC_SECRET_SHA512, "SHA512", 8, 30, t); expect(code).toBe(expected); }); } }); // T-004b: period boundary roll. // t=29 is in period-counter 0 (floor(29/30) = 0). // t=30 is in period-counter 1 (floor(30/30) = 1). // They must produce DIFFERENT codes; this pins the "30-second window" // boundary and would catch a truncation error that keeps counter=0 for t=30. it("T-004b: period-boundary roll — t=29 and t=30 produce different codes", () => { const code29 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 29); const code30 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 30); expect(code29).not.toBe(code30); }); // T-004c: parseOtpauthURI correctly extracts algorithm, digits, and period. // This is the real entry point for TOTP entries imported from an authenticator // app. A missing or wrong field silently produces wrong codes. it("T-004c: parseOtpauthURI extracts algorithm, digits, and period", async () => { const { parseOtpauthURI } = await import("./totp"); const uri = "otpauth://totp/Example:user@example.com?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Example"; const params = parseOtpauthURI(uri); expect(params).not.toBeNull(); expect(params!.algorithm).toBe("SHA256"); expect(params!.digits).toBe(8); expect(params!.period).toBe(60); expect(params!.secret).toBeTruthy(); }); // T-004d: non-standard period (60s) produces the correct code. // t=0..59 should all produce the same 6-digit code; t=60 should produce // a different one. This exercises the PCH-01-019 period-threading path. it("T-004d: 60-second period — t=0 and t=59 are in the same window", () => { const code0 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 0); const code59 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 59); const code60 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 60); expect(code0).toBe(code59); // same 60-second window expect(code0).not.toBe(code60); // next window starts at t=60 }); // T-004e: 8-digit codes are exactly 8 characters and are numeric. // A digit-count regression would silently truncate or expand codes. it("T-004e: 8-digit code has exactly 8 numeric characters", () => { const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, 1111111109); expect(code).toMatch(/^\d{8}$/); expect(code).toBe("07081804"); }); });