// Package totp implements RFC 6238 time-based one-time passwords (TOTP) on // top of RFC 4226 HOTP, using HMAC-SHA1, 6 digits, and a 30-second step. // // It is deliberately dependency-free (standard library only) so it can be // shared between the admin backend two-factor login and the user-center 2FA // (doc/05) without pulling in a third-party OTP package. package totp import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "crypto/subtle" "encoding/base32" "encoding/binary" "fmt" "net/url" "strings" "time" ) const ( // Digits is the number of decimal digits in a generated code. Digits = 6 // Period is the time step length. Period = 30 * time.Second // secretBytes is the length of a freshly generated shared secret. 20 bytes // (160 bits) matches the RFC 4226 recommendation and the SHA-1 block size. secretBytes = 20 ) // b32 is the no-padding, upper-case Base32 encoding used for OTP secrets // (the alphabet authenticator apps expect). var b32 = base32.StdEncoding.WithPadding(base32.NoPadding) // GenerateSecret returns a new cryptographically random Base32-encoded secret. func GenerateSecret() (string, error) { buf := make([]byte, secretBytes) if _, err := rand.Read(buf); err != nil { return "", fmt.Errorf("totp.GenerateSecret: %w", err) } return b32.EncodeToString(buf), nil } // Code returns the TOTP code for the given Base32 secret at time t. func Code(secret string, t time.Time) (string, error) { key, err := decodeSecret(secret) if err != nil { return "", err } counter := uint64(t.UTC().Unix()) / uint64(Period.Seconds()) return hotp(key, counter), nil } // Validate reports whether code is a valid TOTP for secret at time t, allowing // ±skew steps of clock drift (skew=1 accepts the previous, current, and next // 30-second windows). Comparison is constant-time. func Validate(secret, code string, t time.Time, skew int) bool { key, err := decodeSecret(secret) if err != nil { return false } code = strings.TrimSpace(code) if len(code) != Digits { return false } if skew < 0 { skew = 0 } base := int64(uint64(t.UTC().Unix()) / uint64(Period.Seconds())) for d := -skew; d <= skew; d++ { c := base + int64(d) if c < 0 { continue } want := hotp(key, uint64(c)) if subtle.ConstantTimeCompare([]byte(want), []byte(code)) == 1 { return true } } return false } // ProvisioningURI builds an otpauth:// URI suitable for rendering as a QR code // or pasting into an authenticator app. func ProvisioningURI(secret, account, issuer string) string { label := url.PathEscape(issuer + ":" + account) q := url.Values{} q.Set("secret", secret) q.Set("issuer", issuer) q.Set("algorithm", "SHA1") q.Set("digits", fmt.Sprintf("%d", Digits)) q.Set("period", fmt.Sprintf("%d", int(Period.Seconds()))) return "otpauth://totp/" + label + "?" + q.Encode() } // decodeSecret accepts a Base32 secret with or without padding/whitespace. func decodeSecret(secret string) ([]byte, error) { s := strings.ToUpper(strings.TrimSpace(secret)) s = strings.ReplaceAll(s, " ", "") s = strings.TrimRight(s, "=") key, err := b32.DecodeString(s) if err != nil { return nil, fmt.Errorf("totp: invalid secret: %w", err) } if len(key) == 0 { return nil, fmt.Errorf("totp: empty secret") } return key, nil } // hotp implements RFC 4226 HOTP with dynamic truncation. func hotp(key []byte, counter uint64) string { var buf [8]byte binary.BigEndian.PutUint64(buf[:], counter) mac := hmac.New(sha1.New, key) mac.Write(buf[:]) sum := mac.Sum(nil) offset := sum[len(sum)-1] & 0x0f value := (uint32(sum[offset]&0x7f) << 24) | (uint32(sum[offset+1]) << 16) | (uint32(sum[offset+2]) << 8) | uint32(sum[offset+3]) mod := uint32(1) for i := 0; i < Digits; i++ { mod *= 10 } return fmt.Sprintf("%0*d", Digits, value%mod) }