package auth // RFC 6238 / RFC 4226 golden vectors (docs/plan-self-hosted-oidc-auth-service.md // §13): the RFCs' own tables, not hand-derived. Secret is the ASCII string // "12345678901234567890" (20 bytes), which base32-encodes to // GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ — the same value a developer can paste // into a real authenticator app. No sleeping: every test passes `at`. import ( "encoding/base32" "errors" "testing" "time" ) // totpSecret is the RFC 4226/6238 test secret (ASCII "12345678901234567890"). var totpSecret = []byte("12345678901234567890") func TestSecretBase32Anchor(t *testing.T) { // §13 preamble: the RFC secret's base32 form — the interop anchor. if got := SecretBase32(totpSecret); got != "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" { t.Fatalf("SecretBase32 = %q, want GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", got) } // Round-trip: GenerateSecret → base32 → decode → identical bytes. sec, err := GenerateSecret() if err != nil { t.Fatal(err) } enc := SecretBase32(sec) dec, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(enc) if err != nil || string(dec) != string(sec) { t.Fatalf("secret round-trip failed: %v (enc=%q)", err, enc) } } // ── §13.1 HOTP core (RFC 4226 Appendix D, 6 digits) ───────────────────────── func TestHOTPRFC4226Table(t *testing.T) { cases := []struct { counter int64 code string }{ {0, "755224"}, {1, "287082"}, {2, "359152"}, {3, "969429"}, {4, "338314"}, {5, "254676"}, {6, "287922"}, {7, "162583"}, {8, "399871"}, {9, "520489"}, } for _, tc := range cases { got, err := HOTP(totpSecret, tc.counter, 6) if err != nil { t.Fatalf("HOTP(%d): %v", tc.counter, err) } if got != tc.code { t.Errorf("HOTP counter %d = %s, want %s", tc.counter, got, tc.code) } } } // ── §13.2 TOTP SHA-1 (RFC 6238 Appendix B, 8 digits) ──────────────────────── func TestTOTPRFC6238Table(t *testing.T) { cases := []struct { at time.Time step int64 // expected Counter code string }{ {time.Unix(59, 0), 1, "94287082"}, {time.Unix(1111111109, 0), 37037036, "07081804"}, {time.Unix(1111111111, 0), 37037037, "14050471"}, {time.Unix(1234567890, 0), 41152263, "89005924"}, {time.Unix(2000000000, 0), 66666666, "69279037"}, {time.Unix(20000000000, 0), 666666666, "65353130"}, } for _, tc := range cases { if c := Counter(tc.at, 30*time.Second); c != tc.step { t.Errorf("Counter(%d) = %d, want %d", tc.at.Unix(), c, tc.step) } got, err := HOTP(totpSecret, tc.step, 8) if err != nil { t.Fatalf("HOTP(%d, 8 digits): %v", tc.step, err) } if got != tc.code { t.Errorf("TOTP t=%d: %s, want %s", tc.at.Unix(), got, tc.code) } // 6-digit = last 6 chars of the same-bin 8-digit code (bin mod 10^6 // is implied by bin mod 10^8 when 10^6 | 10^8) — cross-check against // the §13.1 table (C=1 → 287082 ⊂ 94287082). short, _ := HOTP(totpSecret, tc.step, 6) if want := tc.code[len(tc.code)-6:]; short != want { t.Errorf("6-digit C=%d = %s, want last-6 %s", tc.step, short, want) } } } // ── §13.3 Window / step-boundary tests (window=0/1, 6 digits) ─────────────── func mustCode(t *testing.T, c int64) string { t.Helper() code, err := HOTP(totpSecret, c, 6) if err != nil { t.Fatal(err) } return code } func TestTOTPWindowCases(t *testing.T) { // Local alias: mint a code for an exact counter (t bound). code := func(c int64) string { return mustCode(t, c) } // W1 — exact step start: counter 0 verifies at t=0. if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(0, 0)); err != nil || c != 0 { t.Fatalf("W1: got counter %d, err %v; want 0, nil", c, err) } // W2 — end of the same step (t=29 s). if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(29, 0)); err != nil || c != 0 { t.Fatalf("W2: got counter %d, err %v; want 0, nil", c, err) } // W3 — window 0, next step: counter 0 is not a candidate at C=1. if _, err := VerifyWindow(0, totpSecret, code(0), time.Unix(30, 0)); !errors.Is(err, ErrInvalidCode) { t.Fatalf("W3: got %v, want ErrInvalidCode", err) } // W4 — window 1 admits C−1: counter 0 at C=1. if c, err := VerifyWindow(1, totpSecret, code(0), time.Unix(30, 0)); err != nil || c != 0 { t.Fatalf("W4: got counter %d, err %v; want 0, nil", c, err) } // W5 — current-step counter at its own step. if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(30, 0)); err != nil || c != 1 { t.Fatalf("W5: got counter %d, err %v; want 1, nil", c, err) } // W6 — counter 1 at t=59 (C=1) then at t=60 (C=2, counter 1 = C−1). if _, err := VerifyWindow(1, totpSecret, code(1), time.Unix(59, 0)); err != nil { t.Fatalf("W6a: %v", err) } if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(60, 0)); err != nil || c != 1 { t.Fatalf("W6b: got counter %d, err %v; want 1, nil", c, err) } // W7 — pre-issued code one step ahead: counter 1 at t=0 (C=0), window 1. if c, err := VerifyWindow(1, totpSecret, code(1), time.Unix(0, 0)); err != nil || c != 1 { t.Fatalf("W7: got counter %d, err %v; want 1, nil", c, err) } // W8 — counter 0 at t=60 (C=2): outside {1,2,3}. if _, err := VerifyWindow(1, totpSecret, code(0), time.Unix(60, 0)); !errors.Is(err, ErrInvalidCode) { t.Fatalf("W8: got %v, want ErrInvalidCode", err) } } // ── §13.4 Replay / monotonicity tests ─────────────────────────────────────── func TestTOTPReplay(t *testing.T) { // R1 — same code twice: second is ErrReplay (counter 5 ≤ last 5). c5 := mustCode(t, 5) c, err := VerifyMonotonic(0, totpSecret, c5, time.Unix(150, 0)) if err != nil || c != 5 { t.Fatalf("R1a: counter %d, err %v; want 5, nil", c, err) } if _, err := VerifyMonotonic(5, totpSecret, c5, time.Unix(150, 0)); !errors.Is(err, ErrReplay) { t.Fatalf("R1b: got %v, want ErrReplay", err) } // R2 — window admits counter 4 (C−1 at t=150) but monotonicity rejects it. c4 := mustCode(t, 4) if _, err := VerifyMonotonic(5, totpSecret, c4, time.Unix(150, 0)); !errors.Is(err, ErrReplay) { t.Fatalf("R2: got %v, want ErrReplay", err) } // R3 — a genuinely newer counter passes and becomes the new floor. c6 := mustCode(t, 6) c, err = VerifyMonotonic(5, totpSecret, c6, time.Unix(180, 0)) if err != nil || c != 6 { t.Fatalf("R3: counter %d, err %v; want 6, nil", c, err) } // R4 — malformed codes normalize to ErrInvalidCode, never ErrReplay. if _, err := VerifyMonotonic(5, totpSecret, "abc123", time.Unix(180, 0)); !errors.Is(err, ErrInvalidCode) { t.Fatalf("R4a: got %v, want ErrInvalidCode", err) } if _, err := VerifyMonotonic(5, totpSecret, "1234567", time.Unix(180, 0)); !errors.Is(err, ErrInvalidCode) { t.Fatalf("R4b (7 digits): got %v, want ErrInvalidCode", err) } // Grouping variants normalize to the same code (plain Verify — the // grouping/normalization path, independent of any replay floor). if _, err := Verify(totpSecret, "755 224", time.Unix(0, 0)); err != nil { t.Fatalf("grouped code: %v", err) } if _, err := Verify(totpSecret, "755224", time.Unix(0, 0)); err != nil { t.Fatalf("plain code: %v", err) } // Pre-epoch time → negative counters are rejected as invalid. if _, err := Verify(totpSecret, "000000", time.Unix(-60, 0)); !errors.Is(err, ErrInvalidCode) { t.Fatalf("pre-epoch: got %v, want ErrInvalidCode", err) } // Bad digits count. if _, err := HOTP(totpSecret, 1, 5); err == nil { t.Fatal("HOTP digits=5 should error") } }