diff --git a/captcha/captcha.go b/captcha/captcha.go new file mode 100644 index 0000000..baf2c47 --- /dev/null +++ b/captcha/captcha.go @@ -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)) +} diff --git a/captcha/captcha_test.go b/captcha/captcha_test.go new file mode 100644 index 0000000..4aecb95 --- /dev/null +++ b/captcha/captcha_test.go @@ -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") + } +}