feat(captcha): single-use verification tokens (replay hardening)

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>
This commit is contained in:
Alex Dunmow 2026-07-04 12:32:52 +08:00
parent 52d7413aa0
commit 8bd92ea5c4
3 changed files with 86 additions and 8 deletions

View File

@ -39,6 +39,7 @@ type Server struct {
challengeTTL time.Duration
tokenTTL time.Duration
nonces NonceStore
usedTokens NonceStore
}
type Option func(*Server)
@ -50,6 +51,10 @@ func WithChallengeExpiry(d time.Duration) Option { return func(sv *Server) { sv.
func WithTokenExpiry(d time.Duration) Option { return func(sv *Server) { sv.tokenTTL = d } }
func WithNonceStore(ns NonceStore) Option { return func(sv *Server) { sv.nonces = ns } }
// WithUsedTokenStore injects the single-use store for redemption tokens
// (mirrors WithNonceStore; a durable cross-process store can implement it later).
func WithUsedTokenStore(ns NonceStore) Option { return func(sv *Server) { sv.usedTokens = ns } }
// New builds a Server. Defaults match the Cap.js reference: 50 sub-challenges,
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
func New(secret []byte, opts ...Option) *Server {
@ -61,6 +66,7 @@ func New(secret []byte, opts ...Option) *Server {
challengeTTL: 10 * time.Minute,
tokenTTL: 5 * time.Minute,
nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(),
}
for _, o := range opts {
o(s)
@ -112,8 +118,27 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
return RedeemResponse{Success: true, Token: vt, Expires: expires}
}
// VerifyToken validates a redemption token and consumes it: a token is
// SINGLE-USE and cannot be replayed within its TTL. The token is burned only on
// a successful verification (valid HMAC + unexpired + not previously used);
// forged/expired tokens never touch the store.
//
// Burn semantics — IMPORTANT for callers: consumption happens here, at the
// captcha layer, BEFORE any downstream business logic runs. So if a caller
// verifies the captcha and THEN rejects the request for a non-captcha reason
// (wrong password, invalid email, form validation error), the token is already
// spent. This is deliberate: it closes the replay window. The Cap widget
// re-issues a fresh token on its next solve, so a re-rendered form/error
// fragment that still carries the widget (with its reset flow) lets the visitor
// re-solve and retry — callers MUST keep the widget present on rejection paths.
func (s *Server) VerifyToken(token string) bool {
return verifyVerificationToken(s.secret, token)
claims := parseVerificationToken(s.secret, token)
if claims == nil {
return false
}
ttl := max(time.Duration(claims.ExpiresMs-nowMs())*time.Millisecond, 0)
// MarkUsed returns false if this token id was already consumed → replay.
return s.usedTokens.MarkUsed(claims.ID, ttl)
}
// VerifyRequest reads the cap-token form field and verifies it. Callers that

View File

@ -42,6 +42,40 @@ func TestChallengeRedeemRoundTrip(t *testing.T) {
}
}
func TestVerifyTokenSingleUse(t *testing.T) {
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge()
res := s.Redeem(cr.Token, solve(cr.Token, cr.Challenge))
if !res.Success {
t.Fatal("valid redeem rejected")
}
// First verification succeeds.
if !s.VerifyToken(res.Token) {
t.Fatal("first VerifyToken should succeed")
}
// Replaying the same token within its TTL must fail — single-use.
if s.VerifyToken(res.Token) {
t.Error("replayed verification token accepted (should be single-use)")
}
// A third replay stays rejected.
if s.VerifyToken(res.Token) {
t.Error("second replay of verification token accepted")
}
// A forged/garbage token never consumes and never verifies.
if s.VerifyToken("not-a-real-token") {
t.Error("garbage token accepted")
}
// An independently issued token is unaffected by another token's burn.
cr2, _ := s.CreateChallenge()
res2 := s.Redeem(cr2.Token, solve(cr2.Token, cr2.Challenge))
if !res2.Success {
t.Fatal("second redeem rejected")
}
if !s.VerifyToken(res2.Token) {
t.Error("fresh independent token rejected after another token was burned")
}
}
func TestRedeemRejects(t *testing.T) {
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge()

View File

@ -72,25 +72,44 @@ func makeVerificationToken(secret []byte, expiresMs int64) (string, error) {
return payload + ":" + sign(secret, payload), nil
}
func verifyVerificationToken(secret []byte, token string) bool {
// 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 false
return nil
}
i := strings.LastIndex(token, ":")
if i < 0 {
return false
return nil
}
payload, sig := token[:i], token[i+1:]
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
return false
return nil
}
f := strings.Split(payload, ":")
if len(f) != 2 {
return false
return nil
}
expires, err := strconv.ParseInt(f[1], 10, 64)
if err != nil {
return false
return nil
}
return expires > nowMs()
if expires <= nowMs() {
return nil
}
return &verificationClaims{ID: f[0], ExpiresMs: expires}
}
func verifyVerificationToken(secret []byte, token string) bool {
return parseVerificationToken(secret, token) != nil
}