# PRD 0079: Per-service signing keys for control-plane auth - **Status:** Active - **Author:** claude - **Created:** 2026-07-26 - **Issue:** #476 ## Summary Provision control-plane signing keys **per service**, through one shared seam, so no service can mint another's credentials. Concretely: the orchestrator holds the control-plane key and mints the gateway's and CLI's tokens; the host controller (#468, next) gets a **separate** key the orchestrator never holds — so the orchestrator cannot forge the credentials it uses to talk to the host controller that starts and stops it. Landing this seam also retires the per-backend provisioning duplication that made PR #471 take three review rounds. ## Problem **1. The orchestrator could forge host-controller credentials.** The orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host controller (#468) is a third role, `host`, on that same key. But then the orchestrator — which holds the key — can mint `host` tokens, and the host controller, which owns the orchestrator's lifecycle, must not trust anything the orchestrator can mint. The two services need separate keys. **2. Every backend provisioned auth by hand.** Each launcher (docker gateway/infra, macOS infra, firecracker infra) re-derived how to generate the signing key, scope it to the orchestrator, mint the gateway JWT, and keep the host key file canonical. All three PR #471 High-severity findings were this one integration bug in different launchers: the data plane got the full `cli` token; the firecracker control plane ran open; the firecracker guest clobbered the host key. ## Goals / Success Criteria - The orchestrator and the host controller sign with **different** keys; neither can mint the other's tokens. (This PR provisions the orchestrator's key and leaves a drop-in seam for the host controller's.) - One shared provisioning seam every backend uses — a new backend or daemon implements it instead of rediscovering these four invariants: 1. the signing key is host-canonical: a guest is handed it, never generates or overwrites it; 2. only the orchestrator process gets the raw key; the gateway gets a pre-minted `gateway` token it can't rewrite into `cli`; 3. the host CLI's `cli` token is minted from the same key, so it stays valid across co-running backends; 4. the orchestrator never runs open — the signing key is mandatory, with no topology opt-out (a separate host does not stop a caller from reaching the control-plane listener, so it cannot make open mode safe). ## Non-goals - The host controller itself (#468) — this only provisions the orchestrator's key and the seam #468 plugs into. - Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional `roles=` arg (default unchanged) so a key can carry a different role set; nothing else changes. - Network topology, the plane split (#469), or the server's open-mode fallback for tests. ## Design A **`TrustDomain`** is one service's signing material: its host-canonical key file, the roles that key may sign, and the env vars its key and a pre-minted token ride in. `mint`/`verify` are scoped to that domain's roles, so a token signed by one service's key neither carries nor verifies another service's role. - `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles `{gateway, cli}`. The orchestrator process holds the key; the gateway holds only a minted `gateway` token; the host CLI mints its own `cli` token. - The host controller (#468) will add a second `TrustDomain` — its own key file and role(s) — that the orchestrator never holds. **`ControlPlaneProvisioning`** is the seam the backends call. `orchestrator_key()` returns the raw key for the control-plane process (fail-closed for every backend: it raises rather than hand back an empty key that would run the server open). `gateway_token()` mints the gateway's token. Each backend applies these through its own transport — docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives *which* key or role. `paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each domain names its own key file. ## Open questions None blocking. #468 adds its `TrustDomain` and a second `ControlPlaneProvisioning`-shaped consumer; renaming that class to something service-neutral is a cosmetic call to make then.