VerifyToken now consumes a redemption token on its first successful verify: a replayed token within its 5-min TTL is rejected. Mirrors the existing single-use challenge-nonce store with a per-Server used-token store keyed on the token's random id, same GC/expiry approach (entries live only for the remaining TTL). The token is burned only on a successful verify (valid HMAC + unexpired + unused); forged/expired tokens never touch the store. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
116 lines
3.1 KiB
Go
116 lines
3.1 KiB
Go
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
|
|
}
|