carlos / platform Public

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>
Paul Campbell f1cc9146130071668e7ca5b1e1f1c5ff0dc1024b parent eced477
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.