<aside> 🧭

Module 07 · Certificate Validation: What a Client Actually Checks

Module 06 delivered a certificate and a chain. This module is the moment immediately after: the client decides whether to accept it. You will reproduce every common failure on purpose and see the exact error each one produces in curl, Python, Go, Node and OpenSSL — because recognising an error on sight is most of what TLS troubleshooting actually is.

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

Prerequisite: Modules 01–06. You need certificate fields from Module 03, chain validation and verify codes from Module 05, and the handshake from Module 06.

</aside>


<aside> 🛂

The picture to hold in your head for this whole module — the officer's checklist.

The passport is on the desk. The officer now works through a list, and stops at the first thing that fails:

  1. Is this document genuine — can I trace its stamp back to a government on my list?
  2. Is it in date?
  3. Is it the right person standing here?
  4. Is it the right kind of document for what you are asking to do?
  5. Has it been cancelled since it was issued?

Five independent gates. Passing four and failing one is a refusal. And — this is the part that catches people — different officers work through the list in different orders, so two officers can refuse the same traveller for two different stated reasons.

</aside>

Part A · The checks, in order

A1 · What a client actually verifies

<aside> 📖

Official docs: RFC 5280 §6 — Certification Path Validation · RFC 9525 — Service Identity in TLS · RFC 8446 §4.4.2 — the Certificate message

</aside>

# Gate What it rejects
1 Build a path No route from this certificate to a trusted anchor — missing intermediate, or unknown CA
2 Verify every signature A forged or corrupted certificate anywhere in the chain
3 Check every validity period Expired or not-yet-valid — at any depth, not just the leaf
4 Enforce CA constraints CA:FALSE used as an issuer, pathlen exceeded, name constraints violated
5 Check the purpose Key Usage or Extended Key Usage that does not permit TLS server authentication
6 Match the hostname A perfectly valid certificate belonging to somebody else
7 Check revocation A certificate withdrawn since issuance (Module 09) — and often skipped entirely

<aside> 🔑

Three things about this list that matter more than the list itself.

Gates 1–5 are about the chain. Gate 6 is not. Hostname matching is a completely separate operation performed on the leaf. That is why openssl verify says OK for a certificate belonging to a different site unless you add -verify_hostname — you have run gates 1–5 and skipped 6.

Gate 3 applies at every depth. An expired intermediate fails the whole chain even though your own certificate has a year left (Module 05, D3).

Gate 7 is the weak one. Most clients either skip revocation or fail open when the responder is unreachable. Module 09 is entirely about why.

</aside>

<aside> 🖥️

Build the failure lab. Everything in this module runs offline against certificates you generate yourself. This one script creates all of them.

mkdir -p ~/tls-lab/m07 && cd ~/tls-lab/m07
umask 077

# --- a CA, and a second unrelated CA ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca.key
openssl req -x509 -key ca.key -out ca.crt -days 3650 -subj "/CN=Test Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out other-ca.key
openssl req -x509 -key other-ca.key -out other-ca.crt -days 3650 -subj "/CN=Some Other CA" \
  -addext "basicConstraints=critical,CA:TRUE" -addext "keyUsage=critical,keyCertSign"

# --- one server key, reused everywhere ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out srv.key

mkext() { printf 'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=%s\nsubjectAltName=%s\n' "$2" "$1" > ext.cnf; }
sign()  { mkext "$2" "$3"; openssl req -new -key srv.key -out t.csr -subj "/CN=$1"
          openssl x509 -req -in t.csr -CA ca.crt -CAkey ca.key -out "$1.crt" -days 90 -extfile ext.cnf; }

sign good.test       "DNS:good.test,IP:127.0.0.1"        serverAuth
sign clientonly.test "DNS:clientonly.test,IP:127.0.0.1"  clientAuth

# --- signed by the OTHER CA: untrusted root ---
mkext "DNS:untrusted.test,IP:127.0.0.1" serverAuth
openssl req -new -key srv.key -out t.csr -subj "/CN=untrusted.test"
openssl x509 -req -in t.csr -CA other-ca.crt -CAkey other-ca.key -out untrusted.test.crt -days 90 -extfile ext.cnf

# --- self-signed ---
openssl req -x509 -key srv.key -out selfsigned.test.crt -days 90 -subj "/CN=selfsigned.test" \
  -addext "subjectAltName=DNS:selfsigned.test,IP:127.0.0.1" \
  -addext "basicConstraints=critical,CA:FALSE" -addext "extendedKeyUsage=serverAuth"

# --- an intermediate, for the incomplete-chain case ---
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out int.key
openssl req -new -key int.key -out int.csr -subj "/CN=Test Lab Issuing CA"
printf 'basicConstraints=critical,CA:TRUE,pathlen:0\nkeyUsage=critical,keyCertSign,cRLSign\n' > i.cnf
openssl x509 -req -in int.csr -CA ca.crt -CAkey ca.key -out int.crt -days 1825 -extfile i.cnf
mkext "DNS:chain.test,IP:127.0.0.1" serverAuth
openssl req -new -key srv.key -out t.csr -subj "/CN=chain.test"
openssl x509 -req -in t.csr -CA int.crt -CAkey int.key -out chain.test.crt -days 90 -extfile ext.cnf

ls *.crt

Expired and not-yet-valid certificates need explicit dates, which openssl x509 -req cannot set on OpenSSL 3.0 — so use openssl ca, which can:

mkdir -p db certs && touch db/index.txt && openssl rand -hex 8 > db/serial
cat > ca.cnf <<'EOF'
[ca]
default_ca=CA
[CA]
dir=.
database=$dir/db/index.txt
serial=$dir/db/serial
new_certs_dir=$dir/certs
certificate=$dir/ca.crt
private_key=$dir/ca.key
default_md=sha256
policy=pol
rand_serial=yes
unique_subject=no
copy_extensions=none
email_in_dn=no
[pol]
commonName=supplied
[srv]
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:expired.test,DNS:future.test,IP:127.0.0.1
EOF

openssl req -new -key srv.key -out t.csr -subj "/CN=expired.test"
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out expired.test.crt \
  -startdate 20240101000000Z -enddate 20240401000000Z

openssl req -new -key srv.key -out t.csr -subj "/CN=future.test"
openssl ca -config ca.cnf -extensions srv -batch -notext -in t.csr -out future.test.crt \
  -startdate 20300101000000Z -enddate 20310101000000Z

for f in good expired future; do printf '%-16s ' "$f"; openssl x509 -in $f.test.crt -noout -dates | tr '\n' ' '; echo; done

All output in this module was produced on OpenSSL 3.0.13, curl 8.x, Python 3.12, Go 1.2x and Node 22.

</aside>

🧪 Exercise A1.1 — Confirm the lab is built and the dates are what you asked for

cd ~/tls-lab/m07
ls *.crt
for f in good expired future; do printf '%-16s ' "$f"; openssl x509 -in $f.test.crt -noout -dates | tr '\n' ' '; echo; done

A2 · Three gates people conflate

<aside> 🚪

The analogy — three different doors, not one.

Is the document real? The officer traces the stamp to a government on the list. This is the chain.

Is it yours? They compare the photo with the face in front of them. This is the hostname.

Does it entitle you to this? A valid driving licence does not get you onto a flight. This is the purpose.

Three independent questions. A document can pass any two and fail the third. Treating them as one thing — "is the certificate valid?" — is why people get stuck: they fix the chain and are baffled that it still fails.

</aside>

🧪 Exercise A2.1 — Watch the same certificate pass one gate and fail another

cd ~/tls-lab/m07

echo "=== chain only: does it trace to our CA? ==="
openssl verify -CAfile ca.crt good.test.crt

echo "=== chain + correct hostname ==="
openssl verify -CAfile ca.crt -verify_hostname good.test good.test.crt

echo "=== chain + WRONG hostname ==="
openssl verify -CAfile ca.crt -verify_hostname wrong.name.test good.test.crt

echo "=== chain + purpose check, on a clientAuth-only certificate ==="
openssl verify -CAfile ca.crt clientonly.test.crt
openssl verify -CAfile ca.crt -purpose sslserver clientonly.test.crt