carlos / platform Public

release: the key grant — root-signed, rung-scoped, stable never

A grant binds a scoped public key to a set of ladder rungs and an expiry,
signed by the root key. It is public material and travels in the clear;
the fleet's trust anchor stays one CARLOS_RELEASE_PUBKEY, with no
registry and nothing in the bucket a bucket-writer could turn into trust.

stable is undelegable in both directions, enforced twice on the verify
side as well as at mint. MintKeyGrant refuses to write it; validateGrant
kills a grant that names it outright; and grant.allows refuses the stable
rung independently, so losing either verify-side check alone still leaves
stable unreachable. Each has its own test, and each was mutation-checked
by deleting it and watching exactly one test go red.

Expiry takes the moment to compare against as an argument rather than
reading the clock — that is what lets verification use the pointer's own
promoted_at later, so an adopted pointer never goes dark retroactively.
The limitation is written down where the code is: the key holder controls
promoted_at, so expiry is hygiene and revocation is a root rotation.

Lifetime is capped at a year at mint and at verification, beside the
stable check, on the same reasoning: a root-key holder can sign anything,
but this scheme declines to honour an unbounded delegation.

The scoped seed is tagged `scoped:`. A scoped key and a root key are both
32 random bytes, and the one mistake that must not pass silently is a
scoped seed in the root key's slot with no grant beside it.
ParseSigningKey strips the tag, so every root key in a secret store today
parses exactly as before.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 9afb4d5e26e56a5778173da898f22e98a65f0013 parent 0335085
3 files changed, +672 −1
  • internal/release/grant.go +288 −0
  • internal/release/grant_test.go +376 −0
  • internal/release/release.go +8 −1
diff --git a/internal/release/grant.go b/internal/release/grant.go
new file mode 100644
index 000000000..f4b422abb
--- /dev/null
+++ b/internal/release/grant.go
@@ -0,0 +1,288 @@
+// 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.
+ 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(), ", "))
+ }
+ }
+ 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 cannot reach %s: %s is never delegable — moving it requires the root key itself",
+ g.Name, RungStable, 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))
+ }
+ 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..ec1c64f9f
--- /dev/null
+++ b/internal/release/grant_test.go
@@ -0,0 +1,376 @@
+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)
+ }
+ }
+}
diff --git a/internal/release/release.go b/internal/release/release.go
index 9964b9cea..c6ad897a7 100644
--- a/internal/release/release.go
+++ b/internal/release/release.go
@@ -349,8 +349,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)
}