From 8999303eefcb3199c6fb0aa76253437578a671ec Mon Sep 17 00:00:00 2001 From: Xe Iaso Date: Sun, 28 Sep 2025 02:35:42 +0000 Subject: [PATCH] feat(lib/challenge/wasm): server side validation logic Signed-off-by: Xe Iaso --- lib/challenge/interface.go | 2 +- lib/challenge/metarefresh/metarefresh.go | 2 +- lib/challenge/preact/preact.go | 2 +- lib/challenge/proofofwork/proofofwork.go | 2 +- lib/challenge/wasm/proofofwork_test.go | 151 ------------------ .../wasm/{proofofwork.go => wasm.go} | 55 ++++--- .../wasm/{proofofwork.templ => wasm.templ} | 0 .../{proofofwork_templ.go => wasm_templ.go} | 20 +-- web/build.sh | 2 +- 9 files changed, 51 insertions(+), 185 deletions(-) delete mode 100644 lib/challenge/wasm/proofofwork_test.go rename lib/challenge/wasm/{proofofwork.go => wasm.go} (53%) rename lib/challenge/wasm/{proofofwork.templ => wasm.templ} (100%) rename lib/challenge/wasm/{proofofwork_templ.go => wasm_templ.go} (88%) diff --git a/lib/challenge/interface.go b/lib/challenge/interface.go index c7a19449..f62f5540 100644 --- a/lib/challenge/interface.go +++ b/lib/challenge/interface.go @@ -58,7 +58,7 @@ type ValidateInput struct { type Impl interface { // Setup registers any additional routes with the Impl for assets or API routes. - Setup(mux *http.ServeMux) + Setup(mux *http.ServeMux) error // Issue a new challenge to the user, called by the Anubis. Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *IssueInput) (templ.Component, error) diff --git a/lib/challenge/metarefresh/metarefresh.go b/lib/challenge/metarefresh/metarefresh.go index c554b918..fbee7fe7 100644 --- a/lib/challenge/metarefresh/metarefresh.go +++ b/lib/challenge/metarefresh/metarefresh.go @@ -21,7 +21,7 @@ func init() { type Impl struct{} -func (i *Impl) Setup(mux *http.ServeMux) {} +func (i *Impl) Setup(mux *http.ServeMux) error { return nil } func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) { u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge") diff --git a/lib/challenge/preact/preact.go b/lib/challenge/preact/preact.go index a785f984..0f2f9fac 100644 --- a/lib/challenge/preact/preact.go +++ b/lib/challenge/preact/preact.go @@ -36,7 +36,7 @@ func init() { type impl struct{} -func (i *impl) Setup(mux *http.ServeMux) {} +func (i *impl) Setup(mux *http.ServeMux) error { return nil } func (i *impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *challenge.IssueInput) (templ.Component, error) { u, err := r.URL.Parse(anubis.BasePrefix + "/.within.website/x/cmd/anubis/api/pass-challenge") diff --git a/lib/challenge/proofofwork/proofofwork.go b/lib/challenge/proofofwork/proofofwork.go index b9be014e..9f9d6ca9 100644 --- a/lib/challenge/proofofwork/proofofwork.go +++ b/lib/challenge/proofofwork/proofofwork.go @@ -25,7 +25,7 @@ type Impl struct { Algorithm string } -func (i *Impl) Setup(mux *http.ServeMux) {} +func (i *Impl) Setup(mux *http.ServeMux) error { return nil } func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) { loc := localization.GetLocalizer(r) diff --git a/lib/challenge/wasm/proofofwork_test.go b/lib/challenge/wasm/proofofwork_test.go deleted file mode 100644 index dd31f163..00000000 --- a/lib/challenge/wasm/proofofwork_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package wasm - -import ( - "errors" - "log/slog" - "net/http" - "net/http/httptest" - "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() - - i.Setup(http.NewServeMux()) - - inp := &challenge.IssueInput{ - Rule: bot, - Challenge: &challenge.Challenge{ - RandomData: cs.challengeStr, - }, - } - - if _, err := i.Issue(httptest.NewRecorder(), cs.req, lg, inp); err != nil { - t.Errorf("can't issue challenge: %v", err) - } - - if err := i.Validate(cs.req, lg, &challenge.ValidateInput{ - Rule: bot, - Challenge: &challenge.Challenge{ - RandomData: cs.challengeStr, - }, - }); !errors.Is(err, cs.err) { - t.Errorf("got wrong error from Validate, got %v but wanted %v", err, cs.err) - } - }) - } -} diff --git a/lib/challenge/wasm/proofofwork.go b/lib/challenge/wasm/wasm.go similarity index 53% rename from lib/challenge/wasm/proofofwork.go rename to lib/challenge/wasm/wasm.go index 843097f0..037ed741 100644 --- a/lib/challenge/wasm/proofofwork.go +++ b/lib/challenge/wasm/wasm.go @@ -1,31 +1,44 @@ package wasm import ( - "crypto/subtle" + "context" + "encoding/hex" "fmt" "log/slog" "net/http" "strconv" - "strings" - "github.com/TecharoHQ/anubis/internal" chall "github.com/TecharoHQ/anubis/lib/challenge" "github.com/TecharoHQ/anubis/lib/localization" + "github.com/TecharoHQ/anubis/wasm" + "github.com/TecharoHQ/anubis/web" "github.com/a-h/templ" ) //go:generate go tool github.com/a-h/templ/cmd/templ generate func init() { - chall.Register("argon2id", &Impl{Algorithm: "argon2id"}) - chall.Register("sha256", &Impl{Algorithm: "sha256"}) + chall.Register("argon2id", &Impl{algorithm: "argon2id"}) + chall.Register("sha256", &Impl{algorithm: "sha256"}) } type Impl struct { - Algorithm string + algorithm string + runner *wasm.Runner } -func (i *Impl) Setup(mux *http.ServeMux) {} +func (i *Impl) Setup(mux *http.ServeMux) error { + fname := fmt.Sprintf("static/wasm/simd128/%s.wasm", i.algorithm) + fin, err := web.Static.Open(fname) + if err != nil { + return err + } + defer fin.Close() + + i.runner, err = wasm.NewRunner(context.Background(), fname, fin) + + return err +} func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) { loc := localization.GetLocalizer(r) @@ -33,9 +46,6 @@ func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in } func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInput) error { - rule := in.Rule - challenge := in.Challenge.RandomData - nonceStr := r.FormValue("nonce") if nonceStr == "" { return chall.NewError("validate", "invalid response", fmt.Errorf("%w nonce", chall.ErrMissingField)) @@ -62,20 +72,27 @@ func (i *Impl) Validate(r *http.Request, lg *slog.Logger, in *chall.ValidateInpu 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)) + challengeBytes, err := hex.DecodeString(in.Challenge.RandomData) + if err != nil { + return chall.NewError("validate", "invalid random data", fmt.Errorf("can't decode random data: %w", err)) } - // 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)) + gotBytes, err := hex.DecodeString(response) + if err != nil { + return chall.NewError("validate", "invalid client data format", fmt.Errorf("%w response", chall.ErrInvalidFormat)) + } + + ok, err := i.runner.Verify(r.Context(), challengeBytes, gotBytes, uint32(nonce), uint32(in.Rule.Challenge.Difficulty)) + if err != nil { + return chall.NewError("validate", "internal WASM error", fmt.Errorf("can't run wasm validation logic: %w", err)) + } + + if !ok { + return chall.NewError("verify", "client calculated wrong data", fmt.Errorf("%w: response invalid: %s", chall.ErrFailed, response)) } lg.Debug("challenge took", "elapsedTime", elapsedTime) - chall.TimeTaken.WithLabelValues(i.Algorithm).Observe(elapsedTime) + chall.TimeTaken.WithLabelValues(i.algorithm).Observe(elapsedTime) return nil } diff --git a/lib/challenge/wasm/proofofwork.templ b/lib/challenge/wasm/wasm.templ similarity index 100% rename from lib/challenge/wasm/proofofwork.templ rename to lib/challenge/wasm/wasm.templ diff --git a/lib/challenge/wasm/proofofwork_templ.go b/lib/challenge/wasm/wasm_templ.go similarity index 88% rename from lib/challenge/wasm/proofofwork_templ.go rename to lib/challenge/wasm/wasm_templ.go index 147e2b7a..86b48afd 100644 --- a/lib/challenge/wasm/proofofwork_templ.go +++ b/lib/challenge/wasm/wasm_templ.go @@ -41,7 +41,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var2 string templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/pensive.webp?cacheBuster=" + anubis.Version) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 10, Col: 165} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 10, Col: 165} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2)) if templ_7745c5c3_Err != nil { @@ -54,7 +54,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var3 string templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/img/happy.webp?cacheBuster=" + anubis.Version) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 11, Col: 174} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 11, Col: 174} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) if templ_7745c5c3_Err != nil { @@ -67,7 +67,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var4 string templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("loading")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 12, Col: 41} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 12, Col: 41} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) if templ_7745c5c3_Err != nil { @@ -80,7 +80,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var5 string templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(anubis.BasePrefix + "/.within.website/x/cmd/anubis/static/js/main.mjs?cacheBuster=" + anubis.Version) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 13, Col: 136} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 13, Col: 136} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5)) if templ_7745c5c3_Err != nil { @@ -98,7 +98,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var6 string templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("simplified_explanation")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 20, Col: 44} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 20, Col: 44} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) if templ_7745c5c3_Err != nil { @@ -116,7 +116,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var7 string templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("ai_companies_explanation")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 24, Col: 46} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 24, Col: 46} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) if templ_7745c5c3_Err != nil { @@ -129,7 +129,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var8 string templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("anubis_compromise")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 27, Col: 39} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 27, Col: 39} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) if templ_7745c5c3_Err != nil { @@ -142,7 +142,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var9 string templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("hack_purpose")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 30, Col: 34} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 30, Col: 34} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) if templ_7745c5c3_Err != nil { @@ -155,7 +155,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var10 string templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("jshelter_note")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 33, Col: 35} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 33, Col: 35} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) if templ_7745c5c3_Err != nil { @@ -173,7 +173,7 @@ func page(localizer *localization.SimpleLocalizer) templ.Component { var templ_7745c5c3_Var11 string templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(localizer.T("javascript_required")) if templ_7745c5c3_Err != nil { - return templ.Error{Err: templ_7745c5c3_Err, FileName: `proofofwork.templ`, Line: 39, Col: 40} + return templ.Error{Err: templ_7745c5c3_Err, FileName: `wasm.templ`, Line: 39, Col: 40} } _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) if templ_7745c5c3_Err != nil { diff --git a/web/build.sh b/web/build.sh index 364fdca9..cebd3c86 100755 --- a/web/build.sh +++ b/web/build.sh @@ -49,7 +49,7 @@ for file in js/**/*.ts js/**/*.mjs; do mkdir -p "$(dirname "$out")" - esbuild "$file" --sourcemap --bundle --outfile="$out" --banner:js="$LICENSE" + esbuild "$file" --sourcemap --minify --bundle --outfile="$out" --banner:js="$LICENSE" gzip -f -k -n "$out" zstd -f -k --ultra -22 "$out" brotli -fZk "$out"