Brief
Two internal services need to talk, and "it's on the private network" is not an answer your future self accepts. You are the PKI. You will mint a root of trust, delegate to an intermediate, issue the leaf certificates your services present, and configure both ends so that each refuses to talk to anyone who cannot prove — with a certificate your CA signed — that they are who they claim. By the end you can point at a rejected handshake and say exactly which extension, which chain link, or which expiry caused it.
Instructions
Use openssl throughout, or smallstep/step-ca if you prefer a real CA daemon; the spec is
tool-agnostic. Keep everything in a scratch lab directory. Two "services" can be as small as
two openssl s_server / s_client instances or two toy HTTP servers on localhost with
different ports/hostnames (e.g. alice.local and bob.local via /etc/hosts).
1. Root and intermediate CA
- Generate a root CA key and self-signed root certificate. Treat the root key as offline: after signing the intermediate, set it aside and never use it to sign leaves.
- Generate an intermediate CA key and CSR, and sign it with the root. Give it
basicConstraints = CA:TRUE, pathlen:0andkeyUsage = keyCertSign, cRLSign. - Inspect both with
openssl x509 -text -nooutand confirm basicConstraints, key usage, and validity windows read as intended.
2. Server leaf
- Generate a server key + CSR and sign it with the intermediate (not the root).
- Set
subjectAltNameto the server's DNS name(s) — the CN is ignored by modern clients, the SAN is what is checked. SetextendedKeyUsage = serverAuth. - Serve it and build the chain to send: leaf + intermediate (not the root). Confirm with
openssl s_client -connect host:port -showcertsthat the full chain is presented. Deliberately reproduce the missing-intermediate bug (serve leaf only, watch verification fail) and then fix it.
3. Client leaf and mutual TLS
- Issue a client leaf from the intermediate with
extendedKeyUsage = clientAuthand an identifying subject/SAN. - Configure the server to require and verify a client certificate against your CA chain
(
s_server -Verify/verifywith the CA file;SSL_VERIFY_PEER | FAIL_IF_NO_PEER_CERTin code). Now both directions authenticate — this is mTLS. - Confirm rejection paths: a client with no cert is refused, and a client presenting a cert from a different CA is refused at the handshake.
4. Identity enforcement
- Have the server read the presented client certificate's subject/SAN and authorize on it
(e.g. allow only
client.alice.local). Show that an untrusted issuer breaks the connection during the TLS handshake — before any application code runs — not at the app layer.
5. Operate it: short lifetimes + revocation
- Issue short-lived leaf certs (hours) and script renewal; add a check that alerts on imminent expiry. Observe why short lifetimes force automation and bound compromise.
- Exercise revocation: either publish a CRL, revoke a leaf, and have the verifier honor it, or lean on short-lived certs as the revocation strategy and demonstrate the window. Show a revoked/expired leaf being rejected.
Examples
Representative verification commands (adapt paths):
# Walk a chain by hand
openssl verify -CAfile root.crt -untrusted intermediate.crt server.crt
# See what a server actually presents
openssl s_client -connect bob.local:8443 -showcerts
# mTLS: succeeds with a client cert, fails without one
curl --cacert root.crt --cert client.crt --key client.key https://bob.local:8443/
curl --cacert root.crt https://bob.local:8443/ # expect handshake failure
Constraints
- Build the trust yourself with
opensslorstep-ca— no public CA, no Let's Encrypt. - The root key stays offline once the intermediate is signed; leaves are signed by the intermediate only.
- Leaves carry the right extensions: SANs for names,
serverAuth/clientAuthEKU for role. A missing or wrong extension should be diagnosable, not glossed over.
Acceptance
- Milestone 1 (root the trust):
openssl x509 -texton root and intermediate shows correct basicConstraints (CA:TRUE, pathlen), key usage, and validity; the intermediate verifies against the root. - Milestone 2 (server leaf):
openssl s_client -showcertsshows leaf + intermediate;openssl verify -CAfile root.crt -untrusted intermediate.crt server.crtreturns OK; the missing-intermediate failure is reproduced and then fixed. - Milestone 3 (client leaf + require): a
curl/client with a valid client cert connects; one with no cert and one with a foreign-CA cert are both rejected at the handshake. - Milestone 4 (identity enforced): the server authorizes on the client cert's subject/SAN; swapping to an untrusted issuer fails during the handshake, not in application logic.
- Milestone 5 (operate): short-lived leaves issue and renew via script with an expiry alert; a revoked (CRL) or expired leaf is rejected by the verifier.
Related
- Stand up your own CA + mTLS between two services — the exercise note this is the subject of.
- Sources: smallstep step-ca docs and the
opensslx509/s_client/verify/camanual pages, adapted into this lab spec.