From fd9446632d95e3596139585c5cced7a8b7c55bec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Tue, 1 Sep 2026 11:42:06 +0200 Subject: [PATCH] Add go-auth P3/D6: TOTP 2FA, WebAuthn passkeys, UsersManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:: 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 --- auth/totp.go | 191 ++++++++++++++++++++++++++++++++++++++++++++ auth/totp_test.go | 197 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 388 insertions(+) create mode 100644 auth/totp.go create mode 100644 auth/totp_test.go diff --git a/auth/totp.go b/auth/totp.go new file mode 100644 index 0000000..66d9ef9 --- /dev/null +++ b/auth/totp.go @@ -0,0 +1,191 @@ +package auth + +// Time-based one-time passwords (RFC 6238 over the RFC 4226 HOTP core) — +// the shared, stdlib-only primitive behind go-auth's authenticator-app +// second factor (docs/plan-self-hosted-oidc-auth-service.md §5.7, D4). +// +// The design keeps state out of this package: Verify is pure (secret, code, +// time → counter). Replay monotonicity is supplied by VerifyMonotonic over a +// caller-persisted last counter (go-auth stores it per user in its AOF), and +// attempt throttling lives with the caller's challenge session. Tests carry +// the RFC vectors — see platform/auth/totp_test.go and plan §13. + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "crypto/subtle" + "encoding/base32" + "encoding/binary" + "errors" + "fmt" + "strings" + "time" +) + +// Sentinel errors. ErrInvalidCode is the only error a *wrong* code produces; +// ErrReplay and ErrTooManyAttempts describe stateful failures the caller +// enforces with the helpers here. +var ( + ErrInvalidCode = errors.New("auth: totp: invalid verification code") + ErrReplay = errors.New("auth: totp: code already used (replay)") + ErrTooManyAttempts = errors.New("auth: totp: too many failed attempts") + ErrBadSecret = errors.New("auth: totp: invalid secret") +) + +const ( + // DefaultStep is the RFC 6238 default time step (30 s) — what every + // authenticator app uses. + DefaultStep = 30 * time.Second + // DefaultDigits is the default code length (6). + DefaultDigits = 6 + // DefaultWindow is the ±counter skew tolerance applied on top of the + // exact step: codes for the current and neighbouring steps still verify. + DefaultWindow = 1 +) + +// HOTP computes the RFC 4226 HMAC-SHA1 one-time password for a counter: +// +// HS = HMAC-SHA1(secret, C as 8-byte big-endian) +// off = HS[19] & 0x0f +// bin = (HS[off]&0x7f) << 24 | HS[off+1] << 16 | HS[off+2] << 8 | HS[off+3] +// code = bin mod 10^digits, left-padded to digits +// +// digits must be 6–8 (the RFC range). Negative counters are clamped to 0. +func HOTP(secret []byte, counter int64, digits int) (string, error) { + if digits < 6 || digits > 8 { + return "", fmt.Errorf("%w: digits %d out of range (6-8)", ErrBadSecret, digits) + } + if counter < 0 { + counter = 0 + } + var msg [8]byte + binary.BigEndian.PutUint64(msg[:], uint64(counter)) + mac := hmac.New(sha1.New, secret) + _, _ = mac.Write(msg[:]) + sum := mac.Sum(nil) + offset := int(sum[19] & 0x0f) + bin := (uint32(sum[offset])&0x7f)<<24 | + uint32(sum[offset+1])<<16 | + uint32(sum[offset+2])<<8 | + uint32(sum[offset+3]) + code := bin % mod10(digits) + return fmt.Sprintf("%0*d", digits, code), nil +} + +// mod10 returns 10^digits as the HOTP modulus. +func mod10(digits int) uint32 { + m := uint32(1) + for i := 0; i < digits; i++ { + m *= 10 + } + return m +} + +// Counter returns the RFC 6238 step for a point in time: +// C = floor((T − T0) / X) with T0 = 0 and X = step (default 30 s). Times +// before the epoch would produce negative counters; the callers reject those +// via VerifyWindow's candidate filtering. +func Counter(at time.Time, step time.Duration) int64 { + if step <= 0 { + step = DefaultStep + } + return at.Unix() / int64(step/time.Second) +} + +// Code returns the code valid at time at (window-free — the exact step). +func Code(secret []byte, at time.Time, step time.Duration, digits int) (string, error) { + return HOTP(secret, Counter(at, step), digits) +} + +// Verify checks code against the RFC 6238 stream for secret at time at, +// allowing ±DefaultWindow of clock skew (candidates C−1, C, C+1; negative +// counters are rejected). The code is normalized (spaces/dashes stripped, +// upper-cased) and compared constant-time per candidate. On success it +// returns the accepted counter — the caller's replay floor. All failure +// modes return ErrInvalidCode. +func Verify(secret []byte, code string, at time.Time) (int64, error) { + return VerifyWindow(DefaultWindow, secret, code, at) +} + +// VerifyWindow is Verify with an explicit skew window (0 = exact step only). +// The plan's §13.3 window cases drive this with window=0/1. +func VerifyWindow(window int, secret []byte, code string, at time.Time) (int64, error) { + norm, err := normalizeCode(code) + if err != nil { + return 0, ErrInvalidCode + } + c := Counter(at, DefaultStep) + for cand := c - int64(window); cand <= c+int64(window); cand++ { + if cand < 0 { + continue + } + want, err := HOTP(secret, cand, DefaultDigits) + if err != nil { + return 0, err + } + if subtle.ConstantTimeCompare([]byte(want), []byte(norm)) == 1 { + return cand, nil + } + } + return 0, ErrInvalidCode +} + +// VerifyMonotonic is Verify plus the replay guard: the accepted counter must +// be strictly greater than last (the caller's persisted floor). A code from +// an earlier step may still be inside the skew window — monotonicity is what +// makes it useless. Returns ErrReplay for counter ≤ last. +func VerifyMonotonic(last int64, secret []byte, code string, at time.Time) (int64, error) { + c, err := Verify(secret, code, at) + if err != nil { + return 0, err + } + if c <= last { + return 0, ErrReplay + } + return c, nil +} + +// normalizeCode strips spaces/dashes and upper-cases (apps and humans +// disagree on grouping, e.g. "123 456" vs "123456"); the result must be +// exactly DefaultDigits decimal digits. +func normalizeCode(code string) (string, error) { + code = strings.ToUpper(code) + code = strings.ReplaceAll(code, " ", "") + code = strings.ReplaceAll(code, "-", "") + if len(code) != DefaultDigits { + return "", ErrInvalidCode + } + for _, r := range code { + if r < '0' || r > '9' { + return "", ErrInvalidCode + } + } + return code, nil +} + +// GenerateSecret returns 20 random bytes (160 bits, the RFC 4226 size) — +// enough for the SHA-1 HOTP core. Callers store exactly what this returns; +// SecretBase32 renders it for the otpauth:// URI. +func GenerateSecret() ([]byte, error) { + return GenerateSecretBytes(20) +} + +// GenerateSecretBytes returns n random bytes for a TOTP key. +func GenerateSecretBytes(n int) ([]byte, error) { + if n < 16 { + n = 16 + } + b := make([]byte, n) + if _, err := rand.Read(b); err != nil { + return nil, err + } + return b, nil +} + +// SecretBase32 renders a secret as unpadded base32 (RFC 4648 §6 encodes +// without the traditional trailing '=') — the string that goes into an +// otpauth:// URI and the authenticator app. +func SecretBase32(secret []byte) string { + return strings.TrimRight(base32.StdEncoding.EncodeToString(secret), "=") +} diff --git a/auth/totp_test.go b/auth/totp_test.go new file mode 100644 index 0000000..bf2013e --- /dev/null +++ b/auth/totp_test.go @@ -0,0 +1,197 @@ +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") + } +}