XEY LABS — MYTHIC / 003 EN/ ID/ 中文/ 日本語/ 한국어 ← BUILD LOG PROJECT REPO ↗ GITHUB ↗

M1 Complete: Network Signals, Worker Solver, Origin SDKs.

M1 is the milestone where "adaptive" stopped being an adjective in Mythic's README and became a mechanism. Until now the server priced abuse with one instrument — proof-of-work difficulty. M1 adds the signals that let it price who is asking: JA4 TLS fingerprints and ASN reputation feed a capped risk engine, the PoW solver moves off the main thread, and origins can verify verdicts natively in Node.js and Python. Commits bc87ed9 through c7e4fc3.

This post was revised after an internal audit found seven gaps in the original "M1 complete" claim. Six are fixed in commit 067e9f7; one remains honestly open and is stated below. The corrections are documented here, not buried.

Why signals — and why advisory only

Bot mitigation lives or dies on signals, and every signal in isolation is weak. A JA4 fingerprint can be parroted byte-for-byte by any client that bothers. An ASN is a heuristic — datacenter today, residential proxy tomorrow. Treat either as ground truth and you have built a blocklist with extra steps.

So M1 treats every network signal as advisory: small, hard-capped contributions that can raise the price of a request but can never unilaterally clear it or condemn it. The flow looks like this:

JA4 fingerprinting: a discount, capped, distrusted

ADR-0010. The server reads the JA4 fingerprint from X-Mythic-JA4, set by the terminating proxy — never from the client directly, since any client-supplied value is attacker-controlled. Fingerprints match against known-browser JA4 patterns; a match applies a small risk discount, capped so it cannot single-handedly clear a challenge. Malformed values and prefix-only matches are rejected from the discount path entirely (a8ce95a). All caps are validated at startup; hostile tuning values refuse to serve (8f560bd).

Stated limitation: JA4 is advisory. A client that parrots a real browser's fingerprint byte-for-byte is indistinguishable at this layer. The discount is small precisely because the signal is spoofable — it raises the cost of naive scripting, nothing more.

ASN scoring: a heuristic that fails closed

Same ADR. Client IPs resolve to Autonomous System Numbers via a MaxMind GeoLite2-ASN database (MYTHIC_ASN_DB). Datacenter, hosting, and VPN-exit ASNs contribute risk; residential ISP ASNs do not. The lookup is nil-safe throughout: an empty MYTHIC_ASN_DB disables the signal cleanly, while a configured path that fails to open is fatal at startup. Misconfiguration fails closed; absence degrades silently by design — the operator chose not to enable it.

Audit correction: the original tests only covered failure modes (nil, empty path, bad path, invalid IP) — no positive lookup was ever exercised. 067e9f7 added TestASNLookupPositive against a synthetic .mmdb fixture generated in-repo (not licensed MaxMind data), pinning the happy path: datacenter ranges score, residential ranges don't.

Web Worker solver: PoW off the main thread

The SHA-256 grind previously ran on the main thread — at difficulty 24+ the page froze for seconds. The SDK now ships solveInWorker(), which stringifies a self-contained solver into a Blob URL and runs it off-thread. No separate worker file to deploy; no bundler plugin required. Progress callbacks fire every 2048 iterations; AbortSignal terminates the worker and revokes the URL:

export async function solveInWorker(
  challenge: Challenge,
  opts: SolveInWorkerOptions = {},
): Promise<Solution> {
  // No Worker API (Node, SSR) → fall back to main thread.
  if (typeof Worker === "undefined") {
    const { solve } = await import("./pow");
    return solve(challenge, opts);
  }
  const workerCode = `(${workerMain.toString()})()`;
  const blob = new Blob([workerCode], { type: "application/javascript" });
  const url = URL.createObjectURL(blob);
  // …postMessage challenge, await solution, revoke URL
}

MythicClient takes useWorker (default true) and auto-starts the behavioral collector on construction. One client, one flow, no manual wiring.

Known gap, stated plainly: the worker path has only ever run under Bun's Worker implementation — never in Chromium, Firefox, or Safari. This is documented in sdk/js/README.md, not claimed otherwise. A Playwright suite was attempted; the build environment could not fetch a browser binary. Until that changes, real-browser behavior is untested and unclaimed.

Origin verification: Node.js and Python

Decision tokens were verifiable only in Go (server/xtoken). Origins now get native helpers. sdk/js/src/verify.ts — parsePublicKey(x) wraps the JWKS raw key in DER SPKI; verifyToken(pub, token) checks the Ed25519 signature over raw payload bytes (no pre-hash), enforces canonical base64url re-encoding, and rejects expired tokens:

// Canonical encodings only: re-encoding the decoded bytes
// must reproduce the input exactly. Go's base64 decoder
// silently discards unused low bits of a trailing character,
// so distinct strings can decode to the same token.
if base64.RawURLEncoding.EncodeToString(payload) != payloadPart ||
    base64.RawURLEncoding.EncodeToString(sig) != sigPart {
    return nil, fmt.Errorf("%w: non-canonical encoding", ErrInvalidToken)
}

sdk/python/mythic/verify.py — same semantics via the cryptography package: parse_public_key, verify_token, TokenClaims dataclass, TokenError on any rejection. The Python SDK is now in CI (.github/workflows/sdk-python-ci.yml) and runs under make test — previously nothing executed it automatically.

Verified cross-language and committed: e2e/crosslang.sh mints a token from a live Go server and verifies it identically in Go, Python, and JS (dec=allow risk=6 across all three).

Evidence

SuiteResult
go build ./... · go vet ./...clean
go test -race -count=17/7 packages, 0 data races
bun test (JS SDK)17/17
tsc --noEmit · bun run buildclean
pytest (Python SDK)6/6
e2e/crosslang.shGo, Python, JS agree on a live token

The scoring contract itself is pinned in code — hostile tuning cannot leak an out-of-range score into a signed token (red-team finding H3, regression-tested):

// The score is a 0-100 contract (token claim `risk`,
// dashboards, tuning docs). Hostile tuning must clamp
// here, not leak 500 into tokens (red-team H3).
if score > 100 {
    score = 100
}
if score < 0 {
    score = 0
}

What the audit changed

After the original "M1 complete" claim, a from-scratch audit (fresh clone, every suite re-run, adversarial pass over the verify path) found seven gaps. Commit 067e9f7 ("fix(audit): close all 7 M1 audit gaps") closed six of them:

  • ASN positive test added (was failure-modes only).
  • Behavioral hints are now sent at verify() — previously the collector stopped after the challenge, leaving the server's verify-stage behavioral parsing as dead code in practice.
  • Python SDK added to CI and make test.
  • Cross-language E2E committed (e2e/crosslang.sh).
  • docs/ROADMAP.md corrected — it had still marked finished M1 items as planned.
  • A misleading worker test name fixed (it claimed to test the fallback while testing the real worker path).

The seventh — a real-browser test for the Web Worker — remains open and is documented as a known gap rather than claimed as done. That is the current honest state of M1.

Open questions

Real-browser worker coverage is the one open item above. Beyond it: an IP reputation feed is still planned (ROADMAP, honestly marked), and M2 behavioral biometrics plus the M5 shared keystore for cross-node verification remain the next milestones. Per project rule, every new signal's bypass is documented before the signal ships — see ADR-0010 § "Bypasses".

— XEY LABS