package claimactivation import ( "bytes" "context" "crypto/ed25519" "encoding/json" "errors" "os" "reflect" "regexp" "slices" "time" "github.com/carlosframework/platform/internal/boxid" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostbox" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/release" ) var ErrActivation = errors.New("claim activation: phase, revision, or actor inputs refused") type Plan struct { Phase string `json:"phase"` Bucket string `json:"bucket"` ExpectedRevision uint64 `json:"expected_revision"` Regions map[string]hostactors.Region `json:"regions,omitempty"` HomeRegion string `json:"home_region,omitempty"` Box *BoxInput `json:"box,omitempty"` Console *hostactors.Console `json:"console,omitempty"` Floor *hostfloor.Floor `json:"floor,omitempty"` Binding *hostactors.CensusKeyBinding `json:"binding,omitempty"` Proof []byte `json:"proof,omitempty"` Identity *IdentityInput `json:"identity,omitempty"` Backfill *BackfillInput `json:"backfill,omitempty"` } // IdentityInput is publish-identity's plan: a release tag, its moves // protocol, the release's signed checksum list (SHA256SUMS.txt and its .sig, // byte for byte) and the console release whose binary the identity names. // No sha256 is typed: the router's comes from the signed sums, the // console's from the console app's signed channel pointer and the manifest // digest it signs. type IdentityInput struct { ReleaseTag string `json:"release_tag"` MovesProtocol int `json:"moves_protocol"` Sums string `json:"sha256sums"` SumsSignature string `json:"sha256sums_sig"` Console ConsoleRelease `json:"console"` } // ConsoleRelease selects one shipped console build by its version (the // console build sha) on a channel whose signed pointer names it. type ConsoleRelease struct { Account string `json:"account"` App string `json:"app"` Channel string `json:"channel"` Version string `json:"version"` } type BoxInput struct { Reference string `json:"reference"` Namespace string `json:"namespace"` Label string `json:"label"` Region string `json:"region"` Pool string `json:"pool"` AWSRegion string `json:"aws_region"` InstanceID string `json:"instance_id"` Operator string `json:"operator"` } type Write struct { Key string `json:"key"` BeforeETag string `json:"before_etag"` SHA256 string `json:"sha256"` Payload json.RawMessage `json:"payload"` } type Result struct { DryRun bool `json:"dry_run"` Revision uint64 `json:"revision"` Writes []Write `json:"writes"` // sign-policy and publish-identity only. FloorSHA256 is the policy's // floor digest; IdentityKey and Release are the identity's key and the // release it names, as read back. Output is the exact file the phase // produces (the armed policy, or the published identity's stored bytes), // set only when the phase is applied. It is never part of the JSON: the // command writes it to --out unchanged, because re-indenting either would // change bytes guard-arm verifies. FloorSHA256 string `json:"floor_sha256,omitempty"` IdentityKey string `json:"identity_key,omitempty"` Release *release.SystemRelease `json:"release,omitempty"` OutputSHA256 string `json:"output_sha256,omitempty"` Output []byte `json:"-"` // The backfill phases only: the signed plan as read // back after the write (on a dry run, the plan that would be written). Backfill *BackfillSummary `json:"backfill,omitempty"` } type Runner struct { Directory hostactors.Directory Signer release.Signer // SumsKey verifies a release's SHA256SUMS.txt (base64 ed25519). The // command sets the vendor key the carlos binary embeds; an empty key // refuses publish-identity. SumsKey string // ConsoleBuild reads a console artefact's version.Build stamp; nil is // release.ArtifactBuild. A test seam only. ConsoleBuild func([]byte) string } // IdentityPlatform is the one platform publish-identity names: the dev // trial's boxes are linux-arm64. const IdentityPlatform = "linux-arm64" // ConsoleProgram is the Go main package a console release must report when // its manifest records one (console release defences D2). It refuses the // 2026-10-06 shape, the carlos CLI shipped as the console. const ConsoleProgram = "github.com/carlosframework/platform/cmd/console" func (r Runner) Run(ctx context.Context, p Plan, dry bool) (Result, error) { result := Result{DryRun: dry, Writes: []Write{}} if p.Bucket == "" || p.Bucket != r.Directory.Bucket || !bytes.Equal(r.Signer.RootPublicKey(), r.Directory.PublicKey) { return result, ErrActivation } d := r.Directory o, err := d.Read(ctx) if p.Phase == "initialize" { if p.ExpectedRevision != 0 { return result, ErrActivation } if err == nil { if o.Roster.Revision != 1 || o.Roster.HomeRegion != "" || o.Roster.RequestLimitNS != 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 || len(o.Roster.Regions) != 0 || len(o.Roster.Holds) != 0 || len(o.Roster.Activations) != 0 { return result, ErrActivation } result.Revision = 1 return result, nil } if !errors.Is(err, os.ErrNotExist) { return result, err } } else if err != nil { return result, err } else if p.ExpectedRevision == 0 || o.Roster.Revision != p.ExpectedRevision { return result, ErrActivation } allowed := map[string]bool{} switch p.Phase { case "initialize", "configure", "home", "enroll-box", "enroll-console", "bind-key": allowed[hostactors.Key] = true case "invite-box": if p.Box != nil { allowed["control/host-actor-invitations/"+p.Box.Reference+".json"] = true } case "invite-console": if p.Console != nil { allowed["control/host-console-invitations/"+p.Console.InstanceID+".json"] = true } case "publish-floor": allowed[hostactors.Key] = true allowed[hostfloor.Key] = true allowed[hostfloor.AuthKey] = true case "publish-identity": if p.Identity != nil { if key, e := release.IdentityKey(p.Identity.ReleaseTag); e == nil { allowed[key] = true } } case "sign-policy": // Writes nothing: the policy goes to the operator's file. case "backfill-collect", "backfill-adopt", "backfill-done": allowed[hostops.BackfillKey] = true } store := &checkedStore{allowed: allowed, Store: d.Store, pub: d.PublicKey, bucket: d.Bucket, expected: o.ETag, dry: dry, overlay: map[string]overlayObject{}, writes: &result.Writes} d.Store = store if p.Phase != "initialize" && p.Phase != "configure" && o.Roster.RequestLimitNS != int64(hostbox.RequestLimit) { return result, ErrActivation } switch p.Phase { case "initialize": err = d.Initialize(ctx, r.Signer) case "configure": if len(p.Regions) == 0 || o.Roster.Publication != "" || len(o.Roster.Boxes) != 0 || len(o.Roster.Consoles) != 0 { return result, ErrActivation } for name, region := range p.Regions { if name != "ie" || region.AWSRegion != "eu-west-1" || !regexp.MustCompile(`^[0-9]{12}$`).MatchString(region.Account) || len(region.Namespaces) == 0 || len(region.Pools) == 0 { return result, ErrActivation } } next := hostactors.Clone(o.Roster) next.Regions = p.Regions next.RequestLimitNS = int64(hostbox.RequestLimit) if !reflect.DeepEqual(next, o.Roster) { _, err = d.Replace(ctx, r.Signer, o, next) } case "home": if p.HomeRegion != "ie" || o.Roster.Regions[p.HomeRegion].AWSRegion != "eu-west-1" || o.Roster.HomeRegion != "" && o.Roster.HomeRegion != p.HomeRegion { return result, ErrActivation } if o.Roster.HomeRegion != p.HomeRegion { next := hostactors.Clone(o.Roster) next.HomeRegion = p.HomeRegion _, err = d.Replace(ctx, r.Signer, o, next) } case "invite-box", "enroll-box": if p.Box == nil { return result, ErrActivation } in := hostactors.EnrollmentRequest{Reference: p.Box.Reference, Namespace: p.Box.Namespace, Label: p.Box.Label, Region: p.Box.Region, Pool: p.Box.Pool, AWSRegion: p.Box.AWSRegion, TofuInstanceID: p.Box.InstanceID, Operator: p.Box.Operator, ExistingReference: true} if !boxid.ValidBox(in.Reference) || in.TofuInstanceID == "" || in.Operator == "" || in.Label == "" || in.Region != "ie" || in.Namespace == "" || in.Pool == "" { return result, ErrActivation } region, ok := o.Roster.Regions[in.Region] if !ok || region.AWSRegion != "eu-west-1" || in.AWSRegion != "" && in.AWSRegion != region.AWSRegion { return result, ErrActivation } if !slices.Contains(region.Namespaces, in.Namespace) || !slices.Contains(region.Pools, in.Pool) { return result, ErrActivation } if old, exists := o.Roster.Boxes[in.Reference]; exists && (old.Label != in.Label || old.State != "active" || old.Namespace != in.Namespace || old.Region != in.Region || old.Pool != in.Pool || old.Cloud.InstanceID != in.TofuInstanceID) { return result, ErrActivation } raw, e := boxid.Load(ctx, d.Store, in.Reference) if e != nil { return result, e } certs, e := boxid.EmbeddedCerts() if e != nil { return result, e } expect := boxid.Expect{Box: in.Reference, AWSRegion: region.AWSRegion, TofuInstanceID: in.TofuInstanceID} for ref, b := range o.Roster.Boxes { expect.Roster = append(expect.Roster, boxid.Enrolled{Box: ref, Cloud: b.Cloud}) } if _, e = (boxid.Verifier{Account: region.Account, Certs: certs}).Enroll(raw, expect); e != nil { return result, e } if p.Phase == "invite-box" { _, err = d.Invite(ctx, r.Signer, in.Reference, raw) } else { _, err = d.EnrollAWS(ctx, r.Signer, o, in, raw) } case "invite-console", "enroll-console": if p.Console == nil || p.Console.Compute == nil { return result, ErrActivation } c := *p.Console b, ok := o.Roster.Boxes[c.Compute.Box] if !ok || b.State != "active" || b.Enrollment != c.Compute.Enrollment || b.Region != "ie" || c.DeploymentBucket != d.Bucket { return result, ErrActivation } if old, ok := o.Roster.Consoles[c.InstanceID]; ok { old.EnrolledAt = c.EnrolledAt if !reflect.DeepEqual(old, c) { return result, ErrActivation } break } if p.Phase == "invite-console" { err = d.InviteConsole(ctx, r.Signer, c) } else { invited, e := d.InvitedConsole(ctx, o, c.InstanceID) if e != nil || !reflect.DeepEqual(invited, c) { return result, ErrActivation } next := hostactors.Clone(o.Roster) next.Consoles[c.InstanceID] = c _, err = d.Replace(ctx, r.Signer, o, next) } case "publish-floor": if p.Floor == nil { return result, ErrActivation } e := hostactors.ExactEvidence{Store: d.Store, PublicKey: d.PublicKey, Boxes: d.PrivateBoxReport} err = d.PublishFloor(ctx, r.Signer, *p.Floor, e, time.Now()) if err == nil { _, err = hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) } case "bind-key": if p.Binding == nil || p.Binding.Bucket != p.Bucket { return result, ErrActivation } _, err = d.BindCensusKey(ctx, r.Signer, o, *p.Binding, p.Proof) case "sign-policy": if p.Floor == nil { return result, ErrActivation } err = r.signPolicy(ctx, d, o, *p.Floor, dry, &result) case "publish-identity": if p.Identity == nil { return result, ErrActivation } err = r.publishIdentity(ctx, d, *p.Identity, dry, &result) case "backfill-collect": if p.Backfill == nil { return result, ErrActivation } err = r.backfillCollect(ctx, d, o, *p.Backfill, &result) case "backfill-adopt": if p.Backfill == nil { return result, ErrActivation } err = r.backfillAdopt(ctx, d, o, *p.Backfill, &result) case "backfill-done": if p.Backfill == nil { return result, ErrActivation } err = r.backfillDone(ctx, d, o, *p.Backfill, &result) default: return result, ErrActivation } if err != nil { return result, err } after, err := d.Read(ctx) if err != nil { return result, err } if after.ETag != store.expected { return result, ErrActivation } result.Revision = after.Roster.Revision if p.Phase != "initialize" && after.Roster.RequestLimitNS != int64(hostbox.RequestLimit) { return result, ErrActivation } return result, nil } type overlayObject struct { raw []byte etag string } type checkedStore struct { allowed map[string]bool release.Store pub ed25519.PublicKey bucket, expected string dry bool overlay map[string]overlayObject writes *[]Write } func (s *checkedStore) Get(ctx context.Context, key string) ([]byte, string, error) { if v, ok := s.overlay[key]; ok { return append([]byte(nil), v.raw...), v.etag, nil } return s.Store.Get(ctx, key) } func (s *checkedStore) Delete(context.Context, string) error { return ErrActivation } func (s *checkedStore) Put(ctx context.Context, key string, raw []byte, before string) (string, error) { if !s.allowed[key] { return "", ErrActivation } // Recheck the roster before every write, including non-roster objects. _, current, e := s.Get(ctx, hostactors.Key) if e != nil && !errors.Is(e, os.ErrNotExist) { return "", e } if current != s.expected || key == hostactors.Key && before != s.expected { return "", ErrActivation } var payload json.RawMessage if key == hostfloor.Key { payload = append([]byte(nil), raw...) } else if release.VerifyRootEnvelope(s.pub, raw, &payload) != nil { return "", ErrActivation } var tag string if s.dry { _, old, e := s.Get(ctx, key) if e != nil && !errors.Is(e, os.ErrNotExist) { return "", e } if old != before { return "", release.ErrCASConflict } tag = hostfloor.Digest(raw) s.overlay[key] = overlayObject{append([]byte(nil), raw...), tag} } else { tag, e = s.Store.Put(ctx, key, raw, before) if e != nil { got, readTag, readErr := s.Get(ctx, key) if readErr != nil || readTag == "" || !bytes.Equal(got, raw) { return "", errors.Join(e, readErr) } tag = readTag } } got, readTag, e := s.Get(ctx, key) if e != nil || tag == "" || tag != readTag || !bytes.Equal(got, raw) { return "", ErrActivation } if key == hostfloor.Key { if _, e = hostfloor.Read(ctx, s, s.pub, s.bucket); e != nil { return "", e } } else { var verified json.RawMessage if release.VerifyRootEnvelope(s.pub, got, &verified) != nil { return "", ErrActivation } } if key == hostactors.Key { s.expected = tag } *s.writes = append(*s.writes, Write{Key: key, BeforeETag: before, SHA256: hostfloor.Digest(raw), Payload: payload}) return tag, nil }