Compare commits
5 Commits
40ba4ee9de
...
5880aa21ee
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5880aa21ee | ||
|
|
cbc9ff495f | ||
|
|
7b15ce70d0 | ||
|
|
f5cbb56df7 | ||
|
|
84723991cd |
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")
|
||||
}
|
||||
}
|
||||
39
captcha/nonce.go
Normal file
39
captcha/nonce.go
Normal 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
27
captcha/nonce_test.go
Normal 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
36
captcha/prng.go
Normal 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
37
captcha/prng_test.go
Normal 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
96
captcha/tokens.go
Normal 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
48
captcha/tokens_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@ -295,10 +296,5 @@ func TestDatabaseSQLDriverRegistered(t *testing.T) {
|
||||
}
|
||||
|
||||
func slicesContains(s []string, v string) bool {
|
||||
for _, x := range s {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return slices.Contains(s, v)
|
||||
}
|
||||
|
||||
@ -21,9 +21,9 @@ import (
|
||||
// DBTX interface, the same pgconn.CommandTag / pgx.Rows / pgx.Row signatures.
|
||||
|
||||
type DBTX interface {
|
||||
Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...interface{}) pgx.Row
|
||||
Exec(context.Context, string, ...any) (pgconn.CommandTag, error)
|
||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||
QueryRow(context.Context, string, ...any) pgx.Row
|
||||
}
|
||||
|
||||
func New(db DBTX) *Queries { return &Queries{db: db} }
|
||||
|
||||
@ -322,7 +322,7 @@ func TestCapabilityRoundTrip(t *testing.T) {
|
||||
name: "menus_get_menu_items", wantMethod: "menus.get_menu_items",
|
||||
resp: &abiv1.MenusGetMenuItemsResponse{Items: []*abiv1.MenuItem{{
|
||||
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",
|
||||
}}},
|
||||
run: func(t *testing.T, cs plugin.CoreServices) {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user