package claimactivation import ( "bytes" "context" "crypto/ed25519" "crypto/rand" "encoding/json" "errors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/status" "os" "strings" "testing" "time" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/release" ) func runnerFixture(t *testing.T) Runner { t.Helper() pub, key, _ := ed25519.GenerateKey(rand.Reader) return Runner{Directory: hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}, Signer: release.RootSigner(key)} } func TestActivationInitializeConfigureAndHome(t *testing.T) { r := runnerFixture(t) ctx := t.Context() p := Plan{Phase: "initialize", Bucket: "bucket"} result, err := r.Run(ctx, p, true) if err != nil || len(result.Writes) != 1 { t.Fatal(result, err) } if _, err = r.Directory.Read(ctx); !errors.Is(err, os.ErrNotExist) { t.Fatal("dry run wrote roster", err) } if _, err = r.Run(ctx, p, false); err != nil { t.Fatal(err) } 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"}}}} if _, err = r.Run(ctx, p, false); err != nil { t.Fatal(err) } got, _ := r.Directory.Read(ctx) if got.Roster.RequestLimitNS != int64(hostbox.RequestLimit) { t.Fatal("wrong request limit") } if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("stale revision accepted") } p = Plan{Phase: "home", Bucket: "bucket", ExpectedRevision: 2, HomeRegion: "ie"} if _, err = r.Run(ctx, p, false); err != nil { t.Fatal(err) } got, _ = r.Directory.Read(ctx) if got.Roster.HomeRegion != "ie" { t.Fatal("home absent") } p.ExpectedRevision = 3 if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 { t.Fatal("home replay", result, err) } p.HomeRegion = "uk" if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("home changed") } } type corruptReadback struct { release.Store corrupt bool } func (s *corruptReadback) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { tag, err := s.Store.Put(ctx, key, raw, etag) s.corrupt = true return tag, err } func (s *corruptReadback) Get(ctx context.Context, key string) ([]byte, string, error) { raw, tag, err := s.Store.Get(ctx, key) if s.corrupt && key == hostactors.Key { raw = []byte(`{"untrusted":true}`) } return raw, tag, err } func TestActivationRequiresAuthenticatedReadback(t *testing.T) { r := runnerFixture(t) r.Directory.Store = &corruptReadback{Store: r.Directory.Store} if _, err := r.Run(t.Context(), Plan{Phase: "initialize", Bucket: "bucket"}, false); err == nil { t.Fatal("unverified readback accepted") } } func TestActivationRefusesUnscopedInputs(t *testing.T) { r := runnerFixture(t) for _, p := range []Plan{{Phase: "initialize", Bucket: "other"}, {Phase: "unknown", Bucket: "bucket"}, {Phase: "enroll-box", Bucket: "bucket"}, {Phase: "bind-key", Bucket: "bucket"}} { if _, err := r.Run(t.Context(), p, false); err == nil { t.Fatal("accepted", p) } } } type lostWriteReply struct { release.Store once bool } func (s *lostWriteReply) Put(ctx context.Context, key string, raw []byte, etag string) (string, error) { tag, err := s.Store.Put(ctx, key, raw, etag) if err == nil && !s.once { s.once = true return "", errors.New("lost reply") } return tag, err } func TestActivationResumesAfterLostWriteReply(t *testing.T) { r := runnerFixture(t) s := &lostWriteReply{Store: r.Directory.Store} r.Directory.Store = s p := Plan{Phase: "initialize", Bucket: "bucket"} if result, err := r.Run(t.Context(), p, false); err != nil || len(result.Writes) != 1 { t.Fatal("authenticated lost-reply recovery failed", result, err) } if result, err := r.Run(t.Context(), p, false); err != nil || result.Revision != 1 { t.Fatal(result, err) } } func TestActivationBindRequiresPublishedFloorAndPossession(t *testing.T) { r := runnerFixture(t) ctx := t.Context() if err := r.Directory.Initialize(ctx, r.Signer); err != nil { t.Fatal(err) } o, _ := r.Directory.Read(ctx) next := hostactors.Clone(o.Roster) next.RequestLimitNS = int64(hostbox.RequestLimit) 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{}} o, err := r.Directory.Replace(ctx, r.Signer, o, next) if err != nil { t.Fatal(err) } pub, key, _ := ed25519.GenerateKey(rand.Reader) binding := hostactors.CensusKeyBinding{Bucket: "bucket", Box: "leo", Enrollment: next.Boxes["leo"].Enrollment, PublicKey: pub} raw, _ := json.Marshal(binding) p := Plan{Phase: "bind-key", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Binding: &binding, Proof: ed25519.Sign(key, raw)} if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("bound before floor") } floor := hostfloor.Floor{Min: 1, MinRelease: "v1.0.0"} if err = hostfloor.Publish(ctx, r.Directory.Store, r.Signer, "bucket", floor); err != nil { t.Fatal(err) } f, err := hostfloor.Read(ctx, r.Directory.Store, r.Directory.PublicKey, "bucket") if err != nil { t.Fatal(err) } next = hostactors.Clone(o.Roster) next.Publication = "published" next.FloorSHA256 = f.Digest o, err = r.Directory.Replace(ctx, r.Signer, o, next) if err != nil { t.Fatal(err) } p.ExpectedRevision = o.Roster.Revision valid := append([]byte(nil), p.Proof...) p.Proof[0] ^= 1 if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("bad possession proof accepted") } p.Proof = valid result, err := r.Run(ctx, p, true) if err != nil || len(result.Writes) != 1 { t.Fatal(result, err) } current, _ := r.Directory.Read(ctx) if len(current.Roster.Boxes["leo"].CensusPublicKey) != 0 { t.Fatal("dry run bound key") } result, err = r.Run(ctx, p, false) if err != nil { t.Fatal(err) } p.ExpectedRevision = result.Revision result, err = r.Run(ctx, p, false) if err != nil || len(result.Writes) != 0 { t.Fatal("binding not resumable", result, err) } } func TestActivationConsoleInvitationEnrollment(t *testing.T) { r := runnerFixture(t) ctx := t.Context() r.Directory.Initialize(ctx, r.Signer) o, _ := r.Directory.Read(ctx) next := hostactors.Clone(o.Roster) next.RequestLimitNS = int64(hostbox.RequestLimit) 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{}} o, err := r.Directory.Replace(ctx, r.Signer, o, next) if err != nil { t.Fatal(err) } 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}} p := Plan{Phase: "enroll-console", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Console: &c} if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("enrollment without invitation") } p.Phase = "invite-console" if _, err = r.Run(ctx, p, false); err != nil { t.Fatal(err) } p.Phase = "enroll-console" result, err := r.Run(ctx, p, false) if err != nil { t.Fatal(err) } p.ExpectedRevision = result.Revision if result, err = r.Run(ctx, p, false); err != nil || len(result.Writes) != 0 { t.Fatal("replay", result, err) } c.Compute.Box = "leo" if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("wrong compute accepted") } } func TestActivationFloorWaitsForGuardAndComputeReport(t *testing.T) { r := runnerFixture(t) ctx := t.Context() store := r.Directory.Store if err := r.Directory.Initialize(ctx, r.Signer); err != nil { t.Fatal(err) } o, _ := r.Directory.Read(ctx) next := hostactors.Clone(o.Roster) next.RequestLimitNS = int64(hostbox.RequestLimit) en := strings.Repeat("a", 32) hash := strings.Repeat("f", 64) tag := "v1.0.0" 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{}} c := hostactors.Console{InstanceID: "dev-console", Enrollment: strings.Repeat("c", 32), Namespaces: []string{"platform"}, DeploymentBucket: "bucket", Compute: &hostactors.ComputeBinding{Box: "sophie", Enrollment: en}} next.Consoles[c.InstanceID] = c o, err := r.Directory.Replace(ctx, r.Signer, o, next) if err != nil { t.Fatal(err) } floor := hostfloor.Floor{Min: 1, MinRelease: tag} body, _ := json.Marshal(floor) digest := hostfloor.Digest(body) 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}}} if _, err = release.PromoteSystem(ctx, store, r.Signer, "router", "stable", rel, release.SystemPromoteOptions{}); err != nil { t.Fatal(err) } wire, _, err := store.Get(ctx, release.SystemPointerKey("router", "stable")) if err != nil { t.Fatal(err) } if err = release.PublishSystemIdentity(ctx, store, r.Directory.PublicKey, wire); err != nil { t.Fatal(err) } now := time.Now().UTC() 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}} put := func(key string, value any) { t.Helper() raw, _ := json.Marshal(value) _, etag, _ := store.Get(ctx, key) if _, err := store.Put(ctx, key, raw, etag); err != nil { t.Fatal(err) } } put("status/boxes/sophie.json", report) 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} put("status/consoles/dev-console.json", cr) p := Plan{Phase: "publish-floor", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor} if _, err = r.Run(ctx, p, false); err == nil { t.Fatal("floor published without box process report") } o, _ = r.Directory.Read(ctx) if o.Roster.Publication != "preparing" { t.Fatal("missing resumable preparation") } if _, _, err = store.Get(ctx, hostfloor.Key); !errors.Is(err, os.ErrNotExist) { t.Fatal("floor written before readiness") } report.Metrics.ConsoleProcesses = []status.ConsoleProcess{{ConsoleID: c.InstanceID, Enrollment: c.Enrollment, ProcessID: cr.ProcessID, BinarySHA256: hash}} put("status/boxes/sophie.json", report) p.ExpectedRevision = o.Roster.Revision if result, err := r.Run(ctx, p, true); err != nil || len(result.Writes) != 3 { t.Fatal("floor preview", result, err) } o, _ = r.Directory.Read(ctx) if o.Roster.Publication != "preparing" { t.Fatal("preview published") } // The policy a box arms with, signed offline before publication, // carries exactly the floor and authorization publish-floor writes. signed, err := r.Run(ctx, Plan{Phase: "sign-policy", Bucket: "bucket", ExpectedRevision: o.Roster.Revision, Floor: &floor}, false) if err != nil || len(signed.Writes) != 0 || signed.FloorSHA256 != digest { t.Fatal("sign-policy", signed, err) } var armed hostfloor.Policy if err = json.Unmarshal(signed.Output, &armed); err != nil { t.Fatal(err) } if _, err = r.Run(ctx, p, false); err != nil { t.Fatal(err) } for key, want := range map[string][]byte{hostfloor.Key: armed.Floor, hostfloor.AuthKey: armed.Authorization} { got, _, err := store.Get(ctx, key) if err != nil || !bytes.Equal(got, want) { t.Fatalf("published %s differs from the armed policy: %s vs %s (%v)", key, got, want, err) } } o, _ = r.Directory.Read(ctx) if o.Roster.Publication != "published" { t.Fatal("floor not published") } if _, err = hostfloor.Read(ctx, store, r.Directory.PublicKey, "bucket"); err != nil { t.Fatal("floor readback", err) } } func TestActivationStoreRefusesUnlistedWrites(t *testing.T) { r := runnerFixture(t) writes := []Write{} store := &checkedStore{Store: r.Directory.Store, pub: r.Directory.PublicKey, bucket: "bucket", overlay: map[string]overlayObject{}, writes: &writes} raw, err := release.SignRootEnvelope(r.Signer, struct { OK bool `json:"ok"` }{true}) if err != nil { t.Fatal(err) } key := "apps/bab/app/instances/test.example.json" if _, err = store.Put(t.Context(), key, raw, ""); err == nil { t.Fatal("activation transport wrote an unlisted resource") } if _, _, err = store.Get(t.Context(), key); !errors.Is(err, os.ErrNotExist) { t.Fatal("unlisted resource changed", err) } }