merge: main — the API doors meet scoped signing keys
Resolution: direct-mode promote/rollback take main's releaseSigner() identity; the API-mode branches are untouched. The console's API doors share s.cfg.Key with the browser doors, and the console refuses a scoped key at startup — so API-signed pointers keep the deployment's root authority, exactly as before the merge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
16 files changed,
+2098
−24
cli.go+106 −5cmd/console/main.go+9 −0docs/superpowers/specs/2026-08-01-carlos-framework-design.md+20 −0docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md+8 −0internal/release/golden_test.go+143 −0internal/release/grant.go+308 −0internal/release/grant_test.go+419 −0internal/release/laddercheck_test.go+68 −1internal/release/qualified_test.go+2 −2internal/release/release.go+126 −15internal/release/release_test.go+1 −1internal/release/scopedpromote_test.go+224 −0internal/release/scopedverify_test.go+404 −0internal/release/signer.go+85 −0main.go+5 −0scopedkeys_e2e_test.go+170 −0
diff --git a/cli.go b/cli.go| index 5b7cf4eb3..fdccb2676 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -761,6 +761,44 @@ func signingKey() ed25519.PrivateKey { |
| return k |
| } |
| +// releaseSigner is the identity the write verbs sign with: the root key, or a |
| +// scoped key travelling with the grant that authorises it. |
| +// |
| +// The grant is discovered from CARLOS_RELEASE_GRANT, exactly as the key is |
| +// discovered from CARLOS_RELEASE_KEY — secret manager to shell variable to |
| +// process memory, never disk. A scoped key with no grant beside it is fatal |
| +// here rather than three machines away: a scoped seed alone proves nothing, |
| +// so it would sign pointers nothing in the fleet will ever accept, and the |
| +// failure would surface as an unexplained signature error on a box. |
| +// |
| +// That check is only possible because a scoped seed is tagged (release's |
| +// ScopedKeyPrefix). Both kinds of key are otherwise 32 random bytes. |
| +func releaseSigner() release.Signer { |
| + raw := os.Getenv("CARLOS_RELEASE_KEY") |
| + key, err := release.ParseSigningKey(raw, "CARLOS_RELEASE_KEY") |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + blob := strings.TrimSpace(os.Getenv("CARLOS_RELEASE_GRANT")) |
| + if blob == "" { |
| + if release.IsScopedSeed(raw) { |
| + log.Fatal("CARLOS_RELEASE_KEY holds a scoped key but CARLOS_RELEASE_GRANT is not set — a scoped key alone proves nothing; set the grant blob that was printed beside the key by `carlos release-keygen -scope`") |
| + } |
| + return release.RootSigner(key) |
| + } |
| + // A grant is only meaningful against the key the fleet trusts, so scoped |
| + // signing needs the deployment's public key too — the same value every box |
| + // is configured with, not a new secret. |
| + if strings.TrimSpace(os.Getenv("CARLOS_RELEASE_PUBKEY")) == "" { |
| + log.Fatal("CARLOS_RELEASE_GRANT is set, so this is a scoped key: signing also needs the deployment's root public key in CARLOS_RELEASE_PUBKEY, which is what the grant is checked against") |
| + } |
| + sg, err := release.ScopedSigner(key, blob, publicKey()) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + return sg |
| +} |
| + |
| func publicKey() ed25519.PublicKey { |
| k, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY") |
| if err != nil { |
| @@ -976,7 +1014,7 @@ func runPromote(args []string) { |
| return |
| } |
| - res, err := release.PromoteOnLadder(context.Background(), openStore(), signingKey(), |
| + res, err := releaseSigner().PromoteOnLadder(context.Background(), openStore(), |
| acctOf(), *app, channel, version, |
| release.PromoteOptions{Hotfix: *hotfix, Actor: promoteActor()}) |
| if err != nil { |
| @@ -1085,19 +1123,82 @@ func runRollback(args []string) { |
| return |
| } |
| - if err := release.Rollback(context.Background(), openStore(), signingKey(), acctOf(), *app, fs.Arg(0)); err != nil { |
| + if err := releaseSigner().Rollback(context.Background(), openStore(), acctOf(), *app, fs.Arg(0)); err != nil { |
| log.Fatal(err) |
| } |
| fmt.Fprintf(stdout, "rolled back %s %s\n", *app, fs.Arg(0)) |
| } |
| +// defaultGrantTTL is how long a scoped key lives when the operator does not |
| +// say: a quarter. Short enough that a forgotten builder key stops working |
| +// while someone still remembers what it was for, long enough that rotating it |
| +// is a calendar item rather than a chore. |
| +const defaultGrantTTL = 90 * 24 * time.Hour |
| + |
| +// runKeygen has two modes in one verb, because they mint the same kind of |
| +// thing for the same deployment. |
| +// |
| +// Without -scope it is what it has always been: a fresh root pair, printed as |
| +// the two variables a deployment is configured with. Nothing about that |
| +// output or its meaning changes. |
| +// |
| +// With -scope it mints a SCOPED key: a fresh pair plus a grant signed by the |
| +// root key read from CARLOS_RELEASE_KEY (same env discipline as promote — the |
| +// root key is read, never printed, never written). What comes out is the |
| +// scoped seed and the grant blob, which travel together. The deployment's |
| +// CARLOS_RELEASE_PUBKEY does NOT change and is deliberately not reprinted |
| +// here: the fleet's trust anchor stays exactly one key, and a second pubkey |
| +// in this output would invite someone to install it on a box. |
| func runKeygen(args []string) { |
| - seed, pub, err := release.GenerateKey() |
| + fs := flag.NewFlagSet("release-keygen", flag.ExitOnError) |
| + scope := fs.String("scope", "", "mint a SCOPED key for these rungs, comma separated (canary,edge,beta) — stable is never delegable") |
| + name := fs.String("name", "", "label for the scoped key; it appears in every refusal it causes") |
| + expires := fs.Duration("expires", defaultGrantTTL, "how long the scoped key may sign for (max 8760h)") |
| + fs.Parse(args) |
| + |
| + if *scope == "" { |
| + var stray []string |
| + fs.Visit(func(f *flag.Flag) { stray = append(stray, "-"+f.Name) }) |
| + if len(stray) > 0 { |
| + log.Fatalf("%s only apply with -scope; without it this mints the deployment's ROOT key", strings.Join(stray, " and ")) |
| + } |
| + seed, pub, err := release.GenerateKey() |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub) |
| + fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config") |
| + return |
| + } |
| + |
| + if *name == "" { |
| + log.Fatal("usage: carlos release-keygen -scope <rungs> -name <label> [-expires <dur>]") |
| + } |
| + if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) { |
| + log.Fatal("CARLOS_RELEASE_KEY holds a scoped key: only the ROOT key can mint a grant, which is what makes a scoped key unable to widen itself") |
| + } |
| + var rungs []string |
| + for _, r := range strings.Split(*scope, ",") { |
| + if r = strings.TrimSpace(r); r != "" { |
| + rungs = append(rungs, r) |
| + } |
| + } |
| + seed, grant, err := release.MintKeyGrant(signingKey(), *name, rungs, *expires) |
| if err != nil { |
| log.Fatal(err) |
| } |
| - fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_PUBKEY=%s\n", seed, pub) |
| - fmt.Fprintln(os.Stderr, "store the KEY in your secret manager; the PUBKEY goes in host config") |
| + fmt.Printf("CARLOS_RELEASE_KEY=%s\nCARLOS_RELEASE_GRANT=%s\n", seed, grant) |
| + fmt.Fprintf(os.Stderr, `scoped key %q for %s, expiring %s |
| + |
| + The KEY is the secret — store it wherever the builder's secrets live. |
| + The GRANT is not secret (public material only) but must travel WITH the |
| + key: a scoped key alone signs pointers nothing will accept. |
| + CARLOS_RELEASE_PUBKEY does not change; the fleet still trusts one key. |
| + |
| + Deploy order: fleet binaries that understand grants FIRST, this key |
| + second. An older binary reads a grant-signed pointer as a bad signature, |
| + which is the right way round to fail but will stop that box adopting. |
| +`, *name, strings.Join(rungs, ", "), time.Now().UTC().Add(*expires).Format(time.RFC3339)) |
| } |
| // statusUsage is shared between "no flag given" and "-h/--help" so an |
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 3f771b83c..e70848e28 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -198,6 +198,15 @@ func serve() { |
| // the promote/rollback surface is disabled. Never logged, never rendered. |
| var key ed25519.PrivateKey |
| if os.Getenv("CARLOS_RELEASE_KEY") != "" { |
| + // A SCOPED key here would be a silent trap: the console signs every |
| + // promotion as if it held the root key, so a scoped seed would produce |
| + // pointers with no grant attached, which nothing in the fleet accepts |
| + // — and the operator would learn about it from a box that stopped |
| + // adopting. Scoped keys are for automation; the console promotes with |
| + // the deployment's own authority or not at all. |
| + if release.IsScopedSeed(os.Getenv("CARLOS_RELEASE_KEY")) { |
| + log.Fatal("console: CARLOS_RELEASE_KEY holds a scoped key — the console promotes with the deployment's ROOT key or runs read-only with none; a scoped key would sign pointers the fleet refuses") |
| + } |
| key, err = release.ParseSigningKey(os.Getenv("CARLOS_RELEASE_KEY"), "CARLOS_RELEASE_KEY") |
| if err != nil { |
| log.Fatal(err) |
diff --git a/docs/superpowers/specs/2026-08-01-carlos-framework-design.md b/docs/superpowers/specs/2026-08-01-carlos-framework-design.md| index dba29cc28..8524df33e 100644 |
| --- a/docs/superpowers/specs/2026-08-01-carlos-framework-design.md |
| +++ b/docs/superpowers/specs/2026-08-01-carlos-framework-design.md |
| @@ -218,6 +218,19 @@ six apps already hand-builds (`mux.HandleFunc("GET /{slug}/orders/{token}", |
| ...)`), just wired by directory walk instead of by hand. **Build fails |
| loudly on any collision** between a hand-written and a generated path. |
| +**Package-per-directory (implementation, 2026-08-03).** The example above |
| +puts `cancel.POST.go` and `edit.GET.go` in the same directory, but Go |
| +compiles one package per directory — both files declaring a bare `func |
| +Handle` would collide if compiled in place. `rastrillo`'s generator |
| +(`internal/generate/generate.go`) resolves this by never compiling |
| +`actions/` in place: each file is parsed, its package clause rewritten to |
| +a name unique to its route, and the result written into its own directory |
| +under `gen/actions/`, which `gen/router.go` imports normally — no AST |
| +surgery beyond the package clause. A leaner alternative, lifting just the |
| +`Handle` function via full AST extraction to stay closer to "one file, no |
| +package boilerplate," was deliberately deferred rather than rushed: real |
| +complexity for a cosmetic win. |
| + |
| **Action signature**, fixed early per the recovered spec's own advice |
| ("decide once, painful to change"): |
| @@ -867,3 +880,10 @@ confirmed rather than assumed (§11); no skill-version pinning in v1 |
| questions — TOML library choice, the mergeable ordering default, and the |
| EXECUTOR declaration mechanism — came back "don't know"/"maybe" and are |
| carried into §15 genuinely open, not guessed at. |
| + |
| +A seventh pass (2026-08-03) folded back an implementation gap §4's |
| +routing example glossed over: `carlosframework/rastrillo`'s generator |
| +found the one-package-per-directory collision the example invites, and |
| +resolved it (package-clause rewrite, one generated package per route |
| +under `gen/actions/`) rather than redesigning the convention — recorded |
| +in §4 as a package-per-directory note. |
diff --git a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md| index ace630d33..4b50a3dc9 100644 |
| --- a/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md |
| +++ b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md |
| @@ -124,6 +124,14 @@ an expired grant fails at promote time and at verify time. (Revocation of |
| an actively-misused key is by rotating the ROOT key, which invalidates every |
| grant at once — documented as the incident response, not built.) |
| +Stated plainly, because it lives nowhere else: **expiry is evadable by the |
| +key's own holder** — promoted_at is signer-controlled, so a scoped key can |
| +backdate a pointer inside its own validity window. Backdating defeats expiry |
| +only, never scope: stable stays refused whatever the date says. Expiry is |
| +hygiene against forgotten keys, not revocation against hostile ones. There is |
| +also a floor: a pointer dated before the grant's minted_at is refused as |
| +forged history — no real promotion by a key can predate the key. |
| + |
| ## 6. Non-goals |
| - Per-app or per-account scoping (rung-only for v1; the grant schema's |
diff --git a/internal/release/golden_test.go b/internal/release/golden_test.go| new file mode 100644 |
| index 000000000..02a478959 |
| --- /dev/null |
| +++ b/internal/release/golden_test.go |
| @@ -0,0 +1,143 @@ |
| +package release |
| + |
| +import ( |
| + "context" |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "reflect" |
| + "sort" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The wire shape of a channel pointer, pinned. Every deployment bucket in |
| +// production holds objects that look EXACTLY like this right now, written by |
| +// binaries that predate whatever is being changed today; they have to keep |
| +// parsing and keep verifying, byte for byte, forever. So this fixture is not |
| +// regenerated when the shape changes — a diff here means live pointers stopped |
| +// verifying, which is a fleet that cannot resolve a channel. |
| +// |
| +// Captured from the signing path on f1cc914 with a deterministic seed |
| +// (bytes 1..32) and a frozen promoted_at, over a payload carrying every |
| +// optional field a real pointer has ever had: history and an audit trail. |
| +const goldenRootSignedWrapper = `{ |
| + "payload": "ewogICJzY2hlbWEiOiAxLAogICJhcHAiOiAia2FzcyIsCiAgImNoYW5uZWwiOiAiY2FuYXJ5L3BsYXRmb3JtLWFkb3B0aW9uIiwKICAidmVyc2lvbiI6ICI5ZjNjMWFkIiwKICAicHJvbW90ZWRfYXQiOiAiMjAyNi0wNy0zMFQxMTowNDoxN1oiLAogICJoaXN0b3J5IjogWwogICAgIjFiMmU0YzciLAogICAgIjBhYzkxZGUiCiAgXSwKICAiYXVkaXQiOiBbCiAgICB7CiAgICAgICJ2ZXJzaW9uIjogIjlmM2MxYWQiLAogICAgICAiZnJvbSI6ICJlZGdlIiwKICAgICAgImFjdG9yIjogInBhdWxAcnNsdy5jb20iLAogICAgICAiYXQiOiAiMjAyNi0wNy0zMFQxMTowNDoxN1oiCiAgICB9CiAgXQp9", |
| + "sig": "yiutCm6KdqvIc4jqX2fCo9cYY3R6OITWoHtPNk8xO0mUFFxD4RZ5XLT4CHUAlUlRQfwEOKUm7ii46qlhjdQFCg==" |
| +}` |
| + |
| +// goldenRootPub is the public half of the fixture's signer. |
| +const goldenRootPub = "ebVWLo/mVPlAeLES6KmLp5AfhTrmlb7X4OORC60ElmQ=" |
| + |
| +// A pointer written by an older binary — no grant field at all — verifies |
| +// against the root public key and decodes to exactly what it said. |
| +// |
| +// Mutation-mindedness: if grant handling were ever made mandatory (require a |
| +// grant, or reject a wrapper without one), this test goes red immediately, |
| +// because the entire live fleet is on wrappers shaped like this one. |
| +func TestGoldenRootSignedWrapperStillVerifies(t *testing.T) { |
| + pub, err := ParsePublicKey(goldenRootPub, "golden") |
| + if err != nil { |
| + t.Fatalf("ParsePublicKey: %v", err) |
| + } |
| + p, err := verifyPointer(pub, []byte(goldenRootSignedWrapper), "kass", "canary/platform-adoption") |
| + if err != nil { |
| + t.Fatalf("golden root-signed pointer no longer verifies: %v", err) |
| + } |
| + want := &ChannelPointer{ |
| + Schema: 1, |
| + App: "kass", |
| + Channel: "canary/platform-adoption", |
| + Version: "9f3c1ad", |
| + PromotedAt: time.Date(2026, 7, 30, 11, 4, 17, 0, time.UTC), |
| + History: []string{"1b2e4c7", "0ac91de"}, |
| + Audit: []Promotion{{ |
| + Version: "9f3c1ad", |
| + From: "edge", |
| + Actor: "paul@rslw.com", |
| + At: time.Date(2026, 7, 30, 11, 4, 17, 0, time.UTC), |
| + }}, |
| + } |
| + if !reflect.DeepEqual(p, want) { |
| + t.Fatalf("golden pointer decoded as\n%+v\nwant\n%+v", p, want) |
| + } |
| +} |
| + |
| +// The wrapper a root key writes TODAY has exactly two keys. A new wrapper |
| +// field that is not omitted when empty would change every pointer the fleet |
| +// writes and break old readers' strictness assumptions; this is the assertion |
| +// that catches a forgotten `omitempty`. |
| +// |
| +// Mutation-mindedness: drop `,omitempty` from any wrapper field added later |
| +// and this fails with the extra key named. |
| +func TestRootSignedWrapperHasOnlyPayloadAndSig(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + wrapper []byte |
| + }{ |
| + {"golden fixture", []byte(goldenRootSignedWrapper)}, |
| + {"freshly signed", freshRootWrapper(t)}, |
| + } { |
| + var keys map[string]json.RawMessage |
| + if err := json.Unmarshal(tc.wrapper, &keys); err != nil { |
| + t.Fatalf("%s: %v", tc.name, err) |
| + } |
| + got := make([]string, 0, len(keys)) |
| + for k := range keys { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"payload", "sig"}; !reflect.DeepEqual(got, want) { |
| + t.Errorf("%s: wrapper keys = %v, want %v", tc.name, got, want) |
| + } |
| + } |
| +} |
| + |
| +// freshRootWrapper promotes with a root key through the real store path and |
| +// returns the bytes that landed in the bucket — the shape a fleet reads. |
| +func freshRootWrapper(t *testing.T) []byte { |
| + t.Helper() |
| + s := NewDirStore(t.TempDir()) |
| + ctx := context.Background() |
| + priv, _ := testKeys(t) |
| + shipRelease(t, s, "bab", "kass", "9f3c1ad", "linux-arm64", []byte("bytes")) |
| + if err := Promote(ctx, s, priv, "bab", "kass", "canary/platform-adoption", "9f3c1ad"); err != nil { |
| + t.Fatalf("Promote: %v", err) |
| + } |
| + raw, _, err := s.Get(ctx, ChannelKey("bab", "kass", "canary/platform-adoption")) |
| + if err != nil { |
| + t.Fatalf("read back pointer: %v", err) |
| + } |
| + return raw |
| +} |
| + |
| +// The signature covers the base64 payload STRING, not the decoded JSON, so no |
| +// canonicalisation question can ever arise. Pinned because a refactor to |
| +// "verify the decoded bytes" would silently invalidate every live pointer. |
| +// |
| +// Mutation-mindedness: change signPointer/verifyPointer to sign the decoded |
| +// payload and the golden fixture stops verifying; this test says why. |
| +func TestSignatureCoversTheBase64PayloadString(t *testing.T) { |
| + pub, err := ParsePublicKey(goldenRootPub, "golden") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var sw signedWrapper |
| + if err := json.Unmarshal([]byte(goldenRootSignedWrapper), &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| + t.Fatal("golden signature does not cover the base64 payload string") |
| + } |
| + decoded, err := base64.StdEncoding.DecodeString(sw.Payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ed25519.Verify(pub, decoded, sig) { |
| + t.Fatal("golden signature covers the DECODED payload — the scheme changed") |
| + } |
| +} |
diff --git a/internal/release/grant.go b/internal/release/grant.go| new file mode 100644 |
| index 000000000..59df20a95 |
| --- /dev/null |
| +++ b/internal/release/grant.go |
| @@ -0,0 +1,308 @@ |
| +// Scoped signing keys — a key that can reach edge cannot touch stable |
| +// (spec docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md). |
| +// |
| +// A deployment has ONE root release key, exactly as before. A scoped key is |
| +// minted by the root key, which signs a KEY GRANT: a statement binding the |
| +// scoped public key to a set of ladder rungs and an expiry. A pointer signed |
| +// by a scoped key travels with its grant inside the signed wrapper, so the |
| +// fleet's trust anchor stays a single CARLOS_RELEASE_PUBKEY — no key |
| +// registry, and nothing in the bucket that a bucket-writer could turn into |
| +// trust. |
| +// |
| +// stable is never delegable. MintKeyGrant refuses to write it into a grant |
| +// and verification refuses to honour one that names it, so even the holder of |
| +// the root key cannot hand out the ability to move stable; moving stable |
| +// always requires the root key itself. Both directions are enforced |
| +// separately, because mint and verify are different programs on different |
| +// machines at different versions. |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "crypto/rand" |
| + "encoding/base64" |
| + "encoding/json" |
| + "fmt" |
| + "regexp" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// maxGrantTTL bounds how long a delegated key lives. A scoped key exists so |
| +// that a compromised builder is a bounded problem; an unbounded grant gives |
| +// that back. The cap is enforced at mint AND at verification — a hand-crafted |
| +// decade-long grant is not an attack the scheme can prevent (it takes the |
| +// root key to sign one) but it is one it declines to honour, in the same |
| +// place it declines to honour stable. |
| +const maxGrantTTL = 365 * 24 * time.Hour |
| + |
| +// ScopedKeyPrefix tags the seed of a scoped key. A scoped seed and a root |
| +// seed are both 32 random bytes, so without a tag nothing downstream can tell |
| +// them apart — and the mistake that must never pass silently is a scoped seed |
| +// sitting in CARLOS_RELEASE_KEY with no CARLOS_RELEASE_GRANT beside it, which |
| +// would sign pointers that nothing in the fleet will ever accept. |
| +// ParseSigningKey accepts tagged and untagged seeds alike, so every root key |
| +// in a secret store today keeps working untouched. |
| +const ScopedKeyPrefix = "scoped:" |
| + |
| +// IsScopedSeed reports whether a CARLOS_RELEASE_KEY value was minted as a |
| +// scoped key. Callers use it to insist on a grant. |
| +func IsScopedSeed(seed string) bool { |
| + return strings.HasPrefix(strings.TrimSpace(seed), ScopedKeyPrefix) |
| +} |
| + |
| +// Grant is the root-signed statement that a scoped public key may sign |
| +// channel pointers for a set of rungs, until a date. It is not secret: it |
| +// contains only public material, and it travels in the clear inside every |
| +// wrapper the scoped key signs. |
| +type Grant struct { |
| + Schema int `json:"schema"` // 1 |
| + Pub string `json:"pub"` // base64 Ed25519 public key of the scoped key |
| + // Rungs uses the frozen ladder vocabulary (RungCanary, RungEdge, |
| + // RungBeta). "canary" covers every canary/<branch> channel. RungStable |
| + // may never appear. |
| + Rungs []string `json:"rungs"` |
| + Name string `json:"name"` // operator's label, for error messages |
| + MintedAt time.Time `json:"minted_at"` |
| + ExpiresAt time.Time `json:"expires_at"` |
| +} |
| + |
| +// signedGrant is the wrapper's grant sub-object: the ROOT key's signature |
| +// over the exact base64 grant payload string. Same discipline as the pointer |
| +// itself — verify the string, decode after, no JSON canonicalisation to get |
| +// wrong. |
| +type signedGrant struct { |
| + Payload string `json:"payload"` |
| + Sig string `json:"sig"` |
| +} |
| + |
| +// grantNameRe keeps a grant's label printable and short: it appears in |
| +// refusals an operator reads at 3am, not in a path. |
| +var grantNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`) |
| + |
| +// MintKeyGrant mints a fresh scoped keypair and the root-signed grant that |
| +// authorises it. It returns the TAGGED seed (the secret, for the builder's |
| +// secret store, as CARLOS_RELEASE_KEY) and the base64 grant blob (not secret, |
| +// as CARLOS_RELEASE_GRANT). The deployment's CARLOS_RELEASE_PUBKEY does not |
| +// change: the fleet still trusts exactly one key, and this one chains to it. |
| +func MintKeyGrant(root ed25519.PrivateKey, name string, rungs []string, ttl time.Duration) (seed, grantB64 string, err error) { |
| + if len(root) != ed25519.PrivateKeySize { |
| + return "", "", fmt.Errorf("mint key grant: need the root signing key") |
| + } |
| + if !grantNameRe.MatchString(name) { |
| + return "", "", fmt.Errorf("mint key grant: invalid name %q (letters, digits, . _ - ; up to 64)", name) |
| + } |
| + if ttl <= 0 || ttl > maxGrantTTL { |
| + return "", "", fmt.Errorf("mint key grant: lifetime %s is outside (0, %s]", ttl, maxGrantTTL) |
| + } |
| + // Checked here as well as in validateGrant so the refusal names the flag |
| + // the operator typed rather than the document they never saw. |
| + seen := map[string]bool{} |
| + deduped := rungs[:0:0] |
| + for _, r := range rungs { |
| + if r == RungStable { |
| + return "", "", fmt.Errorf("mint key grant: %s is never delegable — moving stable requires the root key itself", RungStable) |
| + } |
| + if RungIndex(r) < 0 { |
| + return "", "", fmt.Errorf("mint key grant: %q is not a rung (%s)", r, strings.Join(delegableRungs(), ", ")) |
| + } |
| + // Deduped at mint (2026-08-03 review, MINOR 6): a repeated rung is |
| + // harmless to verify but noise in every grant listing forever. |
| + if !seen[r] { |
| + seen[r] = true |
| + deduped = append(deduped, r) |
| + } |
| + } |
| + rungs = deduped |
| + pub, priv, err := ed25519.GenerateKey(rand.Reader) |
| + if err != nil { |
| + return "", "", err |
| + } |
| + now := time.Now().UTC().Truncate(time.Second) |
| + g := Grant{ |
| + Schema: 1, |
| + Pub: base64.StdEncoding.EncodeToString(pub), |
| + Rungs: append([]string(nil), rungs...), |
| + Name: name, |
| + MintedAt: now, |
| + ExpiresAt: now.Add(ttl).Truncate(time.Second), |
| + } |
| + // Mint through the verifier's own validation, so a grant this binary |
| + // writes is one this binary would accept. A mint that can produce a |
| + // document verification rejects is a footgun with a signature on it. |
| + if err := validateGrant(&g); err != nil { |
| + return "", "", fmt.Errorf("mint key grant: %w", err) |
| + } |
| + payload, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + return "", "", err |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + blob, err := json.MarshalIndent(signedGrant{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(root, []byte(b64))), |
| + }, "", " ") |
| + if err != nil { |
| + return "", "", err |
| + } |
| + return ScopedKeyPrefix + base64.StdEncoding.EncodeToString(priv.Seed()), |
| + base64.StdEncoding.EncodeToString(blob), nil |
| +} |
| + |
| +// parseGrantBlob decodes the base64 grant blob an operator carries in |
| +// CARLOS_RELEASE_GRANT. It parses only — nothing here is trusted until |
| +// verifyGrant has checked the root signature. src names the origin in errors, |
| +// because the usual cause is the wrong secret in the right variable. |
| +func parseGrantBlob(b64, src string) (*signedGrant, error) { |
| + raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + if err != nil { |
| + return nil, fmt.Errorf("%s: expected a base64 key grant: %v", src, err) |
| + } |
| + var sg signedGrant |
| + if err := json.Unmarshal(raw, &sg); err != nil { |
| + return nil, fmt.Errorf("%s: key grant: %v", src, err) |
| + } |
| + if sg.Payload == "" || sg.Sig == "" { |
| + return nil, fmt.Errorf("%s: key grant has no payload/sig", src) |
| + } |
| + return &sg, nil |
| +} |
| + |
| +// verifyGrant checks a grant's signature against the ROOT public key the |
| +// verifier is pinned to, and only then decodes and validates it — everything |
| +// the caller acts on came from under the root's signature. |
| +func verifyGrant(root ed25519.PublicKey, sg *signedGrant) (*Grant, error) { |
| + if len(root) != ed25519.PublicKeySize { |
| + return nil, fmt.Errorf("key grant: no usable root public key") |
| + } |
| + sig, err := base64.StdEncoding.DecodeString(sg.Sig) |
| + if err != nil { |
| + return nil, fmt.Errorf("key grant sig: %v", err) |
| + } |
| + // A tampered payload and a signature by the wrong key are the same event |
| + // to Ed25519 and deliberately share one message: what matters is that |
| + // neither is honoured, not which one it was. |
| + if !ed25519.Verify(root, []byte(sg.Payload), sig) { |
| + return nil, fmt.Errorf("key grant signature verification failed (not signed by the deployment's root release key)") |
| + } |
| + payload, err := base64.StdEncoding.DecodeString(sg.Payload) |
| + if err != nil { |
| + return nil, fmt.Errorf("key grant payload: %v", err) |
| + } |
| + var g Grant |
| + if err := json.Unmarshal(payload, &g); err != nil { |
| + return nil, fmt.Errorf("key grant payload: %v", err) |
| + } |
| + if err := validateGrant(&g); err != nil { |
| + return nil, err |
| + } |
| + return &g, nil |
| +} |
| + |
| +// validateGrant rejects a grant that parsed but must not be honoured. This is |
| +// the verify-side half of "stable is undelegable": a grant naming stable is |
| +// dead in its entirety, not merely unable to reach stable. |
| +func validateGrant(g *Grant) error { |
| + if g.Schema != 1 { |
| + return fmt.Errorf("unsupported key grant schema %d", g.Schema) |
| + } |
| + if !grantNameRe.MatchString(g.Name) { |
| + return fmt.Errorf("key grant has invalid name %q", g.Name) |
| + } |
| + if _, err := ParsePublicKey(g.Pub, "key grant pub"); err != nil { |
| + return err |
| + } |
| + if len(g.Rungs) == 0 { |
| + return fmt.Errorf("key grant %q names no rungs", g.Name) |
| + } |
| + for _, r := range g.Rungs { |
| + if r == RungStable { |
| + return fmt.Errorf("key grant %q names %s: %s is never delegable — moving it requires the root key itself", g.Name, RungStable, RungStable) |
| + } |
| + if RungIndex(r) < 0 { |
| + return fmt.Errorf("key grant %q names unknown rung %q (%s)", g.Name, r, strings.Join(delegableRungs(), ", ")) |
| + } |
| + } |
| + if g.MintedAt.IsZero() { |
| + return fmt.Errorf("key grant %q has no minted_at", g.Name) |
| + } |
| + if g.ExpiresAt.IsZero() { |
| + return fmt.Errorf("key grant %q has no expires_at", g.Name) |
| + } |
| + if !g.ExpiresAt.After(g.MintedAt) { |
| + return fmt.Errorf("key grant %q expires at or before it was minted", g.Name) |
| + } |
| + if g.ExpiresAt.Sub(g.MintedAt) > maxGrantTTL { |
| + return fmt.Errorf("key grant %q runs %s, longer than the %s maximum", g.Name, g.ExpiresAt.Sub(g.MintedAt), maxGrantTTL) |
| + } |
| + return nil |
| +} |
| + |
| +// allows reports whether this grant covers a channel at a given moment. |
| +// |
| +// `at` is passed in rather than read from the clock, and that is the whole |
| +// design of expiry (spec §5): at verification it is the pointer's OWN |
| +// promoted_at, so a pointer promoted while the grant was valid keeps |
| +// verifying forever — adoption re-verifies every pass, and an expiry that bit |
| +// retroactively would take a running route's channel resolution dark. At |
| +// promotion it is the wall clock, which is where expiry actually gates. |
| +// |
| +// The limitation this accepts, stated plainly: the holder of a scoped key |
| +// controls promoted_at, so it can backdate a pointer to evade its own expiry. |
| +// Expiry is hygiene, not revocation. Revoking a key that is being actively |
| +// misused means rotating the ROOT key, which kills every grant at once. |
| +func (g *Grant) allows(channel string, at time.Time) error { |
| + rung, ok := Rung(channel) |
| + if !ok { |
| + return fmt.Errorf("key grant %q covers rungs (%s); %q is not a rung at all — point it with the root key", |
| + g.Name, strings.Join(g.Rungs, ", "), channel) |
| + } |
| + // The belt to validateGrant's braces. A grant naming stable is refused |
| + // 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 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", |
| + g.Name, strings.Join(g.Rungs, ", "), channel, rung) |
| + } |
| + if at.After(g.ExpiresAt) { |
| + return fmt.Errorf("key grant %q expired at %s, before %s", |
| + g.Name, g.ExpiresAt.Format(time.RFC3339), at.UTC().Format(time.RFC3339)) |
| + } |
| + // The lower bound (2026-08-03 review, IMPORTANT 1): a pointer dated |
| + // before the grant even existed is forged history — no real promotion by |
| + // this key can predate the key. Clock-free and adoption-safe for the same |
| + // reason the upper bound is: a genuine promotion always stamps |
| + // promoted_at at or after minted_at, so nothing legitimately adopted can |
| + // trip this, while a backdated pointer aimed under the grant's floor |
| + // fails closed. |
| + if at.Before(g.MintedAt) { |
| + return fmt.Errorf("key grant %q was minted %s; a pointer dated %s predates the key that signed it", |
| + g.Name, g.MintedAt.Format(time.RFC3339), at.UTC().Format(time.RFC3339)) |
| + } |
| + return nil |
| +} |
| + |
| +func (g *Grant) covers(rung string) bool { |
| + for _, r := range g.Rungs { |
| + if r == rung { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// delegableRungs is the ladder minus stable — what a grant may name, for the |
| +// refusals that have to list the alternatives. |
| +func delegableRungs() []string { |
| + out := make([]string, 0, len(rungs)-1) |
| + for _, r := range rungs { |
| + if r != RungStable { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
diff --git a/internal/release/grant_test.go b/internal/release/grant_test.go| new file mode 100644 |
| index 000000000..ff762cbbe |
| --- /dev/null |
| +++ b/internal/release/grant_test.go |
| @@ -0,0 +1,419 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// mintGrant mints a scoped key + grant with the root key, failing the test on |
| +// error — the happy path most cases start from. |
| +func mintGrant(t *testing.T, root ed25519.PrivateKey, name string, rungs []string, ttl time.Duration) (ed25519.PrivateKey, string) { |
| + t.Helper() |
| + seed, blob, err := MintKeyGrant(root, name, rungs, ttl) |
| + if err != nil { |
| + t.Fatalf("MintKeyGrant(%v): %v", rungs, err) |
| + } |
| + key, err := ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatalf("ParseSigningKey: %v", err) |
| + } |
| + return key, blob |
| +} |
| + |
| +// forgeGrant signs an arbitrary grant payload with signer — the hand-crafted |
| +// grant an attacker (or a root-key holder trying to delegate stable) would |
| +// produce. This is how the verify-side guards are exercised without going |
| +// through MintKeyGrant, whose own refusals would otherwise hide them. |
| +func forgeGrant(t *testing.T, signer ed25519.PrivateKey, g Grant) string { |
| + t.Helper() |
| + payload, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + blob, err := json.MarshalIndent(signedGrant{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(signer, []byte(b64))), |
| + }, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return base64.StdEncoding.EncodeToString(blob) |
| +} |
| + |
| +// goodGrant is a grant that passes every check, as the base for one-field |
| +// mutations. |
| +func goodGrant(pub ed25519.PublicKey) Grant { |
| + now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC) |
| + return Grant{ |
| + Schema: 1, |
| + Pub: base64.StdEncoding.EncodeToString(pub), |
| + Rungs: []string{RungCanary, RungEdge}, |
| + Name: "edge-builder", |
| + MintedAt: now, |
| + ExpiresAt: now.Add(90 * 24 * time.Hour), |
| + } |
| +} |
| + |
| +// The property the whole feature exists for, direction one: not even the |
| +// holder of the root key can mint a grant that reaches stable. |
| +// |
| +// Mutation-mindedness: delete the RungStable check in MintKeyGrant and this |
| +// fails on the first two cases — including the one where stable is smuggled |
| +// in beside a legitimate rung, which a check that only looked at rungs[0] |
| +// would miss. |
| +func TestMintRefusesStable(t *testing.T) { |
| + root, _ := testKeys(t) |
| + for _, rungs := range [][]string{ |
| + {RungStable}, |
| + {RungEdge, RungStable}, |
| + {RungCanary, RungStable, RungBeta}, |
| + } { |
| + _, _, err := MintKeyGrant(root, "edge-builder", rungs, 30*24*time.Hour) |
| + if err == nil { |
| + t.Fatalf("MintKeyGrant(%v) succeeded; stable must never be delegable", rungs) |
| + } |
| + if !strings.Contains(err.Error(), "stable") { |
| + t.Errorf("MintKeyGrant(%v) refusal %q does not name stable", rungs, err) |
| + } |
| + } |
| +} |
| + |
| +// Direction two, enforced separately: a grant that names stable and carries a |
| +// VALID root signature is still refused at verification. Mint and verify are |
| +// two different programs in production — the minting binary may be older than |
| +// the verifying one, or may not have been ours at all. |
| +// |
| +// Mutation-mindedness: delete the RungStable check in validateGrant and this |
| +// goes green while TestMintRefusesStable stays green, which is exactly the |
| +// hole the spec asks to be closed in both directions. |
| +func TestVerifyRefusesAHandCraftedStableGrant(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + g.Rungs = []string{RungEdge, RungStable} |
| + blob := forgeGrant(t, root, g) |
| + |
| + sg, err := parseGrantBlob(blob, "test") |
| + if err != nil { |
| + t.Fatalf("parseGrantBlob: %v", err) |
| + } |
| + got, err := verifyGrant(rootPub, sg) |
| + if err == nil { |
| + t.Fatalf("verifyGrant honoured a stable grant: %+v", got) |
| + } |
| + if !strings.Contains(err.Error(), "stable") { |
| + t.Errorf("refusal %q does not name stable", err) |
| + } |
| +} |
| + |
| +// The second stable guard, in the scope decision itself: even if a grant |
| +// naming stable were somehow honoured, the channel check refuses stable. |
| +// |
| +// Mutation-mindedness: this is the belt to validateGrant's braces. Remove |
| +// either one alone and one of these two tests fails; that is the point of |
| +// having both. |
| +func TestGrantAllowsRefusesStableChannel(t *testing.T) { |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + g.Rungs = []string{RungEdge, RungStable} // as if validateGrant had let it by |
| + err := g.allows("stable", g.MintedAt) |
| + if err == nil { |
| + t.Fatal("grant.allows permitted stable") |
| + } |
| + if !strings.Contains(err.Error(), "stable") { |
| + t.Errorf("refusal %q does not name stable", err) |
| + } |
| +} |
| + |
| +// canary in a grant covers every canary/<branch> channel — the fleet runs on |
| +// canary/* names, so a grant that only matched the literal string "canary" |
| +// would be useless. beta is reachable when granted; a rung outside the grant |
| +// is not; a channel that is not a rung at all is not. |
| +// |
| +// Mutation-mindedness: replace Rung(channel) with a plain string compare and |
| +// the canary/* cases fail; drop the covers() check and the out-of-scope cases |
| +// pass when they must not. |
| +func TestGrantScopeMatchesTheLadderVocabulary(t *testing.T) { |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) // canary, edge |
| + at := g.MintedAt |
| + for _, ch := range []string{"canary", "canary/platform-adoption", "canary/keymail", "canary/console", "canary/rehearsal", "edge"} { |
| + if err := g.allows(ch, at); err != nil { |
| + t.Errorf("grant covering %v refused %q: %v", g.Rungs, ch, err) |
| + } |
| + } |
| + for _, ch := range []string{"beta", "stable", "wip"} { |
| + if err := g.allows(ch, at); err == nil { |
| + t.Errorf("grant covering %v allowed %q", g.Rungs, ch) |
| + } |
| + } |
| + // A grant that names beta reaches beta and nothing above it. |
| + b := goodGrant(scopedPub) |
| + b.Rungs = []string{RungBeta} |
| + if err := b.allows("beta", at); err != nil { |
| + t.Errorf("beta grant refused beta: %v", err) |
| + } |
| + if err := b.allows("edge", at); err == nil { |
| + t.Error("beta-only grant allowed edge") |
| + } |
| +} |
| + |
| +// Expiry is compared against the moment passed in — the pointer's own |
| +// promoted_at at verification, wall clock at promotion — never against |
| +// time.Now() inside the check. |
| +// |
| +// Mutation-mindedness: swap `at` for time.Now() in allows() and the |
| +// "promoted while valid" case fails, because its `at` is a 2026 date and the |
| +// grant it uses expired 90 days after that. |
| +func TestGrantExpiryIsComparedAgainstTheGivenMoment(t *testing.T) { |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + if err := g.allows("edge", g.ExpiresAt.Add(-time.Second)); err != nil { |
| + t.Errorf("grant refused a moment inside its window: %v", err) |
| + } |
| + if err := g.allows("edge", g.ExpiresAt); err != nil { |
| + t.Errorf("grant refused its exact expiry instant: %v", err) |
| + } |
| + err := g.allows("edge", g.ExpiresAt.Add(time.Second)) |
| + if err == nil { |
| + t.Fatal("grant allowed a moment past its expiry") |
| + } |
| + if !strings.Contains(err.Error(), "expired") { |
| + t.Errorf("refusal %q does not say expired", err) |
| + } |
| +} |
| + |
| +// A grant's lifetime is capped at a year, at mint and at verification. A |
| +// root-key holder hand-crafting a ten-year edge key is not an attack the |
| +// scheme can prevent, but it is one it declines to honour: the bound on |
| +// delegation lives in the same place as the bound on what may be delegated. |
| +// |
| +// Mutation-mindedness: raise or delete maxGrantTTL in either place and the |
| +// corresponding half of this test goes green when it must not. |
| +func TestGrantLifetimeIsCappedAtOneYear(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + if _, _, err := MintKeyGrant(root, "edge-builder", []string{RungEdge}, maxGrantTTL+time.Hour); err == nil { |
| + t.Error("MintKeyGrant accepted a lifetime over the cap") |
| + } |
| + if _, _, err := MintKeyGrant(root, "edge-builder", []string{RungEdge}, maxGrantTTL); err != nil { |
| + t.Errorf("MintKeyGrant refused exactly the cap: %v", err) |
| + } |
| + if _, _, err := MintKeyGrant(root, "edge-builder", []string{RungEdge}, 0); err == nil { |
| + t.Error("MintKeyGrant accepted a zero lifetime") |
| + } |
| + if _, _, err := MintKeyGrant(root, "edge-builder", []string{RungEdge}, -time.Hour); err == nil { |
| + t.Error("MintKeyGrant accepted a negative lifetime") |
| + } |
| + |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + g.ExpiresAt = g.MintedAt.Add(maxGrantTTL + time.Hour) |
| + sg, err := parseGrantBlob(forgeGrant(t, root, g), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := verifyGrant(rootPub, sg); err == nil { |
| + t.Error("verifyGrant honoured a grant running longer than the cap") |
| + } |
| +} |
| + |
| +// A grant naming a rung that is not in the frozen vocabulary fails closed — |
| +// a typo must not silently widen or narrow a scope. |
| +// |
| +// Mutation-mindedness: drop the RungIndex check and "prod" is honoured as a |
| +// rung nothing matches, which is a scope nobody can reason about. |
| +func TestGrantRejectsUnknownRungs(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + if _, _, err := MintKeyGrant(root, "edge-builder", []string{"prod"}, time.Hour); err == nil { |
| + t.Error("MintKeyGrant accepted an unknown rung") |
| + } |
| + if _, _, err := MintKeyGrant(root, "edge-builder", nil, time.Hour); err == nil { |
| + t.Error("MintKeyGrant accepted an empty rung list") |
| + } |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + g.Rungs = []string{"prod"} |
| + sg, err := parseGrantBlob(forgeGrant(t, root, g), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := verifyGrant(rootPub, sg); err == nil { |
| + t.Error("verifyGrant honoured an unknown rung") |
| + } |
| +} |
| + |
| +// A grant signed by anything other than the root key the verifier is pinned |
| +// to fails, and says so as a GRANT failure — distinct from a pointer |
| +// signature failure, so an operator can tell which link of the chain broke. |
| +// |
| +// A tampered payload and a wrong signer are cryptographically the same event |
| +// ("this signature does not check out") and deliberately share one message; |
| +// what must never happen is either of them being honoured. |
| +// |
| +// Mutation-mindedness: remove the ed25519.Verify in verifyGrant and both |
| +// cases go green, and anyone able to write a bucket object could mint trust. |
| +func TestGrantSignatureMustBeByTheRootKey(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + notRoot, _ := testKeys(t) |
| + _, scopedPub := testKeys(t) |
| + |
| + // Signed by a key that is not the root. |
| + sg, err := parseGrantBlob(forgeGrant(t, notRoot, goodGrant(scopedPub)), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := verifyGrant(rootPub, sg); err == nil { |
| + t.Fatal("verifyGrant honoured a grant signed by a non-root key") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("refusal %q does not name the grant signature", err) |
| + } |
| + |
| + // Signed by the root, then the payload swapped for a wider scope. |
| + good := goodGrant(scopedPub) |
| + wider := good |
| + wider.Rungs = []string{RungCanary, RungEdge, RungBeta} |
| + tampered, err := parseGrantBlob(forgeGrant(t, root, good), "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + widerPayload, err := json.MarshalIndent(wider, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + tampered.Payload = base64.StdEncoding.EncodeToString(widerPayload) |
| + if _, err := verifyGrant(rootPub, tampered); err == nil { |
| + t.Fatal("verifyGrant honoured a tampered grant payload") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("refusal %q does not name the grant signature", err) |
| + } |
| +} |
| + |
| +// A minted grant round-trips: the blob parses, verifies against the root |
| +// public key, and describes the key that was minted with it. |
| +func TestMintedGrantRoundTrips(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + key, blob := mintGrant(t, root, "edge-builder", []string{RungCanary, RungEdge}, 30*24*time.Hour) |
| + |
| + sg, err := parseGrantBlob(blob, "test") |
| + if err != nil { |
| + t.Fatalf("parseGrantBlob: %v", err) |
| + } |
| + g, err := verifyGrant(rootPub, sg) |
| + if err != nil { |
| + t.Fatalf("verifyGrant: %v", err) |
| + } |
| + if g.Schema != 1 || g.Name != "edge-builder" { |
| + t.Fatalf("grant = %+v", g) |
| + } |
| + want := base64.StdEncoding.EncodeToString(key.Public().(ed25519.PublicKey)) |
| + if g.Pub != want { |
| + t.Fatalf("grant pub = %q, want the minted key's public half %q", g.Pub, want) |
| + } |
| + if got := g.ExpiresAt.Sub(g.MintedAt); got != 30*24*time.Hour { |
| + t.Fatalf("grant lifetime = %v, want 720h", got) |
| + } |
| + if err := g.allows("edge", g.MintedAt); err != nil { |
| + t.Fatalf("minted edge grant refuses edge: %v", err) |
| + } |
| +} |
| + |
| +// The scoped seed is TAGGED. A scoped key and a root key are both 32 random |
| +// bytes, so nothing downstream could otherwise tell them apart — and the one |
| +// mistake that must never pass silently is a scoped seed dropped into the |
| +// root key's slot with no grant beside it. ParseSigningKey accepts the tag |
| +// and an untagged seed alike, so every existing root key keeps parsing. |
| +// |
| +// Mutation-mindedness: drop the prefix from MintKeyGrant's seed and the |
| +// promote-side "grant is missing" refusal loses the only signal it has. |
| +func TestScopedSeedIsTaggedAndStillParses(t *testing.T) { |
| + root, _ := testKeys(t) |
| + seed, _, err := MintKeyGrant(root, "edge-builder", []string{RungEdge}, time.Hour) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(seed, ScopedKeyPrefix) { |
| + t.Fatalf("minted seed %q is not tagged with %q", seed, ScopedKeyPrefix) |
| + } |
| + if !IsScopedSeed(seed) { |
| + t.Fatalf("IsScopedSeed(%q) = false", seed) |
| + } |
| + key, err := ParseSigningKey(seed, "test") |
| + if err != nil { |
| + t.Fatalf("ParseSigningKey on a tagged seed: %v", err) |
| + } |
| + // The same 32 bytes without the tag parse to the same key, and are not |
| + // mistaken for a scoped seed. |
| + plain := strings.TrimPrefix(seed, ScopedKeyPrefix) |
| + if IsScopedSeed(plain) { |
| + t.Error("an untagged seed was reported scoped") |
| + } |
| + same, err := ParseSigningKey(plain, "test") |
| + if err != nil { |
| + t.Fatalf("ParseSigningKey on an untagged seed: %v", err) |
| + } |
| + if !key.Equal(same) { |
| + t.Error("tagged and untagged forms of one seed parsed to different keys") |
| + } |
| +} |
| + |
| +// A grant blob that is not base64, not JSON, or not a grant at all fails with |
| +// a message naming where it came from — the operator pasted the wrong secret. |
| +func TestGrantBlobParseErrorsNameTheSource(t *testing.T) { |
| + for _, blob := range []string{"", "not base64!!", base64.StdEncoding.EncodeToString([]byte("{not json"))} { |
| + _, err := parseGrantBlob(blob, "CARLOS_RELEASE_GRANT") |
| + if err == nil { |
| + t.Fatalf("parseGrantBlob(%q) succeeded", blob) |
| + } |
| + if !strings.Contains(err.Error(), "CARLOS_RELEASE_GRANT") { |
| + t.Errorf("error %q does not name the source", err) |
| + } |
| + } |
| +} |
| + |
| +// A pointer dated before the grant existed is forged history: no real |
| +// promotion by a key can predate the key, so the floor refuses it outright |
| +// (2026-08-03 review, IMPORTANT 1 — before the floor, a scoped key could |
| +// sign a pointer dated years before its own mint and it verified). |
| +// |
| +// Mutation-mindedness: delete the at.Before(g.MintedAt) refusal in allows() |
| +// and the first half of this test goes green when it must not; the second |
| +// half pins that the exact mint instant — the earliest honest promotion — |
| +// stays allowed, so the floor cannot drift into refusing real history. |
| +func TestGrantRefusesAPointerPredatingTheGrant(t *testing.T) { |
| + _, scopedPub := testKeys(t) |
| + g := goodGrant(scopedPub) |
| + err := g.allows("edge", g.MintedAt.Add(-time.Second)) |
| + if err == nil { |
| + t.Fatal("grant honoured a pointer dated before the grant was minted") |
| + } |
| + if !strings.Contains(err.Error(), "predates") { |
| + t.Errorf("refusal %q does not say the pointer predates the key", err) |
| + } |
| + if err := g.allows("edge", g.MintedAt); err != nil { |
| + t.Errorf("grant refused its own mint instant: %v", err) |
| + } |
| +} |
| + |
| +// Duplicate rungs are collapsed at mint (2026-08-03 review, MINOR 6): |
| +// harmless to verify, noise in every listing of the grant forever. |
| +func TestMintDeduplicatesRungs(t *testing.T) { |
| + root, _ := testKeys(t) |
| + rootPub := root.Public().(ed25519.PublicKey) |
| + _, blob := mintGrant(t, root, "dupes", []string{"edge", "edge", "canary", "edge"}, time.Hour) |
| + sg, err := parseGrantBlob(blob, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + g, err := verifyGrant(rootPub, sg) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(g.Rungs) != 2 { |
| + t.Fatalf("rungs = %v, want the two distinct ones", g.Rungs) |
| + } |
| +} |
diff --git a/internal/release/laddercheck_test.go b/internal/release/laddercheck_test.go| index 6b778e15c..182ce0da0 100644 |
| --- a/internal/release/laddercheck_test.go |
| +++ b/internal/release/laddercheck_test.go |
| @@ -1,16 +1,21 @@ |
| package release_test |
| import ( |
| + "context" |
| + "encoding/json" |
| "testing" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| +// liveChannels is what production is running on RIGHT NOW. |
| +var liveChannels = []string{"canary/platform-adoption", "canary/keymail", "canary/console", "canary/rehearsal"} |
| + |
| // The two channel names production is running on RIGHT NOW must never be |
| // refused by the ladder — go.tito.io follows canary/platform-adoption and |
| // keymail.dev follows canary/keymail. If these fail, production cannot deploy. |
| func TestLiveProductionChannelsAreNeverRefused(t *testing.T) { |
| - for _, ch := range []string{"canary/platform-adoption", "canary/keymail", "canary/console", "canary/rehearsal"} { |
| + for _, ch := range liveChannels { |
| rung, ok := release.Rung(ch) |
| if !ok || rung != release.RungCanary { |
| t.Fatalf("Rung(%q) = %q,%v — want canary,true", ch, rung, ok) |
| @@ -36,6 +41,68 @@ func TestEdgeToStableIsRefusedAndNamesTheBypass(t *testing.T) { |
| } |
| } |
| +// The other half of "production can still deploy": every live channel is |
| +// promoted with the ROOT key, and what lands is a wrapper with NO grant in |
| +// it, verifying against the root public key. Scoped signing is opt-in from |
| +// the first byte — nothing about the fleet's existing pointers changes until |
| +// someone deliberately mints a scoped key. |
| +// |
| +// Mutation-mindedness: make the grant field non-optional, attach an empty |
| +// grant to root-signed pointers, or route root signing through grant |
| +// handling, and this fails on the key list — before any signature check, and |
| +// for every channel production actually runs on. |
| +func TestLiveProductionChannelsStayRootSignedWithNoGrant(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + seedB64, pubB64, err := release.GenerateKey() |
| + if err != nil { |
| + t.Fatalf("GenerateKey: %v", err) |
| + } |
| + priv, err := release.ParseSigningKey(seedB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pub, err := release.ParsePublicKey(pubB64, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + m := release.Manifest{ |
| + App: "kass", Version: "9f3c1ad", Kind: "binary", |
| + Artifacts: map[string]release.Artifact{"linux-arm64": {}}, |
| + } |
| + if err := release.Ship(ctx, s, release.OpsAccount, m, map[string][]byte{"linux-arm64": []byte("build")}); err != nil { |
| + t.Fatalf("Ship: %v", err) |
| + } |
| + |
| + for _, ch := range liveChannels { |
| + if err := release.Promote(ctx, s, priv, release.OpsAccount, "kass", ch, "9f3c1ad"); err != nil { |
| + t.Fatalf("Promote %s: %v", ch, err) |
| + } |
| + raw, _, err := s.Get(ctx, release.ChannelKey(release.OpsAccount, "kass", ch)) |
| + if err != nil { |
| + t.Fatalf("read %s: %v", ch, err) |
| + } |
| + var keys map[string]json.RawMessage |
| + if err := json.Unmarshal(raw, &keys); err != nil { |
| + t.Fatalf("%s: %v", ch, err) |
| + } |
| + if len(keys) != 2 || keys["payload"] == nil || keys["sig"] == nil { |
| + t.Errorf("%s wrapper has keys %v, want exactly payload and sig", ch, keysOf(keys)) |
| + } |
| + if _, err := release.Channel(ctx, s, pub, release.OpsAccount, "kass", ch); err != nil { |
| + t.Errorf("%s does not verify against the root pubkey: %v", ch, err) |
| + } |
| + } |
| +} |
| + |
| +func keysOf(m map[string]json.RawMessage) []string { |
| + out := make([]string, 0, len(m)) |
| + for k := range m { |
| + out = append(out, k) |
| + } |
| + return out |
| +} |
| + |
| func contains(s, sub string) bool { |
| for i := 0; i+len(sub) <= len(s); i++ { |
| if s[i:i+len(sub)] == sub { |
diff --git a/internal/release/qualified_test.go b/internal/release/qualified_test.go| index ea53feed1..946087ac6 100644 |
| --- a/internal/release/qualified_test.go |
| +++ b/internal/release/qualified_test.go |
| @@ -176,7 +176,7 @@ func TestLegacyOnlyAppIsInvisibleToTheRuntime(t *testing.T) { |
| if _, err := dir.Put(ctx, LegacyManifestKey("old", "v1"), raw, ""); err != nil { |
| t.Fatal(err) |
| } |
| - ptr, err := signPointer(priv, &ChannelPointer{App: "old", Channel: "stable", Version: "v1"}) |
| + ptr, err := signPointer(RootSigner(priv), &ChannelPointer{App: "old", Channel: "stable", Version: "v1"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
| @@ -523,7 +523,7 @@ func TestPromoteDoesNotInheritALegacyPointer(t *testing.T) { |
| shipRelease(t, s, "kqmv", "myapp", "v1", "linux-amd64", []byte("v1")) |
| shipRelease(t, s, "kqmv", "myapp", "v2", "linux-amd64", []byte("v2")) |
| - legacy, err := signPointer(priv, &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"}) |
| + legacy, err := signPointer(RootSigner(priv), &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"}) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/release/release.go b/internal/release/release.go| index 9964b9cea..e67ac5ab9 100644 |
| --- a/internal/release/release.go |
| +++ b/internal/release/release.go |
| @@ -241,25 +241,47 @@ func validateManifest(m *Manifest) error { |
| type signedWrapper struct { |
| Payload string `json:"payload"` |
| Sig string `json:"sig"` |
| + // Grant is ABSENT for a root-signed pointer — omitempty on a nil pointer, |
| + // so the bytes a root key writes are byte-identical to the bytes it wrote |
| + // before scoped keys existed (golden_test.go pins exactly that). When |
| + // present, Sig above is by the SCOPED key and this is the root's |
| + // signature over the statement authorising it. |
| + // |
| + // An old binary reading a new grant-bearing wrapper fails signature |
| + // verification, because it checks Sig against the root key. That is the |
| + // correct fail-closed direction and it sets the deploy order: binaries |
| + // first, scoped keys second. |
| + Grant *signedGrant `json:"grant,omitempty"` |
| } |
| // signPointer stamps PromotedAt (now) and wraps the pointer in the signed |
| -// wire format. |
| -func signPointer(priv ed25519.PrivateKey, p *ChannelPointer) ([]byte, error) { |
| +// wire format, attaching the signer's grant when it has one. |
| +// |
| +// The scope check lives here as well as at the top of the promote verbs: this |
| +// is the single place a pointer becomes signed bytes, so a caller that |
| +// forgets to ask cannot produce a scope-violating pointer even by accident. |
| +func signPointer(sg Signer, p *ChannelPointer) ([]byte, error) { |
| + if err := sg.usable(); err != nil { |
| + return nil, err |
| + } |
| p.Schema = 1 |
| p.PromotedAt = time.Now().UTC().Truncate(time.Second) |
| if err := validatePointer(p, p.App, p.Channel); err != nil { |
| return nil, err |
| } |
| + if err := sg.allows(p.Channel, p.PromotedAt); err != nil { |
| + return nil, err |
| + } |
| payload, err := json.MarshalIndent(p, "", " ") |
| if err != nil { |
| return nil, err |
| } |
| b64 := base64.StdEncoding.EncodeToString(payload) |
| - sig := ed25519.Sign(priv, []byte(b64)) |
| + sig := ed25519.Sign(sg.key, []byte(b64)) |
| return json.MarshalIndent(signedWrapper{ |
| Payload: b64, |
| Sig: base64.StdEncoding.EncodeToString(sig), |
| + Grant: sg.grant, |
| }, "", " ") |
| } |
| @@ -268,16 +290,47 @@ func signPointer(priv ed25519.PrivateKey, p *ChannelPointer) ([]byte, error) { |
| // signature. wantApp/wantChannel are matched against the SIGNED payload, so |
| // a validly-signed pointer copied from another channel's key (a cross-channel |
| // replay) is rejected. |
| +// |
| +// pub is the deployment's ROOT public key, and it is the only key a box is |
| +// ever configured with. A wrapper with no grant was signed by that key |
| +// directly — the shape the whole fleet is on. A wrapper WITH a grant was |
| +// signed by a scoped key, and the grant is the root's signature saying which |
| +// rungs that key may reach; so the chain is checked root -> grant -> pointer, |
| +// and then the pointer's own channel and promoted_at are checked against the |
| +// scope. Both scope checks read the SIGNED payload, never the caller's |
| +// arguments — a grant that is honoured on a channel the payload does not name |
| +// would be no scope at all. |
| +// |
| +// This is the single parse site for channel pointers, and it must stay that |
| +// way: every consumer (Channel, adoption, VersionRung, the console, the edge) |
| +// inherits grant handling by funnelling through here. |
| func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel string) (*ChannelPointer, error) { |
| var sw signedWrapper |
| if err := json.Unmarshal(wrapper, &sw); err != nil { |
| return nil, fmt.Errorf("pointer wrapper: %v", err) |
| } |
| + // signer is who must have signed the payload: the root itself, or the |
| + // scoped key the root delegated to. |
| + signer, scope := pub, (*Grant)(nil) |
| + if sw.Grant != nil { |
| + g, err := verifyGrant(pub, sw.Grant) |
| + if err != nil { |
| + return nil, err |
| + } |
| + gpub, err := ParsePublicKey(g.Pub, "key grant pub") |
| + if err != nil { |
| + return nil, err |
| + } |
| + signer, scope = gpub, g |
| + } |
| sig, err := base64.StdEncoding.DecodeString(sw.Sig) |
| if err != nil { |
| return nil, fmt.Errorf("pointer sig: %v", err) |
| } |
| - if !ed25519.Verify(pub, []byte(sw.Payload), sig) { |
| + if len(signer) != ed25519.PublicKeySize { |
| + return nil, fmt.Errorf("channel pointer: no usable release public key") |
| + } |
| + if !ed25519.Verify(signer, []byte(sw.Payload), sig) { |
| return nil, fmt.Errorf("channel pointer signature verification failed") |
| } |
| payload, err := base64.StdEncoding.DecodeString(sw.Payload) |
| @@ -291,6 +344,15 @@ func verifyPointer(pub ed25519.PublicKey, wrapper []byte, wantApp, wantChannel s |
| if err := validatePointer(&p, wantApp, wantChannel); err != nil { |
| return nil, err |
| } |
| + if scope != nil { |
| + // promoted_at, not the wall clock: adoption re-verifies every pass, so |
| + // comparing expiry against now would make an expired grant take a |
| + // running route's channel resolution dark long after the promotion it |
| + // was meant to gate (spec §5). |
| + if err := scope.allows(p.Channel, p.PromotedAt); err != nil { |
| + return nil, fmt.Errorf("channel pointer %s/%s: %w", p.App, p.Channel, err) |
| + } |
| + } |
| return &p, nil |
| } |
| @@ -349,8 +411,15 @@ func GenerateKey() (seedB64, pubB64 string, err error) { |
| // ParseSigningKey turns a base64 Ed25519 seed — from CARLOS_RELEASE_KEY — |
| // into the signing key. src only names the origin in the error. |
| +// |
| +// A scoped key's seed carries the ScopedKeyPrefix tag, which is stripped |
| +// here: the bytes are an Ed25519 seed either way, and every root key already |
| +// in a secret store keeps parsing exactly as it did. Whether the tag was |
| +// there is a question for the caller (IsScopedSeed), which is the one that |
| +// knows whether a grant is required. |
| func ParseSigningKey(b64, src string) (ed25519.PrivateKey, error) { |
| - seed, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64)) |
| + seed, err := base64.StdEncoding.DecodeString( |
| + strings.TrimPrefix(strings.TrimSpace(b64), ScopedKeyPrefix)) |
| if err != nil || len(seed) != ed25519.SeedSize { |
| return nil, fmt.Errorf("%s: expected a base64 %d-byte Ed25519 seed", src, ed25519.SeedSize) |
| } |
| @@ -463,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) |
| } |
| @@ -488,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 |
| @@ -531,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(priv, next) |
| + wrapper, err := signPointer(sg, next) |
| if err != nil { |
| return err |
| } |
| @@ -582,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) |
| @@ -595,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. |
| @@ -645,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 |
| @@ -707,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 { |
| @@ -735,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(priv, next) |
| + wrapper, err := signPointer(sg, next) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/release/release_test.go b/internal/release/release_test.go| index 235666e50..a4385b872 100644 |
| --- a/internal/release/release_test.go |
| +++ b/internal/release/release_test.go |
| @@ -267,7 +267,7 @@ func TestSignStampsPromotedAt(t *testing.T) { |
| priv, pub := testKeys(t) |
| p := &ChannelPointer{App: "myapp", Channel: "stable", Version: "v1"} |
| before := time.Now().Add(-time.Second) |
| - raw, err := signPointer(priv, p) |
| + raw, err := signPointer(RootSigner(priv), p) |
| if err != nil { |
| t.Fatalf("signPointer: %v", 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") |
| + } |
| +} |
diff --git a/internal/release/scopedverify_test.go b/internal/release/scopedverify_test.go| new file mode 100644 |
| index 000000000..90510874a |
| --- /dev/null |
| +++ b/internal/release/scopedverify_test.go |
| @@ -0,0 +1,404 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "encoding/json" |
| + "reflect" |
| + "sort" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +// The grant-signed wire shape, pinned beside the root-signed one. Captured |
| +// with deterministic seeds (root = bytes 1..32, scoped = bytes 101..132) and |
| +// frozen timestamps: a grant minted 2026-08-03T09:00Z for canary+edge, |
| +// 90 days, over an edge pointer promoted half an hour later. |
| +// |
| +// The root public key is goldenRootPub — the SAME anchor as the root-signed |
| +// fixture, which is the point of the whole scheme: one CARLOS_RELEASE_PUBKEY |
| +// verifies both shapes. |
| +const goldenGrantSignedWrapper = `{ |
| + "payload": "ewogICJzY2hlbWEiOiAxLAogICJhcHAiOiAia2FzcyIsCiAgImNoYW5uZWwiOiAiZWRnZSIsCiAgInZlcnNpb24iOiAiOWYzYzFhZCIsCiAgInByb21vdGVkX2F0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIiwKICAiYXVkaXQiOiBbCiAgICB7CiAgICAgICJ2ZXJzaW9uIjogIjlmM2MxYWQiLAogICAgICAidG8iOiAiZWRnZSIsCiAgICAgICJhY3RvciI6ICJidWlsZGVyQGNpIiwKICAgICAgImF0IjogIjIwMjYtMDgtMDNUMDk6MzA6MDBaIgogICAgfQogIF0KfQ==", |
| + "sig": "oXZdFZDT5ul4RlKG3/VgSmhJjKBshdontRLT5ytm6IsB8MbAnoDicG4T2cVIN0pML2DsJUNhYEIcjg9eZvW2Dw==", |
| + "grant": { |
| + "payload": "ewogICJzY2hlbWEiOiAxLAogICJwdWIiOiAiMmlucFd3TGdEL29WWkZkMSt4MHJvaUtobERPVjdxQnJsT0xBVjdlK2FkQT0iLAogICJydW5ncyI6IFsKICAgICJjYW5hcnkiLAogICAgImVkZ2UiCiAgXSwKICAibmFtZSI6ICJlZGdlLWJ1aWxkZXIiLAogICJtaW50ZWRfYXQiOiAiMjAyNi0wOC0wM1QwOTowMDowMFoiLAogICJleHBpcmVzX2F0IjogIjIwMjYtMTEtMDFUMDk6MDA6MDBaIgp9", |
| + "sig": "MlawPu9BYwUcYr2VhAfPqgj2SRWzfDSIeSGxbW+3noo+xB36gSiPo3Aqa4ZNZ5TZvOx+Vjv+mlg/+Rsb1wYZAw==" |
| + } |
| +}` |
| + |
| +// rootPubKey is the fixture's root anchor, parsed. |
| +func rootPubKey(t *testing.T) ed25519.PublicKey { |
| + t.Helper() |
| + pub, err := ParsePublicKey(goldenRootPub, "golden") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return pub |
| +} |
| + |
| +// scopedFixture returns a fresh root key, its public half, a scoped key |
| +// covering rungs, and a Signer built from them — the setup nearly every |
| +// scoped test needs. |
| +func scopedFixture(t *testing.T, rungs []string, ttl time.Duration) (root ed25519.PrivateKey, rootPub ed25519.PublicKey, sg Signer) { |
| + t.Helper() |
| + root, rootPub = testKeys(t) |
| + key, blob := mintGrant(t, root, "edge-builder", rungs, ttl) |
| + sg, err := ScopedSigner(key, blob, rootPub) |
| + if err != nil { |
| + t.Fatalf("ScopedSigner: %v", err) |
| + } |
| + return root, rootPub, sg |
| +} |
| + |
| +// signAs wraps a pointer with a given signer, without a store. The pointer's |
| +// promoted_at is signPointer's own stamp, so these are pointers as they would |
| +// really be written. |
| +func signAs(t *testing.T, sg Signer, app, channel, version string) []byte { |
| + t.Helper() |
| + w, err := signPointer(sg, &ChannelPointer{App: app, Channel: channel, Version: version}) |
| + if err != nil { |
| + t.Fatalf("signPointer(%s): %v", channel, err) |
| + } |
| + return w |
| +} |
| + |
| +// The grant-signed shape, pinned: three top-level keys, the grant a two-key |
| +// sub-object, and the whole thing verifying against the SAME root public key |
| +// the fleet already has configured. |
| +// |
| +// Mutation-mindedness: rename or restructure any wrapper/grant field and this |
| +// fails on the key list before the signature check even runs — which is what |
| +// stops a "harmless" JSON tag edit from orphaning pointers already in |
| +// production buckets. |
| +func TestGoldenGrantSignedWrapperShapeAndVerification(t *testing.T) { |
| + var top map[string]json.RawMessage |
| + if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &top); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := make([]string, 0, len(top)) |
| + for k := range top { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"grant", "payload", "sig"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant-signed wrapper keys = %v, want %v", got, want) |
| + } |
| + var sub map[string]json.RawMessage |
| + if err := json.Unmarshal(top["grant"], &sub); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = got[:0] |
| + for k := range sub { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"payload", "sig"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant sub-object keys = %v, want %v", got, want) |
| + } |
| + |
| + p, err := verifyPointer(rootPubKey(t), []byte(goldenGrantSignedWrapper), "kass", "edge") |
| + if err != nil { |
| + t.Fatalf("golden grant-signed pointer does not verify: %v", err) |
| + } |
| + if p.Version != "9f3c1ad" || p.Channel != "edge" { |
| + t.Fatalf("pointer = %+v", p) |
| + } |
| + |
| + // The grant payload's own field names, pinned: they are wire, and a |
| + // verifier that silently read zero values out of a renamed field would |
| + // treat an expiring grant as one that never expires. |
| + var sgw signedWrapper |
| + if err := json.Unmarshal([]byte(goldenGrantSignedWrapper), &sgw); err != nil { |
| + t.Fatal(err) |
| + } |
| + raw, err := base64.StdEncoding.DecodeString(sgw.Grant.Payload) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var gk map[string]json.RawMessage |
| + if err := json.Unmarshal(raw, &gk); err != nil { |
| + t.Fatal(err) |
| + } |
| + got = got[:0] |
| + for k := range gk { |
| + got = append(got, k) |
| + } |
| + sort.Strings(got) |
| + if want := []string{"expires_at", "minted_at", "name", "pub", "rungs", "schema"}; !reflect.DeepEqual(got, want) { |
| + t.Fatalf("grant payload keys = %v, want %v", got, want) |
| + } |
| +} |
| + |
| +// In scope verifies; out of scope does not — at the verify end, where it has |
| +// to hold even if the promoting binary was compromised, patched, or simply |
| +// older than this one. |
| +// |
| +// Mutation-mindedness: delete the scope.allows call in verifyPointer and the |
| +// beta case passes, which is a builder key placing builds on beta. |
| +func TestVerifyEnforcesGrantScope(t *testing.T) { |
| + _, rootPub, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 90*24*time.Hour) |
| + |
| + for _, ch := range []string{"canary/keymail", "edge"} { |
| + w := signAs(t, sg, "kass", ch, "9f3c1ad") |
| + if _, err := verifyPointer(rootPub, w, "kass", ch); err != nil { |
| + t.Errorf("in-scope channel %q did not verify: %v", ch, err) |
| + } |
| + } |
| + |
| + // Out of scope: sign it anyway, bypassing signPointer's own refusal, and |
| + // prove the VERIFIER refuses it. The promote-side guard and this one are |
| + // independent — an attacker holding the scoped key does not run our |
| + // promote path. |
| + for _, ch := range []string{"beta", "stable"} { |
| + w := forgePointerAs(t, sg, "kass", ch, "9f3c1ad", time.Now().UTC().Truncate(time.Second)) |
| + _, err := verifyPointer(rootPub, w, "kass", ch) |
| + if err == nil { |
| + t.Fatalf("out-of-scope channel %q verified", ch) |
| + } |
| + if !strings.Contains(err.Error(), "key grant") { |
| + t.Errorf("refusal for %q = %q, want it to name the key grant", ch, err) |
| + } |
| + } |
| +} |
| + |
| +// forgePointerAs signs a pointer with the signer's key and grant while |
| +// skipping signPointer's scope check, and stamps an arbitrary promoted_at. |
| +// This is the hostile promoter: it holds the scoped key and will not ask |
| +// permission before signing. |
| +func forgePointerAs(t *testing.T, sg Signer, app, channel, version string, at time.Time) []byte { |
| + t.Helper() |
| + p := &ChannelPointer{Schema: 1, App: app, Channel: channel, Version: version, PromotedAt: at} |
| + payload, err := json.MarshalIndent(p, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + w, err := json.MarshalIndent(signedWrapper{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(sg.key, []byte(b64))), |
| + Grant: sg.grant, |
| + }, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return w |
| +} |
| + |
| +// Expiry is a promotion-time gate and nothing else. A pointer promoted while |
| +// the grant was valid verifies forever — adoption re-verifies every pass, and |
| +// a pointer that stopped verifying at midnight would take a running route's |
| +// channel resolution dark for a reason that has nothing to do with the build. |
| +// A pointer promoted AFTER expiry never verifies at all. |
| +// |
| +// Mutation-mindedness: replace p.PromotedAt with time.Now() in verifyPointer's |
| +// scope.allows call and the first case fails. The grant used here expired in |
| +// 2025, long before this test can ever run, and its pointer must still |
| +// resolve — that is only true if expiry is read from the pointer. |
| +func TestExpiryGatesPromotionNotAdoption(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + pub, _ := key.Public().(ed25519.PublicKey) |
| + |
| + // A grant that lived for the first quarter of 2025 and is long dead now. |
| + g := Grant{ |
| + Schema: 1, |
| + Pub: base64.StdEncoding.EncodeToString(pub), |
| + Rungs: []string{RungEdge}, |
| + Name: "edge-builder", |
| + MintedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), |
| + ExpiresAt: time.Date(2025, 4, 1, 0, 0, 0, 0, time.UTC), |
| + } |
| + sg := Signer{key: key, grant: forgeGrantSub(t, root, g), scope: &g, root: rootPub} |
| + |
| + // Promoted while the grant was valid, read today: still resolves. This is |
| + // the property adoption depends on — it re-verifies every pass, and a |
| + // pointer that went dark on the grant's expiry date would take a running |
| + // route's channel resolution with it. |
| + w := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)) |
| + if _, err := verifyPointer(rootPub, w, "kass", "edge"); err != nil { |
| + t.Fatalf("a pointer promoted while the grant was valid stopped verifying: %v", err) |
| + } |
| + |
| + // Promoted one second after expiry: dead, and it says why. |
| + late := forgePointerAs(t, sg, "kass", "edge", "9f3c1ad", g.ExpiresAt.Add(time.Second)) |
| + _, err := verifyPointer(rootPub, late, "kass", "edge") |
| + if err == nil { |
| + t.Fatal("a pointer promoted after the grant expired verified") |
| + } |
| + if !strings.Contains(err.Error(), "expired") { |
| + t.Errorf("refusal %q does not say expired", err) |
| + } |
| + |
| + // And the promote side refuses to sign with it at all, now. |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("an expired grant signed a new pointer") |
| + } |
| +} |
| + |
| +// The three ways a grant-bearing wrapper can be malformed, each failing |
| +// closed, and each naming the link of the chain that broke: a grant the root |
| +// did not sign, a grant whose payload was swapped after signing, and a grant |
| +// that is perfectly valid but authorises a DIFFERENT key than the one that |
| +// signed the pointer. |
| +// |
| +// The first two share a message on purpose — Ed25519 cannot distinguish a |
| +// tampered payload from a wrong signer, and pretending otherwise would be a |
| +// lie in an error string. The third is a genuinely different failure and says |
| +// so: the grant is fine, the pointer is not. |
| +// |
| +// Mutation-mindedness: drop the grant signature check and the first two pass; |
| +// verify the pointer against `pub` instead of the granted key and the third |
| +// passes, which would let any grant launder any signature. |
| +func TestGrantChainFailuresAreDistinct(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + notRoot, _ := testKeys(t) |
| + key, blob := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + sg, err := ScopedSigner(key, blob, rootPub) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + good := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| + |
| + // (1) grant signed by a key that is not the root. |
| + forged := withGrant(t, good, forgeGrantSub(t, notRoot, *sg.scope)) |
| + if _, err := verifyPointer(rootPub, forged, "kass", "edge"); err == nil { |
| + t.Error("a grant signed by a non-root key was honoured") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("non-root grant refusal = %q", err) |
| + } |
| + |
| + // (2) grant payload swapped for a wider scope after the root signed it. |
| + wider := *sg.scope |
| + wider.Rungs = []string{RungCanary, RungEdge, RungBeta} |
| + sub := *forgeGrantSub(t, root, *sg.scope) |
| + widerPayload, err := json.MarshalIndent(wider, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + sub.Payload = base64.StdEncoding.EncodeToString(widerPayload) |
| + tampered := withGrant(t, good, &sub) |
| + if _, err := verifyPointer(rootPub, tampered, "kass", "edge"); err == nil { |
| + t.Error("a tampered grant payload was honoured") |
| + } else if !strings.Contains(err.Error(), "key grant signature") { |
| + t.Errorf("tampered grant refusal = %q", err) |
| + } |
| + |
| + // (3) a valid grant for someone ELSE's key, attached to a pointer this |
| + // key signed. The grant verifies; the pointer signature must not. |
| + otherKey, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| + _ = otherKey |
| + otherSub, err := parseGrantBlob(otherBlob, "test") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mismatched := withGrant(t, good, otherSub) |
| + if _, err := verifyPointer(rootPub, mismatched, "kass", "edge"); err == nil { |
| + t.Error("a grant for a different key was honoured") |
| + } else if !strings.Contains(err.Error(), "channel pointer signature") { |
| + t.Errorf("mismatched-key refusal = %q, want it to name the POINTER signature", err) |
| + } |
| +} |
| + |
| +// forgeGrantSub signs an arbitrary grant with signer and returns the wrapper |
| +// sub-object. |
| +func forgeGrantSub(t *testing.T, signer ed25519.PrivateKey, g Grant) *signedGrant { |
| + t.Helper() |
| + payload, err := json.MarshalIndent(g, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + b64 := base64.StdEncoding.EncodeToString(payload) |
| + return &signedGrant{ |
| + Payload: b64, |
| + Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(signer, []byte(b64))), |
| + } |
| +} |
| + |
| +// withGrant swaps the grant sub-object of a wrapper, leaving payload and sig |
| +// untouched. |
| +func withGrant(t *testing.T, wrapper []byte, sub *signedGrant) []byte { |
| + t.Helper() |
| + var sw signedWrapper |
| + if err := json.Unmarshal(wrapper, &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.Grant = sub |
| + return mustJSON(t, sw) |
| +} |
| + |
| +func mustJSON(t *testing.T, v any) []byte { |
| + t.Helper() |
| + b, err := json.MarshalIndent(v, "", " ") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +// A pointer signed by a scoped key but stripped of its grant is refused: the |
| +// signature is not the root's. This is also what an OLD binary does with a |
| +// new grant-bearing wrapper — it ignores the field it does not know and |
| +// checks the sig against the root key — so the fail-closed deploy order |
| +// (binaries first, scoped keys second) is a property, not a hope. |
| +// |
| +// Mutation-mindedness: default `signer` to anything but the root pubkey when |
| +// there is no grant and this passes, which would accept unsigned-by-root |
| +// pointers on the entire live fleet. |
| +func TestScopedPointerWithoutItsGrantIsRefused(t *testing.T) { |
| + _, rootPub, sg := scopedFixture(t, []string{RungEdge}, 30*24*time.Hour) |
| + w := signAs(t, sg, "kass", "edge", "9f3c1ad") |
| + |
| + var sw signedWrapper |
| + if err := json.Unmarshal(w, &sw); err != nil { |
| + t.Fatal(err) |
| + } |
| + sw.Grant = nil |
| + if _, err := verifyPointer(rootPub, mustJSON(t, sw), "kass", "edge"); err == nil { |
| + t.Fatal("a scoped signature was accepted as a root signature") |
| + } |
| +} |
| + |
| +// A scoped signer refuses to SIGN out of scope, independently of anything the |
| +// promote verbs check. signPointer is the one place a pointer becomes signed |
| +// bytes, so this is the floor under every caller present and future. |
| +// |
| +// Mutation-mindedness: remove the allows() call from signPointer and this |
| +// fails; it is what makes "a caller forgot to check" not a vulnerability. |
| +func TestSignPointerRefusesOutOfScope(t *testing.T) { |
| + _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 30*24*time.Hour) |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "stable", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("a canary/edge signer signed stable") |
| + } |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "beta", Version: "9f3c1ad"}); err == nil { |
| + t.Fatal("a canary/edge signer signed beta") |
| + } |
| + if _, err := signPointer(sg, &ChannelPointer{App: "kass", Channel: "edge", Version: "9f3c1ad"}); err != nil { |
| + t.Fatalf("a canary/edge signer refused edge: %v", err) |
| + } |
| +} |
| + |
| +// ScopedSigner refuses a grant that does not authorise the key it is handed — |
| +// the operator pasted one builder's grant beside another builder's seed. It |
| +// fails when the Signer is BUILT, not at the first promote, because a signer |
| +// that cannot prove its own authority should not exist. |
| +// |
| +// Mutation-mindedness: drop the g.Pub comparison and this passes; the signer |
| +// would then produce pointers that every verifier rejects, turning a clear |
| +// local error into a mystery in production. |
| +func TestScopedSignerRefusesAGrantForAnotherKey(t *testing.T) { |
| + root, rootPub := testKeys(t) |
| + key, _ := mintGrant(t, root, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + _, otherBlob := mintGrant(t, root, "other-builder", []string{RungEdge}, 30*24*time.Hour) |
| + |
| + if _, err := ScopedSigner(key, otherBlob, rootPub); err == nil { |
| + t.Fatal("ScopedSigner accepted a grant for a different key") |
| + } |
| + // And it refuses a grant that does not chain to THIS deployment's root. |
| + elsewhere, _ := testKeys(t) |
| + otherKey, otherRootBlob := mintGrant(t, elsewhere, "edge-builder", []string{RungEdge}, 30*24*time.Hour) |
| + if _, err := ScopedSigner(otherKey, otherRootBlob, rootPub); err == nil { |
| + t.Fatal("ScopedSigner accepted a grant signed by another deployment's root key") |
| + } |
| +} |
diff --git a/internal/release/signer.go b/internal/release/signer.go| new file mode 100644 |
| index 000000000..9c411f711 |
| --- /dev/null |
| +++ b/internal/release/signer.go |
| @@ -0,0 +1,85 @@ |
| +package release |
| + |
| +import ( |
| + "crypto/ed25519" |
| + "encoding/base64" |
| + "fmt" |
| + "time" |
| +) |
| + |
| +// Signer is a release signing identity: a private key, and — when that key is |
| +// scoped — the root-signed grant that says which rungs it may reach. |
| +// |
| +// It also carries the deployment's ROOT public key, which is the verification |
| +// anchor for pointers being REPLACED. That used to be the signing key's own |
| +// public half, and for a root signer it still is; for a scoped signer it |
| +// cannot be, because the pointer a scoped key is about to replace was almost |
| +// certainly signed by something else (the root key, or another scoped key). |
| +// One anchor, both directions. |
| +type Signer struct { |
| + key ed25519.PrivateKey |
| + // grant is the sub-object attached to every wrapper this signer writes; |
| + // nil for a root signer, which attaches nothing and so writes the exact |
| + // bytes it wrote before scoped keys existed. |
| + grant *signedGrant |
| + // scope is grant's verified contents — nil for a root signer, which may |
| + // sign any channel. |
| + scope *Grant |
| + root ed25519.PublicKey |
| +} |
| + |
| +// RootSigner is today's identity: the deployment's root key, unrestricted. |
| +func RootSigner(key ed25519.PrivateKey) Signer { |
| + pub, _ := key.Public().(ed25519.PublicKey) |
| + return Signer{key: key, root: pub} |
| +} |
| + |
| +// ScopedSigner is a delegated identity: a scoped key plus the base64 grant |
| +// blob that authorises it (CARLOS_RELEASE_GRANT), checked here against the |
| +// deployment's root public key. The grant is verified at construction — a |
| +// signer that cannot prove its own authority must not exist long enough to |
| +// sign anything. |
| +func ScopedSigner(key ed25519.PrivateKey, grantB64 string, root ed25519.PublicKey) (Signer, error) { |
| + if len(key) != ed25519.PrivateKeySize { |
| + return Signer{}, fmt.Errorf("scoped signing: CARLOS_RELEASE_KEY is not a signing key") |
| + } |
| + if len(root) != ed25519.PublicKeySize { |
| + return Signer{}, fmt.Errorf("scoped signing needs the deployment's root public key in CARLOS_RELEASE_PUBKEY: a scoped key's grant is only meaningful against the key the fleet trusts") |
| + } |
| + sg, err := parseGrantBlob(grantB64, "CARLOS_RELEASE_GRANT") |
| + if err != nil { |
| + return Signer{}, err |
| + } |
| + g, err := verifyGrant(root, sg) |
| + if err != nil { |
| + return Signer{}, err |
| + } |
| + pub, _ := key.Public().(ed25519.PublicKey) |
| + if g.Pub != base64.StdEncoding.EncodeToString(pub) { |
| + return Signer{}, fmt.Errorf("key grant %q is for a different key than CARLOS_RELEASE_KEY holds", g.Name) |
| + } |
| + return Signer{key: key, grant: sg, scope: g, root: root}, nil |
| +} |
| + |
| +// verifyPub is the anchor this signer verifies EXISTING pointers against: the |
| +// root public key, whether this signer is the root or a delegate of it. |
| +func (s Signer) verifyPub() ed25519.PublicKey { return s.root } |
| + |
| +// allows reports whether this signer may sign for a channel at a moment. A |
| +// root signer may sign anything, which is the property scoped keys exist to |
| +// take away from everything else. |
| +func (s Signer) allows(channel string, at time.Time) error { |
| + if s.scope == nil { |
| + return nil |
| + } |
| + return s.scope.allows(channel, at) |
| +} |
| + |
| +// usable rejects a zero Signer before it reaches ed25519.Sign, which panics |
| +// on a wrong-sized key. |
| +func (s Signer) usable() error { |
| + if len(s.key) != ed25519.PrivateKeySize { |
| + return fmt.Errorf("no usable release signing key") |
| + } |
| + return nil |
| +} |
diff --git a/main.go b/main.go| index 379e3e791..eee1262a7 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -98,6 +98,11 @@ commands: |
| ship | promote | channels publish and promote releases (-account <sqid|name>) |
| promote climbs the ladder canary -> edge -> beta |
| -> stable; -hotfix is the recorded bypass |
| + release-keygen mint the deployment's root signing key, or with |
| + -scope <rungs> -name <label> [-expires <dur>] a |
| + SCOPED key that can only sign those rungs |
| + (stable is never delegable; needs the root key |
| + in CARLOS_RELEASE_KEY to sign the grant) |
| adopt run one release-adoption pass (the hourly timer's verb) |
| canary ship this worktree to an ephemeral canary channel |
| bootstrap prepare a host (units, dirs, user) |
diff --git a/scopedkeys_e2e_test.go b/scopedkeys_e2e_test.go| new file mode 100644 |
| index 000000000..044b96012 |
| --- /dev/null |
| +++ b/scopedkeys_e2e_test.go |
| @@ -0,0 +1,170 @@ |
| +package main |
| + |
| +import ( |
| + "os" |
| + "os/exec" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// The scoped signing key as an operator meets it: two modes of one verb, and |
| +// a promote that signs with whatever CARLOS_RELEASE_KEY holds. Driven through |
| +// the real binary, because the whole feature is about which secret is sitting |
| +// in which variable on which machine — a thing the functions underneath |
| +// cannot be wrong about on their own. |
| +func TestScopedKeysCLI(t *testing.T) { |
| + bin := buildCarlos(t) |
| + bucket := t.TempDir() |
| + |
| + run := func(env []string, args ...string) (string, error) { |
| + t.Helper() |
| + cmd := exec.Command(bin, args...) |
| + cmd.Env = append(os.Environ(), |
| + append([]string{ |
| + "CARLOS_DEPLOYMENT_DIR=" + bucket, |
| + "CARLOS_DATA_DIR=" + t.TempDir(), |
| + "CARLOS_CREDENTIALS=" + filepath.Join(t.TempDir(), "credentials"), |
| + }, env...)...) |
| + out, err := cmd.CombinedOutput() |
| + return string(out), err |
| + } |
| + |
| + // -- root keygen, unchanged ------------------------------------------ |
| + // |
| + // Mutation-mindedness: any change to the no-flag output breaks every |
| + // deployment's bootstrap instructions, so it is asserted key by key. |
| + out, err := run(nil, "release-keygen") |
| + if err != nil { |
| + t.Fatalf("release-keygen: %v\n%s", err, out) |
| + } |
| + rootKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| + rootPub := envValue(t, out, "CARLOS_RELEASE_PUBKEY") |
| + if release.IsScopedSeed(rootKey) { |
| + t.Fatal("the root key came out tagged as scoped") |
| + } |
| + rootEnv := []string{"CARLOS_RELEASE_KEY=" + rootKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| + |
| + // -name/-expires without -scope are refused rather than silently |
| + // ignored: an operator who typed them meant to mint a scoped key, and |
| + // walking away with a ROOT key they think is scoped is the worst |
| + // possible outcome of this verb. |
| + if out, err := run(rootEnv, "release-keygen", "-name", "edge-builder"); err == nil { |
| + t.Fatalf("release-keygen -name without -scope minted a root key:\n%s", out) |
| + } |
| + |
| + // -- scoped keygen --------------------------------------------------- |
| + out, err = run(rootEnv, "release-keygen", "-scope", "canary,edge", "-name", "edge-builder") |
| + if err != nil { |
| + t.Fatalf("release-keygen -scope: %v\n%s", err, out) |
| + } |
| + scopedKey := envValue(t, out, "CARLOS_RELEASE_KEY") |
| + grant := envValue(t, out, "CARLOS_RELEASE_GRANT") |
| + if !release.IsScopedSeed(scopedKey) { |
| + t.Fatalf("scoped keygen printed an untagged key %q", scopedKey) |
| + } |
| + // The fleet's anchor is not reprinted as an assignment. A second |
| + // copy-pasteable CARLOS_RELEASE_PUBKEY= line is an invitation to install |
| + // it on a box, which would give the deployment two trust anchors and |
| + // defeat the entire scheme. (The prose guidance names the variable to say |
| + // it does NOT change; that is the opposite of a footgun.) |
| + for _, line := range strings.Split(out, "\n") { |
| + if strings.HasPrefix(strings.TrimSpace(line), "CARLOS_RELEASE_PUBKEY=") { |
| + t.Fatalf("scoped keygen printed a public key assignment:\n%s", out) |
| + } |
| + } |
| + if !strings.Contains(out, "edge-builder") { |
| + t.Fatalf("scoped keygen does not name the key it minted:\n%s", out) |
| + } |
| + |
| + // stable is refused at mint, by the verb, naming stable. |
| + if out, err := run(rootEnv, "release-keygen", "-scope", "edge,stable", "-name", "bad"); err == nil { |
| + t.Fatalf("release-keygen -scope edge,stable succeeded:\n%s", out) |
| + } else if !strings.Contains(out, "stable") { |
| + t.Fatalf("refusal does not name stable:\n%s", out) |
| + } |
| + |
| + // Minting needs the ROOT key: a scoped key cannot widen itself. |
| + scopedOnly := []string{"CARLOS_RELEASE_KEY=" + scopedKey, "CARLOS_RELEASE_PUBKEY=" + rootPub} |
| + if out, err := run(scopedOnly, "release-keygen", "-scope", "beta", "-name", "wider"); err == nil { |
| + t.Fatalf("a scoped key minted a grant:\n%s", out) |
| + } |
| + |
| + // -- promoting with the scoped key ----------------------------------- |
| + art := filepath.Join(t.TempDir(), "kass") |
| + if err := os.WriteFile(art, []byte("build one"), 0o755); err != nil { |
| + t.Fatal(err) |
| + } |
| + if out, err := run(rootEnv, "ship", "-app", "kass", "-version", "9f3c1ad", art); err != nil { |
| + t.Fatalf("ship: %v\n%s", err, out) |
| + } |
| + |
| + scopedEnv := append(append([]string{}, scopedOnly...), "CARLOS_RELEASE_GRANT="+grant) |
| + if out, err := run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "edge"); err != nil { |
| + t.Fatalf("scoped promote to edge: %v\n%s", err, out) |
| + } |
| + // It verifies for a reader holding only the ROOT public key — which is |
| + // every box in the fleet. |
| + out, err = run(rootEnv, "channels", "-app", "kass") |
| + if err != nil { |
| + t.Fatalf("channels: %v\n%s", err, out) |
| + } |
| + if !strings.Contains(out, "edge") || !strings.Contains(out, "9f3c1ad") { |
| + t.Fatalf("channels does not show the scope-signed pointer:\n%s", out) |
| + } |
| + |
| + // -- what the scoped key may not do ---------------------------------- |
| + // |
| + // stable is refused locally and nothing is written. The bucket check is |
| + // the load-bearing half: an error message is easy, a channel pointer that |
| + // never appeared is the property. |
| + out, err = run(scopedEnv, "promote", "-app", "kass", "9f3c1ad", "stable") |
| + if err == nil { |
| + t.Fatalf("the edge key promoted to stable:\n%s", out) |
| + } |
| + for _, want := range []string{"edge-builder", "stable"} { |
| + if !strings.Contains(out, want) { |
| + t.Errorf("refusal does not name %q:\n%s", want, out) |
| + } |
| + } |
| + if _, err := os.Stat(filepath.Join(bucket, "apps", release.OpsAccount, "kass", "channels", "stable.json")); !os.IsNotExist(err) { |
| + t.Fatalf("a stable pointer exists after a refused promote (stat err = %v)", err) |
| + } |
| + |
| + // -hotfix bypasses the ladder, never the grant. |
| + if out, err := run(scopedEnv, "promote", "-app", "kass", "-hotfix", "9f3c1ad", "stable"); err == nil { |
| + t.Fatalf("-hotfix let the edge key reach stable:\n%s", out) |
| + } |
| + |
| + // A scoped key with no grant beside it fails HERE, naming the grant, |
| + // rather than signing something the fleet will silently refuse later. |
| + out, err = run(scopedOnly, "promote", "-app", "kass", "9f3c1ad", "edge") |
| + if err == nil { |
| + t.Fatalf("a scoped key promoted with no grant:\n%s", out) |
| + } |
| + if !strings.Contains(out, "CARLOS_RELEASE_GRANT") { |
| + t.Fatalf("refusal does not name the missing grant:\n%s", out) |
| + } |
| + |
| + // -- and the root key is still unrestricted -------------------------- |
| + if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "beta"); err != nil { |
| + t.Fatalf("root promote to beta: %v\n%s", err, out) |
| + } |
| + if out, err := run(rootEnv, "promote", "-app", "kass", "9f3c1ad", "stable"); err != nil { |
| + t.Fatalf("root promote to stable: %v\n%s", err, out) |
| + } |
| +} |
| + |
| +// envValue pulls VAR=value out of a verb's stdout. |
| +func envValue(t *testing.T, out, key string) string { |
| + t.Helper() |
| + for _, line := range strings.Split(out, "\n") { |
| + if v, ok := strings.CutPrefix(strings.TrimSpace(line), key+"="); ok { |
| + return v |
| + } |
| + } |
| + t.Fatalf("no %s in output:\n%s", key, out) |
| + return "" |
| +} |