> ⚠️ Auto-generated from the repository — do not edit here. Source: https://github.com/luisgf/infrabroker/tree/main/docs # Threat Model — infrabroker What this system defends, against whom, and — explicitly — what it does **not** cover. For how the mechanisms work see [ARCHITECTURE.md](ARCHITECTURE.md); to report a vulnerability see [SECURITY.md](SECURITY.md). --- ## Premise An AI agent needs to run commands on Linux hosts over SSH. The naive approach — hand the agent a static SSH key — fails because the key is exfiltratable (prompt injection, memory dump, a leaked tool log) and, once stolen, is valid until manually revoked. infrabroker removes the long-lived credential from the agent's reach: the agent receives only command **output**, never key material, and every operation uses a fresh, narrowly-scoped, minutes-long certificate. The design defends two distinct threats: 1. **Credential theft** — an attacker who reads the agent's memory/logs/traffic should gain nothing reusable. 2. **A compromised agent** — an agent under prompt injection should not be able to run arbitrary commands, only those the operator's policy permits. The first is fully addressed. The second is addressed for one-shot execution and partially for sessions — see the gaps below. --- ## Assets | Asset | Why it matters | |---|---| | **CA private key** | Signs every SSH certificate. Whoever holds it can mint access to any managed host. The crown jewel. | | **Ephemeral key pairs** | One per operation, in broker memory only. Short-lived; their value is bounded by the cert TTL. | | **Audit log integrity** | The forensic record. Tampering would hide abuse. | | **Host access** | The ultimate target: shell on production Linux hosts. | | **Policy & RBAC config** (`signer.json`) | Defines who may reach what. Its integrity equals the access boundary. | --- ## Actors & trust levels | Actor | Trust | Notes | |---|---|---| | **AI model** | Untrusted | Assumed subject to prompt injection. Sees only output; never holds credentials. | | **Broker** (`mcp-broker`, `mcp-broker-http`, `broker`) | Semi-trusted — *may be compromised* | Holds ephemeral private keys transiently. Never holds the CA key. Authenticates to the signer with its own mTLS CN. | | **Control plane** (`control-plane`) | Semi-trusted (PEP) | Orchestrates approval and behavior guardrails. **No CA key.** Trusted by the signer only for `on_behalf_of`/`approved` if its CN is in `trusted_forwarders`. | | **Signer** (`signer`) | Trusted | **Sole custodian of the CA key.** Authoritative for policy, RBAC, and the approval gate. Kept deliberately minimal and stateless. | | **Operator** | Trusted | Edits `signer.json`, approves out-of-band, holds approver/reload certs. | | **Remote host `sshd`** | Trusted endpoint | Enforces `force-command`, `source-address`, principals, and sudoers — the last line of defense. | The central design choice follows from this table: **keep the CA key in the smallest, most-trusted component** (the signer) and let everything else operate without it. A compromised broker or control plane cannot forge certificates. --- ## Trust boundaries & guarantees ### Model → broker - The model never receives key material — only `stdout/stderr/exit_code`. - **stdio:** isolation is the OS process (the MCP client launches the broker). - **HTTP:** OIDC bearer token, validated locally against the issuer JWKS (signature, `iss`, `aud`, `exp`, and `iat` when a max age is set). **Fail-closed (v1.11.2):** a missing groups claim (when `groups_claim` is configured) or a missing `iat` (when `max_token_age_seconds > 0`) rejects the token, so a misconfiguration cannot silently disable per-user RBAC. ### Broker → signer (and via control plane) - **mTLS with TLS 1.3 minimum.** The caller identity is the client-cert CN — not assertable by the broker in the request body. - **The broker sends an intent, not constraints.** The signer derives every certificate constraint from policy; the broker cannot widen its own grant. - **Per-group RBAC** (broker CN → `allowed_groups`) and optional per-host `allowed_callers` both gate access; a broker must pass both. - **Impersonation is unforgeable:** `on_behalf_of` and `approved` are honored **only** when the mTLS CN is in `trusted_forwarders` (the control plane). ### Signer → host - **One-shot:** the command is baked into the cert's `force-command` by the CA key. sshd enforces it; the broker cannot alter it. This is the strongest guarantee in the system — it survives a fully compromised broker. - **Scope pinning:** `source-address` (bastion egress IP on jump chains), `ValidPrincipals`, and a minutes-long TTL bound where, as whom, and how long a cert is usable. No agent/X11 forwarding extensions. - **Command policy** (allowlist/denylist/require-approval, optionally with `shell_parse` AST checking) restricts *what* one-shot command may run, and newlines are rejected so extra lines cannot be smuggled past the regexes. ### Approval & audit - **Approval gate is authoritative and unavoidable:** the signer issues no cert for a `require_approval` command unless `approved` arrives from a trusted forwarder. A direct broker cannot self-approve, and the originator of a request cannot decide its own approval (four-eyes, even if its CN is an approver). Each approval is consumed once. - **Audit log** is append-only, SHA-256 hash-chained, and Ed25519-signed per entry; any deletion/reordering/modification is detectable by replaying the chain. The chain stays continuous **across log rotation** — each rotated-to file's first entry links to the previous file's last hash — so dropping a whole rotated segment (or truncating the active file and restarting, which re-anchors to genesis) is detectable with **`broker-ctl audit verify --all`**, which verifies the whole segment set and the cross-file linkage. Note that single-file `verify` accepts the first entry's `prev_hash` as an unchecked seed, so cross-segment integrity requires `--all` (v1.13.0). Three logs (signer, broker, sshd) correlate by cert `serial`. --- ## Defense in depth (one-shot) A single malicious one-shot command must pass, in order: 1. Frontend auth (process / OIDC token). 2. Broker→signer mTLS + group RBAC + `allowed_callers`. 3. Per-user RBAC (OIDC groups ∩ host groups), if applicable. 4. Command policy (allow/deny, `shell_parse`, newline rejection). 5. Approval gate (if `require_approval`). 6. Behavior guardrails (if the control plane is in `enforce`). 7. On the host: `force-command`, `source-address`, principal, **sudoers**. Layers 4–7 are what make this more than a credential vault. **Process isolation on a colocated host.** The reference deployment (`deploy/`) runs each service as its **own system user** with a per-service PKI subdirectory and per-service config group. A compromised broker frontend therefore cannot read the signer's CA key (`pem` custody), policy, grant state, audit seed, or mTLS key — nor impersonate the signer, the control plane, or the admin CLI (whose material is root-only). Writes were already contained by the systemd sandbox; the user split contains reads. Running the signer on a separate host remains the stronger posture. --- ## Explicit non-goals & gaps These are deliberate limits, not oversights. Naming them is the point of this document — they define where additional controls (or a different tool) are needed. ### 1. Session command firewall is broker-enforced, not host-enforced `force-command` only applies to one-shot. In a session the cert authenticates the connection and commands flow as separate channels; the host does not see the signer's per-command decision. The broker preflights every `ssh_session_exec` against the current signer policy, so policy reloads affect sessions that were already open. The preflight revalidates target access, bastion access, end-user groups, sudo, sudo_user, PTY, and the physical SSH chain (`addr`/`user`/`host_key`/`jump`); if the host route changed since the session was opened, the broker rejects the next command and the caller must open a fresh session. On command-policy hosts, `mode=exec` commands are also checked before execution, and `shell`/`pty` session commands are rejected because stateful command streams are not independently verifiable. This protects against a compromised/prompt-injected model using the normal broker tool path. It does **not** survive a compromised broker that obtains a session cert and skips the preflight. On hosts without a command policy, the command text itself is not restricted by infrabroker; it can run anything the host's sudoers/principal allow. - **Mitigation today:** prefer `ssh_execute` on sensitive hosts when you need the host-enforced `force-command` guarantee; use `mode=exec` sessions only when connection reuse matters and broker-side preflight is an acceptable control. Keep `source-address` + principal + restrictive sudoers. Note the certificate TTL bounds *one-shot* exposure but **not** an open session: OpenSSH validates the certificate only at authentication, so an established session lives until the reaper closes it — bound by `session_idle_seconds` / `session_max_seconds`, which is the value to set as the session exposure window. - **Possible future control:** host-side command wrappers or short-lived per-command tokens could make session exec filtering host-enforced too. - **Composition note (v1.14.0):** a host's effective firewall is the composition of its inline `command_policy` and the policies of all its groups (additive: deny wins, allow is a union). This makes **group membership security-relevant**: assigning a host to a group can *widen* its allow-set, not only narrow it. Treat `group_command_policies` as part of the firewall config, keep allowlists minimal, and use the `_default` group (applies to every host) for global denylist guardrails (e.g. `^rm `, `^reboot`). A host left out of every allowlist group but carrying a `_default` denylist is default-allow except for the denied patterns — use an allowlist group for true least-privilege. ### 2. Behavior guardrails are detection, not containment The guardrail subject is the **authenticated broker CN** (the mTLS client certificate). The client-supplied `end_user` only qualifies the subject (`:`) when the broker CN is listed in the control plane's `trusted_forwarders` — i.e. a broker the operator trusts to authenticate end users (e.g. via OIDC). For any other CN the unauthenticated `end_user` is ignored, so a client **cannot** reset baselines or rate limits by rotating it (fixed in v1.12.6). The residual gap is narrower: a *trusted* forwarder that is itself compromised can still rotate the `end_user` half of its own subject. In `enforce`, a novel host/command is not learned while it is pending approval; retrying the same unapproved anomaly remains anomalous. Behavior remains a detection layer, not the authoritative containment boundary: the hard controls are the signer-side policy and approval gate, which a broker cannot bypass. ### 3. No certificate revocation (KRL) Mitigation is the short TTL (minutes). A certificate leaked within its validity window is usable until it expires; there is no way to cut it short. - **Roadmap:** a `/v1/revoke` endpoint generating an OpenSSH KRL by serial, plus `RevokedKeys` in sshd. Tracked in [HANDOFF.md](https://github.com/luisgf/infrabroker/blob/main/docs/HANDOFF.md). ### 4. Rate limiting on the signer is opt-in The signer supports a per-CN token-bucket rate limit on `POST /v1/sign` (`sign_rate_limit_per_min`, hot-reloadable): keyed on the authenticated mTLS peer CN, enforced before body parsing, excess requests get `429` with a `Retry-After` hint, and rejections are deliberately not audited so the tamper-evident log cannot become the flooding amplifier. The residual gap is that the cap is opt-in (0/absent = disabled, backward compatible) — set it in production. The control plane additionally applies its own per-subject behavioral rate limit on the forwarded path. ### 5. In-memory state → single instance Sessions, approvals, grants, and behavior baselines live in process memory. Running multiple broker or control-plane replicas would split this state. Horizontal scaling requires externalizing it (e.g. Redis with TTL). - **Mitigation (restart survival, not multi-instance):** the opt-in `state_db` (SQLite, write-through) persists the signer's runtime grants/waivers and the control plane's approval registry across restarts. The in-memory state remains the only state consulted on the decision path; live SSH sessions and behaviour baselines are intentionally not persisted (a TCP connection cannot be resurrected; the baseline re-learns). - **Residual risk (crash window):** an approval is marked consumed with a best-effort write *after* the certificate is issued. A crash (or a state-db write failure, counted by `statedb_errors_total`) in that window re-exposes the approval as consumable once more after the restart — bounded by the approval TTL and the certificate TTL. Grant revocation takes the opposite trade: the db delete is mandatory, so a revoked grant can never resurrect. ### 6. `callers` is default-open unless `_default` is set A broker CN absent from the `callers` table has **no** group restriction (it sees and can sign for every host). This is backward-compatible by design, but it means forgetting to list a CN fails open, not closed. - **Mitigation (opt-in default-deny):** add a reserved `"_default": {"allowed_groups": []}` entry to `callers` (`broker-ctl callers add --name _default --groups ""`); unlisted CNs then inherit it and are denied every host. The residual gap is that the closed default itself is opt-in, kept for backward compatibility. - **Mitigation:** list every broker CN explicitly; per-host `allowed_callers` can pin sensitive hosts regardless. - **Control-plane role separation:** the control plane separates the broker role from the approver role on the signing path (`/v1/sign`, `/v1/hosts`, `/v1/sign/result`). With no `sign_callers` list a CN in `approval.callers` is denied the sign path (an approver is not a broker — secure by default); a non-empty `sign_callers` is an exact allowlist. An empty or control-character client-certificate CN is rejected (fail-closed) rather than treated as an unlisted, default-open identity. ### 7. CA key custody depends on deployment Local/lab mode loads the CA key from a PEM file into process memory (a runtime `[WARN]` flags this). Production should use AKV (supported) or another HSM/KMS-backed `crypto.Signer`. The seam exists; using PEM in production is an operator error the code warns about but cannot prevent. ### 8. Secrets in commands: redaction is opt-in and best-effort A command is written to the broker and signer audit logs and, for `shell`/`pty` sessions, to the ASCIIcast recording; the control plane additionally sends it in approval notifications (log/webhook/Teams). A credential passed inline — `mysql -psecret`, `PGPASSWORD=… pg_dump`, `curl -H "Authorization: Bearer …"` — would otherwise persist in plaintext in every one of those sinks. - **Mitigation:** the opt-in `redact` config block (all three services) masks secrets at every persistent/outbound sink — audit log free-text fields, session recordings, and the approval notification payload — using built-in patterns plus operator-defined RE2 rules, replacing the secret with `[REDACTED:]` **before** the audit entry is signed (verification is unaffected; the original is irrecoverable). Redaction never touches the decision path: the signer, the certificate force-command, and the mTLS approval UI see the original command. - **Residual risk:** pattern matching is best-effort, not DLP (see [SECURITY.md](SECURITY.md#redaction-is-best-effort)) — an unanticipated secret format survives, and output recorded in `.cast` files arrives in arbitrary chunks that can split a secret across two events. Prefer credential-free invocations (env files on the host, `~/.pgpass`, secret managers) and keep treating audit logs / recordings as sensitive at rest (`0600`, restricted directories). ### 9. Audit failure is fail-open If writing an audit entry fails (disk full, I/O error), the failure is logged but the operation **still proceeds** — issuance and execution are not blocked. This favors availability over a hard guarantee that every action is recorded. A compliance deployment that requires "no audit, no action" would need a fail-closed toggle (not yet implemented). - **Mitigation today:** every service exposes the `audit_append_failures_total` counter on its `monitor_listen` endpoint (`/metrics`) — alert on any increase; it is the machine-readable signal that the trail has a gap. The process log also carries `error writing audit log` warnings. Keep the audit volume healthy. ### 10. Kubernetes: token grants the SA's RBAC, not a single call The Kubernetes target (v1.34.0) is a **credential-broker**: for an authorised action the signer mints a bound ServiceAccount token and the broker runs the one API call with it. Two structural differences from SSH follow, by design: - **No inevadible per-call firewall.** SSH bakes a `force-command` into the certificate and sshd enforces it, so the credential does exactly one thing. A Kubernetes token instead carries the **whole RBAC** of its ServiceAccount for its lifetime (600–900s), so within that window it can do anything that SA may do — not only the approved action. Granularity is therefore the SA's native RBAC (layer B) refined by the broker's action policy (layer A), not "only this exact object". **Mitigation:** scope each agent ServiceAccount to least privilege (the layer-A default-deny policy bounds what the broker will *request*, but layer B is what the token actually *grants*); keep the bound TTL at the 600s floor; do not add `secrets` to an allow rule unless required. - **The signer holds a standing cluster credential.** The per-cluster minter token (`token_file`) is a long-lived credential — unlike the SSH CA, which signs but never authenticates as a principal. Its RBAC is deliberately minimal (only `create` on `serviceaccounts/token` for the bound SAs), so a signer compromise yields token-minting for those SAs, not cluster-admin. **Mitigation:** the minter SA's Role must grant nothing else; rotate the `token_file` out-of-band (the signer re-reads it per mint). The reused control plane keeps its guarantees: the action's canonical string is recomputed by the signer and must match the structured request (so the approver and the audit log see what runs), a `k8s_apply` manifest is never logged verbatim (only its sha256), and `require_approval`, grants, and approve-and-learn apply to k8s actions exactly as to shell commands. - **Corollary for `k8s_apply` — approval gates the target, not the payload.** Because the manifest never leaves the broker (only its sha256 is audited), an approval for `apply deployments prod/api` authorizes an **arbitrary manifest spec** for that object — image, privileges, replicas, env — that no human reviewed. The API server binds the path to `metadata.name`/`namespace`, but not the rest of the spec, so the approver sees only the verb/resource/ namespace/name, not *what* is applied. **Mitigation:** scope `apply` rules narrowly (pin `namespaces`/`names`) and reserve them for trusted flows; prefer `require_approval` on `apply` only where the target coordinates alone are a sufficient gate. This is the deliberate cost of not shipping (possibly secret-bearing) manifests through the control plane. ### 11. Out of scope entirely - Confidentiality of command **output** beyond transport TLS (the model sees it by design). - Compromise of the **signer host** or the **operator's** credentials (top of the trust chain — if the CA key host is owned, the model is moot). - Supply-chain integrity of the Go dependencies. - Network-level DoS below the application layer. --- ## Summary | Threat | Status | |---|---| | Credential exfiltration from the agent | **Mitigated** — no reusable credential ever reaches the model | | Compromised agent, one-shot commands | **Mitigated** — policy + force-command + approval, signer-authoritative | | Compromised agent, sessions | **Partial** — every `ssh_session_exec` is broker-preflighted; `shell`/`pty` rejected once policy is active; host-enforced guarantee remains one-shot only | | Compromised broker forging access | **Mitigated** — no CA key; signer derives all constraints | | Stolen cert reuse within TTL | **Accepted risk** — no revocation; bounded by minutes-long TTL | | Compromised agent, Kubernetes actions | **Partial** — layer-A default-deny action policy + approval; layer-B is the SA's RBAC (a bound token grants the SA's whole RBAC for its TTL, not one call — gap #10) | | Signer/operator compromise | **Out of scope** — trusted root | The credential-custody story is strong and complete. The action-control story is strong for one-shot and weaker for sessions because per-command filtering is broker-enforced, not host-enforced. Closing gaps #1 and #3 would be the highest-value security investments.