release: promote and rollback as a signing identity, root or scoped
Promote, PromoteOnLadder and Rollback become methods on Signer; the free functions stay exactly as they were, delegating through RootSigner. All 74 existing call sites — the console, the migration verb, every test — are untouched, which is the point: the root-key path is not what this change is allowed to disturb. Two behaviours the methods add. An out-of-scope channel is refused before the store is touched at all, so the refusal reads the same whether or not the bucket is reachable, and it names the grant, what the grant covers and the channel asked for. And the pointer being REPLACED is verified against the deployment's root public key rather than the signing key's own public half — a scoped key's first promotion is almost always over a root-signed pointer, and anchoring at the signer would refuse it and drop its history. -hotfix bypasses the ladder and can never bypass a grant. The two gates answer different questions: the ladder asks whether this BUILD is mature enough, the grant asks whether this KEY may reach the rung at all. A flag an operator can type is not a way to widen a delegation. Mutation-verified: anchoring verification at the signing key breaks replacing a root-signed pointer; moving the scope check below the manifest read trips a store that fails on any access; making the check conditional on !opt.Hotfix breaks the hotfix test. Plus the end-to-end — mint an edge key, promote, adopt with only the root pubkey configured, then watch stable be refused locally and a root-signed stable grant be refused at verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+278
−12
internal/release/grant.go+2 −2internal/release/release.go+52 −10internal/release/scopedpromote_test.go+224 −0
diff --git a/internal/release/grant.go b/internal/release/grant.go| index f4b422abb..0a3c0aa10 100644 |
| --- a/internal/release/grant.go |
| +++ b/internal/release/grant.go |
| @@ -252,8 +252,8 @@ func (g *Grant) allows(channel string, at time.Time) error { |
| // outright there; if that check were ever lost, this one still means no |
| // grant reaches stable. |
| if rung == RungStable { |
| - return fmt.Errorf("key grant %q cannot reach %s: %s is never delegable — moving it requires the root key itself", |
| - g.Name, RungStable, RungStable) |
| + return fmt.Errorf("key grant %q covers %s and cannot reach %q: %s is never delegable — moving it requires the root key itself", |
| + g.Name, strings.Join(g.Rungs, ", "), channel, RungStable) |
| } |
| if !g.covers(rung) { |
| return fmt.Errorf("key grant %q covers %s; channel %q is on rung %s", |
diff --git a/internal/release/release.go b/internal/release/release.go| index 4c333b7f4..e67ac5ab9 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -532,19 +532,27 @@ func GetManifest(ctx context.Context, s Store, acct, app, version string) (*Mani |
| return &m, nil |
| } |
| +// Promote points a channel at a shipped version with the deployment's root |
| +// key. It is Signer.Promote with RootSigner — the shape every caller that |
| +// holds the root key (the console, the migration verb, the tests) keeps using |
| +// unchanged. |
| +func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, rec ...Promotion) error { |
| + return RootSigner(priv).Promote(ctx, s, acct, app, channel, version, rec...) |
| +} |
| + |
| // Promote points a channel at a shipped version, signing the new pointer and |
| // swapping it in via CAS (a concurrent promote surfaces as ErrCASConflict — |
| // re-read and retry, never overwrite blind). The outgoing version is |
| // prepended to History (capped at historyMax), which is what Rollback |
| -// consumes. An existing pointer must verify against this key's public half |
| -// before it is replaced: history from a document we can't trust doesn't get |
| -// carried forward. |
| +// consumes. An existing pointer must verify against the deployment's ROOT |
| +// public key before it is replaced: history from a document we can't trust |
| +// doesn't get carried forward. |
| // |
| // An optional rec appends an entry to the pointer's audit trail (who, from |
| // which rung, hotfix or not). It is variadic because most callers — the |
| // migration verb, the tests, anything moving a pointer mechanically — have no |
| // promotion to record; a promotion a person made does. |
| -func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, rec ...Promotion) error { |
| +func (sg Signer) Promote(ctx context.Context, s Store, acct, app, channel, version string, rec ...Promotion) error { |
| if !ValidAccount(acct) { |
| return fmt.Errorf("promote: invalid account %q", acct) |
| } |
| @@ -557,11 +565,19 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, c |
| if !ValidVersion(version) || version == "dev" { |
| return fmt.Errorf("promote: invalid version %q", version) |
| } |
| + // A scoped key learns it may not touch this channel HERE, before a single |
| + // byte moves in either direction: no manifest read, no pointer read, and |
| + // above all no write. The ladder refuses in the same style, and for the |
| + // same reason — an operator finding out from a verifier three machines |
| + // away has already been told the wrong thing about what happened. |
| + if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| + return fmt.Errorf("promote: %w", err) |
| + } |
| // A channel only ever points at a version that was actually shipped. |
| if _, err := GetManifest(ctx, s, acct, app, version); err != nil { |
| return fmt.Errorf("promote: version %s is not shipped: %w", version, err) |
| } |
| - pub, _ := priv.Public().(ed25519.PublicKey) |
| + pub := sg.verifyPub() |
| key := ChannelKey(acct, app, channel) |
| // The qualified key and nothing else, so the etag CAS holds against the |
| // object actually being written. An app still on legacy keys reads as a |
| @@ -600,7 +616,7 @@ func Promote(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, c |
| } |
| next.Audit = prependCapped(e, old.Audit, auditMax) |
| } |
| - wrapper, err := signPointer(RootSigner(priv), next) |
| + wrapper, err := signPointer(sg, next) |
| if err != nil { |
| return err |
| } |
| @@ -651,6 +667,15 @@ type PromoteResult struct { |
| // replacement, so nothing that legitimately points a channel outside the |
| // ladder (a migration, the console's canary flow) changes behaviour. |
| func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) { |
| + return RootSigner(priv).PromoteOnLadder(ctx, s, acct, app, channel, version, opt) |
| +} |
| + |
| +// PromoteOnLadder as a scoped or root identity. A scoped key meets TWO gates |
| +// here, and they answer different questions: the ladder asks whether this |
| +// BUILD is mature enough for the rung, the grant asks whether this KEY may |
| +// reach it at all. -hotfix bypasses the first and can never bypass the |
| +// second — a bypass an operator can type is not a way to widen a delegation. |
| +func (sg Signer) PromoteOnLadder(ctx context.Context, s Store, acct, app, channel, version string, opt PromoteOptions) (PromoteResult, error) { |
| res := PromoteResult{Hotfix: opt.Hotfix} |
| if !ValidAccount(acct) { |
| return res, fmt.Errorf("promote: invalid account %q", acct) |
| @@ -664,8 +689,13 @@ func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct |
| if !ValidVersion(version) || version == "dev" { |
| return res, fmt.Errorf("promote: invalid version %q", version) |
| } |
| + // Before the no-op read, before the prefix listing, before anything: a key |
| + // that may not sign this channel is told so without touching the bucket. |
| + if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| + return res, fmt.Errorf("promote: %w", err) |
| + } |
| res.To, _ = Rung(channel) |
| - pub, _ := priv.Public().(ed25519.PublicKey) |
| + pub := sg.verifyPub() |
| // A repeat is a no-op, checked before anything else: re-running a command |
| // must not fail, whichever rung the build has since climbed to. |
| @@ -714,7 +744,7 @@ func PromoteOnLadder(ctx context.Context, s Store, priv ed25519.PrivateKey, acct |
| } |
| } |
| rec := Promotion{From: res.From, To: res.To, Actor: opt.Actor, Hotfix: opt.Hotfix} |
| - if err := Promote(ctx, s, priv, acct, app, channel, version, rec); err != nil { |
| + if err := sg.Promote(ctx, s, acct, app, channel, version, rec); err != nil { |
| return res, err |
| } |
| return res, nil |
| @@ -776,13 +806,25 @@ func Channel(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app, cha |
| // (that's how it got installed the first time), so evacuating a bad release |
| // never itself sits in a bake window (bake.go). |
| func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, channel string) error { |
| + return RootSigner(priv).Rollback(ctx, s, acct, app, channel) |
| +} |
| + |
| +// Rollback as a scoped or root identity. A rollback signs a new pointer for |
| +// the same channel, so it is bounded by the same grant: a key that cannot |
| +// promote to stable cannot roll stable back either. Rolling back a channel |
| +// outside the scope needs the root key, which is the same answer as moving it |
| +// forward. |
| +func (sg Signer) Rollback(ctx context.Context, s Store, acct, app, channel string) error { |
| if !ValidAccount(acct) { |
| return fmt.Errorf("rollback: invalid account %q", acct) |
| } |
| if !ValidApp(app) || !ValidChannel(channel) { |
| return fmt.Errorf("rollback: invalid app/channel %s/%s", app, channel) |
| } |
| - pub, _ := priv.Public().(ed25519.PublicKey) |
| + if err := sg.allows(channel, time.Now().UTC()); err != nil { |
| + return fmt.Errorf("rollback: %w", err) |
| + } |
| + pub := sg.verifyPub() |
| key := ChannelKey(acct, app, channel) |
| cur, etag, err := GetQualified(ctx, s, key) |
| if err != nil { |
| @@ -804,7 +846,7 @@ func Rollback(ctx context.Context, s Store, priv ed25519.PrivateKey, acct, app, |
| // erase how the channel got here either. |
| Audit: old.Audit, |
| } |
| - wrapper, err := signPointer(RootSigner(priv), next) |
| + wrapper, err := signPointer(sg, next) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/release/scopedpromote_test.go b/internal/release/scopedpromote_test.go| new file mode 100644 |
| index 000000000..0fd3bbafa |
| --- /dev/null |
| +++ b/internal/release/scopedpromote_test.go |
| @@ -0,0 +1,224 @@ |
| +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 |
| +} |
| + |
| +// 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") |
| + } |
| +} |