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:
parent
52d7413aa0
commit
8bd92ea5c4
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user