Files
mats fd9446632d Add go-auth P3/D6: TOTP 2FA, WebAuthn passkeys, UsersManager
The authenticator-app second factor (D4, plan §5.7): shared RFC 4226/6238
primitive in platform/auth/totp.go (±1 window, monotonic replay floor, §13
RFC vectors), per-user AES-GCM-wrapped secrets under AUTH_TOTP_WRAP_KEY,
hashed one-shot recovery codes, and the password → /totp challenge (or
recovery) → session browser flow with first-login enrollment (otpauth QR via
vendored qrcode-generator, two-code confirm, once-only recovery reveal).
AUTH_REQUIRE_TOTP defaults on; per-challenge throttling (R5). Covered E2E by
internal/oidc/totp_flow_test.go.

Passkeys (D6, plan §5.8): go-auth is its own WebAuthn RP — hand-rolled
CBOR/COSE subset parser (stdlib only), register/login ceremonies with
attestation:none, per-request RP ID + origin derived from the issuer host,
AOF-backed cred:<user>:<credID> store. The /admin UsersManager (invite,
disable, reset password, list/rename/delete passkeys, enroll in-browser) is
session + admin-role gated. WebAuthn vectors + ceremony negatives in
internal/webauthn + webauthn_flow_test.go.

Also: bare-host GET / now redirects to /login (the issuer root otherwise
404s in a browser).

Generated with Codebuff 🤖
Co-Authored-By: Codebuff <noreply@codebuff.com>
2026-09-01 11:42:06 +02:00

198 lines
7.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}