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 CDN
Hybrid post-quantum key exchange (X25519MLKEM768) is now the
default in Chrome, Edge and Firefox, in Go 1.24+, in OpenSSL 3.5+ and at
several CDNs. The ML-KEM key share is roughly 1.2 KB, so
the ClientHello carrying it no longer fits one TCP segment. 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 |
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 |
tls-broken | ERROR | the port answered and no profile completed a handshake | not a post-quantum finding at all |
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.
--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.
The tap is this repository. The formula clones the tag and builds from source, so one formula covers macOS and Linux on Intel and ARM.
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: the tap is this repository
brew tap allan-nava/pqprobe https://github.com/Allan-Nava/pqprobe
brew install 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] # what a class means; no network call
pqprobe version
| 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 |
--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) |
--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 |
--min-severity S | — | hide findings below S; the endpoint header stays |
--exit-on S | never | exit 1 when a finding reaches S |
--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.
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 |
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.
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