Allow for short lived, provisioned SSH keys #104

Closed
opened 2026-05-29 00:51:30 -04:00 by didericis · 4 comments
Owner

Key management for agents is kind of annoying to do manually, and potentially insecure (ssh keys are not invalidated). Also I'm having issues with the git-gate layer and need to refactor it regardless. Would be nice to have some method to auto inject keys that live for a set period of time.

Key management for agents is kind of annoying to do manually, and potentially insecure (ssh keys are not invalidated). Also I'm having issues with the git-gate layer and need to refactor it regardless. Would be nice to have some method to auto inject keys that live for a set period of time.
didericis added the Kind/SecurityKind/Enhancement labels 2026-05-29 00:51:30 -04:00
Collaborator

Proposed solution: deploy_key_provisioner

Rather than SSH CA certificates (also considered — see below), the cleanest approach is to generate a fresh SSH keypair at spin-up, register the public key as a deploy key via the Gitea/GitHub API, and delete it at teardown.

Manifest change

Add an optional deploy_key_provisioner block to GitEntry. It is mutually exclusive with identity_file — exactly one must be present:

git:
  remotes:
    my-repo:
      upstream: ssh://git@gitea.dideric.is/didericis/my-repo.git
      known_host_key: "gitea.dideric.is ssh-ed25519 AAAA..."
      deploy_key_provisioner:
        token_ref: GITEA_DEPLOY_TOKEN   # host env var — never enters agent
        read_only: false                # false = push access

token_ref is the API token used to register and delete the deploy key (i.e. the provisioning credential). It is separate from the generated SSH keypair that git-gate actually uses for git operations.

Dataclass sketch

@dataclass(frozen=True)
class DeployKeyProvisioner:
    TokenRef: str
    ReadOnly: bool = False

@dataclass(frozen=True)
class GitEntry:
    ...
    IdentityFile: Optional[str] = None
    DeployKeyProvisioner: Optional[DeployKeyProvisioner] = None
    # validation: exactly one of IdentityFile / DeployKeyProvisioner required

Platform (Gitea vs GitHub) is inferred from UpstreamHost — no explicit field needed yet.

Runtime flow

  1. prepare() — generate fresh ed25519 keypair into a temp dir, POST the public key to the Gitea/GitHub API, store the private key path as IdentityFile for git-gate
  2. launch() — register a stack.callback() to DELETE the deploy key via API; the existing ExitStack + finally guarantees this runs even on crash, and teardown() swallows exceptions so a failed delete doesn't mask the original error
  3. git-gate — unchanged; it receives IdentityFile as it does today

Why not SSH CA certificates

CA certs were considered first. The per-session ephemerality is equivalent, but the operational tradeoffs favour deploy keys here:

  • Revocation: deleting a deploy key is one API call, instant, no server config change, no Gitea restart, no KRL management. Revoking a CA requires updating SSH_TRUSTED_USER_CA_KEYS in app.ini + restart, or a KRL file Gitea needs to hot-reload.
  • Blast radius of compromise: a leaked token_ref API token lets an attacker create visible, auditable keys. A leaked CA key silently mints valid certs for any repo trusting that CA.
  • GitHub parity: deploy key API works on free GitHub orgs; SSH CA requires Team/Enterprise.
  • Per-repo scoping: deploy keys are inherently repo-scoped; a CA spans all repos that trust it.

Orphan management

If a container crashes before teardown runs, the deploy key persists. The dashboard (supervise plane) is the right place to surface and revoke these — active deploy keys per agent, with a manual revoke action for orphans. This follows the same pattern as the existing orphan state-dir detection in cleanup.py.

## Proposed solution: `deploy_key_provisioner` Rather than SSH CA certificates (also considered — see below), the cleanest approach is to generate a fresh SSH keypair at spin-up, register the public key as a deploy key via the Gitea/GitHub API, and delete it at teardown. ### Manifest change Add an optional `deploy_key_provisioner` block to `GitEntry`. It is mutually exclusive with `identity_file` — exactly one must be present: ```yaml git: remotes: my-repo: upstream: ssh://git@gitea.dideric.is/didericis/my-repo.git known_host_key: "gitea.dideric.is ssh-ed25519 AAAA..." deploy_key_provisioner: token_ref: GITEA_DEPLOY_TOKEN # host env var — never enters agent read_only: false # false = push access ``` `token_ref` is the API token used to register and delete the deploy key (i.e. the provisioning credential). It is separate from the generated SSH keypair that git-gate actually uses for git operations. ### Dataclass sketch ```python @dataclass(frozen=True) class DeployKeyProvisioner: TokenRef: str ReadOnly: bool = False @dataclass(frozen=True) class GitEntry: ... IdentityFile: Optional[str] = None DeployKeyProvisioner: Optional[DeployKeyProvisioner] = None # validation: exactly one of IdentityFile / DeployKeyProvisioner required ``` Platform (Gitea vs GitHub) is inferred from `UpstreamHost` — no explicit field needed yet. ### Runtime flow 1. **`prepare()`** — generate fresh ed25519 keypair into a temp dir, POST the public key to the Gitea/GitHub API, store the private key path as `IdentityFile` for git-gate 2. **`launch()`** — register a `stack.callback()` to DELETE the deploy key via API; the existing `ExitStack` + `finally` guarantees this runs even on crash, and `teardown()` swallows exceptions so a failed delete doesn't mask the original error 3. **git-gate** — unchanged; it receives `IdentityFile` as it does today ### Why not SSH CA certificates CA certs were considered first. The per-session ephemerality is equivalent, but the operational tradeoffs favour deploy keys here: - **Revocation**: deleting a deploy key is one API call, instant, no server config change, no Gitea restart, no KRL management. Revoking a CA requires updating `SSH_TRUSTED_USER_CA_KEYS` in `app.ini` + restart, or a KRL file Gitea needs to hot-reload. - **Blast radius of compromise**: a leaked `token_ref` API token lets an attacker create visible, auditable keys. A leaked CA key silently mints valid certs for any repo trusting that CA. - **GitHub parity**: deploy key API works on free GitHub orgs; SSH CA requires Team/Enterprise. - **Per-repo scoping**: deploy keys are inherently repo-scoped; a CA spans all repos that trust it. ### Orphan management If a container crashes before teardown runs, the deploy key persists. The dashboard (supervise plane) is the right place to surface and revoke these — active deploy keys per agent, with a manual revoke action for orphans. This follows the same pattern as the existing orphan state-dir detection in `cleanup.py`.
didericis-claude was assigned by didericis 2026-05-29 01:11:24 -04:00
Author
Owner

Think that makes sense. We should also use this as an opportunity to introduce a contrib structure for scoping things like specific deploy key provisioners, specific agent provisioners, etc.

Think that makes sense. We should also use this as an opportunity to introduce a `contrib` structure for scoping things like specific deploy key provisioners, specific agent provisioners, etc.
Collaborator

Agreed — bot_bottle/contrib/ fits cleanly as the home for anything that is platform-specific rather than core: deploy-key provisioners per forge, agent provisioners per runtime, etc. The core defines the abstract interface (DeployKeyProvisioner ABC); each contrib sub-package ships a concrete implementation alongside any platform-specific helpers it needs.

For this feature:

  • bot_bottle/contrib/gitea/ — first contrib sub-package, shipping GiteaDeployKeyProvisioner
  • manifest_git.py grows a DeployKeyConfig dataclass; deploy_key: becomes an alternative to identity: in git-gate.repos entries (mutually exclusive, parser rejects both-or-neither)
  • At prepare time: provisioner generates an ed25519 keypair, registers the public half via the Gitea deploy-key API, and places the private key where git-gate expects a static identity file — the rest of the pipeline is unchanged
  • At teardown: provisioner deletes the registered key via API (best-effort; failure is logged, not fatal)

Filing PRD 0048 to nail down the full schema and runtime contract.

Agreed — `bot_bottle/contrib/` fits cleanly as the home for anything that is platform-specific rather than core: deploy-key provisioners per forge, agent provisioners per runtime, etc. The core defines the abstract interface (`DeployKeyProvisioner` ABC); each contrib sub-package ships a concrete implementation alongside any platform-specific helpers it needs. For this feature: - `bot_bottle/contrib/gitea/` — first contrib sub-package, shipping `GiteaDeployKeyProvisioner` - `manifest_git.py` grows a `DeployKeyConfig` dataclass; `deploy_key:` becomes an alternative to `identity:` in `git-gate.repos` entries (mutually exclusive, parser rejects both-or-neither) - At prepare time: provisioner generates an ed25519 keypair, registers the public half via the Gitea deploy-key API, and places the private key where git-gate expects a static identity file — the rest of the pipeline is unchanged - At teardown: provisioner deletes the registered key via API (best-effort; failure is logged, not fatal) Filing PRD 0048 to nail down the full schema and runtime contract.
Collaborator

Implemented in PR #170 (merged 2026-06-03). See also PRD 0048 at docs/prds/0048-ssh-deploy-key-provisioning.md and the implementation at bot_bottle/contrib/gitea/deploy_key_provisioner.py.

Implemented in PR #170 (merged 2026-06-03). See also PRD 0048 at `docs/prds/0048-ssh-deploy-key-provisioning.md` and the implementation at `bot_bottle/contrib/gitea/deploy_key_provisioner.py`.
Sign in to join this conversation.
2 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: didericis/bot-bottle#104