Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e16efc86ad | |||
| 010e253d66 | |||
| 245f258f20 | |||
| c62d57d5ac | |||
| 34bb7263fa | |||
| 2644759b0d | |||
| e53104d5c1 | |||
| 8f6148d571 | |||
| 45f3cefbc5 | |||
| 0e70d26af4 | |||
| f2d8158742 |
@@ -19,7 +19,6 @@ from .util import run_docker
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
bot_bottle_root,
|
||||
host_orchestrator_token,
|
||||
)
|
||||
from ...gateway import GatewayError
|
||||
from ...orchestrator.lifecycle import (
|
||||
@@ -171,7 +170,9 @@ class DockerOrchestrator(Orchestrator):
|
||||
fixed-name container first)."""
|
||||
self._ensure_control_network()
|
||||
run_docker(["docker", "rm", "--force", self.name])
|
||||
_signing_key = host_orchestrator_token()
|
||||
# The signing key comes through the shared provisioning contract (#476),
|
||||
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||
_signing_key = self.control_plane_key()
|
||||
proc = run_docker([
|
||||
"docker", "run", "--detach",
|
||||
"--name", self.name,
|
||||
|
||||
@@ -21,7 +21,6 @@ import time
|
||||
from pathlib import Path
|
||||
|
||||
from ...log import die, info
|
||||
from ...paths import host_orchestrator_token
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS,
|
||||
Orchestrator,
|
||||
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
|
||||
data_drive=self._ensure_registry_volume(),
|
||||
)
|
||||
# Push the host-canonical signing key (the init waits for it before
|
||||
# starting the control plane). The host token file stays the single
|
||||
# source of truth, so a co-running docker/macOS control plane keeps
|
||||
# working; the guest verifies tokens with the same key the CLI signs from.
|
||||
# starting the control plane). It comes through the shared provisioning
|
||||
# contract (#476) — the same host token file every backend uses, so a
|
||||
# co-running docker/macOS control plane keeps working and the guest
|
||||
# verifies tokens with the same key the CLI signs from; fail-closed, so
|
||||
# the guest is never handed an empty key that would run it OPEN.
|
||||
infra_vm.push_secret(
|
||||
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
|
||||
"the control-plane signing key to the orchestrator VM "
|
||||
"(its control plane will not start)",
|
||||
)
|
||||
|
||||
@@ -18,10 +18,7 @@ import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ... import log
|
||||
from ...paths import (
|
||||
ORCHESTRATOR_TOKEN_ENV,
|
||||
host_orchestrator_token,
|
||||
)
|
||||
from ...paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ...orchestrator.lifecycle import (
|
||||
DEFAULT_HEALTH_TIMEOUT_SECONDS,
|
||||
DEFAULT_PORT,
|
||||
@@ -134,7 +131,9 @@ class MacosOrchestrator(Orchestrator):
|
||||
|
||||
def _run_container(self, current_hash: str) -> None:
|
||||
container_mod.force_remove_container(self.name)
|
||||
_signing_key = host_orchestrator_token()
|
||||
# The signing key comes through the shared provisioning contract (#476),
|
||||
# which fail-closes rather than yield an empty key that would run OPEN.
|
||||
_signing_key = self.control_plane_key()
|
||||
argv = [
|
||||
"container", "run", "--detach",
|
||||
"--name", self.name,
|
||||
|
||||
@@ -66,7 +66,12 @@ if TYPE_CHECKING:
|
||||
from .agent import ManifestAgent, ManifestAgentProvider
|
||||
from .bottle import ManifestBottle
|
||||
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
|
||||
from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig
|
||||
from .git import (
|
||||
ManifestGitEntry,
|
||||
ManifestGitSigning,
|
||||
ManifestGitUser,
|
||||
ManifestKeyConfig,
|
||||
)
|
||||
|
||||
|
||||
# Facade name -> submodule that defines it. The aggregate model (`Manifest`,
|
||||
@@ -82,6 +87,7 @@ _LAZY_MODULES: dict[str, str] = {
|
||||
"ManifestEgressRoute": "egress",
|
||||
"ManifestEgressConfig": "egress",
|
||||
"ManifestGitEntry": "git",
|
||||
"ManifestGitSigning": "git",
|
||||
"ManifestGitUser": "git",
|
||||
"ManifestKeyConfig": "git",
|
||||
}
|
||||
@@ -107,6 +113,7 @@ __all__ = [
|
||||
"ManifestIndex",
|
||||
"ManifestError",
|
||||
"ManifestGitEntry",
|
||||
"ManifestGitSigning",
|
||||
"ManifestGitUser",
|
||||
"ManifestKeyConfig",
|
||||
"ManifestAgentProvider",
|
||||
|
||||
@@ -18,7 +18,12 @@ from typing import Mapping
|
||||
from .util import ManifestError, as_json_object
|
||||
from .agent import ManifestAgentProvider
|
||||
from .egress import ManifestEgressConfig
|
||||
from .git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config
|
||||
from .git import (
|
||||
ManifestGitEntry,
|
||||
ManifestGitSigning,
|
||||
ManifestGitUser,
|
||||
parse_git_gate_config,
|
||||
)
|
||||
from .schema import BOTTLE_KEYS
|
||||
|
||||
__all__ = ["ManifestBottle"]
|
||||
@@ -38,6 +43,10 @@ class ManifestBottle:
|
||||
# `git config --global` step entirely. A bottle can declare a user
|
||||
# identity without any git-gate.repos upstreams, and vice versa.
|
||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
||||
# Per-bottle commit signing (PRD prd-new: signed commits & audit
|
||||
# attribution). Off by default; `git-gate.signing.enabled: true`
|
||||
# opts a bottle into per-activation signing + audit. Bottle-only.
|
||||
git_signing: ManifestGitSigning = field(default_factory=ManifestGitSigning)
|
||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||
# Per-bottle stuck-recovery daemon (PRD 0013). When true (the
|
||||
# default, issue #249), the launch step brings up a supervise
|
||||
@@ -109,9 +118,10 @@ class ManifestBottle:
|
||||
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
git_user = ManifestGitUser()
|
||||
git_signing = ManifestGitSigning()
|
||||
git_raw = d.get("git-gate")
|
||||
if git_raw is not None:
|
||||
git, git_user = parse_git_gate_config(name, git_raw)
|
||||
git, git_user, git_signing = parse_git_gate_config(name, git_raw)
|
||||
|
||||
agent_provider = (
|
||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||
@@ -141,7 +151,8 @@ class ManifestBottle:
|
||||
|
||||
return cls(
|
||||
env=env, agent_provider=agent_provider, git=git,
|
||||
git_user=git_user, egress=egress, supervise=supervise_raw,
|
||||
git_user=git_user, git_signing=git_signing, egress=egress,
|
||||
supervise=supervise_raw,
|
||||
nested_containers=nested_raw,
|
||||
declared_fields=frozenset(d),
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from .bottle import ManifestBottle
|
||||
from .egress import ManifestEgressConfig, validate_egress_routes
|
||||
from .git import ManifestGitUser, parse_git_gate_config
|
||||
from .git import ManifestGitSigning, ManifestGitUser, parse_git_gate_config
|
||||
from .util import ManifestError, as_json_object
|
||||
|
||||
|
||||
@@ -19,6 +19,17 @@ def _overlay_declared_bool(
|
||||
return value
|
||||
|
||||
|
||||
def _overlay_signing(
|
||||
base: ManifestBottle, override: ManifestBottle
|
||||
) -> ManifestGitSigning:
|
||||
"""Overlay `git-gate.signing`: an override that enables signing wins;
|
||||
otherwise the base's value is inherited. Mirrors the non-empty-wins
|
||||
overlay used for `git_user` — a child cannot un-set a parent's signing
|
||||
by declaring `enabled: false` (that reads as the default), the same
|
||||
way a child cannot blank a parent's git-gate.user field."""
|
||||
return override.git_signing if override.git_signing.enabled else base.git_signing
|
||||
|
||||
|
||||
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
||||
|
||||
@@ -69,6 +80,7 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle
|
||||
agent_provider=override.agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
git_signing=_overlay_signing(base, override),
|
||||
egress=merged_egress,
|
||||
supervise=_overlay_declared_bool(base, override, "supervise"),
|
||||
nested_containers=_overlay_declared_bool(
|
||||
@@ -210,7 +222,7 @@ def _fold_two_bottles(
|
||||
for n in names
|
||||
}
|
||||
if merged_repos_raw:
|
||||
merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
merged_git, _, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw})
|
||||
else:
|
||||
merged_git = ()
|
||||
|
||||
@@ -225,6 +237,7 @@ def _fold_two_bottles(
|
||||
agent_provider=later.agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
git_signing=_overlay_signing(earlier, later),
|
||||
egress=merged_egress,
|
||||
supervise=_overlay_declared_bool(earlier, later, "supervise"),
|
||||
nested_containers=_overlay_declared_bool(
|
||||
@@ -299,6 +312,7 @@ def _merge_bottles(
|
||||
agent_provider=merged_agent_provider,
|
||||
git=merged_git,
|
||||
git_user=merged_git_user,
|
||||
git_signing=_overlay_signing(parent, child),
|
||||
egress=merged_egress,
|
||||
supervise=merged_supervise,
|
||||
nested_containers=merged_nested_containers,
|
||||
|
||||
@@ -283,16 +283,52 @@ class ManifestGitUser:
|
||||
return not self.name and not self.email
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ManifestGitSigning:
|
||||
"""Per-bottle commit-signing switch (PRD prd-new: signed commits &
|
||||
audit attribution).
|
||||
|
||||
When `enabled`, the launcher mints a per-activation Ed25519 signing
|
||||
key host-side, holds the private half in the sidecar ssh-agent, and
|
||||
configures the bottle to sign every commit at commit time; the gate
|
||||
rejects any commit it forwards that is not signed by the activation
|
||||
key, and the control plane records an audit binding.
|
||||
|
||||
Bottle-only: it carries data-plane / audit policy, not an
|
||||
agent-overlayable identity, so — like `git-gate.repos` — it is
|
||||
rejected at the agent level. Defaults to off: bottles that omit
|
||||
`git-gate.signing` behave exactly as before."""
|
||||
|
||||
enabled: bool = False
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitSigning":
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.signing")
|
||||
for k in d:
|
||||
if k != "enabled":
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' git-gate.signing has unknown key "
|
||||
f"{k!r}; allowed: enabled"
|
||||
)
|
||||
enabled = d.get("enabled", False)
|
||||
if not isinstance(enabled, bool):
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' git-gate.signing.enabled must be a "
|
||||
f"boolean (was {type(enabled).__name__})"
|
||||
)
|
||||
return cls(enabled=enabled)
|
||||
|
||||
|
||||
def parse_git_gate_config(
|
||||
bottle_name: str,
|
||||
raw: object,
|
||||
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
|
||||
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser, ManifestGitSigning]:
|
||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||
for k in d:
|
||||
if k not in {"user", "repos"}:
|
||||
if k not in {"user", "repos", "signing"}:
|
||||
raise ManifestError(
|
||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
||||
f"allowed: user, repos"
|
||||
f"allowed: user, repos, signing"
|
||||
)
|
||||
|
||||
git_user = (
|
||||
@@ -301,6 +337,12 @@ def parse_git_gate_config(
|
||||
else ManifestGitUser()
|
||||
)
|
||||
|
||||
git_signing = (
|
||||
ManifestGitSigning.from_dict(bottle_name, d["signing"])
|
||||
if "signing" in d
|
||||
else ManifestGitSigning()
|
||||
)
|
||||
|
||||
git: tuple[ManifestGitEntry, ...] = ()
|
||||
repos_raw = d.get("repos")
|
||||
if repos_raw is not None:
|
||||
@@ -311,4 +353,4 @@ def parse_git_gate_config(
|
||||
)
|
||||
validate_unique_git_names(bottle_name, git)
|
||||
|
||||
return git, git_user
|
||||
return git, git_user, git_signing
|
||||
|
||||
@@ -18,8 +18,8 @@ import urllib.request
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
|
||||
from ..orchestrator_auth import ROLE_CLI, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
from ..orchestrator_auth import ROLE_CLI
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from .server import ORCHESTRATOR_AUTH_HEADER
|
||||
|
||||
DEFAULT_TIMEOUT_SECONDS = 5.0
|
||||
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
|
||||
"" means 'send no auth header' — correct against an open (unconfigured)
|
||||
control plane, and harmlessly rejected by a secured one."""
|
||||
try:
|
||||
return mint(ROLE_CLI, host_orchestrator_token())
|
||||
return CONTROL_PLANE.mint(ROLE_CLI)
|
||||
except (OSError, ValueError):
|
||||
return ""
|
||||
|
||||
|
||||
@@ -21,8 +21,7 @@ import urllib.error
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
from ..orchestrator_auth import ROLE_GATEWAY, mint
|
||||
from ..paths import host_orchestrator_token
|
||||
from ..trust_domain import ControlPlaneProvisioning
|
||||
|
||||
DEFAULT_PORT = 8099
|
||||
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
|
||||
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
|
||||
so it — not the gateway — mints the gateway's role-scoped token.
|
||||
Backend-neutral."""
|
||||
|
||||
# The shared control-plane auth provisioning contract (#476). Every backend
|
||||
# gets its signing key + gateway token through this one seam rather than
|
||||
# re-deriving the wiring; it is fail-closed for every backend — the
|
||||
# orchestrator never starts without its signing key.
|
||||
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
|
||||
|
||||
def ensure_built(self) -> None:
|
||||
"""Ensure the orchestrator's image / rootfs exists, building it if
|
||||
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
|
||||
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
|
||||
def mint_gateway_token(self) -> str:
|
||||
"""Mint a role-scoped `gateway` JWT from the host signing key for the
|
||||
gateway to present. The orchestrator holds the key; the gateway never
|
||||
does (#469). Backend-neutral — the same host token file is the single
|
||||
source of truth across backends."""
|
||||
return mint(ROLE_GATEWAY, host_orchestrator_token())
|
||||
does (#469). Routed through the shared provisioning contract (#476), so
|
||||
the same host token file is the single source of truth across backends."""
|
||||
return self.provisioning.gateway_token()
|
||||
|
||||
def control_plane_key(self) -> str:
|
||||
"""The raw signing key the control-plane *process* must receive — the ONE
|
||||
place a backend obtains it (docker/macOS inject it as `key_env`;
|
||||
firecracker pushes it to the guest). Fail-closed via the provisioning
|
||||
contract: it raises rather than yield an empty key that would run the
|
||||
server OPEN (#476)."""
|
||||
return self.provisioning.orchestrator_key()
|
||||
|
||||
|
||||
__all__ = [
|
||||
@@ -117,4 +130,5 @@ __all__ = [
|
||||
"OrchestratorStartError",
|
||||
"source_hash",
|
||||
"Orchestrator",
|
||||
"ControlPlaneProvisioning",
|
||||
]
|
||||
|
||||
@@ -63,8 +63,8 @@ import sys
|
||||
import typing
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
|
||||
from ..paths import ORCHESTRATOR_TOKEN_ENV
|
||||
from ..orchestrator_auth import ROLE_CLI, ROLES
|
||||
from ..trust_domain import CONTROL_PLANE
|
||||
from ..supervisor.types import TOOLS
|
||||
from .service import OrchestratorCore
|
||||
|
||||
@@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
|
||||
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
|
||||
self.orchestrator = orchestrator
|
||||
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
|
||||
# The control-plane trust domain's signing key, as injected into THIS
|
||||
# (the owning) process by the launcher (#476). Unset → open mode below.
|
||||
self._signing_key = CONTROL_PLANE.key_from_env()
|
||||
if not self._signing_key:
|
||||
sys.stderr.write(
|
||||
"orchestrator: WARNING — no control-plane signing key "
|
||||
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
|
||||
f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
|
||||
"authentication. Any client that can reach this port can drive "
|
||||
"it. Backends that put the control plane on an agent-reachable "
|
||||
"network MUST set this.\n"
|
||||
@@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
|
||||
role (→ per-route 401/403 in `dispatch`)."""
|
||||
if not self._signing_key:
|
||||
return ROLE_CLI
|
||||
return verify(presented, self._signing_key)
|
||||
return CONTROL_PLANE.verify(presented, self._signing_key)
|
||||
|
||||
|
||||
def make_server(
|
||||
|
||||
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
|
||||
)
|
||||
|
||||
|
||||
def mint(role: str, secret: str) -> str:
|
||||
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
|
||||
"""A compact HS256 token asserting `role`, signed with `secret`.
|
||||
|
||||
Raises ValueError for an unknown role (mint only what the control plane will
|
||||
accept) or an empty signing key (an unsigned credential is never valid)."""
|
||||
if role not in ROLES:
|
||||
`roles` is the set the signing key is allowed to sign (default: the
|
||||
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
|
||||
controller) passes its own key + role set so its tokens can't be forged with
|
||||
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
|
||||
|
||||
Raises ValueError for a role outside `roles`, or an empty signing key (an
|
||||
unsigned credential is never valid)."""
|
||||
if role not in roles:
|
||||
raise ValueError(f"unknown control-plane role {role!r}")
|
||||
if not secret:
|
||||
raise ValueError("cannot mint a control-plane token without a signing key")
|
||||
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
|
||||
return f"{signing_input}.{_sign(secret, signing_input)}"
|
||||
|
||||
|
||||
def verify(token: str, secret: str) -> str | None:
|
||||
def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
|
||||
"""The role a valid `token` carries, or None if it is malformed, wrongly
|
||||
signed, or names an unknown role. Constant-time signature check; rejects any
|
||||
header whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||
signed, or names a role outside `roles` (the verifying trust domain's set —
|
||||
default `{gateway, cli}`). Constant-time signature check; rejects any header
|
||||
whose alg isn't HS256 (no alg-confusion / `none`)."""
|
||||
if not token or not secret:
|
||||
return None
|
||||
parts = token.split(".")
|
||||
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
|
||||
if not isinstance(header, dict) or header.get("alg") != _ALG:
|
||||
return None
|
||||
role = payload.get("role") if isinstance(payload, dict) else None
|
||||
return role if isinstance(role, str) and role in ROLES else None
|
||||
return role if isinstance(role, str) and role in roles else None
|
||||
|
||||
|
||||
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
|
||||
|
||||
+19
-9
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
|
||||
return ca_dir
|
||||
|
||||
|
||||
def host_orchestrator_token() -> str:
|
||||
"""The per-host control-plane secret, minted (256-bit, url-safe) and
|
||||
persisted 0600 on first use, then reused.
|
||||
def host_signing_key(filename: str) -> str:
|
||||
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
|
||||
and persisted 0600 on first use, then reused.
|
||||
|
||||
This is the shared secret the launchers inject into the control-plane and
|
||||
gateway containers and that the host CLI presents on every call. It is a
|
||||
*host* artifact — the file lives under the root the agent never mounts, and
|
||||
the env var is set only on the trusted containers — so reading it here is
|
||||
safe on the host launch path but the value never reaches a bottle."""
|
||||
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
|
||||
The generic form of `host_orchestrator_token()`: each service names its own
|
||||
key file (`trust_domain.py`), so the orchestrator and a separate service like
|
||||
the host controller (#468) get distinct keys neither can read. It is a *host*
|
||||
artifact — the file lives under the root the agent never mounts, and its value
|
||||
is injected only into the trusted control-plane process — so reading it here
|
||||
is safe on the launch path but the value never reaches a bottle."""
|
||||
path = bot_bottle_root() / filename
|
||||
try:
|
||||
existing = path.read_text().strip()
|
||||
if existing:
|
||||
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
|
||||
return token
|
||||
|
||||
|
||||
def host_orchestrator_token() -> str:
|
||||
"""The per-host control-plane signing key — the host-canonical key the
|
||||
launchers inject into the control-plane process and the host CLI mints its
|
||||
own `cli` token from. The `control-plane` trust domain's specialization of
|
||||
`host_signing_key()`."""
|
||||
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"HOST_DB_FILENAME",
|
||||
"ORCHESTRATOR_TOKEN_FILENAME",
|
||||
@@ -138,5 +147,6 @@ __all__ = [
|
||||
"host_db_path",
|
||||
"host_db_dir",
|
||||
"host_gateway_ca_dir",
|
||||
"host_signing_key",
|
||||
"host_orchestrator_token",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""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/prd-new-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
|
||||
from .paths import (
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ProvisioningError",
|
||||
"TrustDomain",
|
||||
"CONTROL_PLANE",
|
||||
"ControlPlaneProvisioning",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
# PRD prd-new: Per-service signing keys for control-plane auth
|
||||
|
||||
- **Status:** Draft
|
||||
- **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.
|
||||
@@ -0,0 +1,463 @@
|
||||
# PRD prd-new: Per-bottle signed commits & audit attribution
|
||||
|
||||
- **Status:** Draft
|
||||
- **Author:** didericis-claude
|
||||
- **Created:** 2026-07-25
|
||||
- **Issue:** #423
|
||||
|
||||
## Summary
|
||||
|
||||
Give each bottled agent a **per-activation signing key** so that every commit it
|
||||
produces is signed in the git-gate trust boundary (outside the bottle) and
|
||||
recorded in bot-bottle's own **host-owned audit store**, which is the portable
|
||||
source of truth. Each row cryptographically binds a commit's bytes (and their
|
||||
control-plane-recomputed SHA) to **access to that activation's signing key**, and
|
||||
binds the key to control-plane-owned activation metadata — bottle, host,
|
||||
manifest, agent, activation interval, retained public key — plus the commit's
|
||||
*claimed* author. The gate mints a short-lived Ed25519 key at spin-up, holds the
|
||||
private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into
|
||||
the bottle. The gate rejects any commit it forwards that is not signed by the
|
||||
activation key; separately, the **control plane** independently recomputes each
|
||||
commit's object ID and verifies its signature before recording attribution — it
|
||||
never trusts a SHA, key, or verdict asserted by the gate.
|
||||
|
||||
This PRD deliberately does **not** enforce or vouch author/committer identity.
|
||||
Author/committer name/email are recorded as claims carried inside the signed
|
||||
object; making the gate reject a mismatching author/committer is a possible
|
||||
future add (see **Non-goals** and **Deferred: identity enforcement**). Push
|
||||
capability stays exactly as PRD 0048 deploy keys; forge subuser accounts,
|
||||
provisioned API tokens, and forge-side status/"Verified" badges remain out of
|
||||
scope (a future "forge actors" PRD).
|
||||
|
||||
Successor to:
|
||||
|
||||
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — established that
|
||||
`git-gate.user` name/email is *claimed, not vouched*. This PRD keeps that
|
||||
posture: it adds signed **provenance** and a durable host record, not identity
|
||||
enforcement.
|
||||
- **PRD 0048 (deploy-key provisioning, #169)** — the host-side mint-at-spin-up /
|
||||
revoke-at-teardown lifecycle the signing key follows. Deploy keys are
|
||||
unchanged.
|
||||
- **PRD 0070 (per-host orchestrator, #351)** — the orchestrator/control plane is
|
||||
the sole owner of `bot-bottle.db`; audit verification and recording live
|
||||
there, not in the data-plane gate (see **Trust boundary**).
|
||||
|
||||
## The guarantee
|
||||
|
||||
The crisp property this feature provides:
|
||||
|
||||
> The **host-owned audit store** binds a set of commit bytes — whose Git object
|
||||
> ID the control plane **recomputes** itself — to **access to this activation's
|
||||
> signing key**, and binds that key to **control-plane-owned activation
|
||||
> metadata**: bottle, host, manifest, agent, activation interval, retained public
|
||||
> key. An agent may author and sign arbitrary commit contents, but it cannot make
|
||||
> that signature verify as a *different* activation, and it cannot choose the
|
||||
> activation metadata the control plane records. The commit's author/committer
|
||||
> identity is **recorded as a claim**, not enforced or vouched. The forge remains
|
||||
> only the repository transport/capability layer.
|
||||
|
||||
What this does and does not prove (issue #423, comments #5554 / #5607 / #5608):
|
||||
|
||||
- It proves **access to activation *Y*'s signing key**: whoever assembled these
|
||||
commit bytes could sign with that key. Recomputing the object ID and verifying
|
||||
the embedded signature binds the SHA to activation *Y*, and the control plane's
|
||||
own records bind *Y*'s key to *Y*'s metadata.
|
||||
- It does **not** prove the commit was ever pushed, observed upstream, kept
|
||||
(vs. later reverted or dropped), or produced by the *agent* rather than by any
|
||||
other holder of the activation signing capability (the sidecar itself). The
|
||||
store deliberately makes no claim about publication or sole-agent authorship
|
||||
— the owner's requirement is attribution of *what manifest/agent/etc. was in
|
||||
use when a commit was signed*, not proof of where the commit went (#5607).
|
||||
- It does **not** make author/committer identity cryptographically vouched. The
|
||||
bottle chooses every byte sent through the forwarded agent, so a signature over
|
||||
`author Mallory <mallory@example>` is just as valid. Those fields are a claim
|
||||
carried inside the signed object and recorded as-is.
|
||||
- The binding is trustworthy because the **control plane** supplies the SHA (it
|
||||
recomputes it), the public key, and the activation metadata from its own state
|
||||
— never from a value the gateway asserts (see **Trust boundary**). The
|
||||
residual, by design: anything that holds the activation signing capability can
|
||||
produce commits that attribute to that activation. That is inherent to a
|
||||
binding on *activation-key access*, not a defect.
|
||||
|
||||
## Problem
|
||||
|
||||
An agent runs on the developer's machine as a *subrole*, scoped down per role.
|
||||
Locally that is fine because the machine is single-tenant. The git history an
|
||||
agent produces, however, is a durable artifact that outlives the session and
|
||||
can be pushed to shared repositories, and today bot-bottle offers no
|
||||
tamper-evidence over it:
|
||||
|
||||
- **No provenance.** Nothing ties a pushed commit to the bottle/activation that
|
||||
actually produced it. `git-gate.user` name/email is forgeable and cosmetic
|
||||
(ADR 0002); a commit could be produced anywhere.
|
||||
- **No durable, portable record.** There is no host-side ledger that says "SHA
|
||||
*X* was produced by agent *A* in bottle *B* on host *H* during interval
|
||||
*[t0,t1]*, signed by key *K*," independent of any forge and surviving key
|
||||
rotation.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- **Per-activation signing key.** A fresh Ed25519 keypair is minted host-side at
|
||||
each activation; the private half lives only in the sidecar `ssh-agent`, never
|
||||
in the bottle. Only `SSH_AUTH_SOCK` crosses the boundary.
|
||||
- **Signed commits with no SHA divergence.** Commits produced in the bottle are
|
||||
signed at commit time; the SHA the agent observes is the SHA that reaches the
|
||||
upstream through the gate.
|
||||
- **Gate rejects unsigned commits.** Before the gate forwards a push, every
|
||||
newly-introduced commit (those not already reachable from the advertised
|
||||
upstream refs) must verify against the activation public key; a push with any
|
||||
unsigned or wrong-key new commit is rejected, loudly, with the offending SHA.
|
||||
This is a **signature** check only — no author/committer matching.
|
||||
- **Control-plane-owned attribution.** The orchestrator/control plane (sole
|
||||
owner of `bot-bottle.db`, PRD 0070) recomputes each commit's object ID from the
|
||||
bytes, verifies the embedded signature against the activation public key it
|
||||
holds, and attaches activation metadata from its own state — accepting no SHA,
|
||||
key, verdict, or metadata asserted by the gateway. No upstream fetch is
|
||||
required.
|
||||
- **Host is the source of truth.** The audit record binds each recomputed SHA to
|
||||
the bottle, host, manifest, agent, activation interval, and retained public
|
||||
key, and records the commit's claimed author/committer.
|
||||
- **Verifiable after teardown.** The audit record retains the **full public
|
||||
key, fingerprint, principal, and validity interval** — enough to regenerate an
|
||||
allowed-signers file and run `git verify-commit` long after the activation
|
||||
ends and the key is gone.
|
||||
- **Reprovision-per-activation, fail-loud teardown.** The signing key is minted
|
||||
once per activation (persists across restarts within that activation) and
|
||||
discarded at teardown; deploy-key revocation continues to follow PRD 0048's
|
||||
fail-loud discipline.
|
||||
- **Push capability unchanged.** Forge access remains PRD 0048 deploy keys; no
|
||||
new forge API dependency beyond 0048's existing deploy-key registration.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Author/committer enforcement.** Explicitly out of scope for this PRD (issue
|
||||
#423, comment #5590). The gate does not reject a commit for carrying a foreign
|
||||
author or committer; those fields are recorded as claims. We rely on the
|
||||
cross-forge audit store of signed commits and the authors recorded there. See
|
||||
**Deferred: identity enforcement** for what a future add would look like.
|
||||
- **Cryptographically-vouched author identity.** Not claimed — see **The
|
||||
guarantee**.
|
||||
- **Forge subuser accounts / provisioned API tokens / PAT minting.** Dropped.
|
||||
Gitea's `POST /users/:name/tokens` requires Basic Auth *as the target user*
|
||||
(an admin PAT cannot mint one for another user; the only server-side path is
|
||||
the `gitea admin user generate-access-token` CLI), so a token-minting
|
||||
bootstrap is a design in its own right (issue #423, comments #5518 / #5554).
|
||||
This PRD needs no subrole API token, so that bootstrap problem does not arise.
|
||||
- **Forge-side attribution surfaces.** No commit-status badges, no forge
|
||||
"Verified" badge. The latter is doubly unsuitable: it renders dynamically
|
||||
against a *currently registered* key (so it would lie the moment a
|
||||
reprovisioned key is revoked), and on Gitea registering a signing key also
|
||||
grants push. Attribution lives in the host record and local `git
|
||||
verify-commit`, not the forge.
|
||||
- **Non-Gitea forges, dashboard UI for orphan cleanup, mid-session rotation,
|
||||
dirty-teardown reconciliation.** As before; a separate cleanup/sync pass
|
||||
handles orphans left by a crash or discarded snapshot.
|
||||
|
||||
## Scope narrowing
|
||||
|
||||
This PRD started as "forge subroles" (forge subuser accounts + provisioned API
|
||||
tokens + optional forge status posting + signing). Review (issue #423, comments
|
||||
#5518 → #5590) narrowed it in two steps:
|
||||
|
||||
1. **Dropped the forge-account and API-token machinery** (#5518 → #5556):
|
||||
the PAT bootstrap is not implementable as sketched (Basic-Auth-as-target-user
|
||||
constraint); the signature never vouched the author anyway; and making the
|
||||
host audit store the portable source of truth is a cleaner boundary that
|
||||
removes the forge-specific token lifecycle and commit-status dependence.
|
||||
2. **Dropped author/committer enforcement** (#5590): rely on the audit store of
|
||||
signed commits and the authors recorded there; gate enforcement of the
|
||||
identity fields is a possible future add, not part of this slice.
|
||||
|
||||
What remains is the core that stands on its own: **signed commits + a
|
||||
host-owned, independently-verified audit record.** Forge *actors* (a per-bottle
|
||||
account that comments/opens PRs) and *identity enforcement* (the gate rejecting a
|
||||
foreign author/committer) are each candidate future PRDs.
|
||||
|
||||
## Design
|
||||
|
||||
### Trust boundary (control plane vs data plane)
|
||||
|
||||
git-gate is the **data plane**: it parses hostile bytes from inside the bottle
|
||||
and forwards pushes. The orchestrator is the **control plane** and, per PRD
|
||||
0070, is the sole owner of `bot-bottle.db`. These are different trust boundaries,
|
||||
and the audit record must be anchored in the control plane:
|
||||
|
||||
- The gate performs a **synchronous pre-forward signature check** (below) and
|
||||
can reject a push before it reaches the upstream. This is a data-plane gate on
|
||||
what leaves the bottle, not the audit binding.
|
||||
- The **control plane** takes the commit bytes to attribute (gateway-delivered
|
||||
opaque bytes are fine), **recomputes the Git object ID**, **verifies the
|
||||
embedded signature** against the activation public key it minted and holds, and
|
||||
writes `attributed_commit` attaching metadata from its own state. It accepts
|
||||
**no** gateway-supplied `verified` flag, claimed SHA, public key, or activation
|
||||
identity.
|
||||
|
||||
The precise trust statement (issue #423, review by didericis-codex on d8362ec,
|
||||
resolved in #5608): the row binds *these commit bytes / this recomputed SHA* to
|
||||
*access to this activation's signing key*, and the control plane binds that key
|
||||
to the recorded activation metadata. It does **not** assert forge observation or
|
||||
that only the agent (not the signing sidecar) authored the commit — so this PRD
|
||||
does **not** claim a compromised gateway cannot obtain an attribution row.
|
||||
Because the sidecar holds the activation signing capability, a compromised
|
||||
gateway *can* assemble and sign a commit and have it attributed to that
|
||||
activation; what it cannot do is make the signature verify as a *different*
|
||||
activation or choose the metadata the control plane records. That residual is
|
||||
acceptable under the intended guarantee (#5607) and is why the guarantee is
|
||||
worded as activation-key access, not agent-only authorship or upstream
|
||||
publication. The gate therefore cannot stand in for host-side verification: the
|
||||
control plane recomputes the object ID and verifies the signature itself rather
|
||||
than trusting the gate's word.
|
||||
|
||||
### Identity model
|
||||
|
||||
Per **bottled agent** (agent definition ∘ sealed bottle), realized per
|
||||
activation:
|
||||
|
||||
| Part | Value | Source | Role |
|
||||
|------|-------|--------|------|
|
||||
| Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only; the anchor of provenance |
|
||||
| Author/committer | name + email | `git-gate.user` (PRD 0027 overlay) | written into commits and **recorded** as a claim; **not** enforced |
|
||||
|
||||
### Manifest surface
|
||||
|
||||
No new top-level keys and no `git-forge`/`forge-accounts` blocks. A single
|
||||
opt-in flag under the existing `git-gate` key turns on per-activation signing;
|
||||
`git-gate.user` (PRD 0027) supplies the author string as today.
|
||||
|
||||
```yaml
|
||||
git-gate:
|
||||
user: # PRD 0027 — author string; recorded, not enforced
|
||||
name: didericis-claude
|
||||
email: eric+claude@dideric.is
|
||||
signing:
|
||||
enabled: true # NEW — opt-in per-activation signing + audit
|
||||
repos:
|
||||
bot-bottle:
|
||||
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
|
||||
provisioned_key: # PRD 0048 — push capability, UNCHANGED
|
||||
provider: gitea
|
||||
token_env: GITEA_DEPLOY_TOKEN
|
||||
host_key: "ssh-ed25519 AAAA..."
|
||||
```
|
||||
|
||||
- `git-gate.signing.enabled: true` opts a bottle in. Without it, behavior is
|
||||
exactly as today. There is **no `enforce` sub-key** — this PRD does not enforce
|
||||
identity fields, so no knob is needed (and a knob that weakened a guarantee
|
||||
was flagged as a contradiction in review).
|
||||
- `git-gate.signing` is **bottle-only** (home-only policy), rejected at the
|
||||
agent level with a clear pointer. `git-gate.user` keeps its PRD 0027
|
||||
agent-overlay semantics.
|
||||
|
||||
### Signing: sign at commit time via a forwarded ssh-agent
|
||||
|
||||
The reason SHAs never diverge:
|
||||
|
||||
- The **sidecar** (the git-gate trust boundary) runs an `ssh-agent` holding the
|
||||
short-lived signing private key.
|
||||
- **Only `SSH_AUTH_SOCK`** is forwarded into the bottle — a bounded signing
|
||||
capability, not the key.
|
||||
- The provisioner writes the bottle `.gitconfig`:
|
||||
|
||||
```ini
|
||||
[commit]
|
||||
gpgsign = true
|
||||
[gpg]
|
||||
format = ssh
|
||||
[user]
|
||||
name = didericis-claude
|
||||
email = eric+claude@dideric.is
|
||||
signingkey = ssh-ed25519 AAAA... # activation signing PUBLIC key
|
||||
```
|
||||
|
||||
- `git commit` asks the forwarded agent to sign; the signature is embedded at
|
||||
object creation, so the agent-space SHA equals the pushed SHA. No transcoder,
|
||||
no SHA translation table.
|
||||
|
||||
### Gate pre-forward signature check (data plane)
|
||||
|
||||
The gate already fetches from upstream before every `upload-pack` and mirrors
|
||||
bidirectionally (PRD 0008). When `git-gate.signing.enabled` is set, after
|
||||
gitleaks and before forwarding a push upstream:
|
||||
|
||||
1. **Compute the newly-introduced set.** Commits reachable from the pushed ref
|
||||
tips but **not** reachable from any ref already advertised by the upstream
|
||||
(which the gate knows because it fetches upstream first) — equivalent to
|
||||
`git rev-list <new-tips> --not <all-known-upstream-refs>`. This excludes
|
||||
pulled/merged existing history; a merge commit the bottle creates is itself
|
||||
new and is checked, its already-upstream ancestors are not.
|
||||
2. **Verify each new commit's signature** against the activation public key. A
|
||||
commit that is unsigned or signed by any other key causes the push to be
|
||||
**rejected** with the offending SHA.
|
||||
3. No author/committer matching is performed.
|
||||
|
||||
This is a synchronous safety gate on what leaves the bottle; it is not the audit
|
||||
record.
|
||||
|
||||
### Control-plane verification & recording
|
||||
|
||||
For each commit to attribute (the gate hands the control plane the commit bytes;
|
||||
opaque gateway-delivered bytes are acceptable because nothing the gateway *says*
|
||||
about them is trusted), the orchestrator/control plane:
|
||||
|
||||
1. **Recomputes the Git object ID** from the bytes itself. The stored `sha` is
|
||||
this recomputed value, never a SHA the gateway claims.
|
||||
2. **Verifies the embedded signature** against the activation public key it
|
||||
minted and holds for that activation (via a generated allowed-signers file) —
|
||||
ignoring any `verified` flag, key, or activation identity supplied by the
|
||||
gateway.
|
||||
3. Writes `attributed_commit` only for bytes that pass, stamping the activation
|
||||
metadata (bottle/manifest/agent/host/interval) from its **own** state — not
|
||||
from anything the gateway provides — and recording the commit's claimed
|
||||
author/committer.
|
||||
|
||||
No upstream fetch is required: the guarantee is a byte↔activation-key binding, so
|
||||
the object does not need to come from the forge (issue #423, #5608). Bytes that
|
||||
do not verify against the activation key are **not** recorded as attributed (they
|
||||
may be logged as an anomaly instead).
|
||||
|
||||
### Audit trail
|
||||
|
||||
The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`, owned by the
|
||||
control plane per PRD 0070) records the signing-key lifecycle and per-commit
|
||||
attribution. Retention is the **full public key, fingerprint, principal, and
|
||||
validity interval** — enough to regenerate an allowed-signers file and verify
|
||||
commits after teardown (issue #423, comment #5554, resolution 3). Never any
|
||||
private key material.
|
||||
|
||||
```sql
|
||||
CREATE TABLE bottled_agent_activation (
|
||||
bottled_agent_slug TEXT NOT NULL,
|
||||
activation_id TEXT NOT NULL, -- one per activation cycle
|
||||
host TEXT NOT NULL,
|
||||
manifest_digest TEXT NOT NULL, -- ties the record to the sealed manifest
|
||||
agent TEXT NOT NULL,
|
||||
signing_pubkey TEXT NOT NULL, -- full ssh-ed25519 public key (for verify-commit)
|
||||
signing_fpr TEXT NOT NULL, -- SHA256:... fingerprint (stable handle)
|
||||
principal TEXT NOT NULL, -- allowed-signers principal, e.g. the author email
|
||||
valid_from TEXT NOT NULL,
|
||||
valid_until TEXT, -- NULL while active; set at teardown
|
||||
status TEXT NOT NULL, -- active | retired
|
||||
PRIMARY KEY (bottled_agent_slug, activation_id)
|
||||
);
|
||||
|
||||
CREATE TABLE attributed_commit (
|
||||
sha TEXT NOT NULL, -- control-plane-RECOMPUTED object ID, not gateway-claimed
|
||||
bottled_agent_slug TEXT NOT NULL,
|
||||
activation_id TEXT NOT NULL,
|
||||
repo TEXT NOT NULL,
|
||||
author_name TEXT NOT NULL, -- CLAIMED, recorded as-is (not enforced)
|
||||
author_email TEXT NOT NULL, -- CLAIMED
|
||||
committer_name TEXT NOT NULL, -- CLAIMED
|
||||
committer_email TEXT NOT NULL, -- CLAIMED
|
||||
observed_at TEXT NOT NULL,
|
||||
PRIMARY KEY (sha, repo)
|
||||
);
|
||||
```
|
||||
|
||||
Verification/allowed-signers generation is a stated part of the design: for a
|
||||
given SHA, join `attributed_commit → bottled_agent_activation`, emit
|
||||
`<principal> <signing_pubkey>` to a temporary allowed-signers file, and
|
||||
`git verify-commit` (or `ssh-keygen -Y verify`) against it. The
|
||||
`(pubkey, principal, valid_from/until)` tuple is exactly what that requires. The
|
||||
recorded author/committer columns are the *claim*; a consumer that wants to know
|
||||
"who says they wrote this" reads them, understanding they are unenforced.
|
||||
|
||||
### Credential lifecycle
|
||||
|
||||
Follows PRD 0048, minus the API-token kind (dropped):
|
||||
|
||||
- **Activation:** mint a fresh Ed25519 signing keypair; load the private half
|
||||
into the sidecar `ssh-agent`; write the public half into `.gitconfig` and the
|
||||
`bottled_agent_activation` row (`active`, `valid_from` set). Deploy keys are
|
||||
provisioned exactly as PRD 0048. Minting is **per activation** (a restart
|
||||
re-attaches the same key; a new activation mints a new key and retires the old
|
||||
row), so frozen snapshots don't accumulate live keys.
|
||||
- **Teardown (fail-loud):** revoke provisioned deploy keys via the forge API
|
||||
(0048); discard the signing key from the sidecar agent and set the activation
|
||||
row to `retired` with `valid_until`. The signing key was never on the forge,
|
||||
so there is nothing to revoke there — only the local retire. Deploy-key
|
||||
revocation failure halts teardown (0048); 404 = already-gone = success.
|
||||
- **Dirty teardown** is assumed handled; a separate cleanup/sync pass reconciles
|
||||
orphaned deploy keys.
|
||||
|
||||
## Deferred: identity enforcement
|
||||
|
||||
If a future PRD wants the gate to *enforce* that new commits carry the manifest
|
||||
identity, the natural shape is: extend the gate pre-forward check to also require
|
||||
each new commit's author **and** committer name/email to equal `git-gate.user`,
|
||||
rejecting mismatches — with the same control-plane re-verification before
|
||||
recording. This is deliberately left out now (issue #423, comment #5590); it is
|
||||
noted so the door stays open and the current schema (which records the claimed
|
||||
author/committer) already carries what such a check would compare against. Note
|
||||
that even then the property would be gate-*enforced*, not signature-*vouched*; a
|
||||
validating signing broker in front of the key would be required for the latter.
|
||||
|
||||
## Implementation chunks
|
||||
|
||||
1. **This PRD.** Sets the (narrowed) design.
|
||||
2. **Manifest surface.** Add `git-gate.signing` (bottle-only; `enabled` only);
|
||||
reject it at the agent level. Unit tests for parse/validation and the
|
||||
agent-level rejection.
|
||||
3. **Signing pipeline.** Sidecar `ssh-agent` provisioning; forward
|
||||
`SSH_AUTH_SOCK` into the bottle across docker, smolmachines, macOS-container,
|
||||
and firecracker backends; emit the `commit.gpgsign` / `gpg.format=ssh` /
|
||||
`user.signingkey` gitconfig. Integration test: a bottle commit is
|
||||
`verify-commit`-valid and its SHA is unchanged through the gate; the private
|
||||
key is absent from the bottle.
|
||||
4. **Gate pre-forward signature check.** Compute the newly-introduced set
|
||||
(excluding upstream-reachable commits), verify each against the activation
|
||||
key, reject unsigned/wrong-key with the offending SHA. Tests: unsigned
|
||||
rejected; wrong-key rejected; pulled/merged upstream history passes; an
|
||||
all-signed push succeeds. A foreign-author commit that is correctly signed
|
||||
**passes the gate** (identity is not enforced here).
|
||||
5. **Control-plane verification + audit.** `bottled_agent_activation` /
|
||||
`attributed_commit` tables (PRD 0067 store, control-plane-owned per PRD 0070);
|
||||
the control plane recomputes each commit's object ID and verifies the
|
||||
signature before writing a row; retain full pubkey + fingerprint + principal +
|
||||
validity interval; record claimed author/committer; allowed-signers generation
|
||||
+ a post-teardown `verify-commit` helper. Tests: a gateway-claimed SHA/key/
|
||||
verdict is ignored — the row's `sha` is the recomputed ID and bytes not signed
|
||||
by the activation key produce **no** row.
|
||||
6. **Docs.** Glossary entry ("per-bottle signed commits"); README manifest
|
||||
section; ADR note that signing-enabled bottles gain signed *provenance* and a
|
||||
host-owned audit record while authorship stays *claimed* (ADR 0002 unchanged).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
- **Unit (must):** `git-gate.signing` parse/validation; agent-level `signing`
|
||||
rejection.
|
||||
- **Integration — signing (must):** end-to-end signed commit verifies with
|
||||
`git verify-commit`; SHA observed in the bottle equals the SHA upstream; the
|
||||
private key is absent from the bottle.
|
||||
- **Integration — gate check (must):** unsigned rejected; wrong-key rejected;
|
||||
the upstream-reachable exclusion (pull + merge human history and push a signed
|
||||
merge); a correctly-signed foreign-author commit **passes** (no identity
|
||||
enforcement); a clean all-signed push succeeds.
|
||||
- **Control plane (must):** the control plane recomputes the object ID and
|
||||
records a row for bytes genuinely signed by the activation key; a gateway-
|
||||
supplied SHA/key/verdict is ignored (the stored `sha` is the recomputed value);
|
||||
bytes signed by a foreign/invalid key produce **no** row.
|
||||
- **Lifecycle:** activation mints the key and writes an `active` row; teardown
|
||||
retires it (`valid_until`) and revokes deploy keys fail-loud; a restart
|
||||
re-attaches the same key (no new row); a fresh activation mints a new key and
|
||||
retires the old.
|
||||
- **Post-teardown verification:** regenerate the allowed-signers file from a
|
||||
`retired` row and confirm `verify-commit` still succeeds for an attributed SHA.
|
||||
|
||||
## Resolved: control-plane transport
|
||||
|
||||
Raised in review and **resolved** (issue #423, #5608): the commit object does not
|
||||
need to come from the forge, and reading the gateway-owned mirror is no stronger
|
||||
than accepting gateway-delivered bytes — both are fabricatable, and neither
|
||||
matters because the control plane trusts nothing the gateway *asserts*. The
|
||||
transport is therefore: the gate hands the control plane the raw commit bytes,
|
||||
the control plane **recomputes the object ID** and **verifies the signature**
|
||||
against the activation key, and stamps its own activation metadata. No upstream
|
||||
fetch. This is exactly what makes the byte↔activation-key binding sound
|
||||
regardless of transport.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Where the gate check slots into PRD 0008 ordering.** Modeled as a
|
||||
pre-forward step after gitleaks; confirm it composes with the existing
|
||||
access-hook / mirror ordering rather than needing a separate hook.
|
||||
@@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator"
|
||||
_RUN = f"{_ORCH}.run_docker"
|
||||
_SLEEP = f"{_ORCH}.time.sleep"
|
||||
_MONOTONIC = f"{_ORCH}.time.monotonic"
|
||||
_TOKEN = f"{_ORCH}.host_orchestrator_token"
|
||||
# The signing key is read through the shared provisioning contract (#476); patch
|
||||
# its host-canonical key file read to keep it off the real host file.
|
||||
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
|
||||
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
|
||||
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase):
|
||||
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
|
||||
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
|
||||
patch.object(infra_vm, "push_secret") as push, \
|
||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \
|
||||
patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \
|
||||
patch.object(orch, "is_running", return_value=False), \
|
||||
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
|
||||
patch.object(orch, "_wait_for_health"):
|
||||
|
||||
@@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
||||
def _run(self) -> list[str]:
|
||||
run = Mock(return_value=_ok())
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
||||
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.bind_mount_spec.side_effect = _spec
|
||||
mod.run_container_argv = run
|
||||
@@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
|
||||
|
||||
def test_start_failure_raises(self) -> None:
|
||||
with patch(f"{_ORCH}.container_mod") as mod, \
|
||||
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
|
||||
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
mod.dns_server.return_value = "1.1.1.1"
|
||||
mod.run_container_argv = Mock(return_value=_fail())
|
||||
with self.assertRaises(OrchestratorStartError):
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Unit: git-gate.signing manifest parsing + validation.
|
||||
|
||||
PRD prd-new (signed commits & audit attribution): a bottle opts into
|
||||
per-activation commit signing with `git-gate.signing.enabled: true`.
|
||||
The block is bottle-only (rejected at the agent level, like
|
||||
git-gate.repos) and defaults to off.
|
||||
"""
|
||||
|
||||
import unittest
|
||||
|
||||
from bot_bottle.manifest import ManifestError, ManifestGitSigning, ManifestIndex
|
||||
|
||||
|
||||
def _bottle(git_gate: dict) -> dict: # type: ignore
|
||||
return {
|
||||
"bottles": {"dev": {"git-gate": git_gate}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
}
|
||||
|
||||
|
||||
class TestSigningParsing(unittest.TestCase):
|
||||
def test_default_is_disabled(self):
|
||||
"""A bottle with no git-gate block signs nothing."""
|
||||
m = ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
self.assertEqual(ManifestGitSigning(), m.bottles["dev"].git_signing)
|
||||
self.assertFalse(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
def test_git_gate_without_signing_is_disabled(self):
|
||||
"""git-gate present (e.g. user only) but no signing → off."""
|
||||
m = ManifestIndex.from_json_obj(_bottle({
|
||||
"user": {"name": "claude", "email": "eric+claude@dideric.is"},
|
||||
}))
|
||||
self.assertFalse(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
def test_enabled_true(self):
|
||||
m = ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": True}}))
|
||||
self.assertTrue(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
def test_enabled_false(self):
|
||||
m = ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": False}}))
|
||||
self.assertFalse(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
def test_signing_coexists_with_user_and_repos(self):
|
||||
m = ManifestIndex.from_json_obj(_bottle({
|
||||
"user": {"name": "claude", "email": "eric+claude@dideric.is"},
|
||||
"signing": {"enabled": True},
|
||||
"repos": {
|
||||
"bot-bottle": {
|
||||
"url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git",
|
||||
"key": {"provider": "static", "path": "/dev/null"},
|
||||
},
|
||||
},
|
||||
}))
|
||||
b = m.bottles["dev"]
|
||||
self.assertTrue(b.git_signing.enabled)
|
||||
self.assertEqual("claude", b.git_user.name)
|
||||
self.assertEqual(1, len(b.git))
|
||||
|
||||
|
||||
class TestSigningValidation(unittest.TestCase):
|
||||
def test_unknown_key_under_signing_dies(self):
|
||||
with self.assertRaises(ManifestError) as cm:
|
||||
ManifestIndex.from_json_obj(_bottle({
|
||||
"signing": {"enabled": True, "enforce": ["author"]},
|
||||
}))
|
||||
msg = str(cm.exception)
|
||||
self.assertIn("git-gate.signing", msg)
|
||||
self.assertIn("enforce", msg)
|
||||
|
||||
def test_non_bool_enabled_dies(self):
|
||||
with self.assertRaises(ManifestError) as cm:
|
||||
ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": "yes"}}))
|
||||
self.assertIn("git-gate.signing.enabled must be a boolean", str(cm.exception))
|
||||
|
||||
def test_signing_not_a_mapping_dies(self):
|
||||
with self.assertRaises(ManifestError):
|
||||
ManifestIndex.from_json_obj(_bottle({"signing": ["enabled"]}))
|
||||
|
||||
def test_unknown_git_gate_key_lists_signing(self):
|
||||
"""The git-gate allowed-key error names signing as valid."""
|
||||
with self.assertRaises(ManifestError) as cm:
|
||||
ManifestIndex.from_json_obj(_bottle({"bogus": {}}))
|
||||
self.assertIn("allowed: user, repos, signing", str(cm.exception))
|
||||
|
||||
|
||||
class TestSigningIsBottleOnly(unittest.TestCase):
|
||||
def test_agent_level_signing_rejected(self):
|
||||
"""git-gate.signing on an agent dies — it is bottle-only."""
|
||||
with self.assertRaises(ManifestError) as cm:
|
||||
ManifestIndex.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {
|
||||
"demo": {
|
||||
"skills": [],
|
||||
"prompt": "",
|
||||
"bottle": "dev",
|
||||
"git-gate": {"signing": {"enabled": True}},
|
||||
},
|
||||
},
|
||||
})
|
||||
msg = str(cm.exception)
|
||||
self.assertIn("git-gate.signing", msg)
|
||||
self.assertIn("not allowed at the agent level", msg)
|
||||
|
||||
|
||||
class TestSigningExtendsOverlay(unittest.TestCase):
|
||||
def test_child_inherits_parent_signing(self):
|
||||
"""A child that omits signing inherits the parent's enabled flag."""
|
||||
m = ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"base": {"git-gate": {"signing": {"enabled": True}}},
|
||||
"dev": {"extends": "base"},
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
self.assertTrue(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
def test_child_enables_over_disabled_parent(self):
|
||||
m = ManifestIndex.from_json_obj({
|
||||
"bottles": {
|
||||
"base": {},
|
||||
"dev": {"extends": "base", "git-gate": {"signing": {"enabled": True}}},
|
||||
},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
self.assertTrue(m.bottles["dev"].git_signing.enabled)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase):
|
||||
mint(ROLE_GATEWAY, "")
|
||||
|
||||
|
||||
class TestCustomRoleSet(unittest.TestCase):
|
||||
"""The `roles=` seam a trust domain other than the control plane uses (#476):
|
||||
mint/verify are scoped to the passed role set, not the module default."""
|
||||
|
||||
_ROLES = frozenset({"host"})
|
||||
|
||||
def test_round_trips_a_role_in_the_custom_set(self) -> None:
|
||||
tok = mint("host", _KEY, roles=self._ROLES)
|
||||
self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES))
|
||||
|
||||
def test_mint_rejects_a_role_outside_the_custom_set(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
mint(ROLE_CLI, _KEY, roles=self._ROLES)
|
||||
|
||||
def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None:
|
||||
# A validly signed token whose role isn't in the verifier's set fails —
|
||||
# this is what keeps one domain's key from asserting another's role.
|
||||
tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token
|
||||
self.assertIsNone(verify(tok, _KEY, roles=self._ROLES))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
|
||||
|
||||
class TestHostAuthToken(unittest.TestCase):
|
||||
def test_mints_a_cli_token_from_the_host_key(self) -> None:
|
||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
||||
# The CLI mints its `cli` token from the control-plane trust domain's
|
||||
# host-canonical key (#476) — patch the key file read underneath it.
|
||||
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||
return_value="signing-key"):
|
||||
tok = _host_auth_token()
|
||||
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
|
||||
|
||||
def test_returns_empty_when_key_unreadable(self) -> None:
|
||||
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
|
||||
with patch("bot_bottle.trust_domain.host_signing_key",
|
||||
side_effect=OSError("no host root")):
|
||||
self.assertEqual("", _host_auth_token())
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Unit: trust domains + the shared control-plane provisioning contract (#476)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from bot_bottle import orchestrator_auth
|
||||
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
|
||||
from bot_bottle.trust_domain import (
|
||||
CONTROL_PLANE,
|
||||
ControlPlaneProvisioning,
|
||||
ProvisioningError,
|
||||
TrustDomain,
|
||||
)
|
||||
|
||||
# A second, unrelated domain — the shape #468's host controller would take: its
|
||||
# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE.
|
||||
_HOST_CTRL = TrustDomain(
|
||||
name="host-controller",
|
||||
key_filename="host-controller-token",
|
||||
roles=frozenset({"host"}),
|
||||
key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN",
|
||||
token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT",
|
||||
)
|
||||
|
||||
|
||||
class TestTrustDomainMintVerify(unittest.TestCase):
|
||||
def test_mint_verify_round_trips_within_a_domain(self) -> None:
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
tok = CONTROL_PLANE.mint(ROLE_GATEWAY)
|
||||
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||
|
||||
def test_mint_rejects_a_role_outside_the_domain(self) -> None:
|
||||
# `host` is a valid role in _HOST_CTRL but not in the control plane —
|
||||
# the control-plane key must refuse to mint it.
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
with self.assertRaises(ValueError):
|
||||
CONTROL_PLANE.mint("host")
|
||||
|
||||
def test_a_custom_role_set_verifies_its_own_role(self) -> None:
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
tok = _HOST_CTRL.mint("host")
|
||||
self.assertEqual("host", _HOST_CTRL.verify(tok, "k"))
|
||||
|
||||
def test_key_from_env_reads_the_domains_env_var(self) -> None:
|
||||
self.assertEqual(
|
||||
"secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "}))
|
||||
self.assertEqual("", CONTROL_PLANE.key_from_env({}))
|
||||
|
||||
|
||||
class TestDomainBoundary(unittest.TestCase):
|
||||
"""The #476/#468 invariant: two domains, two keys, two role sets — one
|
||||
domain's key can neither mint nor verify the other's tokens."""
|
||||
|
||||
def test_a_control_plane_token_does_not_verify_under_another_domains_key(
|
||||
self,
|
||||
) -> None:
|
||||
# Even with an IDENTICAL underlying key, a `cli` token minted under the
|
||||
# control-plane domain must not verify as a role in the host-controller
|
||||
# domain — the role isn't in that domain's set.
|
||||
cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes")
|
||||
self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes"))
|
||||
|
||||
def test_distinct_keys_do_not_cross_verify(self) -> None:
|
||||
# The realistic case: distinct host-canonical keys per domain. A token
|
||||
# signed by one key never verifies under the other.
|
||||
host_tok = orchestrator_auth.mint(
|
||||
"host", "host-ctrl-key", roles=_HOST_CTRL.roles)
|
||||
self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key"))
|
||||
self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key"))
|
||||
|
||||
|
||||
class TestControlPlaneProvisioning(unittest.TestCase):
|
||||
def test_orchestrator_key_returns_the_canonical_key(self) -> None:
|
||||
prov = ControlPlaneProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"):
|
||||
self.assertEqual("key", prov.orchestrator_key())
|
||||
|
||||
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
|
||||
# Invariant 4: the orchestrator must never start without a key — it would
|
||||
# run OPEN and grant every caller that reaches it full `cli`. There is no
|
||||
# topology opt-out: a separate host does not stop the gateway (or any
|
||||
# other caller) from reaching the control-plane listener.
|
||||
prov = ControlPlaneProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
|
||||
with self.assertRaises(ProvisioningError):
|
||||
prov.orchestrator_key()
|
||||
|
||||
def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None:
|
||||
prov = ControlPlaneProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
tok = prov.gateway_token()
|
||||
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
|
||||
|
||||
def test_gateway_token_cannot_be_reused_as_cli(self) -> None:
|
||||
# The data plane's token is `gateway`-scoped: it never carries `cli`.
|
||||
prov = ControlPlaneProvisioning()
|
||||
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
|
||||
tok = prov.gateway_token()
|
||||
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user