totp.rfc6238.test.ts

6.1 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 } from "otpauth";1213// RFC 6238 Appendix B test secret: ASCII "12345678901234567890"14// Encoded as base32 for the otpauth library. The library also accepts15// the raw ASCII string as base32 for SHA1, but the hex form is portable.16// For SHA256/SHA512 the RFC uses longer secrets — same ASCII pattern repeated.17const RFC_SECRET_SHA1 = "12345678901234567890";18const RFC_SECRET_SHA256 = "12345678901234567890123456789012";19const RFC_SECRET_SHA512 =20  "1234567890123456789012345678901234567890123456789012345678901234";2122// Helper: call otpauth's generate with a pinned Unix timestamp (in seconds).23function generateAt(secret: string, algorithm: string, digits: number, period: number, t: number): string {24  const totp = new TOTP({ secret, algorithm, digits, period });25  return totp.generate({ timestamp: t * 1000 });26}2728describe("T-004: TOTP RFC 6238 known-answer vectors", () => {29  // T-004a: RFC 6238 Appendix B SHA-1 vectors.30  // The secret is the 20-byte ASCII string "12345678901234567890".31  // Each vector uses 8 digits and a 30-second period.32  //33  // Bug this catches: wrong HMAC variant, wrong byte-order in counter,34  // truncation off-by-one, or library API regression.35  describe("T-004a: RFC 6238 SHA-1 vectors (8 digits, 30s period)", () => {36    const sha1Vectors: Array<{ t: number; expected: string }> = [37      { t: 59,           expected: "94287082" },38      { t: 1111111109,   expected: "07081804" },39      { t: 1111111111,   expected: "14050471" },40      { t: 1234567890,   expected: "89005924" },41      { t: 2000000000,   expected: "69279037" },42      { t: 20000000000,  expected: "65353130" },43    ];4445    for (const { t, expected } of sha1Vectors) {46      it(`t=${t} → ${expected}`, () => {47        const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, t);48        expect(code).toBe(expected);49      });50    }51  });5253  // T-004a (SHA-256): RFC 6238 Appendix B SHA-256 vectors.54  // The SHA-256 secret is the 32-byte ASCII string repeated from the same base.55  describe("T-004a: RFC 6238 SHA-256 vectors (8 digits, 30s period)", () => {56    const sha256Vectors: Array<{ t: number; expected: string }> = [57      { t: 59,           expected: "46119246" },58      { t: 1111111109,   expected: "68084774" },59      { t: 1111111111,   expected: "67062674" },60      { t: 1234567890,   expected: "91819424" },61      { t: 2000000000,   expected: "90698825" },62      { t: 20000000000,  expected: "77737706" },63    ];6465    for (const { t, expected } of sha256Vectors) {66      it(`t=${t} → ${expected}`, () => {67        const code = generateAt(RFC_SECRET_SHA256, "SHA256", 8, 30, t);68        expect(code).toBe(expected);69      });70    }71  });7273  // T-004a (SHA-512): RFC 6238 Appendix B SHA-512 vectors.74  describe("T-004a: RFC 6238 SHA-512 vectors (8 digits, 30s period)", () => {75    const sha512Vectors: Array<{ t: number; expected: string }> = [76      { t: 59,           expected: "90693936" },77      { t: 1111111109,   expected: "25091201" },78      { t: 1111111111,   expected: "99943326" },79      { t: 1234567890,   expected: "93441116" },80      { t: 2000000000,   expected: "38618901" },81      { t: 20000000000,  expected: "47863826" },82    ];8384    for (const { t, expected } of sha512Vectors) {85      it(`t=${t} → ${expected}`, () => {86        const code = generateAt(RFC_SECRET_SHA512, "SHA512", 8, 30, t);87        expect(code).toBe(expected);88      });89    }90  });9192  // T-004b: period boundary roll.93  // t=29 is in period-counter 0 (floor(29/30) = 0).94  // t=30 is in period-counter 1 (floor(30/30) = 1).95  // They must produce DIFFERENT codes; this pins the "30-second window"96  // boundary and would catch a truncation error that keeps counter=0 for t=30.97  it("T-004b: period-boundary roll — t=29 and t=30 produce different codes", () => {98    const code29 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 29);99    const code30 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 30, 30);100    expect(code29).not.toBe(code30);101  });102103  // T-004c: parseOtpauthURI correctly extracts algorithm, digits, and period.104  // This is the real entry point for TOTP entries imported from an authenticator105  // app. A missing or wrong field silently produces wrong codes.106  it("T-004c: parseOtpauthURI extracts algorithm, digits, and period", async () => {107    const { parseOtpauthURI } = await import("../../crypto/totp");108    const uri = "otpauth://totp/Example:[email protected]?secret=JBSWY3DPEHPK3PXP&algorithm=SHA256&digits=8&period=60&issuer=Example";109    const params = parseOtpauthURI(uri);110111    expect(params).not.toBeNull();112    expect(params!.algorithm).toBe("SHA256");113    expect(params!.digits).toBe(8);114    expect(params!.period).toBe(60);115    expect(params!.secret).toBeTruthy();116  });117118  // T-004d: non-standard period (60s) produces the correct code.119  // t=0..59 should all produce the same 6-digit code; t=60 should produce120  // a different one. This exercises the PCH-01-019 period-threading path.121  it("T-004d: 60-second period — t=0 and t=59 are in the same window", () => {122    const code0  = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 0);123    const code59 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 59);124    const code60 = generateAt(RFC_SECRET_SHA1, "SHA1", 6, 60, 60);125126    expect(code0).toBe(code59);   // same 60-second window127    expect(code0).not.toBe(code60); // next window starts at t=60128  });129130  // T-004e: 8-digit codes are exactly 8 characters and are numeric.131  // A digit-count regression would silently truncate or expand codes.132  it("T-004e: 8-digit code has exactly 8 numeric characters", () => {133    const code = generateAt(RFC_SECRET_SHA1, "SHA1", 8, 30, 1111111109);134    expect(code).toMatch(/^\d{8}$/);135    expect(code).toBe("07081804");136  });137});