Keys and certificates
The same objects described twice. First as the protocol sees them — what a key is when you strip away the mathematics, and what problem certificates were invented to solve. Then as the code sees them — structs, wire bytes, files and the functions that check them.
Read part one if you want to know why SSH certificates look the way they do. Read part two if you need to change something.
Part one: the abstract view
Section titled “Part one: the abstract view”A key is not a number
Section titled “A key is not a number”In the protocol, a public key is a triple:
- an algorithm name — a string like
ssh-ed25519 - an opaque blob — bytes whose meaning only the algorithm knows
- a verify function — given (blob, signature, data) → yes or no
The protocol never looks inside the blob. It negotiates by name, passes length-prefixed strings around, and delegates every judgement about the contents to the algorithm implementation. That is why adding a key type — ed25519, FIDO tokens, post-quantum hybrids — has never required a protocol revision. The framing has no opinion about what it is framing.
What authentication actually proves
Section titled “What authentication actually proves”Possessing a private key is not enough; you must prove possession now, to this server, for this purpose. So the client signs a blob containing:
- the connection’s session identifier, which is the first key exchange hash and unique to this connection;
- the username, service and method being requested;
- the public key being offered.
Three properties fall out. The session id makes the signature useless anywhere else, so a captured signature cannot be replayed against another server. The username and method make it useless for a different request on the same connection. And because the session id comes from a key exchange the server participated in, a man in the middle cannot forward it.
This is why the server does not merely check a signature — it checks that the signed data is a well-formed request naming the right session. See server-side authentication.
The problem certificates solve
Section titled “The problem certificates solve”Raw keys work, and they scale badly in both directions.
Server side. Every server needs every authorised user’s public key in
~/.ssh/authorized_keys. A thousand servers and five hundred users is half a
million file entries that must be kept in sync. Removing someone means touching
every host.
Client side. Every client needs every host’s key in known_hosts. In
practice nobody distributes those, so the first connection asks a question the
user cannot answer:
The authenticity of host 'db-07' can't be established.ED25519 key fingerprint is SHA256:…Are you sure you want to continue connecting (yes/no)?Trust on first use is not a trust model, it is a deferral. Rebuild the host and everyone gets a warning they are trained to click through.
Both problems have the same shape: n×m distribution of long-lived facts, with no expiry and no revocation.
What a certificate is
Section titled “What a certificate is”A certificate is one signed sentence:
This CA asserts that this public key speaks for these principals, between these two times, under these constraints.
That is the whole idea. The verifier needs exactly one long-lived fact — the CA’s public key — and everything else arrives in the certificate the client presents.
Note carefully what an SSH certificate is not:
- Not a chain. One signature, by one CA, over the certificate. There are no intermediates and no path-building algorithm.
- Not X.509. No ASN.1, no DER, no distinguished names, no OIDs. The encoding is the same length-prefixed strings as everything else on the wire.
- Not a namespace. A certificate does not name a global identity; it lists principals it is permitted to claim.
The omissions are the design. Chain validation is where most PKI vulnerabilities have historically lived, and SSH simply does not have that code.
Two directions, one mechanism
Section titled “Two directions, one mechanism”The same object solves both distribution problems, pointed opposite ways:
| User certificate | Host certificate | |
|---|---|---|
| Signed by | A CA the server trusts | A CA the client trusts |
| Presented by | The client, during authentication | The server, during key exchange |
| Principals are | Usernames the holder may log in as | Hostnames the server may claim to be |
| Replaces | Entries in authorized_keys | Entries in known_hosts |
| Configured with | TrustedUserCAKeys, or a cert-authority line in authorized_keys | An @cert-authority line in known_hosts |
Host certificates are the half people forget, and they are the half that kills
the TOFU prompt outright: one @cert-authority line, and every host in the
fleet verifies on first contact, forever.
Principals, not names
Section titled “Principals, not names”A certificate does not say “this is Alice”. It says “this key may claim to be
alice, deploy, or admin”. The server decides how that list is used:
- by default, the requested Unix username must appear in the principal list;
- with
AuthorizedPrincipalsFileorAuthorizedPrincipalsCommand, the server matches the certificate’s principals against its own list instead, decoupling the certificate entirely from local usernames.
Host certificates use the same field for hostnames, with wildcard patterns
allowed, so *.db.example.com is one certificate.
Critical options versus extensions
Section titled “Critical options versus extensions”Every certificate carries two bags of name/value pairs, and the difference between them is the most elegant thing in the format:
| Unknown name means | |
|---|---|
| Critical options | Reject the certificate |
| Extensions | Ignore the entry and continue |
The issuer chooses which bag to use, and thereby chooses what an older verifier is permitted to not understand.
Put force-command in critical options and a verifier too old to understand it
refuses the certificate — which is right, because honouring the certificate
while ignoring the restriction would be a privilege escalation. Put
permit-pty in extensions and an old verifier that ignores it simply grants
less than intended — a safe failure.
That is forward compatibility done properly: the security consequence of not understanding something is decided by the party who knows what it means.
Validity, and what replaces revocation
Section titled “Validity, and what replaces revocation”Certificates carry valid_after and valid_before. The intended model is
short lifetimes — hours, issued on demand by something that has already
authenticated the user another way. Expiry then does the work that revocation
would otherwise have to.
For the cases where you must revoke early, SSH has key revocation lists rather than anything online: no OCSP, no CRL fetching, no network dependency in the authentication path. A KRL is a compact local file that can revoke by serial number range, by key id, by explicit key, or by CA.
Part two: the engineering view
Section titled “Part two: the engineering view”One struct, one extra pointer
Section titled “One struct, one extra pointer”A certificate is not a separate type. It is a
struct sshkey whose cert pointer is non-NULL and whose
type is a different enum value — KEY_ED25519_CERT rather than
KEY_ED25519:
struct sshkey_cert { struct sshbuf *certblob; /* the original bytes, kept verbatim */ u_int type; /* SSH2_CERT_TYPE_USER or _HOST */ uint64_t serial; char *key_id; u_int nprincipals; char **principals; uint64_t valid_after, valid_before; struct sshbuf *critical; struct sshbuf *extensions; struct sshkey *signature_key; /* the CA's public key */ char *signature_type;};(sshkey.h:98) The helpers you will meet constantly:
| Function | Does |
|---|---|
sshkey_is_cert | Is this a certificate? |
sshkey_type_plain | KEY_ED25519_CERT → KEY_ED25519 |
sshkey_to_certified | Attach an empty cert, promote the type |
sshkey_drop_cert | Strip back to the bare key |
certblob matters more than it looks: the raw bytes are kept so the signature
can be verified over exactly what arrived, and so the certificate can be
re-serialised without a lossy round trip.
The wire encoding
Section titled “The wire encoding”A certificate blob is the plain key blob with a nonce bolted on the front and
the assertion bolted on the back. Parsed by
cert_parse:
| Field | Type | Notes |
|---|---|---|
| type name | string | ssh-ed25519-cert-v01@openssh.com |
| nonce | string | 32 random bytes (sshkey.c:2313) |
| public key fields | varies | exactly as in the uncertified key |
| serial | uint64 | for revocation by range |
| type | uint32 | 1 = user, 2 = host |
| key id | string | free text, appears in the server’s logs |
| valid principals | string | a nested blob of strings, max 256 |
| valid after / before | uint64 ×2 | seconds since epoch |
| critical options | string | nested name/value pairs |
| extensions | string | nested name/value pairs |
| reserved | string | ignored, must be present |
| signature key | string | the CA’s public key blob |
| signature | string | over everything above |
The nonce exists so that two certificates over the same key and fields are not byte-identical — it removes any chance of the signature being over attacker-chosen predictable input.
cert_parse computes the signed length as everything before the signature
field, which is why the format can be extended only by appending — and why
reserved is there.
Three kinds of name
Section titled “Three kinds of name”This trips people up, because three different things all look like algorithm names:
| Name | What it is | Example |
|---|---|---|
| Key type | The algorithm of the key itself | ssh-ed25519 |
| Certificate type | The certified form of that key type | ssh-ed25519-cert-v01@openssh.com |
| Signature algorithm | How an RSA key signs | rsa-sha2-512 |
They compose: rsa-sha2-512-cert-v01@openssh.com is an RSA certificate whose
signature uses SHA-512. PubkeyAcceptedAlgorithms matches against these full
names, which is why allowing ssh-ed25519 does not allow
ssh-ed25519-cert-v01@openssh.com — they are different strings and must both
be listed.
Issuing
Section titled “Issuing”sshkey_certify fills in the fields, generates the nonce,
serialises everything, and signs with the CA key. ssh-keygen is the front end:
ssh-keygen -t ed25519 -f user_ca -C "user CA"
ssh-keygen -s user_ca -I "alice@laptop" -n alice,deploy -V +8h id_ed25519.pub-I is the key id that will appear in the server’s logs, -n the principals,
-V the validity window. Host certificates add -h.
Verifying a user certificate
Section titled “Verifying a user certificate”user_cert_trusted_ca is the whole server-side
policy, in order:
- Is the CA trusted?
sshkey_in_file(key->cert->signature_key, options.trusted_user_ca_keys, …)— the CA’s public key must appear inTrustedUserCAKeys. - Principal match. If
AuthorizedPrincipalsFileorAuthorizedPrincipalsCommandis set, the certificate’s principals are matched against that list and a match is required. Otherwise the requested username must appear in the principals. sshkey_cert_check_authority_now— user-versus-host type,valid_after/valid_beforeagainst the clock, a non-empty principal list, and the principal match itself.- Options become policy.
sshauthopt_from_certturns the two option bags into astruct sshauthopt;auth_authorise_keyoptsapplies things likesource-address. - Merge. If a principals file supplied its own options, they are merged with the certificate’s, and the more restrictive wins.
Note what has not happened yet: the signature over the authentication request has not been checked. Being a valid certificate only establishes that this key is allowed to try — proof of possession is a separate step, and on the server it happens across the privilege boundary in the monitor.
The per-user alternative to TrustedUserCAKeys is a cert-authority prefix on
an authorized_keys line, which trusts that CA for that one account.
Verifying a host certificate
Section titled “Verifying a host certificate”The client side is smaller. known_hosts lines may be prefixed with
@cert-authority; when the host presents a certificate,
the hostname is matched against the principals as a wildcard pattern rather than
compared literally (sshconnect.c:1317). Servers offer
one via HostCertificate in sshd_config.
Options into policy
Section titled “Options into policy”cert_option_list walks each bag and folds recognised
names into struct sshauthopt. The critical/extension asymmetry described in
part one is literally these few lines
(auth-options.c:167):
error("Certificate critical option \"%s\" is not supported", name);goto out; /* ← reject the certificate */} else { logit("Certificate extension \"%s\" is not supported", name);} /* ← ignore and carry on */What is currently understood:
| Critical options | Extensions |
|---|---|
force-command | permit-X11-forwarding |
source-address | permit-agent-forwarding |
verify-required | permit-port-forwarding |
permit-pty, permit-user-rc | |
no-touch-required |
The split follows the rule exactly: everything that restricts is critical, everything that permits is an extension. Ignoring a restriction is dangerous; ignoring a permission is merely inconvenient.
Revocation
Section titled “Revocation”krl.c implements KRLs as red-black trees of revoked serial ranges,
key ids and explicit keys, checked by
ssh_krl_check_key. Configure with RevokedKeys in
sshd_config or RevokedHostKeys in ssh_config.
ssh-keygen -k -f revoked.krl -s user_ca.pub serials.txtssh-keygen -Q -f revoked.krl id_ed25519-cert.pub # test a key against itBecause a KRL can revoke by serial range, revoking everything issued before a given point costs one line — which is what makes short-lived certificates practical to operate.
Where it all lives
Section titled “Where it all lives”| File | Holds |
|---|---|
sshkey.h / sshkey.c | Both structs, parsing, serialisation, issuing, the authority check |
auth-options.c | Certificate options and authorized_keys options → sshauthopt |
auth2-pubkey.c | Server-side user certificate policy |
hostfile.c / sshconnect.c | Client-side host certificate policy |
krl.c | Revocation lists |
ssh-keygen.c | Issuing, inspecting, KRL management |
Looking at one
Section titled “Looking at one”The fastest way to make the format concrete:
ssh-keygen -L -f id_ed25519-cert.pubIt prints every field in the table above — serial, key id, principals, validity window, critical options and extensions — in the same order the bytes appear on the wire.
For how a key is used once it is trusted, see client authentication; for the struct in its wider context, core types.