feat(captcha): stateless Cap protocol Server (challenge/redeem/verify)

This commit is contained in:
Alex Dunmow 2026-07-03 17:21:21 +08:00
parent 7b15ce70d0
commit cbc9ff495f
2 changed files with 196 additions and 0 deletions

124
captcha/captcha.go Normal file
View File

@ -0,0 +1,124 @@
// 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
}
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 } }
// 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(),
}
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}
}
func (s *Server) VerifyToken(token string) bool {
return verifyVerificationToken(s.secret, token)
}
// 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))
}

72
captcha/captcha_test.go Normal file
View File

@ -0,0 +1,72 @@
package captcha
import (
"crypto/sha256"
"encoding/hex"
"strconv"
"testing"
)
// solve reproduces what the widget does, using the package's own prng, so the
// test proves CreateChallenge and Redeem agree end-to-end.
func solve(token string, ch Challenge) []string {
sols := make([]string, ch.C)
for i := 1; i <= ch.C; i++ {
salt := prng(token+strconv.Itoa(i), ch.S)
target := prng(token+strconv.Itoa(i)+"d", ch.D)
for n := 0; ; n++ {
sum := sha256.Sum256([]byte(salt + strconv.Itoa(n)))
if has := hex.EncodeToString(sum[:]); len(has) >= len(target) && has[:len(target)] == target {
sols[i-1] = strconv.Itoa(n)
break
}
}
}
return sols
}
func TestChallengeRedeemRoundTrip(t *testing.T) {
// Small difficulty keeps the test fast.
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, err := s.CreateChallenge()
if err != nil {
t.Fatal(err)
}
sols := solve(cr.Token, cr.Challenge)
res := s.Redeem(cr.Token, sols)
if !res.Success {
t.Fatal("valid solutions rejected")
}
if !s.VerifyToken(res.Token) {
t.Fatal("issued verification token failed VerifyToken")
}
}
func TestRedeemRejects(t *testing.T) {
s := New([]byte("secret"), WithChallenge(5, 8, 2))
cr, _ := s.CreateChallenge()
sols := solve(cr.Token, cr.Challenge)
// wrong solution
bad := append([]string(nil), sols...)
bad[0] = "999999999"
if s.Redeem(cr.Token, bad).Success {
t.Error("wrong solution accepted")
}
// wrong count — use a fresh challenge so this exercises the count check
// itself, not the consumed-nonce path from the wrong-solution redeem above.
crc, _ := s.CreateChallenge()
solsc := solve(crc.Token, crc.Challenge)
if s.Redeem(crc.Token, solsc[:len(solsc)-1]).Success {
t.Error("short solution list accepted")
}
// replay: first redeem consumes the nonce
cr2, _ := s.CreateChallenge()
sols2 := solve(cr2.Token, cr2.Challenge)
if !s.Redeem(cr2.Token, sols2).Success {
t.Fatal("first redeem should succeed")
}
if s.Redeem(cr2.Token, sols2).Success {
t.Error("replayed redeem accepted")
}
}