| 1 | package claimactivation |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "io" |
| 8 | "os" |
| 9 | "reflect" |
| 10 | "strings" |
| 11 | |
| 12 | "github.com/carlosframework/platform/internal/hostactors" |
| 13 | "github.com/carlosframework/platform/internal/hostfloor" |
| 14 | "github.com/carlosframework/platform/internal/publicrelease" |
| 15 | "github.com/carlosframework/platform/internal/release" |
| 16 | ) |
| 17 | |
| 18 | // signPolicy is the sign-policy phase: the armed policy guard-arm takes, |
| 19 | // signed by hostfloor.ArmedPolicy, the signer hostfloor.Publish uses, so the |
| 20 | // later publish-floor phase writes the same floor bytes and authorization. |
| 21 | // It writes nothing to the bucket. It refuses a floor other than the one the |
| 22 | // deployment is already bound to: the roster's preparing or published |
| 23 | // digest, or a published floor pair, or half of one. A box armed for another |
| 24 | // floor would refuse every guarded launch once the real floor is published. |
| 25 | func (r Runner) signPolicy(ctx context.Context, d hostactors.Directory, o hostactors.Observed, floor hostfloor.Floor, dry bool, result *Result) error { |
| 26 | policy, err := hostfloor.ArmedPolicy(r.Signer, d.Bucket, floor) |
| 27 | if err != nil { |
| 28 | return ErrActivation |
| 29 | } |
| 30 | verified, err := policy.Verify(d.PublicKey, d.Bucket) |
| 31 | if err != nil { |
| 32 | return ErrActivation |
| 33 | } |
| 34 | if o.Roster.FloorSHA256 != "" && o.Roster.FloorSHA256 != verified.Digest { |
| 35 | return ErrActivation |
| 36 | } |
| 37 | _, _, rawErr := d.Store.Get(ctx, hostfloor.Key) |
| 38 | _, _, authErr := d.Store.Get(ctx, hostfloor.AuthKey) |
| 39 | switch { |
| 40 | case errors.Is(rawErr, os.ErrNotExist) && errors.Is(authErr, os.ErrNotExist): |
| 41 | case rawErr != nil && !errors.Is(rawErr, os.ErrNotExist): |
| 42 | return rawErr |
| 43 | case authErr != nil && !errors.Is(authErr, os.ErrNotExist): |
| 44 | return authErr |
| 45 | default: |
| 46 | published, e := hostfloor.Read(ctx, d.Store, d.PublicKey, d.Bucket) |
| 47 | if e != nil || published.Digest != verified.Digest { |
| 48 | return ErrActivation |
| 49 | } |
| 50 | } |
| 51 | result.FloorSHA256 = verified.Digest |
| 52 | if dry { |
| 53 | return nil |
| 54 | } |
| 55 | out, err := json.Marshal(policy) |
| 56 | if err != nil { |
| 57 | return err |
| 58 | } |
| 59 | result.Output = out |
| 60 | result.OutputSHA256 = hostfloor.Digest(out) |
| 61 | return nil |
| 62 | } |
| 63 | |
| 64 | // publishIdentity is the publish-identity phase. It builds the release's |
| 65 | // identity from signed inputs only, signs it with the root key, publishes it |
| 66 | // create-only (the same identity again is a no-op, a different one under |
| 67 | // the tag is refused) and reads it back through the deployment key. |
| 68 | func (r Runner) publishIdentity(ctx context.Context, d hostactors.Directory, in IdentityInput, dry bool, result *Result) error { |
| 69 | rel, err := r.identityRelease(ctx, d, in) |
| 70 | if err != nil { |
| 71 | return err |
| 72 | } |
| 73 | wire, err := release.SignSystemIdentity(r.Signer, rel) |
| 74 | if err != nil { |
| 75 | return ErrActivation |
| 76 | } |
| 77 | want, err := release.VerifySystemIdentity(d.PublicKey, wire) |
| 78 | if err != nil { |
| 79 | return ErrActivation |
| 80 | } |
| 81 | if err = release.PublishSystemIdentity(ctx, d.Store, d.PublicKey, wire); err != nil { |
| 82 | return err |
| 83 | } |
| 84 | key, _ := release.IdentityKey(rel.ReleaseTag) |
| 85 | stored, _, err := d.Store.Get(ctx, key) |
| 86 | if err != nil { |
| 87 | return err |
| 88 | } |
| 89 | got, err := release.VerifySystemIdentity(d.PublicKey, stored) |
| 90 | if err != nil || got.Current.ReleaseTag != rel.ReleaseTag || !reflect.DeepEqual(got.Current, want.Current) { |
| 91 | return ErrActivation |
| 92 | } |
| 93 | result.IdentityKey = key |
| 94 | result.Release = &got.Current |
| 95 | if dry { |
| 96 | return nil |
| 97 | } |
| 98 | result.Output = stored |
| 99 | result.OutputSHA256 = hostfloor.Digest(stored) |
| 100 | return nil |
| 101 | } |
| 102 | |
| 103 | // identityRelease is the release an identity names, from the plan's signed |
| 104 | // inputs (controller ruling C2: no typed hashes). The router's sha256 is the |
| 105 | // one the vendor-signed SHA256SUMS.txt lists for carlos-linux-arm64. The |
| 106 | // console's comes from consoleArtifact. The console sha256 may not be any |
| 107 | // file in the release's checksum list (the 2026-10-06 outage shipped the CLI |
| 108 | // as the console). version and release_tag are both the tag: a release |
| 109 | // build is stamped with its tag, and guard-arm and the console's own report |
| 110 | // compare the running build's stamp with the identity's version. |
| 111 | func (r Runner) identityRelease(ctx context.Context, d hostactors.Directory, in IdentityInput) (release.SystemRelease, error) { |
| 112 | tag := in.ReleaseTag |
| 113 | if _, ok := release.ReleaseTag(tag); !ok || in.MovesProtocol < 1 || r.SumsKey == "" { |
| 114 | return release.SystemRelease{}, ErrActivation |
| 115 | } |
| 116 | sums, err := publicrelease.VerifySums(r.SumsKey, []byte(in.Sums), in.SumsSignature) |
| 117 | if err != nil { |
| 118 | return release.SystemRelease{}, ErrActivation |
| 119 | } |
| 120 | // The sums carry no tag of their own: a release's signed list names its |
| 121 | // debs carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb, so both |
| 122 | // must be listed for exactly this tag, and no deb for another version |
| 123 | // may be, or another release's genuine list would pass under this tag |
| 124 | // (controller ruling F3). |
| 125 | if !sumsNameRelease(sums, tag) { |
| 126 | return release.SystemRelease{}, ErrActivation |
| 127 | } |
| 128 | router, ok := sums[publicrelease.AssetName(IdentityPlatform)] |
| 129 | if !ok { |
| 130 | return release.SystemRelease{}, ErrActivation |
| 131 | } |
| 132 | console, err := r.consoleArtifact(ctx, d, in.Console, tag) |
| 133 | if err != nil { |
| 134 | return release.SystemRelease{}, err |
| 135 | } |
| 136 | for _, sha := range sums { |
| 137 | if sha == console { |
| 138 | return release.SystemRelease{}, ErrActivation |
| 139 | } |
| 140 | } |
| 141 | return release.SystemRelease{ |
| 142 | Version: tag, Source: "release:" + tag, ReleaseTag: tag, MovesProtocol: in.MovesProtocol, |
| 143 | Artifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: release.SystemArtifactKey(tag, IdentityPlatform), SHA256: router}}, |
| 144 | ConsoleArtifacts: map[string]release.SystemArtifact{IdentityPlatform: {Key: "bootstrap/console/" + tag + "/carlos-" + IdentityPlatform, SHA256: console}}, |
| 145 | }, nil |
| 146 | } |
| 147 | |
| 148 | // consoleArtifact is the console's sha256 for IdentityPlatform: the one the |
| 149 | // console build's manifest records, where the manifest must hash to the |
| 150 | // digest the console channel's root-verified pointer signs for that exact |
| 151 | // version. A version the pointer does not name, or names without a signed |
| 152 | // digest, is refused. The artefact itself is then read from the bucket, |
| 153 | // must hash to that sha256 and must be linked with version.Build equal to |
| 154 | // the release tag. The console's own floor report (Disk.Running) compares |
| 155 | // its build stamp with the identity's version, and the identity is |
| 156 | // create-only under its tag, so a console stamped otherwise is refused here |
| 157 | // rather than published. The stamp read is the build info's recorded flag, |
| 158 | // behind release.CheckELFBounds; the linked value is not read (stripped |
| 159 | // binaries have no symbols). Owed before production cohorts: trusted build |
| 160 | // attestation tied to the artefact digest (controller ruling F2). |
| 161 | // sumsNameRelease reports whether a verified checksum list is tag's: it |
| 162 | // lists carlos_<X.Y.Z>_amd64.deb and carlos_<X.Y.Z>_arm64.deb for the tag's |
| 163 | // X.Y.Z, and no other carlos_*.deb. |
| 164 | func sumsNameRelease(sums map[string]string, tag string) bool { |
| 165 | version := strings.TrimPrefix(tag, "v") |
| 166 | want := map[string]bool{"carlos_" + version + "_amd64.deb": true, "carlos_" + version + "_arm64.deb": true} |
| 167 | for name := range want { |
| 168 | if _, ok := sums[name]; !ok { |
| 169 | return false |
| 170 | } |
| 171 | } |
| 172 | for name := range sums { |
| 173 | if strings.HasPrefix(name, "carlos_") && strings.HasSuffix(name, ".deb") && !want[name] { |
| 174 | return false |
| 175 | } |
| 176 | } |
| 177 | return true |
| 178 | } |
| 179 | |
| 180 | func (r Runner) consoleArtifact(ctx context.Context, d hostactors.Directory, c ConsoleRelease, tag string) (string, error) { |
| 181 | if !release.ValidAccount(c.Account) || !release.ValidApp(c.App) || !release.ValidChannel(c.Channel) || !release.ValidVersion(c.Version) || c.Version == "dev" { |
| 182 | return "", ErrActivation |
| 183 | } |
| 184 | pointer, err := release.Channel(ctx, d.Store, d.PublicKey, c.Account, c.App, c.Channel) |
| 185 | if err != nil { |
| 186 | return "", err |
| 187 | } |
| 188 | digest := pointer.DigestOf(c.Version) |
| 189 | if digest == "" { |
| 190 | return "", ErrActivation |
| 191 | } |
| 192 | m, _, err := release.CheckManifestDigest(ctx, d.Store, c.Account, c.App, c.Version, digest) |
| 193 | if err != nil { |
| 194 | return "", err |
| 195 | } |
| 196 | if m.Kind != "binary" || m.App != c.App || m.Version != c.Version || m.Program != "" && m.Program != ConsoleProgram { |
| 197 | return "", ErrActivation |
| 198 | } |
| 199 | a, ok := m.Artifacts[IdentityPlatform] |
| 200 | if !ok { |
| 201 | return "", ErrActivation |
| 202 | } |
| 203 | body, err := readConsole(ctx, d.Store, release.ArtifactKey(c.Account, c.App, c.Version, a.File)) |
| 204 | if err != nil { |
| 205 | return "", err |
| 206 | } |
| 207 | stamp := release.ArtifactBuild |
| 208 | if r.ConsoleBuild != nil { |
| 209 | stamp = r.ConsoleBuild |
| 210 | } |
| 211 | if hostfloor.Digest(body) != a.SHA256 || stamp(body) != tag { |
| 212 | return "", ErrActivation |
| 213 | } |
| 214 | return a.SHA256, nil |
| 215 | } |
| 216 | |
| 217 | // consoleMaxBytes bounds a console artefact, as Disk.VerifyFile bounds what |
| 218 | // it hashes. |
| 219 | const consoleMaxBytes = 512 << 20 |
| 220 | |
| 221 | func readConsole(ctx context.Context, s release.Store, key string) ([]byte, error) { |
| 222 | rc, _, err := release.OpenObject(ctx, s, key) |
| 223 | if err != nil { |
| 224 | return nil, err |
| 225 | } |
| 226 | defer rc.Close() |
| 227 | body, err := io.ReadAll(io.LimitReader(rc, consoleMaxBytes+1)) |
| 228 | if err != nil { |
| 229 | return nil, err |
| 230 | } |
| 231 | if len(body) > consoleMaxBytes { |
| 232 | return nil, ErrActivation |
| 233 | } |
| 234 | return body, nil |
| 235 | } |
| 236 | |