Which classes of client can still complete a TLS handshake with this endpoint, now that post-quantum key exchange is on by default in browsers and CDNs — and when one class cannot, is it refused or cut off? One static Go binary, zero dependencies, no application data ever sent.
$ go install github.com/Allan-Nava/pqprobe/cmd/pqprobe@latest
$ pqprobe probe origin.example.com
The question it answers
curl and down for a CDNML-KEM (FIPS 203, formerly Kyber) is a key encapsulation mechanism whose security does not rest on the discrete logarithm problem — the thing a quantum computer would break, and with it every X25519 and P-256 handshake ever recorded. That word is the point: traffic captured today can be decrypted years later, which is why key exchange is migrating first and why the threat is called harvest now, decrypt later.
X25519MLKEM768 is a hybrid — an X25519 share
and an ML-KEM-768 share, combined so the session survives if either does.
Nobody is betting on the new mathematics alone, and nobody is betting on
the old. The cost is size, and it is the entire reason this tool exists:
| classical | hybrid | |
|---|---|---|
| key share in the ClientHello | 32 bytes (X25519) | 32 + 1216 bytes |
| ClientHello on the wire | ~270 bytes | ~1500 bytes |
Measured, not estimated: pqprobe probe example.com prints
hello 273 B for the classical profile and
hello 1495 B for the hybrid one. And ~1500 is exactly the
wrong number — a standard MTU is 1500, leaving about 1460 for TCP payload,
so the hybrid ClientHello no longer fits one segment,
where every ClientHello has fitted one for thirty years. It gets split, and
anything on the path that assumed otherwise now has an opinion about the
second half.
Hybrid key exchange is now the default in Chrome, Edge and Firefox, in Go 1.24+, in OpenSSL 3.5+ and at several CDNs. Anything on the path that assumed it did — an old TLS library, a middlebox inspecting the hello, a load balancer with its own parser — now has a chance to mishandle the second segment. When it does, the connection resets or hangs, and no alert is sent, because nothing on the far side got far enough to send one.
The evidence that reaches an operator is the worst possible shape:
curl, openssl s_client, the load balancer health probe and the monitoring agent all send a small classical hello.The asymmetry is the diagnosis, and it takes about two seconds to see once something dials both ways:
$ pqprobe probe origin.example.com
BAD origin.example.com:443 pq-intolerant
BAD verdict pq-intolerant — post-quantum-capable clients cannot connect at all, while classical clients can
↳ the classical client connected and the post-quantum-capable one was cut off (reset): every client
that merely *offers* ML-KEM fails here — Chrome and Edge 131+, Firefox 132+, a CDN with
post-quantum enabled — while curl and your existing health checks keep passing
WARN handshake/pq-preferred no handshake (reset): read: connection reset by peer
↳ an abrupt end means the peer never sent a TLS alert: it choked on the ClientHello rather than declining it
OK handshake/classic TLS 1.3, X25519, TLS_AES_128_GCM_SHA256
pq-intolerant is the finding somebody takes to a CDN vendor,
and one reset is also what a stale conntrack entry or a node being drained
looks like. Both dials cut off reads as reproduced on a second
dial; cut off and then connected reads as flapping, not
walled — a WARN next to a working endpoint, never a BAD. An alert is
never re-dialled: it is an answer the peer chose to give.
What it reports
pqprobe dials the same endpoint several times with deliberately different client shapes and reads the shape of the refusal. Every post-quantum conclusion is conditional on the classical profile having connected: an endpoint that answered nothing is never graded.
| Class | Status | What the peer did | What it means for you |
|---|---|---|---|
pq-ready | OK | completed hybrid key exchange, including for a client that requires it | done — re-check after TLS stack changes |
pq-capable | WARN | negotiates ML-KEM when offered, but the pq-only profile did not complete | fine today; a post-quantum-required client is not served |
pq-blind | WARN | no ML-KEM, but falls back cleanly to a classical group | works today, breaks the day a client requires post-quantum |
pq-refusing | BAD | sent a TLS alert to a hybrid hello that also offered X25519 | a policy or a pinned group list — look at the configuration |
pq-intolerant | BAD | reset, timed out or vanished mid-hello | an outage waiting for a CDN to flip a default — look at the path |
no-tls13 | WARN | serves TLS 1.2 and nothing newer | post-quantum key exchange is a 1.3 feature: a ceiling, not a setting |
no-tls | ERROR | would not upgrade to TLS (--starttls) | not a grade — it refused TLS, not post-quantum clients |
mtls-required | ERROR | requested a client certificate, and no handshake survived it | not a grade — it refused the prober, not post-quantum clients |
unreachable | ERROR | answered nothing | not a grade — fix reachability first |
pq-other-hybrid | BAD | negotiates hybrid ML-KEM, but only in SecP256r1MLKEM768 or SecP384r1MLKEM1024 | not broken — a FIPS-shaped stack no browser can reach; add X25519MLKEM768 |
tls-broken | ERROR | the port answered and no profile completed a handshake | not a post-quantum finding at all — run --per-group before believing it |
pq-refusing — read the configThe peer parsed a hello that also offered X25519 and P-256 and still said no. Look for a pinned group list, a TLS policy, a hardware accelerator with a fixed algorithm set.
pq-intolerant — read the pathNo alert was sent. Look at what the ClientHello had to cross: an old TLS library, a middlebox, a load balancer that reads the hello, anything that assumes a handshake fits one packet.
Client profiles
pqprobe builds its ClientHello with Go's crypto/tls. It cannot
reproduce Chrome's extension order or a CDN's exact cipher list, and it
never claims to. What a profile pins down is which key exchange
groups are offered and which TLS versions are acceptable — the
property that decides whether a post-quantum-capable client can finish a
handshake. The client names below say who is affected; no code
branches on them.
| Profile | Groups | Versions | Stands for |
|---|---|---|---|
classic | X25519, P-256 | 1.2–1.3 | curl, openssl s_client, any pre-2024 client, every health check you already run |
pq-preferred | X25519MLKEM768, X25519, P-256 | 1.2–1.3 | Chrome/Edge 131+, Firefox 132+, CDNs with post-quantum enabled, Go 1.24+, OpenSSL 3.5+ |
pq-only | X25519MLKEM768 | 1.3 | a client with post-quantum required — the default of the next few years |
tls13-only | X25519, P-256 | 1.3 | a modern client with TLS 1.2 disabled |
tls12 | X25519, P-256 | 1.2 | old Java and .NET stacks, embedded boxes, legacy CDN pull agents |
The default set is classic,pq-preferred,pq-only: a baseline and
the two post-quantum questions. The version edges cost two more connections
per endpoint and answer a different question, so they are opt-in via
--profile.
pq-preferred is the profile that mattersIt is the realistic client. It offers hybrid ML-KEM and classical groups, so a peer with no post-quantum support is still expected to complete it, by selecting X25519. A failure here therefore does not mean "no post-quantum support" — it means cannot talk to a client that offered it, and the reason is usually the ~1.2 KB hello.
--groups X25519MLKEM768,X25519 dials exactly that set, in that
order, with the same version window as pq-preferred so the two
results are comparable. It gets its own handshake finding — you asked for
the dial — but it does not decide the class: a set you described is a
question, not a baseline. An unknown group name is a usage error listing
the known ones, never a silently smaller set.
ML-DSA (FIPS 204, formerly Dilithium) is the post-quantum signature algorithm, and certificates will eventually be signed with it. A signature goes from 64 bytes to about 3.3 KB and a public key from 64 bytes to about 2 KB, so every certificate in a chain gains roughly 4 KB — travelling in the server's direction this time, past a different set of middleboxes.
Nothing serves those certificates yet, so pqprobe cannot probe them. What
it reports on every run is the number you would be starting from:
chain-size, measured on the wire — 2718 bytes for
example.com, 3658 for github.com. Shortening a
chain is a choice today and an outage later.
--per-group adds one TLS 1.3 handshake per key exchange group
— ML-KEM, X25519, P-256, P-384, P-521 — each offering that group and
nothing else, in sequence:
$ pqprobe probe --per-group github.com
OK groups accepted: X25519, P-256 · declined with an alert: X25519MLKEM768, P-384, P-521
It is a report, not a grade: no real client offers a single group, so the
map never moves the class. What it answers is which group a
migration can be planned against — and the two refusals stay apart here
too, since cut off on the hybrid group is a size symptom
rather than a policy.
Go's defaults change between releases — that is how X25519MLKEM768
became a default in the first place. A profile that inherited them would
prove something different after every toolchain upgrade, which is
indistinguishable from the endpoint having changed.
$ pqprobe profiles
classic TLS 1.3 offering only classical groups (X25519, P-256)
groups: X25519, P-256
clients: curl, openssl s_client, any pre-2024 client, and every health check you already run
pq-preferred TLS 1.3 offering hybrid ML-KEM first, with X25519 and P-256 behind it
groups: X25519MLKEM768, X25519, P-256
clients: Chrome and Edge 131+, Firefox 132+, CloudFront and other CDNs with post-quantum enabled, Go 1.24+, OpenSSL 3.5+
pq-only TLS 1.3 offering only hybrid ML-KEM — no classical fallback
groups: X25519MLKEM768
clients: a client with post-quantum required, and the default of the next few years
Install
Go 1.25 or newer. X25519MLKEM768 comes from the standard library, which is why the tool has no dependencies at all.
A cask: brew install --cask Allan-Nava/tap/pqprobe hands over the prebuilt binary from the release — no Go, no compile. Generated by goreleaser on every tag.
ghcr.io/allan-nava/pqprobe is scratch plus the binary and the CA bundle — no shell, multi-arch, with a provenance attestation and smoke-tested after it is pushed.
uses: Allan-Nava/pqprobe@vX.Y.Z — a composite action that installs the binary, writes the markdown report to the job summary and fails the step only on exit-on.
Outbound TCP to the endpoints being probed, and nothing else. No agent on the targets, no credentials, no configuration file.
# Homebrew: a prebuilt binary, no Go needed
brew install --cask Allan-Nava/tap/pqprobe
# the released binary
go install github.com/Allan-Nava/pqprobe/cmd/pqprobe@latest
# from source
git clone https://github.com/Allan-Nava/pqprobe
cd pqprobe && go build -o pqprobe ./cmd/pqprobe
./pqprobe probe example.com
# the published image: multi-arch, attested, nothing but the binary
docker run --rm ghcr.io/allan-nava/pqprobe:latest probe example.com
# or build it yourself
docker build -t pqprobe .
docker run --rm pqprobe probe example.com
Usage
pqprobe probe <target>... [flags]
pqprobe profiles
pqprobe explain [class|topic] # what a class or a reported-only word means; no network call
pqprobe completion bash|zsh|fish # generated from the flag set, never hand-kept
pqprobe man # the man page, in roff
pqprobe version [--short] # --short: the version alone, for embedding
| Form | Meaning |
|---|---|
example.com | port 443 assumed |
example.com:8443 | explicit port |
https://example.com/path | the path is ignored — pqprobe sends no request |
10.0.0.5=origin.example.com | dial the address, send that server name |
The last form is the one that finds real problems: it is what a CDN does, and it is how you probe one node of a pool that is fronted by a single name.
| Flag | Default | What it does |
|---|---|---|
--profile a,b | classic,pq-preferred,pq-only | client profiles to dial |
--per-group | — | also dial each key exchange group on its own, and report the accepted set |
--per-address | — | probe every A/AAAA record of each name, by address, still sending the name |
--size-sweep | — | grow the ClientHello in steps and report the size at which the peer stops answering |
--alpn-check | — | dial the same client with h2,http/1.1 too, and report when the ALPN bytes change the answer |
--groups a,b | — | also dial exactly this key exchange group set, in this order |
--inventory FILE | — | Ansible INI inventory to take hosts from |
--group g,h | all | restrict to these inventory groups |
--list FILE | — | flat list of targets, one per line |
--port N | 443 | default port for targets written without one |
--sni NAME | — | server name for every target |
--alpn a,b | none | ALPN protocols to offer |
--starttls PROTO | — | upgrade to TLS through the protocol's own negotiation first: smtp, imap, postgres, mysql, ftp, nntp, ldap, xmpp |
--ech | — | also dial with Encrypted Client Hello, taking each endpoint's config from the ech= parameter of its HTTPS DNS record |
--dns HOST:PORT | this machine's | resolver for every lookup the run makes — target names, --per-address records and the ECH record; the report says so, because a run resolved somewhere else probed something else |
--ech-config BASE64 | — | also dial the same client offering Encrypted Client Hello with this ECHConfigList — the ech= value of the endpoint's HTTPS DNS record; reported, never graded |
--net tcp4|tcp6 | both | pin the address family every connection uses; the family is stated in the report, and one excluded here is never a grade against the endpoint |
--socks5 HOST:PORT | — | reach every endpoint through a no-auth SOCKS5 proxy (HTTP CONNECT is a request and is not supported) |
--timeout D | 10s | per-handshake timeout |
--confirm | on | re-dial an abrupt failure once before believing it (--confirm=false to dial once) |
--concurrency N | 8 | endpoints in flight (the profiles of one endpoint stay sequential) |
--textfile FILE | — | also write Prometheus textfile-collector metrics to FILE, replaced atomically |
--watch D | — | re-probe every D and print only the transitions (minimum 5s, text output only) |
--markdown | — | a table and collapsible detail, for a pull request comment or a CI job summary |
--baseline FILE | — | compare against a previous --json run and report the transitions |
--json | — | full report, every per-profile result included |
--findings | — | flat findings array |
--findings=wrapped | — | the wrapped object a fleet aggregator consumes, with a stable id per finding (note the =) |
--min-severity S | — | hide findings below S; the endpoint header stays |
--exit-on S|class | never | exit 1 when a finding reaches S, or when an endpoint lands in exactly that class — --exit-on BAD also fires on an expiring certificate, which is rarely what a pipeline meant |
--expiry-warn N | 21 | certificate expiry WARN threshold, days |
--expiry-bad N | 7 | certificate expiry BAD threshold, days |
| Code | Meaning |
|---|---|
0 | the probe ran — findings are output, not an error |
1 | --exit-on threshold reached |
2 | usage error, or no target could be parsed |
--exit-on opts into failing.
pqprobe probe --inventory inventory/edge --group edge \
--findings --min-severity WARN --exit-on BAD > findings.json
# or against yesterday: only what changed
pqprobe probe --inventory inventory/edge --baseline yesterday.json --exit-on BAD
--findings is the flat array the sibling tools speak: one
object per finding, with check, target,
status, message and hint, plus
value and unit wherever there is a number — so a
machine consumer never has to parse the prose. An empty run emits
[], never null.
--findings=wrapped is the shape a fleet aggregator consumes —
{check, status, summary, findings:[{id, severity, title, detail}]}
— and the id is why it exists: it fingerprints the same
problem on the same target across runs, built from the check and the
target and never from the message, which carries days and byte counts that
change on their own.
A fleet
$ pqprobe probe --inventory ansible/inventory/edge --group edge --findings | jq '.[0]'
{
"check": "verdict",
"target": "10.11.10.5:443",
"status": "BAD",
"message": "pq-intolerant — post-quantum-capable clients cannot connect at all, while classical clients can",
"hint": "…"
}
ansible_host= wins over the inventory alias, because the alias frequently does not resolve outside the control node.[group:vars] is never read as hosts. (Reading it is how a probe list acquires an endpoint called ansible_user.)1.2.3.4=origin.example.com dials the address while sending that server name — the only way to reproduce a CDN-only failure from a workstation, and the way to find the one node out of six that is broken.--per-address probes every A/AAAA record of a name, by address, and one addresses finding says whether the pool agrees — one bad node out of six is invisible to a name-only probe.Real output over three public endpoints, September 2026:
$ pqprobe probe example.com github.com google.com
WARN github.com:443 pq-blind
WARN verdict pq-blind — no post-quantum support, but post-quantum-capable clients still connect on a classical group
WARN handshake/pq-only no handshake (alert): remote error: tls: handshake failure
OK handshake/pq-preferred TLS 1.3, X25519, TLS_AES_128_GCM_SHA256
OK example.com:443 pq-ready
OK google.com:443 pq-ready
3 endpoint(s): 1 pq-blind, 2 pq-ready · worst: 0 ERROR, 0 BAD, 1 WARN, 2 OK
Findings reference
Every run emits findings: check, target,
status, message, an optional
value/unit, and a hint that says
what to do. Worst first, in every renderer.
| Status | Meaning |
|---|---|
| OK | the statement is fine |
| WARN | works today, with something to plan |
| BAD | a class of client cannot connect |
| ERROR | the probe could not run — nothing below it can be concluded |
ERROR sorts above BAD on purpose:
an endpoint that was never reached is not an endpoint that passed, and an
operator has to see it first.
| Check | Target | What it says |
|---|---|---|
handshake | host:port/profile | one attempt: negotiated version, group, cipher, ALPN, the measured ClientHello size and elapsed ms — or how it failed |
verdict | host:port | the class, with the affected clients named in the hint |
groups | host:port | with --per-group: which groups the peer accepted alone, and how it refused the others |
expiry | host:port | days to leaf expiry (--expiry-warn, --expiry-bad) |
chain | host:port | the chain does not verify, or the peer sent the leaf alone |
chain-size | host:port | what the certificate chain costs on the wire — the headroom for post-quantum authentication |
client-auth | host:port | the peer requested a client certificate: this endpoint is mutual TLS |
addresses | the name | with --per-address: how many addresses the name has, and which one answers differently |
transition | host:port | with --baseline: the class changed since a stored run, or the endpoint is new or gone |
size-limit | host:port | with --size-sweep: the ClientHello sizes the peer answered and the first it did not |
alpn | host:port | with --alpn-check: whether offering h2,http/1.1 changes the answer |
tls-version | host:port | TLS 1.3 did not complete while 1.2 did |
A failed handshake is a WARN on its own, never a
BAD: whether it matters is the verdict's job to say, and a
per-profile BAD would count the same fact twice.
Safety
No request, no body, no credentials, no application data.
With --starttls it also sends that protocol's negotiation —
EHLO, STARTTLS, or Postgres's eight-byte
SSLRequest — and nothing more, because without it those ports
cannot be probed at all.
There is nothing in the binary that can change state on the far side, which
is what makes it reasonable to point at production. This is enforced, not
promised: go.mod has no require block, and CI
fails the build on an import of net/http or
os/exec.
A static Go binary built from the standard library. Auditable, and small enough to read.
Nothing is shelled out to. The scratch image has no shell to shell out to either.
Three handshakes per endpoint by default, sequential within an endpoint, bounded across the fleet.
Scope
None of these is a gap waiting to be filled. Each one is a decision — see INTENT.md.
| Not… | Because | Use instead |
|---|---|---|
| a TLS scanner | no cipher enumeration, no configuration grade, no CVE chasing | testssl.sh, sslyze |
| a certificate monitor | expiry is reported because the certificates are already in hand; lifecycle is a different job | checkfleet |
| a load generator | a handful of connections per endpoint and no request at all | crowdsim |
| a fingerprinting tool | Go's crypto/tls cannot reproduce a browser's ClientHello, and no output may imply it can | capability classes, above |
| a monitoring system | no time series, no dashboard, no alert routing; it emits findings | whatever already pages you |
The repository
Why the tool exists, the goals in priority order, and the non-goals as decisions rather than gaps.
What is missing. The single source of planned work; every item has a stable PQ-n id.
The generated view of the backlog, grouped by milestone. CI fails when the two disagree.
What changed. Keep a Changelog, one section per tagged vX.Y.Z.
How work happens here — operating rules and known traps, for people and for AI agents.
go test -race ./... — the suite includes a server that dies on a large ClientHello, so the real failure reproduces offline.
Everything that can be a script is one, and CI runs all of them: the roadmap and the GitHub issues are generated from the backlog, this page is link-gated, the PNGs are checksummed against their SVGs, the About box is data, and a release is a tag — archives for six platforms, SHA256SUMS, a provenance attestation, the ghcr.io image, and notes lifted from the changelog.
go test ./... # includes a server that dies on a large ClientHello
go test -race ./...
./scripts/backlog.sh lint && ./scripts/backlog.sh check
./scripts/docs.sh check # no dead link in the site or the Markdown
./scripts/repo-meta.sh lint
sh scripts/render-assets.sh --check
# every commit is a version: gates, changelog section, one commit, one tag
./scripts/release.sh 0.2.0 --commit
./scripts/version.sh check # HEAD is the version the changelog names