Skip to content

feat(gateway): a join token is pinned to the first worker that uses it - #894

Draft
V3RON wants to merge 11 commits into
security/os-version-boundfrom
security/uplink-worker-pin
Draft

V3RON wants to merge 11 commits into
security/os-version-boundfrom
security/uplink-worker-pin

Conversation

@V3RON

@V3RON V3RON commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A gateway trusted the worker id a dial claimed in x-simlock-worker-id, and a new uplink under a live id replaced the old link. So one leaked worker join token let anyone take over any worker's link and its relayed traffic.

Now the gateway pins a join token to the first worker id it accepts. Another worker id on that token is refused as worker-mismatch (HTTP 403, worker.rejected). A worker id that another live token is pinned to is refused the same way, so a fresh token cannot take over a paired worker either. To re-pair, run token revoke then token create.

Behaviour change for existing fleets: an existing join token gets pinned to the first worker that reconnects after the upgrade. Workers that share one token today will see all but one refused. The docs already said one token per worker.

Changes

  • src/auth/token-store.ts: tokens owner version 2 adds worker_id TEXT with a unique index. pinWorker(id, workerId) runs in one transaction (UPDATE OR IGNORE … WHERE worker_id IS NULL, then a re-read), so of two first joins at once only one wins. TokenRecord.workerId.
  • src/auth/index.ts: authenticateUplink(credential, claimedWorkerId) returns worker-mismatch for an empty or missing id, an id other than the pinned one, or an id another token holds.
  • src/ports/uplink.ts, uplink-websocket.ts: new outcome worker-mismatch; the upgrade answers 401 only for unauthenticated, and 403 for the other refusals.
  • src/gateway/service.ts, src/daemon/main.ts: the claimed worker id is passed to authenticate. worker.rejected.reason gains worker-mismatch (WorkerRejectedReason in src/bus). The rule that a new uplink replaces the old one under a live id stays; it now applies only with the token pinned to that id.
  • Contract: tokenRecordSchema gains workerId?, so token.list / GET /v1/tokens / simlock token list show it. PROTOCOL_VERSION_RANGE goes from 58 to 59.
  • Docs: both event catalogs, gateway/joining-workers (new "Pair a token with its worker" section, the 403 log row), gateway/access-control, gateway/managing-workers, gateway/alerts, the token CLI reference. Manuals: src/auth, src/gateway (removes the known gap), the protocol value in src/contract and src/daemon.
  • Tests that joined several workers with one token now mint one token per worker (mintJoinToken in e2e/helpers and in the console fixtures, which startFleet, joinOldWorker and the usage spec use).

Tests

  • src/auth/token-store.test.ts: pin once and list workerId; a version 1 table moves up with its tokens unpinned.
  • src/auth/auth.test.ts: the first id is pinned, the same id is accepted again, another id or an empty one gets worker-mismatch; of two first joins at once, one is accepted; an id pinned to another token gets worker-mismatch until that token is revoked.
  • src/gateway/service.test.ts: worker-mismatch added to the refusal table. Worker B with A's token is refused with one worker.rejected (worker-mismatch), A's link is untouched, and A's own redial still replaces its link.
  • src/ports/uplink-websocket.test.ts: 401 for unauthenticated, 403 for forbidden and worker-mismatch.
  • e2e/gateway-fleet.test.ts: a second worker with a used token gets worker-mismatch, and token list shows the first worker's id.
  • Runs: pnpm test:changed passed. pnpm test:e2e on the six gateway e2e files passed (41 tests). The console lane in Chromium passed, except for the known flaky test in Console e2e: a worker is drained from Fleet with no dialog, and Undo undrains it fails on the row's drained text #886. The website build and check-openapi passed.
  • Mutation checks by hand: dropping AND worker_id IS NULL fails the pin and race tests; dropping the unique index fails the other-token test.

Assumptions

  • Assumption: a worker id that another live join token is already pinned to is refused as worker-mismatch, enforced by a unique index on tokens.worker_id. Without it, a leaked unused token could pin itself to a paired worker's id and replace that worker's link. I chose the stricter option. The cost: to give a worker a new token, revoke the old one first. The docs say so.
  • Assumption: an empty claimed worker id never pins a token; it is refused as worker-mismatch. A missing header was already a 400.
  • Assumption: ADR 0005 §4/§22 list only unauthenticated and forbidden as worker.rejected reasons. I did not edit the accepted ADR. The new reason is recorded in docs/internal/EVENTS.md and in the manuals.

Written by an agent.

V3RON added 4 commits October 22, 2026 17:24
Unit tests for the token store's pin, authenticateUplink's worker-mismatch,
the gateway's refusal and the 403 on the upgrade; an e2e for a second worker
with a used token. Fleet tests give each worker its own join token.
The gateway pins a worker join token to the first worker id it accepts
(tokens v2: worker_id, unique). Another id on that token, or a pinned id on
another token, is refused as worker-mismatch (HTTP 403, worker.rejected).
token.list shows workerId. Protocol 55.
@V3RON

V3RON commented Oct 22, 2026

Copy link
Copy Markdown
Contributor Author

Review (maintainer's agent, round 1)

No blocking findings.

  • pinWorker is correct under concurrency: UPDATE OR IGNORE … WHERE worker_id IS NULL plus a re-read in the same BEGIN IMMEDIATE transaction means two first joins with one token cannot both win, and a worker id another token holds (unique index) leaves the row unpinned and refused. The state module runs each migration through exec, so the two-statement TOKENS_V2 applies whole. SQLite's unique index admits many NULLs, so unpinned tokens coexist.
  • The unique index is stricter than the brief and the reasoning holds: without it an unused leaked token could pin itself to a running worker's id and replace its link. The cost (revoke before re-pairing) is documented on the joining-workers page with the exact commands.
  • Refusal mapping is right: 401 only for an unknown token, 403 for wrong role and for mismatch; the event carries which. Both event catalogs, the bus type and the registry agree. Revoke frees the pairing because the row goes.
  • Contract: tokenRecordSchema.workerId optional, protocol 54 → 55. fix(contract): an exact osVersion is bounded like a component version #895 (osVersion bound) bumps to 55 too; whichever merges second rebases and bumps to 56.
  • Tests adapted correctly: one token per worker in every e2e and console fixture that used to share one, which is what the new rule requires.

Notes for the maintainer, no change needed in this PR:

  • ADR 0005 §4/§22 name two refusal reasons; this adds a third. An ADR amendment (or a one-line note) is the maintainer's call.
  • An existing fleet whose workers share one join token keeps only the first worker that reconnects after this upgrade; the rest need their own tokens. Nothing has shipped with shared tokens yet, but the joining-workers page could say so in one sentence if a release carries this.

@V3RON
V3RON changed the base branch from main to security/os-version-bound October 23, 2026 06:19
@V3RON
V3RON added this pull request to stack #897 October 23, 2026 06:19

This branch has not been deployed

No deployments
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