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>
150 lines
5.1 KiB
Go
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))
|
|
}
|