One level down
A cause like no-mlkem says what is missing. It doesn’t say what it takes: one config line, a library upgrade, or a ticket to your hosting provider. The fingerprint answers that.
pqdoctor origin.example.org✘ origin.example.org:443 (203.0.113.7) Not post-quantum cause: no-mlkem
Why, one level down the TLS library can't do ML-KEM — upgrade it, or put something PQ in front no-mlkem-library confidence: high (remote) library: OpenSSL 3.0–3.4 · Accepts exactly OpenSSL 3.0–3.4's default groups (X25519, P-256, P-384, P-521, X448, ffdhe2048) and no ML-KEM — OpenSSL 3.5+ would add X25519MLKEM768 to that list.
Next step Put Caddy 2.10+ in front (post-quantum by default) pqdoctor sidecar origin.example.org --kind caddyThe four classes
Section titled “The four classes”| Class | Means | Next step |
|---|---|---|
config |
The TLS library has ML-KEM; the group list leaves it out | fix --apply |
library |
The TLS library can’t do ML-KEM | a sidecar now, the upgrade later |
provider |
A provider or appliance terminates TLS (AWS load balancers, F5 BIG-IP, Envoy, Heroku, Netlify, Azure, DDoS shields, …) | its PQ setting, its firmware, or ask it |
unknown |
Can’t tell from outside | pqdoctor <host> --local on the server |
In JSON the class is remedy.class and remedy.subcause (no-mlkem-config, no-mlkem-library, no-mlkem-provider, no-mlkem-unknown); the original causeCode doesn’t change, so older scripts keep working. Scan records get optional subcause, library, provider, confidence and tlsSig fields.
How it tells
Section titled “How it tells”In this order:
-
A provider named by the
Serverheader (awselb/2.0,BigIP,envoy,ddos-guard, …), or, for Cloudflare origins in Flarebox, the origin’s own hostname (*.herokuapp.com,*.elb.amazonaws.com,*.azurewebsites.net, …). -
A library named by the header: an explicit
OpenSSL/3.0.8token, or a distro package version that pins the release —nginx/1.24.0 (Ubuntu)is Ubuntu 24.04’s package, which ships OpenSSL 3.0.13. -
The group profile. Six more ClientHellos, each offering a single classical group — X25519, P-256, P-384, P-521, X448, ffdhe2048 — with no key share, so a group the server supports comes back as a HelloRetryRequest naming it. That’s the server’s exact group list, and each library’s default list is distinctive:
Accepts Is all six, no ML-KEM OpenSSL 3.0–3.4’s defaults (or Java up to JDK 26) — no ML-KEM X448 but no FFDHE OpenSSL 1.1.1’s defaults (no FFDHE in TLS 1.3 there) — end of life since 2023 P-521, no X448 or FFDHE Go before 1.24 anything else a list set by hand -
Stack hints: IIS and Kestrel mean Windows Schannel; a non-PQ Caddy is almost certainly older than 2.10.
The rules were calibrated against real servers — Python on OpenSSL 3.0.18, Perl on OpenSSL 3.6.1, Node on OpenSSL 3.5.5, and Go — not written from memory, and every library, distro and product fact behind them has a source.
What it won’t guess
Section titled “What it won’t guess”A hand-set group list hides the library version. With the same list, OpenSSL 3.6 and OpenSSL 3.0 answer every probe identically — same HelloRetryRequest choice, same preference, same extension order. So a hand-set list with no other evidence stays unknown. The next step is still clear: the list has to change either way, so pqdoctor suggests trying the config fix, which can’t make things worse.
Locally
Section titled “Locally”On the server itself, pqdoctor can read the real versions:
pqdoctor local✔ nginx 1.28.0 OpenSSL 3.5.3 — has ML-KEM: a config change is enough built with OpenSSL 3.5.3 16 Sep 2025✘ openssl 3.0.13 OpenSSL 3.0.13 — no ML-KEM: upgrade, or put a PQ proxy / Cloudflare Tunnel in frontIt runs nginx -V, haproxy -vv, apache2 -v / httpd -v with openssl version, caddy build-info and node -p process.versions.openssl — read-only, two seconds each. pqdoctor <host> --local merges that into a check: the local reading wins, and if it disagrees with the remote guess the report says so (usually a load balancer or proxy terminates TLS in front of the server you’re on).
Only servers that aren’t post-quantum get the six extra hellos, one at a time like the other probes. It’s on by default for one host and --fingerprint for scans; --no-fingerprint turns it off.