Fleet trust, stage 3: a directory the relay cannot read, and a browser that pairs once - #41
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stage 3 of
spec/fleet-trust.md, end to end: the relaygrows 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 signedblobs — 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).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.
507over the caprather 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.mdgains the leg, the routes table and the rankingrule that is explicitly not the relay's.
B — the Go daemon's leg (
d382196)internal/transport/relay/directory.go: socket first, snapshot second (a writethat 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.
verified under the fleet public key and dropped if it fails; the relay's own
bounds are re-enforced on this side.
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.
migration v1 gets
FleetDirectoryat v2;flue relay statusgrew afleet:line that reads the directory and verifies it locally.
C — the browser (this part)
internal/daemonwrites
&f=<fleet public key>besidek=; the pairing page reads it on thefirst render, pins it in one record per origin, and the scrub takes it with
tandk. The QR is the one leg of the ceremony no intermediary can sitin — 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.tsdecodes and verifiesinternal/fleet'slength-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.cert, which is what rule 2 of the daemon's acceptance order reads.
GET /directorybuilds the machine list.web/src/relay/directory.tsverifies 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 thisbrowser now holds locally rather than one read off the relay.
machines.tsfolds the fleet's list into the storedrecords (
mergeMachines, still holding no key);fleetSourcesbuilds asource per row — the ceremony's pinned key where there is one, the
certificate's
noisekey otherwise.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 noversion 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:
list inside the registry write (
AddFromFleetCert), so a concurrent revokecannot 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.
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 /directorytakes no credential, so a publishedcertificate 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.mdandspec/relay-protocol.md's "What the relay sees" allnow draw that line rather than blurring it.
Flag day
An existing relay must run
flue relay updateonce.FleetDirectoryis asecond Durable Object class, and a Worker deployed before it has no such
binding:
/directoryanswers503 {"error":"directory unavailable"}andflue relay statusnames the fix in as many words —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).
putanswered507before the store and before the fan-out, so a revocationpast 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 setupmints a fresh secret and fleet key, and the new relay starts empty") was simply
false.
DELETE /directoryempties the whole set and the count, gated by thesame 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 resetis the operator's side, behind a typed "yes". Theresidual — 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).
deployedMigrationTaganswered
""for every failure, and""meant "fresh account". So an existingv1 relay whose pre-flight
GET .../scriptstimed out sent the whole historyuntagged → 10079 → the blanket already-applied recovery dropped migrations →
re-PUT bound
FleetDirectoryto a class no migration created → 10061, andflue relay updatedied. The read now answers(tag, known); an unknown tagstill 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 statusand the Remotescreen now warn from 90% and name
flue relay reset, from single constants oneach 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
GETis shape-identical complete or filtered.spec/relay-protocol.mdgains"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_firstand rate rule are now compared againstrelay/wrangler.jsoncby a test rather than by prose — a wrangler-only
v3would have made everylater
flue relay updateskip migrations silently. An unknown deployed tag saysso instead of being a silent no-op that surfaces as a baffling 10061.
cert.ts's encoder explains why itsiatceiling is 2^53 where Go's is 2^62(a
numbercannot carry more exactly, and signing a rounded value is the bugthe refusal prevents), and a test pins that the decoders agree to the unit.
MAX_DAEMON_SOCKETShas a test;GET /directoryhas anETagand answers amatching
If-None-Matchwith 304, which the daemon's leg now uses — cachingthe tag only for a snapshot it fully applied, so an unapplied revocation is
retried rather than skipped; and the
ratelimitscomment 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 thatis not this one over
wss://<relay>/client/<id>— pre-existing,adoptRemoteson 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
readDirectoryanswers "no machines" for every fault: aloopback tab showed a fleet of one.
daemon.LocalCSPForadds the one relayorigin 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 resetcovers 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 /directoryon that same secret already deletesevery 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.mdas futurework 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.
Directory.mine()(thebulk republish) had stopped;
pairing.gohad not, so every ceremony stillspent 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 toremove. Removed, and
PublishFleetBlobnow parses the kind and drops adevice certificate outright so it cannot come back. The branch's own
TestPairingPublishesTheDeviceCertToTheFleetasserted the wrong behaviourand is replaced by
TestPairingPublishesNothingToTheFleet, which alsoasserts 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.
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.
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.
adoptFleetCertnowreports when it stored a certificate the browser did not have, and
FleetClientre-runs the expansion once. Three tests, including the racethat used to decide it.
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.
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.
fleetCertFormatched on the 48-bitDeviceID, whereFindByKey's owncomment explains at length why an id-only match is unsafe (
Addpermitscolliding ids).
ConnMetanow carries the static key the handshake provedand the lookup resolves on those bytes.
above.
publishAll,FleetDirectory, the browser'sdirectory reader and
docs/RELAY.md's flag-day paragraph, plus the privacydelta stated precisely in the three documents that owe it.
Test evidence
make web relayclean;go vet ./...clean.go test ./...internal/fleet,internal/daemon,internal/transport/relay,internal/cloudflare,internal/relaydeploy)go test -race ./internal/crypto/... ./internal/transport/... ./internal/fleet/...cd relay && pnpm testcd web && pnpm vitest runcd web && pnpm run lint,cd relay && pnpm lintWatched 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 thecap be published after a reset" — 404 from the router, which had no DELETE.
internal/cloudflare→TestDeployRecoversTheUpgradeWhenTheTagReadFailed— dies with the real
cloudflare: 10061 … binding DIRECTORY refers to class FleetDirectory which is not exported by the script, which is the chain thatkilled
flue relay update. Its two siblings —TestDeployFailsClosedOnAnUnreadablePreconditionandTestDeployDoesNotRetryATagItAlreadyClaimed— both see the blind second PUT.{"tag":"v3",…}torelay/wrangler.jsoncalone 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 inthe payload" — verified failing against the previous
noise.ts, whichsealed an empty payload:
expected '7aa6099ce8…' to be '7aa6099ce8…', the twodiffering from the payload ciphertext onward.
testdata/noise/ik-payload.jsonis the Go responder's own vector and
msg1Payloadis the signed device certfrom
testdata/fleet/certs.jsonbyte for byte, so this single case ties theTS cert encoder and the TS initiator to Go at once.
stubbed out of
verifyCert: five tests fall, including "never builds a sourcefrom 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.
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.
cert.test.tsagainst the committed vectors and indirectory.test.ts.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.
canonicalHexand
signedHex, including the astral-character name that catches a lengthprefix counting UTF-16 units.
🤖 Generated with Claude Code