Accuracy
Three checks, each against something independent of pqdoctor’s own probes.
| Check | Result |
|---|---|
| 60 real sites vs OpenSSL 3.5.5 | 59 / 59 where OpenSSL gave a definite answer |
| 100 hosts from the study vs OpenSSL 3.5.5 | 91 / 93 where OpenSSL gave a definite answer; both differences explained below |
| The fix lab: real servers with known faults | 30 / 30 |
60 real sites
Section titled “60 real sites”Ground truth is OpenSSL 3.5.5 (bundled with Node 24) connecting with only X25519MLKEM768 offered and TLS 1.3 required. A completed handshake proves the site supports hybrid post-quantum key exchange. Both tools ran back to back from the same machine on 2026-10-13.
Agreement: 59 / 59 on every site where OpenSSL gave a definite answer.
- The one OpenSSL couldn’t decide (Netflix reset the PQ-only connection) was checked again with a normal group list. It got TLS 1.3 with plain X25519, so pqdoctor’s “Not PQ” is right there too.
- The three “TLS 1.2 only” diagnoses (nginx.org, www.mit.edu, www.baidu.com) were confirmed separately. OpenSSL requiring TLS 1.3 got a
protocol_versionalert from all three. - Re-run later through the
pqdoctorCLI (4 hosts at a time, at most 2 new hosts a second): again 59 / 59, with Netflix again undecided by OpenSSL. Cause codes: 23pq-preferred, 22pq, 12no-mlkem, 3tls12-only.
The sites included cloudflare.com, google.com, github.com, microsoft.com, apple.com, wikipedia.org, www.debian.org, kernel.org, www.gov.uk, www.irs.gov, stripe.com and slack.com.
What the first run got wrong
Section titled “What the first run got wrong”The first run fired 7 parallel probes per site. www.debian.org rate-limited the burst, every probe timed out, and pqdoctor reported “No TLS handshake at all”. That was confidently wrong. Probes now run one at a time with a short gap, a failed probe is retried once, and when only timeouts or resets come back, the verdict is “Unreachable — possibly rate-limited”, not a diagnosis.
100 hosts from the study
Section titled “100 hosts from the study”A seeded random sample of 100 answering hosts from the study, half of them non-PQ where possible, was checked with the same OpenSSL 3.5.5 PQ-only test.
Agreement: 91 / 93 where OpenSSL gave a definite answer. OpenSSL couldn’t decide 7: each reset the connection (ECONNRESET).
The two differences:
- receptivity.io: OpenSSL said no, pqdoctor said
pq-preferred. Re-checked on 2026-10-14: the server’s ServerHello picksX25519MLKEM768, and OpenSSL’s handshake fails later with anunrecognized_namealert. That’s an SNI or certificate problem after the key exchange, which the ground-truth script counts as “no” because it treats every alert as a failure. The key exchange is post-quantum; the ground-truth script is too coarse here. - kaufland.de: OpenSSL said no, pqdoctor said
pq. Re-checked on 2026-10-14: the server itself flips. A direct OpenSSL PQ-only connection succeeded once. Minutes later, 8 of 8 OpenSSL attempts and 4 of 4 pqdoctor runs both said not PQ. At any one moment the two tools agree; the deployment doesn’t.
The fix lab
Section titled “The fix lab”Real TLS servers (Go 1.25.4 and OpenSSL 3.5.5) were set up with known problems, checked with pqdoctor, then given the fix pqdoctor suggests and checked again.
The group strings are taken word for word from pqdoctor’s own nginx, Apache and Node snippets. They go through the same OpenSSL call (SSL_CTX_set1_groups_list) that nginx’s ssl_ecdh_curve and Apache’s Groups use.
30 / 30 checks passed: 11 key-exchange diagnoses, 11 fixes, 3 ML-DSA checks, and 5 STARTTLS and every-address checks.
| Case | Expected | pqdoctor said | Fix applied | After |
|---|---|---|---|---|
| OpenSSL 3.5 defaults | PQ | PQ | nothing to fix | PQ |
Groups pinned to X25519:P-256 |
Not PQ | Not PQ | ssl_ecdh_curve X25519MLKEM768:X25519:prime256v1 |
PQ |
Groups pinned to P-256:P-384 |
Not PQ | Not PQ | Groups X25519MLKEM768:X25519:prime256v1 |
PQ |
Node with ecdhCurve X25519 |
Not PQ | Not PQ | ecdhCurve: 'X25519MLKEM768:X25519:P-256' |
PQ |
| TLS 1.2 only | No TLS 1.3 | No TLS 1.3 | ssl_protocols TLSv1.2 TLSv1.3 + groups |
PQ |
| Go 1.25 defaults | PQ | PQ | nothing to fix | PQ |
Go without ML-KEM (GODEBUG=tlsmlkem=0) |
Not PQ | Not PQ | Go 1.24+ defaults (CurvePreferences = nil) |
PQ |
Go with CurvePreferences X25519, P-256 |
Not PQ | Not PQ | remove CurvePreferences |
PQ |
| Behind a box dropping split hellos | PQ + warning | PQ + warning | remove the box | PQ |
| Behind a box dropping hellos over 1,200 bytes | Breaks on PQ | Breaks on PQ | remove the box | PQ |
| Behind a box resetting on the PQ codepoint | Breaks on PQ | Breaks on PQ | remove the box | PQ |
ML-DSA: an ECDSA-only server was reported “No ML-DSA certificate”. With an ML-DSA-44 certificate added next to it, it served ML-DSA to ML-DSA clients, and classical clients still got ECDSA. See ML-DSA readiness.
STARTTLS and every address: SMTP, IMAP and POP3 with pinned groups were each fixed by the Postfix or Dovecot list. A server without STARTTLS gave an error, not a diagnosis. localhost answering classical on 127.0.0.1 and PQ on ::1 was reported per address. See Mail.
Not covered by the fix lab
Section titled “Not covered by the fix lab”- The nginx, Apache, HAProxy, Postfix, Dovecot and Exim binaries. Only their group strings are validated, through OpenSSL 3.5. The servers need Linux to run.
- Public IPv6. The lab machine has no IPv6 route, so IPv6 is proven on ::1 only.
- Kyber-draft-only servers. No maintained TLS stack still ships
X25519Kyber768Draft00alone. Covered by unit tests only. - The stack guess from the
Serverheader. The lab uses IPs, so no header is read.