You already know what a certificate is (05-01) and how TLS puts it to work on every connection (05-02). But in production a certificate isn't a concept: it's an operational artifact with an expiration date, which someone has to issue, deploy, monitor, renew, and — on a bad day — revoke. Most real-world "TLS incidents" aren't cryptanalysis: they're certificates that expired on a Sunday because nobody renewed them. In this lesson you close out the module with the piece that turns everything before it into a reliable service: the full lifecycle, automation with ACME/Let's Encrypt and certbot, revocation mechanisms and their miseries (CRL, OCSP, stapling), an expiration monitor in Python for MediNube's domains, the lifecycle of internal certificates and clinics' mTLS client certificates, and a simulated incident: portal.medinube.example's private key leaked into a public repo, with its step-by-step runbook.
Contents
- The certificate as an operational artifact: the lifecycle
- Expiration: why short lifetimes are an advantage
- ACME and Let's Encrypt: automatic issuance and renewal
- certbot for portal.medinube.example
- Revocation: when and how (CRL, OCSP, stapling)
- Monitoring: finding out before expiration
- Internal certificates: the mini-PKI and clinics' mTLS clients
- Pinning: mention and risks
- Simulated incident: leaked private key — the runbook
The certificate as an operational artifact: the lifecycle
A certificate is born, works, and dies. Everything in this lesson fits into this cycle:
flowchart LR
E["Issue<br/>(CSR → CA signs)"] --> D["Deploy<br/>(nginx, reload)"]
D --> M["Monitor<br/>(expiring soon?<br/>is the right one still deployed?)"]
M -->|"renewal window<br/>arrives"| R["Renew<br/>(new CSR, new cert,<br/>ideally new key)"]
R --> D
M -->|"key compromise<br/>or entity offboarding"| X["Revoke<br/>(CRL/OCSP + reissue)"]
X --> E
Read the diagram as a loop: normal life is issue → deploy → monitor → renew → deploy..., and revocation is the emergency exit. Every arrow is a classic failure point: issuing without deploying (the new certificate sits in a folder), deploying without reloading nginx (it serves the old one until restart), renewing without monitoring (you don't know if it worked), revoking without reissuing (service down). The lesson's thesis: everything that can be automatic, should be, and whatever can't needs a written runbook.
Expiration: why short lifetimes are an advantage
It might seem annoying that certificates expire, and increasingly sooner too: from the 3-5 years typical in the 2010s, the industry moved to the 398-day maximum browsers accept (2020), Let's Encrypt issues certificates valid for 90 days, and the industry (CA/Browser Forum) has approved a staged reduction toward ~47-day certificates over the coming years. It's not sadism, it's security:
- It limits the damage window. If a key is compromised and nobody detects it, a 90-day certificate stops being valid within weeks; a 3-year one, within years. As you'll see below, revocation works poorly: short lifetime is the revocation that actually works.
- It forces automation. Nobody renews by hand every 90 days without eventually failing; with short lifetimes, you either automate or you suffer. And a process that's automated and exercised every 60 days is a process you know works — compared to the yearly ritual nobody remembers how to run.
- It speeds up crypto-agility (rule 8). If tomorrow you need to drop an algorithm or switch CAs, a certificate fleet that rotates every 90 days migrates entirely within weeks.
- It keeps data fresh. Domains change owners, companies disappear: information validated by the CA goes stale fast.
Mindset shift for the MediNube team: a certificate isn't "something you install," it's something that rotates, the way keys should rotate too (a topic module 6 revisits for application keys).
ACME and Let's Encrypt: automatic issuance and renewal
Let's Encrypt (2015) changed the game: free DV certificates, issued in seconds, through an open automated-issuance protocol: ACME (RFC 8555). ACME's idea is to automate exactly what 05-01 had "the CA doing behind the scenes": verifying that whoever requests the certificate controls the domain. To do that, it proposes challenges:
| HTTP-01 | DNS-01 | |
|---|---|---|
| What it proves | Control of the domain's web server | Control of the domain's DNS |
| Mechanics | The CA requests http://your-domain/.well-known/acme-challenge/<token> and must find the agreed-upon value |
You publish a _acme-challenge.your-domain TXT record with the agreed-upon value |
| Requirements | Port 80 reachable from the Internet | Your DNS provider's API (to automate) |
Wildcard (*.medinube.example)? |
No | Yes — the only way |
| Works for machines not exposed to the Internet? | No | Yes (the challenge is resolved in DNS, not on the machine) |
| Complexity | Minimal | Depends on the DNS provider |
The full flow, with pieces you already know: the ACME client generates an account key (signatures, 04-03) → requests a certificate for portal.medinube.example → the CA proposes the challenge → the client solves it (HTTP file or DNS TXT record) → the CA checks it from outside → the client sends the CSR (05-01) → the CA issues it and publishes it in the Certificate Transparency logs (05-01) → the client installs the certificate. Zero humans.
certbot for portal.medinube.example
certbot is the reference ACME client. Setting it up for the portal, with the nginx from 05-02 already serving the domain:
# Installation on Debian/Ubuntu (also available via snap/pip):
sudo apt install certbot python3-certbot-nginx
# Issue + automatically configure nginx in one step:
# --nginx: uses nginx to solve the HTTP-01 challenge AND edits its config
# (ssl_certificate* paths, 80→443 redirect if requested).
# -d: the domain (repeatable: -d www.medinube.example ...).
sudo certbot --nginx -d portal.medinube.example \
--email [email protected] --agree-tosAfter this, certbot leaves the material at stable paths that nginx references (notice: they're the ones from 05-02!):
/etc/letsencrypt/live/portal.medinube.example/fullchain.pem— leaf + intermediate./etc/letsencrypt/live/portal.medinube.example/privkey.pem— the private key (restrictive permissions;live/entries are symlinks to the latest version, which is why nginx needs no changes on renewal).
Automatic renewal is the half that matters. Modern packages install a systemd timer (or a cron job) that runs certbot renew twice a day; certbot only renews certificates with fewer than 30 days left, and reloads nginx after renewing (via a hook):
# Is the timer active? systemctl list-timers | grep certbot # Dry run WITHOUT issuing anything real: checks the challenge, permissions, and hooks. # ALWAYS do this after changing nginx or DNS configuration. sudo certbot renew --dry-run # Cron equivalent if there's no systemd (at 03:17 and 15:17, a random # minute chosen so as not to hit the CA right on the hour): # 17 3,15 * * * root certbot renew --quiet --deploy-hook "systemctl reload nginx"
For the *.medinube.example wildcard (if MediNube ever gave each clinic its own subdomain), the challenge must be DNS-01, using a plugin for the DNS provider (certbot-dns-cloudflare, certbot-dns-route53, etc.) and its credentials — credentials that are one more secret to protect, a direct preview of 06-01.
With this, "monitoring" doesn't disappear: automatic renewal fails too (DNS changed, a new firewall blocks port 80, the DNS plugin's credential expired). Automate renewal and watch its outcome (monitoring section).
Revocation: when and how (CRL, OCSP, stapling)
Revoking = declaring a certificate invalid before it expires. The two triggers that matter to MediNube:
- Private key compromise (the incident at the end of this lesson): anyone with the key can impersonate the portal until the certificate stops being valid.
- An entity offboards: Luna Medical Center cancels its contract — its mTLS client certificate (05-02) must stop granting API access today, not whenever it happens to expire.
The problem: telling every client in the world that a given certificate is no longer valid is inherently hard. The mechanisms, all imperfect:
| Mechanism | How it works | Problems |
|---|---|---|
| CRL (Certificate Revocation List) | The CA publishes a signed list of revoked serial numbers; the client downloads and checks it | Large lists, cached for hours/days; exposure window; many clients don't even check it |
| OCSP | The client asks the CA in real time about that specific certificate | Extra latency; the CA sees your browsing (privacy); and if OCSP doesn't respond, almost every client soft-fails: it proceeds anyway — a MITM that blocks OCSP neutralizes the mechanism |
| OCSP stapling | The server periodically fetches a fresh, signed OCSP response and "staples" it to the handshake | Fixes latency and privacy; but if the server doesn't staple, the client usually tolerates it (except with Must-Staple, rarely used); Let's Encrypt actually retired its OCSP service in 2025, falling back to CRLs |
| Short lifetime | Don't revoke: let it expire within days/weeks | It's the ecosystem's de facto revocation: it doesn't depend on the client checking anything; its "window" is the certificate's remaining lifetime |
Honest reading of the table: revocation in the public Web PKI is a best-effort mechanism. That's why the industry pushes ever-shorter lifetimes, and why, after a compromise, the runbook (last section) never stops at "revoke": it's always revoke + reissue with a new key + deploy, assuming some clients will never hear about the revocation. By contrast, in an internal PKI (our mini-PKI), you control both ends: you can distribute real CRLs and have your servers strictly enforce them — internal revocation actually can be reliable.
With the lab mini-PKI, revoking requires keeping a small database of issuances (openssl ca instead of the direct openssl x509 -req from 05-01); the conceptual flow is: mark the serial as revoked → regenerate the signed CRL (openssl ca -gencrl) → distribute it to the servers that validate (the mTLS nginx loads it with ssl_crl).
Monitoring: finding out before expiration
Operating rule: no MediNube certificate expires as a surprise. The simplest, most robust monitor doesn't look at configuration, it looks at what's actually being served — the same way a client would. Script medinube/monitor_certs.py:
"""MediNube certificate expiration monitor.
Connects to each domain the way a real TLS client would,
reads the presented certificate, and warns if it expires in < DAYS_THRESHOLD.
Meant for a daily cron job; alerts can be delivered through the
HMAC-signed webhook from lesson 03-02.
"""
import socket
import ssl
import sys
from datetime import datetime, timezone
from cryptography import x509
# Every exposed or important internal TLS endpoint at MediNube:
DOMAINS = [
("portal.medinube.example", 443),
("api.medinube.example", 443),
]
DAYS_THRESHOLD = 30 # reaction margin; > certbot's renewal window
def days_remaining(host: str, port: int) -> int:
"""Returns how many days of validity are left on the served certificate."""
context = ssl.create_default_context() # full validation (05-02)
with socket.create_connection((host, port), timeout=10) as tcp:
with context.wrap_socket(tcp, server_hostname=host) as tls:
# getpeercert(binary_form=True) → DER; we parse it with
# cryptography to work with objects, as in 05-01.
der = tls.getpeercert(binary_form=True)
cert = x509.load_der_x509_certificate(der)
remaining = cert.not_valid_after_utc - datetime.now(timezone.utc)
return remaining.days
def main() -> int:
exit_code = 0
for host, port in DOMAINS:
try:
days = days_remaining(host, port)
except ssl.SSLCertVerificationError as e:
# A certificate that's ALREADY invalid (expired, broken chain) is a max alert:
print(f"[CRITICAL] {host}: certificate does NOT validate: {e}")
exit_code = 2
continue
except OSError as e:
print(f"[ERROR] {host}: could not connect: {e}")
exit_code = max(exit_code, 1)
continue
if days < DAYS_THRESHOLD:
print(f"[WARNING] {host}: expires in {days} days (< {DAYS_THRESHOLD})")
exit_code = max(exit_code, 1)
else:
print(f"[OK] {host}: {days} days remaining")
return exit_code
if __name__ == "__main__":
sys.exit(main())Design points, in detail:
- We use
ssl.create_default_context()with validation on: if the certificate no longer validates, we want the critical alert, not to read it anyway. (Inspecting already-expired certificates would need tricks that skip validation; for monitoring, validation is part of the test.) getpeercert(binary_form=True)returns the leaf's DER;cryptographygives usnot_valid_after_utcas a timezone-aware datetime, and we subtract againstdatetime.now(timezone.utc)— the same pattern from 05-01's exercise 2.- The 30-day threshold isn't arbitrary: certbot renews once there are fewer than 30 days left, so "fewer than 30 days left and still not renewed" means the automation is failing and there's still human reaction time.
- The exit code (0/1/2) is understood by any task scheduler or monitoring system. In cron:
0 8 * * * python3 /opt/medinube/monitor_certs.py || curl ... # notify. To notify the team's channel, reuse the signed webhook from 03-02: the alert travels withX-MediNube-TimestampandX-MediNube-Firma: v1=<hex>, because a forgeable security alert would be ironic. - A natural extension (exercise 1): also watch clinics' mTLS client certificates, which can't be checked by connecting to anything — you have to read the PEM files issued by the internal CA.
Internal certificates: the mini-PKI and clinics' mTLS clients
The whole lifecycle applies equally — with nuances — to certificates that don't come from a public CA:
The lab's root CA (05-01). It also expires (we issued it for 5 years). Renewing it is a major event: the new root must be redistributed to every client that trusts it (verify=/etc/medinube/pki/ca.crt in the 05-02 integrations) before the old one expires, with an overlap period where both are valid. Put it on the team calendar the day you create it: "renew internal root: start 6 months before 2031." Internal leaves, by contrast, rotate like public ones: short-lived and automated (ACME-like internal tools exist — step-ca, HashiCorp Vault — for when the artisanal mini-PKI outgrows itself; just a mention, secrets management is 06-01).
Onboarding a clinic (mTLS, 05-02). Standard procedure: the clinic generates its own key and CSR (the private key never travels, 05-01) with SAN clinica-sol.clientes.medinube.example → MediNube verifies over a trusted channel that the CSR really belongs to who it claims (identity validation is the CA's job, even when the CA is you!) → the internal CA signs it with a short lifetime (90 days) → the clinic installs it and tests it against the mTLS endpoint.
Offboarding a clinic = revocation + defense in depth. Luna Medical Center cancels its contract. Steps: (1) revoke its certificate in the internal CA and regenerate/distribute the CRL to the mTLS nginx; (2) additionally, disable its identity ($ssl_client_s_dn) at the application's authorization layer — because rule 9 still applies: cryptography doesn't replace access control, so the offboarding takes effect even if a CRL is slow to propagate; (3) the 90-day short lifetime acts as the final safety net. Three layers, none sufficient alone, all three together robust.
Pinning: mention and risks
You'll hear about pinning: fixing, on the client (typically a mobile app), the fingerprint of the server's certificate or public key, and rejecting any other one even if the chain validates. It raises the bar against compromised CAs... and it's an operational trap: the pin lives inside the installed app, and the certificate rotates every 90 days. If you rotate the key without updating the pin (or without having also pinned a backup key), your own app refuses to talk to you — a self-inflicted denial of service across your entire installed base. Browsers' HPKP was retired precisely because of this class of accident; today the ecosystem prefers Certificate Transparency (05-01) as a control. For MediNube: no pinning except in very well-justified cases, and always with backup keys and a pin expiration. Know it exists, know why it almost never pays off.
Simulated incident: leaked private key — the runbook
Friday, 5:40 p.m. A MediNube developer flags that, while making a utilities repository public, they discovered it had contained, for three weeks, a deploy/ directory with portal.medinube.example's privkey.pem. The portal certificate's private key is sitting in a public repo. Panic? No: runbook. (Reassuring note you owe to 05-02: thanks to TLS 1.3's forward secrecy, past captured traffic is NOT decryptable with that key; the risk from now on is impersonation.)
-
Confirm and scope it (minute 0). Is it really the key in use? Compare the public key derived from the leaked file against the one from the served certificate — same key = same modulus/public point:
openssl pkey -in leaked_privkey.pem -pubout | openssl sha256 openssl s_client -connect portal.medinube.example:443 \\ -servername portal.medinube.example </dev/null 2>/dev/null \\ | openssl x509 -noout -pubkey | openssl sha256 # Do the hashes match? Then the active key is compromised. -
Treat the key as compromised, effective immediately. Three weeks public = copied. Removing it from the repo does not undo anything (git history and other people's clones still exist); rewriting history is hygiene, not remediation.
-
Generate a new key + CSR + reissue (minutes 5-15). Never reissue with the same key. With certbot it's a single step that forces a new key and reissues:
sudo certbot certonly --nginx -d portal.medinube.example --force-renewal # (certbot generates a new privkey by default on every issuance) -
Deploy and reload (minutes 15-20).
sudo systemctl reload nginxand verify with step 1's command that the portal is now serving the new key (the hashes no longer match the leaked one) and withs_clientthatVerify return code: 0 (ok). -
Revoke the old certificate (minutes 20-30). Now that the new one is serving traffic, invalidate the old one — certbot lets you declare the reason:
sudo certbot revoke \\ --cert-path /etc/letsencrypt/archive/portal.medinube.example/cert1.pem \\ --reason keycompromiseRemember the revocation section: some clients will never find out; that's why steps 3-4 come first — the service is already safe even if revocation propagates slowly. (With Certificate Transparency, also watch for any new, unrequested issuances for your domains: if the attacker tried anything else, it'll show up.)
-
Collateral hunting. What else was in that repo? Other keys, tokens, certbot's DNS plugin credentials? Every finding opens its own mini rotation runbook.
-
Blameless post-mortem (the following week). Root cause: a private key living in an ordinary working directory, ready to be committed. Actions: a secrets scanner in CI (that blocks the push), defensive
.gitignoreentries, and the underlying fix: keys don't live in repos or project directories; they live in a secrets manager. Which is exactly where module 6 begins.
Rehearse this runbook in the lab (exercise 3) before you need it: an unrehearsed runbook is just a hypothesis.
Common Mistakes and Tips
- Trusting that "certbot is already set up" without watching it. Automatic renewal fails silently (new firewall, changed DNS, expired credential).
certbot renew --dry-runafter every infrastructure change + an external expiration monitor. Automate and verify. - Renewing the certificate and not reloading the service. nginx loads the certificate at startup/reload; if the reload hook is missing, you'll serve the old one until it expires in front of your users. The monitor catches this because it looks at what's served, not what's issued.
- Reissuing after a compromise while reusing the compromised private key. Reissuing with the same compromised key is like putting a new case on a stolen phone. Always a new key.
- Trusting a clinic's offboarding to revocation alone. CRL/OCSP propagate slowly or aren't checked at all. Offboarding = revoke and deauthorize in the application and let the short lifetime finish the job.
- Wildcards "to simplify things" without thinking it through.
*.medinube.exampleis a single key that, if compromised, covers every subdomain, and requires exposing DNS credentials to the automation. Use it when the case calls for it, not out of laziness about adding another-d. - Forgetting internal certificates in the inventory. The lab root, clinic certs,
farmacias-ca.crt... they expire too. Every certificate that exists should be on some monitor's list, or it will expire on a Sunday. - Health data in the mix: remember MediNube is a fictional universe; in a real deployment, an incident like this runbook's would additionally involve GDPR obligations (breach assessment and, where applicable, notifying the authority) and compliance review — the technical post-mortem isn't the end of the paperwork.
Exercises
Exercise 1 — PEM file monitor. Extend the monitor with days_remaining_pem(path: str) -> tuple[str, int] which, given a local PEM (e.g., Clínica Sol's mTLS client certificate issued by the internal CA), returns (subject_name, days_remaining). Add a PEM_FILES list to main() using the same threshold. That way the monitor also covers what can't be checked by connecting to it.
Exercise 2 — ACME challenge decision. For each MediNube case, decide HTTP-01 or DNS-01 and justify it: (a) portal.medinube.example, a public web server; (b) *.clientes.medinube.example, one subdomain per clinic; (c) intranet.medinube.example, publicly resolvable in DNS but only reachable from the corporate VPN.
Exercise 3 — Compromise drill in the lab. With the mini-PKI from 05-01: (1) generate a new key and certificate for portal.medinube.example (portal2.key/portal2.crt) signed by your CA; (2) verify with openssl that portal2.crt's public key differs from portal.crt's (the equivalent of the runbook's step 1); (3) note which step of the real runbook you can't reproduce in the lab as you set it up, and why.
Solutions
Solution 1.
def days_remaining_pem(path: str) -> tuple[str, int]:
with open(path, "rb") as f:
cert = x509.load_pem_x509_certificate(f.read())
remaining = cert.not_valid_after_utc - datetime.now(timezone.utc)
return cert.subject.rfc4514_string(), remaining.days
PEM_FILES = [
"/etc/medinube/pki/ca.crt", # the root expires too!
"/etc/medinube/pki/clientes/clinica-sol.crt",
]
# In main(), after the DOMAINS loop:
for path in PEM_FILES:
try:
subject, days = days_remaining_pem(path)
except (OSError, ValueError) as e:
print(f"[ERROR] {path}: could not read/parse: {e}")
exit_code = max(exit_code, 1)
continue
if days < DAYS_THRESHOLD:
print(f"[WARNING] {subject} ({path}): expires in {days} days")
exit_code = max(exit_code, 1)
else:
print(f"[OK] {subject}: {days} days remaining")Note: for the root CA a larger threshold makes sense (e.g., 180 days), because renewing it means redistributing trust to every client, not a simple reload — you could parametrize the threshold per file as an improvement.
Solution 2. (a) HTTP-01: a public server with port 80 reachable, the ideal case, zero extra credentials. (b) DNS-01, mandatory: it's a wildcard, and HTTP-01 can't issue those; it also requires automating against the DNS API and protecting that credential (06-01). (c) DNS-01: the CA can't reach the server over HTTP (VPN-only), but the DNS challenge resolves through public DNS without exposing the intranet. A reasonable alternative you might have proposed: issue it with the lab's internal CA, since only corporate clients that already trust it consume it.
Solution 3.
cd ~/medinube-lab/pki
# (1) new key and certificate, same identity:
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out portal2.key
openssl req -new -key portal2.key \
-subj "/CN=portal.medinube.example/O=MediNube SL" \
-addext "subjectAltName=DNS:portal.medinube.example" -out portal2.csr
openssl x509 -req -in portal2.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
-days 90 -sha256 -copy_extensions copy -out portal2.crt
# (2) compare public keys by hash (they must DIFFER):
openssl x509 -in portal.crt -noout -pubkey | openssl sha256
openssl x509 -in portal2.crt -noout -pubkey | openssl sha256(3) The irreproducible step is effective revocation: we issued directly with openssl x509 -req, without openssl ca's issuance database, so there's nothing to generate a CRL from; and even if there were, no client checks it yet. It's a perfect illustration of the lesson: revocation only works if revocation infrastructure exists and clients enforce it — otherwise your only real defenses are a fast reissue+deploy and the certificate's short lifetime.
Conclusion
With this lesson, the certificate stops being a concept and becomes an artifact with a life: issue → deploy → monitor → renew → revoke, with ACME/certbot automating the happy path for portal.medinube.example, short lifetimes as the revocation that actually works, CRL/OCSP/stapling as a reminder that telling the whole world is hard, a Python monitor that promises nothing expires as a surprise (alerting through 03-02's signed webhook), the full lifecycle of internal and mTLS client certificates (with a clinic's offboarding solved in three layers), and a compromise runbook rehearsed before it's ever needed.
And with it, all of Module 5 closes out. Take stock of the debt we'd been carrying since 04-01: "knowing whose public key each one is, with verifiable backing." Resolved, threefold: X.509 certificates and CAs (05-01) bind key and identity with signatures MalloryClinic can't forge; TLS (05-02) puts that trust to work on every connection, assembling every primitive from the course into a single handshake; and lifecycle operations (this lesson) guarantee that trust doesn't rot over time. The crack is closed: you encrypt, sign, verify, agree on keys, and you know who you're talking to.
So, are we done? Almost: what's left is bringing it all into the day-to-day of development, which is exactly where flawless cryptography gets undone by mundane details. This lesson's incident already hinted at it: the key didn't fall because of mathematics, it fell because of a git push. Module 6: Cryptography in Real-World Development starts right there — key and secrets management (06-01: where that privkey.pem and certbot's DNS credentials should have lived), and continues with encryption at rest and in transit applied to MediNube's architecture (06-02, including the envelope encryption we previewed in 04-05), signed tokens and JWT for the portal's sessions (06-03), a gallery of common cryptographic mistakes with all the course's _BAD examples gathered together (06-04), and a look at what's coming: post-quantum cryptography (06-05). You already have every piece and all the trust you need; now it's time to use them well when nobody's watching. See you in module 6.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
