pqprobe

pqprobe

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.

CI status MIT licence Go 1.25+ No dependencies
install
$ go install github.com/Allan-Nava/pqprobe/cmd/pqprobe@latest
$ pqprobe probe origin.example.com

The question it answers

An endpoint can be up for 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:

The asymmetry is the diagnosis, and it takes about two seconds to see once something dials both ways:

the asymmetry
$ 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
An abrupt failure is dialled twice before it is believed. 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.
The alert-versus-reset distinction is the whole tool. A peer that sends a TLS alert parsed your hello and declined a group: a setting, a pinned group list, a negotiation that worked. A peer that resets, times out or vanishes choked on the hello itself, and is broken for every client that so much as offers ML-KEM — however happy that client would have been with X25519.

What it reports

One class per endpoint, and what to do about it

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.

ClassStatusWhat the peer didWhat it means for you
pq-readyOKcompleted hybrid key exchange, including for a client that requires itdone — re-check after TLS stack changes
pq-capableWARNnegotiates ML-KEM when offered, but the pq-only profile did not completefine today; a post-quantum-required client is not served
pq-blindWARNno ML-KEM, but falls back cleanly to a classical groupworks today, breaks the day a client requires post-quantum
pq-refusingBADsent a TLS alert to a hybrid hello that also offered X25519a policy or a pinned group list — look at the configuration
pq-intolerantBADreset, timed out or vanished mid-helloan outage waiting for a CDN to flip a default — look at the path
no-tls13WARNserves TLS 1.2 and nothing newerpost-quantum key exchange is a 1.3 feature: a ceiling, not a setting
mtls-requiredERRORrequested a client certificate, and no handshake survived itnot a grade — it refused the prober, not post-quantum clients
unreachableERRORanswered nothingnot a grade — fix reachability first
tls-brokenERRORthe port answered and no profile completed a handshakenot a post-quantum finding at all

pq-refusing — read the config

The 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 path

No 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

A capability class, never a fingerprint

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.

ProfileGroupsVersionsStands for
classicX25519, P-2561.2–1.3curl, openssl s_client, any pre-2024 client, every health check you already run
pq-preferredX25519MLKEM768, X25519, P-2561.2–1.3Chrome/Edge 131+, Firefox 132+, CDNs with post-quantum enabled, Go 1.24+, OpenSSL 3.5+
pq-onlyX25519MLKEM7681.3a client with post-quantum required — the default of the next few years
tls13-onlyX25519, P-2561.3a modern client with TLS 1.2 disabled
tls12X25519, P-2561.2old 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.

Why pq-preferred is the profile that matters

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

The set you are migrating to

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

Which group, not whether some group

--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:

the group map
$ 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.

Why every profile pins its own groups and versions

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
$ 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

One binary, and nothing to configure

Go

Go 1.25 or newer. X25519MLKEM768 comes from the standard library, which is why the tool has no dependencies at all.

Homebrew

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.

Docker

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.

GitHub Action

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.

Requirements

Outbound TCP to the endpoints being probed, and nothing else. No agent on the targets, no credentials, no configuration file.

three ways
# 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

Commands, targets, flags, exit status

synopsis
pqprobe probe <target>... [flags]
pqprobe profiles
pqprobe explain [class]   # what a class means; no network call
pqprobe version

Targets

FormMeaning
example.comport 443 assumed
example.com:8443explicit port
https://example.com/paththe path is ignored — pqprobe sends no request
10.0.0.5=origin.example.comdial 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.

Flags

FlagDefaultWhat it does
--profile a,bclassic,pq-preferred,pq-onlyclient profiles to dial
--per-groupalso dial each key exchange group on its own, and report the accepted set
--per-addressprobe every A/AAAA record of each name, by address, still sending the name
--size-sweepgrow the ClientHello in steps and report the size at which the peer stops answering
--alpn-checkdial the same client with h2,http/1.1 too, and report when the ALPN bytes change the answer
--groups a,balso dial exactly this key exchange group set, in this order
--inventory FILEAnsible INI inventory to take hosts from
--group g,hallrestrict to these inventory groups
--list FILEflat list of targets, one per line
--port N443default port for targets written without one
--sni NAMEserver name for every target
--alpn a,bnoneALPN protocols to offer
--socks5 HOST:PORTreach every endpoint through a no-auth SOCKS5 proxy (HTTP CONNECT is a request and is not supported)
--timeout D10sper-handshake timeout
--confirmonre-dial an abrupt failure once before believing it (--confirm=false to dial once)
--concurrency N8endpoints in flight (the profiles of one endpoint stay sequential)
--watch Dre-probe every D and print only the transitions (minimum 5s, text output only)
--markdowna table and collapsible detail, for a pull request comment or a CI job summary
--baseline FILEcompare against a previous --json run and report the transitions
--jsonfull report, every per-profile result included
--findingsflat findings array
--min-severity Shide findings below S; the endpoint header stays
--exit-on Sneverexit 1 when a finding reaches S
--expiry-warn N21certificate expiry WARN threshold, days
--expiry-bad N7certificate expiry BAD threshold, days

Exit status

CodeMeaning
0the probe ran — findings are output, not an error
1--exit-on threshold reached
2usage error, or no target could be parsed
Exit 0 on a WARN is deliberate. A check that fails the pipeline on every deviation is a check people learn to ignore. Only --exit-on opts into failing.

In a scheduled job

cron / CI
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

From the inventory you already have

one finding, as JSON
$ 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": "…"
}

Real output over three public endpoints, September 2026:

three endpoints
$ 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 check, every status, worst first

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.

Statuses

StatusMeaning
OKthe statement is fine
WARNworks today, with something to plan
BADa class of client cannot connect
ERRORthe 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.

Checks

CheckTargetWhat it says
handshakehost:port/profileone attempt: negotiated version, group, cipher, ALPN, the measured ClientHello size and elapsed ms — or how it failed
verdicthost:portthe class, with the affected clients named in the hint
groupshost:portwith --per-group: which groups the peer accepted alone, and how it refused the others
expiryhost:portdays to leaf expiry (--expiry-warn, --expiry-bad)
chainhost:portthe chain does not verify, or the peer sent the leaf alone
client-authhost:portthe peer requested a client certificate: this endpoint is mutual TLS
addressesthe namewith --per-address: how many addresses the name has, and which one answers differently
transitionhost:portwith --baseline: the class changed since a stored run, or the endpoint is new or gone
size-limithost:portwith --size-sweep: the ClientHello sizes the peer answered and the first it did not
alpnhost:portwith --alpn-check: whether offering h2,http/1.1 changes the answer
tls-versionhost:portTLS 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.

Capability and certificate stay separate. The dialler never verifies; the chain is verified afterwards from the certificates the peer sent. An expired certificate is reported as an expiry, never as "this endpoint refuses post-quantum clients" — and a capability answer never depends on the local trust store.

Safety

It handshakes, and it closes

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.

Zero dependencies

A static Go binary built from the standard library. Auditable, and small enough to read.

No subprocesses

Nothing is shelled out to. The scratch image has no shell to shell out to either.

A handful of connections

Three handshakes per endpoint by default, sequential within an endpoint, bounded across the fleet.

Scope

What it is not

None of these is a gap waiting to be filled. Each one is a decision — see INTENT.md.

Not…BecauseUse instead
a TLS scannerno cipher enumeration, no configuration grade, no CVE chasingtestssl.sh, sslyze
a certificate monitorexpiry is reported because the certificates are already in hand; lifecycle is a different jobcheckfleet
a load generatora handful of connections per endpoint and no request at allcrowdsim
a fingerprinting toolGo's crypto/tls cannot reproduce a browser's ClientHello, and no output may imply it cancapability classes, above
a monitoring systemno time series, no dashboard, no alert routing; it emits findingswhatever already pages you

What pqprobe deliberately does not tell you

The repository

Where everything is written down

INTENT.md

Why the tool exists, the goals in priority order, and the non-goals as decisions rather than gaps.

BACKLOG.md

What is missing. The single source of planned work; every item has a stable PQ-n id.

ROADMAP.md

The generated view of the backlog, grouped by milestone. CI fails when the two disagree.

CHANGELOG.md

What changed. Keep a Changelog, one section per tagged vX.Y.Z.

AGENTS.md

How work happens here — operating rules and known traps, for people and for AI agents.

Development

go test -race ./... — the suite includes a server that dies on a large ClientHello, so the real failure reproduces offline.

Automated

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.

working on it
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