SSH PKI — an HSM-backed OpenSSH certificate authority¶
Replace long-lived authorized_keys and known_hosts sprawl with one trust
anchor. Hosts trust the CA once; users and automation present short-lived,
policy-shaped certificates; revocations propagate as OpenSSH KRLs. The CA private
key is generated in, and every signature produced by, the HSM — it never touches
disk.
This example ships:
| File | Purpose |
|---|---|
config.yaml |
Server config with three SSH signing profiles (operator, ci-deploy, prod-host) |
sshd_config.d/50-secsy-trusted-user-ca.conf |
Server-side: trust the CA for user logins + honor the KRL |
sshd_config.d/60-secsy-host-cert.conf |
Server-side: present this host's host certificate |
ssh_known_hosts.example |
Client-side: @cert-authority trust for host certificates |
scripts/refresh-krl.sh |
Pull the current KRL from the server (for cron/systemd) |
scripts/enroll-host.sh |
Sign a host's key into a host certificate |
systemd/ |
Timer + service to refresh the KRL periodically |
Full reference: docs/ca/ssh-ca.md.
1. Start the server¶
$ cp examples/ssh-pki/config.yaml /etc/secsy-pki/config.yaml
# edit pkcs11.module_path / token_label / pin and the profile globs for your org
$ secsy-pki-server -config /etc/secsy-pki/config.yaml
2. Create the CA (key generated inside the HSM)¶
ca-init writes the CA public key to stdout and prints its id + ready-to-paste
trust lines to stderr. Keep the id — the public endpoints are addressed by it.
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh ca-init \
-label ops-ssh-ca -key-type ed25519 > ops-ssh-ca.pub
SSH CA "ops-ssh-ca" created (id 7f3c…, key type ed25519)
Trust this CA for user certificates (sshd_config):
TrustedUserCAKeys /etc/ssh/ops-ssh-ca.pub
Trust it for host certificates (known_hosts):
@cert-authority * ssh-ed25519 AAAA…
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh profiles # confirm your profiles loaded
Ed25519 needs an EdDSA-capable token (SoftHSM ≥ 2.4, YubiHSM 2). On a token without it, use
-key-type ecdsa-p256. RSA CAs sign withrsa-sha2-512, never legacyssh-rsa/SHA-1.
3. Trust the CA on every host¶
Distribute the CA public key and drop the two sshd_config snippets in. The user
CA public key and KRL are served from unauthenticated endpoints (like CRL
distribution), so hosts can pull them without credentials:
# On each host — replace <ca-id> with the id from step 2, pki.example.com with your server
$ curl -fsSo /etc/ssh/ops-ssh-ca.pub https://pki.example.com/api/ssh/cas/<ca-id>/public
$ curl -fsSo /etc/ssh/ops-ssh-ca.krl https://pki.example.com/api/ssh/cas/<ca-id>/krl
$ install -m0644 examples/ssh-pki/sshd_config.d/50-secsy-trusted-user-ca.conf /etc/ssh/sshd_config.d/
$ systemctl reload sshd
50-secsy-trusted-user-ca.conf sets TrustedUserCAKeys (accept user certs signed
by this CA) and RevokedKeys (honor the KRL). Keep the KRL fresh with the
systemd timer so revocations take effect — the KRL header carries a
monotonically increasing version (the revocation count).
4. Issue a user certificate¶
# 8-hour interactive operator cert for alice, principal "alice"
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh sign-user \
-ca ops-ssh-ca -profile operator \
-pub ~alice/.ssh/id_ed25519.pub -key-id alice@corp -principals alice \
-out ~alice/.ssh/id_ed25519-cert.pub
# 15-minute CI deploy cert, locked to the runner's egress CIDR
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh sign-user \
-ca ops-ssh-ca -profile ci-deploy \
-pub deploy_ed25519.pub -key-id "deploy@$GITHUB_RUN_ID" -principals deploy-web \
-option source-address=203.0.113.0/24 \
-out deploy_ed25519-cert.pub
# Inspect exactly what was signed (same parser sshd uses)
$ ssh-keygen -L -f ~alice/.ssh/id_ed25519-cert.pub
The client just needs the private key and the *-cert.pub beside it; ssh sends
the certificate automatically. Nothing is copied into authorized_keys.
5. Issue host certificates (so clients stop trusting-on-first-use)¶
Run scripts/enroll-host.sh on the server operator's
side for each host, then install 60-secsy-host-cert.conf on the host:
$ examples/ssh-pki/scripts/enroll-host.sh ops-ssh-ca web1 web1.prod.example.com,web1
# copies ssh_host_ed25519_key-cert.pub to the host; then on the host:
$ install -m0644 examples/ssh-pki/sshd_config.d/60-secsy-host-cert.conf /etc/ssh/sshd_config.d/
$ systemctl reload sshd
Clients trust host certificates by pinning the CA once in known_hosts — see
ssh_known_hosts.example:
6. Revoke¶
Revoke one certificate by serial, or every certificate issued for an identity by key ID, then regenerate and republish the KRL:
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh revoke -ca ops-ssh-ca -serial 42 -reason "laptop stolen"
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh revoke -ca ops-ssh-ca -key-id alice@corp
# Hosts pull the refreshed KRL on their timer; to verify locally:
$ secsy-ca -config /etc/secsy-pki/config.yaml ssh krl -ca ops-ssh-ca -out ops-ssh-ca.krl
$ ssh-keygen -Q -f ops-ssh-ca.krl ~alice/.ssh/id_ed25519-cert.pub # "... is revoked"
Signing profiles in this example¶
| Profile | Type | Default / max validity | Principals | Notes |
|---|---|---|---|---|
operator |
user | 8h / 24h | ≤ 4, any | permit-pty, permit-user-rc; may pin source-address |
ci-deploy |
user | 15m / 1h | ≤ 1, deploy-* |
no PTY; source-address / force-command allowed |
prod-host |
host | 90d / 366d | *.prod.example.com |
host certs carry no extensions |
Built-in user-default (12h) and host-default (90d) remain available if a
request names no -profile. Requests over max_validity are clamped;
certificates must name at least one principal (a principal-less OpenSSH cert is
valid for everyone — opt out only via allow_empty_principals if you truly
mean it).
Automating issuance¶
- Interactive users: the
secsy-sshclient wraps OIDC login → sign → connect, caching the cert until it expires. - Hosts & agents: call
POST /api/ssh/cas/{id}/signfrom config management, or run the host agent for unattended renewal. - The KRL/public endpoints are safe to put behind a CDN; keep
rate_limitenabled if they face untrusted networks.
Production checklist¶
- Replace
root_user.password; prefer OIDC/API-token operators and setpolicy.allow_root_basic_auth: false. - Source the HSM PIN from a secret, not inline
pkcs11.pin(docs/hsm/configuration.md). - Give the CA a real serving-TLS certificate (or
server.tls.self_issue). - Ship the KRL-refresh timer to every host so revocations converge.
- Keep host and user certificate lifetimes short; short lifetimes are the point.