spec: scoped signing keys — a key that can reach edge cannot touch stable
The edge rung needs an identity that signs for the bottom rungs only, so compromising a builder cannot move 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 undelegable in both directions (mint refuses, verify refuses), expiry gates promotion but never retroactively darkens an adopted pointer, and old binaries fail closed on grant-signed wrappers, which sets the deploy order.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 file changed,
+149
−0
docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md+149 −0
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| new file mode 100644 |
| index 000000000..ace630d33 |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-03-scoped-signing-keys-design.md |
| @@ -0,0 +1,149 @@ |
| +# Scoped signing keys — a key that can reach edge cannot touch stable |
| + |
| +**Status:** approved 2026-08-03 (Paul: "go on scoped signing keys"). |
| +**Extends:** the pipelines design (2026-08-02, §3 the ladder, §6 what edge |
| +requires) and the release signing scheme in `internal/release`. |
| + |
| +## 1. Why |
| + |
| +One Ed25519 key signs every channel pointer on a deployment. Whoever holds it |
| +can move `stable`. That is acceptable while every signer is a human reading |
| +from SSM, and unacceptable the moment anything automatic signs — which is |
| +exactly what the `edge` rung requires (build-on-merge promotes with no human |
| +in the loop). The pipelines spec named it: *"an identity that can sign for |
| +edge and ONLY edge, so that compromising CI cannot place a build on stable."* |
| + |
| +Scoping is the platform-native answer to the CI question: once a key can be |
| +limited to the bottom rungs, WHERE builds run becomes a per-deployment |
| +preference (a `carlos builder`, a rented runner, a laptop) rather than a |
| +trust decision. |
| + |
| +## 2. The model |
| + |
| +A deployment has a **root release key** (today's key, unchanged) and any |
| +number of **scoped keys**, each restricted to a set of rungs. |
| + |
| +- The root key signs anything, exactly as today. Existing deployments, |
| + pointers, and workflows change nothing. |
| +- A scoped key is minted BY the root key: the root signs a **key grant** — |
| + a statement binding the scoped public key to its allowed rungs. |
| +- A pointer signed by a scoped key travels with its grant; verifiers check |
| + the grant's signature against the root public key they already have, then |
| + the pointer's signature against the granted key, then that the pointer's |
| + channel falls inside the granted rungs. |
| + |
| +This keeps the fleet's trust anchor SINGULAR: boxes still configure exactly |
| +one `CARLOS_RELEASE_PUBKEY`. No key registry, no bucket-stored trust state |
| +(a bucket-writer must never be able to mint trust), no second distribution |
| +channel. The grant is self-contained and travels inside the signed wrapper. |
| + |
| +## 3. Wire shape (additive) |
| + |
| +The signed wrapper gains one optional field: |
| + |
| +```json |
| +{ |
| + "payload": "<b64 pointer payload>", |
| + "sig": "<b64 signature by the SIGNING key>", |
| + "grant": { // ABSENT for root-signed pointers |
| + "payload": "<b64 grant payload>", |
| + "sig": "<b64 signature by the ROOT key>" |
| + } |
| +} |
| +``` |
| + |
| +Grant payload (JSON, schema 1): |
| + |
| +```json |
| +{ |
| + "schema": 1, |
| + "pub": "<b64 scoped Ed25519 public key>", |
| + "rungs": ["canary", "edge"], |
| + "name": "edge-builder", |
| + "minted_at": "2026-08-03T...Z", |
| + "expires_at": "2026-11-03T...Z" |
| +} |
| +``` |
| + |
| +Rules: |
| + |
| +- `rungs` uses the frozen ladder vocabulary (`release.Rung*`); a grant naming |
| + an unknown rung fails verification closed. `canary` in a grant covers every |
| + `canary/*` channel. |
| +- **`stable` may never appear in a grant.** Not "shouldn't": `MintKeyGrant` |
| + refuses to create one and verification refuses to honour one, so even a |
| + root-key holder cannot delegate stable — moving stable always requires the |
| + root key itself. This is the property the whole feature exists for; it is |
| + enforced in both directions so a hand-crafted grant is as dead as a |
| + mis-minted one. |
| +- `expires_at` is required, max 1 year out. Verification fails closed on an |
| + expired grant — an expired grant is a *promotion-time* failure only; |
| + pointers already written and verified stay adopted (adoption re-verifies, |
| + so see §5). |
| +- A pointer whose channel is outside the grant's rungs fails verification. |
| +- A wrapper with a grant whose `pub` does not verify the pointer `sig`, or |
| + whose grant `sig` does not verify against the root pubkey, fails. |
| +- Absent `grant` = root-signed = any channel. Old wrappers parse unchanged; |
| + old binaries reading a NEW wrapper with a grant will fail signature |
| + verification (the sig is by the scoped key, not the root) — which is the |
| + correct fail-closed direction, and means scoped keys are only usable once |
| + the fleet's binaries understand grants. Deploy order: binary first, |
| + scoped keys second. |
| + |
| +## 4. Verbs |
| + |
| +``` |
| +carlos release-keygen -scope edge[,canary] -name <label> [-expires <dur>] |
| +``` |
| + |
| +Reads the ROOT key (same env/SSM discipline as promote — never printed), |
| +mints a fresh scoped keypair + grant, prints the scoped SEED and the grant |
| +blob for the operator to store wherever the builder's secrets live. The |
| +grant is not secret (it contains only public material); the seed is. |
| + |
| +`carlos promote` gains nothing: it signs with whatever `CARLOS_RELEASE_KEY` |
| +holds. If that is a scoped seed, the accompanying `CARLOS_RELEASE_GRANT` |
| +(the grant blob, base64) is attached to the wrapper. Promotion to a channel |
| +outside the grant fails LOCALLY with a clear error before any write — |
| +matching the ladder's refusal style. |
| + |
| +## 5. Verification surface |
| + |
| +One function change: `verifyPointer` grows grant handling. Its callers — |
| +`Channel`, adoption, `VersionRung`, the console, the edge — inherit it, so |
| +the consumer list is the parse sites, all already funneled through |
| +`verifyPointer` (checked; that funnel is why this is a small change). |
| + |
| +Adoption re-verifies pointers each pass. An expired grant therefore makes an |
| +already-adopted pointer STOP verifying on the next pass, which would take a |
| +running route's channel resolution dark. That is wrong: expiry is a |
| +promotion-time gate, not a retroactive revocation. So `verifyPointer` takes |
| +the pointer's own `promoted_at` as "now" for the expiry check — a pointer |
| +promoted while the grant was valid stays valid forever; a new promotion with |
| +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.) |
| + |
| +## 6. Non-goals |
| + |
| +- Per-app or per-account scoping (rung-only for v1; the grant schema's |
| + versioning leaves room). |
| +- A grant/key registry or console UI (list what exists by listing your |
| + secret store; the platform deliberately holds no inventory). |
| +- Root key rotation tooling (documented as manual incident response). |
| +- The `carlos builder` itself — separate spec; this is its prerequisite. |
| + |
| +## 7. Testing |
| + |
| +- Golden wrapper vectors: root-signed (no grant) and grant-signed, so the |
| + wire shape cannot drift. |
| +- Matrix: grant-signed pointer inside scope verifies; outside scope fails; |
| + expired-at-promotion fails; promoted-before-expiry verifies after expiry; |
| + tampered grant fails; grant by a non-root key fails; `stable` in a grant |
| + refused at mint AND at verify (both directions, separately). |
| +- Promote-time local refusal (channel outside scope) names the scope. |
| +- Old wrapper (no grant) still verifies — pinned against a fixture captured |
| + from a real production pointer shape. |
| +- e2e: mint a scoped edge key, promote to edge with it, adopt succeeds; |
| + attempt stable with it, refused locally; hand-craft a stable grant, verify |
| + refuses. |