5f1d3fbd15
prd-number-check / require-numbered-prds (pull_request) Failing after 11s
tracker-policy-pr / check-pr (pull_request) Successful in 16s
test / integration-docker (pull_request) Failing after 45s
test / unit (pull_request) Successful in 56s
test / coverage (pull_request) Has been skipped
lint / lint (push) Failing after 14m57s
Chunk 2 of the host-control-server stack: close the PRD's **durable secret** gap and replace chunk 1's BOT_BOTTLE_BROKER_SECRET stopgap. - trust_domain.py: two new domains. LAUNCH_BROKER holds the durable HS256 key both the orchestrator (signer) and the host control server (verifier) share for the broker's launch JWT — a host-canonical key file minted 0600 on first use, so a restarted orchestrator re-verifies against the same key. HOST_CONTROLLER is the separate domain for the controller's own lifecycle endpoints, keyed by a key the orchestrator never holds (its role is `host`, deliberately outside control-plane ROLES). LaunchBrokerProvisioning is the fail-closed seam. - orchestrator_auth.py: ROLE_HOST, outside ROLES. - paths.py: key-file + env-var constants for both domains. Key resolution is split by owner (addresses codex review on #497): broker_secret(allow_host_file=...) — the host controller / dev-harness (True) may mint/read the durable host key file it owns; the GUEST orchestrator (--broker http, default False) must be *injected* the key and fails closed if it isn't. A guest that fell back to the host file would mint a process-local key unrelated to the host controller's, so startup would succeed but every launch would 401 — this prevents that silent divergence. Tested: domain boundary + separation, provisioning fail-closed, and broker_secret env-only (guest) vs host-file (host) resolution. pyright clean; pylint 9.86. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
227 lines
9.8 KiB
Python
227 lines
9.8 KiB
Python
"""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",
|
|
]
|