Skip to content

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.

Terminal window
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 caddy
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.

In this order:

  1. A provider named by the Server header (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, …).

  2. A library named by the header: an explicit OpenSSL/3.0.8 token, 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.

  3. 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
  4. 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.

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.

On the server itself, pqdoctor can read the real versions:

Terminal window
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 front

It 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.