| 1 | package claimactivation |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | "crypto/rand" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "github.com/carlosframework/platform/internal/hostfloor" |
| 11 | "github.com/carlosframework/platform/internal/status" |
| 12 | "os" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | "time" |
| 16 | |
| 17 | "github.com/carlosframework/platform/internal/hostactors" |
| 18 | "github.com/carlosframework/platform/internal/hostbox" |
| 19 | "github.com/carlosframework/platform/internal/release" |
| 20 | ) |
| 21 | |
| 22 | func runnerFixture(t *testing.T) Runner { |
| 23 | t.Helper() |
| 24 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 25 | return Runner{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key)} |
| 26 | } |
| 27 | func TestActivationInitializeConfigureAndHome(t *testing.T) { |
| 28 | r := runnerFixture(t) |
| 29 | ctx := t.Context() |
| 30 | p := Plan{Phase: "initialize", Bucket: "bucket"} |
| 31 | result, err := r.Run(ctx, p, true) |
| 32 | if err != nil || len(result.Writes) != 1 { |
| 33 | t.Fatal(result, err) |
| 34 | } |
| 35 | if _, err = r.Directory.Read(ctx); !errors.Is(err, os.ErrNotExist) { |
| 36 | t.Fatal("dry run wrote roster", err) |
| 37 | } |
| 38 | if _, err = r.Run(ctx, p, false); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | p = Plan{Phase: "configure", Bucket: "bucket", ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"cohort1"}}}} |
| 42 | if _, err = r.Run(ctx, p, false); err != nil { |
| 43 | t.Fatal(err) |
| 44 | } |
| 45 | got, _ := r.Directory.Read(ctx) |
| 46 | if got.Roster.RequestLimitNS != int64(hostbox.RequestLimit) { |
| 47 | t.Fatal("wrong request limit") |
| 48 | } |
| 49 | if _, err = r.Run(ctx, p, false); err == nil { |
| 50 | t.Fatal("stale revision accepted") |
| 51 | } |
| 52 | p = Plan{Phase: "home", Bucket: "bucket", ExpectedRevision: 2, HomeRegion: "ie"} |
| 53 | if _, err = r.Run(ctx, p, false); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | got, _ = r.Directory.Read(ctx) |
| 57 | if got.Roster.HomeRegion != "ie" { |
| 58 | t.Fatal("home absent") |
| 59 | } |
| 60 | p.ExpectedRevision = 3 |
| 61 | if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 { |
| 62 | t.Fatal("home replay", result, err) |
| 63 | } |
| 64 | p.HomeRegion = "uk" |
| 65 | if _, err = r.Run(ctx, p, false); err == nil { |
| 66 | t.Fatal("home changed") |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | type corruptReadback struct { |
| 71 | release.Store |
| 72 | corrupt bool |
| 73 | } |
| 74 | |
| 75 | func (s *corruptReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| 76 | tag, err := s.Store.Put(ctx, key, raw, etag) |
| 77 | s.corrupt = true |
| 78 | return tag, err |
| 79 | } |
| 80 | func (s *corruptReadback) Get(ctx context.Context, key string) ([]byte, string, error) { |
| 81 | raw, tag, err := s.Store.Get(ctx, key) |
| 82 | if s.corrupt && key == hostactors.Key { |
| 83 | raw = []byte(`{"untrusted":true}`) |
| 84 | } |
| 85 | return raw, tag, err |
| 86 | } |
| 87 | func TestActivationRequiresAuthenticatedReadback(t *testing.T) { |
| 88 | r := runnerFixture(t) |
| 89 | r.Directory.Store = &corruptReadback{Store: r.Directory.Store} |
| 90 | if _, err := r.Run(t.Context(), Plan{Phase: "initialize", Bucket: "bucket"}, false); err == nil { |
| 91 | t.Fatal("unverified readback accepted") |
| 92 | } |
| 93 | } |
| 94 | func TestActivationRefusesUnscopedInputs(t *testing.T) { |
| 95 | r := runnerFixture(t) |
| 96 | for _, p := range []Plan{{Phase: "initialize", Bucket: "other"}, {Phase: "unknown", Bucket: "bucket"}, {Phase: "enroll-box", Bucket: "bucket"}, {Phase: "bind-key", Bucket: "bucket"}} { |
| 97 | if _, err := r.Run(t.Context(), p, false); err == nil { |
| 98 | t.Fatal("accepted", p) |
| 99 | } |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | type lostWriteReply struct { |
| 104 | release.Store |
| 105 | once bool |
| 106 | } |
| 107 | |
| 108 | func (s *lostWriteReply) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { |
| 109 | tag, err := s.Store.Put(ctx, key, raw, etag) |
| 110 | if err == nil && !s.once { |
| 111 | s.once = true |
| 112 | return "", errors.New("lost reply") |
| 113 | } |
| 114 | return tag, err |
| 115 | } |
| 116 | func TestActivationResumesAfterLostWriteReply(t *testing.T) { |
| 117 | r := runnerFixture(t) |
| 118 | s := &lostWriteReply{Store: r.Directory.Store} |
| 119 | r.Directory.Store = s |
| 120 | p := Plan{Phase: "initialize", Bucket: "bucket"} |
| 121 | if result, err := r.Run(t.Context(), p, false); err != nil || len(result.Writes) != 1 { |
| 122 | t.Fatal("authenticated lost-reply recovery failed", result, err) |
| 123 | } |
| 124 | if result, err := r.Run(t.Context(), p, false); err != nil || result.Revision != 1 { |
| 125 | t.Fatal(result, err) |
| 126 | } |
| 127 | } |
| 128 | func TestActivationBindRequiresPublishedFloorAndPossession(t *testing.T) { |
| 129 | r := runnerFixture(t) |
| 130 | ctx := t.Context() |
| 131 | if err := r.Directory.Initialize(ctx, r.Signer); err != nil { |
| 132 | t.Fatal(err) |
| 133 | } |
| 134 | o, _ := r.Directory.Read(ctx) |
| 135 | next := hostactors.Clone(o.Roster) |
| 136 | next.RequestLimitNS = int64(hostbox.RequestLimit) |
| 137 | next.Boxes["leo"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "leo", Label: "leo", Region: "ie", Pool: "cohort1", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-leo"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}} |
| 138 | o, err := r.Directory.Replace(ctx, r.Signer, o, next) |
| 139 | if err != nil { |
| 140 | t.Fatal(err) |
| 141 | } |
| 142 | pub, key, _ := ed25519.GenerateKey(rand.Reader) |
| 143 | binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: next.Boxes["leo"].Enrollment, PublicKey: pub} |
| 144 | raw, _ := json.Marshal(binding) |
| 145 | p := Plan{Phase: "bind-key", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Binding: &binding, Proof: ed25519.Sign(key, raw)} |
| 146 | if _, err = r.Run(ctx, p, false); err == nil { |
| 147 | t.Fatal("bound before floor") |
| 148 | } |
| 149 | floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} |
| 150 | if err = hostfloor.Publish(ctx, r.Directory.Store, r.Signer, "bucket", floor); err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | f, err := hostfloor.Read(ctx, r.Directory.Store, r.Directory.PublicKey, "bucket") |
| 154 | if err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | next = hostactors.Clone(o.Roster) |
| 158 | next.Publication = "published" |
| 159 | next.FloorSHA256 = f.Digest |
| 160 | o, err = r.Directory.Replace(ctx, r.Signer, o, next) |
| 161 | if err != nil { |
| 162 | t.Fatal(err) |
| 163 | } |
| 164 | p.ExpectedRevision = o.Roster.Revision |
| 165 | valid := append([]byte(nil), p.Proof...) |
| 166 | p.Proof[0] ^= 1 |
| 167 | if _, err = r.Run(ctx, p, false); err == nil { |
| 168 | t.Fatal("bad possession proof accepted") |
| 169 | } |
| 170 | p.Proof = valid |
| 171 | result, err := r.Run(ctx, p, true) |
| 172 | if err != nil || len(result.Writes) != 1 { |
| 173 | t.Fatal(result, err) |
| 174 | } |
| 175 | current, _ := r.Directory.Read(ctx) |
| 176 | if len(current.Roster.Boxes["leo"].CensusPublicKey) != 0 { |
| 177 | t.Fatal("dry run bound key") |
| 178 | } |
| 179 | result, err = r.Run(ctx, p, false) |
| 180 | if err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | p.ExpectedRevision = result.Revision |
| 184 | result, err = r.Run(ctx, p, false) |
| 185 | if err != nil || len(result.Writes) != 0 { |
| 186 | t.Fatal("binding not resumable", result, err) |
| 187 | } |
| 188 | } |
| 189 | func TestActivationConsoleInvitationEnrollment(t *testing.T) { |
| 190 | r := runnerFixture(t) |
| 191 | ctx := t.Context() |
| 192 | r.Directory.Initialize(ctx, r.Signer) |
| 193 | o, _ := r.Directory.Read(ctx) |
| 194 | next := hostactors.Clone(o.Roster) |
| 195 | next.RequestLimitNS = int64(hostbox.RequestLimit) |
| 196 | next.Boxes["sophie"] = hostactors.Box{Enrollment: strings.Repeat("a", 32), Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}} |
| 197 | o, err := r.Directory.Replace(ctx, r.Signer, o, next) |
| 198 | if err != nil { |
| 199 | t.Fatal(err) |
| 200 | } |
| 201 | c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: next.Boxes["sophie"].Enrollment}} |
| 202 | p := Plan{Phase: "enroll-console", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Console: &c} |
| 203 | if _, err = r.Run(ctx, p, false); err == nil { |
| 204 | t.Fatal("enrollment without invitation") |
| 205 | } |
| 206 | p.Phase = "invite-console" |
| 207 | if _, err = r.Run(ctx, p, false); err != nil { |
| 208 | t.Fatal(err) |
| 209 | } |
| 210 | p.Phase = "enroll-console" |
| 211 | result, err := r.Run(ctx, p, false) |
| 212 | if err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | p.ExpectedRevision = result.Revision |
| 216 | if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 { |
| 217 | t.Fatal("replay", result, err) |
| 218 | } |
| 219 | c.Compute.Box = "leo" |
| 220 | if _, err = r.Run(ctx, p, false); err == nil { |
| 221 | t.Fatal("wrong compute accepted") |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func TestActivationFloorWaitsForGuardAndComputeReport(t *testing.T) { |
| 226 | r := runnerFixture(t) |
| 227 | ctx := t.Context() |
| 228 | store := r.Directory.Store |
| 229 | if err := r.Directory.Initialize(ctx, r.Signer); err != nil { |
| 230 | t.Fatal(err) |
| 231 | } |
| 232 | o, _ := r.Directory.Read(ctx) |
| 233 | next := hostactors.Clone(o.Roster) |
| 234 | next.RequestLimitNS = int64(hostbox.RequestLimit) |
| 235 | en := strings.Repeat("a", 32) |
| 236 | hash := strings.Repeat("f", 64) |
| 237 | tag := "v1.0.0" |
| 238 | next.Boxes["sophie"] = hostactors.Box{Enrollment: en, Namespace: "platform", BoxID: "sophie", Label: "sophie", Region: "ie", Pool: "console", Cloud: hostactors.Cloud{Provider: "aws", Account: "123456789012", Region: "eu-west-1", InstanceID: "i-sophie"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", LossIDs: []string{}} |
| 239 | c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: en}} |
| 240 | next.Consoles[c.InstanceID] = c |
| 241 | o, err := r.Directory.Replace(ctx, r.Signer, o, next) |
| 242 | if err != nil { |
| 243 | t.Fatal(err) |
| 244 | } |
| 245 | floor := hostfloor.Floor{Min: 1, MinRelease: tag} |
| 246 | body, _ := json.Marshal(floor) |
| 247 | digest := hostfloor.Digest(body) |
| 248 | rel := release.SystemRelease{Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: 1, Artifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: release.SystemArtifactKey(tag, "linux-amd64"), SHA256: hash}}, ConsoleArtifacts: map[string]release.SystemArtifact{"linux-amd64": {Key: "bootstrap/console/" + tag + "/carlos-linux-amd64", SHA256: hash}}} |
| 249 | if _, err = release.PromoteSystem(ctx, store, r.Signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { |
| 250 | t.Fatal(err) |
| 251 | } |
| 252 | wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable")) |
| 253 | if err != nil { |
| 254 | t.Fatal(err) |
| 255 | } |
| 256 | if err = release.PublishSystemIdentity(ctx, store, r.Directory.PublicKey, wire); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | now := time.Now().UTC() |
| 260 | report := status.Box{GeneratedAt: now, Metrics: &status.BoxMetrics{Enrollment: en, InstanceID: "i-sophie", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true}} |
| 261 | put := func(key string, value any) { |
| 262 | t.Helper() |
| 263 | raw, _ := json.Marshal(value) |
| 264 | _, etag, _ := store.Get(ctx, key) |
| 265 | if _, err := store.Put(ctx, key, raw, etag); err != nil { |
| 266 | t.Fatal(err) |
| 267 | } |
| 268 | } |
| 269 | put("status/boxes/sophie.json", report) |
| 270 | cr := hostactors.Report{Compute: c.Compute, Component: "console", GeneratedAt: now, Actor: c.InstanceID, Enrollment: c.Enrollment, InstanceID: c.InstanceID, ProcessID: "console-process", Release: tag, ReleaseTag: tag, Platform: "linux-amd64", BinarySHA256: hash, MovesProtocol: 1, BootID: "boot", FloorSHA256: digest, FloorGuardReady: true} |
| 271 | put("status/consoles/dev-console.json", cr) |
| 272 | p := Plan{Phase: "publish-floor", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor} |
| 273 | if _, err = r.Run(ctx, p, false); err == nil { |
| 274 | t.Fatal("floor published without box process report") |
| 275 | } |
| 276 | o, _ = r.Directory.Read(ctx) |
| 277 | if o.Roster.Publication != "preparing" { |
| 278 | t.Fatal("missing resumable preparation") |
| 279 | } |
| 280 | if _, _, err = store.Get(ctx, hostfloor.Key); !errors.Is(err, os.ErrNotExist) { |
| 281 | t.Fatal("floor written before readiness") |
| 282 | } |
| 283 | report.Metrics.ConsoleProcesses = []status.ConsoleProcess{{ConsoleID: c.InstanceID, Enrollment: c.Enrollment, ProcessID: cr.ProcessID, BinarySHA256: hash}} |
| 284 | put("status/boxes/sophie.json", report) |
| 285 | p.ExpectedRevision = o.Roster.Revision |
| 286 | if result, err := r.Run(ctx, p, true); err != nil || len(result.Writes) != 3 { |
| 287 | t.Fatal("floor preview", result, err) |
| 288 | } |
| 289 | o, _ = r.Directory.Read(ctx) |
| 290 | if o.Roster.Publication != "preparing" { |
| 291 | t.Fatal("preview published") |
| 292 | } |
| 293 | // The policy a box arms with, signed offline before publication, |
| 294 | // carries exactly the floor and authorization publish-floor writes. |
| 295 | signed, err := r.Run(ctx, Plan{Phase: "sign-policy", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}, false) |
| 296 | if err != nil || len(signed.Writes) != 0 || signed.FloorSHA256 != digest { |
| 297 | t.Fatal("sign-policy", signed, err) |
| 298 | } |
| 299 | var armed hostfloor.Policy |
| 300 | if err = json.Unmarshal(signed.Output, &armed); err != nil { |
| 301 | t.Fatal(err) |
| 302 | } |
| 303 | if _, err = r.Run(ctx, p, false); err != nil { |
| 304 | t.Fatal(err) |
| 305 | } |
| 306 | for key, want := range map[string][]byte{hostfloor.Key: armed.Floor, hostfloor.AuthKey: armed.Authorization} { |
| 307 | got, _, err := store.Get(ctx, key) |
| 308 | if err != nil || !bytes.Equal(got, want) { |
| 309 | t.Fatalf("published %s differs from the armed policy: %s vs %s (%v)", key, got, want, err) |
| 310 | } |
| 311 | } |
| 312 | o, _ = r.Directory.Read(ctx) |
| 313 | if o.Roster.Publication != "published" { |
| 314 | t.Fatal("floor not published") |
| 315 | } |
| 316 | if _, err = hostfloor.Read(ctx, store, r.Directory.PublicKey, "bucket"); err != nil { |
| 317 | t.Fatal("floor readback", err) |
| 318 | } |
| 319 | } |
| 320 | |
| 321 | func TestActivationStoreRefusesUnlistedWrites(t *testing.T) { |
| 322 | r := runnerFixture(t) |
| 323 | writes := []Write{} |
| 324 | store := &checkedStore{Store: r.Directory.Store, pub: r.Directory.PublicKey, bucket: "bucket", overlay: map[string]overlayObject{}, writes: &writes} |
| 325 | raw, err := release.SignRootEnvelope(r.Signer, struct { |
| 326 | OK bool `json:"ok"` |
| 327 | }{true}) |
| 328 | if err != nil { |
| 329 | t.Fatal(err) |
| 330 | } |
| 331 | key := "apps/bab/app/instances/test.example.json" |
| 332 | if _, err = store.Put(t.Context(), key, raw, ""); err == nil { |
| 333 | t.Fatal("activation transport wrote an unlisted resource") |
| 334 | } |
| 335 | if _, _, err = store.Get(t.Context(), key); !errors.Is(err, os.ErrNotExist) { |
| 336 | t.Fatal("unlisted resource changed", err) |
| 337 | } |
| 338 | } |
| 339 | |