20bcd5b1
agent: a trust root the agent cannot use is refused at the door
a73x 2026-08-23 10:36
Commit message
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 | } | ||