SSH certificate authority¶
secsy-pki includes an HSM-backed OpenSSH certificate authority: it signs
OpenSSH user and host certificates with CA keys held by the configured
key provider (PKCS#11 HSM, cloud KMS, or the software keystore), and publishes
revocations as OpenSSH Key Revocation Lists (KRLs) that sshd consumes
directly. The CA private key never leaves the backend — every signature is
produced by the device through crypto.Signer.
Why SSH certificates instead of raw authorized_keys:
- One trust anchor instead of key sprawl. Hosts trust the CA once
(
TrustedUserCAKeys); users and automation get short-lived certificates instead of long-lived keys copied to every server. - Short-lived, policy-shaped credentials. Profiles pin certificate type,
validity bounds, permitted principals, extensions, and critical options
(e.g.
source-address,force-command). - Real revocation. Every certificate carries a store-allocated serial;
revocations (by serial or by key ID) are served as a KRL over HTTP, the
artifact
sshd'sRevokedKeysoption understands. - Enterprise controls. Signing and revocation are RBAC- and tenant-gated
exactly like X.509 issuance, and every operation lands in the tamper-evident
audit log (
ssh.ca_init,ssh.sign,ssh.revoke).
Quick start (CLI)¶
# 1. Create the CA — the key is generated inside the HSM.
# -max-validity is optional; it caps every certificate this CA will ever sign.
$ secsy-ca ssh ca-init -label ops-ssh-ca -key-type ed25519 -max-validity 12h > ops-ssh-ca.pub
# 2. Sign a user's public key into a 12h certificate (user-default profile).
$ secsy-ca ssh sign-user -ca ops-ssh-ca -pub ~/.ssh/id_ed25519.pub \
-key-id alice@corp -principals alice -out ~/.ssh/id_ed25519-cert.pub
# 3. Sign a host key into a host certificate (host-default profile, 90d).
$ secsy-ca ssh sign-host -ca ops-ssh-ca -pub /etc/ssh/ssh_host_ed25519_key.pub \
-principals web1.example.com,web1 -out /etc/ssh/ssh_host_ed25519_key-cert.pub
# 4. Revoke a certificate and regenerate the KRL.
$ secsy-ca ssh revoke -ca ops-ssh-ca -serial 3 -reason "laptop stolen"
$ secsy-ca ssh krl -ca ops-ssh-ca -out /etc/ssh/ops-ssh-ca.krl
# Inventory, profiles, and the CA's signing ceiling.
$ secsy-ca ssh list -ca ops-ssh-ca
$ secsy-ca ssh profiles
$ secsy-ca ssh ca-max-validity -ca ops-ssh-ca
ssh-keygen -L -f id_ed25519-cert.pub shows exactly what was signed;
ssh-keygen -Q -f ops-ssh-ca.krl id_ed25519-cert.pub checks a certificate
against the KRL — the same code path sshd uses.
Deploying trust¶
User certificates — on every host that should accept them
(/etc/ssh/sshd_config):
Fetch both artifacts from the server (public endpoints, no credentials — like CRL distribution):
$ curl -o /etc/ssh/ops-ssh-ca.pub https://pki.example.com/api/ssh/cas/<ca-id>/public
$ curl -o /etc/ssh/ops-ssh-ca.krl https://pki.example.com/api/ssh/cas/<ca-id>/krl
Refresh the KRL on a timer (cron/systemd) so revocations propagate; the KRL header carries a monotonically increasing version (the revocation count) and a generation timestamp.
Host certificates — clients pin the CA in ~/.ssh/known_hosts (or a
global ssh_known_hosts):
and each host presents its certificate (/etc/ssh/sshd_config):
Signing profiles¶
A profile is the policy a certificate is signed under. Two built-ins exist:
| Profile | Type | Default / max validity | Notes |
|---|---|---|---|
user-default |
user | 12h / 30d | standard permit-* extension set; source-address, force-command, verify-required critical options permitted |
host-default |
host | 90d / 366d | no extensions or critical options (host certs carry none) |
Custom profiles are defined in config and overlay the built-ins by name:
ssh:
krl_comment: "corp ops CA" # stamped into generated KRLs
profiles:
- name: ci-deploy
description: CI deploy automation
cert_type: user
default_validity: 1h # Go duration, or 30d / 12w
max_validity: 24h # longer requests are clamped
allowed_principals: ["deploy-*"] # glob patterns; empty = any
max_principals: 2
default_extensions: # applied when the request names none
permit-pty: ""
allowed_critical_options: # request-supplied keys must be listed
- source-address
- name: prod-host
cert_type: host
default_validity: 90d
max_validity: 366d
allowed_principals: ["*.prod.example.com"]
Enforcement rules:
- Certificates must name at least one principal (a principal-less OpenSSH
certificate is valid for every user/host). Opt out per profile with
allow_empty_principals: trueonly if you understand the blast radius. - A request's extensions/critical options replace the profile defaults but
every key must be permitted (a default key or in the
allowed_*list). - Requested validity beyond
max_validityis clamped (matching the X.509 profile convention).ValidAfteris backdated 5 minutes for clock skew. - Host certificates never carry extensions or critical options; requests that ask for them are refused.
The CA's maximum signing duration¶
A profile's max_validity bounds the certificates signed under that profile.
The CA-level maximum bounds every certificate signed by that CA key, no
matter which profile, which endpoint, or which client asked:
Profile max_validity |
CA maximum | |
|---|---|---|
| Scope | one named profile | one authority, all its profiles |
| Set in | config (ssh.profiles), restart/reload to change |
the store — change it live from the CLI, the API, or the console |
| Binds the profile default | no (the default is already within it) | yes — a 90d profile default under a 12h CA is signed for 12h |
Binds the legacy /api/keys/{id}/sign endpoint |
no (that path uses restriction sets) | yes |
Use it when the authority itself has a policy — "the bastion CA never signs anything that outlives a shift" — rather than when one certificate shape does. Because it binds every path through the key, it is the control that cannot be sidestepped by adding a profile or by loosening a restriction set.
# Set it at creation, or any time afterwards (24h, 30d, 12w, or a Go duration).
$ secsy-ca ssh ca-init -label bastion-ca -max-validity 12h
$ secsy-ca ssh ca-max-validity -ca bastion-ca -set 8h
SSH CA "bastion-ca" now signs no certificate longer than 8h0m0s (was 12h0m0s).
Existing certificates keep the lifetime they were signed with.
# Read it back (prints "none" when the CA is uncapped).
$ secsy-ca ssh ca-max-validity -ca bastion-ca
8h0m0s
# Remove it; the signing profiles become the only bound again.
$ secsy-ca ssh ca-max-validity -ca bastion-ca -set none
Clamping, not refusal: a request for more comes back shorter rather than
failing, so a ceiling can be tightened without breaking the clients that ask for
the old lifetime. The CLI, the API response (clamped_by_ca_max_validity,
ca_max_validity_seconds) and the console all say when a clamp happened, and
the ssh.sign audit event records it.
Over REST:
$ curl -u root:$PASSWORD -X PUT https://pki.example.com/api/ssh/cas/$CA/max-validity \
-H 'Content-Type: application/json' -d '{"max_validity_seconds":28800}'
{"ca_id":"…","label":"bastion-ca","max_validity_seconds":28800,"previous_max_validity_seconds":43200}
GET /api/ssh/cas reports each CA's ceiling as max_ssh_validity_secs (absent
or 0 = uncapped). In the console it is the Maximum certificate lifetime
(this CA) panel on the SSH CA view, and a field on the Create SSH CA form.
Two properties worth knowing:
- Not retroactive. Tightening the ceiling does not shorten certificates already signed — revoke them (and publish the KRL) if that is what you need.
- Privilege-granting in one direction. Raising or clearing a ceiling widens
what the authority may sign, so the change takes
ca:manageon the CA (not the issue capability), is step-up capable, and is audited asssh.ca_configwith the old and new value plus the direction of the change.
Serials and revocation¶
Serials are allocated from the CA's monotonic store counter (shared with the X.509 issuance path, so serials are unique per CA regardless of certificate kind) — this is what makes serial-based revocation trustworthy.
Revoke by serial (one certificate) or by key ID (every certificate issued for an identity):
$ secsy-ca ssh revoke -ca ops-ssh-ca -serial 42
$ secsy-ca ssh revoke -ca ops-ssh-ca -key-id alice@corp
The KRL is generated from the revocation store on demand — secsy-ca ssh krl
or GET /api/ssh/cas/{id}/krl — as a KRL_SECTION_CERTIFICATES section bound
to the CA public key (so revoked serials cannot collide with another CA's
serial space), with serial-list and key-ID subsections per OpenSSH's
PROTOCOL.krl. Interop is proven in tests against ssh-keygen -Q.
REST API¶
| Endpoint | Auth | Purpose |
|---|---|---|
POST /api/ssh/cas |
ca:manage (step-up capable) |
initialize an SSH CA |
GET /api/ssh/profiles |
read role | effective signing profiles |
POST /api/ssh/cas/{id}/sign |
issue capability on the CA | sign a user/host certificate |
POST /api/ssh/cas/{id}/revoke |
issue capability (step-up capable) | revoke by serial or key ID |
PUT /api/ssh/cas/{id}/max-validity |
ca:manage on the CA (step-up capable) |
set/clear the CA's maximum certificate lifetime |
GET /api/ssh/cas/{id}/certificates |
read role + tenant membership | inventory |
GET /api/ssh/cas/{id}/revocations |
read role + tenant membership | revocation records |
GET /api/ssh/cas/{id}/public |
public | CA public key (trust anchor) |
GET /api/ssh/cas/{id}/krl |
public | binary KRL for RevokedKeys |
Signing example:
$ curl -u root:$PASSWORD -X POST https://pki.example.com/api/ssh/cas/$CA/sign \
-H 'Content-Type: application/json' \
-d '{"public_key":"ssh-ed25519 AAAA... alice@laptop",
"cert_type":"user","principals":["alice"],"key_id":"alice@corp",
"critical_options":{"source-address":"10.0.0.0/8"}}'
Authorization mirrors X.509 issuance: signing/revocation require the issue
capability within the CA's tenant (or a per-CA SIGN_CERTIFICATE grant);
cross-tenant access is denied. The full OpenAPI definition lives in the served
spec (/openapi.yaml, tag SSH CA).
The legacy
/api/keys/{id}/signendpoint (restriction-set based, random serials, no revocation) remains for the originalsecsy-sshclient; new integrations should use/api/ssh/*, which adds store-allocated serials, profiles, and KRL revocation.
Key types¶
ed25519 (default), ecdsa-p256, ecdsa-p384, rsa-2048, rsa-4096.
Ed25519 requires an EdDSA-capable token (SoftHSM ≥ 2.4, YubiHSM 2); on HSMs
without it use ecdsa-p256. RSA CAs sign with rsa-sha2-512 (never legacy
ssh-rsa/SHA-1). The SoftHSM suite exercises all three families end to end,
including ssh-keygen interop.
Audit and metrics¶
Audit actions: ssh.ca_init, ssh.sign (serial, type, key ID, principals,
profile in the detail — plus the CA ceiling when one clamped the lifetime),
ssh.revoke (target + idempotence), and ssh.ca_config (a change to the CA's
maximum certificate lifetime: old value, new value, and whether the change
tightened, raised or cleared it). CLI operations are recorded with actor cli.
Metrics: secsy_ssh_certificates_total{type,result},
secsy_ssh_revocations_total{result}, secsy_ssh_krl_requests_total{result},
plus the shared HSM operation/duration metrics from the key-provider layer.