feat(fleet): the fleet key, its certificates, and a daemon that admits what the fleet signed - #40
Merged
Merged
Conversation
…lete Stage 2 of spec/fleet-trust.md, interrupted mid-implementation (the agent writing it ran out of credits while adding channel_test.go coverage for the acceptance rule). Committed to preserve the work, NOT reviewed and NOT known to pass: transport tests were reported green, the acceptance rule's own tests were still being written. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…broke The stale tests are the ones that predate --fleet: six join/config call sites that now need a seed, plus the relay-dial test, which built a zero daemon.Identity and so never had a fleet key for the relay leg to start with. flue status gains the assertion the second credential earns — it must no more print the fleet key than the daemon secret. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…at says it will not dial
Security review fixes for stage 2 of spec/fleet-trust.md. Four of them are
comments that claimed something the code did not do.
**A relay.json the daemon refuses is no longer reported as fine (H1).**
relay.New grew a fault relayProblems never learned — a missing fleet key —
so an upgrade with an older relay.json lost remote access while `flue
status`, `flue relay status` and /api/relay/info all called it configured.
relayProblems names the fault, /api/relay/info carries the same list, and the
Remote screen has a state for it: "Not usable", the daemon's own words for
why, and the two ways out.
**Rule 1 consults revocations (M1).** serveChannel did FindByKey → paired →
accept without ever reading the revocation list, so removeDevice's claim that
a landed revocation makes the key "already dead to the acceptance rule" was
false: a revoke whose second write failed left an entry that walked the
device straight back in. The check is folded into FindByKey, inside the same
critical section as the registry read, matching the fleet-cert path — which
also means stage 3's gossip handler has one question to ask, not two.
**docs/RELAY.md says what the join line now costs.** The printed example
showed a line this binary refuses; the join paragraph still said the secret
was the whole credential. It now teaches both credentials, states the weight
the spec requires ("a leaked join line used to buy disruption; with the fleet
key aboard it buys the fleet"), extends the shell-history warning to the
fleet key, and adds the layering table with its honest cost — a compromised
machine can sign for the fleet. Stale join lines fixed in README.md, usage
text, site/, and DEVELOPMENT.md, whose tunnel tier now documents minting a
dev fleet key by hand (nothing at the relay has to agree with it).
**Also**
- A first deploy from the Remote screen dialled nothing: startRelay took the
fleet public key from the boot-time identity, which for a daemon that had
no relay.json is empty. It reads the file it just read. The signing half
still waits for a restart, and the deploy's steps say so.
- A fleet-signed cert name (512 bytes, newlines allowed) now goes through
daemon.DeviceLabel like every other device name (L2).
- One iat ceiling for encode and decode, so Sign cannot produce bytes Verify
refuses (L1).
- Pairing mints no device cert when this machine has no id on the relay: the
spec types pairedOn as a machine-id and an unattributable cert is worse
than none (L6).
- Comments corrected on RelayJoinPath, RelayUIDeployResult.JoinCommand and
the join reveal card: the endpoint hands the browser both credentials.
**Tests**
Pins for all of it: a revoked-but-still-listed key refused at the store and
over a relay channel, cert-name normalisation, the fleet seed surviving both
SaveRelay calls and both spellings of the join line, pairing's cert fields
and its refusal without a machine id, and the fleet decoder's unknown
version, unknown kind and over-ceiling string length — signed under the real
key, so each proves the parse and not the signature.
Left open, deliberately: DeviceStore.Add still does not consult revocations,
so re-pairing a revoked key writes an entry FindByKey then refuses. The old
comment excused this with "a fresh ceremony mints a fresh key anyway"; the
browser reuses its IndexedDB key, so it does not. Noted where it bites.
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 2 of
spec/fleet-trust.md(stage 1 — self-certifying machine ids and the rate rule — landed in #39). This is the Go/daemon half: a fleet key, the three certificate kinds, and an acceptance rule that admits a device the fleet vouched for even when this machine never ran its pairing ceremony. The relay directory, the gossip that publishes certs, and the whole browser side are stage 3 and deliberately absent.What landed
The fleet key.
flue relay setupmints an Ed25519 keypair beside the fresh daemon secret and prints it in the join line (--fleet <seed>);flue relay joinrequires it; both store the seed inrelay.json(already 0600). It never reaches Cloudflare — no binding, no secret, no log — which is the entire point: the Worker gates routing, the fleet key gates trust, and the two credentials fail independently.internal/fleet. Machine certs, device certs, revocations — the spec's field sets exactly. Encoding is length-prefixed rather than canonical CBOR, argued in the package comment: three fixed-shape records have no map to order and no integer forms to choose between, so a fixed layout with explicit lengths rules out the ambiguity canonical CBOR exists to rule out, without a dependency or a hand-rolled canonicity subset. Domain-separated withflue-fleet-cert/, in the same style as stage 1's machine-id MAC. Revocation permanently outranks a device cert for the same key, whatever the timestamps say.Machine certs ship as a type and a fixture, and nothing mints one.
fleet.MachineCertexists,testdata/fleet/certs.jsonpins its bytes, and no production path constructs or consumes it — a machine cert is what the directory publishes so a browser can learn a sibling's Noise key, and the directory is stage 3. It is here now only so both languages are pinned to the same encoding before anything depends on it.The acceptance rule (
internal/transport/relay/channel.go): local registry hit and not revoked → accept as before; otherwise a device cert riding IK message A's payload that verifies under the fleet public key, whose subject equals the handshake's static key, and whose key is unrevoked → accept and record the device locally with the cert's name, so Devices lists it and LastSeen works; otherwise the same refusal as today. Pairing now also mints a fleet-signed device cert.What stage 3 must wire
PUT/GET/WS /directory) and the daemon gossip that publishes machine certs at join, device certs at pairing, and revocations at revoke — stage 2 mints and stores them but has nowhere to publish.testdata/fleet/certs.jsonandtestdata/noise/ik-payload.jsonare committed for exactly that).The asymmetry to hold in view while doing it. Trust is already fleet-wide and killing it is not. A device cert minted on machine A admits that device to every machine on the relay, today, on the strength of the cert alone — while a revoke performed on A drops the key from A's registry and A's revocation list and reaches no sibling at all. Every other machine keeps honouring the cert until someone revokes on it too. That is acceptable only because nothing yet distributes certs automatically: a device still has to have been handed its cert by the machine that paired it. It stops being acceptable the moment the TS initiator carries certs into handshakes on its own, so directory gossip is a hard prerequisite for shipping the browser side — not a nice-to-have alongside it. Ship revocation distribution before, or with, the thing that makes admission effortless.
Tests
go test ./...green across all packages;go vet ./...clean (with and without-tags dev);-racegreen oninternal/crypto,internal/transport/...andinternal/fleet.cd web && pnpm vitest run(999) andpnpm run lintgreen;sitebuilds and typechecks. New cross-language fixturestestdata/fleet/certs.json(every cert kind under a known key, canonical bytes pinned) andtestdata/noise/ik-payload.json. No TS or web runtime file changed for the protocol — the browser receives certs in stage 3 and constructs none today; the web change in this branch is the Remote screen's new "not usable" state, below.Honest note on provenance: the agent writing this stage was interrupted mid-run (out of credits) with the work uncommitted and six stale
cmd/fluetests unfixed — the ones predating--fleet, plus a relay-dial test that built a zerodaemon.Identityand so had no fleet key for the relay leg to start with. Those were finished by hand and are the second commit;flue statusalso gained the assertion the second credential earns, that it must no more print the fleet key than the daemon secret.Review fixes applied
A security review returned SHIP WITH FIXES, no critical findings. The third commit applies them. Four were comments claiming something the code did not do.
flue statuscalled a now-undialablerelay.json"configured" (high).relay.Newgrew a faultrelayProblemsnever learned — a missing fleet key — so a user upgrading with an olderrelay.jsonlost remote access with one stderr warning whileflue status,flue relay statusand/api/relay/infoall reported it fine.relayProblemsnames the fault and points at the way out;/api/relay/infocarries the same list (problems); the Remote screen renders a state for it ("Not usable", the daemon's words for why, and the two routes out) instead of the "never configured" panel it used to show.removeDeviceclaimed that a landed revocation makes the key "already dead to the acceptance rule";serveChanneldidFindByKey → paired → acceptand never read the revocation list, so a revoke whose second write failed left an entry that walked the device back in. The check is folded intoFindByKey, inside the same critical section as the registry read — matching the fleet-cert path, and leaving stage 3's gossip handler one question to ask rather than two. Pinned at the store and over a live relay channel; both tests fail without the fix.docs/RELAY.mdprinted a join line this binary refuses and still said "the Worker exists and the secret is the whole credential". It now teaches both credentials, states the weight the spec requires in the spec's words ("a leaked join line used to buy disruption; with the fleet key aboard it buys the fleet"), extends the shell-history warning past the secret, and adds the layering table with its honest cost — every machine holds the same private key, so a compromised machine can sign for the fleet. Stale join lines fixed inREADME.md,usageText,site/, anddocs/DEVELOPMENT.md— whose cloudflared tier had no working form at all, and now documents minting a dev fleet key by hand, which works precisely because nothing at the relay has to agree with it.FleetSeed; a regression dropping it from eitherSaveRelaywould have killed every machine's relay leg with a green suite. Both, plus both spellings of the join line, plus "the fleet key is inrelay.jsonand nowhere else, and was never uploaded".iatceiling soSigncannot emit bytesVerifyrefuses; a fleet-signed cert name (512 bytes, newlines permitted) normalised throughdaemon.DeviceLabellike every other device name; theRelayJoinPathandJoinCommandcomments corrected — the endpoint hands the browser both credentials, not "the fleet secret"; pairing mints no cert when this machine has no id on the relay, sincepairedOnis a machine-id the directory will key on and an unattributable cert is worse than none. New negative tests ininternal/fleetfor an unknown version byte, an unknown kind byte and an over-ceiling string length — signed under the real key, so each proves the parse and not the signature.Found while fixing, not in the review: a first deploy from the Remote screen started no transport.
startRelaytook the fleet public key from the boot-timedaemon.Identity, which is empty on a daemon that booted without arelay.json— sorelay.Newrefused the relay the user had just deployed, one log line and no card. It now reads the key from the file it has already read. The signing half still waits for a restart (Identityis fixed at construction), and the deploy's steps say so out loud.Left open, deliberately:
DeviceStore.Addstill does not consult revocations, so re-pairing a revoked key writes an entryFindByKeythen refuses — paired on the screen, closed on the wire. The comment excusing this said "a fresh ceremony mints a fresh key anyway";web/src/crypto/keys.tsreuses its IndexedDB key across pairings, so it does not. What un-revoking should mean is a design call, and it is noted where it bites rather than decided here.🤖 Generated with Claude Code