//go:build linux package host import ( "crypto/ed25519" "encoding/json" "errors" "os" "path/filepath" "strings" "testing" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/release" ) // signedWrite changes the roster by a DIRECT SIGNED WRITE: mutate edits a // copy, Revision moves by one, and the result is signed with the root key // and CAS-written, bypassing Directory.Replace's membership rules. The L5 // changes (a floor change after publication, a home region change, a // same-reference re-enrolment, a census-key rotation) are refused by the // normal writers, so each is simulated this way and named as such. func (f *bindingRosterFixture) signedWrite(mutate func(*hostactors.Roster)) { f.t.Helper() o := f.read() next := hostactors.Clone(o.Roster) mutate(&next) next.Revision++ wire, err := release.SignRootEnvelope(f.signer, next) if err != nil { f.t.Fatalf("signedWrite: %v", err) } if _, err := f.store.Put(f.t.Context(), hostactors.Key, wire, o.ETag); err != nil { f.t.Fatalf("signedWrite: %v", err) } } // republishFloor replaces the published floor, remotely (the signed floor // and its authorization, overwritten by DIRECT WRITE: hostfloor.Publish is // create-only) and locally (the box's floor policy), and returns its digest. func (f *bindingRosterFixture) republishFloor(floor hostfloor.Floor) string { f.t.Helper() ctx := f.t.Context() raw, auth, err := hostfloor.Authorize(f.signer, bindingFixtureBucket, floor) if err != nil { f.t.Fatal(err) } for key, body := range map[string][]byte{hostfloor.AuthKey: auth, hostfloor.Key: raw} { _, etag, err := f.store.Get(ctx, key) if err != nil { f.t.Fatal(err) } if _, err := f.store.Put(ctx, key, body, etag); err != nil { f.t.Fatal(err) } } policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: bindingFixtureBucket, Mode: "enforced", Floor: raw, Authorization: auth}) if err := os.WriteFile(filepath.Join(f.floorDir, "policy.json"), policy, 0600); err != nil { f.t.Fatal(err) } return hostfloor.Digest(raw) } // L5 (roster binding): a change to a binding fact other than quarantine // moves the box binding and refuses installed state. Each change is a // direct signed write (see signedWrite): the published floor (rewritten // locally and remotely so productionClaimRoster.Actor still passes its floor // check and only the binding differs), the home region, a same-reference // re-enrolment, and a census-key rotation. After each, the installation made // at B0 is refused at root's wake (state registration) and the B0 agent's // new install is refused at root's inventory write; the bound identity // comparison refuses the pinned actor. func TestClaimBindingFloorAndHomeChange(t *testing.T) { for _, change := range []string{"floor", "home region", "re-enrolment", "census key"} { t.Run(change, func(t *testing.T) { ctx := t.Context() w := newAssemblyWorld(t) w.mustStart() b0 := w.fresh() agent := w.agent(b0) rec, route := w.newHost("a") if err := agent.install(rec, route); err != nil { t.Fatal(err) } switch change { case "floor": digest := w.roster.republishFloor(hostfloor.Floor{Min: 1, MinRelease: "v0.1.1"}) w.roster.signedWrite(func(r *hostactors.Roster) { r.FloorSHA256 = digest }) case "home region": w.roster.signedWrite(func(r *hostactors.Roster) { r.HomeRegion = "uk" }) case "re-enrolment": w.roster.signedWrite(func(r *hostactors.Roster) { b := r.Boxes["box-1"] b.Enrollment = strings.Repeat("f", 32) r.Boxes["box-1"] = b }) case "census key": rotated := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) w.roster.signedWrite(func(r *hostactors.Roster) { b := r.Boxes["box-1"] b.CensusPublicKey = rotated.Public().(ed25519.PublicKey) r.Boxes["box-1"] = b }) } b1 := w.fresh() if !hostactors.ValidBinding(b1.Binding) || b1.Binding == b0.Binding { t.Fatalf("%s did not move the binding", change) } if change == "floor" { only := b1 only.Binding = b0.Binding if !hostbox.SameBinding(only, b0) { t.Fatalf("a floor change moved more than the binding: %+v", b1) } } if err := agent.registerState(rec.Host, route.DBPath, b1); !errors.Is(err, hostbox.ErrTypedInventory) { t.Fatalf("the B0 installation at wake after the %s change: %v", change, err) } recB, routeB := w.newHost("b") if err := agent.install(recB, routeB); err == nil { t.Fatalf("root accepted a B0 install after the %s change", change) } if err := claimBoundIdentity(w.owners.base.Identity.Verify, b0)(ctx); err == nil { t.Fatalf("the bound identity accepted the B0 actor after the %s change", change) } }) } }