Skip to content

Commit 87348c7

Browse files
karngyanclaude
andauthored
The fleet key a running daemon signs with: live, not boot-time (#43)
* fix(daemon): read the fleet signing identity live, not once at boot The fleet key rides relay.json, and relay.json is written by another process: `flue relay setup` and `flue relay join` are terminal commands, and the daemon they configure has been running since login. The relay leg already knew this and re-reads the file on every start; the identity did not, so a daemon that was running when its fleet was created came up live on the relay — publishing a machine cert that verifies — and silently unable to sign for it. The two records that make a device part of a fleet are each written exactly once, by the pairing ceremony: the `f=` the browser pins, and the device certificate. Neither has a repair path, so every device paired in that window was fleet-blind forever. daemon.Identity.Fleet is now a FleetSource — asked, at the moment of signing, what this process may sign with — and cmd/flue answers it by reading relay.json (fleetOnDisk). Read-on-use rather than a watcher or an in-memory hook: the writers are in another process, the file is small, and all four callers have a human waiting on them. The machine id a certificate names is read from the same place for the same reason, so a daemon whose relay leg has not started yet still signs certs somebody can attribute. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(relay): replace the relay leg in process, and tell the daemon from the CLI A deploy from the Remote screen used to end with "restart the daemon": the leg was dialling the relay.json that existed when it started, the deploy had just replaced that file, and nothing in the process could reconcile the two. PR #42 gave relayRuntime a real teardown, so it can now — stop the old leg, wait for it to be gone, start one that reads the file that is there. RestartNeeded is deleted rather than reworded, on the API type and on the Remote screen. The waiting is load-bearing: Transport.Run reports the relay off from a deferred call on its own goroutine, and that line landing after a replacement leg said "connecting" would leave every welcome announcing a relay this daemon is in fact dialling, and every pairing URL naming loopback until the next dial succeeded. startRelay hands back a wait for both of its legs; the stop is bounded at five seconds. `flue relay setup`, `join`, `address` and `leave` run in another process, so they now knock on the daemon's loopback door — POST /api/relay/reload, session token in the header, no body — and the daemon replaces its leg from the relay.json that is on disk. Their "restart the daemon" notes are gone; the only surviving restart line is for a daemon that is running and could not be told, which is the one case where it is still true. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(fleet): back-fill the certificate a device was paired without Device.Cert is written at the ceremony and nowhere else, and the other door into the registry — AddFromFleetCert — wants a certificate the device already holds. So every device paired while its machine had no fleet key was stuck: it reached the machine it paired with, could present nothing to any sibling, and re-pairing was the only way out. That also made the re-supply path web/src/fleet/fleet.ts documents a promise the daemon could not keep — "a browser paired before its machine had a fleet key picks one up from any machine it can still reach" needs a machine with one to hand over. The welcome mints it now, on the first connection where this machine can: crypto.DeviceStore.SetCert fills an empty cert and refuses every other case (an existing one, an unpaired key, a revoked key), and Server.backfillFleetCert signs {device key, label, this machine's id, the pairing's own iat}. Lazily rather than in a startup sweep, for the reason the fleet key itself is read lazily: the key can arrive at any moment from a file another process writes, and the welcome is both the moment the answer is needed and the moment the device is there to take it. One mint per device, not one per connection — the blob is persisted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(daemon): refuse a pairing QR this process cannot sign, and name the fault A daemon on a relay that holds no fleet key still drew a QR, and the device that scanned it pinned no fleet key and was minted no certificate. Both records are written exactly once, by that ceremony, so the device reached that one machine for the life of the pairing — silently, and with nothing able to repair it afterwards. The welcome's relay snapshot now carries noFleetKey (no key in relay.json, one it cannot parse, or no machine id for a certificate to name), and the Devices screen refuses to draw a QR the way it already refuses one that could only name loopback: button shut, one paragraph, tied to the button by aria-describedby. Stated as the fault rather than as a capability so an older daemon's silence is never read as a refusal. relayProblems gains the seed that is present and unusable — which is also the line that answers "can this process sign", now that the daemon parses that same value at the moment it mints — and `flue relay status`'s fleet line spells out what an unusable key costs and how to fix it. After the live read this is nearly unreachable, which is why it is a backstop rather than a feature: the state it catches is permanent for every device paired into it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * feat(web): say what this browser cannot reach instead of skipping it fleetSources dropped two facts on the floor. A machine the fleet directory names that this browser holds no certificate for was skipped with a `continue`, and a browser with no pinned fleet key got an empty fleet view — so the maintainer's second machine simply was not on the screen, and nothing anywhere said the list was short. Both are now counted and reported (FleetGaps), and the sessions screen states them in the same muted band the unreachable and revoked machines already use: "N machines in this fleet have no certificate this browser can present", or, for a browser with no fleet key at all, that it is pinned to one machine and pairing again shows the rest. Neither band offers a button, and that is the honest part: a certificate comes from a daemon over a connection that will not accept this browser without one, and a fleet key must never be learned from a connection — that is the trust-on-first-use the pinned key exists to refuse. Pairing again is the way out of both, and it starts on the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test: the setup-then-pair path, in the order a real machine does it Every piece of the defect was covered and the seam between them was not, because every test built its daemon after the relay.json it would read. Real machines are the other way round: the daemon starts at login and the relay is set up in a terminal an hour later. TestASetupInOneTerminalPairsInTheNextBreath runs it in that order and across the two processes the user has — a daemon serving on loopback, and `flue relay join` writing relay.json beside it — then pairs with no restart anywhere and checks what the phone actually gets: a link carrying `&f=` that decodes to the fleet key from the join line and names the relay origin, and a device certificate that verifies under that key and names the machine id the join minted. Against the un-fixed identity (a boot-time read, restored locally to check) it fails on the missing `&f=`, and with that assertion relaxed it fails again on "the ceremony minted no certificate under the fleet key this machine just joined" — the two records that made the maintainer's phone permanently a one-machine phone. TestPairStartURLCarriesTheFleetKey also asserts the literal `&f=` now: conn.go splices that parameter in by hand, so the joining `&` is code, and a link that lost it parses fine and pins nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * docs(relay): stop teaching a restart that is no longer needed RELAY.md taught the trap as a step: restart after setup, restart after join, "the restart is not optional", and a release checklist that restarted the daemon before pairing anything — which is exactly why the window between the two was never noticed. Setup and join now tell the running daemon, and the fleet key is read at every signature, so the instructions are wrong rather than merely dated. The release gate now says the opposite where it matters: pair a phone *before* touching the daemon, and check the QR link carries `&f=`. That is the check that would have caught this by hand. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 7ff9861 commit 87348c7

26 files changed

Lines changed: 2091 additions & 230 deletions

cmd/flue/main.go

Lines changed: 152 additions & 41 deletions
Large diffs are not rendered by default.

cmd/flue/main_test.go

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"context"
66
"errors"
77
"fmt"
8+
"log/slog"
89
"net"
910
"net/http"
1011
"net/http/httptest"
@@ -1337,7 +1338,7 @@ func TestStartRelayDialsAConfiguredRelay(t *testing.T) {
13371338
if err != nil {
13381339
t.Fatalf("fleet.Parse: %v", err)
13391340
}
1340-
id := daemon.Identity{Fleet: fk}
1341+
id := daemon.Identity{Fleet: daemon.StaticFleet(fk, "karns-macbook-pro-a1b2-0f9a12cd")}
13411342

13421343
srv := daemon.New(session.NewRegistry(time.Now), local.NewAuth("0123456789abcdef", 0),
13431344
uiHandler(), version, id)
@@ -1413,7 +1414,7 @@ func TestRelayRuntimeStopEndsTheDialling(t *testing.T) {
14131414
if err != nil {
14141415
t.Fatalf("fleet.Parse: %v", err)
14151416
}
1416-
id := daemon.Identity{Fleet: fk}
1417+
id := daemon.Identity{Fleet: daemon.StaticFleet(fk, "karns-macbook-pro-a1b2-0f9a12cd")}
14171418
srv := daemon.New(session.NewRegistry(time.Now), local.NewAuth("0123456789abcdef", 0),
14181419
uiHandler(), version, id)
14191420
t.Cleanup(srv.Shutdown)
@@ -1506,6 +1507,65 @@ func TestStartRelayIsNeverFatal(t *testing.T) {
15061507
}
15071508
}
15081509

1510+
// TestLoadIdentityReadsTheFleetKeyWhenItIsUsed: the identity a serving daemon
1511+
// holds must answer with the relay.json that exists *now*, not the one that
1512+
// existed when it booted.
1513+
//
1514+
// The window this closes is the ordinary one, not an exotic one: the daemon
1515+
// starts at login, and `flue relay setup` runs later, in a terminal, in a
1516+
// process of its own. Nothing tells the daemon. An identity that had parsed
1517+
// the file at construction went on signing with what it found there — nothing
1518+
// — while the relay leg (which re-reads the file) came up live, so the machine
1519+
// was on the relay and unable to mint a single certificate for it.
1520+
func TestLoadIdentityReadsTheFleetKeyWhenItIsUsed(t *testing.T) {
1521+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
1522+
1523+
id, err := loadIdentity(slog.New(slog.DiscardHandler))
1524+
if err != nil {
1525+
t.Fatalf("loadIdentity: %v", err)
1526+
}
1527+
if got := id.Fleet(); got.Key.Valid() || got.MachineID != "" {
1528+
t.Fatalf("a daemon with no relay.json holds %+v, want no fleet identity at all", got)
1529+
}
1530+
1531+
// `flue relay setup`, in the other terminal, after this daemon started.
1532+
const machineID = "karns-macbook-pro-a1b2-0f9a12cd"
1533+
if err := config.SaveRelay(config.Relay{
1534+
URL: "wss://r.example",
1535+
Secret: "s3cr3t-daemon-secret",
1536+
Origin: "https://r.example",
1537+
MachineID: machineID,
1538+
FleetSeed: testFleetSeed,
1539+
}); err != nil {
1540+
t.Fatalf("SaveRelay: %v", err)
1541+
}
1542+
1543+
want, err := fleet.Parse(testFleetSeed)
1544+
if err != nil {
1545+
t.Fatalf("fleet.Parse: %v", err)
1546+
}
1547+
got := id.Fleet()
1548+
if !got.Key.Valid() {
1549+
t.Fatal("the identity still holds no fleet key after relay.json was written; every pairing from here is fleet-blind and permanently so")
1550+
}
1551+
if got.Key.Seed() != want.Seed() {
1552+
t.Errorf("fleet key = %q, want the seed relay.json holds %q", got.Key.Seed(), want.Seed())
1553+
}
1554+
if got.MachineID != machineID {
1555+
t.Errorf("machine id = %q, want %q — a cert naming no machine is one no reader can attribute", got.MachineID, machineID)
1556+
}
1557+
1558+
// And a file rewritten into nonsense under a running daemon costs the fleet
1559+
// half and nothing else: this is remote access, not the terminal in the
1560+
// tab, and refusing to pair at all would trade the second for the first.
1561+
if err := config.SaveRelay(config.Relay{URL: "wss://r.example", FleetSeed: "not-a-seed"}); err != nil {
1562+
t.Fatalf("SaveRelay: %v", err)
1563+
}
1564+
if got := id.Fleet(); got.Key.Valid() {
1565+
t.Errorf("a seed that does not parse produced a usable key %v", got.Key.Seed())
1566+
}
1567+
}
1568+
15091569
// TestStatusReportsAnIncompleteRelayConfig: a relay.json missing a field is one
15101570
// relay.New refuses, so the daemon never dials it. Reporting it as "configured"
15111571
// would make the one report someone reads to find out why nothing works say
@@ -1531,6 +1591,13 @@ func TestStatusReportsAnIncompleteRelayConfig(t *testing.T) {
15311591
// `flue status`, `flue relay status` and /api/relay/info all called it
15321592
// configured and fine.
15331593
{"no fleet key", config.Relay{URL: "wss://r.example", Secret: secret, Origin: "https://r.example", MachineID: "m-0001"}, "no fleet key"},
1594+
// A seed that is present and unusable — a hand-edited file, a
1595+
// half-pasted join line. It reads differently from the case above,
1596+
// because the file looks configured, and it is the line that answers
1597+
// "can this process sign": the daemon parses this same value at the
1598+
// moment it mints a device certificate, so a seed named here is a
1599+
// daemon that will pair devices onto no fleet at all.
1600+
{"unusable fleet key", config.Relay{URL: "wss://r.example", Secret: secret, Origin: "https://r.example", MachineID: "m-0001", FleetSeed: "not-a-seed"}, "a fleet key this daemon cannot use"},
15341601
} {
15351602
t.Run(tc.name, func(t *testing.T) {
15361603
t.Setenv("XDG_CONFIG_HOME", t.TempDir())

cmd/flue/norestart_test.go

Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/base64"
7+
"encoding/json"
8+
"errors"
9+
"io"
10+
"log/slog"
11+
"net/http"
12+
"net/http/httptest"
13+
"net/url"
14+
"strconv"
15+
"strings"
16+
"sync"
17+
"testing"
18+
"time"
19+
20+
"github.com/karnstack/flue/internal/config"
21+
"github.com/karnstack/flue/internal/crypto"
22+
"github.com/karnstack/flue/internal/daemon"
23+
"github.com/karnstack/flue/internal/fleet"
24+
"github.com/karnstack/flue/internal/session"
25+
"github.com/karnstack/flue/internal/transport/local"
26+
"github.com/karnstack/flue/internal/wire"
27+
)
28+
29+
// This file holds one test, and it is the one that would have caught the bug.
30+
//
31+
// The shape of that bug: a daemon that was already running when its fleet was
32+
// created stayed on the relay and silently stopped being able to sign for it.
33+
// Every piece of it was covered — the ceremony mints a cert, the URL carries
34+
// `f=`, the relay leg re-reads relay.json — and the seam between the pieces was
35+
// not, because every test built its daemon *after* the file it would read.
36+
// Real machines are the other way round: the daemon starts at login and the
37+
// relay is set up in a terminal an hour later.
38+
//
39+
// So this one runs in that order, across the same two processes the user has:
40+
// a daemon serving, and the CLI writing relay.json beside it.
41+
42+
// pairPipe is a MessageConn over two channels — the smallest thing a daemon
43+
// will serve. The relay transport hands it a socket; a test hands it this.
44+
type pairPipe struct {
45+
in chan []byte
46+
out chan []byte
47+
closed chan struct{}
48+
once sync.Once
49+
}
50+
51+
func newPairPipe() *pairPipe {
52+
return &pairPipe{in: make(chan []byte, 8), out: make(chan []byte, 32), closed: make(chan struct{})}
53+
}
54+
55+
func (p *pairPipe) Read(ctx context.Context) (bool, []byte, error) {
56+
select {
57+
case m := <-p.in:
58+
return true, m, nil
59+
case <-p.closed:
60+
return false, nil, io.EOF
61+
case <-ctx.Done():
62+
return false, nil, ctx.Err()
63+
}
64+
}
65+
66+
func (p *pairPipe) Write(ctx context.Context, text bool, data []byte) error {
67+
if !text {
68+
return nil
69+
}
70+
select {
71+
case p.out <- append([]byte(nil), data...):
72+
return nil
73+
case <-p.closed:
74+
return errors.New("closed")
75+
case <-ctx.Done():
76+
return ctx.Err()
77+
}
78+
}
79+
80+
func (p *pairPipe) Close() error { p.once.Do(func() { close(p.closed) }); return nil }
81+
82+
// openPairingWindow does what the Devices screen does: connect, say hello, ask
83+
// to pair, and read the link the daemon answers with.
84+
func openPairingWindow(t *testing.T, srv *daemon.Server) wire.Pairing {
85+
t.Helper()
86+
p := newPairPipe()
87+
go srv.ServeConn(context.Background(), p, daemon.ConnMeta{Peer: "test", Origin: "http://127.0.0.1:7717"})
88+
t.Cleanup(func() { _ = p.Close() })
89+
90+
hello, err := wire.EncodeControl(wire.Hello{Ver: "test"})
91+
if err != nil {
92+
t.Fatalf("EncodeControl: %v", err)
93+
}
94+
p.in <- hello
95+
start, err := wire.EncodeControl(wire.PairStart{})
96+
if err != nil {
97+
t.Fatalf("EncodeControl: %v", err)
98+
}
99+
p.in <- start
100+
101+
deadline := time.After(3 * time.Second)
102+
for {
103+
select {
104+
case raw := <-p.out:
105+
msg, err := wire.DecodeControl(raw)
106+
if err != nil {
107+
t.Fatalf("undecodable control frame: %v", err)
108+
}
109+
if pairing, ok := msg.(wire.Pairing); ok {
110+
return pairing
111+
}
112+
case <-deadline:
113+
t.Fatal("the daemon never answered pairStart")
114+
}
115+
}
116+
}
117+
118+
// TestASetupInOneTerminalPairsInTheNextBreath is the whole defect, end to end.
119+
//
120+
// A daemon comes up on a machine with no relay. `flue relay join` then runs in
121+
// another process — the terminal, the way a second machine is added — and the
122+
// user pairs a phone immediately afterwards, restarting nothing. What the
123+
// phone gets has to be a real fleet membership: a link carrying the fleet
124+
// public key it pins, and a certificate minted under that key naming the
125+
// machine it paired with.
126+
//
127+
// Before the fix this failed twice over: the link came back without `&f=`
128+
// (fleetPubParam read a fleet key captured at construction, which was empty),
129+
// and the ceremony minted no certificate (same key, plus a machine id that
130+
// belonged to a relay leg this process never started). Both records are written
131+
// once and never revisited, so that phone was permanently a one-machine phone —
132+
// and nothing anywhere said so.
133+
func TestASetupInOneTerminalPairsInTheNextBreath(t *testing.T) {
134+
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
135+
136+
token, err := config.LoadOrCreateToken()
137+
if err != nil {
138+
t.Fatalf("LoadOrCreateToken: %v", err)
139+
}
140+
141+
// --- the daemon, started before any relay exists -------------------------
142+
//
143+
// Exactly what cmdServe builds, in the same order: the identity first, then
144+
// the server, then the relay bookkeeping and the service the /api/relay
145+
// endpoints run through.
146+
identity, err := loadIdentity(slog.New(slog.DiscardHandler))
147+
if err != nil {
148+
t.Fatalf("loadIdentity: %v", err)
149+
}
150+
srv := daemon.New(session.NewRegistry(time.Now), local.NewAuth(token, 0), uiHandler(), version, identity)
151+
t.Cleanup(srv.Shutdown)
152+
ts := httptest.NewServer(srv.Handler())
153+
t.Cleanup(ts.Close)
154+
u, err := url.Parse(ts.URL)
155+
if err != nil {
156+
t.Fatalf("parse %q: %v", ts.URL, err)
157+
}
158+
port, err := strconv.Atoi(u.Port())
159+
if err != nil {
160+
t.Fatalf("port of %q: %v", ts.URL, err)
161+
}
162+
srv.SetAuth(local.NewAuth(token, port))
163+
164+
// The relay leg, stubbed at the one place a test cannot follow: dialling.
165+
// It does what startRelay does with the file — reads it, tells the daemon
166+
// which machine it is and where it is reachable — and reports the socket as
167+
// up without opening one. startRelay's own wiring is covered by
168+
// TestStartRelayDialsAConfiguredRelay; what this test is about is whether
169+
// anything at all happens when another process writes that file.
170+
legs := 0
171+
rt := &relayRuntime{start: func() (bool, func()) {
172+
cfg, ok, err := config.LoadRelay()
173+
if err != nil || !ok {
174+
return false, nil
175+
}
176+
legs++
177+
srv.SetRelayMachine(cfg.MachineID, cfg.MachineName)
178+
srv.SetRelayOrigin(cfg.Origin)
179+
srv.SetRelayStatus(daemon.RelayConnected, cfg.Origin)
180+
return true, func() { srv.SetRelayStatus(daemon.RelayOff, "") }
181+
}}
182+
srv.SetRelayUI(&relayUIService{runtime: rt})
183+
// How the CLI in the other process finds this daemon at all.
184+
if err := daemon.WriteRuntime(port); err != nil {
185+
t.Fatalf("WriteRuntime: %v", err)
186+
}
187+
188+
// --- before: a machine on no relay pairs as it always did ----------------
189+
if got := openPairingWindow(t, srv); strings.Contains(got.URL, "&f=") {
190+
t.Fatalf("a daemon with no fleet key offered a fleet key: %s", got.URL)
191+
}
192+
193+
// --- the other terminal ---------------------------------------------------
194+
var out strings.Builder
195+
if err := runRelayJoin(&out, []string{
196+
"wss://relay.example.com",
197+
"--secret", "s3cr3t-daemon-secret",
198+
"--fleet", testFleetSeed,
199+
"--name", "Karn's MacBook Pro",
200+
}); err != nil {
201+
t.Fatalf("runRelayJoin: %v", err)
202+
}
203+
if legs != 1 {
204+
t.Fatalf("relay legs started = %d; the running daemon never picked up the join", legs)
205+
}
206+
if strings.Contains(out.String(), "flue disable && flue enable") {
207+
t.Errorf("join told the user to restart a daemon it had just reconfigured:\n%s", out.String())
208+
}
209+
210+
cfg, ok, err := config.LoadRelay()
211+
if err != nil || !ok {
212+
t.Fatalf("LoadRelay after the join: ok=%v err=%v", ok, err)
213+
}
214+
want, err := fleet.Parse(testFleetSeed)
215+
if err != nil {
216+
t.Fatalf("fleet.Parse: %v", err)
217+
}
218+
219+
// --- after: no restart, and a phone that joins the fleet -----------------
220+
pairing := openPairingWindow(t, srv)
221+
// The literal spelling, because that is what a phone's camera reads and
222+
// what conn.go splices in by hand: a `f=` that arrived as `?f=`, or with
223+
// the parameter dropped, is a browser that pins nothing.
224+
if !strings.Contains(pairing.URL, "&f=") {
225+
t.Fatalf("the pairing link carries no &f= after the join, so this browser would pin no fleet key and could never be given one: %s", pairing.URL)
226+
}
227+
link, err := url.Parse(pairing.URL)
228+
if err != nil {
229+
t.Fatalf("pairing url %q does not parse: %v", pairing.URL, err)
230+
}
231+
raw, err := base64.RawURLEncoding.DecodeString(link.Query().Get("f"))
232+
if err != nil {
233+
t.Fatalf("f = %q is not unpadded URL-safe base64: %v", link.Query().Get("f"), err)
234+
}
235+
if !bytes.Equal(raw, want.Public()) {
236+
t.Errorf("f decodes to %x, want the fleet key the join line carried %x", raw, want.Public())
237+
}
238+
// And the QR names the relay, not loopback: the leg the join started is
239+
// what a phone would open.
240+
if !strings.HasPrefix(pairing.URL, cfg.Origin) {
241+
t.Errorf("the pairing link names %s, want the relay origin %s", pairing.URL, cfg.Origin)
242+
}
243+
244+
// The ceremony itself, as the pairing page performs it.
245+
devKey := make([]byte, 32)
246+
for i := range devKey {
247+
devKey[i] = 0x2a
248+
}
249+
body, err := json.Marshal(map[string]string{
250+
"token": pairing.Token,
251+
"publicKey": base64.StdEncoding.EncodeToString(devKey),
252+
"label": "phone",
253+
})
254+
if err != nil {
255+
t.Fatalf("marshal the pairing request: %v", err)
256+
}
257+
if out := srv.PairDevice(body, "test"); out.Status != http.StatusOK {
258+
t.Fatalf("PairDevice = %d (%s), want 200", out.Status, out.Body)
259+
}
260+
261+
dir, err := config.Dir()
262+
if err != nil {
263+
t.Fatalf("config.Dir: %v", err)
264+
}
265+
dev, found, err := crypto.NewDeviceStore(dir).FindByKey(devKey)
266+
if err != nil || !found {
267+
t.Fatalf("FindByKey after pairing = %v, %v", found, err)
268+
}
269+
cert, err := fleet.VerifyDevice(want.Public(), dev.Cert)
270+
if err != nil {
271+
t.Fatalf("the ceremony minted no certificate under the fleet key this machine just joined: %v", err)
272+
}
273+
if cert.PairedOn != cfg.MachineID {
274+
t.Errorf("cert names pairedOn %q, want the machine id the join minted %q", cert.PairedOn, cfg.MachineID)
275+
}
276+
if !bytes.Equal(cert.Device, devKey) {
277+
t.Error("the ceremony minted a certificate for another device's key")
278+
}
279+
}

0 commit comments

Comments
 (0)