Skip to content

XERK-268: make an agent's host proved by its credential, not typed - #440

Merged
xerhab merged 7 commits into
mainfrom
XERK-268
Aug 12, 2026
Merged

XERK-268: make an agent's host proved by its credential, not typed#440
xerhab merged 7 commits into
mainfrom
XERK-268

Conversation

@xerhab

@xerhab xerhab commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Closes XERK-268.

The defect, and how it turned out to be bigger than the ticket

<host> in /api/agents/<host>/… is a value the caller types. With one fleet-shared TURMA_AGENT_TOKEN, nothing backed it — so scoping a relay to m.srcHost / m.targetHost / u.host refused a caller naming itself and passed one naming the victim.

Verifying that, I found the ticket's severity rationale understated. device on /api/heartbeat is self-asserted the same way, and the reply hands back that host's queued commands — so the shared token alone was enough, with no leaked id:

  1. Attacker POSTs {"device":"hsA"} → gets back {"type":"exportSession","migrationId":"…"}.
  2. Beats again as hsA asserting worktreePath:"…/PWNED", then POSTs forged bytes to /api/agents/hsA/migrations/<id>/blob{"ok":true}.
  3. The real target is told importSession … cwd:"…/PWNED" — attacker-chosen bytes and cwd.

That also rules out the per-relay one-time secret the ticket weighed as the other option: it would ride on exactly the commands an impersonated heartbeat hands out. Only binding the credential to a host closes it. Chosen with the ticket's author.

The fix

Each agent runs on its own token, <base64url(device)>.<HMAC-SHA256(master, device)>. The hub keeps only the master and re-derives it for whatever host a request names, so there is no host list and no restart to add one.

The token names its host on purpose. An HMAC can't be inverted, so a bare digest could only be checked once the host was known — and /api/heartbeat, whose host is buried in a 32 MiB body, would have had to admit any bearer before reading it. tokenHost() reads the name half and re-derives, comparing the whole token, so editing the name breaks the HMAC.

agentBearerKind(req, host)proved / operator / legacy / null is the one resolver. Bound at: the three blob relays, archive ingest, the updating signal, the heartbeat's device, /agent/control's ?name=, and /agent/data (which now carries the host its channel was opened for). This fixes every agent-authed route at once, including ones added later — and the two XERK-266 guards land here too, since that branch is unmerged; with the binding they finally mean what they claim.

Compatibility — nothing breaks on merge

The master still authenticates as legacy, so upgrading the hub alone changes nothing and every agent keeps working. TURMA_AGENT_STRICT=1 retires it; the hub warns at boot until it is set. ttydAuth(host) sends whichever token that host actually authenticated with, so a half-rolled fleet keeps its terminals. No agent-side behavior change — only the value of TURMA_TOKEN.

node turma/server.js --agent-token <device> prints a host's token.

The DockerOps rollover is XERK-275, deliberately not here: an agent presenting a derived token to a pre-XERK-268 hub gets a flat 401, so the hub must ship first.

Verification

Adversarial QA (~/.claude/agents/qa.md) ran three passes; the first two were FAIL and PARTIAL and both were right.

FAIL — an unauthenticated remote DoS I introduced. Binding the heartbeat forced its gate to a weaker check that accepted any Bearer <anything>, so an anonymous caller reached a 32 MiB readBody + JSON.parse. 8 concurrent 31 MiB posts killed a 256 MiB container 3/3 with a V8 heap OOM — the control plane for the whole fleet. Fixed at the root by the self-identifying token above. Re-measured: 401 in 3 ms, RSS +256 KiB vs +143 MiB, container survives 8 and 24 concurrent. Two riders died with it (a JSON.parse internals leak, and an attacker-chosen device reaching the log pre-binding).

PARTIAL — TURMA_AGENT_STRICT did not bite at that gate. A master-holder still reached the read before the 403, so a leaked master still OOM'd a fleet whose whole point was that the master had been retired. Not a regression (origin/main dies to the same attack) but it was this change's own claim. Now refused at the gate, with the rollover message rather than a bare 401. QA re-verified by memory, not status code: refused master +3 MiB RSS vs +137 MiB for an accepted beat — a 45× gap proving the body is unread.

PASS. All six claims confirmed with running evidence; 23/24 mutants caught, the survivor empirically proven equivalent. What QA could not break: 20 forged-token shapes and 40 non-canonical encodings all rejected; no timing oracle (spread 1.70×, non-monotonic, full match fastest); no host-enumeration oracle — a nonexistent host name gives an identical refusal; gate and handlers cannot disagree on the decoded segment. Full cross-host matrix over 6 surfaces × 2 rigs, 26-case WebSocket matrix, migration/attachment/archive/terminal E2E, and the real hub-agent.py driven both ways.

Also fixed from QA: the /agent/data?ch=__proto__ hub kill (pre-existing, on a line this diff rewrote — both tunnel maps are null-prototype now); the agent discarding the hub's refusal body, so a bad rollover no longer reads as a bare HTTP Error 403; a wrong-host token answering 403 naming both hosts, since its likeliest cause is a rename (the name is inside the token); hostAgentToken refusing non-string and non-round-tripping names, which String() would have coerced into a real credential; and tests for the three mutants that escaped — including the migration GET's targetHost scope, which unguarded hands any host another's raw transcript.

I disputed one finding with evidence and QA retracted it: the tokenBound-ordering mutant is caught (their mutant left the original assignment in place, making it a no-op).

Suites: node 1099/1099, python 1288 OK.

Known limitation, not introduced here

An authenticated agent can still OOM the hub with 8 × 31 MiB beats. Inherent to the design — the host is in the body, so the body must be read to bind it — and present on origin/main via its equivalent credential. Filed as XERK-288 rather than scope-crept into this PR; the fix is a decision about what rides the heartbeat.

Not verified

Android / glasses / veiller were not booted: no wire change (tokenBound is stripped from /api/agents and SSE, no field added or retyped), so no PARITY.md entry is owed. Also unexercised: a real two-agent migration with live tmux/claude (the auth-relevant half is covered), the Sessions Move UI, the agent-host image, and a DockerOps rehearsal.

Merge with main, and one thing carried that isn't mine

XERK-266 and XERK-263 landed while this was in QA, and both had grown the same two migration blob routes. The route bodies are main's entirely — its uniform-404 refusals and disk spooling are ahead of what this branch carried, and this branch's contribution there was only the srcHost/targetHost scope main already has. What is kept is the binding at the gate, which is orthogonal.

The comments are rewritten rather than taken from either side, because this change alters what main's now say: where main documents the scoping as "defense in depth, NOT an identity check" and names the two oracles its uniform 404s could not close — the reply timing and the 413 — both needed an unverified caller, and there no longer is one. The uniform 404s stay: they are what still holds if a future route reaches that code with a weaker credential.

Test suites were additive on both sides, so both are kept; this branch's duplicate title was renamed. One real behavioral conflict, fixed rather than papered over: XERK-263's MIGRATE_INFLIGHT_MAX (4) counts in-flight moves fleet-wide, and this branch's tests left three of them, so main's later tests got a 503 and failed somewhere unrelated to what they test. Added settleMigration() to the three tests that start a move.

.trivyignore carries one entry that has nothing to do with this change. CVE-2026-46600 (golang.org/x/net in the bundled terraform) was published today and fails the agent-image scan on every open PR — XERK-265 is red on it too. terraform 1.15.8 is still the newest stable (1.16.0 is at rc1), so TERRAFORM_VERSION has nothing to bump to short of shipping a pre-release into a production image. Triaged in the same shape and with the same reasoning as the three terraform entries already in that file, with an expiry to force the revisit. Called out here because it is unrelated to the rest of the diff — revert that commit alone if you would rather handle it separately.

xerhab added 7 commits August 12, 2026 13:50
Every agent-authed surface names the host it acts as — the `<host>` path
segment, the heartbeat's `device`, the tunnel's `?name=` — and under one
fleet-shared token none of those were backed by anything. Scoping a relay to
`m.srcHost`/`m.targetHost`/`u.host` therefore refused a caller naming ITSELF
and passed one naming the victim.

`device` is self-asserted the same way, and a heartbeat reply hands back that
host's queued commands, so the shared token ALONE was enough: beat as the
source, collect the exportSession command carrying the migration id, then relay
a forged bundle and choose the cwd the real target is told to resume at. That
also rules out the per-relay one-time secret this ticket weighed as the other
option — it would ride on exactly the commands the impersonated beat hands out.

So bind the credential to the host instead. Each agent runs on its own token,
HMAC(TURMA_AGENT_TOKEN, <device>); the hub keeps only the master and re-derives
what it expects for whatever host a request names, so no host list and no
restart to add one. That fixes every agent-authed route at once, including ones
added later, and the two migration blob guards this ticket was filed against
land here too — with the binding they finally mean what they say.

The master still authenticates as `legacy`, so a fleet mid-rollover keeps
beating and every existing deployment is unaffected; TURMA_AGENT_STRICT retires
it, and the hub warns at boot until it is set. ttyd is proxied with whichever
token the host actually authenticated with, so a half-rolled fleet keeps its
terminals. No agent-side code change — only the value of TURMA_TOKEN.

`node turma/server.js --agent-token <device>` prints a host's token.
The gate decodes the segment to check the credential against it, so a bad
percent-escape threw there instead of in the route — turning an anonymous
caller's 401 into a 400 that ran before any auth did. Match it raw when it
does not decode; the route still 400s for a caller that got past auth.
D1 (HIGH, introduced here): the heartbeat's pre-body gate accepted any
`Bearer <anything>`, because a bare HMAC digest cannot be recognised without
already knowing the host — so an anonymous caller reached a 32 MiB readBody plus
JSON.parse and the normalizers. 8 concurrent 31 MiB posts killed a 256 MiB hub
container 3/3 with a V8 heap OOM, taking the control plane for the whole fleet;
origin/main answers 401 before the read.

Fixed at the root by making the token NAME its host:
`<base64url(device)>.<HMAC(master, device)>`. The hub reads the name off the
token and re-derives to verify it, so a caller is identified from the credential
alone — before a body, a path, or a registry lookup. `agentPresented` refuses an
unknown bearer again, and the two riders on the same root cause go with it: an
unauthenticated caller no longer gets JSON.parse internals back, and no longer
gets an attacker-chosen `device` written into the hub's log pre-binding.

D2 (HIGH, pre-existing on the line this diff rewrote): `pendingChannels` was a
plain object, so `/agent/data?ch=__proto__` read back Object.prototype — truthy,
past the "is there a pending channel" check, dead on the next property access,
out of an async upgrade handler with no unhandledRejection hook. Both tunnel
maps are null-prototype now.

D3: hub-agent.py discarded the hub's refusal body, so a rollover gone wrong read
as "HTTP Error 403: Forbidden" and a host that silently vanished. The heartbeat
and the updating announce now log through _http_error_detail, which learns the
hub's {error} shape alongside the two trackers'.

D4: a valid token for the WRONG host answered a bare 401. Its likeliest cause is
a host rename — the name is inside the token — so it answers 403 naming both
hosts. It leaks nothing: the token names its own host on its face.

D5: the three mutants that escaped now have tests — the migration GET's
targetHost scope (which, unguarded, hands any host another's raw transcript
knowing only an id that rides /api/agents), a wrong host answering a pending
data channel, and the __proto__ crash above.

D6: hostAgentToken refuses a non-string host outright; String() would have
coerced an array or a toString()-carrying object into a REAL credential.

D9: agentAuthorized was dead in the request path and read like a gate. Removed;
agentPresented is the one coarse check.

D7/D8 stand as designed and are called out in the PR.
QA's delta pass cleared the FAIL and found one real gap: `agentPresented`
accepted the master unconditionally, so on a hub where the master was supposedly
retired a master-holder still reached the 32 MiB readBody + parse before the
403. 8 and 24 concurrent 31 MiB posts killed a strict 256 MiB container with a
V8 heap OOM. Not a regression — origin/main dies to the same attack, since there
the master was the only agent credential — but "the master then stops being
accepted" was this change's own claim, and a leaked master is exactly what
strict mode exists to contain.

Strict now refuses the master at that gate, and refuses it with the rollover
message rather than a bare 401: the host is still unread behind the gate, so
`agentPresentedRefusal` says the same thing without naming one. Verified on the
container — 24x403, hub alive, fleet reachable, and the body provably unparsed
(invalid JSON gets the 403, not a parse error).

Also from that pass:

- N5: a host name that does not survive the UTF-8 round trip now gets no token.
  A lone surrogate encodes to the replacement character, so two different names
  derived one credential. Unreachable in practice; the derivation should be
  injective regardless.
- N6: `_http_error_detail` checks the hub's {error} AFTER both tracker shapes,
  so teaching it that key cannot change what an existing Jira/ADO failure says.
- N3/N4: the tunnel maps and `tokenHost` are exported. The __proto__ guard is
  now asserted on the maps directly — reaching it through a socket meant the
  regression was detected by the hub DYING mid-run, which reads as a CI timeout
  rather than a failing test.
- N2: tests for the canonicalization invariant (only the canonical base64url
  spelling of a host authenticates) and for the malformed-segment fallback.

QA's M4 escape is not one: moving `tokenBound` before the `...payload` spread
fails `XERK-268: ttyd is proxied with the token that host actually runs`.
Verified by applying the mutant to a copy of the tree.
XERK-266 landed on main while this was in QA, along with XERK-263's spool-to-disk
relay. Both sides had grown the same two migration blob routes.

The route bodies are main's entirely: its uniform-404 refusals and disk spooling
are strictly ahead of what this branch carried, and this branch's contribution
there was only the `m.srcHost`/`m.targetHost` scope main already has. What is
kept is the binding at the gate, which is orthogonal to all of it.

The comments are rewritten rather than taken from either side, because XERK-268
changes what main's now say. Where main documents the scoping as "defense in
depth, NOT an identity check" and names the two oracles its uniform 404s could
not close — the reply timing and the 413 — both needed an unverified caller, and
there is no longer one. The uniform 404s stay: they are what still holds if a
future route reaches that code with a weaker credential.

Test suites are additive on both sides, so both are kept. This branch's
"scoped to the migration's own two hosts" is renamed to "the blob relay's host
scope holds for a proved caller" — main's XERK-266 test owns the original title,
and the two check different things.

One real conflict of behavior, fixed rather than papered over: XERK-263 added
MIGRATE_INFLIGHT_MAX (4), counted fleet-wide over exporting/importing moves, and
this branch's tests left three in flight — so main's later tests got a 503 and
failed somewhere unrelated to what they test. Added settleMigration() and called
it from the three tests that start a move.

node 1127/1127, python 1288 OK.
Published today; it fails the agent-image scan on every open PR, not just this
one (XERK-265 is red on the same finding). Exactly one finding, only in
/usr/local/bin/terraform, where x/net v0.55.0 is compiled in.

terraform 1.15.8 is still the newest stable — 1.16.0 is at rc1 — so
TERRAFORM_VERSION has nothing to bump to short of putting a pre-release in a
production image. Same shape and same reasoning as the three terraform entries
already in this file, with an expiry to force the revisit.
Only conflict is .trivyignore: PR #441 landed the same CVE-2026-46600 triage
while this was in CI. Took main's entry wholesale and dropped this branch's —
same finding, same conclusion, and the shared file should carry one copy in the
voice that got there first. Nothing of this branch's reasoning is lost; main's
wording is if anything more specific about the release index it checked.
@xerhab
xerhab merged commit 05fa50d into main Aug 12, 2026
9 of 10 checks passed
@xerhab
xerhab deleted the XERK-268 branch August 12, 2026 20:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant