Module 05 — Chain of Trust & Running Your Own CA

Updated 21 August 2026

Module 05 · Chain of Trust & Running Your Own CA

In Module 04 you played the CA for one certificate, and every shortcut you took is fixed here. This module explains what a chain actually is, how a client builds one, why the order of certificates in a file matters, and how to run a two-tier private CA you would not be embarrassed to describe in an interview.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–04. You need basicConstraints and AKI/SKI from Module 03, the CSR and issuing workflow from Module 04, and the verify codes you have been collecting since Module 03 (B2.1).


The picture to hold in your head for this whole module — the passport, and the office that issued it.

The border officer holds your passport. It was stamped by Regional Office 7, which the officer has never dealt with.

So they ask a second question: who says Regional Office 7 can issue passports? And Office 7 has its own document — a warrant — signed by the national government.

The officer has the government's charter on file. That they trust, because it is on the list.

Three documents, each vouching for the one below it, ending at something already trusted. That is a chain of trust, and everything in this module is about how that stack is built, ordered, transported and constrained.

Set up a working directory before you start.
bash
mkdir -p ~/tls-lab/m05 && cd ~/tls-lab/m05
umask 077
openssl version

Everything here runs offline. You will build a complete two-tier certificate authority from nothing, and the only tools involved are openssl and a text editor.

All expected output in this module was produced on OpenSSL 3.0.13.

Part A · What a chain is, and how a client builds one

The analogy — three documents on the officer's desk.
  1. Your passport — says who you are. Stamped by Regional Office 7.
  2. Office 7's warrant — says Office 7 may issue passports. Signed by the government.
  3. The government's charter — signed by nobody but itself, and already on the officer's list.

The officer checks each document against the one above it, and stops when they reach something already trusted. If any link is missing, expired, or not actually authorised to issue, the whole stack fails — no matter how perfect your passport is.

A chain is not a special file format. It is just a sequence of certificates where each one's Issuer matches the next one's Subject, ending at a certificate in the client's trust store.

Diagram source
flowchart TD
    L["🍃 LEAF<br>subject: app.internal.test<br>issuer: Issuing CA 1<br>CA:FALSE"]
    I["🏢 INTERMEDIATE<br>subject: Issuing CA 1<br>issuer: Root CA<br>CA:TRUE pathlen:0"]
    R["👑 ROOT<br>subject: Root CA<br>issuer: Root CA<br>CA:TRUE · self-signed"]
    T["💻 TRUST STORE<br>the client already<br>has this file"]
    L -->|"my issuer is..."| I
    I -->|"my issuer is..."| R
    R -.->|"is it on the list?"| T
    style L fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style I fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style R fill:#e1d5e7,stroke:#9673a6,stroke-width:2px

At every link, a client performs the same set of checks. RFC 5280 §6 defines them formally; in practice they are:

CheckWhat fails if it is wrong
SignatureThis certificate really was signed by the key in the one above it
Validity datesEvery certificate in the chain must be in date — including the intermediates
Name chainingIssuer of one equals Subject of the next
basicConstraintsEverything above the leaf must be CA:TRUE, or the chain is refused
keyUsageEvery issuer must carry keyCertSign
pathlenThe chain must not be deeper than an issuer permitted (Part C1)
Name constraintsThe leaf's names must fall inside what each CA above was allowed to issue (Part C2)
RevocationNothing in the chain has been withdrawn (Module 09)
HostnameChecked on the leaf only, and separately from the chain
The two things people get wrong about this list.

Every certificate must be in date, not just yours. An expired intermediate breaks a perfectly valid leaf, and monitoring that only checks the leaf will not see it coming. This is a genuinely common outage.

Hostname matching is not part of chain validation. It is a separate check, on the leaf only. That is why openssl verify will happily say OK for a certificate belonging to a completely different site — you have to ask for -verify_hostname explicitly, as you saw in Module 03 (B2.1).

🧪 Exercise A1.1 — Look at a real chain and find the links
bash
cd ~/tls-lab/m05

openssl s_client -connect letsencrypt.org:443 -servername letsencrypt.org -showcerts </dev/null 2>/dev/null \
  | sed -n '/BEGIN CERT/,/END CERT/p' > le-chain.pem

grep -c 'BEGIN CERTIFICATE' le-chain.pem

openssl crl2pkcs7 -nocrl -certfile le-chain.pem | openssl pkcs7 -print_certs -noout
Expected result — click to reveal
plain text
2

subject=CN = letsencrypt.org
issuer=C = US, O = Let's Encrypt, CN = E7

subject=C = US, O = Let's Encrypt, CN = E7
issuer=C = US, O = Internet Security Research Group, CN = ISRG Root X1

What to read out of this.

  • Read it as a ladder. Certificate 1's issuer= line is identical to certificate 2's subject= line. That is the name chaining check, visible.
  • The server sent TWO certificates, not three. The root — ISRG Root X1 — is not in the file. It does not need to be: your machine already has it. Sending it would waste bytes on every single connection for no benefit.
  • This is the rule for what you serve: your leaf, plus every intermediate, and never the root. A surprising number of deployments include the root out of caution; it is harmless but pointless, and it makes every handshake bigger.
  • Your exact intermediate name will differ. Let's Encrypt rotates through several — at the time of writing the active ones are YE1/YE2 (ECDSA) and YR1/YR2 (RSA), and which you get depends on your key type. The shape is what matters, not the name.

💡 If your issuer says something unexpected here, re-read the proxy warning in Module 03 (B1.1) — you are behind a TLS-inspecting middlebox and are seeing its chain, not the real one.

🎯 Interview questions — The chain

Q. Explain the concept of a trust chain.

A sequence of certificates in which each is signed by the next, ending at a trust anchor already present in the client's trust store. Typically: leaf → intermediate → root.

At every link a client checks the signature, the validity dates, that the issuer's Subject matches the certificate's Issuer, that the issuer is marked CA:TRUE with keyCertSign, and that path length and name constraints are respected. Trust flows downward from the anchor; nothing is trusted because it says so itself.

The two details that mark out real experience:

  1. Every certificate in the chain must be in date, not just the leaf. An expired intermediate is a full outage and leaf-only monitoring misses it entirely.
  2. Hostname matching is not part of chain validation. It is a separate check against the leaf's SAN. A chain can validate perfectly for a certificate belonging to someone else — which is exactly why openssl verify needs -verify_hostname before it means anything.
Q. Should you include the root certificate in what your server sends?

No. Send the leaf and every intermediate; omit the root.

The client either already has the root in its trust store — in which case sending it is wasted bytes on every handshake — or it does not, in which case receiving it changes nothing, because a client will never trust a root simply because a server offered one. That would defeat the entire point of a trust store.

The measurable version: Let's Encrypt cut certificate bytes in a TLS handshake by over 40% in 2024 purely by shortening the chain it hands out. At scale, chain length is a real performance number, not a detail.


A2 · Path building — whose job is it?

The analogy — who assembles the stack?

The officer does. You hand over your passport; they work upward from it, looking up each issuer in turn until they reach something on their list.

You cannot dictate which chain they use. But you can make their job possible: hand over Office 7's warrant along with your passport, because the officer has no copy of it and no way to guess.

That is the whole division of labour in TLS. The client builds the chain. The server's only job is to supply the middle pieces.

Path building is a search, not a lookup. The client has a bag of certificates — the ones the server sent, the ones in its trust store, sometimes ones it cached earlier — and it tries to find a path from the leaf to a trusted anchor.

There may be more than one valid path (Part D3 shows exactly this), and there may be dead ends. The Authority Key Identifier from Module 03 (C4) is what makes the search fast: instead of trying every certificate with a matching Issuer name, the client jumps straight to the one whose Subject Key Identifier matches.

🧪 Exercise A2.1 — Watch OpenSSL build a chain, and watch it fail without the middle

You will build the CA properly in Part B. For now, use the real Let's Encrypt chain you just downloaded.

bash
cd ~/tls-lab/m05
csplit -sz -f le- -b '%02d.pem' le-chain.pem '/BEGIN CERTIFICATE/' '{*}'
ls le-*.pem

echo "=== 1. leaf alone, no intermediate supplied ==="
openssl verify le-00.pem

echo
echo "=== 2. leaf with the intermediate supplied ==="
openssl verify -untrusted le-01.pem le-00.pem

echo
echo "=== 3. show the path it built ==="
openssl verify -untrusted le-01.pem -show_chain le-00.pem
Expected result — click to reveal
plain text
le-00.pem  le-01.pem

=== 1. leaf alone, no intermediate supplied ===
CN = letsencrypt.org
error 20 at 0 depth lookup: unable to get local issuer certificate
error le-00.pem: verification failed

=== 2. leaf with the intermediate supplied ===
le-00.pem: OK

=== 3. show the path it built ===
le-00.pem: OK
Chain:
depth=0: CN = letsencrypt.org (untrusted)
depth=1: C = US, O = Let's Encrypt, CN = E7 (untrusted)
depth=2: C = US, O = Internet Security Research Group, CN = ISRG Root X1

What to read out of this — the third block is the one worth studying.

  • -show_chain prints the path OpenSSL actually assembled, with depths. depth=0 is the leaf, and it counts upward. This is the single most useful flag for understanding a chain problem, and almost nobody knows it exists.
  • (untrusted) does not mean "bad". It means "this certificate came from the material you supplied, not from the trust store". Only depth=2 has no marker — that is the trust anchor, the one that came from /etc/ssl/certs.
  • The root was never in any file you passed. OpenSSL found ISRG Root X1 in the system trust store by itself. That is the anchor step, and it is what makes the chain terminate.
  • Error 20 in step 1 is the single most common TLS failure in production. unable to get local issuer certificate means: I have this certificate, I know who issued it, and I cannot find that issuer anywhere. Nine times out of ten it means a server is not sending its intermediate.

🔑 The division of labour, stated the way you should say it in an interview: "Path building is the client's job. The server's only responsibility is to send the intermediates, because the client has no way to obtain them otherwise — except AIA chasing, which most non-browser clients do not do."

🎯 Interview questions — Path building

Q. Who builds the certificate chain — the client or the server?

The client builds and validates it. The server merely supplies material: its leaf plus any intermediates.

The client starts at the leaf and searches upward, using the Issuer name and the Authority Key Identifier to find each parent, drawing from what the server sent, its own trust store, and sometimes a cache of previously seen intermediates. It stops when it reaches a trust anchor.

Why this framing matters operationally: it tells you where to fix things. If a client cannot build a path, the fix is almost always on the server — send the intermediates — not on the client. Adding intermediates to a client's trust store "fixes" one client while leaving every other client broken, and it has to be repeated forever.

The detail worth adding: because it is a search rather than a lookup, more than one valid path can exist. That is what makes cross-signing work, and it is also why two clients can reach different conclusions about the same server.


A3 · Trust anchors and the Linux trust store

The analogy — adding your own government to one border post's list.

You cannot make the world accept your passports. But you can walk into one border post and ask them to add your charter to their list.

From then on, that post accepts your documents — and only that post. Every other border post is unchanged.

That is what installing a private root CA does. And as Module 02 (C3) showed, a single machine has several such lists — the OS one, Java's cacerts, Node's, Python's, Firefox's — and adding to one does nothing for the others.

FamilyDrop the certificate hereThen run
Debian / Ubuntu/usr/local/share/ca-certificates/name.crtsudo update-ca-certificates
RHEL / Rocky / Alma / Fedora/etc/pki/ca-trust/source/anchors/name.crtsudo update-ca-trust extract
Alpine/usr/local/share/ca-certificates/name.crtsudo update-ca-certificates
macOSsecurity add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain name.crt — ⚠️ not on a work laptop
Know these commands; do not run them on a machine you do not own.

The macOS one in particular writes to the System keychain, which requires admin rights and is exactly the kind of change corporate endpoint security and MDM compliance tooling watch for. A root you generated yourself is technically harmless, but the alert it can raise is real and awkward to explain.

Exercise A3.2a below teaches the same thing with -CAfile and -CApath and changes nothing. That is also the approach you should prefer in production code anyway — point the application at a CA bundle rather than granting a CA authority over the whole machine.

On Debian the file must end in .crt and contain PEM. A file named .pem in /usr/local/share/ca-certificates/ is silently ignoredupdate-ca-certificates will run, report success, and skip it.

That is a genuinely maddening ten minutes if you do not know it, because every command reports success and nothing works.

🧪 Exercise A3.1 — Look at how the trust store is actually built
bash
echo "=== how many trust anchors does this machine have? ==="
ls /etc/ssl/certs/*.pem 2>/dev/null | wc -l
ls /etc/ssl/certs/ | wc -l

echo
echo "=== why are there more entries than files? ==="
ls -l /etc/ssl/certs/ | head -6

echo
echo "=== the hash a certificate gets filed under ==="
openssl x509 -in /etc/ssl/certs/ISRG_Root_X1.pem -noout -subject_hash -subject 2>/dev/null \
  || openssl x509 -in "$(ls /etc/ssl/certs/*.pem | head -1)" -noout -subject_hash -subject
Expected result — click to reveal
plain text
=== how many trust anchors does this machine have? ===
152
305

=== why are there more entries than files? ===
lrwxrwxrwx 1 root root   21 Aug 20 11:30 002c0b4f.0 -> GlobalSign_Root_CA.pem
lrwxrwxrwx 1 root root   25 Aug 20 11:30 02265526.0 -> Entrust_Root_Certification_Authority_-_G2.pem
lrwxrwxrwx 1 root root   20 Aug 20 11:30 0b1b94ef.0 -> Certigna.pem

=== the hash a certificate gets filed under ===
subject_hash=8d33f237
subject=C = US, O = Internet Security Research Group, CN = ISRG Root X1

What to read out of this.

  • 152 files but 305 entries. The extra ones are symlinks named after a hash of the subject, with a .0 suffix. That is the indexing mechanism, and it is the answer to "how does OpenSSL find a root among 152 without opening all of them?"
  • The lookup is: hash the Issuer name you are looking for, open <hash>.0. One filesystem operation instead of 152 file reads. The .0 is a collision counter — if two different certificates hash the same, the second becomes .1.
  • This is why openssl rehash exists. If you drop a certificate into a -CApath directory by hand and do not rehash it, OpenSSL will never find it, and will report unable to get local issuer certificate while the file sits right there. update-ca-certificates does the rehash for you, which is why you should use it rather than copying files around.
  • -CAfile versus -CApath: -CAfile is one file that OpenSSL reads completely; -CApath is a hashed directory it looks into. The hash mechanism only applies to the second.
Do NOT run the next exercise on a work laptop or any managed machine.

Installing a CA into the system trust store is the one genuinely invasive thing in this entire track. On a corporate device, endpoint security and MDM compliance tooling frequently watch the system keychain or trust bundle, and adding a root — even a harmless one you generated yourself — can raise a security alert or a compliance failure. It is also the sort of change that is easy to forget about and awkward to explain later.

Exercise A3.2a below teaches the identical lesson and changes nothing on your machine. Do that one instead. Only run A3.2b if you are on a throwaway VM, a container, or a personal machine you are happy to modify — and it includes the undo command.

🧪 Exercise A3.2a — The safe version: trust without touching the system

Run this after Part B, when you have a CA. It demonstrates exactly the same point — that trust lives in the store, not the certificate — without modifying anything.

bash
cd ~/tls-lab/m05

echo "=== 1. against the SYSTEM trust store: our CA is unknown ==="
openssl verify -untrusted int/int.crt app.crt

echo "=== 2. against a trust store we supply ourselves ==="
openssl verify -CAfile root/root.crt -untrusted int/int.crt app.crt

echo "=== 3. a directory-based store, built in our own lab ==="
mkdir -p mystore && cp root/root.crt mystore/
openssl rehash mystore
ls -l mystore/
openssl verify -CApath mystore -untrusted int/int.crt app.crt
Expected result — click to reveal
plain text
=== 1. against the SYSTEM trust store: our CA is unknown ===
error 20 at 1 depth lookup: unable to get local issuer certificate
error app.crt: verification failed

=== 2. against a trust store we supply ourselves ===
app.crt: OK

=== 3. a directory-based store, built in our own lab ===
total 8
lrwxrwxrwx 1 zaeem zaeem   8 Aug 20 15:02 f0d8335d.0 -> root.crt
-rw------- 1 zaeem zaeem 1830 Aug 20 15:02 root.crt
app.crt: OK

What to read out of this — you have learned the whole lesson without changing your machine.

  • error 20 at 1 depth, not at 0. Depth 1 is the intermediate. OpenSSL got past the leaf, then could not find the intermediate's issuer. The depth number tells you which link is broken, which is far more useful than the error text alone.
  • The certificate did not change between 1 and 2. Only the store did. That is Module 03's D3 lesson made concrete: trust is a property of the store, not of the file.
  • Step 3 builds a real hashed trust store in your lab directory. openssl rehash created the symlink f0d8335d.0 — the subject hash from Exercise A3.1. This is exactly what /etc/ssl/certs is, just in a folder you own. -CApath then uses it.
  • That is the whole mechanism. The system store is not magic; it is a directory of certificates plus hash symlinks, and update-ca-certificates is a script that copies files and runs rehash.

🔑 And this is how you should do it in production code anyway. Applications that need to trust an internal CA should be pointed at a CA bundle--cacert, RootCAs, NODE_EXTRA_CA_CERTS, REQUESTS_CA_BUNDLE, SSL_CERT_FILE — rather than relying on the machine's global store. It is explicit, it is visible in configuration, it survives OS upgrades, and it does not grant that CA authority over everything else on the host.

🧪 Exercise A3.2b — The system version: VM or personal machine ONLY
Skip this on a work laptop. Run it in a disposable VM or a container — for example docker run --rm -it -v ~/tls-lab:/lab ubuntu:24.04 bash, then apt update && apt install -y openssl ca-certificates. The undo command is included below and does work, but on a managed device the alert may already have fired by then.
bash
cd ~/tls-lab/m05

echo "=== before ==="
openssl verify -untrusted int/int.crt app.crt

# --- Debian / Ubuntu ---
sudo cp root/root.crt /usr/local/share/ca-certificates/zaeem-labs-root.crt
sudo update-ca-certificates

# --- RHEL / Rocky / Alma / Fedora ---
# sudo cp root/root.crt /etc/pki/ca-trust/source/anchors/zaeem-labs-root.crt
# sudo update-ca-trust extract

echo "=== after ==="
openssl verify -untrusted int/int.crt app.crt

echo "=== what actually happened on disk ==="
ls -l /etc/ssl/certs/ | grep -i zaeem
openssl x509 -in root/root.crt -noout -subject_hash

# --- UNDO, and do this when you are finished ---
# sudo rm /usr/local/share/ca-certificates/zaeem-labs-root.crt
# sudo update-ca-certificates --fresh
Expected result — click to reveal
plain text
=== before: our own CA is not trusted ===
error 20 at 1 depth lookup: unable to get local issuer certificate
error app.crt: verification failed

Adding debian:zaeem-labs-root.pem
done.
done.

=== after ===
app.crt: OK

=== what actually happened on disk ===
lrwxrwxrwx 1 root root 19 Aug 20 11:30 f0d8335d.0 -> zaeem-labs-root.pem
lrwxrwxrwx 1 root root 52 Aug 20 11:30 zaeem-labs-root.pem -> /usr/local/share/ca-certificates/zaeem-labs-root.crt
f0d8335d

What to read out of this.

  • error 20 at 1 depth, not at 0. Depth 1 is the intermediate. OpenSSL got past the leaf fine, then could not find the intermediate's issuer. The depth number tells you which link is broken, which is far more useful than the error text alone.
  • The certificate did not change. Only the list did. This is Module 03's D3 lesson, made concrete: trust is a property of the store.
  • Two symlinks were created. One with a friendly name pointing at your file, and one named f0d8335d.0 — the subject hash from A3.1. Both are needed: the hash link is how OpenSSL finds it, the named link is how humans find it.
  • Adding debian:zaeem-labs-root.pem — note the debian: prefix and the .pem extension in the output. The source file must be .crt, but the installed copy is renamed. That asymmetry is exactly why the .crt requirement catches people.

⚠️ To undo this, delete the source file and run the update again — do not delete the symlinks in /etc/ssl/certs/, which will be recreated:

bash
sudo rm /usr/local/share/ca-certificates/zaeem-labs-root.crt
sudo update-ca-certificates --fresh

Now imagine this at 500 hosts. Doing this by hand does not scale and does not survive. The root must be delivered by configuration management or baked into your base image — and separately into every runtime trust store that matters (Java's cacerts, Node's NODE_EXTRA_CA_CERTS, Python's REQUESTS_CA_BUNDLE). Teams that install roots by hand rediscover this at every OS upgrade, when the package manager replaces the bundle.

🎯 Interview questions — Trust stores

Q. How do you make a private CA trusted across a fleet?

Distribute the root certificate only — never the intermediate, and never a private key — through configuration management or the base image, so it survives rebuilds and OS upgrades.

On Debian that is /usr/local/share/ca-certificates/name.crt plus update-ca-certificates; on RHEL, /etc/pki/ca-trust/source/anchors/ plus update-ca-trust extract. The file must be PEM and, on Debian, must end in .crt or it is silently skipped.

The part people forget: the OS store is only one of several. Java reads cacerts, Node reads NODE_EXTRA_CA_CERTS, Python's requests reads certifi unless told otherwise, and Firefox has its own store entirely. A root installed for the OS does nothing for a Java service on the same host — which is the classic "curl works, the app does not" symptom from Module 02.

And the design point: distribute the root, not the intermediate. Trusting the root means you can rotate intermediates without touching a single client, which is the whole reason for a two-tier hierarchy.


Part B · Building a real private CA

B1 · Why two levels

The analogy — the charter in the vault.

The national charter is kept in a vault. It is brought out perhaps once a decade, under guard, to appoint a new regional office. It is never used for day-to-day work.

The regional offices do the actual stamping, thousands of times a day, using their own warrants.

Why split it? Because if a regional office is compromised you close it and open another — inconvenient, survivable. If the charter is compromised there is no recovery: it cannot revoke itself, and every border post in the world has to be persuaded to remove it from their list, which takes years.

Root CAIntermediate / Issuing CA
Key livesOffline — HSM in a safe, or an air-gapped machineOnline, on the issuing server
UsedA few times a decadeConstantly
Lifetime10–25 years3–5 years
Key sizeRSA 4096 or EC P-384 — it must outlive everythingRSA 2048 or EC P-256 is fine
Distributed to clientsYes — this is the trust anchorNo — it is served by the server instead
If compromisedCatastrophic. No recovery pathRevoke it, issue a new one, reissue leaves. Painful but survivable
basicConstraintsCA:TRUECA:TRUE, pathlen:0
The one-sentence answer to "why intermediates?": so the root's private key can stay offline.

Everything else — segmentation by product or region, different EKU constraints per intermediate, easier rotation — is a bonus. The offline root is the reason.

🎯 Interview questions — CA hierarchy

Q. Why do intermediate CAs exist? Why not issue directly from the root?

So the root key can stay offline. A key used for every issuance cannot live in a safe; a key used four times a decade can.

It also bounds damage. A compromised intermediate is revoked and replaced, affecting only the certificates beneath it. A compromised root has no recovery path — it cannot revoke itself, and removing it requires every browser and OS vendor to ship an update, which takes years to reach the long tail of devices.

And it enables segmentation: separate intermediates per product, region or validation level, each constrained by EKU, pathlen and name constraints.

The design detail worth volunteering: issuing intermediates carry pathlen:0, so a compromised intermediate can mint leaf certificates but cannot build a hierarchy beneath itself. The root delegates once and no further.


B2 · The CA directory, and what openssl ca actually needs

The analogy — the office ledger.

A passport office does not just stamp documents and forget them. It keeps a ledger: every document issued, its number, who it was issued to, and whether it has since been cancelled.

Without the ledger the office cannot answer "is passport 4471 still valid?" — which means it cannot revoke anything, because revocation is nothing more than a line in that ledger.

That ledger is index.txt, and it is the single thing that separates a real CA from the openssl x509 -req you used in Module 04.

openssl ca is fussy because it maintains state. It needs four things to exist before it will run:

FileWhat it is for
index.txtThe ledger. One line per certificate ever issued. Must exist, even empty
serialThe next serial number, in hex. Ignored when rand_serial = yes, but still required
certs/ (new_certs_dir)A copy of every certificate issued, filed by serial. Your audit trail
crlnumberOnly needed once you start generating revocation lists (Module 09)
🧪 Exercise B2.1 — Build the root CA
bash
cd ~/tls-lab/m05
umask 077
mkdir -p root/{certs,db,private}
touch root/db/index.txt
openssl rand -hex 8 > root/db/serial

cat > root.cnf <<'EOF'
[ ca ]
default_ca = CA_root

[ CA_root ]
dir               = ./root
database          = $dir/db/index.txt
serial            = $dir/db/serial
new_certs_dir     = $dir/certs
certificate       = $dir/root.crt
private_key       = $dir/private/root.key
default_days      = 3650
default_md        = sha256
policy            = policy_loose
email_in_dn       = no
rand_serial       = yes
unique_subject    = no
copy_extensions   = none

[ policy_loose ]
countryName             = optional
organizationName        = optional
commonName              = supplied

[ req ]
distinguished_name = dn
prompt             = no

[ dn ]
C  = MY
O  = Zaeem Labs
CN = Zaeem Labs Root CA

[ v3_intermediate ]
basicConstraints       = critical,CA:TRUE,pathlen:0
keyUsage               = critical,keyCertSign,cRLSign
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
EOF

# 4096-bit, because this key must outlive everything below it
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root/private/root.key

openssl req -x509 -config root.cnf -key root/private/root.key -out root/root.crt -days 7300 \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash"

openssl x509 -in root/root.crt -noout -subject -issuer
openssl x509 -in root/root.crt -noout -ext basicConstraints,keyUsage
Expected result — click to reveal
plain text
subject=C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA
X509v3 Basic Constraints: critical
    CA:TRUE
X509v3 Key Usage: critical
    Certificate Sign, CRL Sign

What to read out of this, and the config choices behind it.

  • Subject equals Issuer — self-signed, as every root is (Module 03, D3).
  • CA:TRUE with no pathlen. Unlimited depth, which is correct for a root: its job is to delegate. Its protection is that the key is offline, not a path limit.
  • rand_serial = yes makes OpenSSL generate random serials instead of counting upward from the serial file — the CA/B Forum entropy requirement from Module 03 (A2), and good practice even privately.
  • copy_extensions = none is the setting that matters most for security. It is Module 04's -copy_extensions trap in configuration form: the CA decides the extensions, never the requester.
  • policy_loose with commonName = supplied means a CSR must carry a CN, and country and organisation are optional. policy_match (the other common choice) would additionally force the CSR's O to equal the CA's — useful inside one organisation, annoying otherwise.
  • 7300 days is 20 years. Long, deliberately. A root that expires is an estate-wide outage, and replacing one is a project measured in months.

⚠️ In production this key would not be here. It would be generated and used on an air-gapped machine or in an HSM, and this directory would only ever hold the certificate. Everything else in this module is realistic; that part is a lab shortcut.

🧪 Exercise B2.2 — Issue the intermediate with openssl ca, and read the ledger
bash
cd ~/tls-lab/m05
mkdir -p int/{certs,db,private}
touch int/db/index.txt
openssl rand -hex 8 > int/db/serial

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out int/private/int.key
openssl req -new -key int/private/int.key -out int/int.csr \
  -subj "/C=MY/O=Zaeem Labs/CN=Zaeem Labs Issuing CA 1"

openssl ca -config root.cnf -extensions v3_intermediate -days 1825 -notext -batch \
  -in int/int.csr -out int/int.crt

echo "=== the certificate ==="
openssl x509 -in int/int.crt -noout -subject -issuer -serial
openssl x509 -in int/int.crt -noout -ext basicConstraints,keyUsage

echo
echo "=== the ledger ==="
cat root/db/index.txt
ls root/certs/
Expected result — click to reveal
plain text
commonName            :ASN.1 12:'Zaeem Labs Issuing CA 1'
Certificate is to be certified until Aug 19 11:28:58 2031 GMT (1825 days)

Write out database with 1 new entries
Database updated

=== the certificate ===
subject=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA
serial=15ACDDBF1E08B89A8CAE1A6539694E22165F757F
X509v3 Basic Constraints: critical
    CA:TRUE, pathlen:0
X509v3 Key Usage: critical
    Certificate Sign, CRL Sign

=== the ledger ===
V	310819112858Z		15ACDDBF1E08B89A8CAE1A6539694E22165F757F	unknown	/C=MY/O=Zaeem Labs/CN=Zaeem Labs Issuing CA 1

=== new_certs_dir ===
15ACDDBF1E08B89A8CAE1A6539694E22165F757F.pem

What to read out of this — the ledger line is the important part.

index.txt is tab-separated with six fields:

FieldValue hereMeaning
1VStatus: Valid, Revoked, or Expired
2310819112858ZExpiry, as YYMMDDHHMMSSZ — the UTCTime format from Module 03 (A3)
3(empty)Revocation date. Filled in when you revoke
415ACDD...Serial number
5unknownFilename in new_certs_dir
6/C=MY/O=...Subject DN
  • Field 1 and field 3 are what revocation is. Revoking a certificate changes V to R and writes a timestamp into field 3. That is the entire mechanism, and the CRL you will generate in Module 09 is just a signed rendering of this file.
  • Write out database with 1 new entries / Database updatedopenssl ca refuses to lose track of anything it signs. This is exactly what openssl x509 -req does not do, and why Module 04's approach cannot support revocation.
  • new_certs_dir now holds a copy filed by serial. If you ever need to know exactly what you issued three years ago, it is there.
  • -batch skipped the confirmation prompts and -notext kept the human-readable dump out of the output file. Without -notext your .crt contains the full text dump above the PEM block — valid, but ugly and larger.
  • pathlen:0 came from the v3_intermediate section, chosen with -extensions. The CSR asked for nothing; the issuer decided everything.

🎯 Interview questions — Running a CA

Q. What is the difference between openssl x509 -req and openssl ca?

openssl x509 -req signs a CSR and forgets it. openssl ca maintains state: a database (index.txt) of everything issued, a serial counter, and a copy of each certificate filed by serial.

That state is not bookkeeping for its own sake — it is what makes revocation possible. A CRL is generated from index.txt; a certificate the CA has no record of cannot be revoked, because there is nothing to mark as revoked.

The practical rule: x509 -req is fine for a throwaway test certificate. Anything you will have to operate — anything that might need revoking, auditing or reissuing — goes through openssl ca or a real CA product.

What you would actually use in production: not raw OpenSSL. step-ca, HashiCorp Vault's PKI engine, EJBCA, or a cloud private CA. Raw openssl ca is how you learn the moving parts and how you run a root ceremony offline; it is not how you run an issuing CA at scale.


B3 · Issuing a server certificate from the intermediate

The analogy — the regional office does the stamping now.

The charter went back into the vault the moment the regional office was appointed. Every passport from here on is stamped by the office, not the government.

So the leaf's issuer is the intermediate, and the root is never touched again.

🧪 Exercise B3.1 — Issue a leaf, and verify the whole chain
bash
cd ~/tls-lab/m05

cat > int.cnf <<'EOF'
[ ca ]
default_ca = CA_int

[ CA_int ]
dir               = ./int
database          = $dir/db/index.txt
serial            = $dir/db/serial
new_certs_dir     = $dir/certs
certificate       = $dir/int.crt
private_key       = $dir/private/int.key
default_days      = 90
default_md        = sha256
policy            = policy_loose
email_in_dn       = no
rand_serial       = yes
unique_subject    = no
copy_extensions   = none

[ policy_loose ]
countryName             = optional
organizationName        = optional
commonName              = supplied

[ v3_server ]
basicConstraints       = critical,CA:FALSE
keyUsage               = critical,digitalSignature
extendedKeyUsage       = serverAuth
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName         = DNS:app.internal.test,DNS:www.app.internal.test
EOF

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out app.key
openssl req -new -key app.key -out app.csr -subj "/C=MY/O=Zaeem Labs/CN=app.internal.test"

openssl ca -config int.cnf -extensions v3_server -notext -batch -in app.csr -out app.crt

echo "=== 1. leaf against the root only ==="
openssl verify -CAfile root/root.crt app.crt

echo "=== 2. leaf with the intermediate supplied ==="
openssl verify -CAfile root/root.crt -untrusted int/int.crt app.crt

echo "=== 3. and the hostname ==="
openssl verify -CAfile root/root.crt -untrusted int/int.crt \
  -verify_hostname app.internal.test app.crt

echo "=== 4. show the path ==="
openssl verify -CAfile root/root.crt -untrusted int/int.crt -show_chain app.crt
Expected result — click to reveal
plain text
Write out database with 1 new entries
Database updated

=== 1. leaf against the root only ===
C = MY, O = Zaeem Labs, CN = app.internal.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error app.crt: verification failed

=== 2. leaf with the intermediate supplied ===
app.crt: OK

=== 3. and the hostname ===
app.crt: OK

=== 4. show the path ===
app.crt: OK
Chain:
depth=0: C = MY, O = Zaeem Labs, CN = app.internal.test (untrusted)
depth=1: C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1 (untrusted)
depth=2: C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA

What to read out of this.

  • Step 1 fails even though you own the root. Trusting the root is not enough — the client still needs the intermediate to bridge the gap. This is exactly the situation your servers will be in, and exactly why fullchain.pem exists.
  • error 20 at 0 depth here, versus at 1 depth in Exercise A3.2. The depth tells you where the search stopped. Depth 0 means it could not find the leaf's issuer; depth 1 means it found the intermediate but not its issuer. Two different fixes.
  • -show_chain proves the three-level structure you built. depth=2 has no (untrusted) marker because it came from -CAfile — it is the anchor.
  • The SAN came from v3_server in the config, not from the CSR. The CSR only carried a CN. The issuer decided the names, which is the Module 04 lesson enforced by configuration.

💡 A real CA would validate those names first. Our config hard-codes them, which is fine for one service and obviously wrong for a general-purpose CA. Real CA software takes the requested names, checks them against an authorisation policy, and then writes them. That validation step is the entire difference between a CA and a signing script.


Part C · Constraining a CA

A CA that can issue anything, for anyone, forever, is a liability — and your internal root is trusted by every machine in the estate. Part C is about the three fences you can put around one.

All three share a property that is worth noticing as you go: they are enforced by the verifier, not the issuer. An issuing CA will cheerfully break its own constraints. The client is what refuses the result.

C1 · pathlen — how deep may the tree go?

The analogy — "you may appoint offices, but they may not appoint their own."

The government's warrant to Regional Office 7 says: you may issue passports, and you may not create sub-offices.

Office 7 could ignore that and appoint one anyway — nothing physically stops it. But when a traveller shows up with a passport from that sub-office, the border officer reads the warrant, sees the restriction, and refuses the whole stack.

pathlen:0 is that sentence. And the fact that the officer enforces it, not the office, is the entire point.

🧪 Exercise C1.1 — Break pathlen on purpose, and watch the client catch it
bash
cd ~/tls-lab/m05

cat >> int.cnf <<'EOF'

[ v3_subca ]
basicConstraints       = critical,CA:TRUE
keyUsage               = critical,keyCertSign,cRLSign
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
EOF

# Our intermediate is pathlen:0 - it must not create sub-CAs. Let's try anyway.
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sub.key
openssl req -new -key sub.key -out sub.csr -subj "/C=MY/O=Zaeem Labs/CN=Rogue Sub CA"
openssl ca -config int.cnf -extensions v3_subca -notext -batch -in sub.csr -out sub.crt

echo "=== the intermediate issued it without complaint ==="
openssl x509 -in sub.crt -noout -subject -ext basicConstraints

# Now use the rogue sub-CA to mint a certificate for someone else's domain
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out victim.key
openssl req -new -key victim.key -out victim.csr -subj "/CN=www.google.com"
cat > sub-ext.cnf <<'EOF'
basicConstraints = critical,CA:FALSE
keyUsage         = critical,digitalSignature
extendedKeyUsage = serverAuth
subjectAltName   = DNS:www.google.com
EOF
openssl x509 -req -in victim.csr -CA sub.crt -CAkey sub.key -out victim.crt \
  -days 90 -extfile sub-ext.cnf

echo "=== now ask a client to accept the 4-deep chain ==="
cat int/int.crt sub.crt > untrusted.pem
openssl verify -CAfile root/root.crt -untrusted untrusted.pem victim.crt
Expected result — click to reveal
plain text
Write out database with 1 new entries
Database updated

=== the intermediate issued it without complaint ===
subject=C = MY, O = Zaeem Labs, CN = Rogue Sub CA
X509v3 Basic Constraints: critical
    CA:TRUE

=== now ask a client to accept the 4-deep chain ===
C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1
error 25 at 2 depth lookup: path length constraint exceeded
error victim.crt: verification failed

What to read out of this — the two halves say opposite things and both are true.

The intermediate issued the sub-CA happily. openssl ca did not check its own pathlen. It signed the request, wrote a line in its ledger, and reported success. An issuer does not police itself.

The verifier refused the chain. error 25 ... path length constraint exceeded, reported at depth 2 — which is the intermediate, the certificate that carried the constraint. The client walked up the chain, reached the intermediate, read pathlen:0, counted the CAs below it, and found one too many.

  • www.google.com was never at risk, because nothing outside your lab trusts your root. But inside an organisation whose machines do trust your root, that certificate would be accepted by everything — which is precisely the scenario pathlen bounds.
  • This is the same duality as Module 04 (D1.2), where a CSR could request CA:TRUE and a careless issuer granted it. Issuance and validation are separate systems, and security requires both to be right.

🔑 Add error 25 to your table. Alongside 20 (missing issuer), 21 (cannot verify first certificate), 18/19 (self-signed), 62 (hostname mismatch) and 47 (name constraint), this is the set that covers nearly every real chain failure you will meet.


C2 · Name constraints — which names may this CA issue for?

The analogy — an office licensed for one region only.

Regional Office 7's warrant says: you may issue passports to residents of the northern province, and nowhere else.

If Office 7 issues one to a southerner, the document looks perfect and the stamps all check out. But the border officer reads the warrant, sees the geographic limit, and refuses.

Name constraints are that clause. They are the single most under-used control in private PKI, and the answer to the question every security reviewer asks: "what stops your internal CA issuing a certificate for google.com?"

Why this matters so much for an internal CA. Your internal root is installed on every laptop and every server. It is, from those machines' point of view, exactly as powerful as DigiCert.

Without name constraints, anyone who compromises your issuing CA can mint a certificate for any domain on the internet — your bank, your identity provider, your cloud console — and every machine in your estate will accept it silently.

With permitted;DNS:.internal.test, that same attacker can only impersonate things inside your own namespace. The blast radius drops from "the entire internet" to "our own estate", which is still bad but is a completely different incident.

SyntaxMeaning
permitted;DNS:.internal.testAny name ending in .internal.test. The leading dot matters
permitted;DNS:internal.testThe bare domain itself. Usually you want both lines
excluded;DNS:.example.comExplicitly forbid a namespace
permitted;IP:10.0.0.0/255.0.0.0IP ranges — note the netmask form, not CIDR
excluded;IP:0.0.0.0/0.0.0.0Forbid all IP addresses. A common hardening line
permitted;email:.internal.testConstrain email SANs too — separate from DNS
🧪 Exercise C2.1 — Build a name-constrained CA and watch it fail to overreach
bash
cd ~/tls-lab/m05

cat >> root.cnf <<'EOF'

[ v3_intermediate_nc ]
basicConstraints       = critical,CA:TRUE,pathlen:0
keyUsage               = critical,keyCertSign,cRLSign
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
nameConstraints        = critical,permitted;DNS:.internal.test,permitted;DNS:internal.test
EOF

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out nc.key
openssl req -new -key nc.key -out nc.csr -subj "/C=MY/O=Zaeem Labs/CN=Zaeem Labs Constrained CA"
openssl ca -config root.cnf -extensions v3_intermediate_nc -days 1825 -notext -batch \
  -in nc.csr -out nc.crt

openssl x509 -in nc.crt -noout -ext nameConstraints

echo "=== issue a name INSIDE the permitted space ==="
cat > ok-ext.cnf <<'EOF'
basicConstraints = critical,CA:FALSE
keyUsage         = critical,digitalSignature
extendedKeyUsage = serverAuth
subjectAltName   = DNS:ok.internal.test
EOF
openssl req -new -key app.key -out ok.csr -subj "/CN=ok.internal.test"
openssl x509 -req -in ok.csr -CA nc.crt -CAkey nc.key -out ok.crt -days 90 -extfile ok-ext.cnf 2>/dev/null
openssl verify -CAfile root/root.crt -untrusted nc.crt ok.crt

echo "=== now issue a name OUTSIDE it ==="
sed 's/ok.internal.test/www.google.com/' ok-ext.cnf > bad-ext.cnf
openssl req -new -key app.key -out badnc.csr -subj "/CN=www.google.com"
openssl x509 -req -in badnc.csr -CA nc.crt -CAkey nc.key -out badnc.crt -days 90 -extfile bad-ext.cnf 2>/dev/null
openssl verify -CAfile root/root.crt -untrusted nc.crt badnc.crt
Expected result — click to reveal
plain text
X509v3 Name Constraints: critical
    Permitted:
      DNS:.internal.test
      DNS:internal.test

=== issue a name INSIDE the permitted space ===
ok.crt: OK

=== now issue a name OUTSIDE it ===
CN = www.google.com
error 47 at 0 depth lookup: permitted subtree violation
error badnc.crt: verification failed

What to read out of this.

  • error 47 — permitted subtree violation. The CA signed the certificate perfectly happily; the verifier rejected it because the name falls outside what the CA above was permitted to issue for. Same issuer/verifier split as pathlen.
  • The error is reported at depth 0 — the leaf — because that is where the offending name lives, even though the constraint lives at depth 1. Compare with error 25 in C1.1, reported at depth 2 where the constraint was. The depth points at the certificate that made the check fail, which is not always the one carrying the rule.
  • Both permitted;DNS: lines are needed. .internal.test with the leading dot covers subdomains; internal.test without it covers the bare domain. It is the same trap as the wildcard rule from Module 03 (B3) — the shorthand does not include the apex.
  • Marked critical, as RFC 5280 requires. That means a client that does not understand name constraints must reject the certificate rather than ignore the restriction — which is exactly the behaviour you want from a control like this.

⚠️ Two real-world caveats worth knowing before you rely on this.

  1. Support is good but not universal. OpenSSL, Go, modern browsers, macOS and Windows all enforce name constraints. Some older embedded stacks and a few Java versions historically did not. It is a strong control, not a guaranteed one — so treat it as defence in depth rather than the only fence.
  2. Constraints apply to the whole subtree below. Adding one to an existing CA invalidates every certificate it has already issued for names outside the new limits. Introduce them with a new intermediate, not by re-issuing an existing one.

🔑 If you take one thing from Part C into an interview, make it this. "Our internal CA is name-constrained to our own domains, so even a full compromise of the issuing CA cannot be used to impersonate an external service to our fleet." That sentence demonstrates that you have thought about your own CA as an attack surface, which most people have not.

🎯 Interview questions — Constraining a CA

Q. What stops your internal CA from issuing a certificate for google.com?

By default, nothing — and that is the point of the question. An internal root installed on every machine is, to those machines, exactly as authoritative as any public CA. Compromise the issuing CA and you can impersonate anything on the internet to your entire estate.

The control is name constraints (RFC 5280 §4.2.1.10): a critical extension on the CA certificate listing permitted and excluded namespaces, for example permitted;DNS:.internal.example.com. A leaf with a name outside that space fails validation with permitted subtree violation, no matter how correctly it was signed.

Pair it with pathlen:0 so the CA cannot create sub-CAs that might carry different constraints, and with EKU restrictions so an intermediate limited to serverAuth cannot mint code-signing certificates.

The caveats that show you have actually deployed this: enforcement is done by the client, and while OpenSSL, Go, browsers, macOS and Windows all honour it, a few older stacks do not — so it is defence in depth. And constraints apply retroactively to everything beneath the CA, so they must be introduced with a new intermediate rather than bolted onto an existing one.

Q. Who enforces pathlen and name constraints — the CA or the client?

The client, during path validation. A CA will happily sign something that violates its own constraints — openssl ca does not check its own pathlen before issuing.

That split is deliberate and it is the same design as basicConstraints: the certificate carries the rule, and every verifier along the way is obliged to enforce it. It means a compromised or misconfigured issuer cannot quietly grant itself more authority, because the authority was written down and signed by someone above it.

The practical consequence: you cannot test these controls by trying to issue a bad certificate — issuance will succeed. You test them by trying to verify the result, which is why every exercise in this part ends with openssl verify rather than with the signing step.


Part D · Chain problems in the real world

D1 · The incomplete chain — the most common TLS failure there is

The analogy — handing over the passport but not the warrant.

You give the officer your passport and keep Office 7's warrant in your pocket. The officer has never heard of Office 7 and has no way to look it up.

Some officers happen to keep copies of common warrants behind the desk, so they wave you through. Others do not, and you are refused.

That difference is the entire "works in Chrome, fails in curl" phenomenon from Module 03 (C5). And crucially: the officer refusing you is doing their job correctly. You are the one who forgot the warrant.

🧪 Exercise D1.1 — Serve a broken chain, then fix it
bash
cd ~/tls-lab/m05
export no_proxy="*"

serve() { pkill -f "accept $3" 2>/dev/null
          (openssl s_server -cert "$1" ${2:+-cert_chain "$2"} -key app.key -accept "$3" -www >/dev/null 2>&1 &)
          sleep 1; }

echo "=== A. leaf only - the broken deployment ==="
serve app.crt "" 4433
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
openssl s_client -connect 127.0.0.1:4433 -servername app.internal.test \
  -CAfile root/root.crt </dev/null 2>&1 | grep 'Verify return code' | head -1
curl -sS --cacert root/root.crt --resolve app.internal.test:4433:127.0.0.1 \
  https://app.internal.test:4433/ -o /dev/null

echo
echo "=== B. leaf + intermediate - the fix ==="
serve app.crt int/int.crt 4434
openssl s_client -connect 127.0.0.1:4434 -servername app.internal.test -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'
curl -sS --cacert root/root.crt --resolve app.internal.test:4434:127.0.0.1 \
  https://app.internal.test:4434/ -o /dev/null -w 'HTTP %{http_code}\n'

pkill -f 'openssl s_server'
Expected result — click to reveal
plain text
=== A. leaf only - the broken deployment ===
1
Verify return code: 21 (unable to verify the first certificate)
curl: (60) SSL certificate problem: unable to get local issuer certificate
More details here: https://curl.se/docs/sslcerts.html

=== B. leaf + intermediate - the fix ===
2
HTTP 200

What to read out of this.

  • grep -c 'BEGIN CERTIFICATE' on -showcerts is your fastest diagnostic. One certificate from a server whose leaf was issued by an intermediate means the chain is incomplete. Full stop, no further investigation needed.
  • Verify code 21, not 20. Both mean "missing intermediate", and which you get depends on context: openssl verify on files gives 20 (unable to get local issuer certificate), while s_client against a live server tends to give 21 (unable to verify the first certificate). Treat 20 and 21 as the same diagnosis.
  • curl reports error 60 with unable to get local issuer certificate — the same underlying condition, a third wording. Three tools, three phrasings, one cause.
  • The fix added one file and nothing else changed.

⚠️ A genuine s_server trap, and it caught me while writing this module. openssl s_server -cert fullchain.pem reads only the first certificate from that file. Unlike nginx, it does not treat a bundle as a chain — you must pass the rest with -cert_chain. So testing a bundle with s_server can show a broken chain even when the same file works perfectly in nginx.

How real servers want it:

ServerConfiguration
nginxssl_certificate fullchain.pem; — leaf then intermediates, one file
Apache ≥ 2.4.8SSLCertificateFile fullchain.pem — combined file accepted
Apache < 2.4.8SSLCertificateFile for the leaf, SSLCertificateChainFile for the chain
HAProxyOne PEM containing key + leaf + intermediates
openssl s_server-cert leaf.crt -cert_chain chain.pemseparate flags

🔑 The rule to remember: cat leaf.crt intermediate.crt > fullchain.pem. Leaf first, then upward, never the root.

🧪 Exercise D1.2 — Does chain order actually matter?
bash
cd ~/tls-lab/m05
cat app.crt int/int.crt > fullchain.pem
cat int/int.crt app.crt > wrongorder.pem

echo "=== correct order: leaf first ==="
openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout | head -4
openssl verify -CAfile root/root.crt -untrusted fullchain.pem app.crt

echo
echo "=== reversed ==="
openssl crl2pkcs7 -nocrl -certfile wrongorder.pem | openssl pkcs7 -print_certs -noout | head -4
openssl verify -CAfile root/root.crt -untrusted wrongorder.pem app.crt
Expected result — click to reveal
plain text
=== correct order: leaf first ===
subject=C = MY, O = Zaeem Labs, CN = app.internal.test
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1

subject=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA

app.crt: OK

=== reversed ===
subject=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Root CA

subject=C = MY, O = Zaeem Labs, CN = app.internal.test
issuer=C = MY, O = Zaeem Labs, CN = Zaeem Labs Issuing CA 1

app.crt: OK

What to read out of this — and the answer is more nuanced than most people say.

  • openssl verify accepted both orders. As a bag of candidate certificates, order is irrelevant — the verifier searches it either way.
  • But the file's order still matters, for two reasons:

1. The web server reads it positionally. nginx takes the first certificate in ssl_certificate as the leaf and everything after as the chain. Put the intermediate first and nginx serves the intermediate as your server certificate — the handshake then presents a certificate whose SAN does not include your hostname, and every client fails with a name mismatch. The file is valid; the deployment is broken.

2. TLS itself specifies the order. RFC 8446 §4.4.2 says the sender's certificate comes first, each subsequent one certifying the previous. Modern clients tolerate a mis-ordered chain on the wire, but tolerating something is not the same as it being correct, and older and embedded stacks are less forgiving.

🔑 So the honest answer in an interview is: "Order does not matter to path building, because the client searches. It matters to the server, which reads the file positionally, and it is what the TLS spec requires on the wire. Leaf first, then up, never the root." That is more accurate than either "order doesn't matter" or "order is critical", and the nuance is the part worth having.

🎯 Interview questions — Incomplete chains

Q. How do you diagnose "unable to get local issuer certificate"?

First establish whether the server is sending its chain at all:

bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERTIFICATE'

One certificate from a publicly issued site means the chain is incomplete — that is the answer, immediately. Confirm with the verify code: 20 or 21 both mean the same thing.

Then get the missing piece from the leaf's AIA CA Issuers URL (Module 03, C5), convert it from DER, and rebuild fullchain.pem with the leaf first.

The two mistakes to name:

  1. Fixing it on the client. Adding the intermediate to a client's trust store makes that one client work and leaves every other one broken, forever. The fault is on the server.
  2. Trusting the browser. Chrome on Windows and macOS fetches missing intermediates via AIA and caches ones it has seen, so it hides the fault entirely. Test with openssl s_client or curl.

The other possibility worth checking: the server might be sending the wrong intermediate — for instance after a CA key rotation. Compare the leaf's AKI with the intermediate's SKI to confirm they belong together.


D2 · Cross-signing — one certificate, two parents

The analogy — the new country nobody recognises yet.

A new nation forms. Its passports are perfectly good, but no border post has its charter on file yet, and getting added to those lists takes years — old equipment, old software, devices that will never be updated.

So an established, already-trusted nation issues a second warrant to the same regional office: "we also vouch for Office 7."

Now the office has two warrants for the same stamp. Modern border posts use the new charter. Old ones use the established country's. Same passports, same office, two different routes to being believed.

That is cross-signing, and it is the mechanism that made Let's Encrypt possible.

The key insight that makes cross-signing click: two certificates, same subject, same public key, different issuers.

Because the key is the same, a leaf signed by that key verifies under either certificate. The client's path builder simply picks whichever route reaches a root it trusts (Part A2 — path building is a search, and here there are genuinely two answers).

🧪 Exercise D2.1 — Cross-sign your own intermediate
bash
cd ~/tls-lab/m05

# A second, "legacy" root - pretend it has been in trust stores for 20 years
mkdir -p root2/{certs,db,private}
touch root2/db/index.txt
openssl rand -hex 8 > root2/db/serial
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root2/private/root2.key
openssl req -x509 -key root2/private/root2.key -out root2/root2.crt -days 7300 \
  -subj "/C=MY/O=Zaeem Labs/CN=Zaeem Labs Legacy Root" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"

# Point a copy of the config at root2, then sign the SAME intermediate CSR again
sed 's|dir               = ./root$|dir               = ./root2|; s|root.crt|root2.crt|; s|root.key|root2.key|' \
  root.cnf > root2.cnf
openssl ca -config root2.cnf -extensions v3_intermediate -days 1825 -notext -batch \
  -in int/int.csr -out int-cross.crt

echo "=== same subject, different issuer ==="
for f in int/int.crt int-cross.crt; do
  printf '%-16s ' "$f"; openssl x509 -in "$f" -noout -subject -issuer | tr '\n' ' '; echo
done

echo "=== same public key? ==="
diff <(openssl x509 -in int/int.crt      -noout -pubkey) \
     <(openssl x509 -in int-cross.crt    -noout -pubkey) && echo "IDENTICAL KEY"

echo "=== the SAME leaf now verifies under EITHER root ==="
openssl verify -CAfile root/root.crt   -untrusted int/int.crt    app.crt
openssl verify -CAfile root2/root2.crt -untrusted int-cross.crt  app.crt
Expected result — click to reveal
plain text
=== same subject, different issuer ===
int/int.crt      subject=... CN = Zaeem Labs Issuing CA 1  issuer=... CN = Zaeem Labs Root CA
int-cross.crt    subject=... CN = Zaeem Labs Issuing CA 1  issuer=... CN = Zaeem Labs Legacy Root

=== same public key? ===
IDENTICAL KEY

=== the SAME leaf now verifies under EITHER root ===
app.crt: OK
app.crt: OK

What to read out of this.

  • app.crt was signed exactly once, and it validates under two completely unrelated roots. That is the whole trick: the leaf's issuer is identified by the intermediate's key, and both intermediate certificates carry that same key.
  • Nothing about the leaf changed. No reissue, no redeploy. You extended trust to a new population of clients purely by handing out a different intermediate.
  • This is why AKI/SKI matter (Module 03, C4). With two certificates sharing a Subject DN, the DN alone is ambiguous. The Authority Key Identifier points at the key, which is what makes the search unambiguous.

The real-world story worth being able to tell. Let's Encrypt launched in 2015 with a root — ISRG Root X1 — that no trust store contained. To be usable immediately, its intermediates were cross-signed by IdenTrust's DST Root CA X3, which had been trusted for years.

For six years, Let's Encrypt certificates chained to DST Root CA X3 on older clients and to ISRG Root X1 on newer ones. Then:

  • 30 September 2021 — DST Root CA X3 expired, and a long tail of old Android devices, OpenSSL 1.0.x systems and appliances broke. Sites that had done nothing wrong went down.
  • February 2024 — Let's Encrypt stopped serving the cross-signed chain by default.
  • June 2024 — it became unavailable entirely.
  • September 2024 — the cross-sign expired for good.

Shortening the chain cut certificate bytes per handshake by over 40%. And the reason it became safe to do was simply that ISRG Root X1's presence in Android trust stores had risen from 66% to 93.9%.

🔑 The interview-ready summary: "Cross-signing lets a new CA be trusted immediately by clients that only know an old root. Two certificates, same subject and key, different issuers — the client picks whichever path reaches a root it has. The cost is a longer chain and a hard dependency on the old root's expiry date, which is exactly what bit everyone in September 2021."

💡 The same mechanism is running right now: Let's Encrypt announced a new "Generation Y" hierarchy in November 2025 — roots ISRG Root YR (RSA 4096) and ISRG Root YE (ECDSA P-384) with intermediates YR1YR3 and YE1YE3 — which will be cross-signed by the existing X-generation roots while the new ones work their way into trust stores. Exactly the same play, ten years later.

🎯 Interview questions — Cross-signing

Q. What is cross-signing and why does it exist?

Issuing a second certificate for the same CA key and subject, signed by a different root. The CA ends up with two certificates that are interchangeable from a signature standpoint, so a leaf it issued validates under either root depending on which the client trusts.

It exists because getting a new root into trust stores takes years — browser and OS update cycles, plus devices that will never be updated at all. Cross-signing lets a new CA be usable on day one by borrowing an established root's reach.

The concrete example: Let's Encrypt's ISRG Root X1 was cross-signed by IdenTrust's DST Root CA X3 from 2015. When X3 expired on 30 September 2021, older clients that had been relying on that path broke — a well-known outage that hit sites which had changed nothing. Let's Encrypt fully retired the cross-sign in 2024, which cut certificate bytes per handshake by over 40%.

The operational lesson: a cross-signed chain has two expiry dates that matter — your certificate's and the cross-signing root's — and the second one is not visible in your own monitoring. It is a good example of why chain-aware monitoring beats leaf-only monitoring.


The analogy — the office's own warrant ran out.

Your passport is valid for another two years. But Regional Office 7's warrant expired last Tuesday.

Nobody told you, because nothing about your document changed. The officer refuses you anyway, because a document is only as good as the authority behind it — and that authority has lapsed.

Every certificate in the chain must be in date. Monitoring only your own expiry date is monitoring one link of three.

🧪 Exercise D3.1 — Build a chain-wide expiry check
bash
cd ~/tls-lab/m05

check_chain() {
  local host="$1" days="${2:-30}" f n=0
  f=$(mktemp)
  openssl s_client -connect "$host:443" -servername "$host" -showcerts </dev/null 2>/dev/null \
    | sed -n '/BEGIN CERT/,/END CERT/p' > "$f"
  [ -s "$f" ] || { echo "$host: could not retrieve certificates"; rm -f "$f"; return 1; }

  local d; d=$(mktemp -d)
  ( cd "$d" && csplit -sz -f c- -b '%02d.pem' "$f" '/BEGIN CERTIFICATE/' '{*}' )

  for c in "$d"/c-*.pem; do
    local subj end status
    subj=$(openssl x509 -in "$c" -noout -subject | sed 's/^subject=//' | cut -c1-45)
    end=$(openssl x509 -in "$c" -noout -enddate | cut -d= -f2)
    if openssl x509 -in "$c" -noout -checkend $((days*86400)) >/dev/null; then
      status="OK  "
    else
      status="WARN"
    fi
    printf '  [%s] depth=%d  %-47s %s\n' "$status" "$n" "$subj" "$end"
    n=$((n+1))
  done
  rm -rf "$f" "$d"
}

check_chain letsencrypt.org 30
check_chain example.com 30
Expected result — click to reveal
plain text
[OK  ] depth=0  CN = letsencrypt.org                                Nov 12 08:14:59 2026 GMT
[OK  ] depth=1  C = US, O = Let's Encrypt, CN = E7                  Sep  2 00:00:00 2028 GMT
[OK  ] depth=0  CN = example.com                                    Jan 15 23:59:59 2027 GMT
[OK  ] depth=1  C = US, O = DigiCert Inc, CN = DigiCert Global G3... Sep 23 00:00:00 2030 GMT

What to read out of this.

  • The script walks every certificate the server sent, not just the leaf. That single change is what makes it useful — an expired intermediate behind a healthy leaf is invisible to almost every off-the-shelf expiry check.
  • It reads what the server is actually serving, over the network, rather than a file on disk. Those two things diverge the moment a renewal writes a new file that nothing reloaded — which is the single most common certificate outage there is.
  • It does not check the root, because the root is not sent and is not supposed to be. To catch an expiring root you would monitor your trust store, which is a different job with a much longer time horizon.
  • Exit-code semantics from Module 03 (A3.2) are reused: -checkend N returns 0 for "safe" and 1 for "expiring or expired". Getting that backwards produces a monitor that reports healthy forever.

🔑 Three things separate real certificate monitoring from the naive kind, and all three are worth naming in an interview:

  1. Check the live endpoint, not the file — catches "renewed but never reloaded".
  2. Check every certificate in the chain — catches expiring intermediates.
  3. Alert with escalating lead time — 30 days, then 7, then daily — so a single missed email is not the last line of defence.

Now imagine this at 500 hosts. Run it from a central place against your public endpoints rather than as an agent on each host, because that also catches load balancers and CDNs terminating TLS with a certificate you did not know about. Feed the results into whatever you already alert on. Module 13 extends this into a full auditing tool.

🎯 Interview questions — Expiry in the chain

Q. Your certificate is valid for another year but the site is down with a certificate error. What could it be?

Several possibilities, roughly in order of likelihood:

  1. An expired intermediate. Every certificate in the chain must be in date, and leaf-only monitoring never sees this coming.
  2. An expired root, or a cross-signing root reaching its end date — the Let's Encrypt / DST Root CA X3 event of September 2021 is the canonical example.
  3. A renewal that was never reloaded. The new certificate is on disk; the running process still holds the old one in memory. Monitoring the file rather than the live endpoint misses this entirely.
  4. The wrong certificate being served — an SNI or virtual-host misconfiguration where a default server block answers instead.
  5. A revoked certificate, or an OCSP responder failing in a hard-fail configuration (Module 09).

The diagnostic that separates them in one command:

bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null \
  | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout

That prints every certificate the server is actually serving, so you can check each one's dates rather than assuming.


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    subgraph VAULT["🔒 OFFLINE - used a few times a decade"]
        R["👑 ROOT CA<br>RSA 4096 · 20 years<br>CA:TRUE · self-signed"]
    end
    subgraph ONLINE["🖥️ ONLINE - issues constantly"]
        I["🏢 ISSUING CA<br>RSA 2048 · 5 years<br>CA:TRUE pathlen:0<br>nameConstraints"]
    end
    R -->|"signs, once"| I
    I -->|"signs, thousands of times"| L1["🍃 app.internal.test"]
    I --> L2["🍃 api.internal.test"]
    I --> L3["🍃 db.internal.test"]
    R -.->|"distributed to<br>every client"| TS["💻 TRUST STORE<br>update-ca-certificates"]
    I -.->|"served by each server<br>in fullchain.pem"| SRV["🌐 THE SERVER<br>leaf + intermediate<br>NEVER the root"]
    style R fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style I fill:#ffe6cc,stroke:#d79b00,stroke-width:2px
    style TS fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style SRV fill:#d5e8d4,stroke:#82b366,stroke-width:2px

The two dotted lines are the ones people get backwards, and they are the shape of the whole module.

The root goes to the clients. It is the trust anchor, and it is the only thing you ever distribute. Because clients trust the root rather than the intermediate, you can rotate intermediates without touching a single machine.

The intermediate goes to the servers. Every server sends its leaf plus the intermediate, because the client has no other way to get it.

Send the root from the server and you waste bytes. Distribute the intermediate to clients instead of the root and you have built a hierarchy you can never rotate.


E2 · Production practice

HabitWhy
Keep the root key offline — HSM, or an air-gapped machine used a few times a decadeA compromised intermediate is replaceable. A compromised root has no recovery path at all
Serve leaf + intermediates, never the rootClients that have the root do not need it; clients that lack it will not trust it anyway. Let's Encrypt cut handshake certificate bytes 40% by shortening its chain
Distribute the root to clients, never the intermediateTrusting the root is what lets you rotate intermediates without touching any client
pathlen:0 on every issuing intermediateA compromised intermediate can mint leaves but cannot build a hierarchy beneath itself
Name-constrain any internal CA to your own namespacesWithout it, compromising your CA means impersonating any site on the internet to your whole fleet
Constrain intermediates with EKU — serverAuth only, unless more is neededAn intermediate limited to serverAuth cannot be turned into a code-signing or S/MIME CA
copy_extensions = none in every CA configThe issuer decides every security-relevant extension. This is Module 04's -copy_extensions trap in config form
Use openssl ca (or real CA software), never x509 -req, for anything you must operateOnly openssl ca keeps index.txt, and without that ledger revocation is impossible
Monitor every certificate in the chain on the live endpointExpired intermediates and un-reloaded renewals are both invisible to leaf-and-file monitoring
Deliver the root via config management or the base image, and into every runtime storeHand-installed roots vanish at OS upgrades, and the OS store does nothing for Java, Node or Python
Give the root a long life — 15–25 years — and plan its replacement years aheadReplacing a root is a multi-year cross-signing programme, not a maintenance window

E3 · Capstone exercise

Build a properly constrained two-tier CA from nothing, prove all four of its safety properties actually hold, and produce a deployable bundle. This exercises every section: hierarchy, openssl ca state, pathlen, name constraints, chain assembly, and chain-aware verification.

Brief.

  1. Build a root CA — offline-style, RSA 4096, 20 years, CA:TRUE, its own key used for nothing else
  2. Build an issuing CA signed by it — 5 years, CA:TRUE, pathlen:0, name-constrained to .corp.test and corp.test, EKU limited to serverAuth
  3. Issue a leaf for shop.corp.test with SANs for shop.corp.test and www.shop.corp.test
  4. Prove all four controls, each with a command whose failure you can show:
    • the chain verifies and the hostname matches
    • a leaf for evil.example.com from the same CA is rejected
    • a sub-CA created by the issuing CA produces a chain that is rejected
    • the leaf fails to verify when the intermediate is not supplied
  5. Assemble fullchain.pem correctly and confirm the order programmatically
  6. Print the CA ledger and explain what each field would become on revocation
Model answer — attempt it first, then click
bash
mkdir -p ~/tls-lab/m05/capstone && cd ~/tls-lab/m05/capstone
umask 077
mkdir -p root/{certs,db,private} sub/{certs,db,private}
touch root/db/index.txt sub/db/index.txt
openssl rand -hex 8 > root/db/serial; openssl rand -hex 8 > sub/db/serial

# ---------- 1. the root ----------
cat > root.cnf <<'EOF'
[ ca ]
default_ca = CA_root
[ CA_root ]
dir             = ./root
database        = $dir/db/index.txt
serial          = $dir/db/serial
new_certs_dir   = $dir/certs
certificate     = $dir/root.crt
private_key     = $dir/private/root.key
default_md      = sha256
policy          = pol
rand_serial     = yes
unique_subject  = no
copy_extensions = none
email_in_dn     = no
[ pol ]
countryName      = optional
organizationName = optional
commonName       = supplied
[ req ]
distinguished_name = dn
prompt = no
[ dn ]
C = MY
O = Corp Test
CN = Corp Test Root CA
[ v3_issuing ]
basicConstraints       = critical,CA:TRUE,pathlen:0
keyUsage               = critical,keyCertSign,cRLSign
extendedKeyUsage       = serverAuth
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid:always,issuer
nameConstraints        = critical,permitted;DNS:.corp.test,permitted;DNS:corp.test,excluded;IP:0.0.0.0/0.0.0.0
EOF

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 -out root/private/root.key
openssl req -x509 -config root.cnf -key root/private/root.key -out root/root.crt -days 7300 \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign" \
  -addext "subjectKeyIdentifier=hash"

# ---------- 2. the issuing CA ----------
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out sub/private/sub.key
openssl req -new -key sub/private/sub.key -out sub/sub.csr \
  -subj "/C=MY/O=Corp Test/CN=Corp Test Issuing CA 1"
openssl ca -config root.cnf -extensions v3_issuing -days 1825 -notext -batch \
  -in sub/sub.csr -out sub/sub.crt

# ---------- 3. the leaf ----------
sed 's|CA_root|CA_sub|; s|./root|./sub|; s|root.crt|sub.crt|; s|root.key|sub.key|' root.cnf > sub.cnf
cat >> sub.cnf <<'EOF'
[ v3_leaf ]
basicConstraints       = critical,CA:FALSE
keyUsage               = critical,digitalSignature
extendedKeyUsage       = serverAuth
subjectKeyIdentifier   = hash
authorityKeyIdentifier = keyid,issuer
subjectAltName         = DNS:shop.corp.test,DNS:www.shop.corp.test
[ v3_evil ]
basicConstraints = critical,CA:FALSE
keyUsage         = critical,digitalSignature
extendedKeyUsage = serverAuth
subjectAltName   = DNS:evil.example.com
[ v3_subca ]
basicConstraints = critical,CA:TRUE
keyUsage         = critical,keyCertSign,cRLSign
EOF

openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out shop.key
openssl req -new -key shop.key -out shop.csr -subj "/C=MY/O=Corp Test/CN=shop.corp.test"
openssl ca -config sub.cnf -extensions v3_leaf -days 90 -notext -batch -in shop.csr -out shop.crt

# ---------- 4. prove the four controls ----------
echo "== 4a. valid chain + hostname =="
openssl verify -CAfile root/root.crt -untrusted sub/sub.crt -verify_hostname shop.corp.test shop.crt

echo "== 4b. a name outside the constraint =="
openssl req -new -key shop.key -out evil.csr -subj "/CN=evil.example.com"
openssl ca -config sub.cnf -extensions v3_evil -days 90 -notext -batch -in evil.csr -out evil.crt
openssl verify -CAfile root/root.crt -untrusted sub/sub.crt evil.crt

echo "== 4c. a sub-CA beneath a pathlen:0 issuer =="
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out rogue.key
openssl req -new -key rogue.key -out rogue.csr -subj "/C=MY/O=Corp Test/CN=Rogue CA"
openssl ca -config sub.cnf -extensions v3_subca -days 365 -notext -batch -in rogue.csr -out rogue.crt
openssl req -new -key shop.key -out r2.csr -subj "/CN=deep.corp.test"
printf 'basicConstraints=critical,CA:FALSE\nsubjectAltName=DNS:deep.corp.test\n' > r2.cnf
openssl x509 -req -in r2.csr -CA rogue.crt -CAkey rogue.key -out deep.crt -days 90 -extfile r2.cnf 2>/dev/null
cat sub/sub.crt rogue.crt > deepchain.pem
openssl verify -CAfile root/root.crt -untrusted deepchain.pem deep.crt

echo "== 4d. no intermediate supplied =="
openssl verify -CAfile root/root.crt shop.crt

# ---------- 5. the bundle ----------
cat shop.crt sub/sub.crt > fullchain.pem
echo "== 5. bundle order (leaf must be first) =="
openssl crl2pkcs7 -nocrl -certfile fullchain.pem | openssl pkcs7 -print_certs -noout | grep subject
head -1 <(openssl x509 -in fullchain.pem -noout -subject)   # what a server reads as the leaf

# ---------- 6. the ledger ----------
echo "== 6. the issuing CA ledger =="
cat sub/db/index.txt

Expected output at the key points:

plain text
== 4a. valid chain + hostname ==
shop.crt: OK

== 4b. a name outside the constraint ==
CN = evil.example.com
error 47 at 0 depth lookup: permitted subtree violation
error evil.crt: verification failed

== 4c. a sub-CA beneath a pathlen:0 issuer ==
C = MY, O = Corp Test, CN = Corp Test Issuing CA 1
error 25 at 2 depth lookup: path length constraint exceeded
error deep.crt: verification failed

== 4d. no intermediate supplied ==
C = MY, O = Corp Test, CN = shop.corp.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error shop.crt: verification failed

== 5. bundle order (leaf must be first) ==
subject=C = MY, O = Corp Test, CN = shop.corp.test
subject=C = MY, O = Corp Test, CN = Corp Test Issuing CA 1
subject=C = MY, O = Corp Test, CN = shop.corp.test

== 6. the issuing CA ledger ==
V	261118...Z		4A1F...	unknown	/C=MY/O=Corp Test/CN=shop.corp.test
V	261118...Z		7B2C...	unknown	/CN=evil.example.com
V	270820...Z		9E44...	unknown	/C=MY/O=Corp Test/CN=Rogue CA

The six things this capstone is really testing.

1. The issuing CA signed everything it was asked to. Look at the ledger: evil.example.com and Rogue CA are both recorded as V — valid, issued, no complaint. Every one of your controls was enforced by the verifier, not the issuer. If you only tested by trying to issue, you would have concluded the controls did not work.

2. Three different error codes, three different controls. 47 for the name constraint, 25 for pathlen, 20 for the missing intermediate. Being able to name which control fired from the code alone is the practical skill.

3. The depths differ, and they point at different things. Error 47 at depth 0 (the leaf carries the bad name); error 25 at depth 2 (the CA carries the constraint). The depth identifies the certificate that made the check fail, which is not always the one holding the rule.

4. excluded;IP:0.0.0.0/0.0.0.0 forbids all IP SANs. Worth including on an internal CA: it means the CA cannot issue certificates for bare IP addresses, which closes off a class of internal impersonation that DNS-based constraints miss entirely.

5. The leaf is first in fullchain.pem — confirmed by openssl x509 -in fullchain.pem -noout -subject, which reads only the first certificate and is therefore exactly what nginx sees.

6. What revocation would look like. In index.txt, revoking evil.example.com changes field 1 from V to R and writes a timestamp into field 3. Nothing else in the file changes. That two-field edit is revocation — everything in Module 09 is about publishing it and getting clients to read it.

What is still missing, honestly: the root key is on the same disk (it should be air-gapped or in an HSM), there is no CRL, no OCSP responder, no automated issuance, and no separation of duties. That is the gap between this and step-ca or Vault PKI — and it is a fair thing to say out loud in an interview.


E4 · Official documentation — what to bookmark and how to read it

The single most useful command in this module: openssl verify -show_chain. It prints the path the verifier actually assembled, with depths and trust markers, which turns "it doesn't work" into "it broke at depth 1".

Make it a reflex: when a chain fails, run -show_chain before changing anything. The depth in the error tells you which link to fix, and that alone will save you from the most common wrong move — adding intermediates to the client's trust store instead of fixing the server.

Core reference pages

LinkWhat it is for
RFC 5280 §6 — Certification Path ValidationThe definitive algorithm every client implements. §6.1 is the checks, §6.1.4 covers pathlen and name constraints
openssl verify manual-untrusted, -CAfile, -CApath, -show_chain, -verify_hostname, and the full list of numeric error codes
openssl ca manualRunning a CA with state: index.txt, serials, policies, -extensions. Also covers CRL generation for Module 09
x509v3_config · configExact syntax for nameConstraints, basicConstraints, keyUsage — the page to keep open while writing a CA config
RFC 5280 §4.2.1.9 — Basic Constraints · §4.2.1.10 — Name ConstraintsThe two extensions Part C is built on
Let's Encrypt — Chains of TrustA live, well-documented real hierarchy. The best worked example of roots, intermediates and rotation
Let's Encrypt — Shortening the Chain (2023) · DST Root CA X3 ExpirationCross-signing explained by the people who did it, including the 2021 breakage
Let's Encrypt — Generation Y hierarchy (Nov 2025)A root rollover happening right now, with the reasoning written down
Mozilla CA Certificate Program policyWhat a CA must do to get into a trust store, and what gets it removed
CA/B Forum Baseline RequirementsThe rules public CAs operate under, including hierarchy and key protection requirements
step-ca · Vault PKI secrets engineWhat you would actually run in production instead of raw openssl ca

How to read a verify failure

Every openssl verify failure has the same three parts, and reading them in this order gets you to the cause fastest:

plain text
error 20 at 1 depth lookup: unable to get local issuer certificate
      |       |                    |
      |       |                    +-- 3. WHAT went wrong
      |       +-- 2. WHICH certificate in the chain  (0 = leaf, counting upward)
      +-- 1. the stable numeric code - use THIS in scripts, never the text
  1. The number is the contract. Error text changes between OpenSSL versions; the codes do not. Automation should match on the number.
  2. The depth tells you which link to look at. Depth 0 problems are about the leaf; depth 1+ are about the CAs above it. error 20 at 0 means the leaf's issuer is missing; error 20 at 1 means the intermediate's issuer is missing — usually an untrusted root.
  3. The text is for humans, and is often less precise than the code.

The error codes worth knowing by heart

CodeTextWhat it usually means
0ok
10certificate has expiredCheck every certificate in the chain, not just the leaf
18self-signed certificateA self-signed leaf, with no CA involved
19self-signed certificate in chainA real chain, but its root is not trusted here
20unable to get local issuer certificateThe commonest failure. Missing intermediate, or an untrusted root
21unable to verify the first certificateSame cause as 20, seen from s_client against a live server
24invalid CA certificateSomething in the chain is not CA:TRUE, or lacks keyCertSign
25path length constraint exceededA pathlen limit was violated (Part C1)
47permitted subtree violationA name constraint was violated (Part C2)
62hostname mismatchChain is fine; the certificate is for a different name

The offline alternative

bash
openssl verify -help                      # every flag, and the error code list
openssl ca -help                          # every flag for CA operation
man config                                # OpenSSL config file syntax
openssl x509 -in c.pem -noout -text | grep -A2 'Basic Constraints'
openssl errstr 0x1416F086                 # decode a raw OpenSSL error number
🧪 Exercise E4.1 — Read the error code list from the CLI
bash
openssl verify -help 2>&1 | head -30
Expected result — click to reveal
plain text
Usage: verify [options] cert.pem...
General options:
 -help                       Display this summary
 -engine val                 Use engine, possibly a hardware device
Certificate chain options:
 -CAfile infile              A file of trusted certificates
 -CApath dir                 A directory of files with trusted certificates
 -no-CAfile                  Do not load the default certificates file
 -no-CApath                  Do not load certificates from the default certificates directory
 -untrusted infile           A file of untrusted certificates
 -trusted infile             A file of trusted certificates
 -show_chain                 Display information about the certificate chain
...
Verification options:
 -verify_hostname val        Expected hostname
 -verify_ip val              Expected IP address
 -verify_email val           Expected email address
 -x509_strict                Disable certificate compatibility work-arounds
 -partial_chain              Accept partial certificate chain if at least one is a trusted certificate

What to read out of this — three flags here are worth knowing about.

  • -untrusted versus -trusted. -untrusted supplies intermediates that still have to chain to an anchor. -trusted says "treat these as anchors directly". Using -trusted where you meant -untrusted makes a broken chain appear to verify, which is a genuinely dangerous mistake in a test script.
  • -partial_chain accepts a chain that stops at any trusted certificate rather than requiring a self-signed root. That is how you pin trust to a specific intermediate rather than a root — occasionally useful, and occasionally the reason someone's verification is looser than they think.
  • -x509_strict turns off the compatibility workarounds OpenSSL applies by default. Worth running once against your own CA's output: if a certificate passes normally but fails under -x509_strict, you have a standards violation that some other client will eventually reject.

💡 -verify_ip and -verify_email are the siblings of -verify_hostname, for checking IP Address: and email: SAN entries. Same idea, different SAN type (Module 03, B3).


E5 · Self-assessment

Answer each out loud before opening it. If your answer is materially thinner than the one behind the toggle, that topic is worth a second pass.

1. What is a chain of trust, and what is checked at each link?

A sequence of certificates, each signed by the next, ending at a trust anchor in the client's store. At every link: signature, validity dates, name chaining (Issuer = next Subject), CA:TRUE, keyCertSign, pathlen, name constraints and revocation.

Two things people miss: every certificate must be in date, not just the leaf; and hostname matching is not part of chain validation — it is a separate check on the leaf's SAN.

2. Who builds the chain, and what is the server responsible for?

The client builds and validates it, searching upward from the leaf using Issuer names and AKI until it reaches a trusted anchor. The server's only job is to supply the intermediates.

This tells you where to fix things: a path-building failure is almost always a server problem. Adding intermediates to a client's trust store fixes one client and leaves every other one broken.

3. Should the root be included in what the server sends?

No. Clients that trust it already have it; clients that do not will never trust it because a server offered it. Sending it wastes bytes on every handshake — Let's Encrypt cut certificate bytes over 40% by shortening its chain.

Send leaf + intermediates. Distribute the root to clients separately.

4. Why two tiers? What is different about the root's key?

So the root key can stay offline — used a handful of times per decade, in an HSM or air-gapped machine. A key used for every issuance cannot be protected that way.

A compromised intermediate is revoked and replaced. A compromised root cannot revoke itself, and removal requires every browser and OS vendor to ship an update — years, in practice.

5. What does openssl ca give you that openssl x509 -req does not?

State: index.txt (the ledger of everything issued), a serial counter, and a copy of each certificate filed by serial.

That ledger is what makes revocation possible — a CRL is generated from it. A certificate the CA has no record of cannot be revoked.

6. What does pathlen:0 do, and who enforces it?

It forbids any CA beneath this one. The client enforces it during path validation — the issuing CA will happily sign a sub-CA anyway, and openssl ca does not check its own pathlen.

Violating it produces error 25: path length constraint exceeded, reported at the depth of the certificate carrying the constraint.

7. What stops your internal CA issuing a certificate for google.com?

By default nothing — and an internal root is as powerful as a public CA on every machine that trusts it. The control is name constraints: a critical extension listing permitted and excluded namespaces, e.g. permitted;DNS:.corp.test.

A leaf outside that space fails with error 47: permitted subtree violation. Pair it with pathlen:0 and EKU limits. Caveat: enforcement is client-side and a few older stacks ignore it, so treat it as defence in depth.

8. What is cross-signing and why does it exist?

A second certificate for the same CA subject and public key, signed by a different root. A leaf then validates under either root, because the issuer is identified by key.

It exists because getting a new root into trust stores takes years. Let's Encrypt's ISRG Root X1 was cross-signed by DST Root CA X3 from 2015; when X3 expired in September 2021, clients relying on that path broke. The cross-sign was fully retired in 2024.

9. Does the order of certificates in fullchain.pem matter?

Not to path building — the client searches, so a bag of certificates works in any order. It matters for two other reasons.

The server reads the file positionally: nginx treats the first certificate as the leaf, so a reversed file makes it serve the intermediate as the server certificate. And RFC 8446 specifies leaf-first on the wire. Leaf first, then up, never the root.

10. Your certificate has a year left but the site is down. What do you check?

An expired intermediate, an expired or retiring cross-signing root, a renewal that was never reloaded, the wrong certificate being served via SNI, or a revocation/OCSP failure.

One command settles most of it — print every certificate the server is actually serving and read their dates, rather than checking the file on disk.

11. What do error codes 20, 21, 25, 47 and 62 mean?

20 unable to get local issuer certificate — missing intermediate or untrusted root. 21 unable to verify the first certificate — the same cause, seen from s_client. 25 path length constraint exceeded. 47 permitted subtree violation — a name constraint. 62 hostname mismatch — chain fine, wrong name.

Match on the number in scripts; the text changes between versions. The depth in the message tells you which link failed.

12. Which certificate do you distribute to clients, and which to servers?

The root goes to clients — it is the trust anchor, delivered by config management or the base image, and into every runtime store (OS, Java cacerts, Node, Python) separately.

The intermediate goes to servers, served in fullchain.pem. Getting this backwards — distributing intermediates to clients — builds a hierarchy you can never rotate without touching every machine.


E6 · Command reference — everything from this module

The commands introduced in Module 05, grouped by what you are trying to achieve. The ones marked ⭐ are genuinely daily-use.

Inspect a chain

bash
openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null \
  | grep -c 'BEGIN CERTIFICATE'                              # ⭐ how many did the server send?
openssl s_client -connect h:443 -servername h -showcerts </dev/null 2>/dev/null \
  | sed -n '/BEGIN CERT/,/END CERT/p' > chain.pem            # ⭐ save the chain
openssl crl2pkcs7 -nocrl -certfile chain.pem \
  | openssl pkcs7 -print_certs -noout                        # ⭐ subject/issuer of EVERY cert
csplit -sz -f c- -b '%02d.pem' chain.pem '/BEGIN CERTIFICATE/' '{*}'   # split into files

Verify

bash
openssl verify -CAfile root.crt -untrusted int.crt leaf.crt          # ⭐ the standard check
openssl verify -CAfile root.crt -untrusted int.crt -show_chain leaf.crt  # ⭐ SHOW THE PATH
openssl verify -CAfile root.crt -untrusted int.crt \
  -verify_hostname app.example.com leaf.crt                          # ⭐ chain + name
openssl verify -CAfile root.crt -untrusted int.crt -verify_ip 10.0.1.5 leaf.crt
openssl verify -CAfile root.crt -untrusted int.crt -x509_strict leaf.crt  # standards-strict
openssl verify -CApath /etc/ssl/certs leaf.crt                       # against a hashed dir

Trust store

bash
sudo cp root.crt /usr/local/share/ca-certificates/myroot.crt && sudo update-ca-certificates  # ⭐ Debian
sudo cp root.crt /etc/pki/ca-trust/source/anchors/ && sudo update-ca-trust extract           # ⭐ RHEL
sudo rm /usr/local/share/ca-certificates/myroot.crt && sudo update-ca-certificates --fresh   # remove
openssl x509 -in root.crt -noout -subject_hash            # ⭐ the name it gets filed under
openssl rehash /path/to/CApath                            # ⭐ after adding files by hand
ls -l /etc/ssl/certs/ | grep -i myroot                    # confirm the symlinks exist

Run a CA

bash
mkdir -p ca/{certs,db,private} && touch ca/db/index.txt && openssl rand -hex 8 > ca/db/serial  # ⭐ setup
openssl ca -config ca.cnf -extensions v3_intermediate -days 1825 -notext -batch \
  -in int.csr -out int.crt                                # ⭐ issue an intermediate
openssl ca -config ca.cnf -extensions v3_server -days 90 -notext -batch \
  -in app.csr -out app.crt                                # ⭐ issue a leaf
cat ca/db/index.txt                                       # ⭐ the ledger
ls ca/certs/                                              # every certificate ever issued

Build and check a bundle

bash
cat leaf.crt int.crt > fullchain.pem                      # ⭐ leaf FIRST, no root
openssl x509 -in fullchain.pem -noout -subject            # ⭐ what nginx will treat as the leaf
grep -c 'BEGIN CERTIFICATE' fullchain.pem                 # ⭐ sanity: 2 or 3, never 1
openssl s_server -cert leaf.crt -cert_chain int.crt -key leaf.key -accept 4433 -www  # ⭐ test server

Confirm two certificates share a CA key (cross-signing)

bash
diff <(openssl x509 -in a.crt -noout -pubkey) <(openssl x509 -in b.crt -noout -pubkey)  # ⭐
openssl x509 -in leaf.crt -noout -ext authorityKeyIdentifier   # ⭐ which key issued this?
openssl x509 -in int.crt  -noout -ext subjectKeyIdentifier     # ⭐ must match the line above
The four-command chain triage. Nothing here changes anything, and between them they identify almost every chain failure:
bash
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null | grep -c 'BEGIN CERT'
openssl s_client -connect host:443 -servername host </dev/null 2>&1 | grep 'Verify return code'
openssl s_client -connect host:443 -servername host -showcerts </dev/null 2>/dev/null \
  | openssl crl2pkcs7 -nocrl -certfile /dev/stdin | openssl pkcs7 -print_certs -noout
openssl verify -CAfile root.crt -untrusted int.crt -show_chain leaf.crt

How many did it send · what does the client say · what is in each one · where exactly did the path break.


Next — Module 06 · The TLS Handshake, 1.2 vs 1.3.

You now have a certificate, a chain, and a CA that issued them. Module 06 covers what actually happens on the wire when a client connects: the full handshake step by step for both TLS 1.2 and 1.3, why 1.3 removed RSA key transport entirely, what forward secrecy really buys you, how cipher suites are named and negotiated, session resumption and the 0-RTT replay problem, SNI and ALPN — and the hybrid post-quantum key exchange that is now the default in every major browser.

Official reading ahead of it: RFC 8446 — TLS 1.3 and openssl s_client.

📚 Sources for the interview questions

Question selection was cross-referenced against publicly published 2026 SSL/TLS and PKI interview question sets, then rewritten and deepened:

Every command and every expected output in this module was executed on OpenSSL 3.0.13, including the complete two-tier CA build, the index.txt ledger format, and all four failure demonstrations — error 20 (missing issuer), error 21 (live server, missing chain), error 25 (path length constraint exceeded) and error 47 (permitted subtree violation). The openssl s_server -cert fullchain.pem behaviour noted in Exercise D1.1 — that it reads only the first certificate and needs -cert_chain for the rest — was discovered while testing this module rather than recalled.

Standards and history were verified against primary sources: RFC 5280 §6 and §4.2.1.9–4.2.1.10, the CA/B Forum Baseline Requirements, and Let's Encrypt's own documentation of its chains of trust, the cross-sign retirement, the DST Root CA X3 expiry and the Generation Y hierarchy.

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what actually differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.