# 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 or the plane split (#469).

## 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.
