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
|
challengeTTL time.Duration
|
||||||
tokenTTL time.Duration
|
tokenTTL time.Duration
|
||||||
nonces NonceStore
|
nonces NonceStore
|
||||||
|
usedTokens NonceStore
|
||||||
}
|
}
|
||||||
|
|
||||||
type Option func(*Server)
|
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 WithTokenExpiry(d time.Duration) Option { return func(sv *Server) { sv.tokenTTL = d } }
|
||||||
func WithNonceStore(ns NonceStore) Option { return func(sv *Server) { sv.nonces = ns } }
|
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,
|
// 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.
|
// 32-char salts, difficulty 4, 10-min challenge / 5-min token expiry.
|
||||||
func New(secret []byte, opts ...Option) *Server {
|
func New(secret []byte, opts ...Option) *Server {
|
||||||
@ -61,6 +66,7 @@ func New(secret []byte, opts ...Option) *Server {
|
|||||||
challengeTTL: 10 * time.Minute,
|
challengeTTL: 10 * time.Minute,
|
||||||
tokenTTL: 5 * time.Minute,
|
tokenTTL: 5 * time.Minute,
|
||||||
nonces: NewMemoryNonceStore(),
|
nonces: NewMemoryNonceStore(),
|
||||||
|
usedTokens: NewMemoryNonceStore(),
|
||||||
}
|
}
|
||||||
for _, o := range opts {
|
for _, o := range opts {
|
||||||
o(s)
|
o(s)
|
||||||
@ -112,8 +118,27 @@ func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
|
|||||||
return RedeemResponse{Success: true, Token: vt, Expires: expires}
|
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 {
|
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
|
// 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) {
|
func TestRedeemRejects(t *testing.T) {
|
||||||
s := New([]byte("secret"), WithChallenge(5, 8, 2))
|
s := New([]byte("secret"), WithChallenge(5, 8, 2))
|
||||||
cr, _ := s.CreateChallenge()
|
cr, _ := s.CreateChallenge()
|
||||||
|
|||||||
@ -72,25 +72,44 @@ func makeVerificationToken(secret []byte, expiresMs int64) (string, error) {
|
|||||||
return payload + ":" + sign(secret, payload), nil
|
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 == "" {
|
if token == "" {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
i := strings.LastIndex(token, ":")
|
i := strings.LastIndex(token, ":")
|
||||||
if i < 0 {
|
if i < 0 {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
payload, sig := token[:i], token[i+1:]
|
payload, sig := token[:i], token[i+1:]
|
||||||
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
|
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
f := strings.Split(payload, ":")
|
f := strings.Split(payload, ":")
|
||||||
if len(f) != 2 {
|
if len(f) != 2 {
|
||||||
return false
|
return nil
|
||||||
}
|
}
|
||||||
expires, err := strconv.ParseInt(f[1], 10, 64)
|
expires, err := strconv.ParseInt(f[1], 10, 64)
|
||||||
if err != nil {
|
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