core/captcha/captcha.go
Alex Dunmow 8bd92ea5c4 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>
2026-07-04 12:32:52 +08:00

150 lines
5.1 KiB
Go

// Package captcha is a stateless, self-hosted proof-of-work CAPTCHA server
// implementing the Cap.js (trycap.dev) protocol in pure Go. No third-party
// service, no API keys, no database: all state is carried in HMAC-signed
// tokens plus an in-process nonce map. Wire-compatible with the cap-widget.
package captcha
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"strconv"
"time"
)
const captchaTokenField = "cap-token"
type Challenge struct {
C int `json:"c"`
S int `json:"s"`
D int `json:"d"`
}
type ChallengeResponse struct {
Challenge Challenge `json:"challenge"`
Token string `json:"token"`
Expires int64 `json:"expires"`
}
type RedeemResponse struct {
Success bool `json:"success"`
Token string `json:"token,omitempty"`
Expires int64 `json:"expires,omitempty"`
}
type Server struct {
secret []byte
count, size, d int
challengeTTL time.Duration
tokenTTL time.Duration
nonces NonceStore
usedTokens NonceStore
}
type Option func(*Server)
func WithChallenge(c, s, d int) Option {
return func(sv *Server) { sv.count, sv.size, sv.d = c, s, d }
}
func WithChallengeExpiry(d time.Duration) Option { return func(sv *Server) { sv.challengeTTL = 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 } }
// 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 {
s := &Server{
secret: secret,
count: 50,
size: 32,
d: 4,
challengeTTL: 10 * time.Minute,
tokenTTL: 5 * time.Minute,
nonces: NewMemoryNonceStore(),
usedTokens: NewMemoryNonceStore(),
}
for _, o := range opts {
o(s)
}
return s
}
func (s *Server) CreateChallenge() (ChallengeResponse, error) {
buf := make([]byte, 25)
if _, err := rand.Read(buf); err != nil {
return ChallengeResponse{}, err
}
nonce := hex.EncodeToString(buf)
expires := nowMs() + s.challengeTTL.Milliseconds()
token := makeChallengeToken(s.secret, nonce, expires, s.count, s.size, s.d)
return ChallengeResponse{
Challenge: Challenge{C: s.count, S: s.size, D: s.d},
Token: token,
Expires: expires,
}, nil
}
func (s *Server) Redeem(token string, solutions []string) RedeemResponse {
claims := verifyChallengeToken(s.secret, token)
if claims == nil {
return RedeemResponse{Success: false}
}
ttl := max(time.Duration(claims.ExpiresMs-nowMs())*time.Millisecond, 0)
if !s.nonces.MarkUsed(claims.Nonce, ttl) {
return RedeemResponse{Success: false}
}
if len(solutions) != claims.C {
return RedeemResponse{Success: false}
}
for i := 1; i <= claims.C; i++ {
salt := prng(token+strconv.Itoa(i), claims.S)
target := prng(token+strconv.Itoa(i)+"d", claims.D)
sum := sha256.Sum256([]byte(salt + solutions[i-1]))
h := hex.EncodeToString(sum[:])
if len(h) < len(target) || h[:len(target)] != target {
return RedeemResponse{Success: false}
}
}
expires := nowMs() + s.tokenTTL.Milliseconds()
vt, err := makeVerificationToken(s.secret, expires)
if err != nil {
return RedeemResponse{Success: false}
}
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 {
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
// have already parsed the form (typical for HTMX handlers) can rely on
// FormValue; it triggers ParseMultipartForm/ParseForm lazily otherwise.
func (s *Server) VerifyRequest(r *http.Request) bool {
return s.VerifyToken(r.FormValue(captchaTokenField))
}