neenv

The root of trust: who signs what, where the keys are kept, and how a certificate is taken back

The problem

Every machine and every device trusts one root certificate. That trust is what lets a browser open an internal name with no warning, a proxy check a calling service's certificate, and a sign-in server recognise a person's laptop. A root limited to your own names stops a stolen key from forging anyone else's website. It does not answer four other questions:

Without a hierarchy, the one key that every device trusts sits on the server that signs, and a compromise of that server is a compromise of all trust. With one, the root signs only a few intermediate certificates and is then put away. Each intermediate signs one kind of certificate. A compromised signing server costs one intermediate, which the root can disown, not the root itself.

Think of a bank. The board's seal is in the vault and is brought out to appoint branch managers. Each branch manager signs the day's cheques. A forged cheque from one branch is that branch's problem; the board appoints a new manager without changing its seal.

Words used here. A root is a certificate that signs itself and that clients are told to trust. An intermediate (or issuing CA) is a certificate the root signed that may itself sign. A leaf is a certificate that may not sign: a server's, a service's or a person's. A cross-certificate is a second certificate for an existing intermediate's key, signed by a different root. A revocation list (CRL) is a signed list of serial numbers its issuer has taken back.

What runs here

step-ca, an open-source certificate authority, in two separate instances, one per intermediate (one instance signs with one intermediate). nginx at the edge checks client certificates and revocation lists. OpenSSL, run by hand, does the few acts that need the root key.

The hierarchy

Root (limited to our names, pathlen 1, 20 years, EC P-256)
│   key: on no machine
├── Service CA (pathlen 0)            servers and services
│     ├── 24-hour identities          service to service, renewed by machine
│     └── 1-year certificates         private-suffix names a person opens in a browser
├── Person CA (pathlen 0)             people's devices
│     └── 1-year client certificates  CN=<person>-<machine>@production, client authentication only
│
SSH host CA key, SSH user CA key      plain SSH CA keys, served by the service CA's instance

Non-production person CA (self-signed, separate)   trusted only by non-production edges; on no device

Where each key is kept, by kind

Key Kept Used for
Root On no machine. Encrypted, in the password manager people use, with that store's own backup and recovery. Minting an intermediate or a cross-certificate; signing the root's own revocation list once a year
Each intermediate On its CA's host, encrypted. The password is fetched from the secret store when the host is configured and placed beside the key, so the CA starts unattended. Signing every certificate of its class
SSH host and user CA keys Made on the configuration controller, encrypted with the CA key password, stored in the password manager before first use, placed on the CA host Signing SSH certificates
A device's key On the device; for SCEP, made there and not exportable Proving the device is who it says

The CA's availability is a cost of short lifetimes

If the service CA is down, nothing renews. Renewal fires at about two thirds of a certificate's life, so 24-hour identities give about 8 hours before the first one expires and about 24 before most have. Watching the CA host is therefore not optional.

Configuration, with example values

The service CA's lifetimes, as step-ca provisioners (ca.json, abridged):

"authority": {
  "claims": { "minTLSCertDuration": "5m", "defaultTLSCertDuration": "24h", "maxTLSCertDuration": "8760h" },
  "provisioners": [
    { "type": "ACME", "name": "acme",     "claims": { "defaultTLSCertDuration": "24h",   "maxTLSCertDuration": "24h" } },
    { "type": "ACME", "name": "acme-web", "claims": { "defaultTLSCertDuration": "8760h", "maxTLSCertDuration": "8760h" } }
  ]
}

The person CA's revocation list and device template:

"crl": { "enabled": true, "generateOnRevoke": true, "cacheDuration": "48h", "renewPeriod": "24h",
         "idpURL": "https://person-ca.home.arpa/1.0/crl" }
{{- if not (regexMatch "^[a-z][a-z0-9]*-[a-z0-9-]+@production$" .Insecure.CR.Subject.CommonName) -}}
  {{- fail "refused: CN is not <person>-<machine>@production" -}}
{{- end -}}
{ "subject": {"commonName": {{ toJson .Insecure.CR.Subject.CommonName }}, "organizationalUnit": ["person"]},
  "keyUsage": ["digitalSignature", "keyEncipherment"], "extKeyUsage": ["clientAuth"],
  "basicConstraints": {"isCA": false},
  "crlDistributionPoints": ["https://person-ca.home.arpa/1.0/crl"] }

An edge that admits people (nginx). Verification is optional, because a visitor with no certificate must still reach the sign-in page. Admission is therefore decided after it: the certificate is passed to the sign-in server only when nginx verified it, and the sign-in server maps the subject to a person and checks that person's list of machines. A header a caller types is overwritten on every request.

ssl_client_certificate /etc/ssl/person-ca-chain.pem;     # the person CA, then the root
ssl_crl                /etc/ssl/person-ca-chain.crl.pem; # a list for EVERY CA in that file
ssl_verify_depth       2;                                # leaf <- person CA <- root
ssl_verify_client      optional;

set $person_cert "";
if ($ssl_client_verify = SUCCESS) {                      # only a verified chain is passed on
    set $person_cert $ssl_client_escaped_cert;
}

location / {
    proxy_set_header ssl-client-cert   $person_cert;     # empty (dropped) unless verified
    proxy_set_header ssl-client-verify $ssl_client_verify;
    proxy_pass https://sign-in.home.arpa;
}

The issuer check this edge should also carry (not in place here; see open). It admits a verified certificate only when the person CA issued it:

map "$ssl_client_verify|$ssl_client_i_dn" $person_cert_ok {
    default 0;
    "SUCCESS|CN=Example Person CA,OU=person,O=Example" 1;   # the person CA's subject, as nginx prints it
}
# then: set $person_cert only when $person_cert_ok is 1

Replacing the root

The mechanism is in the constrained-root pattern: the new root cross-signs each intermediate's existing key, both roots are trusted during the overlap, and the old one leaves last. No device re-enrols and no key changes. What a whole estate adds:

  1. Mint the new root and trust both. Put its key in the store at once.
  2. Devices install the new root next to the old one. On a phone or laptop that is the person's act.
  3. Each CA presents the old-issued certificate first, then the cross. What was measured, with the server sending leaf, old-issued, cross: OpenSSL 3.5, curl, Python and Node holding the old root alone accepted it, and holding the new root alone refused it; with the order reversed, the reverse. OpenSSL does search for alternative chains, but in this measurement it did not reach the new root through the second, untrusted certificate of the same subject. Go, Chromium and Firefox accepted either order with either root. So this order keeps every client that holds only the old root working, and path builders reach the new root through the cross. An OpenSSL-family client must not be moved to the new root alone until the cross is presented first or alone.
  4. Re-issue the long-lived certificates. A year-long certificate keeps the chain it was issued with for its whole life (certbot stores it), so a device that trusts only the new root cannot reach it. Re-issue every one whose stored chain differs from what the CA presents now. 24-hour certificates turn over by themselves.
  5. Find every private copy of the old root before removing it: application trust stores (Java keystores, a secret store's own CA file), files a tool reads with a single-root option, downloads you publish for devices, and copies inside container images. Each one holding the old root alone breaks at the next step.
  6. The CAs present the cross alone; the old root leaves every store. Devices remove it too.
  7. The new root signs its own revocation list. Once a chain ends at the new root, an edge that checks the whole chain needs a list signed by it (the drill shows this).

Signing the new root with the old one does not work with pathlen:1. The chain cross, then new root, then old root has two CAs between the old root and the leaf, which pathlen:1 forbids. Cross-sign the intermediates instead.

Taking a certificate back

There are two ways, by class.

A service certificate is not revoked; it expires. It lives 24 hours. To take one back, stop it renewing (remove the line that allows it) and wait one lifetime. No list is published for services.

A person certificate is on a revocation list, which the edge enforces.

The sign-in server's own revocation check is off; nginx refuses before the request reaches it.

The drill

drill-revocation.sh builds a throwaway hierarchy in a private temporary directory (old root, new root limited to home.arpa, a person CA with an old-issued and a cross-certificate, a service CA, two person devices and a 24-hour service certificate), revokes one device, and checks with openssl verify. The person edge's issuer check is played by comparing the leaf's issuer with the person CA's subject, as the nginx map above would; nginx itself is not run. A refusal counts only for its stated reason. It reads no real key, contacts nothing, and deletes everything on exit.

Needs: bash and OpenSSL 3.0 or later, on Linux or macOS (macOS ships LibreSSL; use Homebrew's openssl@3, or point OPENSSL= at it).

$ ./drill-revocation.sh
PASS kept-device want=ok got=ok
PASS revoked want=refused(certificate revoked) got=refused(certificate revoked)
PASS no-revocation-check want=ok got=ok
PASS root-list-missing want=refused(unable to get certificate CRL) got=refused(unable to get certificate CRL)
PASS list-stale want=refused(CRL has expired) got=refused(CRL has expired)
PASS new-root-kept-device want=ok got=ok
PASS new-root-revoked want=refused(certificate revoked) got=refused(certificate revoked)
PASS new-root-old-list-only want=refused(unable to get certificate CRL) got=refused(unable to get certificate CRL)
PASS service-today want=ok got=ok
PASS service-tomorrow want=refused(certificate has expired) got=refused(certificate has expired)
PASS service-chain-at-person-edge want=ok got=ok
PASS service-at-person-edge-with-lists want=refused(unable to get certificate CRL) got=refused(unable to get certificate CRL)
PASS service-at-person-edge-issuer-check want=refused(issuer is not the person CA) got=refused(issuer is not the person CA)
PASS person-at-person-edge-issuer-check want=ok got=ok
14 passed, 0 failed

no-revocation-check shows that the revoked device is admitted by any client that does not check: the list is only as good as the edge that reads it. service-chain-at-person-edge shows the weakness under open: a service certificate builds a valid chain at an edge that trusts the root; with the lists it is refused only for want of a service CA list, and the issuer check refuses it for the right reason. Each arm removes one fix and must go red:

$ ARM=no-revoke ./drill-revocation.sh      # the lost device is never put on the list
FAIL revoked want=refused(certificate revoked) got=ok
FAIL new-root-revoked want=refused(certificate revoked) got=ok
ARM=no-revoke: 12 passed, 2 failed

$ ARM=no-cross ./drill-revocation.sh       # the new root never cross-signs the person CA
FAIL new-root-kept-device want=ok got=error(unable_to_get_local_issuer_certificate)
FAIL new-root-revoked want=refused(certificate revoked) got=error(unable_to_get_local_issuer_certificate)
FAIL new-root-old-list-only want=refused(unable to get certificate CRL) got=error(unable_to_get_local_issuer_certificate)
FAIL person-at-person-edge-issuer-check want=ok got=error(unable_to_get_local_issuer_certificate)
ARM=no-cross: 10 passed, 4 failed

$ ARM=no-issuer-check ./drill-revocation.sh   # the person edge admits any verified chain
FAIL service-at-person-edge-issuer-check want=refused(issuer is not the person CA) got=ok
ARM=no-issuer-check: 13 passed, 1 failed

(The arms' PASS lines are left out above.) The constrained-root pattern's drills cover the name limit and chain order across clients; this one covers revocation and what a rotation does to it.

What is still open