Skip to content

The fleet key a paired browser was never allowed to be given - #44

Merged
karngyan merged 3 commits into
mainfrom
fix/adopt-fleet-key
Aug 10, 2026
Merged

The fleet key a paired browser was never allowed to be given#44
karngyan merged 3 commits into
mainfrom
fix/adopt-fleet-key

Conversation

@karngyan

Copy link
Copy Markdown
Contributor

A browser that paired before its machine had a working fleet key pinned no
fleet public key. That was terminal: fleet.ts returns an empty fleet without
one, so the browser read no directory, listed the single machine it paired
with, and the only cure was another pairing ceremony — on a fleet where every
machine had been joined correctly with --secret/--fleet. Machines were
fine; devices were stuck.

Why "the fleet key cannot be re-supplied" was too strong

The claim in the code was that a key learned from a connection is a key the
connection chose, so accepting one would be trust-on-first-use. True of an
arbitrary connection. Not true of this one.

A browser that pinned a machine's daemon static key at a ceremony has an
authenticated channel to that machine. Noise IK names the pinned key as the
responder's static and mixes it into es and ss, so message B only decrypts
for a peer holding its private half (web/src/crypto/noise.ts,
internal/crypto/handshake.go). A key arriving on that session is an
authenticated statement from a party the browser already trusts out of band —
not an assertion from an unknown peer.

  • A hostile relay cannot inject one. It carries ciphertext and cannot forge
    the session; a welcome it wrote never decrypts.
  • A compromised daemon gains nothing. The fleet seed is in relay.json on
    every machine, so a machine that could lie about this key can already mint a
    device certificate for any key it likes. It holds full fleet power already;
    handing over a wrong key is not an escalation.
  • A browser with no pinned daemon key anywhere still has only the QR
    unchanged, and correct.

The precedent was already in the tree: the device certificate rides the welcome
for exactly this reason. The fleet key belongs on the same path.

The exact trust condition

A fleet key may be kept from a welcome only when the session carrying it
is authenticated by a daemon static key this browser pinned at a ceremony of
its own — and only into an empty record.

It is a parameter (adoptFleetKey(w, pinnedDaemon)), not an inference, because
by the time a welcome arrives every session looks alike. FleetSource.pinned
carries the fact from the only place that knows it — whoever built the
transport:

how the daemon key was learned pinned may supply a fleet key
pairing ceremony (relayBoot, fleetSources pin) true yes
machine certificate out of the directory false no — the cert verified under the very key being replaced; the anchor cannot be moved by what it anchors
loopback /ws (session cookie, no key) false no

relayBoot's return type now says pinned: true explicitly, so a future second
way of reaching a machine cannot inherit the privilege by accident.

An existing pin is never overwritten. Two keys differ only if the fleet was
set up again, and this browser's device certificate is then signed by a key
nothing honours — adopting the new one would swap a browser that lists what it
can reach for one that lists what it cannot. The mismatch goes to the console
rather than being swallowed; pairing again (the only thing that mints a
certificate) stays the way out.

What heals now, and what still needs a ceremony

Heals automatically, in-session, no reload:

  • A browser paired before its machine had a fleet key, connecting to that
    machine. The welcome carries the key and the certificate; the key is taken
    first because the certificate verifies under it, so a browser holding neither
    is whole from one message. The fleet then re-expands the way #43's
    certificate adoption does (resupplied, one rebuild per epoch) and the other
    machines appear while the tab is open.

Still needs a ceremony, legitimately:

  • A tab that pinned no daemon key at all — a loopback tab, whose pairing link
    points at the relay's address rather than its own.
  • A browser whose fleet was set up again (its certificate is dead).
  • A browser whose only paired machine still holds no fleet key.

The band retired for the healed case. It used to read "Pair it again from any
machine on the fleet and they all appear", which is now false. It splits on
FleetGaps.pinned — the count of ceremonies this browser performed: at zero it
points at the relay's address, above zero it says a machine will hand the key
over as soon as it answers, and that nothing has yet. The stale comments in
fleet.ts and crypto/keys.ts that described a path which could not work now
describe the one that does.

Test evidence

Adversarial, because the refusals are the feature:

  • adopted when the sender's daemon key is pinned;
  • refused with no pin for that machine;
  • refused for a machine whose daemon key came only from a machine certificate —
    asserted through the real builder, which marks that source pinned: false;
  • refused for 31 bytes, 64 bytes, non-base64, empty, absent;
  • an existing, different pin is kept and warned about; an identical one is a
    no-op that warns about nothing.

End to end (a browser paired before its machine had a fleet key): a browser
with a pinned daemon key for one machine and no fleet key connects, and ends up
holding the fleet key and seeing every machine in the directory — real builder,
real key store, real certificate verification, only the transport and the
directory response stubbed. The mirror case asserts a ride this browser never
pinned keeps nothing and reads no directory.

Fails against the un-fixed code: the end-to-end heal, plus the two
adoptFleetKey cases that assert a key is kept. Verified by stubbing the
adoption out — 3 failed, 55 passed. Every refusal case passes either way, which
is the point of them.

Go: TestWelcomeCarriesTheFleetPublicKey — omitted before the fleet exists,
carried after a key arrives under a running daemon (the live read #43 added),
public half only, never the seed.

make web relay                                                   ok
go test ./...                                                    ok
go vet ./...                                                     ok
go test -race ./internal/crypto/... ./internal/transport/... ./internal/fleet/...  ok
cd web && pnpm vitest run                     58 files, 1140 tests passed
cd web && pnpm run lint                                          ok
cd relay && pnpm test                          6 files, 161 tests passed

🤖 Generated with Claude Code

karngyan and others added 3 commits August 10, 2026 22:18
A browser pins the fleet public key from the QR at pairing time and from
nowhere else. That was right and it was also final: a browser that paired
while its machine could not sign pinned nothing, so it read no directory
and listed the single machine it paired with, for good.

The device certificate had exactly this problem and rides the welcome to
solve it. The fleet key belongs on the same path, for the same reason: a
relayed connection is a Noise IK session opened against a static key the
browser pinned out of band, so what arrives on it is an authenticated
statement from a party the browser already trusts. Sent whenever this
daemon holds a key — read live, so a machine joined under a running
daemon answers with what it can sign under now — and omitted when it
holds none. The public half only; the seed never leaves the machines.

Which sessions may *keep* it is a property of the channel, and the client
is the end that knows which channel it is on. That half is next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The last forced re-pairing. A browser that paired before its machine had
a working fleet key pinned none, so it read no directory, listed the one
machine it paired with, and the only cure was another ceremony — on a
fleet where every machine had been joined correctly.

The prevailing claim was that a fleet key cannot be re-supplied without
becoming trust-on-first-use. True of an arbitrary connection, too strong
for this one: a browser that pinned a machine's daemon static key has an
authenticated channel to it, because IK names that key as the responder's
static and message B decrypts only for the peer holding its private half.
A relay cannot forge the session, and a daemon that could lie holds the
fleet seed already and can mint a certificate for any key it likes.

So the key is adopted from a welcome, under one condition and no other:
the session must be keyed to a daemon key this browser pinned itself.
Never from a machine reached on a certificate — that certificate verified
under the very key being replaced — and never from loopback, which
authenticates a cookie and no key at all. FleetSource.pinned carries the
fact from the only place that knows it, the code that built the
transport, and relayBoot now states it in its return type so a second way
of reaching a machine cannot acquire it by accident.

The key is taken before the certificate on the same welcome, because the
certificate verifies under it — so the browser that has been stuck
longest is whole again from one message — and the fleet re-expands in
session, the way a re-supplied certificate already does. The band that
said "pair it again from any machine on the fleet" now says the two
things that are still true: nothing has answered with a key yet, or this
tab never paired with anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The document said a browser pins the fleet key from the QR and stopped
there, which left the browser the QR reached too early with no described
way back — and no statement of why the wire is now allowed to carry one.

Both stated: the rule, and the reason it is not trust on first use. Also
what it excludes, since that is the load-bearing half — a machine reached
on a machine cert, whose Noise key the fleet key itself vouched for, and
loopback, which authenticates a cookie and no key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@karngyan
karngyan merged commit 07bc438 into main Aug 10, 2026
1 check passed
@karngyan
karngyan deleted the fix/adopt-fleet-key branch August 10, 2026 17:05
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