carlos / platform Public

merge: scoped signing keys — a key that can reach edge cannot touch stable

Grants are minted by the root key and travel inside the signed wrapper:
the fleet's trust anchor stays one pubkey, no registry, no bucket-stored
trust. stable is refused in three independent places (mint,
validateGrant, allows) and the reviewer could not construct a grant that
reached it — case tricks, nesting confusion, empty scopes, duplicates,
and -hotfix all fail, because hotfix bypasses ordering only: scope is
authority, not process. Expiry gates promotion without ever darkening an
adopted pointer, is stated plainly as evadable by the key's own holder
(hygiene, not revocation), and now has its floor — a pointer dated
before the grant's mint is forged history.

Old wrappers verify byte-identically (golden fixture captured before any
code change); new grant-signed wrappers fail closed on old binaries,
which sets the deploy order: binaries first, scoped keys second.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell b60b18afa15840be1414fdd355ea875bf617f8a5 parent f1cc914parent 8091c6f
15 files changed, +2078 −24
  • cli.go +106 −5
  • cmd/console/main.go +9 −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 b5adf5c8f..88872fe60 100644
--- a/cli.go
+++ b/cli.go
@@ -742,6 +742,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 {
@@ -824,7 +862,7 @@ func runPromote(args []string) {
log.Fatal("usage: carlos promote -app <a> [-hotfix] <version> <channel>")
}
version, channel := fs.Arg(0), fs.Arg(1)
- 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 {
@@ -891,19 +929,82 @@ func runRollback(args []string) {
if *app == "" || fs.NArg() != 1 {
log.Fatal("usage: carlos rollback -app <a> <channel>")
}
- 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.Printf("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-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 ""
+}