carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package claimactivation
2
3import (
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.
25func (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.
68func (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.
111func (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.
164func 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
180func (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.
219const consoleMaxBytes = 512 << 20
220
221func 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