Files

124 lines
3.9 KiB
Go
Raw Permalink Normal View History

2026-08-09 18:03:09 +01:00
// Package totp implements TOTP (RFC 6238, built on HOTP RFC 4226) —
// hand-rolled on stdlib crypto/hmac + crypto/sha1 + encoding/base32, no
// third-party OTP library. Correctness is checked against RFC 6238's own
// published test vectors (Appendix B) in the test suite, not just "it
// produces a 6-digit number."
package totp
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha1"
"encoding/base32"
"fmt"
"math"
"net/url"
"strconv"
"strings"
"time"
)
const (
period = 30 // seconds per RFC 6238's recommended default
digits = 6
)
// GenerateSecret creates a new random 20-byte (160-bit) secret, base32
// encoded — the standard size real authenticator apps (Google Authenticator,
// Authy, etc.) expect.
func GenerateSecret() (string, error) {
b := make([]byte, 20)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generating TOTP secret: %w", err)
}
return base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b), nil
}
// Generate computes the TOTP code for secret at the given time — exported
// primarily so the test suite can check RFC 6238's published vectors, which
// specify exact codes for exact timestamps.
func Generate(secret string, at time.Time) (string, error) {
key, err := decodeSecret(secret)
if err != nil {
return "", err
}
counter := uint64(at.Unix() / period)
return hotp(key, counter), nil
}
// Validate checks code against the current time step and, per common TOTP
// practice, the one step before and after (±30s) to tolerate minor clock
// drift between server and authenticator app.
func Validate(secret, code string) (bool, error) {
key, err := decodeSecret(secret)
if err != nil {
return false, err
}
code = strings.TrimSpace(code)
now := time.Now().UTC()
counter := uint64(now.Unix() / period)
for _, skew := range []int64{0, -1, 1} {
c := hotp(key, uint64(int64(counter)+skew))
if c == code {
return true, nil
}
}
return false, nil
}
// hotp implements RFC 4226 HOTP — the counter-based primitive TOTP wraps.
func hotp(key []byte, counter uint64) string {
msg := make([]byte, 8)
for i := 7; i >= 0; i-- {
msg[i] = byte(counter & 0xff)
counter >>= 8
}
mac := hmac.New(sha1.New, key)
mac.Write(msg)
sum := mac.Sum(nil)
offset := sum[len(sum)-1] & 0x0f
binCode := (uint32(sum[offset])&0x7f)<<24 |
(uint32(sum[offset+1])&0xff)<<16 |
(uint32(sum[offset+2])&0xff)<<8 |
(uint32(sum[offset+3]) & 0xff)
mod := uint32(math.Pow10(digits))
return fmt.Sprintf("%0*d", digits, binCode%mod)
}
func decodeSecret(secret string) ([]byte, error) {
secret = strings.ToUpper(strings.TrimSpace(secret))
secret = strings.ReplaceAll(secret, " ", "")
key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(secret)
if err != nil {
return nil, fmt.Errorf("decoding TOTP secret: %w", err)
}
return key, nil
}
// ProvisioningURI builds the otpauth:// URI real authenticator apps use to
// set up an account — either scanned as a QR code (QR rendering itself is
// deliberately not implemented here, see package doc note below) or
// manually entered, since every mainstream authenticator app supports
// typing in the secret directly as a fallback to scanning.
//
// Note: this package does not generate a QR code image. Real QR encoding
// (Reed-Solomon error correction, matrix placement) is a substantial
// sub-project of its own with little shared surface with TOTP itself —
// deferred rather than half-implemented. The webmail MFA setup page
// displays this URI as both a copyable string and (optionally, via a
// client-side QR library the frontend can add later) a scannable code.
func ProvisioningURI(secret, accountEmail, issuer string) string {
v := url.Values{}
v.Set("secret", secret)
v.Set("issuer", issuer)
v.Set("algorithm", "SHA1")
v.Set("digits", strconv.Itoa(digits))
v.Set("period", strconv.Itoa(period))
label := url.PathEscape(issuer) + ":" + url.PathEscape(accountEmail)
return fmt.Sprintf("otpauth://totp/%s?%s", label, v.Encode())
}