Compare commits

...

5 Commits

Author SHA1 Message Date
Alex Dunmow
5880aa21ee test: modernize idiom in wasmguest tests (slices.Contains, any, new(v))
- driver_test.go: use slices.Contains instead of a hand-rolled loop
- sqlcgen_test.go: interface{} -> any in the generated-style DBTX shim
- caps_roundtrip_test.go: new(idParent.String()) instead of proto.String
  for the pointer literal

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:51:08 +08:00
Alex Dunmow
cbc9ff495f feat(captcha): stateless Cap protocol Server (challenge/redeem/verify) 2026-07-03 17:24:19 +08:00
Alex Dunmow
7b15ce70d0 feat(captcha): in-process nonce store for single-use enforcement 2026-07-03 17:16:19 +08:00
Alex Dunmow
f5cbb56df7 feat(captcha): HMAC-signed stateless challenge + verification tokens 2026-07-03 17:13:24 +08:00
Alex Dunmow
84723991cd feat(captcha): FNV-1a + xorshift32 PRNG matching Cap.js widget
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 17:12:04 +08:00
11 changed files with 485 additions and 10 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")
}
}

39
captcha/nonce.go Normal file
View File

@ -0,0 +1,39 @@
package captcha
import (
"sync"
"time"
)
// NonceStore tracks used challenge nonces for single-use enforcement. The
// default MemoryNonceStore is per-process (no DB) — sufficient for the 5a
// model. A durable cross-process store (5b) can implement this interface later.
type NonceStore interface {
// MarkUsed records nonce; returns true on first use, false if already seen.
MarkUsed(nonce string, ttl time.Duration) bool
}
type MemoryNonceStore struct {
mu sync.Mutex
used map[string]time.Time
}
func NewMemoryNonceStore() *MemoryNonceStore {
return &MemoryNonceStore{used: make(map[string]time.Time)}
}
func (m *MemoryNonceStore) MarkUsed(nonce string, ttl time.Duration) bool {
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for k, exp := range m.used {
if !exp.After(now) {
delete(m.used, k)
}
}
if _, seen := m.used[nonce]; seen {
return false
}
m.used[nonce] = now.Add(ttl)
return true
}

27
captcha/nonce_test.go Normal file
View File

@ -0,0 +1,27 @@
package captcha
import (
"testing"
"time"
)
func TestMemoryNonceStoreSingleUse(t *testing.T) {
st := NewMemoryNonceStore()
if !st.MarkUsed("n1", time.Minute) {
t.Fatal("first use should return true")
}
if st.MarkUsed("n1", time.Minute) {
t.Fatal("second use should return false")
}
if !st.MarkUsed("n2", time.Minute) {
t.Fatal("distinct nonce should return true")
}
}
func TestMemoryNonceStoreExpiry(t *testing.T) {
st := NewMemoryNonceStore()
st.MarkUsed("n1", -time.Second) // already expired
if !st.MarkUsed("n1", time.Minute) {
t.Fatal("expired nonce should be reusable")
}
}

36
captcha/prng.go Normal file
View File

@ -0,0 +1,36 @@
package captcha
import (
"fmt"
"strings"
)
const prngMask = 0xFFFFFFFF
// prng generates a deterministic lowercase-hex string of length chars from
// seed, matching the Cap.js widget PRNG exactly: FNV-1a seeds a 32-bit state,
// then xorshift32 produces the hex output. Must stay byte-for-byte compatible
// with the widget or solutions will never verify.
func prng(seed string, length int) string {
state := fnv1a(seed)
var b strings.Builder
for b.Len() < length {
state ^= (state << 13) & prngMask
state ^= state >> 17
state ^= (state << 5) & prngMask
state &= prngMask
fmt.Fprintf(&b, "%08x", state)
}
return b.String()[:length]
}
// fnv1a returns the 32-bit FNV-1a hash of s. Iterates over runes (code points)
// to match the JS/Python reference (which use charCodeAt / ord).
func fnv1a(s string) uint32 {
var h uint32 = 2166136261
for _, ch := range s {
h ^= uint32(ch)
h = (h + (h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24)) & prngMask
}
return h
}

37
captcha/prng_test.go Normal file
View File

@ -0,0 +1,37 @@
package captcha
import "testing"
func TestPrngKnownVectors(t *testing.T) {
cases := []struct {
seed string
length int
want string
}{
{"hello", 8, "eb492c6e"},
{"hello", 16, "eb492c6e1655ea8c"},
{"test", 32, "9c7ca3730a4a283aa6e4bc1c1d83b14f"},
{"a", 8, "441aaeb8"},
{"z", 8, "da40eb31"},
}
for _, c := range cases {
if got := prng(c.seed, c.length); got != c.want {
t.Errorf("prng(%q,%d)=%q want %q", c.seed, c.length, got, c.want)
}
}
}
func TestPrngLengthAndDeterminism(t *testing.T) {
for _, n := range []int{1, 8, 16, 32, 64} {
if got := prng("x", n); len(got) != n {
t.Errorf("len(prng(\"x\",%d))=%d want %d", n, len(got), n)
}
}
if prng("seed1", 16) == prng("seed2", 16) {
t.Error("different seeds produced identical output")
}
a, b := prng("test", 16), prng("test", 16)
if a != b {
t.Error("prng not deterministic")
}
}

96
captcha/tokens.go Normal file
View File

@ -0,0 +1,96 @@
package captcha
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
// nowMs is a seam so tests can control time. Millisecond epoch to match the
// reference (JS Date.now()).
var nowMs = func() int64 { return time.Now().UnixMilli() }
type challengeClaims struct {
Nonce string
ExpiresMs int64
C, S, D int
}
func sign(secret []byte, payload string) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte(payload))
return hex.EncodeToString(m.Sum(nil))
}
// makeChallengeToken encodes challenge params as "nonce:expires:c:s:d:hmac".
func makeChallengeToken(secret []byte, nonce string, expiresMs int64, c, s, d int) string {
payload := nonce + ":" + strconv.FormatInt(expiresMs, 10) + ":" +
strconv.Itoa(c) + ":" + strconv.Itoa(s) + ":" + strconv.Itoa(d)
return payload + ":" + sign(secret, payload)
}
func verifyChallengeToken(secret []byte, token string) *challengeClaims {
if token == "" {
return nil
}
i := strings.LastIndex(token, ":")
if i < 0 {
return nil
}
payload, sig := token[:i], token[i+1:]
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
return nil
}
f := strings.Split(payload, ":")
if len(f) != 5 {
return nil
}
expires, err1 := strconv.ParseInt(f[1], 10, 64)
c, err2 := strconv.Atoi(f[2])
s, err3 := strconv.Atoi(f[3])
d, err4 := strconv.Atoi(f[4])
if err1 != nil || err2 != nil || err3 != nil || err4 != nil {
return nil
}
if expires <= nowMs() {
return nil
}
return &challengeClaims{Nonce: f[0], ExpiresMs: expires, C: c, S: s, D: d}
}
// makeVerificationToken creates "random:expires:hmac".
func makeVerificationToken(secret []byte, expiresMs int64) (string, error) {
buf := make([]byte, 15)
if _, err := rand.Read(buf); err != nil {
return "", err
}
payload := hex.EncodeToString(buf) + ":" + strconv.FormatInt(expiresMs, 10)
return payload + ":" + sign(secret, payload), nil
}
func verifyVerificationToken(secret []byte, token string) bool {
if token == "" {
return false
}
i := strings.LastIndex(token, ":")
if i < 0 {
return false
}
payload, sig := token[:i], token[i+1:]
if !hmac.Equal([]byte(sig), []byte(sign(secret, payload))) {
return false
}
f := strings.Split(payload, ":")
if len(f) != 2 {
return false
}
expires, err := strconv.ParseInt(f[1], 10, 64)
if err != nil {
return false
}
return expires > nowMs()
}

48
captcha/tokens_test.go Normal file
View File

@ -0,0 +1,48 @@
package captcha
import "testing"
func TestChallengeTokenRoundTrip(t *testing.T) {
secret := []byte("s3cr3t")
tok := makeChallengeToken(secret, "abc123", nowMs()+60_000, 50, 32, 4)
claims := verifyChallengeToken(secret, tok)
if claims == nil {
t.Fatal("valid token rejected")
}
if claims.Nonce != "abc123" || claims.C != 50 || claims.S != 32 || claims.D != 4 {
t.Fatalf("bad claims: %+v", claims)
}
}
func TestChallengeTokenTamperAndExpiry(t *testing.T) {
secret := []byte("s3cr3t")
tok := makeChallengeToken(secret, "abc123", nowMs()+60_000, 50, 32, 4)
if verifyChallengeToken([]byte("wrong"), tok) != nil {
t.Error("token accepted under wrong secret")
}
if verifyChallengeToken(secret, tok+"x") != nil {
t.Error("tampered token accepted")
}
expired := makeChallengeToken(secret, "abc123", nowMs()-1, 50, 32, 4)
if verifyChallengeToken(secret, expired) != nil {
t.Error("expired token accepted")
}
}
func TestVerificationToken(t *testing.T) {
secret := []byte("s3cr3t")
vt, err := makeVerificationToken(secret, nowMs()+60_000)
if err != nil {
t.Fatal(err)
}
if !verifyVerificationToken(secret, vt) {
t.Error("valid verification token rejected")
}
if verifyVerificationToken([]byte("wrong"), vt) {
t.Error("verification token accepted under wrong secret")
}
expired, _ := makeVerificationToken(secret, nowMs()-1)
if verifyVerificationToken(secret, expired) {
t.Error("expired verification token accepted")
}
}

View File

@ -5,6 +5,7 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"reflect" "reflect"
"slices"
"testing" "testing"
"time" "time"
@ -295,10 +296,5 @@ func TestDatabaseSQLDriverRegistered(t *testing.T) {
} }
func slicesContains(s []string, v string) bool { func slicesContains(s []string, v string) bool {
for _, x := range s { return slices.Contains(s, v)
if x == v {
return true
}
}
return false
} }

View File

@ -21,9 +21,9 @@ import (
// DBTX interface, the same pgconn.CommandTag / pgx.Rows / pgx.Row signatures. // DBTX interface, the same pgconn.CommandTag / pgx.Rows / pgx.Row signatures.
type DBTX interface { type DBTX interface {
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
Query(context.Context, string, ...interface{}) (pgx.Rows, error) Query(context.Context, string, ...any) (pgx.Rows, error)
QueryRow(context.Context, string, ...interface{}) pgx.Row QueryRow(context.Context, string, ...any) pgx.Row
} }
func New(db DBTX) *Queries { return &Queries{db: db} } func New(db DBTX) *Queries { return &Queries{db: db} }

View File

@ -322,7 +322,7 @@ func TestCapabilityRoundTrip(t *testing.T) {
name: "menus_get_menu_items", wantMethod: "menus.get_menu_items", name: "menus_get_menu_items", wantMethod: "menus.get_menu_items",
resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{ resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{
Id: idItem.String(), MenuId: idMenu.String(), Label: "Home", Url: "/", Id: idItem.String(), MenuId: idMenu.String(), Label: "Home", Url: "/",
PageSlug: "home", ParentId: proto.String(idParent.String()), SortOrder: 1, PageSlug: "home", ParentId: new(idParent.String()), SortOrder: 1,
OpenInNewTab: true, CssClass: "nav", ItemType: "link", Icon: "home", OpenInNewTab: true, CssClass: "nav", ItemType: "link", Icon: "home",
}}}, }}},
run: func(t *testing.T, cs plugin.CoreServices) { run: func(t *testing.T, cs plugin.CoreServices) {