feat(captcha): stateless Cap protocol Server (challenge/redeem/verify)
This commit is contained in:
parent
7b15ce70d0
commit
cbc9ff495f
124
captcha/captcha.go
Normal file
124
captcha/captcha.go
Normal 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
72
captcha/captcha_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user