Skip to content

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

Merged
V3RON merged 2 commits into
security/os-version-boundfrom
security/uplink-worker-pin
Oct 26, 2026
Merged

V3RON merged 2 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, 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.

Now the gateway pins a join token to the first worker id it accepts. Another id on that token, or an id another live token holds, is refused as worker-mismatch (HTTP 403, worker.rejected). To re-pair: token revoke, then token create.

Behaviour change: an existing token is pinned to the first worker that reconnects after the upgrade, so workers sharing one token see all but one refused. The docs already said one token per worker.

Changes

  • src/auth/token-store.ts: worker_id column with a unique index; pinWorker pins in one transaction, so of two first joins only one wins.
  • authenticateUplink(credential, claimedWorkerId) returns worker-mismatch; the upgrade answers 403 for it.
  • tokenRecordSchema.workerId, shown by token list. PROTOCOL_VERSION_RANGE 62 → 63.
  • Docs: event catalogs, gateway pages, the token CLI reference, manuals.
  • Tests that shared one token now mint one per worker.

Tests

  • Token store, auth (pin, race, other-token), gateway service, uplink status codes, e2e/gateway-fleet.test.ts.

Assumptions

  • Assumption: refuse a worker id another live token is pinned to? — yes, through a unique index on tokens.worker_id, so a leaked unused token cannot take over a paired worker; a new token needs the old one revoked first.
  • Assumption: may an empty claimed worker id pin a token? — no, it is refused as worker-mismatch.
  • Assumption: edit accepted ADR 0005's list of worker.rejected reasons? — no; the new reason is recorded in docs/internal/EVENTS.md and the manuals.

Review

Spec review: skipped. Code review: 0 blocking, 0 fixed, 0 notes. Claims review: skipped.
Rounds: 1. Tokens: n/a.

One combined review by the maintainer's agent on 2026-10-22 (comment below); no blocking findings.

Rebased onto main e1f2f43 on 2026-10-26 as one commit. Protocol 62 → 63. tokenRecordSchema.workerId gets .max(128) with a why comment, which main's wire-bound lint now requires; the uplink already cuts claimed ids to 128 characters. src/gateway/AGENTS.md keeps main's shorter known gaps, minus the "claimed id never verified" gap this PR closes. src/auth/AGENTS.md merges this PR's authenticateUplink row with main's tokens.verify. service.test.ts drops two comments main's comment pass had removed. Main's new e2e/gateway-event-relay.test.ts joined two workers with one token, which the pin now refuses; it mints one per worker (764cc61).

Written by an agent.

@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
@V3RON
V3RON marked this pull request as ready for review October 26, 2026 09:45
@V3RON
V3RON force-pushed the security/uplink-worker-pin branch from 6698566 to 764cc61 Compare October 26, 2026 10:29
@V3RON
V3RON force-pushed the security/uplink-worker-pin branch from 764cc61 to 73552bc Compare October 26, 2026 10:37
@V3RON
V3RON force-pushed the security/uplink-worker-pin branch from 73552bc to 38c2358 Compare October 26, 2026 14:25
@V3RON
V3RON merged commit ac99629 into main Oct 26, 2026
24 of 25 checks passed
@V3RON
V3RON deleted the security/uplink-worker-pin branch October 26, 2026 14:26
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