Skip to content

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.


In the protocol, a public key is a triple:

  1. an algorithm name — a string like ssh-ed25519
  2. an opaque blob — bytes whose meaning only the algorithm knows
  3. 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.

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.

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.

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.

The same object solves both distribution problems, pointed opposite ways:

User certificateHost certificate
Signed byA CA the server trustsA CA the client trusts
Presented byThe client, during authenticationThe server, during key exchange
Principals areUsernames the holder may log in asHostnames the server may claim to be
ReplacesEntries in authorized_keysEntries in known_hosts
Configured withTrustedUserCAKeys, or a cert-authority line in authorized_keysAn @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.

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 AuthorizedPrincipalsFile or AuthorizedPrincipalsCommand, 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.

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 optionsReject the certificate
ExtensionsIgnore 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.

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.


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:

FunctionDoes
sshkey_is_certIs this a certificate?
sshkey_type_plainKEY_ED25519_CERT → KEY_ED25519
sshkey_to_certifiedAttach an empty cert, promote the type
sshkey_drop_certStrip 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.

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:

FieldTypeNotes
type namestring[email protected]
noncestring32 random bytes (sshkey.c:2313)
public key fieldsvariesexactly as in the uncertified key
serialuint64for revocation by range
typeuint321 = user, 2 = host
key idstringfree text, appears in the server’s logs
valid principalsstringa nested blob of strings, max 256
valid after / beforeuint64 ×2seconds since epoch
critical optionsstringnested name/value pairs
extensionsstringnested name/value pairs
reservedstringignored, must be present
signature keystringthe CA’s public key blob
signaturestringover 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.

This trips people up, because three different things all look like algorithm names:

NameWhat it isExample
Key typeThe algorithm of the key itselfssh-ed25519
Certificate typeThe certified form of that key type[email protected]
Signature algorithmHow an RSA key signsrsa-sha2-512

They compose: [email protected] is an RSA certificate whose signature uses SHA-512. PubkeyAcceptedAlgorithms matches against these full names, which is why allowing ssh-ed25519 does not allow [email protected] — they are different strings and must both be listed.

sshkey_certify fills in the fields, generates the nonce, serialises everything, and signs with the CA key. ssh-keygen is the front end:

Terminal window
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.

user_cert_trusted_ca is the whole server-side policy, in order:

  1. Is the CA trusted? sshkey_in_file(key->cert->signature_key, options.trusted_user_ca_keys, …) — the CA’s public key must appear in TrustedUserCAKeys.
  2. Principal match. If AuthorizedPrincipalsFile or AuthorizedPrincipalsCommand is set, the certificate’s principals are matched against that list and a match is required. Otherwise the requested username must appear in the principals.
  3. sshkey_cert_check_authority_now — user-versus-host type, valid_after/valid_before against the clock, a non-empty principal list, and the principal match itself.
  4. Options become policy. sshauthopt_from_cert turns the two option bags into a struct sshauthopt; auth_authorise_keyopts applies things like source-address.
  5. 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.

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.

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 optionsExtensions
force-commandpermit-X11-forwarding
source-addresspermit-agent-forwarding
verify-requiredpermit-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.

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.

Terminal window
ssh-keygen -k -f revoked.krl -s user_ca.pub serials.txt
ssh-keygen -Q -f revoked.krl id_ed25519-cert.pub # test a key against it

Because 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.

FileHolds
sshkey.h / sshkey.cBoth structs, parsing, serialisation, issuing, the authority check
auth-options.cCertificate options and authorized_keys options → sshauthopt
auth2-pubkey.cServer-side user certificate policy
hostfile.c / sshconnect.cClient-side host certificate policy
krl.cRevocation lists
ssh-keygen.cIssuing, inspecting, KRL management

The fastest way to make the format concrete:

Terminal window
ssh-keygen -L -f id_ed25519-cert.pub

It 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.