feat: implement challenge registry (#607)

* feat: implement challenge method registry

This paves the way for implementing a no-js check method (#95) by making
the challenge providers more generic.

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib/challenge): rename proof-of-work package to proofofwork

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib): make validated challenges a CounterVec

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib): annotate jwts with challenge method

Signed-off-by: Xe Iaso <me@xeiaso.net>

* test(lib/challenge/proofofwork): implement tests

Signed-off-by: Xe Iaso <me@xeiaso.net>

* test(lib): add smoke tests for known good and known bad config files

Signed-off-by: Xe Iaso <me@xeiaso.net>

* docs: update CHANGELOG

Signed-off-by: Xe Iaso <me@xeiaso.net>

* fix(lib): use challenge.Impl#Issue when issuing challenges

Signed-off-by: Xe Iaso <me@xeiaso.net>

---------

Signed-off-by: Xe Iaso <me@xeiaso.net>
This commit is contained in:
Xe Iaso 2025-06-03 22:01:58 -04:00 committed by GitHub
parent ba4412c907
commit f2db43ad4b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 480 additions and 132 deletions

View file

@ -0,0 +1,83 @@
package proofofwork
import (
"crypto/subtle"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"github.com/TecharoHQ/anubis/internal"
chall "github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/web"
"github.com/a-h/templ"
)
func init() {
chall.Register("fast", &Impl{Algorithm: "fast"})
chall.Register("slow", &Impl{Algorithm: "slow"})
}
type Impl struct {
Algorithm string
}
func (i *Impl) Fail(w http.ResponseWriter, r *http.Request) error {
return nil
}
func (i *Impl) Issue(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string, ogTags map[string]string) (templ.Component, error) {
component, err := web.BaseWithChallengeAndOGTags("Making sure you're not a bot!", web.Index(), challenge, rule.Challenge, ogTags)
if err != nil {
return nil, fmt.Errorf("can't render page: %w", err)
}
return component, nil
}
func (i *Impl) Validate(r *http.Request, lg *slog.Logger, rule *policy.Bot, challenge string) error {
nonceStr := r.FormValue("nonce")
if nonceStr == "" {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField))
}
nonce, err := strconv.Atoi(nonceStr)
if err != nil {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: nonce: %w", chall.ErrInvalidFormat, err))
}
elapsedTimeStr := r.FormValue("elapsedTime")
if elapsedTimeStr == "" {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w elapsedTime", chall.ErrMissingField))
}
elapsedTime, err := strconv.ParseFloat(elapsedTimeStr, 64)
if err != nil {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: elapsedTime: %w", chall.ErrInvalidFormat, err))
}
response := r.FormValue("response")
if response == "" {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w response", chall.ErrMissingField))
}
calcString := fmt.Sprintf("%s%d", challenge, nonce)
calculated := internal.SHA256sum(calcString)
if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted response %s but got %s", chall.ErrFailed, calculated, response))
}
// compare the leading zeroes
if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) {
return chall.NewError("validate", "invalid response", fmt.Errorf("%w: wanted %d leading zeros but got %s", chall.ErrFailed, rule.Challenge.Difficulty, response))
}
lg.Debug("challenge took", "elapsedTime", elapsedTime)
chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime)
return nil
}

View file

@ -0,0 +1,136 @@
package proofofwork
import (
"errors"
"log/slog"
"net/http"
"testing"
"github.com/TecharoHQ/anubis/lib/challenge"
"github.com/TecharoHQ/anubis/lib/policy"
"github.com/TecharoHQ/anubis/lib/policy/config"
)
func mkRequest(t *testing.T, values map[string]string) *http.Request {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "/", nil)
if err != nil {
t.Fatal(err)
}
q := req.URL.Query()
for k, v := range values {
q.Set(k, v)
}
req.URL.RawQuery = q.Encode()
return req
}
func TestBasic(t *testing.T) {
i := &Impl{Algorithm: "fast"}
bot := &policy.Bot{
Challenge: &config.ChallengeRules{
Algorithm: "fast",
Difficulty: 0,
ReportAs: 0,
},
}
const challengeStr = "hunter"
const response = "2652bdba8fb4d2ab39ef28d8534d7694c557a4ae146c1e9237bd8d950280500e"
for _, cs := range []struct {
name string
req *http.Request
err error
challengeStr string
}{
{
name: "allgood",
req: mkRequest(t, map[string]string{
"nonce": "0",
"elapsedTime": "69",
"response": response,
}),
err: nil,
challengeStr: challengeStr,
},
{
name: "no-params",
req: mkRequest(t, map[string]string{}),
err: challenge.ErrMissingField,
challengeStr: challengeStr,
},
{
name: "missing-nonce",
req: mkRequest(t, map[string]string{
"elapsedTime": "69",
"response": response,
}),
err: challenge.ErrMissingField,
challengeStr: challengeStr,
},
{
name: "missing-elapsedTime",
req: mkRequest(t, map[string]string{
"nonce": "0",
"response": response,
}),
err: challenge.ErrMissingField,
challengeStr: challengeStr,
},
{
name: "missing-response",
req: mkRequest(t, map[string]string{
"nonce": "0",
"elapsedTime": "69",
}),
err: challenge.ErrMissingField,
challengeStr: challengeStr,
},
{
name: "wrong-nonce-format",
req: mkRequest(t, map[string]string{
"nonce": "taco",
"elapsedTime": "69",
"response": response,
}),
err: challenge.ErrInvalidFormat,
challengeStr: challengeStr,
},
{
name: "wrong-elapsedTime-format",
req: mkRequest(t, map[string]string{
"nonce": "0",
"elapsedTime": "taco",
"response": response,
}),
err: challenge.ErrInvalidFormat,
challengeStr: challengeStr,
},
{
name: "invalid-response",
req: mkRequest(t, map[string]string{
"nonce": "0",
"elapsedTime": "69",
"response": response,
}),
err: challenge.ErrFailed,
challengeStr: "Tacos are tasty",
},
} {
t.Run(cs.name, func(t *testing.T) {
lg := slog.With()
if _, err := i.Issue(cs.req, lg, bot, cs.challengeStr, nil); err != nil {
t.Errorf("can't issue challenge: %v", err)
}
if err := i.Validate(cs.req, lg, bot, cs.challengeStr); !errors.Is(err, cs.err) {
t.Errorf("got wrong error from Validate, got %v but wanted %v", err, cs.err)
}
})
}
}