a73x

20bcd5b1

agent: a trust root the agent cannot use is refused at the door

a73x   2026-08-23 10:36

Commit message
agent: a trust root the agent cannot use is refused at the door

The pinned server fingerprint is the agent's only means of authenticating
the control plane -- the sync client's TLS VerifyConnection compares every
peer against that one string. An empty or malformed one is not a weaker
pin, it is a pin nothing matches: the host enrols, reports success, and
then refuses every server it ever dials, surfacing hours later as a
certificate mismatch nowhere near the join that caused it.

joinblob.validate already enforced the shape on the way in. SaveIdentity
now enforces it on the way down, so the mistake is unrepresentable rather
than merely untested, and join says the token is spent when it fires.

docs/shape.html
Old New
@@ -288,7 +288,9 @@
288 "importPath": "internal/agent/state", 288 "importPath": "internal/agent/state",
289 "plane": "data", 289 "plane": "data",
290 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", 290 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).",
291 "imports": [] 291 "imports": [
292 "internal/names"
293 ]
292 }, 294 },
293 { 295 {
294 "importPath": "internal/agent/statelock", 296 "importPath": "internal/agent/statelock",
docs/shape.json
Old New
@@ -237,7 +237,9 @@
237 "importPath": "internal/agent/state", 237 "importPath": "internal/agent/state",
238 "plane": "data", 238 "plane": "data",
239 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).", 239 "synopsis": "Package state is the agent's durable state directory (default /var/lib/eitri-agent).",
240 "imports": [] 240 "imports": [
241 "internal/names"
242 ]
241 }, 243 },
242 { 244 {
243 "importPath": "internal/agent/statelock", 245 "importPath": "internal/agent/statelock",
internal/agent/run/cli.go
Old New
@@ -258,7 +258,7 @@ func join(st *state.Store, cfg Config, blob string) error {
258 ServerCertSHA256: f.CertFP, // authoritative; response fingerprint ignored 258 ServerCertSHA256: f.CertFP, // authoritative; response fingerprint ignored
259 } 259 }
260 if err := st.SaveIdentity(id); err != nil { 260 if err := st.SaveIdentity(id); err != nil {
261 return fmt.Errorf("save identity: %w", err) 261 return fmt.Errorf("enroll succeeded but this host is NOT joined: nothing was written, and the blob's one-shot token is now spent, so a retry needs a freshly minted one: %w", err)
262 } 262 }
263 guestCIDR := resolveGuestCIDR(st, cfg, result.BridgeCIDR) 263 guestCIDR := resolveGuestCIDR(st, cfg, result.BridgeCIDR)
264 fmt.Printf("Enrolled: host_id=%s guest_cidr=%s\n", result.HostID, guestCIDR) 264 fmt.Printf("Enrolled: host_id=%s guest_cidr=%s\n", result.HostID, guestCIDR)
internal/agent/run/guestcidr_test.go
Old New
@@ -1,6 +1,7 @@
1 package run 1 package run
2 2
3 import ( 3 import (
4 "strings"
4 "testing" 5 "testing"
5 6
6 "github.com/a73x/eitri/internal/agent/state" 7 "github.com/a73x/eitri/internal/agent/state"
@@ -12,7 +13,8 @@ func storeWithIdentity(t *testing.T, cidr string) *state.Store {
12 t.Helper() 13 t.Helper()
13 st, err := state.Open(t.TempDir()) 14 st, err := state.Open(t.TempDir())
14 require.NoError(t, err) 15 require.NoError(t, err)
15 require.NoError(t, st.SaveIdentity(state.Identity{HostID: "h1", BridgeCIDR: cidr})) 16 require.NoError(t, st.SaveIdentity(state.Identity{HostID: "h1", BridgeCIDR: cidr,
17 ServerQUICAddr: "10.0.0.1:8443", ServerCertSHA256: strings.Repeat("a", 64)}))
16 return st 18 return st
17 } 19 }
18 20
internal/agent/state/state.go
Old New
@@ -12,6 +12,8 @@ import (
12 "strconv" 12 "strconv"
13 "strings" 13 "strings"
14 "time" 14 "time"
15
16 "github.com/a73x/eitri/internal/names"
15 ) 17 )
16 18
17 type VMSpec struct { 19 type VMSpec struct {
@@ -297,8 +299,15 @@ func (s *Store) Identity() (Identity, bool) {
297 return id, true 299 return id, true
298 } 300 }
299 301
300 // SaveIdentity persists the agent's identity atomically. 302 // SaveIdentity persists the agent's identity atomically, refusing one whose
303 // pinned fingerprint the agent could never dial with.
301 func (s *Store) SaveIdentity(id Identity) error { 304 func (s *Store) SaveIdentity(id Identity) error {
305 if !names.IsSHA256Hex(id.ServerCertSHA256) {
306 return fmt.Errorf("refusing to persist server cert fingerprint %q: it must be 64 lowercase hex, "+
307 "because it is the agent's only means of authenticating the control plane — "+
308 "an unusable one enrolls cleanly and then refuses every server this host ever dials",
309 id.ServerCertSHA256)
310 }
302 data, err := json.MarshalIndent(id, "", " ") 311 data, err := json.MarshalIndent(id, "", " ")
303 if err != nil { 312 if err != nil {
304 return err 313 return err
internal/agent/state/state_test.go
Old New
@@ -4,6 +4,7 @@ import (
4 "os" 4 "os"
5 "path/filepath" 5 "path/filepath"
6 "strconv" 6 "strconv"
7 "strings"
7 "testing" 8 "testing"
8 "time" 9 "time"
9 10
@@ -167,3 +168,38 @@ func TestDiskPathLocatesDiskFile(t *testing.T) {
167 _, err = os.Stat(s.DiskPath("vm1")) 168 _, err = os.Stat(s.DiskPath("vm1"))
168 assert.NoError(t, err) 169 assert.NoError(t, err)
169 } 170 }
171
172 // TestSaveIdentityRefusesAnUnusableTrustRoot asserts the guard from the side
173 // that never reaches a server. The stored fingerprint is what the sync client's
174 // TLS VerifyConnection compares every peer against, so "" or a typo is not a
175 // weaker pin — it is a pin nothing can match, and the host enrols cleanly, says
176 // so, and then refuses every server it dials until someone reads a certificate
177 // mismatch at 3am. joinblob.validate enforces the same shape on the way in;
178 // this is the half that makes the mistake unrepresentable on the way down.
179 func TestSaveIdentityRefusesAnUnusableTrustRoot(t *testing.T) {
180 good := strings.Repeat("a", 64)
181 for _, bad := range []string{
182 "",
183 strings.Repeat("a", 63),
184 strings.Repeat("a", 65),
185 strings.ToUpper(good), // hex, but the pin is compared as a lowercase string
186 strings.Repeat("g", 64),
187 "sha256:" + good,
188 } {
189 s := open(t)
190 err := s.SaveIdentity(Identity{HostID: "h1", Credential: "c1",
191 ServerQUICAddr: "10.0.0.1:8443", ServerCertSHA256: bad})
192 require.Error(t, err, "fingerprint %q authenticates no control plane and must never be persisted", bad)
193 assert.Contains(t, err.Error(), "64 lowercase hex")
194
195 _, ok := s.Identity()
196 assert.False(t, ok, "a refused identity must leave the agent unenrolled, not half-enrolled with %q", bad)
197 }
198
199 s := open(t)
200 require.NoError(t, s.SaveIdentity(Identity{HostID: "h1", Credential: "c1",
201 ServerQUICAddr: "10.0.0.1:8443", ServerCertSHA256: good}))
202 id, ok := s.Identity()
203 require.True(t, ok)
204 assert.Equal(t, good, id.ServerCertSHA256)
205 }