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.
The seven ClientHellos
Section titled “The seven ClientHellos”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 |
The common base
Section titled “The common base”| 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.
Groups and key shares
Section titled “Groups and key shares”| 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.
Padding and splitting
Section titled “Padding and splitting”bigClassicalSplitgets a padding extension that makes its handshake exactly as long aspqOnly’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_NODELAYis set, so the two writes go as separate TCP segments.
The certificate probes
Section titled “The certificate probes”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.
Group survey
Section titled “Group survey”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 probe
Section titled “SLH-DSA probe”--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_version0x0300(record0x0300),0x0301,0x0302and0x0303withoutsupported_versions, and TLS 1.3 throughsupported_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
Section titled “Middlebox”--middlebox: 8 connections, see the variants. The splits are 40 ms apart with TCP_NODELAY.
Proxies
Section titled “Proxies”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.
Timing and retries
Section titled “Timing and retries”| 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 |
The HTTP request
Section titled “The HTTP request”One plain-HTTP request on port 80, to tailor the fixes:
HEAD / HTTP/1.1Host: example.comUser-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 Cadds; contact: Cinside the parentheses.--user-agentreplaces 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.
STARTTLS
Section titled “STARTTLS”| 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.
Limits
Section titled “Limits”| 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 |
Identifying pqdoctor traffic
Section titled “Identifying pqdoctor traffic”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.
--auditadds about 57,--middlebox8,--slh-dsa1. - QUIC Initials carry ALPN
h3and pqdoctor’s transport parameters; DTLS hellos carry no ALPN.