package claimactivation import ( "context" "encoding/json" "errors" "io" "os" "reflect" "strings" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/publicrelease" "github.com/carlosframework/platform/internal/release" ) // signPolicy is the sign-policy phase: the armed policy guard-arm takes, // signed by hostfloor.ArmedPolicy, the signer hostfloor.Publish uses, so the // later publish-floor phase writes the same floor bytes and authorization. // It writes nothing to the bucket. It refuses a floor other than the one the // deployment is already bound to: the roster's preparing or published // digest, or a published floor pair, or half of one. A box armed for another // floor would refuse every guarded launch once the real floor is published. func (r Runner) signPolicy(ctx context.Context, d hostactors.Directory, o hostactors.Observed, floor hostfloor.Floor, dry bool, result *Result) error { policy, err := hostfloor.ArmedPolicy(r.Signer, d.Bucket, floor) if err != nil { return ErrActivation } verified, err := policy.Verify(d.PublicKey, d.Bucket) if err != nil { return ErrActivation } if o.Roster.FloorSHA256 != "" && o.Roster.FloorSHA256 != verified.Digest { return ErrActivation } _, _, rawErr := d.Store.Get(ctx, hostfloor.Key) _, _, authErr := d.Store.Get(ctx, hostfloor.AuthKey) switch { case errors.Is(rawErr, os.ErrNotExist) && errors.Is(authErr, os.ErrNotExist): case rawErr != nil && !errors.Is(rawErr, os.ErrNotExist): return rawErr case authErr != nil && !errors.Is(authErr, os.ErrNotExist): return authErr default: published, e := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) if e != nil || published.Digest != verified.Digest { return ErrActivation } } result.FloorSHA256 = verified.Digest if dry { return nil } out, err := json.Marshal(policy) if err != nil { return err } result.Output = out result.OutputSHA256 = hostfloor.Digest(out) return nil } // publishIdentity is the publish-identity phase. It builds the release's // identity from signed inputs only, signs it with the root key, publishes it // create-only (the same identity again is a no-op, a different one under // the tag is refused) and reads it back through the deployment key. func (r Runner) publishIdentity(ctx context.Context, d hostactors.Directory, in IdentityInput, dry bool, result *Result) error { rel, err := r.identityRelease(ctx, d, in) if err != nil { return err } wire, err := release.SignSystemIdentity(r.Signer, rel) if err != nil { return ErrActivation } want, err := release.VerifySystemIdentity(d.PublicKey, wire) if err != nil { return ErrActivation } if err = release.PublishSystemIdentity(ctx, d.Store, d.PublicKey, wire); err != nil { return err } key, _ := release.IdentityKey(rel.ReleaseTag) stored, _, err := d.Store.Get(ctx, key) if err != nil { return err } got, err := release.VerifySystemIdentity(d.PublicKey, stored) if err != nil || got.Current.ReleaseTag != rel.ReleaseTag || !reflect.DeepEqual(got.Current, want.Current) { return ErrActivation } result.IdentityKey = key result.Release = &got.Current if dry { return nil } result.Output = stored result.OutputSHA256 = hostfloor.Digest(stored) return nil } // identityRelease is the release an identity names, from the plan's signed // inputs (controller ruling C2: no typed hashes). The router's sha256 is the // one the vendor-signed SHA256SUMS.txt lists for carlos-linux-arm64. The // console's comes from consoleArtifact. The console sha256 may not be any // file in the release's checksum list (the 2026-10-06 outage shipped the CLI // as the console). version and release_tag are both the tag: a release // build is stamped with its tag, and guard-arm and the console's own report // compare the running build's stamp with the identity's version. func (r Runner) identityRelease(ctx context.Context, d hostactors.Directory, in IdentityInput) (release.SystemRelease, error) { tag := in.ReleaseTag if _, ok := release.ReleaseTag(tag); !ok || in.MovesProtocol < 1 || r.SumsKey == "" { return release.SystemRelease{}, ErrActivation } sums, err := publicrelease.VerifySums(r.SumsKey, []byte(in.Sums), in.SumsSignature) if err != nil { return release.SystemRelease{}, ErrActivation } // The sums carry no tag of their own: a release's signed list names its // debs carlos__amd64.deb and carlos__arm64.deb, so both // must be listed for exactly this tag, and no deb for another version // may be, or another release's genuine list would pass under this tag // (controller ruling F3). if !sumsNameRelease(sums, tag) { return release.SystemRelease{}, ErrActivation } router, ok := sums[publicrelease.AssetName(IdentityPlatform)] if !ok { return release.SystemRelease{}, ErrActivation } console, err := r.consoleArtifact(ctx, d, in.Console, tag) if err != nil { return release.SystemRelease{}, err } for _, sha := range sums { if sha == console { return release.SystemRelease{}, ErrActivation } } return release.SystemRelease{ Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: in.MovesProtocol, Artifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: release.SystemArtifactKey(tag, IdentityPlatform), SHA256: router}}, ConsoleArtifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: "bootstrap/console/" + tag + "/carlos-" + IdentityPlatform, SHA256: console}}, }, nil } // consoleArtifact is the console's sha256 for IdentityPlatform: the one the // console build's manifest records, where the manifest must hash to the // digest the console channel's root-verified pointer signs for that exact // version. A version the pointer does not name, or names without a signed // digest, is refused. The artefact itself is then read from the bucket, // must hash to that sha256 and must be linked with version.Build equal to // the release tag. The console's own floor report (Disk.Running) compares // its build stamp with the identity's version, and the identity is // create-only under its tag, so a console stamped otherwise is refused here // rather than published. The stamp read is the build info's recorded flag, // behind release.CheckELFBounds; the linked value is not read (stripped // binaries have no symbols). Owed before production cohorts: trusted build // attestation tied to the artefact digest (controller ruling F2). // sumsNameRelease reports whether a verified checksum list is tag's: it // lists carlos__amd64.deb and carlos__arm64.deb for the tag's // X.Y.Z, and no other carlos_*.deb. func sumsNameRelease(sums map[string]string, tag string) bool { version := strings.TrimPrefix(tag, "v") want := map[string]bool{"carlos_" + version + "_amd64.deb": true, "carlos_" + version + "_arm64.deb": true} for name := range want { if _, ok := sums[name]; !ok { return false } } for name := range sums { if strings.HasPrefix(name, "carlos_") && strings.HasSuffix(name, ".deb") && !want[name] { return false } } return true } func (r Runner) consoleArtifact(ctx context.Context, d hostactors.Directory, c ConsoleRelease, tag string) (string, error) { if !release.ValidAccount(c.Account) || !release.ValidApp(c.App) || !release.ValidChannel(c.Channel) || !release.ValidVersion(c.Version) || c.Version == "dev" { return "", ErrActivation } pointer, err := release.Channel(ctx, d.Store, d.PublicKey, c.Account, c.App, c.Channel) if err != nil { return "", err } digest := pointer.DigestOf(c.Version) if digest == "" { return "", ErrActivation } m, _, err := release.CheckManifestDigest(ctx, d.Store, c.Account, c.App, c.Version, digest) if err != nil { return "", err } if m.Kind != "binary" || m.App != c.App || m.Version != c.Version || m.Program != "" && m.Program != ConsoleProgram { return "", ErrActivation } a, ok := m.Artifacts[IdentityPlatform] if !ok { return "", ErrActivation } body, err := readConsole(ctx, d.Store, release.ArtifactKey(c.Account, c.App, c.Version, a.File)) if err != nil { return "", err } stamp := release.ArtifactBuild if r.ConsoleBuild != nil { stamp = r.ConsoleBuild } if hostfloor.Digest(body) != a.SHA256 || stamp(body) != tag { return "", ErrActivation } return a.SHA256, nil } // consoleMaxBytes bounds a console artefact, as Disk.VerifyFile bounds what // it hashes. const consoleMaxBytes = 512 << 20 func readConsole(ctx context.Context, s release.Store, key string) ([]byte, error) { rc, _, err := release.OpenObject(ctx, s, key) if err != nil { return nil, err } defer rc.Close() body, err := io.ReadAll(io.LimitReader(rc, consoleMaxBytes+1)) if err != nil { return nil, err } if len(body) > consoleMaxBytes { return nil, ErrActivation } return body, nil }