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>
3 files changed,
+672
−1
internal/release/grant.go+288 −0internal/release/grant_test.go+376 −0internal/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) |
| } |