carlos / platform Public

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>
Paul Campbell fa41fc9c108432996d86d1118fdce4b3f2805f87 parent c6a2646parent a8b4ad4
16 files changed, +2098 −24
  • cli.go +106 −5
  • cmd/console/main.go +9 −0
  • docs/superpowers/specs/2026-08-01-carlos-framework-design.md +20 −0
  • docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md +8 −0
  • internal/release/golden_test.go +143 −0
  • internal/release/grant.go +308 −0
  • internal/release/grant_test.go +419 −0
  • internal/release/laddercheck_test.go +68 −1
  • internal/release/qualified_test.go +2 −2
  • internal/release/release.go +126 −15
  • internal/release/release_test.go +1 −1
  • internal/release/scopedpromote_test.go +224 −0
  • internal/release/scopedverify_test.go +404 −0
  • internal/release/signer.go +85 −0
  • main.go +5 −0
  • scopedkeys_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 ""
+}