Threat Model — infrabroker¶
What this system defends, against whom, and — explicitly — what it does not cover. For how the mechanisms work see ARCHITECTURE.md; to report a vulnerability see 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:
- Credential theft — an attacker who reads the agent's memory/logs/traffic should gain nothing reusable.
- 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, andiatwhen a max age is set). Fail-closed (v1.11.2): a missing groups claim (whengroups_claimis configured) or a missingiat(whenmax_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-hostallowed_callersboth gate access; a broker must pass both. - Impersonation is unforgeable:
on_behalf_ofandapprovedare honored only when the mTLS CN is intrusted_forwarders(the control plane).
Signer → host¶
- One-shot: the command is baked into the cert's
force-commandby 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_parseAST 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_approvalcommand unlessapprovedarrives 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-fileverifyaccepts the first entry'sprev_hashas an unchecked seed, so cross-segment integrity requires--all(v1.13.0). Three logs (signer, broker, sshd) correlate by certserial.
Defense in depth (one-shot)¶
A single malicious one-shot command must pass, in order:
- Frontend auth (process / OIDC token).
- Broker→signer mTLS + group RBAC +
allowed_callers. - Per-user RBAC (OIDC groups ∩ host groups), if applicable.
- Command policy (allow/deny,
shell_parse, newline rejection). - Approval gate (if
require_approval). - Behavior guardrails (if the control plane is in
enforce). - 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
(<broker CN>:<end_user>) 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.
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:<rule>] 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) — 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.