package claimactivation import ( "bytes" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "encoding/json" "net/http" "net/http/httptest" "sort" "strings" "sync" "testing" "github.com/carlosframework/platform/internal/hostactors" "github.com/carlosframework/platform/internal/hostfloor" "github.com/carlosframework/platform/internal/lease/leasetest" "github.com/carlosframework/platform/internal/release" ) const artifactsBucket = "carlos-trial-bucket" // s3Seen is one request the fake bucket answered. type s3Seen struct{ Method, Key, IfNoneMatch, IfMatch string } // artifactsWorld is the trusted signing host against a real S3Store: the // production client over leasetest's S3 server, which honours If-None-Match // and If-Match. The roster is initialized and configured through the // runner's own phases, and the console release is shipped and promoted // through release.Ship and Signer.Promote with pointer digests on, as // carlos ship and carlos promote leave it. type artifactsWorld struct { r Runner store release.Store model *leasetest.VersionedBucket sumsKey ed25519.PrivateKey mu sync.Mutex seen []s3Seen console []byte router []byte } func (w *artifactsWorld) requests() []s3Seen { w.mu.Lock() defer w.mu.Unlock() return append([]s3Seen(nil), w.seen...) } // mark is a point in the request log; writesSince lists every request // after it that could change the bucket. func (w *artifactsWorld) mark() int { return len(w.requests()) } func (w *artifactsWorld) writesSince(mark int) []s3Seen { var out []s3Seen for _, r := range w.requests()[mark:] { if r.Method != http.MethodGet && r.Method != http.MethodHead { out = append(out, r) } } return out } // fakeConsoleBuild stands in for release.ArtifactBuild over the fixture's // console bytes, which are not Go binaries: "console build stamp " // is a build stamped . ArtifactBuild itself is tested on real binaries // in internal/release, and the root-runner witness runs the production // reader over a real console image. func fakeConsoleBuild(b []byte) string { _, stamp, ok := strings.Cut(string(b), " stamp ") if !ok { return "" } return stamp } func sha(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } func newArtifactsWorld(t *testing.T) *artifactsWorld { t.Helper() w := &artifactsWorld{model: leasetest.NewVersionedBucket(), console: []byte("console build abc1234 stamp v0.28.2"), router: []byte("carlos v0.28.2 linux-arm64")} w.model.AddBucket(artifactsBucket) next, _ := leasetest.NewS3Server(w.model) server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { _, key, _ := strings.Cut(strings.TrimPrefix(req.URL.Path, "/"), "/") w.mu.Lock() w.seen = append(w.seen, s3Seen{req.Method, key, req.Header.Get("If-None-Match"), req.Header.Get("If-Match")}) w.mu.Unlock() next.ServeHTTP(rw, req) })) t.Cleanup(server.Close) for k, v := range map[string]string{ "AWS_ACCESS_KEY_ID": "test-key", "AWS_SECRET_ACCESS_KEY": "test-secret", "AWS_SESSION_TOKEN": "", "AWS_REGION": "eu-west-1", "AWS_EC2_METADATA_DISABLED": "true", "AWS_CONFIG_FILE": "/nonexistent", "AWS_SHARED_CREDENTIALS_FILE": "/nonexistent", "CARLOS_S3_ENDPOINT": server.URL, } { t.Setenv(k, v) } s3, err := release.NewS3StoreIn(t.Context(), artifactsBucket, "eu-west-1") if err != nil { t.Fatal(err) } w.store = s3 pub, key, _ := ed25519.GenerateKey(rand.Reader) sumsPub, sumsKey, _ := ed25519.GenerateKey(rand.Reader) w.sumsKey = sumsKey w.r = Runner{Directory: hostactors.Directory{Store: s3, PublicKey: pub, Bucket: artifactsBucket}, Signer: release.RootSigner(key), SumsKey: base64.StdEncoding.EncodeToString(sumsPub), ConsoleBuild: fakeConsoleBuild} ctx := t.Context() if _, err = w.r.Run(ctx, Plan{Phase: "initialize", Bucket: artifactsBucket}, false); err != nil { t.Fatal(err) } if _, err = w.r.Run(ctx, Plan{Phase: "configure", Bucket: artifactsBucket, ExpectedRevision: 1, Regions: map[string]hostactors.Region{"ie": {Account: "123456789012", AWSRegion: "eu-west-1", Namespaces: []string{"platform"}, Pools: []string{"console", "cohort1"}}}}, false); err != nil { t.Fatal(err) } if err = release.WriteDigestMarker(ctx, s3, w.r.Signer, "bab", "test"); err != nil { t.Fatal(err) } w.ship(t, "abc1234", w.console, "") if err = w.r.Signer.Promote(ctx, s3, "bab", "console", "canary/console", "abc1234"); err != nil { t.Fatal(err) } return w } func (w *artifactsWorld) ship(t *testing.T, version string, body []byte, program string) { t.Helper() m := release.Manifest{App: "console", Version: version, Kind: "binary", Program: program, Artifacts: map[string]release.Artifact{IdentityPlatform: {}}} if err := release.Ship(t.Context(), w.store, "bab", m, map[string][]byte{IdentityPlatform: body}); err != nil { t.Fatal(err) } } func (w *artifactsWorld) revision(t *testing.T) uint64 { t.Helper() o, err := w.r.Directory.Read(t.Context()) if err != nil { t.Fatal(err) } return o.Roster.Revision } // sums is a release's SHA256SUMS.txt and its .sig, signed by key, one line // per file in name order, as the release recipe writes it. func sums(key ed25519.PrivateKey, files map[string][]byte) (string, string) { names := make([]string, 0, len(files)) for name := range files { names = append(names, name) } sort.Strings(names) var b strings.Builder for _, name := range names { b.WriteString(sha(files[name]) + " " + name + "\n") } return b.String(), base64.StdEncoding.EncodeToString(ed25519.Sign(key, []byte(b.String()))) + "\n" } // releaseFiles is a release's eight signed files, real-shaped: six carlos // builds (Windows zipped) and the two debs, for version X.Y.Z. func releaseFiles(version string, router []byte) map[string][]byte { files := map[string][]byte{"carlos-linux-arm64": router} for _, name := range []string{"carlos-darwin-amd64", "carlos-darwin-arm64", "carlos-linux-amd64", "carlos-windows-amd64.zip", "carlos-windows-arm64.zip", "carlos_" + version + "_amd64.deb", "carlos_" + version + "_arm64.deb"} { files[name] = []byte(name + " " + version) } return files } func (w *artifactsWorld) identityPlan(t *testing.T, version string) Plan { text, sig := sums(w.sumsKey, releaseFiles("0.28.2", w.router)) return Plan{Phase: "publish-identity", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Identity: &IdentityInput{ ReleaseTag: "v0.28.2", MovesProtocol: 1, Sums: text, SumsSignature: sig, Console: ConsoleRelease{Account: "bab", App: "console", Channel: "canary/console", Version: version}}} } func TestSignPolicyWritesNothingAndSignsTheFloor(t *testing.T) { w := newArtifactsWorld(t) ctx := t.Context() floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} p := Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor} mark := w.mark() preview, err := w.r.Run(ctx, p, true) if err != nil { t.Fatal(err) } if !preview.DryRun || len(preview.Writes) != 0 || preview.Output != nil || preview.OutputSHA256 != "" || len(preview.FloorSHA256) != 64 { t.Fatalf("preview %+v", preview) } result, err := w.r.Run(ctx, p, false) if err != nil { t.Fatal(err) } if len(result.Writes) != 0 || result.FloorSHA256 != preview.FloorSHA256 || result.OutputSHA256 != hostfloor.Digest(result.Output) { t.Fatalf("result %+v", result) } if got := w.writesSince(mark); len(got) != 0 { t.Fatalf("sign-policy wrote to the bucket: %+v", got) } // The output is the file guard-arm reads: its parser, unmarshal then // Policy.Verify against the deployment key and bucket. var policy hostfloor.Policy if err = json.Unmarshal(result.Output, &policy); err != nil || policy.Mode != "armed" { t.Fatal("not an armed policy", err) } verified, err := policy.Verify(w.r.Directory.PublicKey, artifactsBucket) if err != nil || verified.Floor != floor || verified.Digest != result.FloorSHA256 { t.Fatal(verified, err) } // The JSON the command prints never carries the policy. printed, _ := json.Marshal(result) if bytes.Contains(printed, []byte("authorization")) { t.Fatalf("policy embedded in the printed result: %s", printed) } // Once the deployment is bound to a floor, only that floor is signed. if err = hostfloor.Publish(ctx, w.store, w.r.Signer, artifactsBucket, floor); err != nil { t.Fatal(err) } again, err := w.r.Run(ctx, p, false) if err != nil || !bytes.Equal(again.Output, result.Output) { t.Fatal("signing the published floor again", err) } other := hostfloor.Floor{Min: 1, MinRelease: "v0.28.3"} p.Floor = &other if _, err = w.r.Run(ctx, p, false); err == nil { t.Fatal("signed a floor other than the published one") } } func TestSignPolicyRefusesAHalfPublishedOrOtherFloor(t *testing.T) { floor := hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"} t.Run("authorization alone", func(t *testing.T) { w := newArtifactsWorld(t) _, auth, err := hostfloor.Authorize(w.r.Signer, artifactsBucket, floor) if err != nil { t.Fatal(err) } if _, err = w.store.Put(t.Context(), hostfloor.AuthKey, auth, ""); err != nil { t.Fatal(err) } if _, err = w.r.Run(t.Context(), Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { t.Fatal("signed beside half a floor pair") } }) t.Run("roster prepared for another floor", func(t *testing.T) { w := newArtifactsWorld(t) ctx := t.Context() o, _ := w.r.Directory.Read(ctx) next := hostactors.Clone(o.Roster) next.Publication = "preparing" next.FloorSHA256 = strings.Repeat("e", 64) if _, err := w.r.Directory.Replace(ctx, w.r.Signer, o, next); err != nil { t.Fatal(err) } if _, err := w.r.Run(ctx, Plan{Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &floor}, false); err == nil { t.Fatal("signed a floor the roster is not preparing") } }) t.Run("stale revision and bad floors", func(t *testing.T) { w := newArtifactsWorld(t) for _, p := range []Plan{ {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t) - 1, Floor: &floor}, {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t)}, {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 0, MinRelease: "v0.28.2"}}, {Phase: "sign-policy", Bucket: artifactsBucket, ExpectedRevision: w.revision(t), Floor: &hostfloor.Floor{Min: 1, MinRelease: "26065378"}}, } { if _, err := w.r.Run(t.Context(), p, false); err == nil { t.Fatal("accepted", p) } } }) } func TestPublishIdentityFromSignedInputsOnly(t *testing.T) { w := newArtifactsWorld(t) ctx := t.Context() p := w.identityPlan(t, "abc1234") before := w.mark() preview, err := w.r.Run(ctx, p, true) if err != nil { t.Fatal(err) } if got := w.writesSince(before); len(got) != 0 { t.Fatalf("dry run wrote: %+v", got) } if len(preview.Writes) != 1 || preview.Writes[0].Key != "control/system/router/releases/v0.28.2.json" || preview.Output != nil { t.Fatalf("preview %+v", preview) } mark := w.mark() result, err := w.r.Run(ctx, p, false) if err != nil { t.Fatal(err) } identityPuts := 0 for _, r := range w.requests()[mark:] { switch { case r.Method == http.MethodPut && r.Key == result.IdentityKey: identityPuts++ if r.IfNoneMatch != "*" || r.IfMatch != "" { t.Fatalf("identity PUT not create-only: %+v", r) } case r.Method != http.MethodGet && r.Method != http.MethodHead: t.Fatalf("publish-identity sent %s %s", r.Method, r.Key) } } if identityPuts != 1 || len(result.Writes) != 1 { t.Fatal("identity writes", identityPuts, result.Writes) } stored, _, err := w.store.Get(ctx, result.IdentityKey) if err != nil || !bytes.Equal(stored, result.Output) || result.OutputSHA256 != hostfloor.Digest(stored) { t.Fatal("output is not the stored identity", err) } // The file is what guard-arm reads with --identity. got, err := release.VerifySystemIdentity(w.r.Directory.PublicKey, result.Output) if err != nil { t.Fatal(err) } c := got.Current if c.Version != "v0.28.2" || c.ReleaseTag != "v0.28.2" || c.Source != "release:v0.28.2" || c.MovesProtocol != 1 || c.Artifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/router/v0.28.2/carlos-linux-arm64", SHA256: sha(w.router)}) || c.ConsoleArtifacts[IdentityPlatform] != (release.SystemArtifact{Key: "bootstrap/console/v0.28.2/carlos-linux-arm64", SHA256: sha(w.console)}) || len(c.Artifacts) != 1 || len(c.ConsoleArtifacts) != 1 { t.Fatalf("identity names %+v", c) } floor := hostfloor.Verified{Floor: hostfloor.Floor{Min: 1, MinRelease: "v0.28.2"}} if hostfloor.CheckImage(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.router)) != nil || hostfloor.CheckComponent(w.r.Directory.PublicKey, floor, result.Output, "v0.28.2", IdentityPlatform, sha(w.console), "console") != nil { t.Fatal("guard-arm's checks refuse the published identity") } // The same plan again is a no-op that hands back the stored bytes. p.ExpectedRevision = w.revision(t) // Signed within the same second the bytes are identical, and the // checked store reports the matching create as the resumed write; later // they differ and PublishSystemIdentity keeps the stored identity. // Either way the stored bytes are what comes back. replay, err := w.r.Run(ctx, p, false) if err != nil || len(replay.Writes) > 1 || !bytes.Equal(replay.Output, stored) { t.Fatal("replay", replay, err) } if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { t.Fatal("replay replaced the identity") } // Another console build under the same tag is refused, and nothing moves. other := []byte("console build def5678 stamp v0.28.2") w.ship(t, "def5678", other, ConsoleProgram) if err = w.r.Signer.Promote(ctx, w.store, "bab", "console", "canary/console", "def5678"); err != nil { t.Fatal(err) } p = w.identityPlan(t, "def5678") mark = w.mark() if _, err = w.r.Run(ctx, p, false); err == nil { t.Fatal("a different identity replaced the published one") } if now, _, _ := w.store.Get(ctx, result.IdentityKey); !bytes.Equal(now, stored) { t.Fatal("refused publish changed the identity") } for _, r := range w.writesSince(mark) { if r.Key != result.IdentityKey || r.IfNoneMatch != "*" { t.Fatalf("refused publish sent %+v", r) } } // The older build stays selectable by version through the pointer's // signed history digests. p = w.identityPlan(t, "abc1234") if replay, err = w.r.Run(ctx, p, false); err != nil || !bytes.Equal(replay.Output, stored) { t.Fatal("history version", err) } } func TestPublishIdentityRefusesUnsignedOrWrongInputs(t *testing.T) { for name, tc := range map[string]struct { setup func(t *testing.T, w *artifactsWorld) mutate func(w *artifactsWorld, p *Plan) }{ "sums signed by another key": {mutate: func(w *artifactsWorld, p *Plan) { _, other, _ := ed25519.GenerateKey(rand.Reader) p.Identity.Sums, p.Identity.SumsSignature = sums(other, releaseFiles("0.28.2", w.router)) }}, "sums changed after signing": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Sums = strings.Replace(p.Identity.Sums, sha(w.router), strings.Repeat("0", 64), 1) }}, "no linux-arm64 router": {mutate: func(w *artifactsWorld, p *Plan) { files := releaseFiles("0.28.2", w.router) delete(files, "carlos-linux-arm64") p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) }}, "console is a release file": {mutate: func(w *artifactsWorld, p *Plan) { files := releaseFiles("0.28.2", w.router) files["carlos-darwin-arm64"] = w.console p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) }}, // F3: a genuine, validly signed list for another release is not this // tag's, whatever its router hash. "v0.28.1's real-shaped sums under v0.28.2": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.1", w.router)) }}, "amd64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { files := releaseFiles("0.28.2", w.router) delete(files, "carlos_0.28.2_amd64.deb") p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) }}, "arm64 deb missing": {mutate: func(w *artifactsWorld, p *Plan) { files := releaseFiles("0.28.2", w.router) delete(files, "carlos_0.28.2_arm64.deb") p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) }}, "another release's deb beside": {mutate: func(w *artifactsWorld, p *Plan) { files := releaseFiles("0.28.2", w.router) files["carlos_0.28.1_arm64.deb"] = []byte("old deb") p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, files) }}, "debs for 0.28.20": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Sums, p.Identity.SumsSignature = sums(w.sumsKey, releaseFiles("0.28.20", w.router)) }}, "no sums key": {mutate: func(w *artifactsWorld, p *Plan) { w.r.SumsKey = "" }}, "moves protocol zero": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.MovesProtocol = 0 }}, "build as tag": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.ReleaseTag = "26065378" }}, "version not on the channel": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "fff0000" }}, "other channel": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Channel = "stable/console" }}, "other account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "bap" }}, "no account": {mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Account = "" }}, "pointer without digests": {setup: func(t *testing.T, w *artifactsWorld) { if err := release.RemoveDigestMarker(t.Context(), w.store, "bab"); err != nil { t.Fatal(err) } w.ship(t, "aaa1111", []byte("console build aaa1111 stamp v0.28.2"), "") if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "edge/console", "aaa1111"); err != nil { t.Fatal(err) } // The pointer verifies and names the version; it signs no digest. ptr, err := release.Channel(t.Context(), w.store, w.r.Directory.PublicKey, "bab", "console", "edge/console") if err != nil || ptr.Version != "aaa1111" || ptr.DigestOf("aaa1111") != "" { t.Fatal("fixture pointer", ptr, err) } }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Channel, p.Identity.Console.Version = "edge/console", "aaa1111" }}, "manifest swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { key := release.ManifestKey("bab", "console", "abc1234") raw, _, err := w.store.Get(t.Context(), key) if err != nil { t.Fatal(err) } var m release.Manifest _ = json.Unmarshal(raw, &m) a := m.Artifacts[IdentityPlatform] a.SHA256 = strings.Repeat("d", 64) m.Artifacts[IdentityPlatform] = a swapped, _ := json.Marshal(m) w.model.Put(artifactsBucket, key, swapped) }}, "console stamped with its build sha": {setup: func(t *testing.T, w *artifactsWorld) { w.ship(t, "ccc3333", []byte("console build ccc3333 stamp ccc3333"), ConsoleProgram) if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ccc3333"); err != nil { t.Fatal(err) } }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ccc3333" }}, "console with no stamp": {setup: func(t *testing.T, w *artifactsWorld) { w.ship(t, "ddd4444", []byte("console build ddd4444"), ConsoleProgram) if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "ddd4444"); err != nil { t.Fatal(err) } }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "ddd4444" }}, "artefact swapped after promotion": {setup: func(t *testing.T, w *artifactsWorld) { m, err := release.GetManifest(t.Context(), w.store, "bab", "console", "abc1234") if err != nil { t.Fatal(err) } w.model.Put(artifactsBucket, release.ArtifactKey("bab", "console", "abc1234", m.Artifacts[IdentityPlatform].File), []byte("console build evil stamp v0.28.2")) }}, "production stamp reader on fixture bytes": {mutate: func(w *artifactsWorld, p *Plan) { w.r.ConsoleBuild = nil }}, "the CLI shipped as the console": {setup: func(t *testing.T, w *artifactsWorld) { w.ship(t, "bbb2222", []byte("carlos cli stamp v0.28.2"), "github.com/carlosframework/platform") if err := w.r.Signer.Promote(t.Context(), w.store, "bab", "console", "canary/console", "bbb2222"); err != nil { t.Fatal(err) } }, mutate: func(w *artifactsWorld, p *Plan) { p.Identity.Console.Version = "bbb2222" }}, } { t.Run(name, func(t *testing.T) { w := newArtifactsWorld(t) if tc.setup != nil { tc.setup(t, w) } p := w.identityPlan(t, "abc1234") if tc.mutate != nil { tc.mutate(w, &p) } mark := w.mark() for _, dry := range []bool{true, false} { result, err := w.r.Run(t.Context(), p, dry) if err == nil { t.Fatalf("accepted (dry %v): %+v", dry, result) } t.Logf("dry %v refused: %v", dry, err) } if got := w.writesSince(mark); len(got) != 0 { t.Fatalf("refusal wrote: %+v", got) } }) } }