pqprobe

module
v0.46.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 6, 2026 License: MIT

README

pqprobe

CI Docs Go Dependencies


pqprobe asks one question about a TLS endpoint: which classes of client can still complete a handshake with it, now that post-quantum key exchange is on by default in browsers and CDNs. One static Go binary, no dependencies, no application data ever sent.

The interesting answer is never a single failure — it is 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

X25519MLKEM768 is hybrid: an X25519 key share and an ML-KEM-768 one, so the session survives if either holds. The ML-KEM share is 1216 bytes, which takes the ClientHello from ~270 bytes to ~1500 — just past what fits a single TCP segment on a standard MTU. Thirty years of ClientHellos fitted one. This one does not, and that is the whole story: why it breaks things.

That endpoint is up. curl is happy, the load balancer's health check is green, the origin's own logs show 200s — and every request arriving through a CDN with post-quantum enabled fails, because the CDN's ClientHello carries a ~1.2 KB ML-KEM key share and something on the path cannot cope with it. This tool exists because that outage happened, was diagnosed as an application problem for several hours, and would have been a one-line answer with a probe that dials like the CDN does.

What it actually does

It dials the same endpoint several times with deliberately different client shapes, and reads the shape of the refusal:

The peer… pqprobe calls it Why it matters
completes the handshake on a hybrid group pq-ready done; re-check after TLS stack changes
falls back to X25519 when offered both pq-blind works today, breaks the day a client requires ML-KEM
sends a TLS alert to a hybrid hello pq-refusing it parsed and declined: a policy or pinned group list
resets, times out or vanishes pq-intolerant it choked on the hello: an outage waiting for a CDN default
serves TLS 1.2 and nothing newer no-tls13 post-quantum key exchange is a 1.3 feature; a ceiling, not a setting
does post-quantum in a group no browser sends pq-other-hybrid not broken — a FIPS-shaped stack: --per-group names the hybrid it took
does post-quantum in a group no browser sends pq-other-hybrid not broken — a FIPS-shaped stack; --per-group names the hybrid it took
will not upgrade to TLS no-tls not a grade — it refused TLS, not post-quantum clients (--starttls)
wants a client certificate mtls-required not a grade — it refused the prober, not post-quantum clients
answers nothing unreachable not a grade — fix reachability first

--size-sweep turns the size argument into a number: it grows the hello in steps and reports the bracket — answered up to 3080 B and stopped answering at 4100 B — measured on the wire, with the padding method stated, because that is what makes it quotable.

An abrupt failure is dialled a second time before any of this is decided: pq-intolerant is the finding somebody takes to a vendor, and one reset is also what a drained node looks like. Both dials cut off reads as reproduced; cut off then connected reads as flapping, not walled, and never as BAD. An alert is never re-dialled — it is an answer.

The alert-versus-reset distinction is the whole tool. A peer that says no politely is negotiating; a peer that disappears mid-hello is broken for every client that offers ML-KEM, whether or not that client would have been perfectly happy with a classical group.

Client 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

tls13-only and tls12 are there too, for the version edges. Every profile pins its own group list and version window, so upgrading the Go toolchain can never quietly change what a run proves.

--per-group answers the next question — which group, not whether some hybrid handshake worked — with one TLS 1.3 handshake per group, 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.

A profile is a capability class, never a fingerprint. pqprobe builds its ClientHello with Go's crypto/tls: it cannot reproduce Chrome's extension order, and it never claims to. What it 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 above are there so a report can say who is affected; nothing branches on them.

A fleet, from the inventory you already have

$ 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 does that automatically for every A/AAAA record of a name, and one addresses finding says whether the pool agrees: 7 addresses disagree: 6 pq-ready, 1 unreachable.
  • --net tcp4|tcp6 pins the address family. Unpinned, a dual-stack name is graded on whichever address the resolver handed over that minute, and two runs can disagree with nothing having changed on the endpoint. The family the run used is stated in the report, because a run that could only use IPv4 and says nothing reads afterwards as "IPv6 is fine" — and a family excluded here is unroutable, never a grade against the peer.

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

pqprobe explain pq-intolerant prints the same knowledge without a run — meaning, affected clients, next action — which is the version you want at 03:00, when reproducing the failure to find out what the word meant is not an option.

Install

# Homebrew — a prebuilt binary, no Go needed
brew install --cask Allan-Nava/tap/pqprobe

# Go
go install github.com/Allan-Nava/pqprobe/cmd/pqprobe@latest

# Docker — scratch plus the binary and the CA bundle, multi-arch, attested
docker run --rm ghcr.io/allan-nava/pqprobe:latest probe example.com

Or build it yourself:

go build -o pqprobe ./cmd/pqprobe
docker build -t pqprobe . && docker run --rm pqprobe probe example.com

Embedding it

Every package is under internal/, so the importable surface is pq/ — deliberately small, and nothing internal leaks through it:

reps, err := pq.Probe(ctx, []string{"origin.example.com", "10.0.0.5=origin.example.com"}, pq.Options{})
for _, r := range reps {
    fmt.Println(r.Target, r.Class, r.Worst) // origin.example.com:443 pq-intolerant BAD
}

An unreachable target is a report with class unreachable, never an error: a fleet check keeps going and names the node that is down — and so is a target that could not be parsed, which used to vanish from the result and leave a check reporting on nine nodes out of ten while looking complete. pq.Explain(class) gives the meaning, the affected clients and the next action without a run.

Output and exit status

Flag Output
(none) text, worst endpoint first, hint on its own line
--json everything, including every per-profile handshake result
--findings the flat findings array — one object per finding, empty array never null
--findings=wrapped the wrapped object a fleet aggregator consumes: {check, status, summary, findings:[{id, severity, title, detail}]}, with a stable id per finding so the same problem can be recognised across runs
--markdown a table and collapsible detail, for a PR comment or a CI job summary
--textfile F Prometheus textfile-collector metrics, written atomically (a side output, not a renderer)
--min-severity S hide findings below S; the endpoint header stays
Exit Meaning
0 the probe ran — findings are output, not an error
1 --exit-on was given and matched: a status threshold reached, or an endpoint in exactly the named class
2 usage 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.

What it is not

  • Not a TLS scanner. It does not enumerate cipher suites, grade configurations or chase CVEs — testssl.sh and sslyze do that well. pqprobe answers one question they do not ask.

  • Not a certificate monitor. It reports leaf expiry and a leaf-only chain because it has them in hand; certificate lifecycle belongs in checkfleet.

  • Not a load generator. It opens a handful of connections per endpoint and sends no request. Traffic belongs in crowdsim.

  • Not a fingerprinting tool — in the default binary. Capability classes, not ClientHello signatures, and no output implies otherwise. If you do want the narrower question ("would Chrome 131 connect?"), it lives in contrib/utls as a separate module and a separate binary, because uTLS is a dependency and this one has none:

    $ pqprobe-utls example.com
    OK    chrome    Chrome 131 (post-quantum by default)   TLS 1.3, X25519MLKEM768, hello 1721 B
    OK    firefox   Firefox 120+                           TLS 1.3, X25519, hello 659 B
    

    Read the two together: that tool cannot tell you which class of client is affected, and this one cannot tell you that a specific browser build fails.

  • Not HTTP/3 — in the default binary. The same question over QUIC lives in contrib/quic, for the same reason: a QUIC stack is a dependency. It is worth asking separately, because the failure is quieter — the ClientHello has to fit QUIC's Initial packet, and when something on the path cannot carry it UDP has no reset to send, so the handshake just never completes:

    $ pqprobe-quic cloudflare.com
    OK    pq-only       TLS 1.3, X25519MLKEM768, alpn h3
    

Safety

pqprobe completes a TLS handshake and closes the connection. No request, no body, no credentials, no application data — with --starttls it also sends that protocol's negotiation (EHLO, STARTTLS, MySQL's 32-byte SSLRequest stopping where the login would start, or Postgres's eight-byte SSLRequest) and nothing more, because without it those ports cannot be probed at all — there is nothing in it that can change state on the far side, which is what makes it safe to point at production. The certificate chain is verified locally, from the certificates the peer sent, and never with the trust store deciding whether the handshake "worked".

Development

go test ./...            # includes a server that dies on a large ClientHello
go test -race ./...
./scripts/backlog.sh lint && ./scripts/backlog.sh check

Documentation: https://allan-nava.github.io/pqprobe/ — one static page, generated by nobody, gated by ./scripts/docs.sh check.

Everything else that can be a script is one, and CI runs all of them:

Script What it keeps true
scripts/backlog.sh ROADMAP.md matches BACKLOG.md, and the GitHub issues match both
scripts/docs.sh no dead link in the site or the Markdown
scripts/render-assets.sh the committed PNGs are in step with their SVGs
scripts/repo-meta.sh the GitHub description, homepage and topics are data in .github/repo-meta
scripts/release-notes.sh a release's notes come out of CHANGELOG.md, never retyped
scripts/release.sh a release runs every gate, tags — and never pushes
scripts/version.sh every commit is a tagged vX.Y.Z the CHANGELOG names
scripts/goreleaser.sh the release config, and the cask it publishes: static build, tap token, quarantine stripped, prereleases skipped
scripts/seo.sh the sitemap, robots.txt and llms.txt agree with the page
scripts/action.sh action.yml is valid, with no expression inside a run block

Each of those has a fixture test that runs in CI — backlog_test.sh, backlog_issues_test.sh, docs_test.sh, assets_test.sh, repo-meta_test.sh, release_test.sh, version_test.sh, goreleaser_test.sh, seo_test.sh, action_test.sh — because a gate nobody tests is a gate that passes for the wrong reason.

Every commit ships as a tagged version. scripts/release.sh <X.Y.Z> --commit is how a change lands: one commit, one vX.Y.Z tag, its own dated CHANGELOG section. That is what makes the changelog the dated history of the tool rather than of the code.

BACKLOG.md is the single source of truth for planned work and ROADMAP.md is generated from it. Why the tool exists and what is deliberately out of scope: INTENT.md. Contributor brief: AGENTS.md.

License

MIT — see LICENSE.

Directories

Path Synopsis
cmd
pqprobe command
Command pqprobe answers one question about a TLS endpoint: which classes of client can still complete a handshake with it, now that post-quantum key exchange is on by default in browsers and CDNs.
Command pqprobe answers one question about a TLS endpoint: which classes of client can still complete a handshake with it, now that post-quantum key exchange is on by default in browsers and CDNs.
internal
clientprofile
Package clientprofile defines the client shapes pqprobe dials with.
Package clientprofile defines the client shapes pqprobe dials with.
finding
Package finding is the result model: one Finding is one statement about one target, and the severity order plus the "worst first" sort are the two rules every renderer obeys.
Package finding is the result model: one Finding is one statement about one target, and the severity order plus the "worst first" sort are the two rules every renderer obeys.
inventory
Package inventory reads the list of endpoints to probe.
Package inventory reads the list of endpoints to probe.
output
Package output renders reports.
Package output renders reports.
probe
Package probe dials one endpoint with one client profile and reports what happened, in enough detail to tell two very different refusals apart.
Package probe dials one endpoint with one client profile and reports what happened, in enough detail to tell two very different refusals apart.
verdict
Package verdict turns a set of per-profile handshake results into the two things an operator needs: a class for the endpoint, and findings that say which real clients are affected and why.
Package verdict turns a set of per-profile handshake results into the two things an operator needs: a class for the endpoint, and findings that say which real clients are affected and why.
Package pq is the public surface of pqprobe: strings in, reports out (PQ-14).
Package pq is the public surface of pqprobe: strings in, reports out (PQ-14).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL