Cryptography · lesson 6 of 8
Beginner
2Intermediate
5Also part of Cryptography
9- + Python Security from Python
- + Java Security from Java
- + Data Security and PII: Hashing, Masking, and Tokenization from Data Engineering
- + FastAPI Authentication: Password Hashing, OAuth2 and JWT from FastAPI
- + JWT Authentication in Spring Boot from Java
- + JavaScript Security Best Practices from JavaScript
- + Secrets: Real Log Masking, and the Base64 Trick That Defeats It from GitHub Actions
- + sensitive = true: What It Hides, and What It Doesn't from Terraform
- + Variable Precedence: host_vars Beats group_vars, and extra-vars Beats Everything from Ansible
X.509 Certificates: Build a Local CA and Watch Verification Fail
Build a throwaway CA with openssl, issue a server certificate, then watch verify fail with error 20, pass with -CAfile, and reject a hostname mismatch.
By SkillByExample · Published
Lesson 5 left a gap: to verify a signature you need the right public key, but how do you know which key is right? A certificate answers that. It’s a public key plus a name, signed by an authority you already trust. Here you build a throwaway certificate authority (CA), issue a server certificate from it, and watch each verification check fail and then pass. The CA is demo-only. It lives in one directory, has a 30-day lifetime, and is never added to a system trust store.
Run the scripts in order, in one directory, inside
docker run --rm -it -v "$PWD":/work -w /work python:3.12-slim bash. That image already includes the
openssl command. They were run with OpenSSL 3.5.7 on Debian 13.7, and Python 3.12.15 with cryptography
50.0.2 for the Python practice task. Your dates and fingerprints will differ.
A root CA: a self-signed certificate
A root CA is just a key pair and a certificate that signs itself.
# make_ca.sh -- a demo-only CA for this lab; never add it to a system trust store
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ca.key
openssl req -x509 -new -key ca.key -days 30 \
-subj "/CN=Demo Lab Root CA" -out ca.crt
openssl x509 -in ca.crt -noout -subject -issuer -enddate -ext basicConstraints
$ sh make_ca.sh
subject=CN=Demo Lab Root CA
issuer=CN=Demo Lab Root CA
notAfter=Nov 12 19:59:12 2026 GMT
X509v3 Basic Constraints: critical
CA:TRUE
Subject and issuer are the same name, which is what “self-signed” means. Nothing signed this certificate
except its own key, so it carries no proof of anything. A root is trusted only because someone chose to
put it in a trust store. CA:TRUE in Basic Constraints marks it as allowed to sign other certificates, and
it’s marked critical, so software that doesn’t understand the extension must reject the certificate.
ca.key is the most sensitive file in any PKI. Real CAs keep it offline or in a hardware security module.
Issuing a server certificate
The server makes its own key pair and a certificate signing request (CSR). The CA signs it, adding the names the certificate is valid for.
# issue_leaf.sh
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out server.key
openssl req -new -key server.key -subj "/CN=app.lab.test" -out server.csr
printf 'subjectAltName=DNS:app.lab.test,DNS:localhost\n' > leaf.ext
printf 'extendedKeyUsage=serverAuth\nbasicConstraints=CA:FALSE\n' >> leaf.ext
openssl x509 -req -in server.csr -CA ca.crt -CAkey ca.key -days 7 \
-extfile leaf.ext -out server.crt
openssl x509 -in server.crt -noout -subject -issuer -enddate \
-ext subjectAltName,extendedKeyUsage
$ sh issue_leaf.sh
Certificate request self-signature ok
subject=CN=app.lab.test
subject=CN=app.lab.test
issuer=CN=Demo Lab Root CA
notAfter=Oct 20 19:59:13 2026 GMT
X509v3 Subject Alternative Name:
DNS:app.lab.test, DNS:localhost
X509v3 Extended Key Usage:
TLS Web Server Authentication
The first two lines come from openssl x509 -req: it checks that the CSR is signed by the key it
contains, then reports the subject it’s signing. The server’s private key never left the server: the CSR
holds only the public key, and the CA never sees server.key. The fields that matter later are:
- Subject Alternative Name lists the hostnames the certificate is valid for. Browsers and TLS libraries
check only this, not the
CN. - Extended Key Usage
serverAuthlimits the certificate to TLS servers. notAfteris 7 days away. Short lifetimes limit the damage from a leaked key. Public CAs are moving towards 47-day maximums.
For the full field dump, run openssl x509 -in server.crt -noout -text.
Verification without, then with, the CA
A verifier has to chain the certificate back to a CA it trusts. First, try without telling it about the lab CA.
# verify.sh
openssl verify server.crt
echo "exit: $?"
openssl verify -CAfile ca.crt server.crt
echo "exit: $?"
$ sh verify.sh
CN=app.lab.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error server.crt: verification failed
exit: 2
server.crt: OK
exit: 0
Error 20, unable to get local issuer certificate, means OpenSSL looked through its default trust store
for the issuer Demo Lab Root CA and didn’t find it. “At 0 depth” means the problem is at the leaf, the
first certificate in the chain. That’s the correct result: nobody should trust a certificate from a CA
they’ve never heard of. With -CAfile ca.crt, the lab CA is trusted for this one command, the
signature checks out, and the result is OK.
You’ll meet error 20 in real deployments too, most often when a server sends its own certificate but not the intermediate CA between it and the root. The fix is always to supply the missing CA or chain. It’s never to turn verification off.
A valid certificate for the wrong name
A correct signature only proves that the CA issued the certificate to someone. The verifier must also check that it was issued for the host it’s talking to.
# hostname.sh
openssl verify -CAfile ca.crt -verify_hostname app.lab.test server.crt
openssl verify -CAfile ca.crt -verify_hostname localhost server.crt
openssl verify -CAfile ca.crt -verify_hostname bank.example server.crt
echo "exit: $?"
$ sh hostname.sh
server.crt: OK
server.crt: OK
CN=app.lab.test
error 62 at 0 depth lookup: hostname mismatch
error server.crt: verification failed
exit: 2
Both names in the SAN pass, and bank.example fails with error 62, hostname mismatch. Without that check,
anyone holding any certificate from a CA you trust could impersonate every site. openssl verify only
checks the hostname when you ask with -verify_hostname. Most TLS clients, including browsers, curl and
Python’s ssl.create_default_context(), check it by default. Code that disables it, such as
check_hostname = False, has the same hole. Lesson 7 runs this same check inside a real TLS handshake.
Practice
- Check expiry without waiting.
openssl verify -attime <unix-time>verifies as if at another time. Verifyserver.crt3 days and 10 days from now.
Solution
# expiry.sh
now=$(date +%s)
openssl verify -CAfile ca.crt -attime $((now + 3*86400)) server.crt
openssl verify -CAfile ca.crt -attime $((now + 10*86400)) server.crt
echo "exit: $?"
$ sh expiry.sh
server.crt: OK
CN=app.lab.test
error 10 at 0 depth lookup: certificate has expired
error server.crt: verification failed
exit: 2
At 10 days, the 7-day certificate has expired: error 10. An expired certificate is rejected even though
its signature is still mathematically valid. That’s what forces regular renewal, and why it pays to
automate renewal (ACME, cert-manager) and alert on notAfter.
- Try a CA that copies the real CA’s name. Make a second CA called
Demo Lab Root CA, use it to sign the same CSR, and verify the result against the realca.crt.
Solution
# rogue_ca.sh -- a second demo CA that copies the first one's name
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out rogue.key
openssl req -x509 -new -key rogue.key -days 30 \
-subj "/CN=Demo Lab Root CA" -out rogue.crt
openssl x509 -req -in server.csr -CA rogue.crt -CAkey rogue.key -days 7 \
-extfile leaf.ext -out forged.crt 2>/dev/null
openssl x509 -in forged.crt -noout -issuer
openssl verify -CAfile ca.crt forged.crt
echo "exit: $?"
$ sh rogue_ca.sh
issuer=CN=Demo Lab Root CA
CN=app.lab.test
error 20 at 0 depth lookup: unable to get local issuer certificate
error forged.crt: verification failed
exit: 2
The issuer name is identical, and verification still fails. A name is just text that anyone can type.
What links a certificate to its issuer is the signature, plus an Authority Key Identifier extension that
names the issuer’s key. Here the identifier points at the rogue key, so OpenSSL doesn’t even accept
ca.crt as a candidate issuer, and it reports error 20.
- Use the server certificate as a CA. Sign a certificate for
bank.lab.testwithserver.crtandserver.key, then verify it with the lab CA trusted andserver.crtsupplied as an intermediate (-untrusted).
Solution
# leaf_as_ca.sh -- can a server certificate issue certificates of its own?
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out other.key
openssl req -new -key other.key -subj "/CN=bank.lab.test" -out other.csr
printf 'subjectAltName=DNS:bank.lab.test\n' > other.ext
openssl x509 -req -in other.csr -CA server.crt -CAkey server.key -days 7 \
-extfile other.ext -out other.crt 2>/dev/null
openssl verify -CAfile ca.crt -untrusted server.crt other.crt
echo "exit: $?"
$ sh leaf_as_ca.sh
CN=app.lab.test
error 79 at 1 depth lookup: invalid CA certificate
error other.crt: verification failed
exit: 2
openssl x509 -req happily produced the certificate, because signing is just maths. The verifier refuses
it with error 79 at depth 1, the would-be intermediate, because server.crt says CA:FALSE. That’s
why basicConstraints=CA:FALSE belongs on every leaf certificate. Without it, anyone who stole a web
server’s key could mint certificates for other sites. Some old TLS libraries skipped this check, which
caused real impersonation bugs.
- Read the certificate from Python. Load
server.crtwithcryptography.x509and print the subject, issuer, SAN names, expiry, key type and SHA-256 fingerprint. Compare the fingerprint withopenssl x509 -fingerprint -sha256.
Solution
# read_cert.py
from cryptography import x509
from cryptography.hazmat.primitives import hashes
with open("server.crt", "rb") as f:
cert = x509.load_pem_x509_certificate(f.read())
san = cert.extensions.get_extension_for_class(x509.SubjectAlternativeName)
print("subject: ", cert.subject.rfc4514_string())
print("issuer: ", cert.issuer.rfc4514_string())
print("SAN: ", san.value.get_values_for_type(x509.DNSName))
print("not after: ", cert.not_valid_after_utc)
print("key type: ", cert.public_key().curve.name)
print("sha256 fp: ", cert.fingerprint(hashes.SHA256()).hex(":").upper()[:47], "...")
$ python read_cert.py
subject: CN=app.lab.test
issuer: CN=Demo Lab Root CA
SAN: ['app.lab.test', 'localhost']
not after: 2026-10-20 19:59:13+00:00
key type: secp256r1
sha256 fp: D6:85:19:CE:22:6F:F8:1D:93:96:90:AD:7C:FD:C8:CB ...
$ openssl x509 -in server.crt -noout -fingerprint -sha256
sha256 Fingerprint=D6:85:19:CE:22:6F:F8:1D:93:96:90:AD:7C:FD:C8:CB:B5:95:71:BC:2A:41:2E:CC:AB:A2:0F:2C:4A:36:64:D6
Both tools report the same fingerprint, because it’s simply SHA-256 over the certificate’s DER bytes. This
is the value to compare when pinning a certificate or checking one out of band. secp256r1 is OpenSSL’s
other name for P-256. Reading fields is safe, but loading a certificate checks nothing, not even
its signature. For verification, use a TLS library or cryptography.x509.verification, never your own
field-by-field checks.