Skip to content

feat(fleet): the fleet key, its certificates, and a daemon that admits what the fleet signed - #40

Merged
karngyan merged 3 commits into
mainfrom
feat/fleet-key-certs
Aug 10, 2026
Merged

feat(fleet): the fleet key, its certificates, and a daemon that admits what the fleet signed#40
karngyan merged 3 commits into
mainfrom
feat/fleet-key-certs

Conversation

@karngyan

@karngyan karngyan commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

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 setup mints an Ed25519 keypair beside the fresh daemon secret and prints it in the join line (--fleet <seed>); flue relay join requires it; both store the seed in relay.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 with flue-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.MachineCert exists, testdata/fleet/certs.json pins 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

  • The directory DO (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.
  • Browser side: pin the fleet public key at first pairing, verify machine certs under it, build machine records from the directory instead of only from ceremonies, and carry the device cert in message A from the TS initiator (testdata/fleet/certs.json and testdata/noise/ik-payload.json are committed for exactly that).
  • Fleet-wide revoke in the Devices UI.

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); -race green on internal/crypto, internal/transport/... and internal/fleet. cd web && pnpm vitest run (999) and pnpm run lint green; site builds and typechecks. New cross-language fixtures testdata/fleet/certs.json (every cert kind under a known key, canonical bytes pinned) and testdata/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/flue tests unfixed — the ones predating --fleet, plus a relay-dial test that built a zero daemon.Identity and so had no fleet key for the relay leg to start with. Those were finished by hand and are the second commit; flue status also 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 status called a now-undialable relay.json "configured" (high). relay.New grew a fault relayProblems never learned — a missing fleet key — so a user upgrading with an older relay.json lost remote access with one stderr warning while flue status, flue relay status and /api/relay/info all reported it fine. relayProblems names the fault and points at the way out; /api/relay/info carries 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.
  • Rule 1 never consulted revocations (medium). removeDevice claimed that a landed revocation makes the key "already dead to the acceptance rule"; serveChannel did FindByKey → paired → accept and 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 into FindByKey, 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.
  • The docs (high). docs/RELAY.md printed 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 in README.md, usageText, site/, and docs/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.
  • Test coverage for the seed (medium). Nothing pinned that setup and join persist FleetSeed; a regression dropping it from either SaveRelay would have killed every machine's relay leg with a green suite. Both, plus both spellings of the join line, plus "the fleet key is in relay.json and nowhere else, and was never uploaded".
  • Lows. One shared iat ceiling so Sign cannot emit bytes Verify refuses; a fleet-signed cert name (512 bytes, newlines permitted) normalised through daemon.DeviceLabel like every other device name; the RelayJoinPath and JoinCommand comments 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, since pairedOn is a machine-id the directory will key on and an unattributable cert is worse than none. New negative tests in internal/fleet for 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. startRelay took the fleet public key from the boot-time daemon.Identity, which is empty on a daemon that booted without a relay.json — so relay.New refused 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 (Identity is fixed at construction), and the deploy's steps say so out loud.

Left open, deliberately: DeviceStore.Add still does not consult revocations, so re-pairing a revoked key writes an entry FindByKey then 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.ts reuses 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

karngyan and others added 3 commits August 10, 2026 03:37
…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>
@karngyan
karngyan merged commit 0f8e22b into main Aug 10, 2026
1 check passed
@karngyan
karngyan deleted the feat/fleet-key-certs branch August 10, 2026 05:45
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