How it works
pqdoctor asks one question of a TLS server: does it negotiate hybrid post-quantum key exchange (X25519MLKEM768, codepoint 0x11EC)? If it doesn’t, the useful part is why, because each reason has a different fix.
To find out, it sends seven ClientHellos it builds by hand and reads only the server’s first reply to each: a ServerHello, a HelloRetryRequest, or an alert. It never completes a handshake for these probes and never sends application data.
The seven probes
Section titled “The seven probes”| # | Probe | What it shows |
|---|---|---|
| 1 | TLS 1.3 with classical groups | The baseline: does TLS 1.3 work at all, and which group does the server pick? |
| 2 | Offer only an X25519MLKEM768 key share |
Does it accept post-quantum when that’s all there is, or ask for something else? |
| 3 | X25519 share, ML-KEM listed first | Does it prefer PQ, asking a client that sent only an X25519 share to retry with ML-KEM? |
| 4 | Old draft group X25519Kyber768Draft00 |
Does it only know the pre-standard Kyber draft? |
| 5 | PQ ClientHello split across two TCP segments | A real PQ hello is large and often split. Does something in the path drop it? |
| 6 | Classical ClientHello, same size, split | The control for 5: is it the size and split, or the PQ group itself? |
| 7 | P-256 / P-384 only | Does the server only answer NIST curves, a sign of a hand-written curve list? |
The probes run one at a time, 120 ms apart, with 5 s connect and read timeouts. A probe that gets no reply is retried once. Parallel probes trip servers’ rate limiters: an early version fired all seven at once, www.debian.org rate-limited the burst, and pqdoctor reported “No TLS handshake at all”. It was confidently wrong, so the probes became sequential and “nothing answered” became its own verdict.
From replies to a cause
Section titled “From replies to a cause”The replies are read in a fixed order. The first rule that matches gives the verdict and the cause code:
- Couldn’t connect:
connect-failed. - Neither a classical nor a P-curve hello got any TLS answer, only timeouts or resets:
no-reply. pqdoctor doesn’t guess here, since the server may be down, firewalled or rate-limiting. - Probe 2 got a post-quantum group: post-quantum. It’s
pq-preferredif probe 3 got a retry request for ML-KEM,pqotherwise, andpq-split-droppedif probe 5 went unanswered. - No hello completed with any group:
no-tls. - The server answered TLS 1.2:
tls12-only. - Classical TLS 1.3 works but the PQ hello dies:
large-hello-droppedif the same-size classical hello (probe 6) also dies,pq-group-rejectedif only the PQ group does. - Probe 4 got the Kyber draft group:
kyber-draft-only. - Otherwise, TLS 1.3 without ML-KEM:
pinned-nistif the server steers X25519 clients to P-256 or only answers P-curve hellos,no-mlkemif not.
Tailoring the fix
Section titled “Tailoring the fix”A fix depends on the server software. pqdoctor makes one plain-HTTP HEAD / on port 80 and reads the Server header (3 s, best effort). From it, it picks a stack: nginx, Apache, HAProxy, Caddy, IIS, Node, Java, envoy and others, and Postfix, Dovecot, Exim or Exchange from a mail server’s greeting. The fixes are then written for that stack, and there’s always a Cloudflare Tunnel fallback. --no-http skips the request.
The certificate check
Section titled “The certificate check”After the key-exchange probes, pqdoctor checks post-quantum authentication: does the server serve an ML-DSA certificate? This needs to read the Certificate message, which TLS 1.3 encrypts, so it’s the one place pqdoctor does real cryptography. It completes one X25519 exchange and runs the TLS 1.3 key schedule (AES-128-GCM) just far enough to decrypt the Certificate and CertificateVerify, then hangs up. It never sends Finished. That’s two more connections. It’s on by default for a single host and off for scans (--auth turns it on).
Every address
Section titled “Every address”A name with several addresses can have servers that disagree. For one host, pqdoctor checks every address the name resolves to, up to six, and reports per address when they differ. The fix lab has a localhost case answering classical on 127.0.0.1 and post-quantum on ::1. --first-address checks only the first.
Being polite
Section titled “Being polite”- Handshake-only: no application data is ever sent.
- The
Serverheader request’s User-Agent names the tool and can carry a contact (--contact) for opt-outs. scanruns at most 4 hosts at once and starts at most 2 new hosts a second by default (caps: 32 and 20). Each host’s probes stay one at a time.--exclude FILEskips hosts and their subdomains.