Files
platform/auth/totp.go
T
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

192 lines
6.3 KiB
Go
Raw 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
// 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), "=")
}