| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "crypto/ed25519" |
| 7 | "encoding/json" |
| 8 | "errors" |
| 9 | "os" |
| 10 | "path/filepath" |
| 11 | "strings" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostactors" |
| 15 | "github.com/carlosframework/platform/internal/hostbox" |
| 16 | "github.com/carlosframework/platform/internal/hostfloor" |
| 17 | "github.com/carlosframework/platform/internal/release" |
| 18 | ) |
| 19 | |
| 20 | // signedWrite changes the roster by a DIRECT SIGNED WRITE: mutate edits a |
| 21 | // copy, Revision moves by one, and the result is signed with the root key |
| 22 | // and CAS-written, bypassing Directory.Replace's membership rules. The L5 |
| 23 | // changes (a floor change after publication, a home region change, a |
| 24 | // same-reference re-enrolment, a census-key rotation) are refused by the |
| 25 | // normal writers, so each is simulated this way and named as such. |
| 26 | func (f *bindingRosterFixture) signedWrite(mutate func(*hostactors.Roster)) { |
| 27 | f.t.Helper() |
| 28 | o := f.read() |
| 29 | next := hostactors.Clone(o.Roster) |
| 30 | mutate(&next) |
| 31 | next.Revision++ |
| 32 | wire, err := release.SignRootEnvelope(f.signer, next) |
| 33 | if err != nil { |
| 34 | f.t.Fatalf("signedWrite: %v", err) |
| 35 | } |
| 36 | if _, err := f.store.Put(f.t.Context(), hostactors.Key, wire, o.ETag); err != nil { |
| 37 | f.t.Fatalf("signedWrite: %v", err) |
| 38 | } |
| 39 | } |
| 40 | |
| 41 | // republishFloor replaces the published floor, remotely (the signed floor |
| 42 | // and its authorization, overwritten by DIRECT WRITE: hostfloor.Publish is |
| 43 | // create-only) and locally (the box's floor policy), and returns its digest. |
| 44 | func (f *bindingRosterFixture) republishFloor(floor hostfloor.Floor) string { |
| 45 | f.t.Helper() |
| 46 | ctx := f.t.Context() |
| 47 | raw, auth, err := hostfloor.Authorize(f.signer, bindingFixtureBucket, floor) |
| 48 | if err != nil { |
| 49 | f.t.Fatal(err) |
| 50 | } |
| 51 | for key, body := range map[string][]byte{hostfloor.AuthKey: auth, hostfloor.Key: raw} { |
| 52 | _, etag, err := f.store.Get(ctx, key) |
| 53 | if err != nil { |
| 54 | f.t.Fatal(err) |
| 55 | } |
| 56 | if _, err := f.store.Put(ctx, key, body, etag); err != nil { |
| 57 | f.t.Fatal(err) |
| 58 | } |
| 59 | } |
| 60 | policy, _ := json.Marshal(hostfloor.Policy{Schema: 1, DeploymentBucket: bindingFixtureBucket, Mode: "enforced", Floor: raw, Authorization: auth}) |
| 61 | if err := os.WriteFile(filepath.Join(f.floorDir, "policy.json"), policy, 0600); err != nil { |
| 62 | f.t.Fatal(err) |
| 63 | } |
| 64 | return hostfloor.Digest(raw) |
| 65 | } |
| 66 | |
| 67 | // L5 (roster binding): a change to a binding fact other than quarantine |
| 68 | // moves the box binding and refuses installed state. Each change is a |
| 69 | // direct signed write (see signedWrite): the published floor (rewritten |
| 70 | // locally and remotely so productionClaimRoster.Actor still passes its floor |
| 71 | // check and only the binding differs), the home region, a same-reference |
| 72 | // re-enrolment, and a census-key rotation. After each, the installation made |
| 73 | // at B0 is refused at root's wake (state registration) and the B0 agent's |
| 74 | // new install is refused at root's inventory write; the bound identity |
| 75 | // comparison refuses the pinned actor. |
| 76 | func TestClaimBindingFloorAndHomeChange(t *testing.T) { |
| 77 | for _, change := range []string{"floor", "home region", "re-enrolment", "census key"} { |
| 78 | t.Run(change, func(t *testing.T) { |
| 79 | ctx := t.Context() |
| 80 | w := newAssemblyWorld(t) |
| 81 | w.mustStart() |
| 82 | b0 := w.fresh() |
| 83 | agent := w.agent(b0) |
| 84 | rec, route := w.newHost("a") |
| 85 | if err := agent.install(rec, route); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | switch change { |
| 89 | case "floor": |
| 90 | digest := w.roster.republishFloor(hostfloor.Floor{Min: 1, MinRelease: "v0.1.1"}) |
| 91 | w.roster.signedWrite(func(r *hostactors.Roster) { r.FloorSHA256 = digest }) |
| 92 | case "home region": |
| 93 | w.roster.signedWrite(func(r *hostactors.Roster) { r.HomeRegion = "uk" }) |
| 94 | case "re-enrolment": |
| 95 | w.roster.signedWrite(func(r *hostactors.Roster) { |
| 96 | b := r.Boxes["box-1"] |
| 97 | b.Enrollment = strings.Repeat("f", 32) |
| 98 | r.Boxes["box-1"] = b |
| 99 | }) |
| 100 | case "census key": |
| 101 | rotated := ed25519.NewKeyFromSeed(make([]byte, ed25519.SeedSize)) |
| 102 | w.roster.signedWrite(func(r *hostactors.Roster) { |
| 103 | b := r.Boxes["box-1"] |
| 104 | b.CensusPublicKey = rotated.Public().(ed25519.PublicKey) |
| 105 | r.Boxes["box-1"] = b |
| 106 | }) |
| 107 | } |
| 108 | b1 := w.fresh() |
| 109 | if !hostactors.ValidBinding(b1.Binding) || b1.Binding == b0.Binding { |
| 110 | t.Fatalf("%s did not move the binding", change) |
| 111 | } |
| 112 | if change == "floor" { |
| 113 | only := b1 |
| 114 | only.Binding = b0.Binding |
| 115 | if !hostbox.SameBinding(only, b0) { |
| 116 | t.Fatalf("a floor change moved more than the binding: %+v", b1) |
| 117 | } |
| 118 | } |
| 119 | if err := agent.registerState(rec.Host, route.DBPath, b1); !errors.Is(err, hostbox.ErrTypedInventory) { |
| 120 | t.Fatalf("the B0 installation at wake after the %s change: %v", change, err) |
| 121 | } |
| 122 | recB, routeB := w.newHost("b") |
| 123 | if err := agent.install(recB, routeB); err == nil { |
| 124 | t.Fatalf("root accepted a B0 install after the %s change", change) |
| 125 | } |
| 126 | if err := claimBoundIdentity(w.owners.base.Identity.Verify, b0)(ctx); err == nil { |
| 127 | t.Fatalf("the bound identity accepted the B0 actor after the %s change", change) |
| 128 | } |
| 129 | }) |
| 130 | } |
| 131 | } |
| 132 | |