A ctlplne studio product
trstctl /docs GitHub ↗ Live demo

SSH — replace standing SSH keys with short-lived certificates

What it is

Most SSH access works by copying a user's public key into a server's authorized_keys file. That scales badly and ages dangerously: keys pile up, nobody remembers whose they are, and removing access means hunting them down across every host. An SSH certificate replaces that model: you trust one SSH certificate authority, which signs short-lived certificates saying "this user may log in as alice until 5 p.m." No per-host key copying, automatic expiry, central control.

This page covers trstctl's three SSH pieces: the CA that signs host and user certificates (F43), the agent that safely configures hosts to trust it (F44), and attestation-gated short-lived user certificates tying SSH access to verified identity (F45).

Why it exists

Standing SSH keys are one of the most common audit findings and breach vectors: orphaned keys grant access nobody tracks, and offboarding rarely removes every key. SSH certificates fix the structural problem: access expires on its own, trust is centralized in the CA, and you grant exactly the principals and time window each session needs. The hard parts are changing host trust without locking yourself out, and ensuring only the right identity can get a certificate — what F44 and F45 address.

How it works

The SSH certificate authority (F43)

trstctl's SSH CA signs two kinds of OpenSSH certificate: host certificates (so clients verify a server without trust-on-first-use prompts) and user certificates (so servers authorize logins without a stored key). Each certificate carries principals, a validity window, and optional critical options and extensions.

All signing goes through the single crypto path — one SignSSHCertificate operation taking an opaque signer handle — so the CA key lives in an HSM, held in the isolated signing service, never in the API process or in the clear. An issuance profile bounds the maximum TTL and allowed certificate types; serial numbers increment under a lock; every issuance is recorded as an immutable ssh.cert.issued event in its own bounded lane. The CA also maintains a key revocation list (KRL): revoke by serial or key ID, then distribute a snapshot to hosts, pulling back a certificate before it expires.

The operator workflow is served two ways: OpenSSH-compatible protocol endpoints (/ssh/ca, /ssh/issue/user, /ssh/issue/host, /ssh/krl) and a guarded product API used by the CLI and console. POST /api/v1/ssh/certificates/preview validates and normalizes an exact host or user request without allocating a serial, writing an event, changing the KRL, making a network call, or calling the signer. It shows the requested and effective TTL (default 1 hour, hard maximum 24 hours), deduplicated principals, public-key and authority fingerprints, applied options/extensions, the one future signer call, and the revocation path. POST /api/v1/ssh/certificates revalidates the same contract and issues exactly one certificate behind an Idempotency-Key; a stable retry returns the first result instead of signing again.

The direct user-certificate path allowlists only source-address and force-command critical options plus known OpenSSH session extensions. Host certificates reject all critical options and extensions. Both paths accept one public key and never accept or return its private key. Status and revocation remain available at GET /api/v1/ssh/status and POST /api/v1/ssh/certificates/revoke; revocation appends an immutable, tenant-scoped ssh.cert.revoked event before publishing the updated KRL snapshot. On every control-plane start, trstctl rebuilds the KRL from those events before serving SSH. A malformed matching event stops startup instead of publishing a partial or empty revocation list.

SSH deployment & trust configuration (F44)

For a host to accept the CA's certificates, it must trust the CA's public key, written into TrustedUserCAKeys and referenced from sshd_config. Editing sshd_config on a live fleet is exactly where people lock themselves out, so trstctl's agent follows a hard rule: additive-only, validated before it takes effect, and rolled back automatically on any failure.

The agent backs up both files, is idempotent (a CA line already present is a no-op), writes changes atomically (write-temp-then-rename), then runs a three-step gauntlet: validate (sshd -t), reload, and health-check that sshd still accepts connections. On any failure it restores both files from backup and reloads the known-good config; reload and health commands are operator-supplied, required, and run as validated argv lines with shell metacharacters rejected (--ssh-trust-reload-cmd, --ssh-trust-health-cmd) — reload success alone isn't proof of health. Removing trust is never implicit: RemoveCATrust needs an explicit confirmation flag. Every action is audited (ssh.trust.added, ssh.trust.removed, ssh.trust.rolled_back, and ssh.trust.rollback_failed on a failed restoration), leaving an unclear host state that needs operator attention.

The control plane also has a served handoff for this high-blast-radius path: POST /api/v1/ssh/trust-rollouts records the source, target hosts, CA fingerprint, reload/health commands, rollback plan, status, and an explicit confirmed=true acknowledgement; POST /api/v1/ssh/hosts/retire records retirement evidence once migration completes. The browser and CLI record/request the workflow, but host file edits happen only inside the operator-confirmed agent path.

Attestation-gated short-lived user certificates (F45)

The most powerful pattern: issue an SSH user certificate only to a caller who proves identity first. This issuer runs an attestation check (the same chain used for workload identity), then derives principals from the verified attestation and calls the SSH CA. It requires an approver distinct from the attested subject, rejects unbound principals, supports OpenSSH source-address and force-command critical options, fails closed on attestation failure, defaults to a 15-minute TTL (capped by the profile), and binds the attestation via an immutable ssh.attested_cert.issued event: access short-lived and provably tied to a specific CI job or cloud instance — no standing keys.

Review the exact request first with POST /api/v1/ssh/attested-user-certs/preview or trstctl ssh preview-attested-user. The preview reads tenant trust, validates and normalizes the public key, approver, principals, lifetime, source addresses, and forced command, then returns proof and key fingerprints, the isolated-signer action, and recovery instructions. It performs no proof verification, write, external call, audit emission, or signer call; proof verification remains execution-only because a proof may contain one-time evidence.

Execution is served at POST /api/v1/ssh/attested-user-certs and by trstctl ssh issue-attested-user; the request carries an attestation method, base64 payload, SSH public key, approver, optional key ID, principals, TTL, source-address allowlist, and force-command policy. The response is the certificate plus serial, key ID, expiry, constraints, and the attestation record — the private key never crosses the API or UI. The console retains one request-scoped idempotency key after an uncertain response; its Retry unchanged request action recovers the original result instead of signing again. For CLI recovery, set TRSTCTL_IDEMPOTENCY_KEY to a stable value and reuse the same command body. A changed request with that key is rejected with HTTP 409.

Use it

Stand up the SSH CA, distribute its public key to hosts via the agent, then issue short-lived user certificates. The CA's public key goes into a host's trust config like this (what the agent writes, additively):

For a container deployment, bind the served SSH workflow and the shared attestation mint to one tenant. This exposes the workflow; it does not invent a trusted identity source:

TRSTCTL_PROTOCOLS_SSH_ENABLED=true
TRSTCTL_PROTOCOLS_SSH_TENANT_ID=11111111-1111-4111-8111-111111111111
TRSTCTL_ATTESTED_ISSUANCE_ENABLED=true
TRSTCTL_ATTESTED_ISSUANCE_TRUST_DOMAIN=example.org
TRSTCTL_ATTESTED_ISSUANCE_DEFAULT_TTL=10m
TRSTCTL_ATTESTED_ISSUANCE_MAX_TTL=1h

Add an enabled, tenant-scoped public trust source through /api/v1/workloads/attester-trust-sources. The same source gates both workload SVIDs and attested SSH user certificates. If no matching source exists, both mints fail closed; the console does not show a fake list of available attestors.

# /etc/ssh/sshd_config
TrustedUserCAKeys /etc/ssh/trusted_user_ca_keys
# /etc/ssh/trusted_user_ca_keys  (the CA public key in authorized_keys form)
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... trstctl-ssh-ca

A user certificate is then issued with an attestation-bound principal and a short TTL (e.g. 15 minutes); the user connects normally and sshd validates the certificate against the trusted CA without any stored key.

trstctl ssh status
trstctl ssh preview \
  --type host \
  --public-key "$(cat /etc/ssh/ssh_host_ed25519_key.pub)" \
  --key-id edge-1.internal \
  --principals edge-1.internal,edge-1 \
  --ttl-seconds 86400
trstctl ssh issue \
  --type host \
  --public-key "$(cat /etc/ssh/ssh_host_ed25519_key.pub)" \
  --key-id edge-1.internal \
  --principals edge-1.internal,edge-1 \
  --ttl-seconds 86400
trstctl ssh trust-rollout \
  --hosts edge-1.internal \
  --ca-fingerprint SHA256:... \
  --reload-cmd 'systemctl reload sshd' \
  --health-cmd 'ssh -o BatchMode=yes localhost true' \
  --rollback-plan 'restore backup, reload sshd' \
  --status health_passed \
  --confirm
cat > ssh-attested-user.json <<EOF
{
  "method": "k8s_sat",
  "payload_base64": "$K8S_SAT_B64",
  "public_key": "$(cat ~/.ssh/id_ed25519.pub)",
  "approver": "ssh-approver",
  "principals": ["web"],
  "source_addresses": ["10.0.0.0/24"],
  "force_command": "/usr/local/bin/deploy",
  "ttl_seconds": 900
}
EOF
trstctl ssh issue-attested-user -f ssh-attested-user.json
trstctl ssh revoke --serial 42 --reason 'revoked'
trstctl ssh retire-host --host edge-1.internal --reason 'replaced'

Pitfalls & limits

  • Never hand-edit trust on a live host. Use the agent so the validate-reload-health-check-rollback safety net applies — a bad manual sshd_config edit can lock you out, and trstctl won't remove existing trust without explicit confirmation.
  • Serving status: the SSH CA is served by the running control plane (protocols.ssh.enabled, default off): cert issuance at /ssh/..., the OpenSSH binary KRL at /ssh/krl (sshd's RevokedKeys consumes it), and workflow API/CLI coverage for effect-free direct issuance preview, idempotent host/user issuance, status, trust rollout evidence, attested user cert issue, KRL revocation, and host retirement. The CA key stays in the isolated signing service, never the API process, with every step recorded as an immutable event and tenant data isolated at the database layer. SSH host-key discovery is also served via ssh discovery sources on the outbox worker; privileged trust rewrites still need the explicit agent-safe rollout workflow — see Current limitations.
  • Short TTLs require renewal. That's the security benefit, but plan the renewal path for long-running sessions.
  • KRL distribution is push-based. Revoking a certificate means distributing the updated KRL to hosts — budget for that propagation.

Reference

  • CA operations: IssueUserCert, IssueHostCert, AuthorityKey (for TrustedUserCAKeys / @cert-authority), KRL.RevokeSerial, KRL.Distribute.
  • Served API/CLI: POST /api/v1/ssh/certificates/preview, POST /api/v1/ssh/certificates, GET /api/v1/ssh/status, POST /api/v1/ssh/trust-rollouts, POST /api/v1/ssh/attested-user-certs/preview, POST /api/v1/ssh/attested-user-certs, POST /api/v1/ssh/certificates/revoke, POST /api/v1/ssh/hosts/retire; trstctl ssh preview|issue|status|trust-rollout|preview-attested-user|issue-attested-user|revoke|retire-host.
  • Agent config: SSHDConfigPath, TrustedUserCAKeysPath, AllowUnconfirmedRemoval (default false).
  • Attested issuance: AttestedUserCertIssuer.Issue (method+payload → cert).
  • Events: ssh.cert.issued, ssh.attested_cert.issued, ssh.trust.added, ssh.trust.removed, ssh.trust.rolled_back, ssh.trust.rollback_failed.
  • Standard: OpenSSH certificate format (PROTOCOL.certkeys).
  • Design deep-dive: SSH trust-rewrite design.

See also

Workload identity (attestation chain F45 reuses) · Issuance & certificate authorities · SSH trust-rewrite design · Discovery & inventory (finding SSH keys) · glossary: SSH certificate, attestation, HSM/KMS

Covers: F43, F44, F45

Rendered live from github.com/ctlplne/trstctl — found a mistake? edit this page.