diff --git a/.devcontainer/docker-compose.yaml b/.devcontainer/docker-compose.yaml index 640ff811..d2fbc3b8 100644 --- a/.devcontainer/docker-compose.yaml +++ b/.devcontainer/docker-compose.yaml @@ -20,11 +20,7 @@ services: dockerfile: .devcontainer/Dockerfile volumes: - ../:/workspace/anubis:cached - - cargo-target:/workspace/anubis/target:cached environment: VALKEY_URL: redis://valkey:6379/0 #entrypoint: ["/usr/bin/sleep", "infinity"] user: vscode - -volumes: - cargo-target: diff --git a/lib/anubis.go b/lib/anubis.go index 6a44e1fc..744b9d22 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -37,6 +37,7 @@ import ( _ "github.com/TecharoHQ/anubis/lib/challenge/metarefresh" _ "github.com/TecharoHQ/anubis/lib/challenge/preact" _ "github.com/TecharoHQ/anubis/lib/challenge/proofofwork" + _ "github.com/TecharoHQ/anubis/lib/challenge/wasm" ) var ( diff --git a/lib/challenge/preact/js/app.tsx b/lib/challenge/preact/js/app.tsx index efa90bdb..9b74ca64 100644 --- a/lib/challenge/preact/js/app.tsx +++ b/lib/challenge/preact/js/app.tsx @@ -1,6 +1,6 @@ import { render, h, Fragment } from "preact"; import { useState, useEffect } from "preact/hooks"; -import { g, j, r, u, x } from "./xeact.js"; +import { g, j, r, u, x } from "../../../../web/lib/xeact"; import { Sha256 } from "@aws-crypto/sha256-js"; /** @jsx h */ diff --git a/lib/challenge/wasm/proofofwork.go b/lib/challenge/wasm/proofofwork.go new file mode 100644 index 00000000..843097f0 --- /dev/null +++ b/lib/challenge/wasm/proofofwork.go @@ -0,0 +1,81 @@ +package wasm + +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/localization" + "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"}) +} + +type Impl struct { + Algorithm string +} + +func (i *Impl) Setup(mux *http.ServeMux) {} + +func (i *Impl) Issue(w http.ResponseWriter, r *http.Request, lg *slog.Logger, in *chall.IssueInput) (templ.Component, error) { + loc := localization.GetLocalizer(r) + return page(loc), nil +} + +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)) + } + + 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 +} diff --git a/lib/challenge/wasm/proofofwork.templ b/lib/challenge/wasm/proofofwork.templ new file mode 100644 index 00000000..48c90f4d --- /dev/null +++ b/lib/challenge/wasm/proofofwork.templ @@ -0,0 +1,44 @@ +package wasm + +import ( + "github.com/TecharoHQ/anubis" + "github.com/TecharoHQ/anubis/lib/localization" +) + +templ page(localizer *localization.SimpleLocalizer) { +
+ { localizer.T("loading") }

+
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + if anubis.UseSimplifiedExplanation { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } else { + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + 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} + } + _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "

") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + } + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "
") + if templ_7745c5c3_Err != nil { + return templ_7745c5c3_Err + } + return nil + }) +} + +var _ = templruntime.GeneratedTemplate diff --git a/lib/challenge/wasm/proofofwork_test.go b/lib/challenge/wasm/proofofwork_test.go new file mode 100644 index 00000000..dd31f163 --- /dev/null +++ b/lib/challenge/wasm/proofofwork_test.go @@ -0,0 +1,151 @@ +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/package.json b/package.json index 37407134..619eb520 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,9 @@ "package": "yeet", "lint": "make lint" }, + "imports": { + "lib/*": "./web/lib/*" + }, "author": "", "license": "ISC", "devDependencies": { diff --git a/web/build.sh b/web/build.sh index d0818e0c..364fdca9 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 --minify --outfile="$out" --banner:js="$LICENSE" + esbuild "$file" --sourcemap --bundle --outfile="$out" --banner:js="$LICENSE" gzip -f -k -n "$out" zstd -f -k --ultra -22 "$out" brotli -fZk "$out" diff --git a/web/js/algorithms/index.ts b/web/js/algorithms/index.ts index 5b571837..e31a024f 100644 --- a/web/js/algorithms/index.ts +++ b/web/js/algorithms/index.ts @@ -1,6 +1,10 @@ import fast from "./fast"; +import wasm from "./wasm"; export default { fast: fast, slow: fast, // XXX(Xe): slow is deprecated, but keep this around in case anything goes bad + + argon2id: wasm, + sha256: wasm, } \ No newline at end of file diff --git a/web/js/algorithms/wasm.ts b/web/js/algorithms/wasm.ts new file mode 100644 index 00000000..83032797 --- /dev/null +++ b/web/js/algorithms/wasm.ts @@ -0,0 +1,87 @@ +import { u } from "../../lib/xeact"; + +type ProgressCallback = (nonce: number) => void; + +interface ProcessOptions { + basePrefix: string; + version: string; + algorithm: string; +} + +const getHardwareConcurrency = () => + navigator.hardwareConcurrency !== undefined ? navigator.hardwareConcurrency : 1; + +export default function process( + options: ProcessOptions, + data: string, + difficulty: number = 5, + signal: AbortSignal | null = null, + progressCallback?: ProgressCallback, + threads: number = Math.trunc(Math.max(getHardwareConcurrency() / 2, 1)), +): Promise { + console.debug(options); + const { basePrefix, version, algorithm } = options; + + let wasmFeatures = "baseline"; + + return new Promise(async (resolve, reject) => { + const module = await fetch(u(`${basePrefix}/.within.website/x/cmd/anubis/static/wasm/${wasmFeatures}/${algorithm}.wasm?cacheBuster=${version}`)) + .then(x => WebAssembly.compileStreaming(x)); + + const webWorkerURL = `${options.basePrefix}/.within.website/x/cmd/anubis/static/js/worker/wasm.mjs?cacheBuster=${version}`; + + const workers: Worker[] = []; + let settled = false; + + const onAbort = () => { + console.log("PoW aborted"); + cleanup(); + reject(new DOMException("Aborted", "AbortError")); + }; + + const cleanup = () => { + if (settled) { + return; + } + settled = true; + workers.forEach((w) => w.terminate()); + if (signal != null) { + signal.removeEventListener("abort", onAbort); + } + }; + + if (signal != null) { + if (signal.aborted) { + return onAbort(); + } + signal.addEventListener("abort", onAbort, { once: true }); + } + + for (let i = 0; i < threads; i++) { + let worker = new Worker(webWorkerURL); + + worker.onmessage = (event) => { + if (typeof event.data === "number") { + progressCallback?.(event.data); + } else { + cleanup(); + resolve(event.data); + } + } + + worker.onerror = (event) => { + cleanup(); + reject(event); + } + + worker.postMessage({ + data, + difficulty, + nonce: i, + threads, + module, + }); + } + }); +}; + diff --git a/web/js/main.ts b/web/js/main.ts index e37c536c..a5df154d 100644 --- a/web/js/main.ts +++ b/web/js/main.ts @@ -171,7 +171,7 @@ const t = (key) => translations[`js_${key}`] || translations[key] || key; try { const t0 = Date.now(); const { hash, nonce } = await process( - { basePrefix, version: anubisVersion }, + { basePrefix, version: anubisVersion, algorithm: rules.algorithm }, challenge.randomData, rules.difficulty, null, diff --git a/web/js/worker/wasm.ts b/web/js/worker/wasm.ts new file mode 100644 index 00000000..cddbe427 --- /dev/null +++ b/web/js/worker/wasm.ts @@ -0,0 +1,106 @@ +export interface Args { + data: string; + difficulty: number; + nonce: number; + threads: number; + module: BufferSource; +} + +interface AnubisExports { + anubis_work: (difficulty: number, initialNonce: number, threads: number) => number; + data_ptr: () => number; + result_hash_ptr: () => number; + result_hash_size: () => number; + set_data_length: (len: number) => void; + memory: WebAssembly.Memory; +} + +addEventListener("message", async (event: MessageEvent) => { + console.log(event.data); + const { data, difficulty, threads, module } = event.data; + let { nonce } = event.data; + + const importObject = { + anubis: { + anubis_update_nonce: (nonce: number) => postMessage(nonce), + } + }; + + if (nonce !== 0) { + importObject.anubis.anubis_update_nonce = (_) => { }; + } + + const obj = await WebAssembly.instantiate(module, importObject); + + const { + anubis_work, + data_ptr, + result_hash_ptr, + result_hash_size, + set_data_length, + memory + } = (obj as unknown as any).exports as unknown as AnubisExports; + function uint8ArrayToHex(arr: Uint8Array) { + return Array.from(arr) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); + } + + function hexToUint8Array(hexString: string): Uint8Array { + // Remove whitespace and optional '0x' prefix + hexString = hexString.replace(/\s+/g, '').replace(/^0x/, ''); + + // Check for valid length + if (hexString.length % 2 !== 0) { + throw new Error('Invalid hex string length'); + } + + // Check for valid characters + if (!/^[0-9a-fA-F]+$/.test(hexString)) { + throw new Error('Invalid hex characters'); + } + + // Convert to Uint8Array + const byteArray = new Uint8Array(hexString.length / 2); + for (let i = 0; i < byteArray.length; i++) { + const byteValue = parseInt(hexString.substr(i * 2, 2), 16); + byteArray[i] = byteValue; + } + + return byteArray; + } + + // Write data to buffer + function writeToBuffer(data: Uint8Array) { + if (data.length > 1024) throw new Error("Data exceeds buffer size"); + + // Get pointer and create view + const offset = data_ptr(); + const buffer = new Uint8Array(memory.buffer, offset, data.length); + + // Copy data + buffer.set(data); + + // Set data length + set_data_length(data.length); + } + + function readFromChallenge() { + const offset = result_hash_ptr(); + const buffer = new Uint8Array(memory.buffer, offset, result_hash_size()); + + return buffer; + } + + writeToBuffer(hexToUint8Array(data)); + + nonce = anubis_work(difficulty, nonce, threads); + const challenge = readFromChallenge(); + const result = uint8ArrayToHex(challenge); + + postMessage({ + hash: result, + difficulty, + nonce, + }); +}); \ No newline at end of file diff --git a/lib/challenge/preact/js/xeact.js b/web/lib/xeact.js similarity index 100% rename from lib/challenge/preact/js/xeact.js rename to web/lib/xeact.js