Peer admission required the remote ticket's subject to equal the local
account on both sides, so only devices of one account could pair. That
made the product multi-device sync rather than collaboration.
The subject equality was the authorization, so it is replaced rather than
deleted. `PeerIdentityPolicy` states which accounts a peer may belong to,
and the two sides get different answers because they do not have the same
ability to tell who the peer is:
- The owner accepting a guest admits any issued account. Nothing at this
layer decides whether the guest joins — a human does, from the approval
prompt, which is shown the verified identity and which the admission
state machine makes unskippable (`Active` is reachable only through
`OwnerAuthorized`).
- A guest joining by invite or relay admits any issued account, because
the invite's signed locator already pinned the owner's Noise static key
and that pin is checked before admission runs. The device is
authenticated whatever account is behind it, which is what makes joining
a stranger's session safe.
- A guest joining over an unpinned LAN discovery still requires the same
account. A guest has no approval prompt — whatever it accepts, it
accepts silently — and nothing else names the peer there: mDNS is
spoofable and no key is known in advance. Relaxing it would let anyone
on the segment holding any valid ticket pose as the owner, undetected.
Opening this needs a way for the guest to confirm who it is joining,
which is a user-facing decision, not a protocol change.
Relaxing the account relaxes nothing else: issuer, expiry, and the binding
to the observed Noise static key are unchanged, and renewal still refuses
any mid-session change of issuer, subject, device id, or key. Tests cover
that a foreign subject is admitted while each of those still rejects, and
the unpinned-LAN case has its own regression guard.
Also fixes two things this uncovered. The live MCP tests in op-host-desktop
drove the endpoint with raw HTTP and no token, so they failed with 401
after bc75c765b authenticated it — they now send the instance token, which
is also a live check that the authentication works. And two constant
relations in the transport config were runtime asserts that clippy rejects
as constant-valued; they are compile-time asserts now, which is what a
relation between constants should have been.
The live MCP endpoint on 127.0.0.1 was a bypass of the collaboration
admission model. Its per-instance token authenticated only the ping probe
and shutdown, so document reads and writes were available to any local
process, and nothing validated Origin or Host — a page in a browser on the
same machine could reach it by DNS rebinding. During a session that is the
shared document, not just this user's file.
Every stateful call now requires the instance token, compared without an
early exit. Host must be a numeric loopback literal on the bound port, and
an Origin, when present, must match it; a request with no Origin still
works, which is what real CLI clients send. OPTIONS, initialize, and ping
stay tokenless so CLI discovery keeps working, and CollabGatePolicy is
untouched — this sits in front of it.
The `op` CLI did not send the token, so authenticating tool calls would
have returned 401 for every `op` invocation against a live editor. The
token was already in the port file next to the port; it is now resolved
with the port and travels as a header. Ping and shutdown keep their
existing tokenless wire contract.
Bootstrap cache: reads now degrade like writes already did. An unreadable
or corrupt cache leaves this start with no anti-rollback generation floor,
which is the position an absent cache has always left it in, and which the
threat model already accepts because deleting the file achieves the same
thing with no more privilege than corrupting it. Refusing bought no
security and cost the ability to collaborate at all. The tests state the
price plainly: with no floor the lower-generation document is accepted,
and `rollback_floor_armed` has to report it.
Threat model: correct an overstatement. Peer admission requires the remote
ticket's subject to equal the local account, so the product pairs only
devices of one account today. A relay operator reconstructs which devices
of an account sync and when — not a cross-account collaboration graph.
Also records that the relay reads exactly one field out of the ticket it
verifies, the expiry, which makes the identity disclosure gratuitous
rather than load-bearing, and states what a minimized credential would and
would not buy.
Follow-up to 0649cf40, which split renewal concurrency away from initial
authentication so an unauthenticated flood cannot starve live tunnels.
`online_challenge_timeout_keeps_its_auth_concurrency_permit` encoded the
old coupling as its contract — it saturated `max_auth_in_flight` to make a
second online challenge contend — so after the split nothing contended and
the test hung the whole suite. The property it covers (a timed-out online
challenge neither leaks its permit nor spawns another blocking task) is
unchanged; it now saturates the renewal budget that actually governs that
path. Its initial-path sibling was already passing and is untouched.
Roster: add `release_disconnected_peer`. A departed guest is retained for
same-epoch resume, but that retention is unbounded and counts against
`max_participants`, so a session that churns through guests eventually
admits nobody and each retained peer holds its result window until the
epoch ends. Releasing is refused for the owner and for a still-connected
peer, and it does not weaken id-space safety: a namespace can only be
reissued when the document carries none of its ids, which is checked
against the document rather than the roster.
Tickets: replace the derived `PartialEq` on `OpaqueTicket` with a
constant-time comparison. Nothing compares a ticket against a stored
secret today — verification lives in the host auth layer — but the derive
inherited `String`'s early-exit compare, so any future `==` on
attacker-supplied input would have been a timing oracle.
Bootstrap cache: a failed write now degrades instead of failing the
bootstrap. The document was already fully verified; what a failed write
costs is the anti-rollback generation floor for the next start, and the
threat model already accepts a missing floor because deleting the cache
has the same effect. An unwritable configuration directory must not mean
"cannot collaborate". The degradation travels with the document rather
than passing silently. Reads stay fail-closed: an absent cache reads as
`Ok(None)`, so only a cache that exists and is corrupt or unreadable
takes that path, and that is an anomaly rather than a first run.
Threat model: document what the relay operator actually sees. The bearer
ticket it verifies carries the account subject, device id, and optional
profile claims, so it learns who collaborates with whom — the asset table
previously implied those reached session participants only. Also adds the
invite-as-bearer-capability lifecycle and the local MCP endpoint, which
during a session reads and writes the shared document outside the
admission story. The relay-client doc comment no longer claims the relay
"sees only bytes".