From 45738b24fc91b6d3afa3508d88586c4f52aac661 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 10 Aug 2026 03:37:53 +0530 Subject: [PATCH 1/3] =?UTF-8?q?wip(fleet):=20fleet=20key,=20certs,=20and?= =?UTF-8?q?=20the=20daemon=20acceptance=20rule=20=E2=80=94=20incomplete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/flue/main.go | 33 +- cmd/flue/relay.go | 60 ++- cmd/flue/relayui.go | 36 +- internal/config/relay.go | 12 + internal/crypto/devices.go | 204 +++++++++- internal/crypto/devices_test.go | 108 +++++- internal/crypto/handshake.go | 51 ++- internal/crypto/handshake_test.go | 45 ++- internal/crypto/vectors_test.go | 158 ++++++-- internal/daemon/pairing.go | 34 +- internal/daemon/server.go | 50 +++ internal/daemon/server_test.go | 2 +- internal/daemon/transport_test.go | 4 +- internal/fleet/fixture_test.go | 213 +++++++++++ internal/fleet/fleet.go | 456 +++++++++++++++++++++++ internal/fleet/fleet_test.go | 207 ++++++++++ internal/transport/relay/channel.go | 100 ++++- internal/transport/relay/channel_test.go | 238 +++++++++++- internal/transport/relay/relay.go | 26 +- internal/transport/relay/relay_test.go | 51 ++- testdata/fleet/certs.json | 56 +++ testdata/noise/ik-payload.json | 29 ++ 22 files changed, 2057 insertions(+), 116 deletions(-) create mode 100644 internal/fleet/fixture_test.go create mode 100644 internal/fleet/fleet.go create mode 100644 internal/fleet/fleet_test.go create mode 100644 testdata/fleet/certs.json create mode 100644 testdata/noise/ik-payload.json diff --git a/cmd/flue/main.go b/cmd/flue/main.go index e54a1ce..8d713ab 100644 --- a/cmd/flue/main.go +++ b/cmd/flue/main.go @@ -25,6 +25,7 @@ import ( "github.com/karnstack/flue/internal/config" "github.com/karnstack/flue/internal/crypto" "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/fleet" "github.com/karnstack/flue/internal/service" "github.com/karnstack/flue/internal/session" "github.com/karnstack/flue/internal/transport/local" @@ -323,7 +324,27 @@ func loadIdentity() (daemon.Identity, error) { if err != nil { return daemon.Identity{}, fmt.Errorf("load the daemon static key: %w", err) } - return daemon.Identity{Key: key, Devices: crypto.NewDeviceStore(dir)}, nil + id := daemon.Identity{Key: key, Devices: crypto.NewDeviceStore(dir)} + + // The fleet key rides relay.json (spec/fleet-trust.md), so it is read + // here beside the other identity material rather than by the relay + // startup: pairing mints device certs and revocation mints revocations + // whether or not the transport ever comes up. An unreadable or absent + // relay.json leaves the identity fleet-less — startRelay reports the + // unreadable case, and a daemon without a fleet key pairs exactly as it + // always did. A relay.json that parses but carries a seed this daemon + // cannot use is fatal, by the same reasoning as the static key above: a + // daemon that started anyway would sign nothing and verify nothing while + // looking perfectly healthy, and a corrupted credential file is a thing + // to say out loud, not to route around. + if rc, ok, err := config.LoadRelay(); err == nil && ok && rc.FleetSeed != "" { + fk, err := fleet.Parse(rc.FleetSeed) + if err != nil { + return daemon.Identity{}, fmt.Errorf("relay.json carries a fleet seed this daemon cannot use: %w", err) + } + id.Fleet = fk + } + return id, nil } // startRelay dials the configured relay, if there is one, and keeps it dialled @@ -359,7 +380,15 @@ func startRelay(ctx context.Context, srv *daemon.Server, identity daemon.Identit return false } - cfg := relay.Config{URL: rc.URL, Secret: rc.Secret, Origin: rc.Origin, MachineID: rc.MachineID} + // The public half only: signing stays with the daemon (pairing, + // revocation), while the transport verifies the certs strangers present. + // identity.Fleet is the parsed form of this same file's seed — loaded by + // loadIdentity, which is fatal on a seed that does not parse — so a nil + // Public() here means relay.json carries no fleet key at all, and + // relay.New refuses that by name: the file predates the fleet key, and + // the re-join line is the way forward (spec/fleet-trust.md keeps no + // compatibility with pre-fleet files, deliberately). + cfg := relay.Config{URL: rc.URL, Secret: rc.Secret, Origin: rc.Origin, MachineID: rc.MachineID, FleetPub: identity.Fleet.Public()} t, err := relay.New(cfg, srv, identity.Key, identity.Devices, logger) if err != nil { logger.Warn("relay not started", "err", err) diff --git a/cmd/flue/relay.go b/cmd/flue/relay.go index 99e0d0d..7a3afa2 100644 --- a/cmd/flue/relay.go +++ b/cmd/flue/relay.go @@ -19,6 +19,7 @@ import ( "github.com/karnstack/flue/internal/cloudflare" "github.com/karnstack/flue/internal/config" "github.com/karnstack/flue/internal/daemon" + "github.com/karnstack/flue/internal/fleet" "github.com/karnstack/flue/internal/relaydeploy" relaybundle "github.com/karnstack/flue/relay" "github.com/karnstack/flue/web" @@ -273,6 +274,20 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri } origin := "https://" + host + // The fleet key, minted beside the fresh secret and — unlike it — never + // sent anywhere: no binding, no secret upload, no log line + // (spec/fleet-trust.md). It travels only in relay.json below and in the + // join line printed at the end, and it is what signs the device certs + // every machine on this relay honours. Fresh on every setup for the same + // reason the secret is: setup is the recovery path, and rotating the + // fleet key is what un-trusts every cert a compromised machine could + // have signed. + fleetKey, err := fleet.Mint(rand.Reader) + if err != nil { + return err + } + fmt.Fprintln(w, " ✓ fleet key minted (stays on your machines; Cloudflare never sees it)") + // This machine's identity on the relay: the id is the slot it dials // (/daemon/) and the name is its human label. Minted fresh on every // run like the secret — setup is the recovery path, and a stale id would @@ -297,6 +312,7 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri if err := config.SaveRelay(config.Relay{ URL: "wss://" + host, Secret: secret, + FleetSeed: fleetKey.Seed(), Origin: origin, MachineID: machineID, MachineName: machineName, @@ -318,11 +334,15 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri } } - // The one line another machine needs, exactly as it should be run there. - // It carries the secret — that is the point: the relay is shared by - // machines that share it, and this is the deliberate hand-off, printed - // once at the moment the user is wiring their fleet up. - fmt.Fprintf(w, "\nto add another machine, run this on it:\n\n flue relay join wss://%s --secret %s\n", host, secret) + // The one line another machine needs, exactly as it should be run there, + // spelled by joinCommand so this print and the Remote screen's copy can + // never drift. It carries the secret and now the fleet key — that is the + // point: the relay is shared by machines that share them, and this is + // the deliberate hand-off, printed once at the moment the user is wiring + // their fleet up. Its weight changed when the fleet key came aboard: + // leaking this line used to buy disruption, and now it buys the fleet — + // docs/RELAY.md says so where it teaches the line. + fmt.Fprintf(w, "\nto add another machine, run this on it:\n\n %s\n", joinCommand(host, secret, fleetKey.Seed())) fmt.Fprint(w, relaySetupDone) return nil @@ -333,7 +353,7 @@ func runRelaySetup(w io.Writer, r io.Reader, api *cloudflare.Client, args []stri // a machine list rendering as a list. const machineNameMaxRunes = 64 -const relayJoinUsage = "usage: flue relay join --secret [--name