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:
FIG. 1 — Every signal is advisory and capped. No single signal can clear or condemn a request on its own.
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
| Suite | Result |
|---|---|
go build ./... · go vet ./... | clean |
go test -race -count=1 | 7/7 packages, 0 data races |
bun test (JS SDK) | 17/17 |
tsc --noEmit · bun run build | clean |
pytest (Python SDK) | 6/6 |
e2e/crosslang.sh | Go, 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.mdcorrected — 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