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

Hardening Mythic.

For one week we stopped building Mythic and tried to break it instead — black-box against a live server, white-box where the black box wasn't enough. The week produced twelve findings. Every one is fixed, pinned by a regression test, and re-verified against its original reproduction before we called it done. This entry also records the two M1 roadmap items that landed in the same pass: key rotation and a Redis store.

Key rotation without downtime

Until this week Mythic signed every decision token with a single Ed25519 key; rotating it meant a restart and invalidated every live token. The server now keeps a key ring with exactly one active signer (ADR-0006). Rotation is time-based and runs in-process (MYTHIC_KEY_MAX_AGE, default 720h). Retired keys stay advertised in the JWKS for MYTHIC_KEY_RETENTION (default 24h) — deliberately longer than any token they signed can remain valid, so in-flight tokens never break mid-rotation:

The keystore is a JSON file written atomically; legacy 32-byte seed files still load and migrate on first save. Origins keep verifying tokens locally, selecting the key by the kid claim — no coordination call needed.

A store that survives more than one node

The in-memory store bound mythicd to a single node (ADR-0009). MYTHIC_STORE=redis selects a Redis backend: single-use challenges via GETDEL (atomic fetch-and-delete) and pressure counters as sorted sets pruned by one Lua script. Both backends implement sliding-window counters, which closes the fixed-window boundary burst. The Store interface now returns errors: an unreachable Redis refuses startup, and a runtime outage surfaces as 503 store_unavailable on /v1 while /healthz stays open for load balancers. A shared contract suite runs against both backends (memory and miniredis) so their semantics cannot drift.

One caveat in writing: the store is shared, signing keys are not. A token from node A does not verify against node B's JWKS. Multi-node token verification needs a shared keystore (KMS/HSM) — M5, not solved by this change.

Twelve findings, grouped

Each row: what the attack showed, then what changed. Every fix carries a regression test; every fix was re-run against its original reproduction.

AreaCauseFix
Transport & limits
Rate-limit budget Budget was consumed after validation — invalid-JSON and unknown-site-key floods ran unthrottled (600 requests at 225 req/s, zero 429s). Budget is now counted before validation.
IPv6 pressure key Limiter keyed IPv6 at /128 — one /64 controlled 2⁶⁴ identities (5,000 requests from a single block, zero hits). Counters aggregate IPv6 to /64 (ADR-0007).
Connection timeouts No body/read/idle timeouts — a slow client held connections indefinitely. ReadTimeout, WriteTimeout, IdleTimeout are set.
Challenge economics
Solve-time floor Floor modeled a 50k H/s worker; measured crypto.subtle workers run ~400k H/s — honest clients were flagged implausible. Floor is now a low quantile of honest solve time (2^d / (2M × 32)): false positives ≤ ~6% worst case, GPU-class answers trip it ~96% (ADR-0008).
Delayed verification Any client could simply delay verification past the wall-clock floor — the floor cannot catch patience. Documented in the threat model as the behavioral layer's job (M2), not the floor's.
Tokens & configuration
Token TTL The invariant "tokens never outlive their challenge" was unenforced — a 1-hour token was issued from a 5-second challenge. TokenTTL is now clamped to ChallengeTTL.
Score clamp Hostile tuning (MYTHIC_FAST_SOLVE_PENALTY=500) leaked a risk score of 500 into signed tokens. Scores clamp to 0–100 at the engine boundary; penalties above 100 refuse to start.
Config validation A negative MYTHIC_BASE_DIFFICULTY issued challenges any nonce satisfied. Configuration is validated at startup; out-of-range values refuse to serve.
Base64 canonical form Go's base64 decoder silently discards the unused low bits of a trailing character, so distinct token strings decoded to the same bytes and verified — breaking string-level token identity. Caught by the tamper-resistance mutation suite on its first run. VerifyToken enforces canonical base64: re-encoding the decoded bytes must reproduce the input exactly, otherwise the token is rejected.
State handling
Keystore concurrency Two processes sharing one keystore collided on the same temp file or silently split-brained their signing keys. Temp names are process-unique; saves refuse to overwrite a file changed since load.
JSON trailing bytes Trailing bytes after a JSON value were silently ignored. Trailing bytes are rejected.
Counter saturation The sliding-window counter stored one entry per hit — including rate-limited ones. A million hits from one identity cost ~16.4 MB of heap and 595 s of CPU. Counters saturate at IPLimit+1: the same flood costs +0.00 MB and 714 ms.

The score-clamp fix is worth showing because it sits at a trust boundary — the value that ends up signed inside tokens:

// 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
}

Verification — and what stays open

Every finding has a reproduction; every fix has a regression test (go test ./... -race, plus the SDK typecheck and tests), and every fix was re-verified against the original reproduction. What remains open is documented in the threat model, not in our heads: patient solvers that delay verification (M2, behavioral signals), distributed attacks with fresh identities (partly addressed since by M1 network fingerprints — see 003), and per-node keystores (M5, shared KMS/HSM).

— XEY LABS