package captcha import ( "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/hex" "strconv" "strings" "time" ) // nowMs is a seam so tests can control time. Millisecond epoch to match the // reference (JS Date.now()). var nowMs = func() int64 { return time.Now().UnixMilli() } type challengeClaims struct { Nonce string ExpiresMs int64 C, S, D int } func sign(secret []byte, payload string) string { m := hmac.New(sha256.New, secret) m.Write([]byte(payload)) return hex.EncodeToString(m.Sum(nil)) } // makeChallengeToken encodes challenge params as "nonce:expires:c:s:d:hmac". func makeChallengeToken(secret []byte, nonce string, expiresMs int64, c, s, d int) string { payload := nonce + ":" + strconv.FormatInt(expiresMs, 10) + ":" + strconv.Itoa(c) + ":" + strconv.Itoa(s) + ":" + strconv.Itoa(d) return payload + ":" + sign(secret, payload) } func verifyChallengeToken(secret []byte, token string) *challengeClaims { if token == "" { return nil } i := strings.LastIndex(token, ":") if i < 0 { return nil } payload, sig := token[:i], token[i+1:] if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) { return nil } f := strings.Split(payload, ":") if len(f) != 5 { return nil } expires, err1 := strconv.ParseInt(f[1], 10, 64) c, err2 := strconv.Atoi(f[2]) s, err3 := strconv.Atoi(f[3]) d, err4 := strconv.Atoi(f[4]) if err1 != nil || err2 != nil || err3 != nil || err4 != nil { return nil } if expires <= nowMs() { return nil } return &challengeClaims{Nonce: f[0], ExpiresMs: expires, C: c, S: s, D: d} } // makeVerificationToken creates "random:expires:hmac". func makeVerificationToken(secret []byte, expiresMs int64) (string, error) { buf := make([]byte, 15) if _, err := rand.Read(buf); err != nil { return "", err } payload := hex.EncodeToString(buf) + ":" + strconv.FormatInt(expiresMs, 10) return payload + ":" + sign(secret, payload), nil } // verificationClaims is the parsed, cryptographically-verified content of a // redemption token. ID is the random per-token identifier (unique per Redeem), // used as the single-use key in the used-token store. type verificationClaims struct { ID string ExpiresMs int64 } // parseVerificationToken validates the HMAC + expiry and returns the claims, or // nil if the token is empty, malformed, forged, or expired. This is a purely // stateless check — it does NOT consume the token (see Server.VerifyToken for // the single-use burn). func parseVerificationToken(secret []byte, token string) *verificationClaims { if token == "" { return nil } i := strings.LastIndex(token, ":") if i < 0 { return nil } payload, sig := token[:i], token[i+1:] if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) { return nil } f := strings.Split(payload, ":") if len(f) != 2 { return nil } expires, err := strconv.ParseInt(f[1], 10, 64) if err != nil { return nil } if expires <= nowMs() { return nil } return &verificationClaims{ID: f[0], ExpiresMs: expires} } func verifyVerificationToken(secret []byte, token string) bool { return parseVerificationToken(secret, token) != nil }