totp.rfc6238.test.ts

6.2 KB · raw

1// T-004: TOTP RFC 6238 known-answer vectors2//3// Why this test exists: the TOTP implementation delegates to the `otpauth`4// library. Without independent RFC vectors, a library regression (wrong5// byte-order, wrong HMAC variant, wrong time-step math) ships a wrong 2FA6// code silently — users are locked out of every TOTP-protected account with7// no failing test to catch it. RFC 6238 Appendix B vectors are the canonical8// ground truth for this check.910import { describe, expect, it } from "vitest";11import { TOTP, Secret } from "otpauth";1213// RFC 6238 Appendix B test secrets are ASCII byte strings, not base32.14// otpauth treats plain strings as base32, so we use Secret.fromHex with15// the hex encoding of the ASCII bytes to pass raw bytes unambiguously.16function asciiToHex(s: string): string {17  return Array.from(s).map(c => c.charCodeAt(0).toString(16).padStart(2, "0")).join("");18}1920const RFC_SECRET_SHA1   = Secret.fromHex(asciiToHex("12345678901234567890"));21const RFC_SECRET_SHA256 = Secret.fromHex(asciiToHex("12345678901234567890123456789012"));22const RFC_SECRET_SHA512 = Secret.fromHex(asciiToHex("1234567890123456789012345678901234567890123456789012345678901234"));2324// Helper: call otpauth's generate with a pinned Unix timestamp (in seconds).25function generateAt(secret: Secret, algorithm: string, digits: number, period: number, t: number): string {26  const totp = new TOTP({ secret, algorithm, digits, period });27  return totp.generate({ timestamp: t * 1000 });28}2930describe("T-004: TOTP RFC 6238 known-answer vectors", () => {31  // T-004a: RFC 6238 Appendix B SHA-1 vectors.32  // The secret is the 20-byte ASCII string "12345678901234567890".33  // Each vector uses 8 digits and a 30-second period.34  //35  // Bug this catches: wrong HMAC variant, wrong byte-order in counter,36  // truncation off-by-one, or library API regression.37  describe("T-004a: RFC 6238 SHA-1 vectors (8 digits, 30s period)", () => {38    const sha1Vectors: Array<{ t: number; expected: string }> = [39      { t: 59,           expected: "94287082" },40      { t: 1111111109,   expected: "07081804" },41      { t: 1111111111,   expected: "14050471" },42      { t: 1234567890,   expected: "89005924" },43      { t: 2000000000,   expected: "69279037" },44      { t: 20000000000,  expected: "65353130" },45    ];4647    for (const { t, expected } of sha1Vectors) {48      it(`t=${t} → ${expected}`, () => {49        const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, t);50        expect(code).toBe(expected);51      });52    }53  });5455  // T-004a (SHA-256): RFC 6238 Appendix B SHA-256 vectors.56  // The SHA-256 secret is the 32-byte ASCII string repeated from the same base.57  describe("T-004a: RFC 6238 SHA-256 vectors (8 digits, 30s period)", () => {58    const sha256Vectors: Array<{ t: number; expected: string }> = [59      { t: 59,           expected: "46119246" },60      { t: 1111111109,   expected: "68084774" },61      { t: 1111111111,   expected: "67062674" },62      { t: 1234567890,   expected: "91819424" },63      { t: 2000000000,   expected: "90698825" },64      { t: 20000000000,  expected: "77737706" },65    ];6667    for (const { t, expected } of sha256Vectors) {68      it(`t=${t} → ${expected}`, () => {69        const code = generateAt(RFC_SECRET_SHA256, "SHA256", 8, 30, t);70        expect(code).toBe(expected);71      });72    }73  });7475  // T-004a (SHA-512): RFC 6238 Appendix B SHA-512 vectors.76  describe("T-004a: RFC 6238 SHA-512 vectors (8 digits, 30s period)", () => {77    const sha512Vectors: Array<{ t: number; expected: string }> = [78      { t: 59,           expected: "90693936" },79      { t: 1111111109,   expected: "25091201" },80      { t: 1111111111,   expected: "99943326" },81      { t: 1234567890,   expected: "93441116" },82      { t: 2000000000,   expected: "38618901" },83      { t: 20000000000,  expected: "47863826" },84    ];8586    for (const { t, expected } of sha512Vectors) {87      it(`t=${t} → ${expected}`, () => {88        const code = generateAt(RFC_SECRET_SHA512, "SHA512", 8, 30, t);89        expect(code).toBe(expected);90      });91    }92  });9394  // T-004b: period boundary roll.95  // t=29 is in period-counter 0 (floor(29/30) = 0).96  // t=30 is in period-counter 1 (floor(30/30) = 1).97  // They must produce DIFFERENT codes; this pins the "30-second window"98  // boundary and would catch a truncation error that keeps counter=0 for t=30.99  it("T-004b: period-boundary roll — t=29 and t=30 produce different codes", () => {100    const code29 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 29);101    const code30 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 30);102    expect(code29).not.toBe(code30);103  });104105  // T-004c: parseOtpauthURI correctly extracts algorithm, digits, and period.106  // This is the real entry point for TOTP entries imported from an authenticator107  // app. A missing or wrong field silently produces wrong codes.108  it("T-004c: parseOtpauthURI extracts algorithm, digits, and period", async () => {109    const { parseOtpauthURI } = await import("./totp");110    const uri = "otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Example";111    const params = parseOtpauthURI(uri);112113    expect(params).not.toBeNull();114    expect(params!.algorithm).toBe("SHA256");115    expect(params!.digits).toBe(8);116    expect(params!.period).toBe(60);117    expect(params!.secret).toBeTruthy();118  });119120  // T-004d: non-standard period (60s) produces the correct code.121  // t=0..59 should all produce the same 6-digit code; t=60 should produce122  // a different one. This exercises the PCH-01-019 period-threading path.123  it("T-004d: 60-second period — t=0 and t=59 are in the same window", () => {124    const code0  = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 0);125    const code59 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 59);126    const code60 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 60);127128    expect(code0).toBe(code59);   // same 60-second window129    expect(code0).not.toBe(code60); // next window starts at t=60130  });131132  // T-004e: 8-digit codes are exactly 8 characters and are numeric.133  // A digit-count regression would silently truncate or expand codes.134  it("T-004e: 8-digit code has exactly 8 numeric characters", () => {135    const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, 1111111109);136    expect(code).toMatch(/^\d{8}$/);137    expect(code).toBe("07081804");138  });139});