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>
This commit is contained in:
mats committed 2026-09-01 11:42:06 +02:00
1 parent a5d00c9d7e
commit fd9446632d
2 files changed
+388

No files matched your search

+191
View File
@@ -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), "=")
}