diff --git a/captcha/captcha.go b/captcha/captcha.go index baf2c47..04b8650 100644 --- a/captcha/captcha.go +++ b/captcha/captcha.go @@ -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 diff --git a/captcha/captcha_test.go b/captcha/captcha_test.go index 4aecb95..2526255 100644 --- a/captcha/captcha_test.go +++ b/captcha/captcha_test.go @@ -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() diff --git a/captcha/tokens.go b/captcha/tokens.go index fb6150a..3cd524f 100644 --- a/captcha/tokens.go +++ b/captcha/tokens.go @@ -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 }