4.3 KiB
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:
- the signing key is host-canonical: a guest is handed it, never generates or overwrites it;
- only the orchestrator process gets the raw key; the gateway gets a
pre-minted
gatewaytoken it can't rewrite intocli; - the host CLI's
clitoken is minted from the same key, so it stays valid across co-running backends; - 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/verifygain an optionalroles=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: keyorchestrator-token, roles{gateway, cli}. The orchestrator process holds the key; the gateway holds only a mintedgatewaytoken; the host CLI mints its ownclitoken.- 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.