diff options
| author | Xe Iaso <me@xeiaso.net> | 2025-04-14 09:21:01 -0400 |
|---|---|---|
| committer | Xe Iaso <me@xeiaso.net> | 2025-04-14 09:21:01 -0400 |
| commit | d96074a82ea22186a206e281951a37133247c134 (patch) | |
| tree | 81138ab4d8df39104a5b144ab675316c931ce2a5 | |
| parent | 95f70ddf21f7b845ab84d1d070e511da54a91df4 (diff) | |
| download | anubis-d96074a82ea22186a206e281951a37133247c134.tar.xz anubis-d96074a82ea22186a206e281951a37133247c134.zip | |
lib: enable wasm based check validation
Signed-off-by: Xe Iaso <me@xeiaso.net>
| -rw-r--r-- | anubis.go | 2 | ||||
| -rw-r--r-- | cmd/anubis/main.go | 8 | ||||
| -rw-r--r-- | lib/anubis.go | 82 | ||||
| -rw-r--r-- | lib/anubis_test.go | 2 | ||||
| -rw-r--r-- | lib/policy/config/config.go | 14 | ||||
| -rw-r--r-- | lib/policy/policy.go | 8 | ||||
| -rw-r--r-- | package-lock.json | 4 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | web/js/algos/argon2id.mjs | 159 | ||||
| -rw-r--r-- | web/js/algos/sha256.mjs | 1 | ||||
| -rw-r--r-- | web/js/main.mjs | 2 |
11 files changed, 249 insertions, 35 deletions
@@ -16,4 +16,4 @@ const StaticPath = "/.within.website/x/cmd/anubis/" // DefaultDifficulty is the default "difficulty" (number of leading zeroes) // that must be met by the client in order to pass the challenge. -const DefaultDifficulty = 4 +const DefaultDifficulty uint32 = 4 diff --git a/cmd/anubis/main.go b/cmd/anubis/main.go index 724f88a..e0ecea5 100644 --- a/cmd/anubis/main.go +++ b/cmd/anubis/main.go @@ -40,7 +40,7 @@ import ( var ( bind = flag.String("bind", ":8923", "network address to bind HTTP to") bindNetwork = flag.String("bind-network", "tcp", "network family to bind HTTP to, e.g. unix, tcp") - challengeDifficulty = flag.Int("difficulty", anubis.DefaultDifficulty, "difficulty of the challenge") + challengeDifficulty = flag.Int("difficulty", int(anubis.DefaultDifficulty), "difficulty of the challenge") cookieDomain = flag.String("cookie-domain", "", "if set, the top-level domain that the Anubis cookie will be valid for") cookiePartitioned = flag.Bool("cookie-partitioned", false, "if true, sets the partitioned flag on Anubis cookies, enabling CHIPS support") ed25519PrivateKeyHex = flag.String("ed25519-private-key-hex", "", "private key used to sign JWTs, if not set a random one will be assigned") @@ -58,7 +58,7 @@ var ( ogPassthrough = flag.Bool("og-passthrough", false, "enable Open Graph tag passthrough") ogTimeToLive = flag.Duration("og-expiry-time", 24*time.Hour, "Open Graph tag cache expiration time") extractResources = flag.String("extract-resources", "", "if set, extract the static resources to the specified folder") - webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals") + webmasterEmail = flag.String("webmaster-email", "", "if set, displays webmaster's email on the reject page for appeals") ) func keyFromHex(value string) (ed25519.PrivateKey, error) { @@ -194,7 +194,7 @@ func main() { log.Fatalf("can't make reverse proxy: %v", err) } - policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, *challengeDifficulty) + policy, err := libanubis.LoadPoliciesOrDefault(*policyFname, uint32(*challengeDifficulty)) if err != nil { log.Fatalf("can't parse policy file: %v", err) } @@ -261,7 +261,7 @@ func main() { OGPassthrough: *ogPassthrough, OGTimeToLive: *ogTimeToLive, Target: *target, - WebmasterEmail: *webmasterEmail, + WebmasterEmail: *webmasterEmail, }) if err != nil { log.Fatalf("can't construct libanubis.Server: %v", err) diff --git a/lib/anubis.go b/lib/anubis.go index 6fd18a5..74d096f 100644 --- a/lib/anubis.go +++ b/lib/anubis.go @@ -1,19 +1,23 @@ package lib import ( + "context" "crypto/ed25519" "crypto/rand" "crypto/sha256" "crypto/subtle" + "encoding/hex" "encoding/json" "fmt" "io" + "io/fs" "log" "log/slog" "math" "net" "net/http" "os" + "path/filepath" "strconv" "strings" "time" @@ -31,6 +35,7 @@ import ( "github.com/TecharoHQ/anubis/internal/ogtags" "github.com/TecharoHQ/anubis/lib/policy" "github.com/TecharoHQ/anubis/lib/policy/config" + "github.com/TecharoHQ/anubis/wasm" "github.com/TecharoHQ/anubis/web" "github.com/TecharoHQ/anubis/xess" ) @@ -80,7 +85,7 @@ type Options struct { WebmasterEmail string } -func LoadPoliciesOrDefault(fname string, defaultDifficulty int) (*policy.ParsedConfig, error) { +func LoadPoliciesOrDefault(fname string, defaultDifficulty uint32) (*policy.ParsedConfig, error) { var fin io.ReadCloser var err error @@ -122,6 +127,32 @@ func New(opts Options) (*Server, error) { opts: opts, DNSBLCache: decaymap.New[string, dnsbl.DroneBLResponse](), OGTags: ogtags.NewOGTagCache(opts.Target, opts.OGPassthrough, opts.OGTimeToLive), + validators: map[string]Verifier{ + "fast": VerifierFunc(BasicSHA256Verify), + "slow": VerifierFunc(BasicSHA256Verify), + }, + } + + finfos, err := fs.ReadDir(web.Static, "static/wasm") + if err != nil { + return nil, fmt.Errorf("[unexpected] can't read any webassembly files in the static folder: %w", err) + } + + for _, finfo := range finfos { + fin, err := web.Static.Open("static/wasm/" + finfo.Name()) + if err != nil { + return nil, fmt.Errorf("[unexpected] can't read static/wasm/%s: %w", finfo.Name(), err) + } + defer fin.Close() + + name := strings.TrimSuffix(finfo.Name(), filepath.Ext(finfo.Name())) + + runner, err := wasm.NewRunner(context.Background(), finfo.Name(), fin) + if err != nil { + return nil, fmt.Errorf("can't load static/wasm/%s: %w", finfo.Name(), err) + } + + result.validators[name] = runner } mux := http.NewServeMux() @@ -161,13 +192,15 @@ type Server struct { opts Options DNSBLCache *decaymap.Impl[string, dnsbl.DroneBLResponse] OGTags *ogtags.OGTagCache + + validators map[string]Verifier } func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) } -func (s *Server) challengeFor(r *http.Request, difficulty int) string { +func (s *Server) challengeFor(r *http.Request, difficulty uint32) string { fp := sha256.Sum256(s.priv.Seed()) challengeData := fmt.Sprintf( @@ -404,7 +437,7 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { templ.Handler(web.Base("Oh noes!", web.ErrorPage("Internal Server Error: administrator has misconfigured Anubis. Please contact the administrator and ask them to look for the logs around \"passChallenge\".", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) return } - lg = lg.With("check_result", cr) + lg = lg.With("check_result", cr, "algorithm", rule.Challenge.Algorithm) nonceStr := r.FormValue("nonce") if nonceStr == "" { @@ -436,33 +469,52 @@ func (s *Server) PassChallenge(w http.ResponseWriter, r *http.Request) { response := r.FormValue("response") redir := r.FormValue("redir") + responseBytes, err := hex.DecodeString(response) + if err != nil { + s.ClearCookie(w) + lg.Debug("response doesn't parse", "err", err) + templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response format", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } + challenge := s.challengeFor(r, rule.Challenge.Difficulty) + challengeBytes, err := hex.DecodeString(challenge) + if err != nil { + s.ClearCookie(w) + lg.Debug("challenge doesn't parse", "err", err) + templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid internal challenge format", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } - nonce, err := strconv.Atoi(nonceStr) + nonceRaw, err := strconv.ParseUint(nonceStr, 10, 32) if err != nil { s.ClearCookie(w) lg.Debug("nonce doesn't parse", "err", err) templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid nonce", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) return } + nonce := uint32(nonceRaw) - calcString := fmt.Sprintf("%s%d", challenge, nonce) - calculated := internal.SHA256sum(calcString) + validator, ok := s.validators[string(rule.Challenge.Algorithm)] + if !ok { + s.ClearCookie(w) + lg.Debug("nonce doesn't parse", "err", err) + templ.Handler(web.Base("Oh noes!", web.ErrorPage(fmt.Sprintf("Internal anubis error has been detected and you cannot proceed. Tried to look up a validator for algorithm %s but wasn't able to find one. Please contact the administrator of this instance of anubis", rule.Challenge.Algorithm), s.opts.WebmasterEmail)), templ.WithStatus(http.StatusInternalServerError)).ServeHTTP(w, r) + return + } - if subtle.ConstantTimeCompare([]byte(response), []byte(calculated)) != 1 { + ok, err = validator.Verify(r.Context(), challengeBytes, responseBytes, nonce, rule.Challenge.Difficulty) + if err != nil { s.ClearCookie(w) - lg.Debug("hash does not match", "got", response, "want", calculated) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) - failedValidations.Inc() + lg.Debug("verification error", "err", err) + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Your challenge failed validation. Please go back and try your challenge again", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusBadRequest)).ServeHTTP(w, r) return } - // compare the leading zeroes - if !strings.HasPrefix(response, strings.Repeat("0", rule.Challenge.Difficulty)) { + if !ok { s.ClearCookie(w) - lg.Debug("difficulty check failed", "response", response, "difficulty", rule.Challenge.Difficulty) - templ.Handler(web.Base("Oh noes!", web.ErrorPage("invalid response", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusForbidden)).ServeHTTP(w, r) - failedValidations.Inc() + lg.Debug("response invalid") + templ.Handler(web.Base("Oh noes!", web.ErrorPage("Your challenge failed validation. Please go back and try your challenge again", s.opts.WebmasterEmail)), templ.WithStatus(http.StatusBadRequest)).ServeHTTP(w, r) return } diff --git a/lib/anubis_test.go b/lib/anubis_test.go index 1e0cdf2..48343a2 100644 --- a/lib/anubis_test.go +++ b/lib/anubis_test.go @@ -197,7 +197,7 @@ func TestCheckDefaultDifficultyMatchesPolicy(t *testing.T) { fmt.Fprintln(w, "OK") }) - for i := 1; i < 10; i++ { + for i := uint32(1); i < 10; i++ { t.Run(fmt.Sprint(i), func(t *testing.T) { anubisPolicy, err := LoadPoliciesOrDefault("", i) if err != nil { diff --git a/lib/policy/config/config.go b/lib/policy/config/config.go index e8f5161..40b4fe9 100644 --- a/lib/policy/config/config.go +++ b/lib/policy/config/config.go @@ -31,9 +31,11 @@ const ( type Algorithm string const ( - AlgorithmUnknown Algorithm = "" - AlgorithmFast Algorithm = "fast" - AlgorithmSlow Algorithm = "slow" + AlgorithmUnknown Algorithm = "" + AlgorithmFast Algorithm = "fast" + AlgorithmSlow Algorithm = "slow" + AlgorithmArgon2ID Algorithm = "argon2id" + AlgorithmSHA256 Algorithm = "sha256" ) type BotConfig struct { @@ -101,8 +103,8 @@ func (b BotConfig) Valid() error { } type ChallengeRules struct { - Difficulty int `json:"difficulty"` - ReportAs int `json:"report_as"` + Difficulty uint32 `json:"difficulty"` + ReportAs uint32 `json:"report_as"` Algorithm Algorithm `json:"algorithm"` } @@ -124,7 +126,7 @@ func (cr ChallengeRules) Valid() error { } switch cr.Algorithm { - case AlgorithmFast, AlgorithmSlow, AlgorithmUnknown: + case AlgorithmFast, AlgorithmSlow, AlgorithmArgon2ID, AlgorithmSHA256, AlgorithmUnknown: // do nothing, it's all good default: errs = append(errs, fmt.Errorf("%w: %q", ErrChallengeRuleHasWrongAlgorithm, cr.Algorithm)) diff --git a/lib/policy/policy.go b/lib/policy/policy.go index 3f80fa3..ba7e44c 100644 --- a/lib/policy/policy.go +++ b/lib/policy/policy.go @@ -27,7 +27,7 @@ type ParsedConfig struct { Bots []Bot DNSBL bool - DefaultDifficulty int + DefaultDifficulty uint32 } func NewParsedConfig(orig config.Config) *ParsedConfig { @@ -36,7 +36,7 @@ func NewParsedConfig(orig config.Config) *ParsedConfig { } } -func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedConfig, error) { +func ParseConfig(fin io.Reader, fname string, defaultDifficulty uint32) (*ParsedConfig, error) { var c config.Config if err := json.NewDecoder(fin).Decode(&c); err != nil { return nil, fmt.Errorf("can't parse policy config JSON %s: %w", fname, err) @@ -99,12 +99,12 @@ func ParseConfig(fin io.Reader, fname string, defaultDifficulty int) (*ParsedCon parsedBot.Challenge = &config.ChallengeRules{ Difficulty: defaultDifficulty, ReportAs: defaultDifficulty, - Algorithm: config.AlgorithmFast, + Algorithm: config.AlgorithmArgon2ID, } } else { parsedBot.Challenge = b.Challenge if parsedBot.Challenge.Algorithm == config.AlgorithmUnknown { - parsedBot.Challenge.Algorithm = config.AlgorithmFast + parsedBot.Challenge.Algorithm = config.AlgorithmArgon2ID } } diff --git a/package-lock.json b/package-lock.json index e94f885..99b7bde 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "@xeserv/xess", + "name": "@techaro/anubis", "version": "1.0.0-see-VERSION-file", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "@xeserv/xess", + "name": "@techaro/anubis", "version": "1.0.0-see-VERSION-file", "license": "ISC", "devDependencies": { diff --git a/package.json b/package.json index 5ab30ab..fc531cd 100644 --- a/package.json +++ b/package.json @@ -24,4 +24,4 @@ "postcss-import-url": "^7.2.0", "postcss-url": "^10.1.3" } -} +}
\ No newline at end of file diff --git a/web/js/algos/argon2id.mjs b/web/js/algos/argon2id.mjs new file mode 100644 index 0000000..4e467bd --- /dev/null +++ b/web/js/algos/argon2id.mjs @@ -0,0 +1,159 @@ +import { u } from "../xeact.mjs"; + +export default function process( + data, + difficulty = 16, + signal = null, + pc = null, + threads = (navigator.hardwareConcurrency || 1), +) { + return new Promise(async (resolve, reject) => { + let webWorkerURL = URL.createObjectURL(new Blob([ + '(', processTask(), ')()' + ], { type: 'application/javascript' })); + + const module = await fetch(u("/.within.website/x/cmd/anubis/static/wasm/argon2id.wasm")) + .then(resp => WebAssembly.compileStreaming(resp)); + + const workers = []; + const terminate = () => { + workers.forEach((w) => w.terminate()); + if (signal != null) { + // clean up listener to avoid memory leak + signal.removeEventListener("abort", terminate); + if (signal.aborted) { + console.log("PoW aborted"); + reject(false); + } + } + }; + if (signal != null) { + signal.addEventListener("abort", terminate, { once: true }); + } + + for (let i = 0; i < threads; i++) { + let worker = new Worker(webWorkerURL); + + worker.onmessage = (event) => { + if (typeof event.data === "number") { + pc?.(event.data); + } else { + terminate(); + resolve(event.data); + } + }; + + worker.onerror = (event) => { + terminate(); + reject(event); + }; + + worker.postMessage({ + data, + difficulty, + nonce: i, + threads, + module, + }); + + workers.push(worker); + } + + URL.revokeObjectURL(webWorkerURL); + }); +} + +function processTask() { + return function () { + addEventListener('message', async (event) => { + const importObject = { + anubis: { + anubis_update_nonce: (nonce) => postMessage(nonce), + } + }; + + const instance = await WebAssembly.instantiate(event.data.module, importObject); + + // Get exports + const { + anubis_work, + data_ptr, + result_hash_ptr, + result_hash_size, + set_data_length, + memory + } = instance.exports; + + function uint8ArrayToHex(arr) { + return Array.from(arr) + .map((c) => c.toString(16).padStart(2, "0")) + .join(""); + } + + function hexToUint8Array(hexString) { + // 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) { + 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; + } + + let data = event.data.data; + let difficulty = event.data.difficulty; + let nonce = event.data.nonce; + let interand = event.data.threads; + + writeToBuffer(hexToUint8Array(data)); + + nonce = anubis_work(difficulty, nonce, interand); + const challenge = readFromChallenge(); + + data = uint8ArrayToHex(challenge); + + postMessage({ + hash: data, + difficulty, + nonce, + }); + }); + }.toString(); +} + diff --git a/web/js/algos/sha256.mjs b/web/js/algos/sha256.mjs index cfea876..36f9bb4 100644 --- a/web/js/algos/sha256.mjs +++ b/web/js/algos/sha256.mjs @@ -138,7 +138,6 @@ function processTask() { let data = event.data.data; let difficulty = event.data.difficulty; - let hash; let nonce = event.data.nonce; let interand = event.data.threads; diff --git a/web/js/main.mjs b/web/js/main.mjs index 79d62e9..ee43e36 100644 --- a/web/js/main.mjs +++ b/web/js/main.mjs @@ -1,3 +1,4 @@ +import argon2id from "./algos/argon2id.mjs"; import fast from "./algos/fast.mjs"; import slow from "./algos/slow.mjs"; import sha256 from "./algos/sha256.mjs"; @@ -5,6 +6,7 @@ import { testVideo } from "./video.mjs"; import { u } from "./xeact.mjs"; const algorithms = { + "argon2id": argon2id, "fast": fast, "slow": slow, "sha256": sha256, |
