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), "=") }