// 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/ 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 }