Skip to content

Probes and wire format

This page documents pqdoctor 0.2.0’s network behaviour byte for byte (the seven probes, the certificate probes, the HTTP request and SSH are unchanged from 0.1.0; everything else here is new in 0.2.0 and opt-in or on only for one host), for anyone who needs to know exactly what reaches their server: network teams, IDS rule writers, and anyone reviewing the tool.

Every key-exchange probe is a TLS 1.3 ClientHello with the same base. Only the groups, the key share and how it’s sent change.

id supported_groups key_share Sent
tls13Classical X25519, P-256, P-384 X25519 Whole
pqOnly X25519MLKEM768, X25519, P-256 X25519MLKEM768 Whole
pqPreference X25519MLKEM768, X25519, P-256 X25519 Whole
kyberDraft X25519Kyber768Draft00, X25519, P-256 X25519Kyber768Draft00 Whole
pqSplit Same hello as pqOnly X25519MLKEM768 Split
bigClassicalSplit X25519, P-256 X25519, padded to pqOnly’s size Split
nistOnly P-256, P-384 P-256 Whole
Field Value
Record version 0x0301
legacy_version 0x0303
Random, session ID 32 bytes each
supported_versions TLS 1.3, TLS 1.2
Cipher suites (15) 1301 1302 1303 c02b c02f c02c c030 cca9 cca8 c013 c014 009c 009d 002f 0035
signature_algorithms 0403 0804 0401 0503 0805 0501 0806 0601 0807
ALPN h2, http/1.1. Left out under STARTTLS
SNI The host name, lowercased. None for IP addresses
psk_key_exchange_modes psk_dhe_ke

Extension order: server_name, extended_master_secret, renegotiation_info, supported_groups, ec_point_formats, signature_algorithms, ALPN, supported_versions, psk_key_exchange_modes, key_share, then padding last when used.

Group Codepoint Key share bytes
X25519 0x001D 32
P-256 (secp256r1) 0x0017 65
P-384 (secp384r1) 0x0018 97
X25519MLKEM768 0x11EC 1,216: ML-KEM-768 key (1,184) then X25519 (32)
X25519Kyber768Draft00 0x6399 1,216: X25519 (32) then the Kyber key (1,184)

| SecP256r1MLKEM768 | 0x11EB | 1,249: P-256 (65) then ML-KEM-768 (1,184) | | SecP384r1MLKEM1024 | 0x11ED | 1,665: P-384 (97) then ML-KEM-1024 (1,568) | | MLKEM512 / MLKEM768 / MLKEM1024 | 0x0200 / 0x0201 / 0x0202 | 800 / 1,184 / 1,568 |

Codepoints are from IANA’s TLS Supported Groups registry; the share layouts from RFC 10024 (ML-KEM first only in X25519MLKEM768) and draft-ietf-tls-mlkem. The last five are only sent by the group survey.

The key shares are placeholders. The key-exchange probes only read the server’s first reply, so no shared secret is ever computed. The ML-KEM part is a valid encapsulation key with all-zero coefficients and a 32-byte seed, and the classical shares are fixed points. No probe does real cryptography, and none could complete a handshake.

  • bigClassicalSplit gets a padding extension that makes its handshake exactly as long as pqOnly’s (over 1,300 bytes). That’s the control: same size, same split, no PQ group.
  • Split sends the first 200 bytes of the record (including the 5-byte record header), flushes, waits 40 ms, then sends the rest. TCP_NODELAY is set, so the two writes go as separate TCP segments.

Run only for one host by default (and in scans with --auth), and only when tls13Classical got a TLS 1.3 reply. They’re the one place pqdoctor does real cryptography.

Probe 1: “Classical schemes, then ML-DSA” Probe 2: “Only ML-DSA schemes”
supported_versions TLS 1.3 only TLS 1.3 only
Groups and key share X25519, with a real X25519 key Same
Cipher suites TLS_AES_128_GCM_SHA256 only Same
signature_algorithms The common list, then 0904 0905 0906 0904 0905 0906 only

pqdoctor completes the X25519 exchange, runs the TLS 1.3 key schedule, decrypts the server’s Certificate and CertificateVerify, and hangs up. It never sends Finished. The two probes are 120 ms apart, and a response is capped at 512 KB.

On for one host (--no-groups to skip), --groups for scans. One hello per group below, offering only that group in supported_groups and key_share, so a ServerHello that picks it means the server accepts it. Same base as the seven probes, sent whole, one at a time, 120 ms apart, retried once on silence. X25519MLKEM768’s answer is taken from pqOnly, so it’s 5 more connections:

SecP256r1MLKEM768, SecP384r1MLKEM1024, MLKEM512, MLKEM768, MLKEM1024.

A server that only accepts one of these gets pq-other-group. The SecP384r1MLKEM1024 hello is about 1.9 KB.

--slh-dsa: one more certificate probe like probe 2 above, with signature_algorithms = the 12 IANA-registered SLH-DSA schemes 0911–091C. IANA has no composite (ML-DSA + classical) signature codepoints, so none are offered. Merkle Tree Certificates aren’t checked.

No probe of its own for a TLS 1.3 server: it validates the chain certificate probe 1 decrypted. For a TLS 1.2-only server, one hello without supported_versions (TLS 1.2), read up to ServerHelloDone; the Certificate is plaintext there.

pqdoctor quic or --quic: three exchanges on UDP, each a fresh QUIC connection (random 8-byte connection IDs):

supported_groups key_share
Classical X25519, P-256 X25519
PQ only X25519MLKEM768, X25519, P-256 X25519MLKEM768
PQ preference X25519MLKEM768, X25519, P-256 X25519

The ClientHello: TLS 1.3 only, an empty legacy_session_id, suites 1301 1302 1303, ALPN h3, and quic_transport_parameters (extension 57) with max_idle_timeout 30 s, max_udp_payload_size 1472, initial_max_data 1 MiB, stream data 256 KiB, 100 streams each way, and initial_source_connection_id. It goes in CRYPTO frames in Initial packets (4-byte packet numbers, PADDING to exactly 1,200 bytes per datagram), two datagrams for the PQ hello. 3 s per exchange, retried once on a timeout.

pqdoctor dtls or --dtls: three probes on UDP, each from a fresh socket. DTLSPlaintext records (record version 0xFEFF, epoch 0) with handshake fragments of at most 1,100 bytes. legacy_version 0xFEFD, an empty session ID and cookie, no ALPN.

supported_versions Groups / key share
DTLS 1.3 / 1.2 classical 0xFEFC, 0xFEFD X25519, P-256, P-384 / X25519
DTLS 1.3 PQ 0xFEFC, 0xFEFD X25519MLKEM768, X25519, P-256 / X25519MLKEM768
DTLS 1.2 only — X25519, P-256, P-384

A HelloVerifyRequest or a HelloRetryRequest cookie is answered once, with the same random. 4 s per probe.

--audit, one at a time, 150 ms apart, retried once on silence:

  • 5 version probes: legacy_version 0x0300 (record 0x0300), 0x0301, 0x0302 and 0x0303 without supported_versions, and TLS 1.3 through supported_versions.
  • 51 suite probes: one TLS 1.2 suite each (see the list), read up to ServerKeyExchange / ServerHelloDone.
  • 1 preference probe: the weakest and strongest accepted suites, weakest first.

Only ClientHellos. No exploit, padding-oracle, Heartbleed or renegotiation bytes.

--middlebox: 8 connections, see the variants. The splits are 40 ms apart with TCP_NODELAY.

With --proxy, every TCP connection above first does CONNECT host:port HTTP/1.1 (http) or a SOCKS5 CONNECT (RFC 1928, with RFC 1929 credentials) to the proxy. The TLS bytes after it are unchanged. QUIC and DTLS don’t use the proxy.

Value
Connect timeout 5 s
Read timeout 5 s, one deadline from after the hello is written
Gap between probes (and between addresses) 120 ms
Retry Once, after 700 ms, for a probe with no reply (timeout, reset, closed). A retry’s reply is kept only if it isn’t silent
Stop early If the first probe can’t connect, the rest are skipped
Not TLS Over 64 KiB without a ServerHello
STARTTLS upgrade 10 s deadline. One upgrade first, then again before every probe

One plain-HTTP request on port 80, to tailor the fixes:

HEAD / HTTP/1.1
Host: example.com
User-Agent: pqdoctor/0.2.0 (post-quantum TLS check; handshake-only)
Connection: close
  • 3 s connect and read timeouts. Up to 8 KiB is read, stopping at the end of the headers.
  • --contact C adds ; contact: C inside the parentheses. --user-agent replaces it. A value with a line break is refused.
  • Skipped for IP addresses, with --no-http, under STARTTLS, and for any address but the first.
Protocol Commands sent
SMTP EHLO pqdoctor.invalid, then STARTTLS
IMAP pq1 STARTTLS
POP3 STLS
FTP (0.2.0) AUTH TLS → 234 (RFC 4217)
XMPP (0.2.0) A jabber:client stream header to the host name, then <starttls xmlns='urn:ietf:params:xml:ns:xmpp-tls'/> → <proceed/>
LDAP (0.2.0) ExtendedRequest 1.3.6.1.4.1.1466.20037, message ID 1 (31 bytes of BER) → resultCode 0
PostgreSQL (0.2.0) SSLRequest (length 8, code 80877103) → S
MySQL (0.2.0) Reads the server’s handshake, checks CLIENT_SSL, sends a 32-byte SSLRequest (sequence 1)
RDP (0.2.0) TPKT + X.224 Connection Request with RDP_NEG_REQ, PROTOCOL_SSL | PROTOCOL_HYBRID → Connection Confirm; TLS follows (CredSSP is never reached)

Picked by port when --starttls isn’t given: 25 and 587 SMTP, 143 IMAP, 110 POP3, and in the CLI from 0.2.0 21 FTP, 5222 XMPP, 389 LDAP, 5432 PostgreSQL, 3306 MySQL, 3389 RDP. Each reads exactly the go-ahead’s bytes, so the next byte is the server’s TLS reply.

pqdoctor sends SSH-2.0-pqdoctor_0.2.0, reads the server’s identification and its first binary packet (KEXINIT), and closes the connection. Limits: 8 KiB of banner lines before the identification, a 255-byte identification, packets up to 256 KiB, and up to 8 IGNORE or DEBUG packets skipped. 5 s connect, read and write timeouts. Only the first address is checked.

Value
Addresses per host Up to 6, IPv4 first, duplicates removed. Single-host checks only
Scan concurrency Default 4, max 32
Scan rate Default 2 new hosts a second, max 20
Probes per host One at a time, always

To recognise or filter pqdoctor on your network:

  • The User-Agent on port 80 starts with pqdoctor/.
  • The SSH client identification is SSH-2.0-pqdoctor_<version>.
  • From one source, a default single-host check is up to 7 TLS connections (plus up to 7 retries), 5 group-survey connections, 2 certificate connections and 1 HTTP request, all within a few seconds. --audit adds about 57, --middlebox 8, --slh-dsa 1.
  • QUIC Initials carry ALPN h3 and pqdoctor’s transport parameters; DTLS hellos carry no ALPN.