package release import ( "context" "strings" "testing" "time" ) // trapStore fails the test on ANY access. The spec requires an out-of-scope // promotion to fail locally before any store WRITE; the code manages it // before any store access at all, and that is what is pinned here — a refusal // that costs a bucket round trip is a refusal that behaves differently when // the bucket is unreachable. type trapStore struct{ t *testing.T } func (s trapStore) Get(_ context.Context, key string) ([]byte, string, error) { s.t.Fatalf("out-of-scope promote read %s — it must refuse before touching the store", key) return nil, "", nil } func (s trapStore) Put(_ context.Context, key string, _ []byte, _ string) (string, error) { s.t.Fatalf("out-of-scope promote WROTE %s — the scope check is not a gate", key) return "", nil } func (s trapStore) List(_ context.Context, prefix string) ([]string, error) { s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix) return nil, nil } func (s trapStore) ListDirs(_ context.Context, prefix string) ([]string, error) { s.t.Fatalf("out-of-scope promote listed %s — it must refuse before touching the store", prefix) return nil, nil } func (s trapStore) Delete(_ context.Context, key string) error { s.t.Fatalf("out-of-scope promote DELETED %s — the scope check is not a gate", key) return nil } // An out-of-scope channel is refused locally, by every write verb, before the // store is touched — and the refusal names the scope, so the operator can see // which key they are holding. // // Mutation-mindedness: move the allows() call below the GetManifest read and // trapStore fails the test on the read; delete it and Put is reached and the // test fails on the write. Either way this is red. func TestScopedPromoteRefusesOutOfScopeBeforeTouchingTheStore(t *testing.T) { _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) ctx := context.Background() s := trapStore{t} for _, ch := range []string{"stable", "beta"} { _, err := sg.PromoteOnLadder(ctx, s, "bab", "kass", ch, "9f3c1ad", PromoteOptions{}) if err == nil { t.Fatalf("PromoteOnLadder to %s with a canary/edge key succeeded", ch) } assertNamesTheScope(t, err, ch) if err := sg.Promote(ctx, s, "bab", "kass", ch, "9f3c1ad"); err == nil { t.Fatalf("Promote to %s with a canary/edge key succeeded", ch) } else { assertNamesTheScope(t, err, ch) } if err := sg.Rollback(ctx, s, "bab", "kass", ch); err == nil { t.Fatalf("Rollback of %s with a canary/edge key succeeded", ch) } else { assertNamesTheScope(t, err, ch) } } } // assertNamesTheScope checks a refusal is legible: it says which grant, what // the grant covers, and which channel was asked for. func assertNamesTheScope(t *testing.T, err error, channel string) { t.Helper() got := err.Error() for _, want := range []string{"edge-builder", "canary, edge", channel} { if !strings.Contains(got, want) { t.Errorf("refusal %q does not name %q", got, want) } } } // -hotfix bypasses the LADDER. It can never bypass a grant: the two gates // answer different questions, and a flag an operator can type is not a way to // widen a delegation. // // Mutation-mindedness: put the allows() check behind `if !opt.Hotfix` and // this test is the only thing that notices — which would make the whole // feature bypassable by a documented flag. func TestHotfixDoesNotWidenAGrant(t *testing.T) { _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) _, err := sg.PromoteOnLadder(context.Background(), trapStore{t}, "bab", "kass", "stable", "9f3c1ad", PromoteOptions{Hotfix: true}) if err == nil { t.Fatal("--hotfix let a canary/edge key promote to stable") } assertNamesTheScope(t, err, "stable") } // A scoped key promotes inside its scope through the real store, and the // pointer it wrote verifies for a fleet configured with the ROOT public key // and nothing else. That is the whole scheme in one assertion: one anchor, // two kinds of signer. func TestScopedPromoteVerifiesAgainstTheRootPubkey(t *testing.T) { root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) ctx := context.Background() s := NewDirStore(t.TempDir()) shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("build")) if err := sg.Promote(ctx, s, "bab", "kass", "edge", "9f3c1ad"); err != nil { t.Fatalf("scoped promote to edge: %v", err) } p, err := Channel(ctx, s, rootPub, "bab", "kass", "edge") if err != nil { t.Fatalf("Channel with the root pubkey: %v", err) } if p.Version != "9f3c1ad" { t.Fatalf("pointer = %+v", p) } // The root key still reaches stable, from beta, as it always did. shipRelease(t, s, "bab", "kass", "aa11bb2", "linux-arm64", []byte("build two")) if err := Promote(ctx, s, root, "bab", "kass", "beta", "aa11bb2"); err != nil { t.Fatalf("root promote to beta: %v", err) } if err := Promote(ctx, s, root, "bab", "kass", "stable", "aa11bb2"); err != nil { t.Fatalf("root promote to stable: %v", err) } } // The pointer a scoped key replaces was signed by something else — the root // key, in every real deployment, because that is what promoted the channel // before the builder existed. Verification of the OUTGOING pointer therefore // has to be anchored at the root, not at the signing key's own public half, // and the proof is that history comes forward. // // Mutation-mindedness: change Signer.verifyPub to return the signing key's // public half and this fails at the second promote with "existing pointer … // signature verification failed", which is exactly what a scoped key would // hit on its first run against a real channel. func TestScopedKeyReplacesARootSignedPointerAndCarriesHistory(t *testing.T) { root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) ctx := context.Background() s := NewDirStore(t.TempDir()) shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("one")) shipRelease(t, s, "bab", "kass", "aa11bb2", "linux-arm64", []byte("two")) if err := Promote(ctx, s, root, "bab", "kass", "edge", "9f3c1ad"); err != nil { t.Fatalf("root promote: %v", err) } if err := sg.Promote(ctx, s, "bab", "kass", "edge", "aa11bb2"); err != nil { t.Fatalf("scoped promote over a root-signed pointer: %v", err) } p, err := Channel(ctx, s, rootPub, "bab", "kass", "edge") if err != nil { t.Fatalf("Channel: %v", err) } if p.Version != "aa11bb2" || len(p.History) != 1 || p.History[0] != "9f3c1ad" { t.Fatalf("pointer = %+v, want aa11bb2 with 9f3c1ad in history", p) } // And back the other way: the root key replaces a scope-signed pointer, // carrying its history. Neither direction may be a dead end, or a // deployment could not stop using a scoped key once it started. shipRelease(t, s, "bab", "kass", "cc33dd4", "linux-arm64", []byte("three")) if err := Promote(ctx, s, root, "bab", "kass", "edge", "cc33dd4"); err != nil { t.Fatalf("root promote over a scope-signed pointer: %v", err) } p, err = Channel(ctx, s, rootPub, "bab", "kass", "edge") if err != nil { t.Fatalf("Channel: %v", err) } if p.Version != "cc33dd4" || len(p.History) != 2 { t.Fatalf("pointer = %+v, want cc33dd4 with two history entries", p) } // Rollback with the scoped key returns to the scope-signed version. if err := sg.Rollback(ctx, s, "bab", "kass", "edge"); err != nil { t.Fatalf("scoped rollback: %v", err) } p, err = Channel(ctx, s, rootPub, "bab", "kass", "edge") if err != nil { t.Fatalf("Channel: %v", err) } if p.Version != "aa11bb2" { t.Fatalf("after rollback pointer = %+v, want aa11bb2", p) } } // End to end, in the shape the feature exists for: mint a scoped edge key, // promote to edge with it, and let an ordinary adoption pass — which knows // only CARLOS_RELEASE_PUBKEY — install the build. Then the same key is // refused stable locally, and a hand-crafted stable grant is refused at // verification. func TestScopedEdgeKeyEndToEnd(t *testing.T) { root, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) ctx := context.Background() s := NewDirStore(t.TempDir()) opt := t.TempDir() reg := testRegistry(t) shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one")) if err := sg.Promote(ctx, s, "bab", "myapp", "canary/x", "v1"); err != nil { t.Fatalf("scoped promote: %v", err) } addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false) if err := Pass(ctx, reg, s, rootPub, opt, func(string) error { return nil }); err != nil { t.Fatalf("adoption pass over a scope-signed pointer: %v", err) } if v, _ := LiveVersion(opt, "awake.example.com"); v != "v1" { t.Fatalf("live version = %q, want v1 — adoption did not accept the scope-signed pointer", v) } // stable is refused locally, without a write. if err := sg.Promote(ctx, trapStore{t}, "bab", "myapp", "stable", "v1"); err == nil { t.Fatal("the edge key promoted to stable") } // And a stable grant hand-crafted with the ROOT key — the strongest // attacker this scheme contemplates, since it is the deployment's own // signing authority — is refused at verification. _, scopedPub := testKeys(t) g := goodGrant(scopedPub) g.Rungs = []string{RungEdge, RungStable} sub, err := parseGrantBlob(forgeGrant(t, root, g), "test") if err != nil { t.Fatal(err) } if _, err := verifyGrant(rootPub, sub); err == nil { t.Fatal("a root-signed stable grant was honoured") } }