Skip to content

Fleet trust, stage 3: a directory the relay cannot read, and a browser that pairs once - #41

Merged
karngyan merged 29 commits into
mainfrom
feat/fleet-directory
Aug 10, 2026
Merged

Fleet trust, stage 3: a directory the relay cannot read, and a browser that pairs once#41
karngyan merged 29 commits into
mainfrom
feat/fleet-directory

Conversation

@karngyan

@karngyan karngyan commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Stage 3 of spec/fleet-trust.md, end to end: the relay
grows a store it cannot read, the daemons keep themselves converged with it,
and the browser stops needing a ceremony per machine. Pair a device once and
the fleet is the unit of trust.

Three parts, in the order they were built and have to be read.

A — the directory Durable Object (486b8d0)

One more Durable Object, and the only one that is not per machine
(idFromName("directory"), because one relay is one fleet). It holds signed
blobs — machine certs and revocations — and stores and serves and never
verifies
: the fleet key never touches the Worker, so it cannot tell a cert
from a revocation from 200 bytes of noise, and must not try.

  • PUT /directory (daemon secret), GET /directory (credential-less,
    metered), DELETE /directory (daemon secret, empties the set),
    WS /directory (daemon secret, push on write).
  • Content-addressed. The key is the SHA-256 of the exact bytes and there is
    no other name for one. A caller-supplied name would put the relay in charge
    of a namespace it cannot check, and one hostile secret-holder could then PUT
    a machine cert over a revocation with the relay's help. A PUT can only add;
    duplicates are idempotent; a blob comes back byte for byte.
  • Bounds, and no eviction. 4 KiB a blob, 512 entries, 507 over the cap
    rather than making room — every eviction policy can drop a revocation, and a
    directory that forgets a revocation re-admits the device it revoked. The way
    out of a full one is emptying the whole set, which needs no opinion about any
    blob: DELETE /directory, flue relay reset. See the review fixes below.
  • spec/relay-protocol.md gains the leg, the routes table and the ranking
    rule that is explicitly not the relay's.

B — the Go daemon's leg (d382196)

internal/transport/relay/directory.go: socket first, snapshot second (a write
that lands between the two arrives by one path or both, and ingest is
idempotent all the way down), then everything this machine holds, re-offered on
every connect and every half hour.

  • Nothing the relay says is trusted. Every blob, by GET or by push, is
    verified under the fleet public key and dropped if it fails; the relay's own
    bounds are re-enforced on this side.
  • Ingest is revocations only. A verified device cert read from the
    directory is deliberately not written to the local registry: possession of a
    public blob proves nothing, and a registry filled from a credential-less GET
    would be honoured by rule 1, which never looks at a cert — quietly turning
    "the fleet vouches for this key today" into "this machine pairs with it
    forever", surviving the fleet-key rotation meant to withdraw it.
  • The deploy learned to send migration history, so an existing relay at
    migration v1 gets FleetDirectory at v2; flue relay status grew a fleet:
    line that reads the directory and verifies it locally.

C — the browser (this part)

  • The fleet public key is pinned from the pairing link. internal/daemon
    writes &f=<fleet public key> beside k=; the pairing page reads it on the
    first render, pins it in one record per origin, and the scrub takes it with
    t and k. The QR is the one leg of the ceremony no intermediary can sit
    in — a fleet key learned from the answer to the POST would be
    trust-on-first-use one level up, and whoever supplied it could mint a machine
    cert for every machine the browser will ever dial. The public half only; the
    seed never reaches a browser.
  • web/src/crypto/cert.ts decodes and verifies internal/fleet's
    length-prefixed canonical encoding, and encodes it too — not because anything
    in the product signs, but because a decoder tested against its own output
    agrees with itself about a bug. Trailing bytes are refused rather than
    ignored; every fault is one answer, null.
  • Noise IK message A stops sealing an empty payload. It carries the device
    cert, which is what rule 2 of the daemon's acceptance order reads.
  • GET /directory builds the machine list. web/src/relay/directory.ts
    verifies every blob under the pinned fleet key, bounds the answer locally
    (4 KiB, 512 entries, a ceiling on the document — the relay's word for its own
    limits is not evidence), collects everything before ranking anything, and
    applies the reader's rule: a revocation outranks a device cert for the same
    key whatever the iat
    — the certificate it outranks being the one this
    browser now holds locally rather than one read off the relay. machines.ts folds the fleet's list into the stored
    records (mergeMachines, still holding no key); fleetSources builds a
    source per row — the ceremony's pinned key where there is one, the
    certificate's noise key otherwise.
  • The Remote screen carries part B's directory counts under the address the
    machine is reachable at, because a machine can be perfectly reachable and
    deaf to its fleet, and nothing said so.

The ordering constraint from the stage-2 review, and how it is satisfied

Stage 2 shipped rule 2 — a daemon admits an unknown key that presents a
fleet-signed device cert — and the review's condition on it was that fleet-wide
admission must not be switched on while the kill switch is still local. It was
dormant, and safely so: no browser ever sent a cert, so nothing was ever
admitted by it.

Part C is what switches it on, and it lands in the same PR as part B. The
first release in which a browser presents a device cert is the first release in
which a revoke performed on any machine is published to the directory, pushed
to every connected daemon, and applied there — registry row dropped by key,
live channels closed with the existing revoked{reason} flow. There is no
version of flue in which a device can walk into a machine it never paired and
a revoke reaches only the machine it was typed on.

Two properties keep that true rather than merely arranged:

  • The reader's rule is enforced on both sides. A daemon checks the revocation
    list inside the registry write (AddFromFleetCert), so a concurrent revoke
    cannot land between a check and a write; the browser refuses to present a
    cert whose key it has seen revoked, however much fresher the cert is.
  • Revocations only ever subtract authority, which is why honouring one from an
    untrusted channel is safe, and device certs are never ingested from that same
    channel, which is why the reverse is not.

Privacy delta

The directory makes visible to whoever runs the Worker — and to anyone who
reads the credential-less route — machine ids and display names, and who
revoked what and when. All of it signed rather than secret. The relay already
routed by machine id; the delta is machine names and the revocation history.

Device public keys and device names are not in it. They were in the first
three commits; the restructure took them out, because that route needs no
credential and a device certificate names a key and the label its owner typed.
A device now gets its own certificate from the machine that minted it — in the
pairing answer and in every relayed welcome — which is both more private and
the end of the directory's only unbounded growth term.

What that does and does not buy, precisely, because it is easy to overclaim.
It does not hide a device certificate from the relay's operator. A relayed
pairing transits the Worker in cleartext — the token, the device key, the label
you typed, and the certificate in the answer — and no directory arrangement
changes that, because the request is the ceremony. What it removes is the
anonymous reader: GET /directory takes no credential, so a published
certificate was a device key and its owner's label readable by anyone who knew
the relay's address, permanently, plus one of 512 entries per ceremony. "Not
published to strangers", not "not seen by the operator". docs/RELAY.md,
spec/fleet-trust.md and spec/relay-protocol.md's "What the relay sees" all
now draw that line rather than blurring it.

Flag day

An existing relay must run flue relay update once. FleetDirectory is a
second Durable Object class, and a Worker deployed before it has no such
binding: /directory answers 503 {"error":"directory unavailable"} and
flue relay status names the fix in as many words —

fleet:    unreachable (this relay has no directory; run `flue relay update` to redeploy it)

Until then no revocation crosses machines and no browser learns of a machine it
did not pair with by hand. Device certificates are unaffected either way: they
never travel through the directory, so a device gets its own from the machine
that paired it on a relay of any vintage. Nothing else is affected: sessions,
pairing and every already-paired machine keep working, which is what makes this
an upgrade rather than an outage. The deploy reads the migration tag the
account's copy of the script already carries and sends only the steps behind
it, so a fresh account runs v1 then v2 and an existing relay runs v2 alone.

Review fixes

An adversarial review of the three commits above returned SHIP WITH FIXES, no
critical findings. What follows is what changed, one commit each.

A full directory permanently disabled the fleet-wide kill switch (HIGH).
put answered 507 before the store and before the fan-out, so a revocation
past the cap was neither kept nor pushed: it reached only the machine it was
typed on, and re-offering it was answered 507 on every reconnect forever.
Nothing evicts, the object is named by a constant, and Durable Object storage
outlives every redeploy — so the documented escape hatch ("flue relay setup
mints a fresh secret and fleet key, and the new relay starts empty") was simply
false. DELETE /directory empties the whole set and the count, gated by the
same daemon secret a PUT presents, and closes every push socket with 1012 so
each daemon reconnects and re-publishes in seconds rather than at its next
half-hourly republish. All-or-nothing is what makes it a thing the relay may
do: a wipe needs no opinion about any blob, where a prune would need the fleet
key. flue relay reset is the operator's side, behind a typed "yes". The
residual — a blob whose only remaining holder never reconnects — is stated in
the class comment, in the spec, in the command's warning and in the docs.
Setup deliberately does not reset: it would be racing the workers.dev
subdomain and a freshly bound secret, on the one command a user runs first.

The 507 story the docs told was wrong (MEDIUM). "reaches only the machines
that were connected to hear the push" — there is no push. The docs, the spec's
Bounds paragraph and the daemon's log line now say what happens: not stored,
not pushed, reaching nobody, and a revoke made past the cap takes effect on the
machine it was typed on and on no other.

The migration tag read failed open (MEDIUM). deployedMigrationTag
answered "" for every failure, and "" meant "fresh account". So an existing
v1 relay whose pre-flight GET .../scripts timed out sent the whole history
untagged → 10079 → the blanket already-applied recovery dropped migrations →
re-PUT bound FleetDirectory to a class no migration created → 10061, and
flue relay update died. The read now answers (tag, known); an unknown tag
still sends the full history so a first deploy works, but the guess is
corrected rather than abandoned — 10079 names the tag the script really
carries, so the retry parses it out and recomputes. A precondition it cannot
read fails closed, the way wrangler does, and one naming the tag already sent
is not retried at all.

Nothing warned before the cap (MEDIUM). flue relay status and the Remote
screen now warn from 90% and name flue relay reset, from single constants on
each side rather than a second copy of 512.

"Availability is the relay's only power" was overstated (MEDIUM). True of
the routing legs and of certificates; for revocations, omission adds — a
machine that never hears one keeps admitting a device that was cut off — and a
GET is shape-identical complete or filtered. spec/relay-protocol.md gains
"What withholding costs": the asymmetry, what bounds it (every holder
republishes every revocation on connect and every 30 minutes; a machine that
heard one never unhears it; the relay still cannot mint the certificate it was
about), and the real fix as future work — a manifest or epoch signed under the
fleet key. Not built here.

Four low-severity items. The migration history, DO bindings,
run_worker_first and rate rule are now compared against relay/wrangler.jsonc
by a test rather than by prose — a wrangler-only v3 would have made every
later flue relay update skip migrations silently. An unknown deployed tag says
so instead of being a silent no-op that surfaces as a baffling 10061.
cert.ts's encoder explains why its iat ceiling is 2^53 where Go's is 2^62
(a number cannot carry more exactly, and signing a rounded value is the bug
the refusal prevents), and a test pins that the decoders agree to the unit.
MAX_DAEMON_SOCKETS has a test; GET /directory has an ETag and answers a
matching If-None-Match with 304, which the daemon's leg now uses — caching
the tag only for a snapshot it fully applied, so an unapplied revocation is
retried rather than skipped; and the ratelimits comment lists /directory.

The CSP finding: real. The daemon serves its UI under connect-src 'self' ws://127.0.0.1:* ws://localhost:*. A loopback tab reaches every machine that
is not this one over wss://<relay>/client/<id> — pre-existing, adoptRemotes
on main has the same shape — and this PR adds fetch('https://<relay>/directory').
Neither is same-origin, so the browser blocked both, and the fetch failed
silently because readDirectory answers "no machines" for every fault: a
loopback tab showed a fleet of one. daemon.LocalCSPFor adds the one relay
origin in both schemes, fed from relay.json rather than from the transport's
status, because a document's policy is fixed when it is served. The
relay-served copy needed nothing — there the relay is the origin.

Two restructure changes, one taken and one refused

Taken: device certificates leave the directory. A device now gets its own
certificate from the machine that minted it — in the pairing answer, verified
against the fleet key the browser pinned from the QR seconds earlier, and again
in the welcome of every connection it opens anywhere in the fleet, inside
Noise, to a device that has just proved it holds the key the certificate names.
That second channel is the re-supply path: a browser that never stored one, or
lost it, or was paired before its machine had a fleet key, picks one up from
any machine it can still reach.

Publishing them as well bought one thing — a browser being able to find its own
— and cost two: a credential-less roster of the operator's device keys and
labels, and one permanent entry per pairing ceremony ever performed, which was
the growth that made a full directory reachable at all. No daemon ever read one
from there; rule 2 reads the certificate the device presents.

The UX constraint holds and is the point: pair once and every machine works,
later-joined ones included, because the certificate travels with the device;
revoke anywhere and the device dies everywhere, because revocations still ride
the directory and its push socket, and the browser still reads them to stop
presenting a certificate the fleet has cut off.

Refused: pruning superseded entries. Because there is next to nothing left
to prune, not because a delete would be dangerous. A revocation is never
superseded; a device certificate is not in the store; what remains is a machine
certificate re-minted by a re-join, single digits out of 512 over a fleet's
life. A mechanism and its tests to reclaim a fraction of a percent of a store
that no longer grows with use is not a trade worth making, flue relay reset
covers the failure mode, and the 90% warning makes the wall a decision.

Two arguments are recorded as not the reason, so nobody re-derives them: that
a targeted delete on the daemon secret would let one compromised machine delete
any entry — true, and DELETE /directory on that same secret already deletes
every entry including every revocation, self-healing for the same reason a
targeted delete would be; and that a signed delete needs the relay to verify —
true, but an argument about which mechanism, not about whether to have one. The
verify-only gate the review raised (the fleet public key bound to the Worker,
authorising a digest delete) is recorded in spec/fleet-trust.md as future
work and rejected for the reasons that are about it: it makes the Worker a
policy engine, and it creates a fleet-key rotation problem nothing else here
has.

Delta-review fixes on the restructure

The restructure above was reviewed on its own and came back SHIP WITH FIXES:
the trust plumbing was sound — both delivery paths verify under the pinned
fleet key and check the certificate's subject against the recipient's own
static key — but it was half-applied. One commit each.

  1. The ceremony still published the certificate. Directory.mine() (the
    bulk republish) had stopped; pairing.go had not, so every ceremony still
    spent a permanent entry and still put a device key and its owner's label on
    the credential-less GET /directory — both costs the restructure exists to
    remove. Removed, and PublishFleetBlob now parses the kind and drops a
    device certificate outright so it cannot come back. The branch's own
    TestPairingPublishesTheDeviceCertToTheFleet asserted the wrong behaviour
    and is replaced by TestPairingPublishesNothingToTheFleet, which also
    asserts the certificate is minted into the registry so the new test cannot
    pass for the wrong reason. It fails against the un-fixed code with "a
    pairing published [device] to the fleet directory, want nothing". This is
    what makes the four documentation claims in this description true.
  2. The "older flue" diagnostic can never be true. No released flue has a
    fleet directory at all, so nothing released can have published to one. Both
    surfaces now say what is true — "nothing in this fleet should be publishing
    one" — and stop there; a build from this branch's middle commits or a
    hand-written PUT is not a diagnosis a count can make.
  3. Re-supply took effect only on the next page load. A browser that booted
    without a certificate built sources for its pinned machines, picked the
    certificate up from the first welcome, and did not gain the machines it
    unlocks until a reload. It happened to work because IndexedDB beats a
    network fetch — timing, not construction, and untested. adoptFleetCert now
    reports when it stored a certificate the browser did not have, and
    FleetClient re-runs the expansion once. Three tests, including the race
    that used to decide it.
  4. A loopback tab has no re-supply path, and four comments said otherwise:
    the loopback welcome carries no certificate (the daemon cannot know a
    cookie-authenticated tab's device key) and a loopback pairing writes no
    per-machine pin, so such a tab has nobody to ask and re-pairs. Comments
    corrected rather than a mechanism invented — the only path that would work
    is a new request in which a tab names its own device key, which is protocol
    surface this review was not the place to add.
  5. No browser test for the pairing-answer delivery, which is the primary
    channel. Seven cases through the real router and the real signer: kept when
    the link's fleet key signed it and it names this browser's key; dropped for
    another signer, another subject, a machine certificate in that slot, and
    garbage; absent is not an error. The seventh pins the load-bearing ordering
    — the verifying key comes from the QR and is never re-read from storage —
    and all four refusals were checked by mutation.
  6. fleetCertFor matched on the 48-bit DeviceID, where FindByKey's own
    comment explains at length why an id-only match is unsafe (Add permits
    colliding ids). ConnMeta now carries the static key the handshake proved
    and the lookup resolves on those bytes.
  7. The pruning refusal is rewritten around the reason that is true; see
    above.
  8. Stale comments across publishAll, FleetDirectory, the browser's
    directory reader and docs/RELAY.md's flag-day paragraph, plus the privacy
    delta stated precisely in the three documents that owe it.

Test evidence

make web relay clean; go vet ./... clean.

suite result
go test ./... all packages ok (incl. internal/fleet, internal/daemon, internal/transport/relay, internal/cloudflare, internal/relaydeploy)
go test -race ./internal/crypto/... ./internal/transport/... ./internal/fleet/... ok
cd relay && pnpm test 161 passed (6 files)
cd web && pnpm vitest run 1118 passed (58 files)
cd web && pnpm run lint, cd relay && pnpm lint clean

Watched failing against the un-fixed code, for the two the review asked for:

  • relay/test/directory.test.ts → "lets a revocation that was refused at the
    cap be published after a reset"
    — 404 from the router, which had no DELETE.
  • internal/cloudflareTestDeployRecoversTheUpgradeWhenTheTagReadFailed
    — dies with the real cloudflare: 10061 … binding DIRECTORY refers to class FleetDirectory which is not exported by the script, which is the chain that
    killed flue relay update. Its two siblings —
    TestDeployFailsClosedOnAnUnreadablePrecondition and
    TestDeployDoesNotRetryATagItAlreadyClaimed — both see the blind second PUT.
  • And the migration drift guard: adding {"tag":"v3",…} to relay/wrangler.jsonc
    alone fails TestMigrationHistoryMatchesTheWranglerCopy.

Negative tests are the point on a trust boundary, so here is what each of them
catches, and which ones were watched failing against the un-fixed code:

  • web/src/crypto/noise.test.ts → "reproduces msg1 with the certificate in
    the payload"
    verified failing against the previous noise.ts, which
    sealed an empty payload: expected '7aa6099ce8…' to be '7aa6099ce8…', the two
    differing from the payload ciphertext onward. testdata/noise/ik-payload.json
    is the Go responder's own vector and msg1Payload is the signed device cert
    from testdata/fleet/certs.json byte for byte, so this single case ties the
    TS cert encoder and the TS initiator to Go at once.
  • The wrong-key casesverified failing with the signature check
    stubbed out of verifyCert: five tests fall, including "never builds a source
    from a machine certificate signed by another key" and "never makes a machine
    out of a certificate signed by another key". A machine cert that does not
    verify under the pinned fleet key never becomes a record.
  • A revoked device is refused even with a fresher cert beside the
    revocation
    , in either order in the answer, and gets no fleet-derived
    sources; it also stops presenting the cert to a machine it did pair with.
  • A blob cut short or with a byte appended is refused, per kind, both in
    cert.test.ts against the committed vectors and in directory.test.ts.
  • A directory the relay truncated costs visibility only — fewer machines,
    no throw, paired machines untouched — as does a 503, an unreachable relay, a
    body that is not a directory, and a document over the size ceiling.
  • The encoder round-trips the Go fixtures: all four cases' canonicalHex
    and signedHex, including the astral-character name that catches a length
    prefix counting UTF-16 units.

🤖 Generated with Claude Code

karngyan and others added 29 commits August 10, 2026 13:34
…read

Auto-pair needs one piece of distribution: a device paired on machine A has
to reach machine B, and the machine list has to reach the browser. Daemons
do not talk to each other and should not start to, and everything that needs
distributing is a public signed artifact — so the relay hosts a directory and
verifies none of it.

One Durable Object for the whole relay (idFromName("directory"), because one
relay is one fleet) with three shapes on one URL: PUT and a push socket
behind the daemon secret, GET behind nothing but the rate rule that already
meters /client and POST /api/pair. The invariant the leg exists to preserve,
and the one every future change to it has to keep: the relay stores and
serves, and never verifies. Readers check every signature under the fleet
public key; a hostile relay can serve a stale, truncated or empty directory,
exactly as it could always refuse to route, and cannot mint anything, because
minting needs the key the Worker deliberately does not hold.

Entries are content-addressed — the storage key is SHA-256 of the exact bytes
and there is no other name for one. That is the only key a Worker which
cannot read a blob is entitled to compute: a caller-supplied name would put
the relay in charge of a namespace it cannot check, and one buggy or hostile
secret-holder could then PUT a machine cert over a revocation with the
relay's help. A PUT can only ever add. Idempotence and byte-exactness fall
straight out of that, and both are what the suite leans on hardest: bytes the
relay had altered would not hash to the name it filed them under.

Bounded at both ends, because this is a credential-less-readable store fed by
secret-holders: 4 KiB a blob, 512 entries, and at the cap a refusal (507, its
own status) rather than an eviction. Every eviction policy can drop a
revocation, and a directory that silently forgets one re-admits the device it
revoked to every machine that had not yet heard.

The Go deploy owes the directory a binding, a v2 migration and two
run_worker_first entries; it gets them with the daemon side of this, and the
Worker answers 503 rather than throwing until it does.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…a kill switch that crosses machines

The relay grew a fleet directory in the last commit and nothing spoke to it.
This is the other end: the deploy that ships the object, the leg that keeps a
machine converged with its fleet, and the rule that a revocation performed on
one machine closes the device on all of them.

**The deploy.** `flue relay setup` was still deploying a Worker whose
/directory answered 503, because the Cloudflare client could only ever say
"introduce these classes" — and that is not expressible for a relay already
sitting at migration v1 that needs FleetDirectory at v2. So the client now
carries the migration *history* and reads the tag the account's copy of the
script already has (GET /accounts/<id>/workers/scripts, the same fact wrangler
reads from the same place), sending only the steps behind it. A fresh account
runs v1 then v2; an existing relay runs v2 alone, against the precondition that
it is still at v1; an up-to-date one sends no migration at all, which is one
fewer script upload than the blind send-and-recover this replaces.
migrationAlreadyApplied stays as the belt for the paths the read cannot cover.
The DIRECTORY binding and the two /directory run_worker_first entries land
beside it, and wrangler.jsonc's comment about what the deploy still owed is
now a note that it owes nothing.

**The leg.** internal/transport/relay/directory.go: socket first, snapshot
second (a write in between arrives by one path or both, and ingest is
idempotent), then everything this machine holds, re-offered on every connect
and every half hour. Nothing the relay says is trusted — every blob, by GET or
by push, is verified under the fleet public key and dropped if it fails — and
nothing about the relay's own bounds is taken on faith: 4 KiB a blob, 512
entries a snapshot, 4 MiB a body, enforced here as well as there.

**Publishing.** The machine cert is minted where the facts it asserts are
decided — `flue relay setup`, `flue relay join`, the Remote screen's deploy —
and stored in relay.json, because the directory is content-addressed and a cert
re-signed at every boot would spend an entry per daemon restart. Device certs
go out as the ceremony mints them, revocations as the operator makes them, and
the leg refuses to publish a machine cert that no longer names this daemon's
static key: a stale one is a machine every device dials and none can handshake
with.

**Ingest: revocations only.** A verified device cert read from the directory is
deliberately *not* written to the local registry. Possession of a public blob
proves nothing; the IK handshake is what proves a browser holds the key, which
is why rule 2 of the acceptance order writes the row at that moment. A registry
filled from the directory would be honoured by rule 1, which never looks at a
cert — quietly turning "the fleet vouches for this key today" into "this
machine pairs with it forever", surviving the fleet-key rotation meant to
withdraw it. Revocations are the opposite: they only subtract authority, so
honouring one from an untrusted channel can never grant access. A verified one
reaches AddRevocation, drops the registry row by key (not by the 48-bit id),
and closes that device's channels with the existing revoked{reason} flow.

**Status.** `flue relay status` now reads the directory itself and reports what
it is holding, how much of it this fleet key signed, and whether this machine
is in there at all — the one fault a new machine actually has. The Remote
screen's /api/relay/info carries the same counts.

Tested against a fake directory that lies in every way a real one could: a blob
signed by another fleet key is dropped, a truncated or extended or bit-flipped
one is dropped, an oversized one is dropped unread, a revocation published
elsewhere closes the device here, ingest is idempotent across both paths, 507
and 413 are loud and do not wedge the leg, and a relay serving a truncated or
empty set costs freshness and nothing else.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`k=` anchors a browser to one machine. `f=` anchors it to the fleet: a
device that pins it accepts any machine whose machine certificate verifies
under that key, and pins the certificate's `noise` key for the IK handshake
(spec/fleet-trust.md, "What changes in the handshake"). That is a
fleet-wide trust decision, so it travels the one leg of the ceremony no
intermediary can sit in — the QR — in the same unpadded URL-safe base64,
spliced raw beside the token.

The public half only, and never the seed: a browser holding the seed could
mint certificates for the fleet. A daemon with no fleet key omits the
parameter rather than sending it empty, because the pairing page reads a
present-but-unusable key as a link that has been tampered with, and a relay
from before the fleet key is not that.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s one

The browser's half of internal/fleet: crypto/cert.ts decodes and verifies the
length-prefixed canonical encoding under a fleet public key, and encodes it
too — not because anything in the product signs (the browser holds no private
half) but because a decoder tested against its own output agrees with itself
about a bug. testdata/fleet/certs.json is the other party: same fields, same
canonical bytes, same blob verifying under the same key, or one of the two
implementations has drifted.

Noise IK's message A stops sealing an empty payload. It carries the device
certificate when there is one, which is what rule 2 of the daemon's acceptance
order reads (channel.go, admitByFleetCert) — a machine this browser never
paired with admits it on that certificate or not at all. Pinned by
testdata/noise/ik-payload.json, whose msg1 is the fixture cert sealed into the
fixture handshake: the vector fails byte-for-byte against an empty payload,
which is what it is for.

Every fault in the reader is one answer, null: not a certificate, a version it
does not speak, a field cut short, a string that is not UTF-8, bytes left over
after the signature, a signature under another key. A blob a hostile relay
chose is not owed a diagnosis, and a caller walking a directory has one thing
to do with each — drop it. Trailing bytes are refused rather than ignored,
because a signed statement with something appended is a statement the
signature does not cover.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pairing link's `f=` is read on the first render, held in memory, and
written to one record per origin (crypto/keys.ts, FLEET_RECORD) once the
ceremony this device physically performed has otherwise succeeded. It is the
anchor everything after this hangs from: a machine certificate that verifies
under it is a machine this browser will dial, pinning the `noise` key that
certificate names. So it comes out of the QR — the one leg of the ceremony no
intermediary can sit in — and never out of the answer to the POST, which is
the trust-on-first-use the pinning exists to end, one level up.

The public half only, and 32 bytes or nothing: an `f` of any other width is
refused as no fleet key rather than rounded off into one, and the machine
still pairs. A link with no `f` at all is a relay from before the fleet key,
which pairs exactly as it always did.

The scrub takes `f` with `t` and `k`. The keys are public and go with the
token anyway: they are read once and never from the address bar again, so a
link still carrying them is a live-looking pairing link with a spent token in
it, sitting in a history entry to be reopened or forwarded. `d` and `n` stay,
as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`GET /directory` is how a browser learns of a machine that joined the relay
after it paired. relay/directory.ts reads it and trusts none of it: every blob
is verified under the fleet key pinned at pairing, the relay's own bounds are
enforced again on this side (4 KiB a blob, 512 entries, a ceiling on the
document), and everything is collected before anything is ranked — the answer
promises no order, so a revocation filed before the certificate it kills must
still kill it.

Two reader's rules live there and nowhere else. A revocation outranks a device
certificate for the same key whatever either `iat` says: not "the newer wins",
which would let a certificate minted after the revoke undo it. And the entry
key is not what decides anything — content addressing is the relay's filing
system, the signature over the bytes is the trust decision.

machines.ts folds the two lists into one (`mergeMachines`), still holding no
key: records first, then whatever the fleet named and they did not, with the
machine's own signed name winning over the one copied out of a pairing link.
fleetSources builds a source per row — pinned key where there is one, the
certificate's `noise` key otherwise, and the device certificate sealed into
message A, which is the only door a machine this browser never met will open.

A certificate signed by anything but the pinned fleet key never becomes a
record; a revoked device presents nothing and gets no fleet-derived sources
even with a fresher certificate beside the revocation; a relay serving half
the set costs machines this browser cannot see and never one it should not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… day

docs/RELAY.md owes an operator four things part B left it: that the directory
exists and how a machine, a device and a revoke travel on it; that a relay
deployed before it needs `flue relay update` run once, until when `/directory`
answers 503 and `flue relay status` says exactly that; what the fleet: line
means, and why `verified` is the only number in it with authority; and the
privacy delta — machine ids and names, device public keys and names, and who
revoked what and when are visible to whoever runs the Worker, signed rather
than secret.

The 507 gets its own paragraph, because a full directory is the one failure
here that is quiet and serious: nothing already published stops working, but
the next thing the fleet signs is not distributed — including a revocation,
which then reaches only the machines that were connected to hear the push.

Also corrected: the section that said revoking was per-machine and the
directory was the next stage, and the compromised-machine analysis, which now
has to account for a machine certificate naming a Noise key of its own.

The Remote screen carries the same counts under the address it is reachable
at, since a machine can be perfectly reachable and deaf to its fleet, and
nothing on that screen said so.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lue relay reset`

A full fleet directory permanently disabled the fleet-wide kill switch, and
the documented recovery did not exist.

`put` returned 507 *before* the store and before `push(blob)`, so a
revocation past the cap was neither kept nor fanned out: it reached only the
machine it was typed on. Nothing evicts (deliberately — every eviction policy
can drop a revocation), the object is `idFromName("directory")` on a constant,
`internal/relaydeploy` ships only `new_sqlite_classes`, and Durable Object
storage outlives every redeploy. So 512 was forever, and growth is monotonic:
one permanent entry per ceremony, per revocation, per machine.

`DELETE /directory`, gated by the same daemon secret PUT and the socket
present, empties the whole set and the count, and closes every push socket
with 1012 so each daemon reconnects and re-publishes at once instead of at its
next half-hourly republish. All-or-nothing is what makes it a thing the relay
may do: a wipe needs no opinion about any blob, where a prune would need the
fleet key the Worker must never hold.

`flue relay reset` is the operator's side, behind a typed "yes" (or --yes),
because the wipe has one cost the fleet does not put back: a blob whose only
remaining holder never reconnects. That residual is stated in the class
comment, in the spec, in the command's own warning and in docs/RELAY.md, whose
"`flue relay setup` mints a fresh secret and fleet key, and the new relay
starts empty" was simply false — setup reuses the script name and never
touched the directory.

Setup deliberately does not reset: it would have to send that request in the
seconds after enabling the workers.dev subdomain and binding a *new* secret,
racing both propagations, on the one command a user runs before they know what
a fleet directory is — and failing loudest on a first deploy with nothing to
clear. The comment on runRelayReset records the decision.

Verified failing without the fix: relay/test/directory.test.ts → "lets a
revocation that was refused at the cap be published after a reset" (404 from
the router, which had no DELETE).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/RELAY.md claimed a revocation past the cap "reaches only the machines
that were connected to hear the push". There is no push: `put` returns 507
before the store, and the socket carries new entries, so a refused blob is
neither kept nor fanned out. It reaches nobody.

The sentence now says that, and follows it to the consequence that matters —
a revoke made past the cap takes effect on the machine it was typed on and on
no other, because a revoke is local first, and re-offering it is answered 507
on every reconnect and every republish. The daemon's log line says the same
thing in its own voice and names `flue relay reset`; the spec's Bounds
paragraph gains the clause it was missing.

A new relay test pins it: with the directory full, a refused PUT delivers
nothing to a connected daemon socket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… a precondition properly

`deployedMigrationTag` answered "" for every failure, and "" meant "fresh
account: send everything, no old_tag". So an existing v1 relay whose pre-flight
`GET .../scripts` timed out sent v1+v2 untagged, Cloudflare refused it on the
tag precondition (10079), the blanket already-applied recovery matched, dropped
the migrations and re-PUT — and that upload binds DIRECTORY to a class no
migration created, so Cloudflare refused again and `flue relay update` died.
The v1 → v2 upgrade this release requires was one transient error away from
impossible, and the comment claimed the opposite cost.

The read now answers (tag, known). Unknown still sends the whole history, so a
genuine first deploy works for a token that cannot list scripts — but it is a
guess now rather than a fact, and the guess is corrected rather than
abandoned: 10079 names the tag the script really carries, so the retry parses
it out and recomputes the migration from it (old_tag: v1, v2's step alone).

A precondition whose message does not name the expected tag fails closed, the
way wrangler does — retrying with no migrations is the very move that produces
the second, worse refusal — and a precondition naming the tag this upload
already claimed is not retried at all, because the retry would be identical.
`migrationAlreadyApplied` keeps only the "migration … already" family, which
wants the opposite repair and gets to keep it.

Verified failing without the fix, all three: the recovery test dies with the
real 10061, and both no-retry tests see the blind second PUT.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rfaces

Nothing said a word until the cap was already reached, and the cap has no
gentle failure: at 512 the relay refuses every new blob before storing it and
therefore before pushing it, so a revocation made past that point reaches
nobody, and no deploy or update frees an entry. The operator's first signal was
a 507 in a daemon log, after a revoke they believed had crossed the fleet.

`flue relay status` and the Remote screen now warn from 90% of the cap and name
`flue relay reset` as the way out, each in its own voice. The threshold and the
cap are single constants on each side — `MaxDirectoryEntries`/`DirectoryWarnAt`
in internal/transport/relay, `MAX_ENTRIES`/`DIRECTORY_WARN_AT` in
web/src/relay/directory.ts — rather than a second copy of 512 beside the reader
that enforces it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ly power"

The claim is exactly true of the routing legs and of the certificates in the
directory: withholding one subtracts authority a reader would otherwise have
had. It is not true of revocations. Omission there *adds* — a machine that
never receives one goes on admitting a device the operator cut off — and a
`GET /directory` is shape-identical whether it is complete or filtered, since
entries are signed one by one and the set is signed not at all.

spec/relay-protocol.md gains a "What withholding costs" section stating the
asymmetry, what bounds it in practice (every holder republishes every
revocation on connect and every 30 minutes; a machine that heard one never
unhears it; the relay still cannot mint the certificate the revocation was
about), and the real fix as future work: a manifest or epoch signed under the
fleet key, so a reader can tell a complete answer from a curated one. Not built
here — it needs rules for who mints it and for readers meeting two epochs.

relay/src/directory.ts and docs/RELAY.md carried the same sentence and now
carry the same exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Worker has two configurations — relay/wrangler.jsonc for `pnpm dev` and
the vitest pool, the constants in internal/relaydeploy for a real deploy — and
only a comment kept them level. A `v3` added to wrangler.jsonc alone would make
every later `flue relay update` skip migrations silently: the account reports
v2, the client's own history ends at v2, so it sends nothing and the class is
never created, on a deploy that succeeds.

deploy_test.go parses the file (a small JSONC stripper that respects string
literals, so a `//` inside a URL survives) and compares the migration history
tag for tag, the Durable Object bindings, `run_worker_first`, the assets
binding, the compatibility date, the default script name and the rate rule's
three numbers. It also checks that every bound class is one some migration
creates — the 10061 that killed `flue relay update`, caught here instead.

`DOBindings` becomes a package-level var so the guard compares what the deploy
sends rather than a second hand-typed copy. Same pattern and same reason as
TestRelayAssetHeadersMatchTheWranglerCopy.

Verified failing: adding `{"tag":"v3",...}` to wrangler.jsonc alone fails
TestMigrationHistoryMatchesTheWranglerCopy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A script carrying a migration tag this binary has never heard of gets no
migration sent, deliberately — replaying an older history over it would undo a
step this flue does not know about. That was right and completely silent, and
if the newer Worker carried a Durable Object class this module does not, the
upload that follows is refused with a 10061 naming the class, with nothing
anywhere connecting it to "you are running an older flue than the one that
deployed this relay".

`DeployInput.OnNote` is the client's one way of reporting a condition it can
detect and cannot fix, and this is its only caller today. relaydeploy wires it
through `Input.OnNote`, kept separate from `OnStep` so a caller printing
"  ✓ %s" per step does not put a tick on a warning, and falling back to OnStep
when unset so nobody swallows the line by omission. `flue relay update` sets it
even though it prints its own steps: that is the command that meets this.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… the decoders do not

Go's `appendIAT` signs anything up to 2^62; `cert.ts`'s `u64` refused anything
past 2^53 and said only "is not a unix time". The asymmetry is right and was
undocumented, which made it look like an oversight.

It is a fact about the language, not the format: a `number` stops being exact
at 2^53, so an iat above it has already been rounded by the time it reaches the
encoder, and writing it would put bytes under a signature nobody named. Throwing
is the only honest answer, and it costs nothing — this encoder exists so the
decoder is tested against something other than itself, the browser signs
nothing, and 2^53 seconds is nine million years out. `MAX_ENCODABLE_IAT` says
all of that, and the thrown message now names the reason.

Where agreement actually matters — reading — the two are identical, and a test
now pins it rather than leaving it to the differential fuzzing: a properly
signed blob with iat 2^60 verifies, exactly 2^62 verifies, 2^62 + 1 does not,
which is Go's `maxIAT` boundary to the unit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…comment that is true

Three hardening items on the directory leg.

**MAX_DAEMON_SOCKETS had no test.** An untested bound is a number, not a
limit. There is now one that opens 256 sockets, watches the 257th refused with
503, and pins that there is no reaper — room appears when a socket closes and
not before, which is the design and was worth stating.

**The credential-less GET is the largest thing the relay serves** — 512 × 4 KiB
of base64 is about 2.8 MiB — and every daemon re-read all of it on every
reconnect, where the usual answer is "nothing new". It now carries an `ETag`,
and a matching `If-None-Match` is answered 304. The tag is a storage version
counter bumped by every write that lands and by every reset: never by a
duplicate PUT, and never reissued after a wipe-and-refill to the same contents.
`Cache-Control: no-store` stays — a directory served from a cache is a
revocation served late — so this is for clients that revalidate on purpose, and
the daemon's leg now does.

The daemon caches the tag only for a snapshot it *fully applied*. A revocation
the sink could not take leaves the tag unset so the next read is unconditional;
caching over it would postpone the retry until somebody else published, and the
artifact dropped is the kill switch. `ingest` grew a `retry` result to say
which failures are worth trying again — a blob that does not verify never is.

**The `ratelimits` comment** in wrangler.jsonc listed the routes it covers and
had not been updated when `/directory` joined them. It now names them, and says
that the rate rule — not the ETag — is what bounds an anonymous caller who
simply asks again.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Confirmed, and it is real. The daemon serves its UI under `connect-src 'self'
ws://127.0.0.1:* ws://localhost:*`. A loopback tab reaches every machine that
is not this one over `wss://<relay>/client/<id>`, and this PR adds
`fetch('https://<relay>/directory')` to learn which machines those are. Neither
is same-origin, so the browser blocks both.

The socket half is pre-existing (`adoptRemotes` on main has the same shape);
the fetch is new here, and it is the worse of the two to lose, because
`readDirectory` catches every fault and answers "no machines" by design — so a
loopback tab showed a fleet of one and said nothing about why.

`daemon.LocalCSPFor` adds the one relay origin to `connect-src`, in both the
schemes the page uses on it, and `SetRelayOrigin` feeds it from relay.json
rather than from the transport's status: a document's policy is fixed when it
is served, and a tab opened while the relay is still dialling still has to be
allowed to reach it a second later. A daemon with no relay is unchanged.

The relay-served copy of the bundle needed nothing — there the relay is the
origin and `RelayCSP`'s `'self'` already covers both.

docs/FOLLOW-UPS.md §6 records the widening beside the wildcard-port item it is
not: one exact origin and two schemes, from a file this daemon already dials.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…n every welcome

Groundwork for taking device certificates out of the relay's public directory.

Until now the only way a browser got its own fleet certificate was to find it
in `GET /directory` — a credential-less route — which made that document a
roster of every device's public key and human label, readable by anyone who
knew the relay's address, and spent one of the directory's 512 permanent
entries on every pairing ceremony ever performed. That is the largest term in
the growth that makes a full directory (and therefore a dead kill switch)
reachable at all.

There are two better channels and the daemon already speaks both. The pairing
answer now carries `deviceCert`: the browser pinned the fleet public key out of
the QR seconds earlier, so it verifies the blob itself and a hostile relay in
the middle can only make the check fail. And every welcome carries the same
blob again, inside Noise, to a device that has just proved it holds the key the
certificate names — which is the re-supply path, so a browser that never stored
one, lost it, or was paired before its machine had a fleet key picks one up
from any machine it can still reach.

The browser keeps it in one record per origin beside the pinned fleet key, and
checks it three ways before it does: it must verify under the *pinned* key, be
a device certificate, and name this browser's own device key. Everything else
is dropped in silence — a missing certificate costs the machines this browser
never paired with, not the one it just did.

Nothing is removed yet; the directory still carries device certs and the reader
still uses them. That is the next commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tory

With the device holding its own certificate, publishing a copy to
`GET /directory` had no consumer left — and two costs.

That route needs no credential, by design, because what it carries is signed
rather than secret. But a device certificate names a device public key and the
label its owner typed, so publishing every one of them made the directory a
roster of the operator's devices, readable by anybody who knew the relay's
address. And nothing in the directory is ever deleted one entry at a time, so
each ceremony ever performed spent one of 512 permanent entries — the term that
actually grows, against machines and revocations that grow with the fleet
rather than with its use, and the reason a full directory (a dead kill switch)
was reachable at all.

No daemon ever read one from there: a machine admits a roaming device on the
certificate the device *presents* in its handshake, which is rule 2 and never
consults this store. The browser was the only reader, and it now reads its own
from this origin's key store.

So `mine()` publishes the machine certificate and the revocations, the browser's
reader stops hunting for its own certificate and keeps doing the one thing it
still owes it — reporting the revocation that outranks it — and `fleetSources`
takes the stored one, dropping it when the directory says this key is revoked.

The UX is unchanged and is the point: pair once and every machine works,
later-joined ones included, because the certificate travels with the device;
revoke anywhere and the device dies everywhere, because revocations still ride
the directory and its push socket.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o longer lists

`flue relay status` and the Remote screen printed "N machines, N devices, N
revocations" straight off the directory. With device certificates no longer
published there, that reads as "this fleet has no devices" when it means "the
directory does not list them" — different claims, and only the second is true.

Both now name machines and revocations, and mention device certificates only
when there are some, because a non-zero count is a real signal rather than a
statistic: a machine in this fleet is running a flue old enough to still
publish them.

spec/fleet-trust.md also records the pruning decision the directory's shape
raises and this change does not take: with device certificates gone the only
supersedable entry is a machine certificate re-minted by a re-join, a handful
in the life of a fleet, and removing them selectively would mean the Worker
verifying a fleet-signed instruction — reversing the invariant the design rests
on — or deleting by digest on the daemon secret alone, which hands anyone who
compromises one machine the ability to drop a revocation. `flue relay reset`
is the keyless answer, and the 90% warning makes it a decision rather than a
discovery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…et line now says

The documented output still showed a device count the directory no longer
carries, and none of the three lines status has learned to print: the cap
warning at 90%, the device certificates that mean an older machine is still
publishing them, and the missing-machine note it already had.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The restructure took device certificates out of `Directory.mine()` — the
bulk republish — and left the live publish standing. So every ceremony
still spent one of the directory's 512 permanent entries and still put the
device's public key and the label its owner typed on the credential-less
`GET /directory`: both of the costs the restructure exists to remove.

Nothing read them there. No daemon ingests a device cert (a roaming device
is admitted on the cert it *presents* in its handshake, rule 2), and the
browser's directory reader skips them, so the removal has no functional
consequence beyond the two costs it stops paying.

`PublishFleetBlob` now parses the kind and drops a device cert outright,
with the reason on it. Nothing offers one today; the guard is there because
the cost of a call site that forgets is permanent and public, and a log
line is a cheaper way to find out.

`TestPairingPublishesTheDeviceCertToTheFleet` asserted the behaviour being
removed and is replaced by its inverse,
`TestPairingPublishesNothingToTheFleet`, which also asserts the cert *is*
minted into the registry so "published nothing" cannot pass for the wrong
reason. It fails against the un-fixed code with "a pairing published
[device] to the fleet directory, want nothing".

This makes four documentation claims true that were not:
docs/RELAY.md's "your device certificates are not in there", the same in
spec/fleet-trust.md and spec/relay-protocol.md, and the "New device" flow.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…tory

Both status surfaces read a non-zero device-certificate count as "a machine
in this fleet is running a flue that still publishes them". That can never
be true: no released flue has a fleet directory at all — the newest tag has
no directory leg on either side — so no released version can have published
anything to one.

What is left, once the ceremony stops publishing, is a build from this
branch's middle commits or a hand-written PUT by whoever holds the daemon
secret. Neither is a diagnosis a count can make, so both surfaces now say
what is true and stop there: "nothing in this fleet should be publishing
one". The count stays: it is still the anomaly worth seeing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…same session

`fleetSources` reads the stored device certificate once, and `adoptRemotes`
is latched by `expanded` for the epoch. So a browser that booted without a
certificate built sources for its pinned machines only, picked the
certificate up from the first welcome, and did not gain the machines that
certificate unlocks until something reloaded the tab — which is exactly the
case re-supply exists for, since a browser missing its certificate is a
browser missing machines.

It happened to work: IndexedDB round-trips beat a directory fetch, so the
expansion usually read the certificate the welcome had just written. That
is timing, not construction, and nothing tested it.

`adoptFleetCert` now reports whether it stored a certificate this browser
did not have — the one case that can grow the reachable set — and
FleetClient re-runs the expansion once when it does. `adoptRemotes` was
already idempotent by id and epoch-guarded, so the second run adds the
machines and nothing else.

Three tests: the late welcome, the welcome that carries the origin and the
certificate together (the race that used to decide it), and the reconnect
that re-offers a certificate already held, which must not cost a second
directory read.

Also makes the comment above `adoptFleetCert` honest about replacement: a
device paired on two machines holds two equally valid certificates and
keeps whichever welcome landed last. The old comment claimed a fresh
ceremony was the only way to see different bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…property

Four comments claimed every connection's welcome re-offers a device its
certificate. A loopback one does not: the welcome carries what
`fleetCertFor` returns, and that is empty without a DeviceID — a
session-token connection has named no device key, so the daemon cannot know
whose certificate to send. A loopback pairing also writes the singleton
`savePinnedDaemonKey` rather than the per-machine pin, so such a tab holds
no per-machine pins and has no sibling to ask either. A loopback-only tab
that loses its certificate re-pairs; that is the whole of its recovery.

Comments corrected rather than a path built. The only mechanism that would
work is a new request in which a loopback tab names its own device key and
the daemon answers with the certificate for it — a wire message, a browser
caller and its refusals — and inventing protocol was out of scope here.
Worth noting the cost is small: the tab in question is on the machine it
paired with, and re-pairing there is the Devices screen and a scan.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`grep deviceCert src/routes/pair.test.tsx` was empty, on the path that is
the *primary* delivery — the welcome's copy is the second chance, and a
loopback tab has no second chance at all.

Seven cases through the real router and the real signer from
testdata/fleet/certs.json: kept when the link's fleet key signed it and it
names this browser's device key; dropped when another key signed it;
dropped when it names a different device; dropped when it is a machine
certificate in that slot; a garbled one and an absent one both leave the
pairing standing.

The seventh pins the ordering that makes the rest mean anything: the key
the certificate is verified under comes from the QR and is never re-read
from storage. A link with no `?f=`, a browser that already holds a pinned
fleet key, and an answer carrying a certificate that key signed — nothing
is kept. Mutating the page to fall back to the stored key is what fails it.

All four refusals were checked by mutation: skipping the verify and the
subject check fails three of them, the storage fallback fails the fourth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ice id

`fleetCertFor` looked the device up with `FindByID`, and an id is
hex(sha256(key)) truncated to 48 bits. `crypto.Add` deliberately permits two
devices to hold colliding ids, and `FindByKey` carries a long comment about
why matching on one alone is not safe — so the welcome was answering the
one question in this file that was not asked in bytes, and two colliding
devices would have been handed each other's certificate.

Harmless today, because the browser checks the certificate's subject
against its own key before storing it and every machine checks it again at
the handshake. "The other end will notice" is not a reason for this end to
answer the wrong question.

`ConnMeta` now carries `DeviceKey` beside `DeviceID` — the bytes the
handshake proved, which is what the relay channel already holds as
`peerStatic` — and `fleetCertFor` resolves on those. `FindByKey` also
refuses a revoked key inside the same critical section as the read, so a
revocation that landed since the handshake now costs the welcome its
certificate, which is the right answer.

Tested by the case a collision looks like from this function's side: a
connection whose ConnMeta names a paired device's id and a different key is
offered nothing. Against the old lookup that connection is handed the
paired device's certificate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The conclusion was right and both premises were not.

It leaned on "with device certificates gone", which only became true in this
branch's first commit — and on "deleting by digest on the daemon secret
would let one compromised machine delete any entry", which this same PR
concedes: `DELETE /directory` is on that same secret and deletes *every*
entry including every revocation. A targeted delete would be strictly
weaker than what the secret already buys, and self-healing for the same
reason the wipe is — publishAll re-offers everything a machine holds on
every connect and every 30 minutes.

The honest reason is a count. A revocation is never superseded, a device
certificate is not in the store, and what is left is a machine certificate
a machine re-minted: single digits out of 512 over a fleet's life. A
mechanism and its tests to reclaim a fraction of a percent of a store that
no longer grows with use is not a trade worth making, `flue relay reset`
covers the failure mode, and the 90% warning makes the wall a decision.

Both discarded arguments are written down as discarded, so nobody
re-derives them. The verify-only deletion gate the review raised — the
fleet *public* key bound to the Worker, authorising a digest delete — is
recorded as future work and rejected for the two reasons that are actually
about it: it makes the Worker a policy engine, and it creates a fleet-key
rotation problem nothing else in this design has.

The same argument in `spec/relay-protocol.md` and in `flue relay reset`'s
own comment is brought into line; both used a device certificate as their
example of a thing worth pruning, and there are none there to prune.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… say what privacy changed

Stragglers from taking device certificates out of the directory:
`publishAll` and the file header in the daemon's directory leg still listed
"the device certs its ceremonies minted" among what a machine PUTs;
`FleetDirectory`'s class comment and its reset rationale said the same on
the Worker side; `docs/RELAY.md`'s flag-day paragraph said an un-updated
relay stops distributing device certificates, which it never did — they do
not travel that way at all, on a relay of any vintage. The browser's
directory reader and the status line in `docs/RELAY.md` also still blamed
an older flue for a device cert in the store; that diagnosis went in the
earlier commit and these are the copies it missed.

And the privacy delta, stated precisely in the three places that owe it —
`spec/relay-protocol.md` "What the relay sees", `spec/fleet-trust.md`'s
privacy note, and `docs/RELAY.md`. The relay OPERATOR still sees every
device certificate: a relayed pairing transits the Worker in cleartext and
the certificate comes back in the answer, and no directory arrangement
changes that, because the request is the ceremony. What the change removes
is the anonymous reader — `GET /directory` needs no credential, so a
published certificate was a device key and its owner's label readable by
anyone who knew the relay's address, permanently. "Not published to
strangers", not "not seen by the operator". The docs blurred the two.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@karngyan
karngyan merged commit b19b1a7 into main Aug 10, 2026
1 check passed
@karngyan
karngyan deleted the feat/fleet-directory branch August 10, 2026 14:00
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