Module 11 — Certificate Transparency, CAA & the Public Trust Ecosystem

Updated 26 August 2026

Module 11 · Certificate Transparency, CAA & the Public Trust Ecosystem

Every module so far has quietly assumed the CAs behave. This one stops assuming. Module 05 showed that any CA in the trust store can sign for any name — so the real question is not "how do we stop a CA misbehaving?" but "how would we ever find out?". Certificate Transparency is the answer the industry actually built, and CAA is the one control you own yourself.

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

Prerequisite: Modules 01–10. You need X.509 extensions from Module 03 (C4–C5), the chain of trust and trust stores from Module 05, openssl verify error codes from Module 07, and the ACME account URI from Module 10 (B2).


The picture to hold in your head for this whole module — every locksmith in the city can cut a key to your front door.

That is genuinely the situation Module 05 left you in. Your browser trusts around 150 root certificates. Any one of them can issue a certificate for your domain, without asking you, without telling you.

You cannot take the locksmiths' tools away — the web needs them. So the industry did two other things instead.

First, it forced every locksmith to publish every key they cut, in a public register that cannot be edited afterwards. That is Certificate Transparency. It does not prevent a bad key; it guarantees you can find out about it.

Second, it let you nail a notice to your own door saying which locksmiths you actually use. That is a CAA record. It is voluntary for you to publish, but mandatory for them to read.

Neither is prevention. Both are accountability — and in this ecosystem accountability turned out to be the thing that worked.

Build the lab. Most of this module runs offline inside ~/tls-lab/m11/. Two sections use DNS lookups (dig) and two use openssl s_client against public sites — both read-only, neither changes anything on your machine.
bash
mkdir -p ~/tls-lab/m11 && cd ~/tls-lab/m11

You will need dig. It ships with macOS. On Debian/Ubuntu: sudo apt-get install -y bind9-dnsutils. On RHEL/Fedora: sudo dnf install -y bind-utils.

All output in this module was produced on OpenSSL 3.0.13 with BIND 9.18 dig, except where a block is explicitly marked as coming from a public site.


Part A · Certificate Transparency — the public ledger

A1 · The problem CT solves — a CA can lie, and nobody would know

The analogy — a bank that never sends statements.

Imagine a bank that will honour a cheque signed by any one of 150 branch managers, and never publishes a list of the cheques it honoured.

A dishonest manager could write cheques against your account for months, and the only way you would ever discover it is if you happened to be standing in the shop when one was cashed.

That is exactly the pre-2013 web. Certificate Transparency is the bank statement — it does not stop the dishonest manager, it just makes it impossible for the cheque to stay hidden.

Module 05 established the rule that makes the whole web PKI work: a certificate is trusted if it chains to any root in your trust store. Module 07 showed the client checking that chain.

Now look at that rule from the other side. There is no field anywhere in X.509 that says "only this CA may issue for yourbank.com". The trust store is a flat list of equals. A small CA you have never heard of, in a jurisdiction you have never visited, can sign a certificate for your domain, and every browser on earth will accept it.

The insight the whole module rests on: web PKI trust is the union of every CA, not the intersection.

Adding a CA to a trust store does not add a little bit of trust. It adds the full power to impersonate every website on the internet. There are around 150 of them.

This is why the interesting security question in public PKI is not cryptographic. The maths has been fine for twenty years. The question is governance: who watches the CAs?

This is not theoretical. In 2011 a Dutch CA called DigiNotar was compromised. The attacker issued a valid certificate for *.google.com and it was used to intercept the Gmail traffic of an estimated 300,000 Iranian users. The certificate had been in the wild for weeks before anyone noticed — and it was noticed by accident, because one Chrome user in Iran had a build with hard-coded pinning for Google's own certificates and reported the error to a forum.

That is the whole problem in one sentence: the detection mechanism was a coincidence. Certificate Transparency exists to replace the coincidence with a guarantee.

🧪 Exercise A1.1 — Count the number of organisations that can impersonate your website
bash
cd ~/tls-lab/m11

# how many roots does this machine trust?  (Debian/Ubuntu path)
grep -c "BEGIN CERT" /etc/ssl/certs/ca-certificates.crt

# how many distinct organisations is that?
mkdir -p roots && cd roots
awk '/BEGIN CERT/{n++} {print > ("root-" n ".pem")}' /etc/ssl/certs/ca-certificates.crt
for f in root-*.pem; do openssl x509 -in "$f" -noout -subject 2>/dev/null; done \
  | sed -n 's/.*O = \([^,]*\).*/\1/p' | sort -u | wc -l
cd ..

On macOS the bundle is not at that path. Use this instead, which exports the System roots from the keychain:

bash
security find-certificate -a -p /System/Library/Keychains/SystemRootCertificates.keychain > macos-roots.pem
grep -c "BEGIN CERT" macos-roots.pem
Expected result — click to reveal
plain text
153
71

What to read out of this — the second number is the one that should bother you.

  • 153 root certificates. Your exact count will differ by distribution and by how recently the bundle was updated. Anything from roughly 130 to 180 is normal.
  • 71 distinct organisations. Several organisations hold more than one root, which is why the second number is smaller.
  • Every one of those 71 organisations can issue a certificate for your domain that this machine will accept without complaint. Not "could theoretically" — the software will simply say OK, exactly as it did in Module 07.

🔑 This is the correct mental model, and most people get it wrong. People imagine the trust store as a list of "companies we think are honest". It is more accurate to read it as a list of 151 spare keys to your front door, held by 71 different organisations, in about 30 different legal jurisdictions.

💡 Now imagine this at 500 hosts. It makes no difference — and that is the point. The exposure is not per-host, it is per-name. One mis-issued wildcard for your domain compromises every host you own simultaneously, and nothing on any of those 500 machines would log a thing, because from their point of view nothing happened at all.

🎯 Interview questions — Why CT exists

Q. What problem does Certificate Transparency solve?

It solves detection, not prevention. In the web PKI any trusted CA can issue a certificate for any domain, and before CT there was no reliable way for a domain owner — or a browser vendor — to learn that this had happened. Mis-issuance was discovered by accident, if at all.

CT requires every publicly-trusted certificate to be submitted to public, append-only logs before browsers will accept it. The domain owner can then watch those logs and see every certificate issued for their names, by anyone.

The detail worth adding: CT deliberately does not try to stop bad issuance, and saying so shows you understand the design. Stopping it would require the log to make a policy judgement, which would make the log a gatekeeper and a single point of failure. Instead CT makes mis-issuance undeniable and time-bounded — and that changed CA behaviour far more effectively than any technical control, because a CA that mis-issues now does so in public and faces removal from the root programs.

Q. If a CA I have never heard of issues a certificate for my domain, what breaks?

Nothing breaks. That is the uncomfortable answer. Chain building in Module 07 stops at the first trusted root it can reach; it has no notion of which CA should have issued. The certificate validates and the browser shows a padlock.

The attacker still needs to get traffic to their server — DNS hijack, BGP hijack, a compromised network, or a malicious proxy — but once they have that, a mis-issued certificate makes the interception invisible.

The detail worth adding: name the three layers of defence in order, because they are commonly confused. CAA reduces the chance of issuance happening at all (Part C). CT guarantees you can detect it afterwards. Revocation (Module 09) is supposed to clean up and largely does not work. A strong answer notes that the middle one — detection — is the one that actually carries the weight in practice.

Q. Why is public key pinning not the answer?

HTTP Public Key Pinning (HPKP) let a site tell browsers "only accept these specific keys for me". It worked, and it was deprecated and removed — Chrome dropped it in version 72 (2019).

The reason is that it was a loaded gun pointed at the site's own foot. A pin is cached by browsers for the max-age you set. If you lose the pinned key, or your CA changes hierarchy, you have bricked your own domain for every returning visitor until the pin expires — and you cannot recall it. There was also a real hostile-pinning risk: an attacker who compromised a server briefly could set a long pin to keys they controlled, and take the site down permanently.

The detail worth adding: the useful contrast is that HPKP failed closed and CT fails open. HPKP made the site owner carry catastrophic risk to buy prevention. CT moved the burden onto the CAs and the log operators, gave the site owner detection instead of prevention, and made the failure mode "you get an email" rather than "your domain is unreachable for a year". Pinning survives today only where the failure is recoverable — inside mobile apps and internal services you also control, which is Module 12's territory.


A2 · The log — an append-only tree you cannot edit afterwards

The analogy — a ledger where every page number is calculated from all the pages before it.

A normal ledger can be tampered with. Tear out page 40, write a new one, put it back — as long as your handwriting matches, nobody can tell.

Now imagine a ledger where the number printed at the top of each page is not 41, but a fingerprint of every page before it. Change one letter on page 40 and page 41's number is wrong, and so is page 42's, and so is the number on the final page — the one the whole world has already written down.

That final number is the root hash. A CT log publishes it constantly. Editing history is not "hard" in this design; it is arithmetically visible.

A CT log is a single, ordered, append-only list of certificates, organised as a Merkle tree — a binary tree of hashes where the leaves are certificates and every internal node is the hash of its two children. The single hash at the top is the root hash, and a signed copy of it is called a Signed Tree Head (STH).

Two operations make the structure useful, and they are the two words to know:

ProofWhat it answers
Inclusion proof"Is this certificate really in the log?" — the log hands you a short list of sibling hashes; you recompute the root yourself and compare
Consistency proof"Is the log I see today an append to the log I saw yesterday?" — proves nothing was removed or rewritten between two tree sizes

Both proofs are log₂(n) in size. For a log with a billion certificates, an inclusion proof is about 30 hashes — under a kilobyte. That efficiency is the entire reason the design is practical.

🧪 Exercise A2.1 — Build a miniature CT log and try to tamper with it

This runs entirely offline. Save it as ~/tls-lab/m11/merkle.py:

python
#!/usr/bin/env python3
# A miniature Certificate Transparency log: an append-only Merkle tree.
import hashlib

def leaf_hash(data):                        # RFC 6962: 0x00 prefix for leaves
    return hashlib.sha256(b'\x00' + data).digest()

def node_hash(l, r):                        # RFC 6962: 0x01 prefix for nodes
    return hashlib.sha256(b'\x01' + l + r).digest()

def root(leaves):
    if not leaves:  return hashlib.sha256(b'').digest()
    if len(leaves) == 1: return leaves[0]
    k = 1
    while k * 2 < len(leaves): k *= 2        # largest power of two < n
    return node_hash(root(leaves[:k]), root(leaves[k:]))

def inclusion_proof(leaves, i):
    if len(leaves) == 1: return []
    k = 1
    while k * 2 < len(leaves): k *= 2
    if i < k:
        return inclusion_proof(leaves[:k], i) + [root(leaves[k:])]
    return inclusion_proof(leaves[k:], i - k) + [root(leaves[:k])]

def verify(leaf, i, n, proof, expected_root):
    h, fn, sn = leaf, i, n - 1
    for p in proof:
        if fn % 2 == 1 or fn == sn:
            h = node_hash(p, h)
            while fn % 2 == 0 and fn != 0: fn >>= 1; sn >>= 1
        else:
            h = node_hash(h, p)
        fn >>= 1; sn >>= 1
    return h == expected_root

certs  = [f"certificate-for-host{n}.example".encode() for n in range(1, 8)]
leaves = [leaf_hash(c) for c in certs]

print("tree size :", len(leaves))
print("root hash :", root(leaves).hex())
print()
idx = 4
proof = inclusion_proof(leaves, idx)
print(f"inclusion proof for leaf {idx} ({certs[idx].decode()}):")
for p in proof: print("   ", p.hex())
print("verifies  :", verify(leaves[idx], idx, len(leaves), proof, root(leaves)))
print()
print("--- now the log operator quietly edits leaf 2 ---")
tampered = list(leaves); tampered[2] = leaf_hash(b"certificate-for-evil.example")
print("new root  :", root(tampered).hex())
print("same root :", root(tampered) == root(leaves))
bash
python3 ~/tls-lab/m11/merkle.py
Expected result — click to reveal
plain text
tree size : 7
root hash : ba3c983a0449631037b48803b513f6ce56bf79515db6a5df51eb8811569046e2

inclusion proof for leaf 4 (certificate-for-host5.example):
    e8563ad760cfb8aae7bdc88d88ad15656d7f00cdbbc8726cb0e769ac61c88c75
    b70330cbdcfa869feb8cd2e9038fdd94a42dbf7ad6def471b4b639055a7a4209
    41b735fc2c87c03ce68bcfc55edc31ba5bf245b56b0997d0841e6f0f0f01ce63
verifies  : True

--- now the log operator quietly edits leaf 2 ---
new root  : bc93d0a9756065cb7ba56d4d36f2bffda18d2378cd944def1e0cb6fd61ab0a4c
same root : False

What to read out of this — three separate things, and the third is the one that matters.

  • The hashes are identical on your machine. SHA-256 is deterministic and the input is fixed, so you will get byte-for-byte the same root as printed here. If you do not, you mistyped something.
  • The proof for leaf 4 is three hashes, not seven. Seven leaves, log₂(7) ≈ 3. You never had to download the other six certificates to prove yours is in the log. This is why a phone can audit a log with a billion entries.
  • Editing leaf 2 changed the root, even though leaf 4's proof did not mention leaf 2 at all. The change propagates upward through the shared parent nodes. A log operator who rewrites history cannot produce the old root hash again, and the old root hash is signed and already published.

🔑 This is why CT does not require you to trust the log. People assume CT works because the log operators are reputable. It works because a dishonest log gets caught by arithmetic: it must either present two different signed root hashes for the same tree size, or fail to produce a consistency proof — and in both cases it has produced cryptographic evidence of its own misbehaviour that anyone can verify forever. That evidence is called a proof of misbehaviour, and one of them ends a log.

💡 The two one-byte prefixes (0x00 for leaves, 0x01 for internal nodes) look like a detail and are not. Without them an attacker could present an internal node's hash as if it were a leaf, and prove the inclusion of a "certificate" that was never submitted. It is called a second-preimage attack on the tree, and the prefixes are the fix.

🎯 Interview questions — How a log works

Q. What is a Merkle tree and why does CT use one?

A Merkle tree is a binary tree of hashes: leaves are the hashes of the data items, every internal node is the hash of its two children, and the single hash at the top — the root — is a fingerprint of the entire dataset in a fixed 32 bytes.

CT uses it for two reasons. Efficient proofs: you can prove one certificate is in a log of a billion with about 30 hashes instead of downloading a billion certificates. Tamper-evidence: any change anywhere alters the root, and the old root has already been signed and published.

The detail worth adding: the important property is not that the log is immutable — nothing stops an operator rewriting their database. It is that rewriting is detectable by anyone, cheaply, forever. CT is built on evidence rather than prevention, and that theme runs through the whole design.

Q. What is the difference between an inclusion proof and a consistency proof?

An inclusion proof answers "is this specific certificate in the log at this tree size?" — used by an auditor or a client that received an SCT and wants to confirm the promise was kept. A consistency proof answers "is the tree I see now a strict append to the tree I saw before?" — used by monitors watching a log over time to confirm nothing was removed or reordered.

Inclusion proves membership at one point in time. Consistency proves the log's history has not been rewritten between two points in time. You need both: a log could faithfully include your certificate today and quietly drop it tomorrow, and only consistency checking catches that.

The detail worth adding: the failure this pair is really guarding against is a split view — the log showing one tree to you and a different tree to the rest of the world, so a mis-issued certificate is "logged" in a version only the victim sees. Consistency proofs alone do not close that; it needs gossip, clients and monitors comparing the STHs they have seen. Gossip is the part of CT that was specified and never meaningfully deployed, and being able to name that gap is a genuinely strong signal in an interview.


A3 · The precertificate and the poison extension

The analogy — the draft passport stamped VOID.

The registry will only give you a receipt number once it has seen your passport. But the passport must have the receipt number printed inside it. Neither can go first.

The fix is a decoy. The passport office prints a complete draft passport, stamps VOID across it in a way that no border guard will ever accept, and sends that to the registry. The registry files it and returns the receipt number. The office then prints the real passport with the number on it.

The draft is the precertificate. The VOID stamp is the poison extension. And it has to be a stamp that makes the document unusable, not just marked, because the draft contains a real signature from a real CA.

Here is the chicken-and-egg problem stated precisely. The SCT (the log's receipt) must live inside the certificate, because the certificate is the only thing the server sends and the client checks. But the log will not issue an SCT until it has seen the certificate. Adding the SCT afterwards is impossible — Module 03 established that any change to the certificate body invalidates the CA's signature.

So the CA builds a precertificate: byte-for-byte what the final certificate will be, signed by the same CA, plus one extra extension:

PropertyValue
OID1.3.6.1.4.1.11129.2.4.3
Criticalitycritical — this is the whole trick
ValueASN.1 NULL, which is the two bytes 05 00. It carries no information at all
OpenSSL's name for itCT Precertificate Poison

Recall from Module 03 the rule about critical extensions: a client that does not understand a critical extension must reject the certificate. No TLS client understands the poison OID, and none ever will — that is deliberate. So the precertificate is a fully-signed, completely valid, entirely unusable certificate. It can be logged safely because no client on earth will accept it.

🧪 Exercise A3.1 — Build a precertificate and watch every client refuse it
bash
cd ~/tls-lab/m11 && mkdir -p precert && cd precert

# a throwaway CA and a leaf request
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out ca.key
openssl req -x509 -new -key ca.key -sha256 -days 3650 -out ca.crt \
  -subj "/CN=CT Lab Root CA" \
  -addext "basicConstraints=critical,CA:TRUE" \
  -addext "keyUsage=critical,keyCertSign,cRLSign"
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out leaf.key
openssl req -new -key leaf.key -out leaf.csr -subj "/CN=shop.ctlab.test"

# the extensions for the REAL certificate
cat > ext-normal.cnf <<'EOF'
basicConstraints=critical,CA:FALSE
keyUsage=critical,digitalSignature,keyEncipherment
extendedKeyUsage=serverAuth
subjectAltName=DNS:shop.ctlab.test
EOF

# the same, plus the poison
cp ext-normal.cnf ext-poison.cnf
echo '1.3.6.1.4.1.11129.2.4.3=critical,DER:05:00' >> ext-poison.cnf

openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 90 -sha256 -extfile ext-normal.cnf -out leaf.crt
openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 90 -sha256 -extfile ext-poison.cnf -out precert.crt

echo "=== the real certificate ==="   ; openssl verify -CAfile ca.crt leaf.crt
echo "=== the precertificate ==="     ; openssl verify -CAfile ca.crt precert.crt
echo "=== how OpenSSL names it ==="
openssl x509 -in precert.crt -noout -text | grep -A1 "Poison"
Expected result — click to reveal
plain text
=== the real certificate ===
leaf.crt: OK
=== the precertificate ===
CN = shop.ctlab.test
error 34 at 0 depth lookup: unhandled critical extension
error precert.crt: verification failed
=== how OpenSSL names it ===
            CT Precertificate Poison: critical
                NULL

What to read out of this — the two certificates are identical apart from two bytes of payload, and one of them is worthless.

  • error 34 — unhandled critical extension. Add this to the verify-code table you built in Module 07. It is not a signature problem, not an expiry problem, not a name problem. The chain is perfect. The client is refusing on principle because it was told something it does not understand is essential.
  • OpenSSL prints the extension by name. It knows the OID (CT Precertificate Poison) and still refuses it — because recognising an OID is not the same as being able to process it. Nothing is allowed to process this one.
  • The value is NULL. Literally nothing. The extension's entire meaning is carried by its presence and its critical flag.

🔑 The counter-intuitive bit worth sitting with. Most security mechanisms fail if a client ignores them. This one fails if a client understands it. The poison extension is safe precisely because it is guaranteed to remain permanently unimplemented — the strength of the design comes from deliberate, enforced ignorance. If a browser ever shipped support for it, CT's safety model would break.

💡 What the log actually signs. The log does not sign the precertificate as you see it. It strips the poison extension and the CA's signature, and signs the resulting TBSCertificate — the certificate body. That is why the SCT you later find inside the real certificate still verifies: both documents share the same body once the poison and the SCT list are removed. Two files, one logged identity.

⚠️ If you search a public CT log for a certificate you know exists, you will often find two entries — the precertificate and the final certificate. They are not a duplicate issuance. crt.sh labels the precertificate row explicitly.

🎯 Interview questions — Precertificates

Q. Why do precertificates exist?

Because of a circular dependency. The SCT must be embedded in the certificate so the server can present it during the handshake with no extra configuration, but the log will not produce an SCT until it has been given the certificate — and you cannot insert anything into a certificate after signing without invalidating the signature.

A precertificate breaks the cycle. The CA signs a copy of the certificate body with a critical poison extension added, submits that to the logs, collects the SCTs, and then signs the real certificate with those SCTs embedded.

The detail worth adding: the poison extension is critical and permanently unimplemented by design — every TLS client must reject it under the RFC 5280 critical-extension rule. So the CA can safely sign a document with real authority, because the document is unusable by construction. It is a rare case where a security property is achieved by guaranteeing that nobody ever implements a feature.

Q. Are there other ways to get SCTs to a client, and when would you use them?

Three, and only the first requires precertificates. Embedded in the certificate — the CA does it at issuance, needs no server configuration, and is how the overwhelming majority of the web works. A TLS extension (signed_certificate_timestamp) sent in the handshake. An OCSP stapled response carrying the SCTs, which Module 09 explained is now effectively dead as Let's Encrypt and others have retired OCSP.

The two non-embedded routes exist for cases where the certificate cannot be reissued but you need CT compliance, or where you want to add SCTs from newer logs to a long-lived certificate without touching the CA.

The detail worth adding: the practical consequence is that Chrome's SCT-count requirements are different for embedded and handshake-delivered SCTs, and knowing which rule applies is what separates someone who has read the policy from someone who has heard of CT. Embedded SCTs are checked against the certificate's lifetime; SCTs delivered in the handshake are simply "at least two, from two distinct operators". That is Part B.


A4 · The SCT — a receipt, not a proof

The analogy — the post office receipt.

You hand a parcel over the counter and get a stamped receipt. The receipt is not proof the parcel arrived. It is a signed promise from the post office that it will arrive within a stated time, and — crucially — evidence you can wave in their face if it does not.

An SCT is that receipt. The stated time is the Maximum Merge Delay. Nobody at the receiving end waits for delivery confirmation before acting; they accept the receipt, because a post office caught breaking its own receipts goes out of business.

An SCT is a small signed structure the log returns when you submit a precertificate. Five fields:

FieldWhat it is
Versionv1 — RFC 6962. Version 2 (RFC 9162) exists on paper and is not deployed
Log ID32 bytes: the SHA-256 of the log's public key. This is how a client knows which log signed it — the log's name appears nowhere
TimestampMilliseconds since the epoch. The moment the log promised to merge
ExtensionsAlmost always none
SignatureThe log's signature over the certificate body plus the timestamp. This is what makes the receipt non-repudiable

The Maximum Merge Delay (MMD) is the deadline in the promise. Chrome's log policy caps it at 4 hours for classic RFC 6962 logs and 1 minute for the newer static-ct-api logs — well under the 24 hours the original RFC suggested. If a log issues an SCT and the certificate is not in the tree by the MMD, the SCT itself is the evidence of the breach.

The bit almost everyone gets wrong: browsers do not check that your certificate is actually in the log.

A browser verifies the SCT's signature against the public key of a log it already knows. That is all. It does not fetch an inclusion proof, and it does not talk to the log — doing so would leak every site you visit to the log operator, which is exactly the privacy problem that killed OCSP in Module 09.

So CT's guarantee is not "this certificate is public". It is "a log has signed a promise to make this certificate public, and if it lies, the promise is the proof".

The enforcement happens out of band, by monitors and auditors, not in your connection path. Same architectural move as CRLite in Module 09 — get the checking out of the handshake.

🧪 Exercise A4.1 — Put SCTs into a certificate and read them back the way OpenSSL prints them

Real SCTs come from real logs, so we forge structurally-correct ones locally. The signatures are random bytes and will not verify — the point is the structure, which is byte-identical to what a public certificate carries. Save as ~/tls-lab/m11/precert/mksct.py:

python
#!/usr/bin/env python3
import struct, binascii, os

def sct(log_id_hex, ts, siglen):
    sig = os.urandom(siglen)
    b  = b'\x00' + binascii.unhexlify(log_id_hex)     # version v1 + 32-byte log ID
    b += struct.pack('>Q', ts)                        # timestamp, ms since epoch
    b += struct.pack('>H', 0)                         # no extensions
    b += b'\x04\x03'                                  # sha256 / ecdsa
    b += struct.pack('>H', siglen) + sig
    return b

items = [
    sct('7d591e12e1782a7b1c61677c5efdf8d0875c14a04e959eb9032fd90e8c2e79b8', 1755000000000, 70),
    sct('eecdd064d5db1acec55cb79db4cd13a23287467cbcecdec351485946711fb59b', 1755000001000, 71),
]
body = b''.join(struct.pack('>H', len(i)) + i for i in items)
lst  = struct.pack('>H', len(body)) + body            # SignedCertificateTimestampList

# wrap the list in a DER OCTET STRING
if len(lst) < 128:   hdr = bytes([len(lst)])
elif len(lst) < 256: hdr = b'\x81' + bytes([len(lst)])
else:                hdr = b'\x82' + struct.pack('>H', len(lst))
der = b'\x04' + hdr + lst

open('sct.hex', 'w').write(':'.join('%02X' % c for c in der))
print('wrote sct.hex —', len(der), 'bytes,', len(items), 'SCTs')
bash
cd ~/tls-lab/m11/precert && python3 mksct.py
# -> wrote sct.hex — 244 bytes, 2 SCTs

cp ext-normal.cnf ext-sct.cnf
echo "1.3.6.1.4.1.11129.2.4.2=DER:$(cat sct.hex)" >> ext-sct.cnf

openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 90 -sha256 -extfile ext-sct.cnf -out sct.crt

openssl x509 -in sct.crt -noout -text | sed -n '/CT Precertificate SCTs/,/Subject Key/p'
Expected result — click to reveal
plain text
CT Precertificate SCTs:
    Signed Certificate Timestamp:
        Version   : v1 (0x0)
        Log ID    : 7D:59:1E:12:E1:78:2A:7B:1C:61:67:7C:5E:FD:F8:D0:
                    87:5C:14:A0:4E:95:9E:B9:03:2F:D9:0E:8C:2E:79:B8
        Timestamp : Aug 12 12:00:00.000 2025 GMT
        Extensions: none
        Signature : ecdsa-with-SHA256
                    12:35:63:4C:39:53:0E:AE:1F:21:F7:45:7A:28:53:C3:
                    ... 70 bytes total ...
    Signed Certificate Timestamp:
        Version   : v1 (0x0)
        Log ID    : EE:CD:D0:64:D5:DB:1A:CE:C5:5C:B7:9D:B4:CD:13:A2:
                    32:87:46:7C:BC:EC:DE:C3:51:48:59:46:71:1F:B5:9B
        Timestamp : Aug 12 12:00:01.000 2025 GMT
        Extensions: none
        Signature : ecdsa-with-SHA256
                    D9:31:C2:6F:A2:B1:EB:99:9D:C5:95:6A:FA:94:CB:22:
                    ... 71 bytes total ...

What to read out of this — the signature bytes will differ on your machine (they are random); everything else will match exactly.

  • This is exactly what a real public certificate looks like. Run the same sed against any certificate fetched from a public site and you will see the same block with real log IDs. Nothing about the structure is special to the lab.
  • Two SCTs, two different Log ID values. That is not decoration — Part B explains why two is the floor and why they must come from two different operators.
  • The log's name is nowhere in the certificate. Only the 32-byte Log ID, which is the SHA-256 of the log's public key. A client turns that into a name by looking it up in the browser's log list, which ships with the browser. No network request is involved.
  • The ECDSA signature lengths differ (70 and 71 bytes). That is normal DER, not a bug — Module 06 covered why ECDSA signatures vary in length by a byte or two depending on the leading bits of r and s.

💡 Now imagine this at 500 hosts. The Log ID values become the thing you actually monitor. When a CT log is retired — and they are retired on a schedule — every certificate whose SCTs came only from that log stops counting towards Chrome's policy at renewal time. Fleets that pin, cache or air-gap their certificate pipeline discover this as a sudden wave of ERR_CERTIFICATE_TRANSPARENCY_REQUIRED on hosts nobody has touched in a year.

🎯 Interview questions — SCTs

Q. What is an SCT and what does it actually prove?

A Signed Certificate Timestamp is a signed promise from a CT log that it has received a certificate and will merge it into its public tree within the Maximum Merge Delay. It contains the log's ID, a timestamp, and the log's signature over the certificate body.

What it proves is narrow and worth stating precisely: it proves a log made a promise, not that the certificate is in the log. The browser verifies only the signature, using a log public key it already has.

The detail worth adding: explain why that weaker guarantee is the right design. Checking real inclusion would mean the client contacting the log, which tells the log operator every site you visit — the same privacy failure that killed OCSP. So CT deliberately settles for a non-repudiable receipt and pushes enforcement to monitors and auditors running outside the connection. Candidates who say "CT proves the certificate is public" have read a blog post; candidates who say "CT proves a log can be held responsible if it is not" have read the RFC.

Q. How does a browser know which log signed an SCT?

By the 32-byte Log ID, which is the SHA-256 hash of the log's public key. The browser ships a log list — for Chrome, log_list.json, updated through the browser's component updater — mapping each Log ID to a public key, an operator, a state and an expiry range. The browser looks up the ID, gets the key, verifies the signature.

No network request is made at handshake time. If the Log ID is not in the list, that SCT simply does not count.

The detail worth adding: this is an operational trap, not just trivia. Log lists are versioned and go stale. A browser or an embedded TLS client with an old log list will reject SCTs from logs added since — and a client with a list newer than your certificate will not count SCTs from logs that have since been retired. Long-lived embedded devices that pin a log list are a recurring source of "it works everywhere except on the kiosks" incidents.

Q. What is the Maximum Merge Delay, and what happens if a log misses it?

The MMD is the window inside which a log commits to actually merging a submitted certificate into its Merkle tree and publishing a tree head that includes it. Chrome's log policy caps it at 4 hours for RFC 6962 logs and 1 minute for static-ct-api logs.

If the log misses it, the SCT it issued is itself the evidence: anyone can present the signed SCT alongside a signed tree head from after the deadline that does not contain the certificate. That pair is a proof of misbehaviour.

The detail worth adding: the consequence is not a warning — it is removal. A log that produces a proof of misbehaviour is disqualified from Chrome's log list, and every certificate relying on it for policy compliance is affected at renewal. Because the whole ecosystem depends on logs being replaceable, Chrome's policy requires SCTs from distinct operators, so that one operator's failure never invalidates a certificate on its own.


Part B · How CT is enforced

B1 · Chrome's CT policy — how many SCTs, and from whom

The analogy — a document that needs two witnesses, and they cannot be married to each other.

Plenty of legal documents need two signatures. The interesting rule is usually not "two" — it is "two people who are not the same household", because two signatures from one household is really one signature.

Chrome's CT rule is exactly that. Two SCTs are not enough on their own; they must come from two different log operators. One operator running four logs still counts as one witness, because if that operator is dishonest or is removed, all four vanish together.

CT is not enforced by the RFC. It is enforced by root programs — Chrome and Apple each publish a policy, and a certificate that does not satisfy it is rejected by that browser with a hard error, not a warning. Chrome's error is ERR_CERTIFICATE_TRANSPARENCY_REQUIRED.

The rule depends on how the SCTs arrive, and this catches people out:

Delivery methodChrome's requirement
Embedded in the certificateCertificate lifetime ≤ 180 days → 2 SCTs; > 180 days → 3 SCTs. At least one must be from a log that was Qualified, Usable or ReadOnly at check time, and at least two must be from distinct log operators
TLS handshake extension or stapled OCSPAt least 2 SCTs, regardless of lifetime, from distinct log operators

Two things follow from that table, and both are practical.

First, shorter certificates need fewer SCTs. This is not a coincidence. An SCT's value decays with time: a receipt for a certificate that expires in six weeks buys much less than a receipt for one that lives two years, so the policy asks for less. It is the same reasoning that drove the 47-day lifetime schedule in Module 10 — short lifetimes reduce how much every other mechanism has to carry.

Second, extra SCTs are harmless. The policy says that as long as some combination of the presented SCTs satisfies a rule, additional SCTs do not affect compliance. CAs routinely embed three or four to survive a log being retired mid-lifetime.

The foot-gun: your private CA is not exempt — but it is not covered either.

CT policy applies to certificates chaining to a publicly-trusted root. Certificates from your own internal CA (Module 05) chain to a root you installed yourself, so browsers skip CT entirely for them. That is why your lab certificates worked in Module 07 with no SCTs at all.

The trap is the reverse case. If you obtain a publicly-trusted certificate through a private or legacy path — an old reseller pipeline, a re-signed certificate, a load balancer that strips extensions — and it reaches a browser without enough SCTs, Chrome fails it outright. There is no click-through. Module 08's chain-order trap and this one produce the same symptom: works in curl, fails in the browser.

🧪 Exercise B1.1 — Read the SCTs off a real public certificate and check them against the policy
bash
cd ~/tls-lab/m11

# fetch a real certificate
echo | openssl s_client -connect wikipedia.org:443 -servername wikipedia.org 2>/dev/null \
  | openssl x509 -out wikipedia.pem

# how many SCTs, and from how many distinct logs?
openssl x509 -in wikipedia.pem -noout -text | grep -c "Signed Certificate Timestamp"
openssl x509 -in wikipedia.pem -noout -text | grep -A1 "Log ID" | grep -v "^--" | paste - - | sort -u | wc -l

# and how long does the certificate live?
openssl x509 -in wikipedia.pem -noout -dates
Expected result — click to reveal
plain text
3
3
notBefore=Jul  2 08:14:22 2026 GMT
notAfter=Sep 30 08:14:21 2026 GMT

What to read out of this — the numbers themselves are less interesting than the arithmetic you do with them.

  • Your exact counts and dates will differ. Certificates are renewed constantly. What should hold is the relationship: SCT count ≥ 2, and ≥ 2 distinct log IDs.
  • A 90-day certificate needs 2 SCTs; this one carries 3. The spare is deliberate insurance against a log being retired before the certificate expires.
  • Distinct log IDs is not the same as distinct operators. The command above counts logs, which is the closest you can get from the certificate alone. Mapping a Log ID to its operator needs the browser's log list — which is exactly why Chrome ships one.

⚠️ If you get 0, read this before assuming something is broken. Zero SCTs on a major public site is almost always a TLS-inspecting proxy — corporate, or a security appliance — re-issuing the certificate from a local CA, exactly as described in Module 03 (B1.1). A locally-issued certificate has no reason to be in a public log, so it has no SCTs. Check the issuer:

bash
openssl x509 -in wikipedia.pem -noout -issuer

If the issuer is your employer, your firewall vendor, or anything other than a public CA, that is your answer.

🔑 This is one of the most useful field diagnostics in the module. "Zero SCTs on a public site" is a near-definitive interception signal, and it takes one command. It is much faster than reasoning about issuers you do not recognise, because a good proxy CA looks plausible — but it can never fake SCTs, since no public log will sign for it.

🎯 Interview questions — CT enforcement

Q. How many SCTs does a certificate need?

It depends on the browser policy and the delivery method. For Chrome, with SCTs embedded in the certificate: two for a lifetime of 180 days or less, three for longer — and at least two of them must come from distinct log operators. For SCTs delivered through the TLS handshake or a stapled OCSP response: at least two, from distinct operators, regardless of lifetime.

The distinct-operator rule matters more than the count. Multiple logs run by one organisation collapse to a single point of failure, so they count as one witness.

The detail worth adding: explain why lifetime changes the number. An SCT is a promise whose value erodes over time, so a certificate that will be alive for two years leans on CT far harder than a 90-day one. Then connect it forward: as the industry moves to 47-day certificates under the CA/Browser Forum schedule, essentially every certificate falls into the two-SCT bucket, and CT compliance quietly gets cheaper for everyone. Candidates who tie the CT policy to the lifetime-reduction programme are showing they see the ecosystem, not just the rule.

Q. A site loads fine in curl but Chrome shows ERR_CERTIFICATE_TRANSPARENCY_REQUIRED. What is happening?

The certificate chains to a publicly-trusted root but does not carry enough valid SCTs. curl and OpenSSL do not enforce CT policy at all, so they are perfectly happy; Chrome enforces it as a hard failure with no click-through.

Diagnose it with openssl s_client piped into openssl x509 -text and count the Signed Certificate Timestamp blocks and their distinct Log ID values.

The detail worth adding: there are three realistic causes and naming them is what makes the answer good. One — a CA issued without logging, which is a CA incident and should be reported. Two — the SCTs are all from logs that have since been retired or disqualified, which happens to long-lived certificates and is fixed by reissuing. Three, and by far the most common — something in the path is re-issuing the certificate: a middlebox, a load balancer terminating and re-originating TLS, or a CDN misconfiguration. The tell for the third is that the issuer is not a public CA.


B2 · The log list, temporal sharding, and the shift to static logs

The analogy — the filing cabinet that gets a new drawer every year.

An office that keeps every document in one drawer eventually cannot open it. So it uses one drawer per year, labelled by when the documents expire, and throws away a drawer once everything inside it has expired anyway.

That is temporal sharding. A CT log is not one endless list — it is a series of logs, each accepting only certificates expiring inside a stated date range, each retired once that range has passed.

A CT log has a lifecycle, and the state names appear in error messages and log lists, so they are worth knowing:

StateMeaning
PendingApplied to the programme, not yet accepted
QualifiedPassed the tests; SCTs from it count, but it is not yet fully in the list
UsableNormal working state — accepting submissions, SCTs count
ReadOnlyNo longer accepts new certificates; existing SCTs still count
RetiredIts date range has passed. SCTs from it no longer count towards policy
RejectedFailed to qualify, or was disqualified for misbehaviour

Each log declares an expiry range [rangeBegin, rangeEnd), which the Chrome policy requires to be 3 to 12 months long, contiguous with its siblings, with coverage running 3–4 years into the future. A certificate can only be submitted to a log whose range contains its notAfter.

Why shard by expiry rather than by issuance date? Because it lets a log be deleted, not just archived. Once every certificate a log could contain has expired, the log has no remaining security value and can be dropped entirely. Sharding by issuance date would mean a 2015 log still had to be kept online in case something issued in 2015 was still valid.

This is the same instinct as Module 09's short-lived certificates: make the data self-expiring so nobody has to maintain it forever.

The change happening right now: static-ct-api. Classic RFC 6962 logs are databases behind an API — expensive to run, hard to scale, and a single operator outage takes them offline. The static-ct-api design (implemented by Sunlight, among others) serves the log as static files on object storage: immutable "tiles" of the Merkle tree, fetched over plain HTTP with a CDN in front. No database in the read path.

The practical consequences are already visible:

  • MMD collapsed from 4 hours (RFC 6962) to 1 minute (static-ct-api) in Chrome's policy, because merging is now cheap.
  • Chrome removed its "One-6962-log" rule — the requirement that at least one SCT come from a classic RFC 6962 log. Chrome 144 (January 2026) was the first release not to enforce it, and enforcement ended entirely on 15 April 2026. Certificates may now be CT-compliant using static-ct-api logs only.
  • Monitoring tooling had to change. Tools written against the RFC 6962 get-entries API do not speak the tile format. If you built CT monitoring before 2025, check that it still sees everything.
🧪 Exercise B2.1 — Work out which shard your certificate went into
bash
cd ~/tls-lab/m11
openssl x509 -in wikipedia.pem -noout -enddate

Now reason about it before opening the toggle: given that shards cover 3–12 months of notAfter values, and given the date above, roughly which shard did this certificate's precertificate have to be submitted to — and what happens to that shard afterwards?

Expected result — click to reveal
plain text
notAfter=Sep 30 08:14:21 2026 GMT

What to read out of this — the shard is chosen by the certificate's death date, not its birth date.

  • A certificate expiring 30 September 2026 must go into a shard whose range contains that date — in practice a 2026h2 shard, since most operators use half-year ranges named like Argon2026h2, Xenon2026h2, Nessie2026h2.
  • Every certificate in that shard is dead by 1 January 2027. Shortly after, the shard becomes Retired and can be deleted. The whole log, permanently.
  • This is why SCTs expire in usefulness. An SCT from a shard that has been retired no longer counts towards Chrome's policy. For a 90-day certificate that is irrelevant — the certificate dies long before its shard does. For an old three-year certificate it was a real problem, and it is one more reason the industry moved to short lifetimes.

💡 Now imagine this at 500 hosts. The failure mode is a golden image or a container base layer that bakes in a certificate. Nothing renews it, nothing alerts on it, and it keeps working — until the CT shard retires or the certificate expires, and then a whole fleet of hosts fails at once, on a date nobody has in a calendar. The fix is Module 10's: automate renewal, and alert on age rather than on failure.

🎯 Interview questions — Logs in practice

Q. What is temporal sharding and why does it exist?

CT logs are partitioned by the expiry date of the certificates they accept. Each shard declares a range — typically half a year, and Chrome's policy allows 3 to 12 months — and only accepts certificates whose notAfter falls inside it.

It exists to bound log size and to make logs disposable. Once every certificate a shard could hold has expired, the shard has no security value and can be retired and deleted.

The detail worth adding: the operational consequence is that SCTs have a shelf life. When a shard retires, SCTs from it stop counting towards CT policy. For 90-day certificates this never bites; for long-lived certificates in embedded devices and appliances it absolutely does, and it produces failures with no obvious trigger, on hosts nobody touched.

Q. What has changed in CT recently that you would flag to a team?

The move to static-ct-api logs — logs served as immutable static tiles from object storage instead of a database behind an API. Sunlight is the best-known implementation. It makes logs dramatically cheaper to run and to mirror, and Chrome's policy reflects that: a 1-minute maximum merge delay for static logs versus 4 hours for RFC 6962 ones.

Chrome has also removed its requirement that at least one SCT come from a classic RFC 6962 log — enforcement ended on 15 April 2026, with Chrome 144 the first release to drop it. Certificates can now be fully compliant with SCTs from static logs alone.

The detail worth adding: the part that affects a team directly is monitoring. Any CT tooling written against the RFC 6962 get-entries endpoint is blind to tile-based logs, so a monitoring setup built before 2025 may quietly be watching a shrinking fraction of issuance. Saying "we should re-verify that our CT monitor covers the static logs" is the answer of someone who has actually operated this.


Part C · Watching the logs, and the CAs behind them

C1 · Monitoring — turning CT into an alarm for your own domains

The analogy — the land registry alert.

In many countries you can register for a free alert from the land registry: if anyone files a document against your property, you get an email. The registry does not stop the filing. It just makes sure you find out on the same day rather than when the bailiffs arrive.

CT monitoring is that alert, for your domain names. Setting it up takes ten minutes and it is, per minute spent, one of the highest-value security controls you will ever configure.

Everything so far has been the mechanism. This section is the part that has actual value to you on a Tuesday afternoon, and it is the part most teams have never set up.

Because every publicly-trusted certificate must be logged before browsers accept it, the logs are a complete public record of every certificate issued for your domains — including ones you did not ask for. There are three levels of effort:

ApproachWhat you get
crt.sh — a web search over a mirror of the logsAd-hoc lookups. Free, no account, has a JSON API. Perfect for investigation, useless as an alarm because nothing tells you when something appears
A hosted monitor (Cert Spotter, Censys, and others)Email or webhook when a certificate appears for a name you watch. Free tiers exist and cover a handful of domains
Self-hosted certspotterYou run the monitor. It follows the logs itself, keeps state in ~/.certspotter, and fires a script or an email per new certificate. No third party sees your watch list
🧪 Exercise C1.1 — Ask the logs what has been issued for a domain you do not own
bash
cd ~/tls-lab/m11

# every unexpired certificate crt.sh knows about for this name and its subdomains
curl -s 'https://crt.sh/?q=%25.wikipedia.org&output=json&exclude=expired' \
  | python3 -c 'import json,sys
rows = json.load(sys.stdin)
print(len(rows), "entries")
for r in rows[:8]:
    print(f"{r[\"not_before\"][:10]}  {r[\"issuer_name\"][:40]:40}  {r[\"name_value\"].splitlines()[0]}")'

# and just the distinct names, which is the version people actually use
curl -s 'https://crt.sh/?q=%25.wikipedia.org&output=json&exclude=expired' \
  | python3 -c 'import json,sys
names=set()
for r in json.load(sys.stdin): names.update(r["name_value"].splitlines())
print(len(names), "distinct names"); [print(" ", n) for n in sorted(names)[:10]]'
Expected result — click to reveal

This one comes from a live public service, so your output will not match line for line — the numbers move every day. What matters is the shape:

plain text
214 entries
2026-08-14  C=US, O=Let's Encrypt, CN=E7                 *.wikipedia.org
2026-08-14  C=US, O=Let's Encrypt, CN=E7                 *.wikipedia.org
2026-08-02  C=US, O=Let's Encrypt, CN=E5                 *.m.wikipedia.org
...
41 distinct names
  *.m.wikipedia.org
  *.wikipedia.org
  *.zero.wikipedia.org
  wikipedia.org

What to read out of this — four things, and the last one is why security teams love this endpoint.

  • Entries come in pairs. Remember Part A: the precertificate and the final certificate are both logged. crt.sh returns both, which is why the entry count is roughly twice the certificate count.
  • The issuer tells you the CA and the intermediate. CN=E7 is a specific Let's Encrypt intermediate. If a name you own suddenly shows an issuer you have never used, that is the alarm.
  • %25 is a URL-encoded %, which is SQL's wildcard. crt.sh is a PostgreSQL database behind a web form; %.wikipedia.org means "any subdomain".
  • You never proved you own the domain. You cannot — and neither can anyone else. CT is public by design, and that cuts both ways, which is C2.

⚠️ crt.sh rate-limits and sometimes times out. It is a free service run by Sectigo as a public good. Do not build production monitoring on top of it — use it for investigation and run a real monitor for alerting.

🔑 Do this for your own domain right now. Substitute your employer's domain, or your own. Nearly everyone who runs this for the first time finds at least one certificate they had forgotten about, and a surprising number find a name that should not exist.

Turning it into an actual alarm. certspotter is a single Go binary that follows the logs directly and tells you about new certificates for names you list:

bash
# install (Go toolchain), or use your distribution's package
go install software.sslmate.com/src/certspotter/cmd/certspotter@latest

mkdir -p ~/.certspotter
cat > ~/.certspotter/watchlist <<'EOF'
.example.com
.example.net
EOF

# first run: start at the current end of the logs, print to stdout
certspotter -start_at_end -stdout
Three details that decide whether this is useful or noise.

-start_at_end on the first run only. Without it, certspotter walks the logs from the beginning and floods you with every certificate ever issued for your names.

A leading dot in the watch list (.example.com) means "this domain and all subdomains". Without the dot it matches the exact name only.

The watch list is read once, at startup. Adding a domain requires a restart — a genuinely common way for a new domain to be silently unmonitored for months.

🎯 Interview questions — CT monitoring

Q. How would you find out if someone got a certificate issued for your domain?

Monitor the CT logs. Every publicly-trusted certificate must be logged before browsers accept it, so the logs are a complete record of issuance for your names, by any CA. In practice: a hosted monitor such as Cert Spotter or Censys for alerting, crt.sh for ad-hoc investigation, or self-hosted certspotter if you would rather not hand your watch list to a third party.

Configure it as an alert, not a dashboard — a certificate appearing from a CA you do not use should page someone.

The detail worth adding: say what a good alert looks like, because "monitor CT" is the easy half. Alert on issuer not in your allow-list and on names that do not match your inventory, not on every issuance — otherwise ACME renewals bury the signal within a week. And pair it with CAA: CAA makes unexpected issuance unlikely, CT makes it visible, and the two together give you a control that both narrows the attack and detects it when it happens anyway.

Q. You get a CT alert for a certificate you did not request. Walk me through what you do.

First establish whether it is really unauthorised. Most such alerts are internal: another team, a CDN or hosting provider issuing on your behalf, a marketing platform, or a forgotten ACME client. Check the issuer, the SANs, the validity dates and the ACME account if you can identify it.

If it is genuinely unauthorised, it means the attacker completed domain validation, so treat it as a domain-control compromise until proven otherwise: check DNS records and registrar audit logs, check web-root access if HTTP-01 could have been used, and check for a BGP or resolver-level hijack. Report it to the issuing CA — they are obliged to investigate and revoke — and to the browser root programs if the CA is unresponsive.

The detail worth adding: the ordering that shows operational judgement is that revocation is the last and least useful step (Module 09 — soft-fail means revocation may reach nobody). The valuable actions are closing the validation path the attacker used, adding or tightening CAA including accounturi pinning, and preserving evidence. And there is a hard truth worth voicing: a mis-issued certificate is a symptom. Something else was already broken for it to exist.


C2 · The other edge — CT publishes your internal hostnames

The analogy — the public planning register.

Planning applications are public so that neighbours can object. That is genuinely good. It also means anyone can read that you are extending the back of number 42, and when the builders will be there.

CT works the same way. The transparency that lets you catch a mis-issued certificate also lets a stranger read the names of every server you have ever put a public certificate on.

This is the part of CT that surprises people, and it is worth internalising before you request a certificate rather than after.

Every name in a certificate's SAN list goes into a public log, permanently, indexed and searchable. Which means a single crt.sh query against a company's domain routinely reveals:

  • jenkins.internal.example.com, vpn-uat.example.com, admin-staging.example.com — the shape of the internal estate
  • Names of unreleased products, from beta-projectname.example.com requested three months before launch
  • Acquisitions, from certificates appearing for a newly-shared domain
  • The exact date a service came online

This is the first thing a competent attacker does during reconnaissance, and it costs them one HTTP request. It is faster and quieter than DNS brute-forcing, and it finds names that would never appear in public DNS at all.

You cannot un-log a name. There is no takedown, no expiry, no redaction. The log is append-only — that is the entire point of Part A. A hostname submitted once is public forever, even if the certificate is revoked five minutes later and the host is decommissioned the same day.

Treat "what names go in this certificate" as a decision with permanent consequences.

The mitigations, in the order you should reach for them:

MitigationWhat it buys, and what it costs
Use a wildcard for internal names*.internal.example.com logs one name instead of fifty. Costs you the blast-radius problem: one key now covers everything under that label
Use a private CA for anything not publicModule 05's CA, or Module 12's internal PKI. Nothing is logged, because nothing is publicly trusted. This is the right answer for internal services
Do not put internal names on public certificates at allThe habit worth building. A public certificate is for a public name
Assume the names are already public and defend accordinglyThe only honest position for anything already issued. Name obscurity was never a control
🧪 Exercise C2.1 — Reconnaissance, from the other side of the desk
bash
# pick a company you do not work for, and read their estate
curl -s 'https://crt.sh/?q=%25.mozilla.org&output=json&exclude=expired' \
  | python3 -c 'import json,sys
names=set()
for r in json.load(sys.stdin): names.update(r["name_value"].splitlines())
interesting=[n for n in names if any(k in n for k in
  ("stage","staging","dev","test","uat","admin","internal","vpn","jenkins","git","jira"))]
print(len(names),"names,",len(interesting),"that look non-public")
for n in sorted(interesting)[:15]: print("  ", n)'
Expected result — click to reveal

Live data again, so your list will differ. The shape is what matters:

plain text
612 names, 37 that look non-public
   admin.mozilla.org
   bugzilla-dev.allizom.org
   ci-staging.mozilla.org
   git-internal.mozilla.org
   stage.mozilla.org
   ...

What to read out of this — you just did external reconnaissance on a large organisation, in one command, with no tools and no permission needed.

  • Every one of those names came from a certificate somebody requested on purpose. None of it is a leak in the usual sense. It is the system working exactly as designed.
  • The keyword filter is crude and still works. A real attacker uses a much longer word list and cross-references issuance dates to spot new infrastructure within hours of it appearing.
  • Some of those hosts may not resolve in public DNS. That is the uncomfortable part: CT reveals names that DNS enumeration never would.

🔑 The lesson is not "CT is dangerous". It is that hostname secrecy has not been a viable control since 2018, and any design that relies on an internal service being hard to find is relying on something that a public log gives away for free. Put internal services behind a private CA and real authentication — which is Module 12.

💡 Now imagine this at 500 hosts. This is the moment to grep your own CT history for names you thought were private, and to add a review step to certificate requests. A single certificate with 200 SANs, issued once by a well-meaning engineer to save time, publishes the complete internal topology of a company forever.

🎯 Interview questions — The cost of transparency

Q. What is the downside of Certificate Transparency?

It publishes every hostname you ever put on a publicly-trusted certificate, permanently and searchably. That includes staging, admin, CI, VPN and internal service names — a complete map of an organisation's estate, available to anyone with one HTTP request to crt.sh. It is now a standard first step in reconnaissance.

There is no way to remove an entry. The log is append-only by design, so a name submitted once is public forever.

The detail worth adding: the right conclusion is not that CT is a bad trade — it plainly is not — but that it closed off hostname obscurity as a control, and some organisations had been quietly relying on it. The mitigations are wildcards for internal names, a private CA for anything not public, and treating the SAN list as a permanent disclosure decision. A candidate who names the wildcard trade-off — fewer names logged, but a much bigger blast radius on one key — is thinking about it properly rather than reciting.


C3 · DV, OV and EV — what the CA actually verified

The analogy — three ways of proving who you are at a door.

DV is showing that you have the key to the flat. It proves you control the address. It says nothing about your name.

OV is showing the key and a letter from the landlord confirming which company rents it. Somebody checked a register.

EV is all of that plus a solicitor's verification of the company's legal existence, trading address and the authority of the person asking.

All three get you through the same door, in the same way. The lock does not care which one you showed.

This is the most over-sold topic in the whole subject, so here is the honest version.

LevelWhat the CA verifiesCA/B Forum policy OID
DV Domain ValidatedOnly that the requester controls the domain — the ACME challenges from Module 10 are exactly this. Fully automatable, seconds to issue, free2.23.140.1.2.1
OV Organisation ValidatedDomain control plus that the named organisation legally exists, via a government or commercial register. The O= field in the Subject is checked. Hours to days, human involvement2.23.140.1.2.2
IV Individual ValidatedDomain control plus the identity of a named natural person2.23.140.1.2.3
EV Extended ValidationEverything in OV plus incorporation records, operational existence, physical address, and verified authority of the requester against the CA/B Forum EV Guidelines2.23.140.1.1
The three things that make this topic a trap in interviews.

One — the TLS is identical. Same key exchange, same ciphers, same strength. Module 06's handshake does not know or care which level you bought. Anyone who says EV is "more secure encryption" has misunderstood the subject.

Two — the browser UI is gone. The green bar with the company name was removed by Chrome 77 and Firefox 70 (both 2019) because studies consistently showed users did not notice it, did not understand it, and did not behave differently when it was absent. The commercial argument for EV largely died with it. The identity information is still in the certificate; the browser just no longer advertises it.

Three — validation level and the O= field move together. A DV certificate has no verified organisation, so under the Baseline Requirements it must not carry a meaningful O=. If you see an organisation name in the Subject, someone checked a register.

🧪 Exercise C3.1 — Read the validation level off a certificate, and check a real one
bash
cd ~/tls-lab/m11/precert

# build one certificate at each level using the CA/B Forum OIDs
for p in "2.23.140.1.2.1:DV" "2.23.140.1.2.2:OV" "2.23.140.1.1:EV"; do
  oid=${p%%:*}; lbl=${p##*:}
  cp ext-normal.cnf pol-$lbl.cnf
  echo "certificatePolicies=$oid" >> pol-$lbl.cnf
  openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -days 90 -sha256 -extfile pol-$lbl.cnf -out pol-$lbl.crt 2>/dev/null
  echo "--- $lbl"
  openssl x509 -in pol-$lbl.crt -noout -text | grep -A1 "Certificate Policies"
done

# now a real one — what did its CA actually verify?
cd ~/tls-lab/m11
openssl x509 -in wikipedia.pem -noout -subject
openssl x509 -in wikipedia.pem -noout -text | grep -A2 "Certificate Policies"
Expected result — click to reveal
plain text
--- DV
            X509v3 Certificate Policies: 
                Policy: 2.23.140.1.2.1
--- OV
            X509v3 Certificate Policies: 
                Policy: 2.23.140.1.2.2
--- EV
            X509v3 Certificate Policies: 
                Policy: 2.23.140.1.1
subject=CN = wikipedia.org
            X509v3 Certificate Policies: 
                Policy: 2.23.140.1.2.1

What to read out of this — the last four lines are the real exercise.

  • The Subject is just CN = wikipedia.org. No O=, no L=, no C=. That is a DV certificate, and the policy OID confirms it.
  • One of the largest sites in the world runs on a free DV certificate. So do most of the others. That is the honest state of the industry, and it is a useful thing to say out loud in an interview when someone implies OV or EV is a security requirement.
  • The lab certificates prove OpenSSL reads the OID and nothing more. Nothing validated anything — I simply asserted the OID. Policy OIDs are meaningful only because the CA is audited against them; they carry no self-enforcing power.

⚠️ If your wikipedia.pem shows a corporate issuer and no policy OID at all, you are behind a TLS-inspecting proxy again (B1.1). Re-run the check on a network you control if you want the real answer.

🎯 Interview questions — Validation levels

Q. What is the difference between DV, OV and EV certificates?

Only what the CA checked before issuing. DV verifies domain control alone and is fully automatable — the ACME challenges are exactly this. OV additionally verifies that a named organisation legally exists and is entitled to the domain, so the Subject carries a checked O=. EV applies the CA/B Forum's Extended Validation Guidelines: incorporation records, operational existence, physical address, and the requester's authority.

The cryptography and the TLS handshake are identical across all three. You can tell them apart by the certificate policy OID: 2.23.140.1.2.1, 2.23.140.1.2.2 and 2.23.140.1.1.

The detail worth adding: the honest commercial answer is that EV's value collapsed when Chrome 77 and Firefox 70 removed the green identity bar in 2019, after research showed users neither noticed nor acted on it. What survives is niche and real — some regulated sectors and some insurance or procurement requirements still specify OV or EV — but presenting it as a security control for the TLS connection is wrong, and interviewers notice.

Q. A colleague wants to buy an EV certificate because "free certificates are less secure". How do you respond?

The encryption is identical. A Let's Encrypt DV certificate and a several-hundred-pound EV certificate use the same key algorithms, negotiate the same TLS 1.3 handshake and offer exactly the same protection against interception. The difference is a paperwork check the CA performed once, before issuing.

If anything the free certificate has an operational security advantage: it is short-lived and issued through automation, so it rotates constantly and a compromised key has a short window of use. Long-lived manually-installed certificates are the ones that go stale, get copied between hosts and quietly outlive the person who installed them.

The detail worth adding: redirect the conversation to where the real risk is. Ask what the actual threat is. If it is mis-issuance, the answer is CAA plus CT monitoring, which costs nothing. If it is compliance, get the requirement in writing and buy exactly what it says. If it is "it feels safer", show them that the site they most trust is running on DV. Being able to redirect a request toward the control that actually addresses the risk is the point of the question, not the certificate.


C4 · Root programs — who watches the CAs, and what happens when one fails

The analogy — a medical register.

Doctors are not policed by their patients. They are policed by a register: to practise you must be on it, staying on it requires audits and disclosure, and serious failures get you struck off. Patients never inspect the register directly — they simply rely on it existing.

Root programs are that register for CAs, and being removed from one is being struck off. Your browser's trust store is the list of currently-registered practitioners.

There is no world government of PKI. The people who actually hold CAs accountable are the organisations that decide which roots ship in software: Chrome, Mozilla, Apple and Microsoft. Each runs a root program with a published policy, and they coordinate through the CCADB — the Common CA Database, operated by the Linux Foundation, where CAs disclose their intermediates, audits and incident reports.

What a root program requires, in outline:

  • Annual audits with unbroken, contiguous coverage — Chrome's policy requires a complete audit at least every 365 days, from key generation until the root leaves the store
  • Compliance with the CA/Browser Forum Baseline Requirements, always the latest version
  • Public incident reporting in Bugzilla, in the CCADB format, with root-cause analysis — the part that most CA removals actually hinge on
  • Full disclosure of every intermediate the root has signed
  • Automation: Chrome Root Program Policy version 1.8 requires that by 15 March 2027 every subordinate CA certificate be integrated with an automation solution capable of issuing and renewing without routine human intervention — the same direction as Module 10
Why Chrome ships its own root store now. Historically browsers used the operating system's trust store, which meant Chrome's security depended on Microsoft's and Apple's decisions and on the user's patch level. Chrome now ships the Chrome Root Store with the browser on Windows, macOS, Linux and Android, updated through the component updater.

The operational consequence is direct and catches people out: adding a CA to your OS trust store no longer necessarily makes Chrome accept it. Chrome does still honour locally-installed roots for compatibility with enterprise interception, but "it works in curl and Safari and not in Chrome" now has one more possible cause than it used to.

When a CA fails, this is what it looks like. These are the cases worth knowing by name, because interviewers use them as a proxy for whether you follow the ecosystem:

CAWhenWhat happened, and the lesson
DigiNotar2011Compromised; a fraudulent *.google.com certificate was used to intercept around 300,000 Iranian users. Removed from every trust store within days; the company was bankrupt within a month. This is the incident that caused CT to exist
Symantec2017–2018Years of mis-issuance and inadequate oversight of delegated partners. Chrome distrusted certificates issued before 1 June 2016 in Chrome 66 (April 2018) and all remaining Symantec-brand certificates — Symantec, Thawte, GeoTrust, RapidSSL — in Chrome 70 (October 2018). The business was sold to DigiCert. Scale is no protection
WoSign / StartCom2016–2017Mis-issuance, backdated certificates to evade a SHA-1 deadline, and an undisclosed acquisition of StartCom. The undisclosed ownership and the dishonesty in responses mattered more than the technical faults
TrustCor2022Removed after reporting linked its ownership to a company selling data-interception products. No mis-issuance was demonstrated. A root program can act on who owns you, not only on what you did
Entrust2024–2025A long sequence of compliance incidents and, in Google's stated reasoning, responses that showed insufficient improvement. Chrome stopped trusting TLS certificates from Entrust roots with notBefore dates after 31 October 2024. Entrust's public-TLS business moved to SSL.com. The trigger was the pattern of incident handling, not any single mis-issuance
Read that table again for the thing it actually teaches. In three of the five cases, the fatal problem was not the original error — it was how the CA behaved after it. Slow disclosure, incomplete root-cause analysis, defensive responses, undisclosed relationships.

Root programs cannot audit a CA's competence continuously. What they can observe is how it behaves when something goes wrong, and they have made it very clear that this is the signal they act on. It is a good model for incident response generally.

🧪 Exercise C4.1 — Find a distrusted CA still sitting in your own trust store
bash
cd ~/tls-lab/m11
mkdir -p rootsplit && cd rootsplit
awk '/BEGIN CERT/{n++} {print > ("r-" n ".pem")}' /etc/ssl/certs/ca-certificates.crt

echo "=== who holds the most roots here?"
for f in r-*.pem; do openssl x509 -in "$f" -noout -subject 2>/dev/null; done \
  | sed -n 's/.*O = \([^,]*\).*/\1/p' | sort | uniq -c | sort -rn | head -8

echo "=== CAs from the distrust table in C4 ==="
for f in r-*.pem; do openssl x509 -in "$f" -noout -subject 2>/dev/null; done \
  | grep -Ei "entrust|symantec|thawte|geotrust|verisign|wosign|startcom|trustcor"
cd ..
Expected result — click to reveal
plain text
=== who holds the most roots here?
      8 DigiCert Inc
      6 SSL Corporation
      5 QuoVadis Limited
      4 Google Trust Services LLC
      4 GlobalSign
      4 Amazon
      4 "Entrust
      3 GlobalSign nv-sa
=== CAs from the distrust table in C4 ===
subject=O = Entrust.net, ... CN = Entrust.net Certification Authority (2048)
subject=C = US, O = "Entrust, Inc.", ... CN = Entrust Root Certification Authority
subject=C = US, O = "Entrust, Inc.", ... CN = Entrust Root Certification Authority - EC1
subject=C = US, O = "Entrust, Inc.", ... CN = Entrust Root Certification Authority - G2
subject=C = US, O = "Entrust, Inc.", ... CN = Entrust Root Certification Authority - G4

What to read out of this — two findings, and the second one is the whole lesson of C4.

  • Symantec, Thawte, GeoTrust and VeriSign are gone. Distrusted in 2018, and by now removed from the bundle entirely. Their absence is what a completed distrust looks like.
  • Five Entrust roots are still here, in an up-to-date trust store, even though Chrome stopped trusting Entrust TLS certificates with notBefore after 31 October 2024. Your exact list will vary by distribution and bundle version.
  • Several organisations hold four to eight roots each. Old roots are kept alive for years because devices and pinned clients still chain to them.

🔑 This is the C4 lesson made concrete: your OS trust store is not the browser's decision. Chrome ships its own root store and applies its own constraints on top — including date-based distrust that no OS bundle expresses. A root being present in ca-certificates.crt tells you openssl and curl will accept it. It tells you nothing about what Chrome will do.

💡 Now imagine this at 500 hosts. This is why "we tested with curl and it was fine" is not a deployment check. Distrust decisions are made per root program, on dates, and only the browser enforces them. Module 13's fleet auditing has to compare against the browser's view, not the host's.

🎯 Interview questions — Root programs and distrust

Q. Who decides which CAs are trusted, and how is a CA removed?

Root program operators — Google (Chrome Root Program), Mozilla, Apple and Microsoft. Each publishes a policy requiring annual audits with unbroken coverage, compliance with the CA/Browser Forum Baseline Requirements, full disclosure of intermediates, and public incident reporting. They coordinate disclosure through the CCADB.

Removal is usually gradual rather than instant: a distrust date is announced, certificates issued after that date stop being trusted, and existing ones are allowed to expire. That avoids breaking the web on a single day.

The detail worth adding: the case that shows you understand the mechanism is Entrust, distrusted by Chrome for certificates with notBefore after 31 October 2024. Google's stated reasoning was not one catastrophic mis-issuance but a pattern of compliance incidents and unsatisfactory responses. Compare that with DigiNotar, removed in days because it was actively compromised. The two shapes — graceful notBefore distrust versus emergency removal — are the two tools root programs have, and knowing when each is used is the substance of the question.

Q. What was the DigiNotar incident and why does it still matter?

In 2011 the Dutch CA DigiNotar was breached and the attacker issued fraudulent certificates, including one for *.google.com that was used to intercept the traffic of roughly 300,000 users in Iran. The certificate had been in use for weeks. It was discovered because a Chrome user in Iran hit a certificate-pinning error and posted about it in a forum.

It matters because of how it was found: by coincidence. There was no systematic way for Google, or anyone, to learn that a certificate for their domain existed.

The detail worth adding: DigiNotar is the direct cause of Certificate Transparency — Google began the CT work in its aftermath. The second lesson is about blast radius: DigiNotar also issued certificates for the Dutch government's PKIoverheid, so removing it from trust stores broke real government services, and browser vendors had to weigh that against leaving a compromised CA trusted. That tension — removing a CA hurts its legitimate users — is exactly why distrust is normally phased by notBefore date rather than done in one step.


Part D · CAA — the one control you own

D1 · The record, and its three tags

The analogy — the notice on your own front door.

You cannot take the locksmiths' tools away. But you can nail a notice to your door reading: "Only Smith & Sons may cut keys for this address. If anyone else asks, refuse — and post a note to this address telling me who asked."

The notice does not physically stop anyone. It works because every locksmith is required by their trade body to read it and obey it, and because breaking that rule gets them struck off (C4).

That notice is a CAA record. You write it in your own DNS, for free, in about a minute.

CAA is a DNS record type that says which CAs are permitted to issue certificates for a domain. Since September 2017 every publicly-trusted CA has been required by the Baseline Requirements to check it before issuing and to refuse if it is not listed.

The record has three parts: a flags byte, a tag, and a value.

TagWhat it does
issueAuthorises a CA to issue non-wildcard certificates — and wildcard ones too, unless an issuewild record is also present
issuewildAuthorises a CA to issue wildcard certificates. If any issuewild record exists, it completely overrides issue for wildcard requests
iodefWhere to report a violation — a mailto: or https: URL. The CA should contact you when it refuses a request because of your CAA record

The value in issue and issuewild is a CA identifying domain name, not a company name. letsencrypt.org, pki.goog, digicert.com, sectigo.com, amazon.com. Each CA publishes its own identifier in its CP/CPS, and getting it wrong is the single most common CAA mistake — the record looks right, and issuance fails.

The special value that forbids everything: a semicolon.
plain text
example.com.  IN  CAA  0 issue ";"

An empty issuer-domain-name means no CA may issue. This is the right record for a domain that should never have a certificate — a parked domain, an internal-only zone, a brand-protection registration. It is the highest-value CAA record most organisations never set.

🧪 Exercise D1.1 — Read real CAA records from real domains
bash
cd ~/tls-lab/m11

echo "=== a locked-down single-CA policy"
dig +short CAA google.com

echo; echo "=== a multi-CA policy with a reporting address"
dig CAA wikipedia.org +noall +answer

echo; echo "=== a domain with no CAA at all"
dig +short CAA example.com
echo "[nothing above means any CA may issue]"
Expected result — click to reveal
plain text
=== a locked-down single-CA policy
0 issue "pki.goog"

=== a multi-CA policy with a reporting address
wikipedia.org.		600	IN	CAA	0 issue "letsencrypt.org"
wikipedia.org.		600	IN	CAA	0 issue "pki.goog"
wikipedia.org.		600	IN	CAA	0 iodef "mailto:[email protected]"

=== a domain with no CAA at all
[nothing above means any CA may issue]

What to read out of this — three different postures, and the third is the default everybody has.

  • google.com allows exactly one CA, their own. Tight, deliberate, and only workable if you never need a second issuer in a hurry.
  • wikipedia.org allows two CAs and publishes an iodef address. That is the shape most organisations should copy: a primary CA, a fallback, and a mailbox that gets told when someone else tries.
  • example.com has no CAA record. So does the overwhelming majority of the internet. Every one of the 71 organisations from Exercise A1.1 may issue for it, and none of them will pause to think about it.
  • The order of records will vary between queries. DNS does not preserve record order and resolvers commonly rotate them. Never write a script that depends on the first line.

🔑 The asymmetry worth noticing. Publishing CAA costs you one DNS record and nothing else — no software, no renewal, no maintenance. Not publishing it leaves the door open to 71 organisations. There are very few controls in security with that ratio, which is why "does the domain have CAA?" is a reasonable one-line audit question for any estate.

💡 Now imagine this at 500 hosts. CAA is per-domain, not per-host, so 500 hosts under one domain are covered by one record. That makes it one of the very few security controls whose cost does not scale with your fleet — and one of the strongest arguments for doing it today rather than after the next incident.


D2 · Where the CA actually looks — the tree-climbing rule

The analogy — asking the flat, then the building, then the street.

You want to know the rules for flat 4B. You check the notice on flat 4B's door. Nothing there? Check the notice in the building's lobby. Nothing there either? Check the street's notice board.

And you stop at the first notice you find — you do not combine them. If the lobby has a notice, the street's notice is irrelevant to you.

When a CA is asked to issue for api.shop.example.com, it queries CAA at each level going upward and stops at the first name that has any CAA record at all:

plain text
api.shop.example.com   →  no CAA?  keep climbing
    shop.example.com   →  no CAA?  keep climbing
         example.com   →  CAA found — these are the rules, stop here
The two mistakes this causes, and both are common.

One — a CAA record on a subdomain silently overrides the parent. Put 0 issue "digicert.com" on shop.example.com and it does not add to the parent's policy — it replaces it. Let's Encrypt is now forbidden for everything under shop.example.com, even though example.com allows it. The climb stops at the first record set found, and there is no inheritance or merging.

Two — dig does not climb for you. A dig CAA api.shop.example.com that returns nothing does not mean "no policy". It means "no record at that label". People check one name, see nothing, and conclude they have no CAA — when in fact the apex has a restrictive record that is about to block their issuance.

There is one more wrinkle: CNAMEs. If the name you ask about is a CNAME, DNS follows it, so you get the CAA of the target, not of the name you asked for. This is why dig CAA www.github.com returns github.com's records — www is a CNAME.

🧪 Exercise D2.1 — Climb the tree yourself, the way a CA does

Save as ~/tls-lab/m11/caa-climb.sh:

bash
#!/usr/bin/env bash
# Walk up the DNS tree exactly the way a CA does, and stop at the first CAA set.
name="$1"
while [ -n "$name" ]; do
  out=$(dig +short CAA "$name")
  if [ -n "$out" ]; then
    echo "CAA found at: $name"
    echo "$out" | sed 's/^/   /'
    exit 0
  fi
  echo "no CAA at:    $name"
  name="${name#*.}"
  [ "$name" = "${name#*.}" ] && break     # stop before the TLD
done
echo "no CAA anywhere up the tree — any CA may issue"
bash
chmod +x ~/tls-lab/m11/caa-climb.sh
~/tls-lab/m11/caa-climb.sh api.internal.wikipedia.org
echo
~/tls-lab/m11/caa-climb.sh www.example.com
Expected result — click to reveal
plain text
no CAA at:    api.internal.wikipedia.org
no CAA at:    internal.wikipedia.org
CAA found at: wikipedia.org
   0 iodef "mailto:[email protected]"
   0 issue "letsencrypt.org"
   0 issue "pki.goog"

no CAA at:    www.example.com
no CAA at:    example.com
no CAA anywhere up the tree — any CA may issue

What to read out of this — the first block is the answer to "why did my certificate request fail when dig showed nothing?"

  • Three queries, not one. A CA asking about api.internal.wikipedia.org does exactly this walk. Two of the three lookups return nothing, and that is normal — the answer lives at the apex.
  • The script stops before the TLD ([ "$name" = "${name#*.}" ] && break). RFC 8659's algorithm technically climbs to the root, but CAA records on public suffixes are not a thing you will meet in practice.
  • The second case is the internet's default. No CAA at any level, so any of the 71 organisations may issue. Fix that for your own domains before you finish this module.

🔑 Keep this script. "Which CAA policy actually applies to this hostname?" is a question you will be asked in the middle of a failed issuance, under time pressure, and answering it with a single dig is how people get it wrong. Part E folds this into the module's capstone tool.

💡 A subtle failure this catches: a wildcard request for *.shop.example.com is evaluated against the CAA of shop.example.com, not of example.com — the wildcard label is stripped first, then the climb begins. If shop.example.com has its own restrictive record, the wildcard fails while ordinary certificates under the apex keep working.

🎯 Interview questions — CAA

Q. What is a CAA record and does it actually stop anyone?

A DNS record listing which CAs may issue certificates for a domain, using the tags issue, issuewild and iodef. Since September 2017 the CA/Browser Forum Baseline Requirements have required every publicly-trusted CA to check CAA before issuing and to refuse if it is not authorised.

It does not stop anyone technically — it is not enforced by cryptography or by the client. It works because CAs are contractually and audit-bound to honour it, and ignoring it is a reportable incident that root programs act on.

The detail worth adding: set expectations correctly, because that is the real question. CAA does not protect you from a compromised or malicious CA, which can simply ignore it — but ignoring it produces a durable, auditable violation, and CT (Part A) makes the resulting certificate visible. So the honest framing is that CAA raises the cost and CT provides the evidence, and neither is a technical prevention. The pairing is the point.

Q. How does a CA decide which CAA record applies to api.shop.example.com?

It queries CAA at api.shop.example.com, then shop.example.com, then example.com, climbing towards the root, and stops at the first name that has any CAA record set. There is no inheritance and no merging — the first record set found is the complete policy. If nothing is found anywhere, CAA does not restrict issuance.

CNAMEs are followed by DNS in the normal way, so querying a CNAME gives you the target's CAA.

The detail worth adding: the operational trap is that a CAA record on a subdomain silently replaces the parent's, so an over-helpful record at shop.example.com can lock out the CA that the rest of the estate uses. The second trap is that a single dig at one label tells you nothing — you have to climb, which most people do not, which is why "we don't have CAA" and "our CAA blocked issuance" are frequently said about the same domain in the same incident.

Q. Which CAA record would you set on a domain that should never have a certificate?

0 issue ";" — an empty issuer-domain-name, which forbids all issuance. Add 0 issuewild ";" alongside it to be explicit about wildcards, and an iodef address so you are told when someone tries.

It is the right record for parked domains, defensive brand registrations, and internal-only zones that should only ever be served by a private CA.

The detail worth adding: this is the highest-value CAA record most organisations never set, because the domains it applies to are precisely the ones nobody owns operationally. A forgotten brand-protection domain with no CAA, no monitoring and stale DNS delegation is a far easier target than the flagship site everyone watches — and the certificate an attacker gets for it is fully valid.


D3 · Pinning the account and the method — CAA's advanced parameters

The analogy — naming the person, not just the firm.

The notice on your door said "only Smith & Sons may cut keys here". Good. But Smith & Sons has four hundred employees and serves the whole city — anyone who walks into their shop and satisfies their normal checks gets a key.

The stronger notice names an individual: "only Smith & Sons, and only when the request comes from account #4231, and only if verified by post rather than over the phone."

Those two extra clauses are accounturi and validationmethods.

Plain CAA narrows issuance from 71 organisations to one. That is an enormous improvement and it is where most people stop. But "one CA" still means "anyone in the world who can pass that CA's validation" — and Let's Encrypt issues to millions of accounts.

RFC 8657 adds two parameters, written after the CA's identifying domain and separated by semicolons:

ParameterWhat it restricts
accounturiOnly this specific ACME account at that CA may issue. The value is the account URL from Module 10 — for Let's Encrypt, https://acme-v02.api.letsencrypt.org/acme/acct/<id>
validationmethodsOnly these challenge types may be used. Let's Encrypt recognises http-01, dns-01 and tls-alpn-01
plain text
example.com.  IN  CAA  0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"

That record says: only Let's Encrypt, only from our account, and only if the request is proven through DNS. An attacker who compromises a web server can no longer obtain a certificate, because HTTP-01 is forbidden. An attacker who opens their own Let's Encrypt account gets nowhere, because the account does not match.

Why validationmethods=dns-01 is the quiet star of this module.

Think about what each ACME challenge from Module 10 actually requires an attacker to compromise. HTTP-01 needs write access to a web root, or anything that can answer on port 80 for your name — a shared host, a misconfigured proxy, a CDN origin, a compromised deploy pipeline. TLS-ALPN-01 needs control of port 443. DNS-01 needs control of your DNS zone.

Restricting to dns-01 means the only path to a certificate for your domain runs through your DNS provider. That is usually your smallest, best-guarded, most-audited surface — and it is often the one surface that is not exposed to the internet at all.

One DNS record removes an entire class of attack, and it costs nothing.

Two ways to lock yourself out with this.

Support is not universal. Let's Encrypt honours both parameters. Not every CA does, and a CA that does not recognise a parameter is permitted by RFC 8659 to ignore the property entirely — which can mean it treats the whole record as not authorising it, and refuses. Test against staging first, exactly as Module 10 (D3) taught.

The account URI is per-environment. The staging account URI is different from production. Pin the production one, and remember that recreating your ACME account — a new account.key — gives you a new account URI and instantly blocks your own renewals. Note the URI somewhere your successor will find it.

🧪 Exercise D3.1 — Find your own ACME account URI, and write the record you would publish
bash
# if you did Module 10 with certbot, the account URI is on disk
sudo find /etc/letsencrypt/accounts -name regr.json -exec cat {} \; 2>/dev/null | head -5

# no certbot? the same value is the Location header from a newAccount request.
# For this exercise just build the record by hand and check the syntax parses:
cat <<'EOF'
example.com.  IN  CAA  0 issue "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
example.com.  IN  CAA  0 issuewild "letsencrypt.org; accounturi=https://acme-v02.api.letsencrypt.org/acme/acct/1234567; validationmethods=dns-01"
example.com.  IN  CAA  0 iodef "mailto:[email protected]"
EOF
Expected result — click to reveal
plain text
{"body": {"key": {"kty": "RSA", "n": "sX9...", "e": "AQAB"}, "contact":
["mailto:[email protected]"], "status": "valid"}, "uri":
"https://acme-v02.api.letsencrypt.org/acme/acct/1234567", "new_authzr_uri":
null, "terms_of_service": null}

What to read out of this — the uri field is the value you pin, and it is the only part that matters.

  • regr.json is certbot's record of the registration. The uri is exactly what accounturi expects, copied verbatim including the scheme.
  • The key in that file is your account key, not any certificate key — Module 10 (B2) drew that distinction and this is where it becomes concrete. The account URI is a name for that key. Lose the key and you lose the account and, if you have pinned it, your ability to renew.
  • If the find returned nothing, you have not run certbot on this machine, which is fine — the second block still shows you the record shape.
  • All three records go together. issue alone leaves wildcards governed by nothing stricter; the issuewild line pins them the same way; iodef is how you learn that someone tried.

🔑 The order to adopt these in, if you are starting from nothing. First publish plain issue for the CAs you actually use — that alone takes you from 71 organisations to one or two, and cannot break anything you are not already doing. Then add iodef. Then, once renewals have run cleanly for a cycle, add validationmethods. Add accounturi last, because it is the one that will lock you out if your ACME account is ever rebuilt.

💡 Now imagine this at 500 hosts. Because CAA is per-domain, adding validationmethods=dns-01 protects all 500 in one change. But it also requires all 500 to renew via DNS-01, so do the reverse check first: find every host still using HTTP-01 and migrate it, or your next renewal wave fails silently at 3am.


🎯 Interview questions — Account and method binding

Q. Your CAA record already names one CA. What is left to protect against, and what would you add?

Naming one CA takes you from about seventy organisations down to one, which is the big win — but that one CA issues to millions of accounts, and it will issue to anyone who passes its validation for your domain. So the remaining exposure is an attacker who can complete a challenge: web-root or port-80 access for HTTP-01, port 443 for TLS-ALPN-01, or DNS control for DNS-01.

RFC 8657 adds two parameters. accounturi restricts issuance to one specific ACME account. validationmethods restricts which challenge types are acceptable.

The detail worth adding: validationmethods=dns-01 is the one to reach for first, and the reasoning is what makes the answer good — it collapses every issuance path down to your DNS zone, which is usually your smallest and best-guarded surface and often not internet-facing at all. One DNS record removes web-server compromise as a route to a valid certificate for your domain. accounturi is stronger still but is the classic self-inflicted outage, because rebuilding an ACME account changes the URI and instantly blocks your own renewals.


D4 · MPIC — why CAA and validation are now checked from several places at once

The analogy — phoning the same shop from four different cities.

Someone tells you they are the manager of a shop. You ring the shop's number and they answer, so you believe them. But what if they diverted the phone line?

So you ring the same number from four different cities, on four different networks. Diverting one line is easy; diverting all four, from four places at once, is much harder. If the answers agree, it is almost certainly the real shop.

That is Multi-Perspective Issuance Corroboration — the CA doing domain validation and CAA lookups from several network locations and comparing the answers.

Everything in Parts A–D assumes the CA's view of the internet is the true one. BGP hijacking breaks that assumption. An attacker who can announce your IP prefix from their own network — or who can influence DNS resolution along the CA's path — makes the CA see their server when it does HTTP-01 validation, and their nameservers when it reads your CAA record. The CA then issues a perfectly legitimate certificate for a domain the attacker does not own, and the certificate is fully valid everywhere.

Academic work at Princeton demonstrated this against real CAs, and it led to a Baseline Requirements change. Since 15 March 2025 CAs must perform domain control validation and CAA checks from multiple network perspectives and corroborate the results, and the requirement has been ratcheting up:

FromRequirement
15 September 2024SHOULD use at least 2 remote perspectives — voluntary
15 March 2025MUST use at least 2 remote perspectives, but may still issue if the quorum fails
15 September 2025MUST NOT issue when too many perspectives disagree — the rule gets teeth
15 March 2026At least 3 remote perspectives, spanning at least 2 distinct Regional Internet Registries
15 June 2026At least 4 remote perspectives — the level in force today
15 December 2026At least 5 remote perspectives

The quorum rule is deliberately forgiving: 1 non-corroborating perspective is allowed with 2–5 perspectives, 2 with 6 or more. Perfect agreement is not required, because the internet is not perfect.

There is a second, related change that is easy to miss: since 15 March 2026, DNSSEC validation back to the IANA root trust anchor must be performed on all DNS queries for CAA lookups by the primary perspective, and a DNSSEC failure such as SERVFAIL must not be treated as permission to issue.

What this means for you, practically. Your DNS must be reachable and consistent from everywhere, not just from wherever you happen to test.

Two configurations that used to work and now cause intermittent, maddening issuance failures: geo-restricted or firewalled nameservers that answer some regions and not others, and anycast or split-horizon DNS where different regions get different answers for the same CAA query.

If certificate issuance starts failing for no visible reason and dig from your laptop looks fine, that is the shape of an MPIC failure. Check from several regions before you blame the CA.

🧪 Exercise D4.1 — Check your DNS the way a CA now does — from more than one place
bash
cd ~/tls-lab/m11

# query the same CAA record through resolvers on different networks
for r in 1.1.1.1 8.8.8.8 9.9.9.9 208.67.222.222; do
  printf "%-16s " "$r"
  dig +short +timeout=3 CAA wikipedia.org @"$r" | tr '\n' ' ' | cut -c1-70
  echo
done
Expected result — click to reveal
plain text
1.1.1.1          0 issue "letsencrypt.org" 0 issue "pki.goog" 0 iodef "mailto
8.8.8.8          0 iodef "mailto:[email protected]" 0 issue "pki.goog" 
9.9.9.9          0 issue "pki.goog" 0 iodef "mailto:[email protected]" 
208.67.222.222   0 issue "letsencrypt.org" 0 iodef "mailto:dns-admin@wikimedi

What to read out of this — the set is the same everywhere; only the order differs.

  • Different order, same records. Resolvers rotate record order, which is why the truncated lines look different. That is not disagreement.
  • This is a poor man's MPIC and it still catches real problems. Four public resolvers are four vantage points. If one of them returned nothing, or returned a different issue value, you would have found a genuine inconsistency before your CA did.
  • It is not a substitute for the real thing. All four of these resolvers are large anycast networks that may still reach your nameservers over similar paths. A real MPIC implementation queries from separate networks in separate registries.

🔑 Add this to your pre-flight checklist before any CAA change. Publish the record, wait for the TTL, then run this loop. A CAA record that has propagated to your provider's primary but not to a secondary nameserver will pass your own dig and fail the CA's quorum — and the error you get back will not say so.

💡 Now imagine this at 500 hosts. MPIC turns "our DNS is a bit flaky in one region" from an annoyance into a hard stop on certificate issuance across the whole estate. Under 47-day certificate lifetimes (Module 10), a DNS inconsistency that blocks issuance for a fortnight is an outage, not a ticket. DNS reliability is now a TLS availability dependency, and that is a genuinely new thing to put in a runbook.

🎯 Interview questions — MPIC and validation integrity

Q. What is MPIC and what attack does it address?

Multi-Perspective Issuance Corroboration: a CA must perform domain control validation and CAA lookups from several network vantage points and corroborate the answers before issuing. It became mandatory under the Baseline Requirements on 15 March 2025 and the required number of perspectives has been increasing — at least four remote perspectives since 15 June 2026, rising to five in December 2026.

It addresses BGP hijacking and path-dependent DNS manipulation. An attacker who can make the CA's traffic reach their server, rather than yours, can complete HTTP-01 validation and obtain a fully valid certificate for a domain they do not own. Hijacking a route so that it is seen from every perspective simultaneously is far harder than from one.

The detail worth adding: the part people miss is that MPIC covers CAA lookups too, not just the challenge. A hijack that only spoofed the challenge but left CAA intact would still be caught by your CAA record — so an attacker had to spoof both, and MPIC now makes both harder from one vantage point. And note the operational consequence: your nameservers must answer consistently from everywhere, which turns geo-restricted or split-horizon DNS into an issuance failure.

Q. Certificate renewal has started failing intermittently for one domain. DNS looks fine from your laptop. Where do you look?

Assume the CA is seeing something you are not. Check the CAA record from several resolvers on different networks and from different regions, and check every authoritative nameserver for the zone individually rather than trusting the resolver's answer — a secondary that has not picked up a zone transfer will serve stale or missing CAA to a fraction of queries.

Then check DNSSEC. Since 15 March 2026 CAs must DNSSEC-validate CAA lookups, and a SERVFAIL from a broken or expired DNSSEC signature is explicitly not permission to issue. An expired zone signature produces exactly this symptom: intermittent, resolver-dependent, invisible from a cached local view.

The detail worth adding: the framing that lands is that MPIC made DNS availability a TLS availability dependency. It used to be that a flaky secondary nameserver degraded resolution slightly; now it can block issuance entirely, and with 47-day certificate lifetimes there is much less slack before that becomes an outage. Say that you would add multi-region DNS checks to monitoring rather than only fixing the immediate record.


Part E · Putting it together

E1 · How this all fits — the complete picture

Diagram source
flowchart TD
    REQ["📝 Someone requests a certificate<br>for your-domain.com"]
    CAA{"🚪 CAA check<br>Is this CA allowed?"}
    ACC{"🔖 accounturi<br>Is this account allowed?"}
    VM{"🎯 validationmethods<br>Is this challenge allowed?"}
    MPIC{"🗺️ MPIC<br>Do 4+ perspectives agree?"}
    REFUSE["🛑 Refused<br>iodef mail sent to you"]
    PRE["🥚 CA builds a precertificate<br>critical poison extension"]
    LOGS["📓 Submitted to CT logs<br>append-only Merkle tree"]
    SCT["🧾 Logs return SCTs<br>signed promise, MMD deadline"]
    CERT["📜 Real certificate issued<br>SCTs embedded"]
    BROWSER{"🌐 Browser check<br>Enough SCTs?<br>Distinct operators?"}
    OK["✅ Padlock"]
    FAIL["❌ ERR_CERTIFICATE_<br>TRANSPARENCY_REQUIRED"]
    MON["🔔 Your CT monitor<br>sees the certificate"]
    ALERT["🚨 You find out<br>within minutes"]

    REQ --> CAA
    CAA -->|"not listed"| REFUSE
    CAA -->|"listed"| ACC
    ACC -->|"wrong account"| REFUSE
    ACC -->|"ok"| VM
    VM -->|"wrong method"| REFUSE
    VM -->|"ok"| MPIC
    MPIC -->|"disagreement"| REFUSE
    MPIC -->|"corroborated"| PRE
    PRE --> LOGS
    LOGS --> SCT
    SCT --> CERT
    CERT --> BROWSER
    BROWSER -->|"no"| FAIL
    BROWSER -->|"yes"| OK
    LOGS -.->|"public, permanent"| MON
    MON --> ALERT

    style CAA fill:#e1d5e7,stroke:#9673a6,stroke-width:3px
    style ACC fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style VM fill:#e1d5e7,stroke:#9673a6,stroke-width:2px
    style REFUSE fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style FAIL fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style OK fill:#d5e8d4,stroke:#82b366,stroke-width:2px
    style ALERT fill:#d5e8d4,stroke:#82b366,stroke-width:3px
    style LOGS fill:#fff2cc,stroke:#d6b656,stroke-width:2px

Read it as two independent paths, and you own something on both of them. The purple boxes on the left are the gates you control with a single DNS record — each one narrows who can obtain a certificate for your name, and each refusal sends you an iodef mail. The dotted line on the right is the one that keeps working even when every gate is bypassed: the certificate is in a public, permanent log, and your monitor sees it.

The green boxes are the two outcomes worth designing for. A padlock for legitimate traffic, and an alert within minutes for everything else. Notice that nothing in this diagram prevents a determined, dishonest CA from issuing — and that the design does not pretend otherwise. It makes issuance narrow, and it makes the result undeniable. That combination is what actually changed CA behaviour, and it is the honest summary of the whole module.


E2 · Production practice

HabitWhy
Publish CAA on every domain you own, including parked and defensive onesOne DNS record takes issuance from 71 organisations to one or two. The forgotten brand-protection domain is the easiest target you have
Always include an iodef address, and make sure someone reads itIt is the only channel by which a CA tells you that somebody tried. A mailbox nobody monitors is the same as no record
Use 0 issue ";" on domains that should never have a certificateParked domains, internal-only zones and brand registrations have no legitimate issuance path, so close it explicitly
Add validationmethods=dns-01 once every renewer uses DNS-01Removes web-server and port-80 compromise as a route to a certificate, permanently, at zero running cost
Add accounturi last, and record the URI where your successor will find itIt is the strongest pin and the easiest way to lock yourself out — rebuilding the ACME account changes the URI
Run a CT monitor that alerts on unexpected issuers, not on all issuanceAlerting on every certificate buries the signal under your own ACME renewals within a week
Treat the SAN list as a permanent public disclosureNames in a public certificate are logged forever and cannot be withdrawn. Review them before requesting, not after
Put internal services on a private CA, never on a public certificateNothing is logged, nothing is disclosed, and you stop leaking your topology to anyone with curl
Verify CAA from several regions after any change, then wait a full TTLMPIC requires consistent answers from four or more perspectives, so a lagging secondary nameserver blocks issuance invisibly
Monitor DNSSEC expiry as a TLS dependencySince March 2026 a DNSSEC SERVFAIL on a CAA lookup is not permission to issue — an expired signature stops renewals
Re-check that your CT monitoring covers static-ct-api logsTooling written against the RFC 6962 get-entries API is blind to tile-based logs and quietly watches less than you think
Never rely on hostname obscurity for anythingCT publishes every name you certify. Obscurity stopped being a control the day CT enforcement began

E3 · Capstone exercise

Build one tool that answers, for any host: is this certificate publicly accountable, and is issuance for this name locked down? Then run it against your own estate and act on what it finds.

Brief. In ~/tls-lab/m11/, build a script ct-caa-audit.sh that:

  1. Fetches the certificate for a host, and prints its subject and issuer
  2. Counts the embedded SCTs and the number of distinct log IDs, works out the certificate's lifetime in days, and compares that against Chrome's rule (2 SCTs at ≤ 180 days, 3 above)
  3. Prints an explicit warning when it finds zero SCTs on a public host, because that means interception rather than a CA failure
  4. Climbs the DNS tree for CAA the way a CA does, stopping at the first record set, and reports which name the policy actually came from
  5. Reports each protection that is missing — no iodef, no issuewild, no accounturi, no validationmethods — rather than only what is present
  6. Works on both GNU and BSD/macOS date, because you will run it on both
Model answer — attempt it first, then click
bash
#!/usr/bin/env bash
# ct-caa-audit — for one host: is its certificate CT-logged, and is issuance locked down?
host="$1"; port="${2:-443}"
[ -z "$host" ] && { echo "usage: $0 <host> [port]"; exit 2; }

# portable "seconds since epoch" for an OpenSSL date, GNU and BSD/macOS
epoch() { date -d "$1" +%s 2>/dev/null || date -j -f "%b %e %T %Y %Z" "$1" +%s; }

# ---------- 1. fetch ----------
crt=$(mktemp)
echo | openssl s_client -connect "$host:$port" -servername "$host" 2>/dev/null \
     | openssl x509 -out "$crt" 2>/dev/null
[ -s "$crt" ] || { echo "$host: could not fetch a certificate"; rm -f "$crt"; exit 1; }

echo "=== $host:$port ==="
openssl x509 -in "$crt" -noout -subject -issuer | sed 's/^/  /'

# ---------- 2. Certificate Transparency ----------
n=$(openssl x509 -in "$crt" -noout -text | grep -c "Signed Certificate Timestamp")
logs=$(openssl x509 -in "$crt" -noout -text | grep -A1 "Log ID" \
       | grep -v '^--' | paste - - | sort -u | wc -l)
nb=$(openssl x509 -in "$crt" -noout -startdate | cut -d= -f2)
na=$(openssl x509 -in "$crt" -noout -enddate   | cut -d= -f2)
days=$(( ( $(epoch "$na") - $(epoch "$nb") ) / 86400 ))
need=2; [ "$days" -gt 180 ] && need=3
printf "  CT      : %d SCTs from %d logs; lifetime %dd -> Chrome needs %d\n" \
       "$n" "$logs" "$days" "$need"
if   [ "$n" -eq 0 ];       then echo "            !! zero SCTs on a public host - suspect TLS interception"
elif [ "$n" -lt "$need" ]; then echo "            !! below Chrome's CT policy"
else                            echo "            ok"
fi

# ---------- 3. CAA, climbing the tree the way a CA does ----------
name="$host"; rec=""; at=""
while [ -n "$name" ]; do
  rec=$(dig +short CAA "$name"); [ -n "$rec" ] && { at="$name"; break; }
  name="${name#*.}"; [ "$name" = "${name#*.}" ] && break
done
if [ -z "$at" ]; then
  echo "  CAA     : none up the tree -- ANY public CA may issue for $host"
else
  echo "  CAA     : set at $at"
  echo "$rec" | sed 's/^/            /'
  echo "$rec" | grep -q iodef             || echo "            .. no iodef: nobody is told when a CA refuses"
  echo "$rec" | grep -q issuewild         || echo "            .. no issuewild: wildcards follow the issue rules"
  echo "$rec" | grep -q accounturi        || echo "            .. no accounturi: any account at that CA may issue"
  echo "$rec" | grep -q validationmethods || echo "            .. no validationmethods: any challenge type is allowed"
fi
rm -f "$crt"

Run it against three domains with deliberately different postures:

bash
chmod +x ~/tls-lab/m11/ct-caa-audit.sh
~/tls-lab/m11/ct-caa-audit.sh google.com
~/tls-lab/m11/ct-caa-audit.sh example.com
plain text
=== google.com:443 ===
  subject=CN = google.com
  issuer=C = US, O = Google Trust Services, CN = WR2
  CT      : 3 SCTs from 3 logs; lifetime 90d -> Chrome needs 2
            ok
  CAA     : set at google.com
            0 issue "pki.goog"
            .. no iodef: nobody is told when a CA refuses
            .. no issuewild: wildcards follow the issue rules
            .. no accounturi: any account at that CA may issue
            .. no validationmethods: any challenge type is allowed

=== example.com:443 ===
  subject=CN = example.com
  issuer=C = US, O = DigiCert Inc, CN = DigiCert Global G3 TLS ECC SHA384 2020 CA1
  CT      : 3 SCTs from 3 logs; lifetime 365d -> Chrome needs 3
            ok
  CAA     : none up the tree -- ANY public CA may issue for example.com

1. The lifetime arithmetic is the part that earns its place. example.com at 365 days needs three SCTs and has exactly three. google.com at 90 days needs two and carries three. Neither number is interesting on its own — the comparison is.

2. Reporting what is missing is the design decision that makes this tool useful. A tool that prints the CAA records you have tells you what you already know. A tool that prints the four protections you have not configured turns a status check into a work list, and it is what makes the difference between an audit script and a dashboard nobody reads.

3. If you get 0 SCTs on every host, you are behind a TLS-inspecting proxy — the certificate you are reading was re-issued locally and was never in any public log. The script says so deliberately rather than reporting a policy violation, because that misdiagnosis wastes real time. Re-run it from a network you control.

4. epoch() exists because of macOS. BSD date has no -d, so the GNU form fails and the || falls through to the BSD form. Every portable TLS script you write will end up with a function like this, and it is worth having once rather than five times.

What is still missing, honestly: this checks one host at a time and does not verify SCT signatures — it counts blocks of text. Verifying an SCT means fetching the log list, matching the log ID to a public key and checking an ECDSA signature over the reconstructed precertificate, which is a real piece of software rather than twenty lines of shell. It also does not map log IDs to operators, so it cannot check the distinct-operator rule. Both gaps are honest ones to state out loud: the script tells you where to look, not that everything is provably correct.

🔑 Keep this file. Module 13 merges certinfo, tlsinfo, tlsdiag, tlsdeploy-check, crl-health, acme-health and ct-caa-audit into one auditing tool you can point at a whole estate.


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

The single most useful document for this module: Chrome Certificate Transparency Policy. It is two pages long, it is the thing that is actually enforced in the browser your users have, and it settles every "how many SCTs do we need" argument in about thirty seconds.

Make it a reflex: when a certificate works in curl and fails in Chrome, read this before anything else. The RFC tells you how CT works; this tells you what will actually happen to your users.

Core reference pages

LinkWhat it is for
Chrome Certificate Transparency PolicyThe SCT count rules that are actually enforced, by delivery method and certificate lifetime
Chrome CT Log PolicyLog states, temporal sharding ranges, MMD limits for RFC 6962 and static-ct-api logs
RFC 6962 — Certificate TransparencyThe mechanism: Merkle trees, precertificates, the poison OID, SCT structure. Experimental status, universally deployed
RFC 9162 — CT version 2.0The standards-track successor. Worth knowing it exists; not deployed
RFC 8659 — DNS CAAThe tags, the tree-climbing algorithm, and what a CA must do when it finds nothing
RFC 8657 — CAA account and method bindingaccounturi and validationmethods syntax and semantics
Let's Encrypt — CAAThe practical page: their identifying domain, supported parameters, and the exact errors you will see
CA/Browser Forum — Baseline Requirements§3.2.2.8 for CAA, §3.2.2.9 for MPIC. The source of truth when a CA's behaviour surprises you
Chrome Root Program PolicyWhat a CA must do to stay trusted, including the March 2027 automation requirement
Mozilla Root Store PolicyThe other major root program, and the one whose incident bugs are public and readable
CCADBWhere CAs disclose intermediates, audits and incidents. The public record behind every distrust decision
crt.shSearch the logs. Free, no account, JSON API. For investigation, not for alerting
Cert SpotterHosted CT monitoring with a free tier, and the self-hostable certspotter binary
Sunlight — static CT logsThe tile-based log design that is replacing RFC 6962 log servers
certificate.transparency.devThe friendliest explanation of the mechanism, with diagrams

How to read the CA/Browser Forum Baseline Requirements without losing an afternoon

The BRs are a 100-page legal document and reading them front to back is a waste of a day. Two habits make them usable:

plain text
3.2.2.4   ← the permitted domain validation methods (the ACME challenges live here)
3.2.2.8   ← CAA: what the CA must check, and the CAA identifying domain
3.2.2.9   ← MPIC: perspectives, quorum, and the phase-in dates
4.9       ← revocation requirements and timelines (Module 09)
6.3.2     ← certificate lifetime limits (the 200/100/47-day schedule, Module 10)
7.1.2     ← the certificate profile: which extensions are required and forbidden

Always check the effective date beside a requirement. The BRs are full of clauses that read as current but are dated a year in the future, and clauses that were superseded last quarter. A statement in the BRs without its date is not a fact yet.

The offline alternative

Everything in this module can be explored without a browser:

bash
openssl x509 -in cert.pem -noout -ext ct_precert_scts       # ⭐ print just the SCTs
openssl x509 -in cert.pem -noout -ext ct_precert_poison     #    just the poison extension
openssl x509 -in cert.pem -noout -ext certificatePolicies   # ⭐ DV / OV / EV policy OID
openssl x509 -help | grep -- -ext                           #    what -ext accepts
man dig                                                     # ⭐ +short, +noall +answer, @resolver
dig -h | head -30                                           #    the flag summary
certspotter -help                                           #    watchlist, hooks, state dir
🧪 Exercise E4.1 — Stop grepping, and ask OpenSSL for the extension by name
bash
cd ~/tls-lab/m11/precert
openssl x509 -in sct.crt      -noout -ext ct_precert_scts | head -6
openssl x509 -in precert.crt  -noout -ext ct_precert_poison
openssl x509 -in pol-EV.crt   -noout -ext certificatePolicies
Expected result — click to reveal
plain text
CT Precertificate SCTs: 
    Signed Certificate Timestamp:
        Version   : v1 (0x0)
        Log ID    : 7D:59:1E:12:E1:78:2A:7B:1C:61:67:7C:5E:FD:F8:D0:
                    87:5C:14:A0:4E:95:9E:B9:03:2F:D9:0E:8C:2E:79:B8
        Timestamp : Aug 12 12:00:00.000 2025 GMT
CT Precertificate Poison: critical
    NULL
X509v3 Certificate Policies: 
    Policy: 2.23.140.1.1

What to read out of this — three commands replaced three fragile grep -A pipelines.

  • -ext takes the extension's short name, and OpenSSL knows the CT ones by name: ct_precert_scts and ct_precert_poison. No OID, no line-counting, no -A20 guessing at how many lines to keep.
  • It also takes an OID directly if the extension has no short name, which is how you read a proprietary extension a vendor has invented.
  • The output is unindented, unlike -text, which makes it much easier to feed into a script.

🔑 This is a general habit, not a CT trick. Anywhere you were about to write openssl x509 -text | grep -A5 "Something", check whether -ext knows the name first. It is more precise, it does not break when the number of output lines changes, and it makes the intent of the command obvious to whoever reads it next.


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. Why can any CA issue a certificate for your domain, and what does that mean in numbers?

Because web PKI trust is a union, not an intersection. A certificate is accepted if it chains to any root in the trust store, and there is no field in X.509 that restricts a domain to a particular CA. A typical trust store holds around 150 roots belonging to roughly 70 organisations.

Every one of those organisations can issue for your name, and every browser will accept it. That is the problem CT and CAA exist to manage — neither of them removes it.

2. What does an SCT actually prove?

That a CT log signed a promise to publish the certificate within its maximum merge delay. Nothing more. The browser verifies only the signature, against a log public key it already ships.

It does not prove the certificate is in the log. Checking that would require the browser to contact the log, which would leak browsing history — the same privacy failure that killed OCSP. Enforcement is pushed to monitors and auditors outside the connection path.

3. Why does a precertificate need a critical extension rather than a normal one?

Because a precertificate is signed by a real CA with real authority, so it must be impossible to use. RFC 5280 requires clients to reject any certificate containing a critical extension they do not understand, and no client understands the poison OID 1.3.6.1.4.1.11129.2.4.3.

A non-critical extension would simply be ignored, and the precertificate would be a fully usable certificate that the CA had signed and never intended to be trusted. The safety comes from the extension being permanently unimplemented by design.

4. How many SCTs does a 90-day certificate need in Chrome, and how many does a 2-year one need?

Embedded SCTs: 2 at 180 days or less, 3 above that. So a 90-day certificate needs two, and a two-year certificate needs three — though two-year public certificates no longer exist under the current lifetime limits. At least two must come from distinct log operators, not just distinct logs.

SCTs delivered through the TLS handshake or a stapled OCSP response need at least two from distinct operators regardless of lifetime.

5. What is temporal sharding, and how can it break a certificate that used to work?

CT logs are partitioned by the expiry date of the certificates they accept, in ranges of 3 to 12 months, so that a shard can be deleted once everything in it has expired.

When a shard is retired, SCTs from it stop counting towards CT policy. A long-lived certificate whose SCTs all came from a now-retired shard can start failing in Chrome with no configuration change and no expiry — a failure with no obvious trigger, on hosts nobody has touched.

6. You see zero SCTs on a certificate from a major public website. What is the most likely explanation?

A TLS-inspecting proxy — corporate, MDM or a security appliance — has terminated the connection and re-issued the certificate from a locally-trusted CA. A locally-issued certificate has no reason to be in a public log, so it carries no SCTs.

Confirm by checking the issuer. This is one of the fastest interception tests available: a good proxy CA can look plausible, but it can never fake SCTs, because no public log will sign for it.

7. What are the downsides of CT, and what do you do about them?

Every hostname on a publicly-trusted certificate is published permanently and searchably, so staging, admin, CI and VPN names become public reconnaissance material. There is no takedown — the log is append-only.

Mitigate by using wildcards for internal names (accepting the larger blast radius on one key), putting internal services on a private CA so nothing is logged at all, and treating the SAN list as a permanent disclosure decision made before the request. And accept that hostname obscurity has not been a control since CT enforcement began.

8. What is the difference between DV, OV and EV, and does it affect the TLS connection?

Only what the CA verified before issuing: domain control (DV), plus verified organisation existence (OV), plus the full EV Guidelines checks on incorporation, address and requester authority (EV). The policy OIDs are 2.23.140.1.2.1, 2.23.140.1.2.2 and 2.23.140.1.1.

It has no effect at all on the TLS connection — same algorithms, same handshake, same strength. The browser identity UI for EV was removed by Chrome 77 and Firefox 70 in 2019, which removed most of its commercial rationale.

9. How does a CA decide which CAA record applies, and what is the classic mistake?

It queries CAA at the exact name, then climbs towards the root one label at a time, and stops at the first name with any CAA record set. There is no inheritance and no merging.

The classic mistake is assuming a subdomain record adds to the parent's. It replaces it entirely — so a well-meaning record on shop.example.com can lock out the CA the rest of the estate uses. The second mistake is running one dig at one label, seeing nothing, and concluding there is no policy.

10. Which two CAA parameters go beyond naming a CA, and what does each buy?

accounturi restricts issuance to one specific ACME account at that CA, identified by its account URL. validationmethods restricts which challenge types may be used — http-01, dns-01, tls-alpn-01.

validationmethods=dns-01 is the higher-value one for most estates: it makes DNS control the only path to a certificate, removing web-root and port-80 compromise as an issuance route. accounturi is the strongest pin and the easiest way to lock yourself out, because rebuilding the ACME account changes the URI.

11. What is MPIC, and what does it require of your DNS?

Multi-Perspective Issuance Corroboration: CAs must perform domain validation and CAA lookups from several network perspectives and corroborate the results, to defeat BGP hijacking and path-dependent DNS manipulation. Mandatory since 15 March 2025; at least four remote perspectives since 15 June 2026, rising to five in December 2026, with one non-corroboration tolerated up to five perspectives.

It requires your nameservers to answer consistently from everywhere. Geo-restricted, firewalled or split-horizon DNS now causes intermittent issuance failures that look fine from your own dig. Since March 2026 CAA lookups must also be DNSSEC-validated, so an expired zone signature blocks renewal.

12. What actually gets a CA removed from a root program?

Not usually a single mis-issuance. In most of the major cases — Symantec, WoSign/StartCom, Entrust — the fatal problem was the pattern of behaviour afterwards: slow disclosure, incomplete root-cause analysis, defensive responses, undisclosed ownership or relationships.

Removal is normally phased by notBefore date so existing certificates expire naturally, because distrusting a CA also breaks its legitimate customers. Emergency removal, as with DigiNotar in 2011, happens only when the CA is actively compromised.


E6 · Command reference — everything from this module

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

Read CT data out of a certificate

bash
openssl x509 -in cert.pem -noout -ext ct_precert_scts              # ⭐ print just the SCTs
openssl x509 -in cert.pem -noout -ext ct_precert_poison            #    the poison extension
openssl x509 -in cert.pem -noout -text | grep -c "Signed Certificate Timestamp"   # ⭐ count SCTs
openssl x509 -in cert.pem -noout -ext certificatePolicies          # ⭐ DV / OV / EV policy OID
openssl x509 -in cert.pem -noout -issuer -subject -dates           # ⭐ who issued it, for what, until when

Fetch a live certificate to inspect

bash
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -out live.pem                                     # ⭐ save the leaf
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -text | grep -A1 "Log ID"                  #    which logs signed it

Query CAA

bash
dig +short CAA example.com                                         # ⭐ the fast look
dig CAA example.com +noall +answer                                 # ⭐ with TTLs, for change work
dig +short CAA example.com @1.1.1.1                                #    from a specific resolver
dig +short CAA example.com @ns1.example.com                        # ⭐ straight from the authoritative server
~/tls-lab/m11/caa-climb.sh api.shop.example.com                    # ⭐ which record actually applies

Search and monitor the CT logs

bash
curl -s 'https://crt.sh/?q=%25.example.com&output=json&exclude=expired' | jq length
curl -s 'https://crt.sh/?q=%25.example.com&output=json&exclude=expired' \
  | jq -r '.[].name_value' | tr '\n' '\n' | sort -u                # ⭐ every name ever certified
certspotter -start_at_end -stdout                                  # ⭐ first run: watch from now on
certspotter -watchlist ~/.certspotter/watchlist -script ~/bin/ct-alert.sh

Build the lab objects

bash
echo '1.3.6.1.4.1.11129.2.4.3=critical,DER:05:00' >> ext.cnf       #    add the poison extension
openssl x509 -req -in leaf.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
  -days 90 -sha256 -extfile ext.cnf -out precert.crt               #    issue a precertificate
openssl verify -CAfile ca.crt precert.crt                          #    → error 34, as designed
python3 ~/tls-lab/m11/merkle.py                                    #    Merkle tree and inclusion proof
The two-minute audit worth running on every domain you own. If you do only one thing from this module, do this one:
bash
d=example.com
dig +short CAA "$d" | grep -q . && echo "CAA: set" || echo "CAA: MISSING — any of ~70 organisations may issue"
dig +short CAA "$d" | grep -q iodef || echo "iodef: MISSING — nobody is told when a CA refuses"
curl -s "https://crt.sh/?q=%25.$d&output=json&exclude=expired" \
  | python3 -c 'import json,sys; r=json.load(sys.stdin); \
      print("issuers seen:", *sorted({x["issuer_name"].split("O=")[-1][:30] for x in r}), sep="\n  ")'

A missing CAA record and an unrecognised issuer are the two findings that matter. Everything else in this module is context for those two lines.


Next — Module 12 · mTLS & Internal PKI.

This module ended on an uncomfortable conclusion: anything internal should not be on a public certificate at all. CT publishes the names, the public CAs cannot vouch for a service that is not on the internet, and hostname obscurity was never a control. So what should internal services use?

Module 12 answers that properly. It covers mutual TLS — where the client also presents a certificate, so identity flows in both directions — client certificate verification in nginx and Apache, and what verify client actually checks. Then the internal PKI that makes it survivable at scale: cert-manager in Kubernetes, HashiCorp Vault's PKI engine, SPIFFE and SVIDs for workload identity, and short-lived certificates measured in hours rather than days. It closes with the question every team eventually asks — when to run your own CA, and when not to.

Official reading ahead of it: RFC 8446 §4.4.2 — Certificate (client authentication), the nginx ssl_verify_client directive, and the SPIFFE specification.

📚 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 offline command and expected output in this module was executed on OpenSSL 3.0.13 and BIND 9.18 dig, including the Merkle tree exercise (whose hashes are deterministic and will match yours exactly), the precertificate poison extension producing error 34, the SCT-list construction, the certificate policy OIDs, and every dig CAA query — the CAA records shown for google.com, wikipedia.org and example.com were read live while writing.

Blocks marked as coming from live public services (crt.sh queries, and the SCT counts on public certificates) are illustrative of the shape of the output rather than reproducible line for line, and are labelled as such where they appear.

Current-state claims were verified against primary sources: the Chrome CT Policy for the 2-and-3 SCT rules and the distinct-operator requirement; the Chrome CT Log Policy for temporal sharding ranges and the 4-hour / 1-minute MMD limits; the ct-policy announcement of the One-6962-log removal for Chrome 144 and the 15 April 2026 date; RFC 6962 for both OIDs; RFC 8659 and RFC 8657 for CAA and its parameters; Let's Encrypt's CAA documentation for their identifying domain and supported parameters; the CA/Browser Forum Baseline Requirements §3.2.2.8–3.2.2.9 and a published CA CPS for the MPIC phase-in dates and quorum table and the March 2026 DNSSEC requirement; and the Chrome Root Program Policy v1.8 for the audit and March 2027 automation requirements.

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.