package security import ( "crypto/hmac" "crypto/rand" "crypto/sha1" "encoding/base32" "encoding/binary" "fmt" "strings" "time" ) // GenerateTOTPSecret returns a base32 secret without padding. func GenerateTOTPSecret() (string, error) { b := make([]byte, 20) if _, err := rand.Read(b); err != nil { return "", err } enc := base32.StdEncoding.WithPadding(base32.NoPadding) return enc.EncodeToString(b), nil } // VerifyTOTP verifies a 6-digit token with ±1 step skew (30s step). func VerifyTOTP(secretBase32, code string, now time.Time) bool { code = strings.ReplaceAll(code, " ", "") if len(code) != 6 { return false } sec, err := decodeBase32NoPad(secretBase32) if err != nil { return false } t := now.Unix() / 30 for _, drift := range []int64{-1, 0, 1} { if hotp(sec, uint64(t+drift)) == code { return true } } return false } func hotp(secret []byte, counter uint64) string { var buf [8]byte binary.BigEndian.PutUint64(buf[:], counter) mac := hmac.New(sha1.New, secret) mac.Write(buf[:]) sum := mac.Sum(nil) off := sum[len(sum)-1] & 0x0f bin := (int(sum[off])&0x7f)<<24 | (int(sum[off+1])&0xff)<<16 | (int(sum[off+2])&0xff)<<8 | (int(sum[off+3]) & 0xff) otp := bin % 1000000 return fmt.Sprintf("%06d", otp) } func decodeBase32NoPad(s string) ([]byte, error) { s = strings.ToUpper(strings.ReplaceAll(s, " ", "")) enc := base32.StdEncoding.WithPadding(base32.NoPadding) return enc.DecodeString(s) }