# PRD 0048: SSH Deploy-Key Provisioning - **Status:** Active - **Author:** didericis-claude - **Created:** 2026-06-03 - **Issue:** #169 ## Summary Replace per-repo static SSH identity files with short-lived ed25519 deploy keys that are generated at spin-up and revoked at teardown. Introduce `bot_bottle/contrib/` as the package for platform-specific provisioners and ship the first contrib sub-package: `bot_bottle/contrib/gitea/` with `GiteaDeployKeyProvisioner`. A new `provisioned_key:` block in `git-gate.repos` entries opts a repo into automatic key lifecycle management; `identity:` stays valid for operators who supply their own key material. ## Problem The current `git-gate.repos` entries require an `identity:` field pointing to a host-side SSH private key (PRD 0047). Keys are static: the operator generates them once, registers them with the upstream forge, and the same key is reused across every bottle spin-up. This has several consequences: - **No automatic revocation.** If a bottle misbehaves or a key leaks, the operator must notice and manually delete the key from the forge. There is no teardown hook that does it. - **Broad blast radius.** A forge deploy key typically grants write access for the lifetime of the key. A static key that survives bottle teardown continues to grant that access. - **Manual rotation burden.** Operators must manage key files on disk, keeping them secure, rotating them on a schedule, and distributing them across hosts that run `./cli.py start`. ## Goals / Success Criteria - `git-gate.repos` entries accept `provisioned_key:` as an alternative to `identity:`. The parser rejects entries that have both, or neither. - `provisioned_key.provider: gitea` provisions and revokes deploy keys via the Gitea HTTP API. - At prepare time the provisioner generates a fresh ed25519 keypair, registers the public half as a repo-scoped deploy key, and makes the private key available to git-gate at the path it expects — the rest of the pipeline is unchanged. - At teardown the provisioner deletes the registered deploy key. Failure to delete halts teardown and propagates the error loudly. - `bot_bottle/contrib/` is introduced as the package for platform-specific implementations; the core defines the abstract interface; contrib sub-packages provide concrete implementations. - Existing `identity:`-based repos continue to work without change. - The unit test suite passes unchanged for `identity:` paths; new tests cover `provisioned_key:` parse, validation, and provisioner dispatch. ## Non-goals - GitHub, GitLab, or other forge providers (a future contrib sub-package each). - Dashboard UI for listing or revoking orphaned deploy keys. - SSH CA certificate approach (rejected in the issue thread in favour of per-repo deploy keys for simpler revocation, smaller blast radius, and forge compatibility). - Key rotation mid-session (keys live for exactly one spin-up / teardown cycle). - Any change to how `identity:` repos are provisioned. ## Design ### Manifest changes (builds on PRD 0047) `git-gate.repos.` currently accepts exactly: ``` url (required string) identity (required string) host_key (optional string) ``` After this PRD: ``` url (required string) identity (optional string — mutually exclusive with provisioned_key) provisioned_key (optional object — mutually exclusive with identity) host_key (optional string) ``` Exactly one of `identity` or `provisioned_key` must be present. The parser emits a targeted error for each violation: ``` bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of 'identity' or 'provisioned_key'; got neither. bottle 'dev' git-gate.repos['bot-bottle'] must set exactly one of 'identity' or 'provisioned_key'; got both. ``` `provisioned_key` object schema: ```yaml provisioned_key: provider: gitea # required; names the contrib module to load token_env: GITEA_TOKEN # required; name of a host env var holding the API token api_url: https://... # optional; defaults to https:// ``` | Field | Type | Notes | |-------|------|-------| | `provider` | required string | Must match a sub-package under `bot_bottle/contrib/` | | `token_env` | required string | Resolved at provision time via `os.environ`; never stored in plan | | `api_url` | optional string | Override when the API endpoint differs from the git host | **Example bottle manifest:** ```yaml git-gate: user: name: implementer-bot email: eric+implementer@dideric.is repos: bot-bottle: url: ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git provisioned_key: provider: gitea token_env: GITEA_DEPLOY_TOKEN host_key: "ssh-rsa AAAA..." ``` ### `contrib` package structure ``` bot_bottle/ contrib/ __init__.py # empty; no core symbols gitea/ __init__.py # empty deploy_key_provisioner.py ``` `contrib` is a flat namespace of forge/platform sub-packages. Each sub-package is self-contained; the core imports from contrib lazily (inside factory functions) so that missing optional dependencies in a contrib sub-package don't break unrelated features. ### Core interface New file: `bot_bottle/deploy_key_provisioner.py` ```python from abc import ABC, abstractmethod class DeployKeyProvisioner(ABC): @abstractmethod def create(self, owner_repo: str, title: str) -> tuple[str, bytes]: """Generate a keypair and register the public half. owner_repo: '/' portion of the git upstream URL. title: human-readable label shown in the forge key list. Returns (key_id, private_key_pem) where key_id is opaque to the caller and is only passed back to delete().""" @abstractmethod def delete(self, owner_repo: str, key_id: str) -> None: """Delete the registered deploy key. Must not raise if the key is already absent (HTTP 404 is success). Must raise for all other failures so that teardown halts.""" def get_provisioner(provider: str, token: str, api_url: str) -> DeployKeyProvisioner: """Instantiate the named contrib provisioner. Raises ManifestError for unknown providers so the error is caught at parse time rather than at runtime.""" if provider == "gitea": from bot_bottle.contrib.gitea.deploy_key_provisioner import ( GiteaDeployKeyProvisioner, ) return GiteaDeployKeyProvisioner(token=token, api_url=api_url) from .manifest_util import ManifestError raise ManifestError(f"unknown provisioned_key provider: {provider!r}") ``` ### Gitea contrib implementation `bot_bottle/contrib/gitea/deploy_key_provisioner.py`: `create(owner_repo, title)`: 1. Generate an ed25519 keypair via `ssh-keygen -t ed25519 -f -N ''` (uses the SSH tooling already required by git-gate; no new Python dependency). 2. Read the private key bytes and the `.pub` file. 3. `POST /api/v1/repos/{owner}/{repo}/keys` with the public key, `title`, and `read_only: false` (deploy keys always need push access for git-gate). 4. Return `(str(response["id"]), private_key_bytes)`. `delete(owner_repo, key_id)`: 1. `DELETE /api/v1/repos/{owner}/{repo}/keys/{id}`. 2. Treat HTTP 404 as success (key already gone). 3. Raise `RuntimeError` for any other non-2xx response or network error, including the status code and response body in the message. HTTP calls use `urllib.request` from the stdlib; no new runtime dependency. ### `GitEntry` dataclass changes `bot_bottle/manifest_git.py`: - Add `ProvisionedKeyConfig` dataclass: ```python @dataclass(frozen=True) class ProvisionedKeyConfig: provider: str token_env: str api_url: str # empty string means "derive from UpstreamHost" ``` - `GitEntry`: - `IdentityFile: str` unchanged internally; empty string when `provisioned_key` is used; set at provision time, not parse time. - New field: `ProvisionedKey: ProvisionedKeyConfig | None = None` - `from_repos_entry` validates the mutually-exclusive constraint and parses the `provisioned_key` block when present. ### `GitGateUpstream` / prepare-time changes `bot_bottle/git_gate.py` and `bot_bottle/backend/docker/provision/git.py`: The existing path writes the identity file path into `GitGateUpstream.IdentityFile` and docker-cp's it into `/git-gate/creds/-key`. That path stays unchanged for `identity:` repos. For `provisioned_key:` repos, a new helper `provision_deploy_key(entry, stage_dir, bottle_name)` runs before the git-gate sidecar starts: 1. Resolve `token = os.environ[entry.ProvisionedKey.token_env]`. Missing key raises `RuntimeError` with a clear message naming the env var. 2. Resolve `api_url = entry.ProvisionedKey.api_url or f"https://{entry.UpstreamHost}"`. 3. Instantiate `get_provisioner(entry.ProvisionedKey.provider, token, api_url)`. 4. Call `provisioner.create(entry.UpstreamPath.lstrip("/"), title)` where `title = f"bot-bottle:{bottle_name}:{entry.Name}"`. 5. Write private key to `stage_dir / f"{entry.Name}-key"` (mode 0o600). 6. Write key ID to `stage_dir / f"{entry.Name}-deploy-key-id"` (plain text). 7. Return the key file path; caller sets `GitGateUpstream.IdentityFile` to it. `owner_repo` is extracted from `entry.UpstreamPath` (the path component of the `ssh://` URL, e.g. `/didericis/bot-bottle.git` → `didericis/bot-bottle`). ### Teardown changes `bot_bottle/backend/docker/cleanup.py` (or the equivalent teardown path): After the git-gate sidecar stops, for each `GitEntry` with `ProvisionedKey` set: 1. Check that `stage_dir / f"{entry.Name}-deploy-key-id"` exists; skip if absent (provision never ran or already cleaned up). 2. Resolve token and API URL as above. 3. Instantiate provisioner and call `provisioner.delete(owner_repo, key_id)`. 4. On success, log at INFO. On failure, allow the exception to propagate — teardown halts and the error surfaces to the operator. A stranded deploy key is a security concern: the operator must know about it and address it manually. Silent continuation is not acceptable. The private key file in `stage_dir` is cleaned up as part of normal stage-dir teardown (no extra step needed). ## Testing strategy ``` python3 -m unittest discover -s tests/unit ``` New / modified test files: - `tests/unit/test_manifest_git.py` — add cases for: - `provisioned_key:` accepted with valid `provider`, `token_env`, optional `api_url` - Both `identity` and `provisioned_key` present → `ManifestError` - Neither `identity` nor `provisioned_key` present → `ManifestError` - Unknown key inside `provisioned_key` block → `ManifestError` - Missing `provider` or `token_env` inside `provisioned_key` → `ManifestError` - `tests/unit/test_deploy_key_provisioner.py` — new: - `get_provisioner("gitea", ...)` returns `GiteaDeployKeyProvisioner` - `get_provisioner("unknown", ...)` raises `ManifestError` - `tests/unit/test_contrib_gitea_deploy_key.py` — new (using `unittest.mock` to stub `urllib.request.urlopen` and `subprocess.run`): - `create()` calls `ssh-keygen`, POSTs to correct endpoint, returns key ID - `delete()` DELETEs to correct endpoint - `delete()` tolerates HTTP 404 (already-deleted key) - `delete()` raises `RuntimeError` on non-404 HTTP error ## Open questions None.