// Package auth implements a minimal, dependency-free TOTP (RFC 6238) / // HOTP (RFC 4226) implementation used for per-user two-factor login. // // Only the Go standard library is used so that adding 2FA support does not // require any go.mod/go.sum changes. package auth import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/subtle" "encoding/base32" "encoding/binary" "fmt" "net/url" "strings" "time" ) const ( // totpPeriod is the standard TOTP time step, in seconds. totpPeriod = 30 // totpDigits is the number of digits in the generated code. totpDigits = 6 // secretSize is the number of random bytes used to build a secret, // matching the common 160-bit (20 byte) recommendation for HMAC-SHA1. secretSize = 20 ) // base32Encoding encodes/decodes secrets without padding, using the // standard (uppercase) RFC 4648 base32 alphabet, matching the otpauth // convention used by authenticator apps. var base32Encoding = base32.StdEncoding.WithPadding(base32.NoPadding) // GenerateSecret creates a new random, base32-encoded TOTP secret. func GenerateSecret() (string, error) { buf := make([]byte, secretSize) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("cannot generate random secret: %w", err) } return strings.ToUpper(base32Encoding.EncodeToString(buf)), nil } // generateCodeAtCounter computes the HOTP code for a given counter value, // per RFC 4226. func generateCodeAtCounter(secretBase32 string, counter uint64) (string, error) { secret, err := decodeSecret(secretBase32) if err != nil { return "", err } msg := make([]byte, 8) binary.BigEndian.PutUint64(msg, counter) mac := hmac.New(sha1.New, secret) mac.Write(msg) sum := mac.Sum(nil) offset := sum[len(sum)-1] & 0x0f truncated := binary.BigEndian.Uint32(sum[offset:offset+4]) & 0x7fffffff mod := uint32(1) for i := 0; i < totpDigits; i++ { mod *= 10 } code := truncated % mod return fmt.Sprintf("%0*d", totpDigits, code), nil } // decodeSecret normalizes and base32-decodes a secret string. func decodeSecret(secretBase32 string) ([]byte, error) { clean := strings.ToUpper(strings.TrimSpace(secretBase32)) clean = strings.ReplaceAll(clean, " ", "") secret, err := base32Encoding.DecodeString(clean) if err != nil { return nil, fmt.Errorf("invalid totp secret: %w", err) } return secret, nil } // counterAt returns the TOTP counter value for the given time. func counterAt(t time.Time) uint64 { return uint64(t.Unix() / totpPeriod) } // GenerateCode returns the 6-digit TOTP code for secretBase32 valid at time t. func GenerateCode(secretBase32 string, t time.Time) (string, error) { return generateCodeAtCounter(secretBase32, counterAt(t)) } // Validate checks whether code is a valid TOTP code for secretBase32 at // time t, allowing +/- one 30-second step of clock skew tolerance. The // final comparison is constant-time. func Validate(secretBase32, code string, t time.Time) bool { code = strings.TrimSpace(code) if len(code) != totpDigits { return false } counter := counterAt(t) // Check current step first, then the adjacent steps (skew tolerance). for _, delta := range []int64{0, -1, 1} { c := counter if delta < 0 { if c == 0 { continue } c-- } else if delta > 0 { c++ } expected, err := generateCodeAtCounter(secretBase32, c) if err != nil { return false } if subtle.ConstantTimeCompare([]byte(expected), []byte(code)) == 1 { return true } } return false } // ProvisioningURI builds an otpauth:// URI suitable for encoding into a QR // code and scanning with any standard authenticator app. func ProvisioningURI(secretBase32, accountName, issuer string) string { label := fmt.Sprintf("%s:%s", issuer, accountName) u := url.URL{ Scheme: "otpauth", Host: "totp", Path: "/" + label, } q := url.Values{} q.Set("secret", secretBase32) q.Set("issuer", issuer) q.Set("algorithm", "SHA1") q.Set("digits", fmt.Sprintf("%d", totpDigits)) q.Set("period", fmt.Sprintf("%d", totpPeriod)) u.RawQuery = q.Encode() return u.String() }