"""Per-service control-plane signing keys (issue #476). 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. Scoping `mint`/`verify` to a domain's roles keeps one service's key from signing (or accepting) another service's tokens. Today there is one domain, `CONTROL_PLANE` — the orchestrator's key (roles `{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's tokens. The host controller (#468) will add a **second** domain with its own key the orchestrator never holds. That is the point: the host controller starts and stops the orchestrator, so the orchestrator must not be able to mint the credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE` instead would defeat that — the orchestrator holds that key, so it could forge `host` tokens. `ControlPlaneProvisioning` is the one seam every backend launcher uses to get the orchestrator its key and the gateway its token, instead of re-deriving that wiring per backend (the bug class behind PR #471 — see `docs/prds/0079-control-plane-auth-provisioning.md`). Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`. """ from __future__ import annotations import os from collections.abc import Mapping from dataclasses import dataclass from . import orchestrator_auth from .orchestrator_auth import ROLE_GATEWAY, ROLE_HOST from .paths import ( HOST_CONTROLLER_AUTH_JWT_ENV, HOST_CONTROLLER_KEY_ENV, HOST_CONTROLLER_KEY_FILENAME, LAUNCH_BROKER_KEY_ENV, LAUNCH_BROKER_KEY_FILENAME, ORCHESTRATOR_AUTH_JWT_ENV, ORCHESTRATOR_TOKEN_ENV, ORCHESTRATOR_TOKEN_FILENAME, host_signing_key, ) class ProvisioningError(RuntimeError): """A control-plane auth invariant would be violated (e.g. starting the orchestrator without its signing key — which would run OPEN).""" @dataclass(frozen=True) class TrustDomain: """One service's signing material: a host-canonical key file, the roles that key may sign, and the env vars its key and a minted token ride in. The service that *owns* the domain (e.g. the orchestrator) receives the raw key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted, role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are scoped to `roles`, so this service's key can neither sign nor accept another service's role.""" name: str key_filename: str roles: frozenset[str] key_env: str token_env: str def signing_key(self) -> str: """This service's host-canonical signing key (minted 0600 on first use). Host-side only — the value is injected into the owning process.""" return host_signing_key(self.key_filename) def key_from_env(self, environ: Mapping[str, str] | None = None) -> str: """The signing key as the owning process sees it — read from `key_env` (default `os.environ`). "" when unset; the caller decides whether that is fatal (`ControlPlaneProvisioning`) or the open-mode fallback (`OrchestratorServer`).""" env = os.environ if environ is None else environ return env.get(self.key_env, "").strip() def mint(self, role: str) -> str: """A role-scoped token for a delegate, signed with this service's key. Raises ValueError for a role this service doesn't sign.""" if role not in self.roles: raise ValueError(f"role {role!r} is not in trust domain {self.name!r}") return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles) def verify(self, token: str, key: str) -> str | None: """The role `token` carries under `key`, or None. `key` is passed in (not read from disk) because the verifier — the control-plane process — holds it in `key_env`, not on disk in its guest.""" return orchestrator_auth.verify(token, key, roles=self.roles) # The orchestrator's domain: the key the orchestrator (and host CLI) holds, the # `gateway` token it mints for the data plane, and the `cli` token the CLI mints # for itself. #468's host controller will add a second, separate domain. CONTROL_PLANE = TrustDomain( name="control-plane", key_filename=ORCHESTRATOR_TOKEN_FILENAME, roles=orchestrator_auth.ROLES, key_env=ORCHESTRATOR_TOKEN_ENV, token_env=ORCHESTRATOR_AUTH_JWT_ENV, ) # The launch-broker domain (#468): durable key material for the broker's own # signed launch requests (`broker.py`'s HS256 launch JWT), shared by the # orchestrator (signer) and the host control server (verifier). Unlike # `CONTROL_PLANE` it mints no role tokens — the broker's provenance is the launch # JWT, not a role token — so its `roles` set is empty and it is used only as a # provider of durable, host-canonical key material (`signing_key` / `key_from_env`). # The durability is the point: the key survives orchestrator restarts, so a # restarted orchestrator re-verifies against the same key instead of the # ephemeral per-process secret the in-process broker used. LAUNCH_BROKER = TrustDomain( name="launch-broker", key_filename=LAUNCH_BROKER_KEY_FILENAME, roles=frozenset(), key_env=LAUNCH_BROKER_KEY_ENV, token_env="", ) # The host controller's own domain (#468) — the SECOND domain #476 reserves. Its # key, which the orchestrator never holds, signs the `host`-role tokens the CLI # presents on the host controller's lifecycle endpoints (start / restart / status # of the orchestrator itself). Keeping it separate from `CONTROL_PLANE` is the # whole point: the host controller starts and stops the orchestrator, so the # orchestrator must not be able to mint the credentials used to drive it. (The # lifecycle endpoints themselves arrive in a later chunk; the domain is # established here alongside the durable launch-broker key.) HOST_CONTROLLER = TrustDomain( name="host-controller", key_filename=HOST_CONTROLLER_KEY_FILENAME, roles=frozenset({ROLE_HOST}), key_env=HOST_CONTROLLER_KEY_ENV, token_env=HOST_CONTROLLER_AUTH_JWT_ENV, ) @dataclass(frozen=True) class ControlPlaneProvisioning: """The one seam every backend launcher uses to provision control-plane auth, instead of re-deriving the four invariants that each cost a PR #471 review round: the orchestrator gets the raw key (`orchestrator_key`), the gateway gets a minted `gateway` token (`gateway_token`), the host CLI mints its own `cli` token from the same host-canonical key, and the orchestrator never starts open.""" domain: TrustDomain = CONTROL_PLANE def orchestrator_key(self) -> str: """The raw signing key the orchestrator process must receive (carry it in `domain.key_env`). Fail-closed: raises rather than return "", since an empty key runs the server open — and being on a separate host does not stop the gateway from reaching the control plane (it must, for `/resolve`), so an open orchestrator would treat that gateway as `cli`.""" key = self.domain.signing_key() if not key: raise ProvisioningError( f"refusing to start the {self.domain.name} orchestrator without " "a signing key: an open orchestrator authenticates no one and " "grants every caller that reaches it full `cli` (#476)" ) return key def gateway_token(self) -> str: """The `gateway`-role token the gateway receives (carry it in `domain.token_env`) — minted from the key, never the key itself, so a compromised gateway cannot forge a `cli` token.""" return self.domain.mint(ROLE_GATEWAY) @dataclass(frozen=True) class LaunchBrokerProvisioning: """The seam that provisions the host-side launch broker's durable keys (#468), the counterpart to `ControlPlaneProvisioning`. Both the orchestrator (signer) and the host control server (verifier) receive the SAME launch-broker key (carry it in `broker_domain.key_env`); the host controller ALSO receives its own lifecycle key (`controller_domain.key_env`) the orchestrator never holds. Fail-closed like the control-plane seam: minting returns "" only if the host root is unwritable, and an empty launch-broker key would leave the verifier unable to authenticate any launch — so we raise rather than hand back a key that would make the host controller reject (or, if a caller defaulted it, accept) unsigned input.""" broker_domain: TrustDomain = LAUNCH_BROKER controller_domain: TrustDomain = HOST_CONTROLLER def broker_key(self) -> str: """The durable launch-broker key both the orchestrator and the host control server must receive (in `broker_domain.key_env`). Raises rather than return "".""" key = self.broker_domain.signing_key() if not key: raise ProvisioningError( f"refusing to provision the {self.broker_domain.name} broker " "without a signing key: the host controller could then verify no " "launch request's provenance" ) return key def controller_key(self) -> str: """The host controller's own lifecycle key — provisioned ONLY to the host controller (in `controller_domain.key_env`), never to the orchestrator, so the orchestrator cannot mint the `host`-role tokens that start and stop it. Raises rather than return "".""" key = self.controller_domain.signing_key() if not key: raise ProvisioningError( f"refusing to provision the {self.controller_domain.name} without " "a signing key: its lifecycle endpoints would authenticate no one" ) return key __all__ = [ "ProvisioningError", "TrustDomain", "CONTROL_PLANE", "LAUNCH_BROKER", "HOST_CONTROLLER", "ControlPlaneProvisioning", "LaunchBrokerProvisioning", ]