carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Scoped signing keys — a key that can reach edge cannot touch stable
2// (spec docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md).
3//
4// A deployment has ONE root release key, exactly as before. A scoped key is
5// minted by the root key, which signs a KEY GRANT: a statement binding the
6// scoped public key to a set of ladder rungs and an expiry. A pointer signed
7// by a scoped key travels with its grant inside the signed wrapper, so the
8// fleet's trust anchor stays a single CARLOS_RELEASE_PUBKEY — no key
9// registry, and nothing in the bucket that a bucket-writer could turn into
10// trust.
11//
12// stable is never delegable. MintKeyGrant refuses to write it into a grant
13// and verification refuses to honour one that names it, so even the holder of
14// the root key cannot hand out the ability to move stable; moving stable
15// always requires the root key itself. Both directions are enforced
16// separately, because mint and verify are different programs on different
17// machines at different versions.
18package release
19
20import (
21 "crypto/ed25519"
22 "crypto/rand"
23 "encoding/base64"
24 "encoding/json"
25 "fmt"
26 "regexp"
27 "strings"
28 "time"
29)
30
31// maxGrantTTL bounds how long a delegated key lives. A scoped key exists so
32// that a compromised builder is a bounded problem; an unbounded grant gives
33// that back. The cap is enforced at mint AND at verification — a hand-crafted
34// decade-long grant is not an attack the scheme can prevent (it takes the
35// root key to sign one) but it is one it declines to honour, in the same
36// place it declines to honour stable.
37const maxGrantTTL = 365 * 24 * time.Hour
38
39// ScopedKeyPrefix tags the seed of a scoped key. A scoped seed and a root
40// seed are both 32 random bytes, so without a tag nothing downstream can tell
41// them apart — and the mistake that must never pass silently is a scoped seed
42// sitting in CARLOS_RELEASE_KEY with no CARLOS_RELEASE_GRANT beside it, which
43// would sign pointers that nothing in the fleet will ever accept.
44// ParseSigningKey accepts tagged and untagged seeds alike, so every root key
45// in a secret store today keeps working untouched.
46const ScopedKeyPrefix = "scoped:"
47
48// IsScopedSeed reports whether a CARLOS_RELEASE_KEY value was minted as a
49// scoped key. Callers use it to insist on a grant.
50func IsScopedSeed(seed string) bool {
51 return strings.HasPrefix(strings.TrimSpace(seed), ScopedKeyPrefix)
52}
53
54// Grant is the root-signed statement that a scoped public key may sign
55// channel pointers for a set of rungs, until a date. It is not secret: it
56// contains only public material, and it travels in the clear inside every
57// wrapper the scoped key signs.
58type Grant struct {
59 Schema int `json:"schema"` // 1
60 Pub string `json:"pub"` // base64 Ed25519 public key of the scoped key
61 // Rungs uses the frozen ladder vocabulary (RungCanary, RungEdge,
62 // RungBeta). "canary" covers every canary/<branch> channel. RungStable
63 // may never appear.
64 Rungs []string `json:"rungs"`
65 Name string `json:"name"` // operator's label, for error messages
66 MintedAt time.Time `json:"minted_at"`
67 ExpiresAt time.Time `json:"expires_at"`
68}
69
70// signedGrant is the wrapper's grant sub-object: the ROOT key's signature
71// over the exact base64 grant payload string. Same discipline as the pointer
72// itself — verify the string, decode after, no JSON canonicalisation to get
73// wrong.
74type signedGrant struct {
75 Payload string `json:"payload"`
76 Sig string `json:"sig"`
77}
78
79// grantNameRe keeps a grant's label printable and short: it appears in
80// refusals an operator reads at 3am, not in a path.
81var grantNameRe = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$`)
82
83// MintKeyGrant mints a fresh scoped keypair and the root-signed grant that
84// authorises it. It returns the TAGGED seed (the secret, for the builder's
85// secret store, as CARLOS_RELEASE_KEY) and the base64 grant blob (not secret,
86// as CARLOS_RELEASE_GRANT). The deployment's CARLOS_RELEASE_PUBKEY does not
87// change: the fleet still trusts exactly one key, and this one chains to it.
88func MintKeyGrant(root ed25519.PrivateKey, name string, rungs []string, ttl time.Duration) (seed, grantB64 string, err error) {
89 if len(root) != ed25519.PrivateKeySize {
90 return "", "", fmt.Errorf("mint key grant: need the root signing key")
91 }
92 if !grantNameRe.MatchString(name) {
93 return "", "", fmt.Errorf("mint key grant: invalid name %q (letters, digits, . _ - ; up to 64)", name)
94 }
95 if ttl <= 0 || ttl > maxGrantTTL {
96 return "", "", fmt.Errorf("mint key grant: lifetime %s is outside (0, %s]", ttl, maxGrantTTL)
97 }
98 // Checked here as well as in validateGrant so the refusal names the flag
99 // the operator typed rather than the document they never saw.
100 seen := map[string]bool{}
101 deduped := rungs[:0:0]
102 for _, r := range rungs {
103 if r == RungStable {
104 return "", "", fmt.Errorf("mint key grant: %s is never delegable — moving stable requires the root key itself", RungStable)
105 }
106 if RungIndex(r) < 0 {
107 return "", "", fmt.Errorf("mint key grant: %q is not a rung (%s)", r, strings.Join(delegableRungs(), ", "))
108 }
109 // Deduped at mint (2026-08-03 review, MINOR 6): a repeated rung is
110 // harmless to verify but noise in every grant listing forever.
111 if !seen[r] {
112 seen[r] = true
113 deduped = append(deduped, r)
114 }
115 }
116 rungs = deduped
117 pub, priv, err := ed25519.GenerateKey(rand.Reader)
118 if err != nil {
119 return "", "", err
120 }
121 now := time.Now().UTC().Truncate(time.Second)
122 g := Grant{
123 Schema: 1,
124 Pub: base64.StdEncoding.EncodeToString(pub),
125 Rungs: append([]string(nil), rungs...),
126 Name: name,
127 MintedAt: now,
128 ExpiresAt: now.Add(ttl).Truncate(time.Second),
129 }
130 // Mint through the verifier's own validation, so a grant this binary
131 // writes is one this binary would accept. A mint that can produce a
132 // document verification rejects is a footgun with a signature on it.
133 if err := validateGrant(&g); err != nil {
134 return "", "", fmt.Errorf("mint key grant: %w", err)
135 }
136 payload, err := json.MarshalIndent(g, "", " ")
137 if err != nil {
138 return "", "", err
139 }
140 b64 := base64.StdEncoding.EncodeToString(payload)
141 blob, err := json.MarshalIndent(signedGrant{
142 Payload: b64,
143 Sig: base64.StdEncoding.EncodeToString(ed25519.Sign(root, []byte(b64))),
144 }, "", " ")
145 if err != nil {
146 return "", "", err
147 }
148 return ScopedKeyPrefix + base64.StdEncoding.EncodeToString(priv.Seed()),
149 base64.StdEncoding.EncodeToString(blob), nil
150}
151
152// parseGrantBlob decodes the base64 grant blob an operator carries in
153// CARLOS_RELEASE_GRANT. It parses only — nothing here is trusted until
154// verifyGrant has checked the root signature. src names the origin in errors,
155// because the usual cause is the wrong secret in the right variable.
156func parseGrantBlob(b64, src string) (*signedGrant, error) {
157 raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(b64))
158 if err != nil {
159 return nil, fmt.Errorf("%s: expected a base64 key grant: %v", src, err)
160 }
161 var sg signedGrant
162 if err := json.Unmarshal(raw, &sg); err != nil {
163 return nil, fmt.Errorf("%s: key grant: %v", src, err)
164 }
165 if sg.Payload == "" || sg.Sig == "" {
166 return nil, fmt.Errorf("%s: key grant has no payload/sig", src)
167 }
168 return &sg, nil
169}
170
171// verifyGrant checks a grant's signature against the ROOT public key the
172// verifier is pinned to, and only then decodes and validates it — everything
173// the caller acts on came from under the root's signature.
174func verifyGrant(root ed25519.PublicKey, sg *signedGrant) (*Grant, error) {
175 if len(root) != ed25519.PublicKeySize {
176 return nil, fmt.Errorf("key grant: no usable root public key")
177 }
178 sig, err := base64.StdEncoding.DecodeString(sg.Sig)
179 if err != nil {
180 return nil, fmt.Errorf("key grant sig: %v", err)
181 }
182 // A tampered payload and a signature by the wrong key are the same event
183 // to Ed25519 and deliberately share one message: what matters is that
184 // neither is honoured, not which one it was.
185 if !ed25519.Verify(root, []byte(sg.Payload), sig) {
186 return nil, fmt.Errorf("key grant signature verification failed (not signed by the deployment's root release key)")
187 }
188 payload, err := base64.StdEncoding.DecodeString(sg.Payload)
189 if err != nil {
190 return nil, fmt.Errorf("key grant payload: %v", err)
191 }
192 var g Grant
193 if err := json.Unmarshal(payload, &g); err != nil {
194 return nil, fmt.Errorf("key grant payload: %v", err)
195 }
196 if err := validateGrant(&g); err != nil {
197 return nil, err
198 }
199 return &g, nil
200}
201
202// validateGrant rejects a grant that parsed but must not be honoured. This is
203// the verify-side half of "stable is undelegable": a grant naming stable is
204// dead in its entirety, not merely unable to reach stable.
205func validateGrant(g *Grant) error {
206 if g.Schema != 1 {
207 return fmt.Errorf("unsupported key grant schema %d", g.Schema)
208 }
209 if !grantNameRe.MatchString(g.Name) {
210 return fmt.Errorf("key grant has invalid name %q", g.Name)
211 }
212 if _, err := ParsePublicKey(g.Pub, "key grant pub"); err != nil {
213 return err
214 }
215 if len(g.Rungs) == 0 {
216 return fmt.Errorf("key grant %q names no rungs", g.Name)
217 }
218 for _, r := range g.Rungs {
219 if r == RungStable {
220 return fmt.Errorf("key grant %q names %s: %s is never delegable — moving it requires the root key itself", g.Name, RungStable, RungStable)
221 }
222 if RungIndex(r) < 0 {
223 return fmt.Errorf("key grant %q names unknown rung %q (%s)", g.Name, r, strings.Join(delegableRungs(), ", "))
224 }
225 }
226 if g.MintedAt.IsZero() {
227 return fmt.Errorf("key grant %q has no minted_at", g.Name)
228 }
229 if g.ExpiresAt.IsZero() {
230 return fmt.Errorf("key grant %q has no expires_at", g.Name)
231 }
232 if !g.ExpiresAt.After(g.MintedAt) {
233 return fmt.Errorf("key grant %q expires at or before it was minted", g.Name)
234 }
235 if g.ExpiresAt.Sub(g.MintedAt) > maxGrantTTL {
236 return fmt.Errorf("key grant %q runs %s, longer than the %s maximum", g.Name, g.ExpiresAt.Sub(g.MintedAt), maxGrantTTL)
237 }
238 return nil
239}
240
241// allows reports whether this grant covers a channel at a given moment.
242//
243// `at` is passed in rather than read from the clock, and that is the whole
244// design of expiry (spec §5): at verification it is the pointer's OWN
245// promoted_at, so a pointer promoted while the grant was valid keeps
246// verifying forever — adoption re-verifies every pass, and an expiry that bit
247// retroactively would take a running route's channel resolution dark. At
248// promotion it is the wall clock, which is where expiry actually gates.
249//
250// The limitation this accepts, stated plainly: the holder of a scoped key
251// controls promoted_at, so it can backdate a pointer to evade its own expiry.
252// Expiry is hygiene, not revocation. Revoking a key that is being actively
253// misused means rotating the ROOT key, which kills every grant at once.
254func (g *Grant) allows(channel string, at time.Time) error {
255 rung, ok := Rung(channel)
256 if !ok {
257 return fmt.Errorf("key grant %q covers rungs (%s); %q is not a rung at all — point it with the root key",
258 g.Name, strings.Join(g.Rungs, ", "), channel)
259 }
260 // The belt to validateGrant's braces. A grant naming stable is refused
261 // outright there; if that check were ever lost, this one still means no
262 // grant reaches stable.
263 if rung == RungStable {
264 return fmt.Errorf("key grant %q covers %s and cannot reach %q: %s is never delegable — moving it requires the root key itself",
265 g.Name, strings.Join(g.Rungs, ", "), channel, RungStable)
266 }
267 if !g.covers(rung) {
268 return fmt.Errorf("key grant %q covers %s; channel %q is on rung %s",
269 g.Name, strings.Join(g.Rungs, ", "), channel, rung)
270 }
271 if at.After(g.ExpiresAt) {
272 return fmt.Errorf("key grant %q expired at %s, before %s",
273 g.Name, g.ExpiresAt.Format(time.RFC3339), at.UTC().Format(time.RFC3339))
274 }
275 // The lower bound (2026-08-03 review, IMPORTANT 1): a pointer dated
276 // before the grant even existed is forged history — no real promotion by
277 // this key can predate the key. Clock-free and adoption-safe for the same
278 // reason the upper bound is: a genuine promotion always stamps
279 // promoted_at at or after minted_at, so nothing legitimately adopted can
280 // trip this, while a backdated pointer aimed under the grant's floor
281 // fails closed.
282 if at.Before(g.MintedAt) {
283 return fmt.Errorf("key grant %q was minted %s; a pointer dated %s predates the key that signed it",
284 g.Name, g.MintedAt.Format(time.RFC3339), at.UTC().Format(time.RFC3339))
285 }
286 return nil
287}
288
289func (g *Grant) covers(rung string) bool {
290 for _, r := range g.Rungs {
291 if r == rung {
292 return true
293 }
294 }
295 return false
296}
297
298// delegableRungs is the ladder minus stable — what a grant may name, for the
299// refusals that have to list the alternatives.
300func delegableRungs() []string {
301 out := make([]string, 0, len(rungs)-1)
302 for _, r := range rungs {
303 if r != RungStable {
304 out = append(out, r)
305 }
306 }
307 return out
308}
309