core/captcha/captcha.go

125 lines
3.7 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
}
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))
}