Background
CertMonitor currently has no visibility into post-quantum cryptography in TLS handshakes or certificates. As hybrid PQ key exchange (RFC 9794 family — X25519MLKEM768, SecP256r1MLKEM768, etc.) is now widely deployed by major CDNs and browsers, and NIST FIPS-204/205 PQ signature standards are finalized, operators need to monitor PQ posture.
This issue tracks adding PQ awareness across the validator surface.
Two distinct PQ surfaces
| Surface |
Risk model |
2026 status |
| PQ key exchange (KEM) |
HNDL — harvest now, decrypt later |
Widely deployed in hybrid form |
| PQ certificate signatures |
Only matters once CRQCs exist |
NIST standards final; web PKI rollout starting |
These need separate validators — different timelines, different data sources, different failure modes.
Current gaps
- certmonitor/validators/key_info.py — classifies SPKI as RSA / EC /
Unknown. PQ keys silently fall through to is_valid=None with key_type="Unknown".
- certmonitor/validators/weak_cipher.py — allow-lists cipher suites, but TLS 1.3 KEMs are negotiated as named groups (not cipher suites), so PQ KEX is invisible here.
- rust_certinfo/src/x509/spki.rs — recognizes only
rsaEncryption and ecPublicKey OIDs. ML-DSA, SLH-DSA, Falcon, and composite OIDs all collapse to PublicKeyAlgorithm::Unknown.
- Python stdlib
ssl does not expose the negotiated TLS 1.3 group at all. There is no current path in the codebase to learn which KEM was negotiated.
Proposed work
1. Add PQ algorithm OIDs to the Rust SPKI parser
Extend PublicKeyAlgorithm enum and OID table in rust_certinfo/src/ to recognize:
- ML-DSA-44 / 65 / 87 (FIPS-204)
- SLH-DSA variants (FIPS-205)
- Falcon (when standardized)
- Hybrid composite signature OIDs (draft-ietf-lamps-pq-composite-sigs — table will need updates as registry stabilizes)
Additive change, no shape change to the Python-facing dict.
2. Upgrade key_info validator
Teach _is_key_strong_enough to recognize PQ algorithms as valid so existing users of key_info don't get misleading is_valid=None on PQ certs. Table-stakes change; should ship before the new validators.
3. New validator: pq_signature (cert-type)
Returns {leaf_key_alg, leaf_sig_alg, is_pq, is_hybrid_composite, is_valid}. Self-contained; depends only on cert parsing changes from (1).
4. New Rust module: tls/ submodule for TLS 1.3 handshake probing
A separate, second connection per scan that opens a raw TCP socket, sends a hand-crafted TLS 1.3 ClientHello with PQ groups in supported_groups, reads ServerHello or HelloRetryRequest, extracts the negotiated group ID, and closes. No crypto operations — handshake never completes; we just read the negotiated group off the wire.
Structure:
```
rust_certinfo/src/tls/
├── groups.rs ← IANA Supported Groups table (contributor-friendly)
├── handshake.rs ← ClientHello/ServerHello parsing
├── records.rs ← TLS record framing
└── probe.rs ← orchestration; exposed to Python via PyO3
```
Exposed as certmonitor.certinfo.probe_tls_handshake(host, port, sni, timeout_ms) returning a dict including {id, name, kind} for the negotiated group. The kind enum (classical_ecdh, hybrid_pq, pure_pq, etc.) is computed Rust-side so Python doesn't need its own copy of the table.
Implementation notes:
- Zero external Rust crates — uses
std::net::TcpStream, std::io, existing der/ reader patterns.
- No randomness needed: fixed pattern (e.g.
b\"CERTMONITOR-PROBE\\x00...\") for ClientHello random/session_id/key_share placeholder. Server doesn't validate client random before sending ServerHello.
- IANA group table lives in its own
groups.rs file as a static slice of GroupInfo { id, name, kind }. Adding a new group is a one-line edit, no protocol code touched.
- Must include realistic extension set (SNI, supported_versions, signature_algorithms, ALPN) so WAFs don't drop the probe as a scanner.
- Read-only parsing with bounded buffers — no key derivation, no decryption, no certificate validation in the probe path.
5. New validator: pq_key_exchange (cipher-type)
Consumes the Rust probe result and judges whether the negotiated KEM is classical / hybrid PQ / pure PQ. Returns {kem_id, kem_name, kem_kind, is_pq, is_valid}. Handles TLS-1.2-only servers gracefully ({kem: \"n/a\", reason: \"server does not support TLS 1.3\"}).
6. New validator: pq_chain (cert-type)
Walks the cert chain (extending chain.py patterns) and reports per-cert {depth, key_alg, sig_alg, is_pq}. Useful during the staged migration period when leaf, intermediate, and root rotate at different times. Note in docstring that chains terminating at public trust anchors will report classical at the root for the foreseeable future.
Architectural decisions
- Wire protocol + binary format parsing lives in Rust. Establishes a clean boundary that future TLS-introspection features (signature_algorithms negotiated, ALPN, ECH, OCSP stapling, etc.) extend naturally.
- Single source of truth for IANA tables: Rust. Probe returns rich structured results (id + name + kind) so Python consumes classifications without duplicating data. If Python ever needs direct table access, expose via a one-line
#[pyfunction].
- All new PQ validators are opt-in. Not added to
DEFAULT_VALIDATORS until web PKI catches up; would be too noisy by default.
- "PQ" includes hybrid.
is_valid=True for either hybrid (classical + PQ) or pure-PQ, with a separate is_hybrid field. Requiring pure PQ today would fail every real-world cert.
Open questions
- Composite signature OIDs (draft-ietf-lamps-pq-composite-sigs) are still in flux. Plan: keep the OID table in one Rust file and add a unit test per OID; expect periodic updates.
- ClientHello fingerprinting by WAFs — needs spot-checks against major CDNs once the probe is built.
- TLS 1.2-only servers: report "n/a" rather than "invalid" — TLS 1.2 will be the dominant signal that an endpoint cannot support PQ at all.
Suggested build order
- Rust: add PQ OIDs to SPKI parser.
- Upgrade
key_info to recognize PQ algorithms.
- Add
pq_signature validator.
- Add Rust
tls/ module with handshake probe + PyO3 export.
- Add
pq_key_exchange validator on top of (4).
- Add
pq_chain validator.
(1)–(3) and (4)–(5) can proceed in parallel once (1) is merged.
Background
CertMonitor currently has no visibility into post-quantum cryptography in TLS handshakes or certificates. As hybrid PQ key exchange (RFC 9794 family —
X25519MLKEM768,SecP256r1MLKEM768, etc.) is now widely deployed by major CDNs and browsers, and NIST FIPS-204/205 PQ signature standards are finalized, operators need to monitor PQ posture.This issue tracks adding PQ awareness across the validator surface.
Two distinct PQ surfaces
These need separate validators — different timelines, different data sources, different failure modes.
Current gaps
Unknown. PQ keys silently fall through tois_valid=Nonewithkey_type="Unknown".rsaEncryptionandecPublicKeyOIDs. ML-DSA, SLH-DSA, Falcon, and composite OIDs all collapse toPublicKeyAlgorithm::Unknown.ssldoes not expose the negotiated TLS 1.3 group at all. There is no current path in the codebase to learn which KEM was negotiated.Proposed work
1. Add PQ algorithm OIDs to the Rust SPKI parser
Extend
PublicKeyAlgorithmenum and OID table inrust_certinfo/src/to recognize:Additive change, no shape change to the Python-facing dict.
2. Upgrade
key_infovalidatorTeach
_is_key_strong_enoughto recognize PQ algorithms as valid so existing users ofkey_infodon't get misleadingis_valid=Noneon PQ certs. Table-stakes change; should ship before the new validators.3. New validator:
pq_signature(cert-type)Returns
{leaf_key_alg, leaf_sig_alg, is_pq, is_hybrid_composite, is_valid}. Self-contained; depends only on cert parsing changes from (1).4. New Rust module:
tls/submodule for TLS 1.3 handshake probingA separate, second connection per scan that opens a raw TCP socket, sends a hand-crafted TLS 1.3 ClientHello with PQ groups in
supported_groups, reads ServerHello or HelloRetryRequest, extracts the negotiated group ID, and closes. No crypto operations — handshake never completes; we just read the negotiated group off the wire.Structure:
```
rust_certinfo/src/tls/
├── groups.rs ← IANA Supported Groups table (contributor-friendly)
├── handshake.rs ← ClientHello/ServerHello parsing
├── records.rs ← TLS record framing
└── probe.rs ← orchestration; exposed to Python via PyO3
```
Exposed as
certmonitor.certinfo.probe_tls_handshake(host, port, sni, timeout_ms)returning a dict including{id, name, kind}for the negotiated group. Thekindenum (classical_ecdh,hybrid_pq,pure_pq, etc.) is computed Rust-side so Python doesn't need its own copy of the table.Implementation notes:
std::net::TcpStream,std::io, existingder/reader patterns.b\"CERTMONITOR-PROBE\\x00...\") for ClientHellorandom/session_id/key_shareplaceholder. Server doesn't validate client random before sending ServerHello.groups.rsfile as a static slice ofGroupInfo { id, name, kind }. Adding a new group is a one-line edit, no protocol code touched.5. New validator:
pq_key_exchange(cipher-type)Consumes the Rust probe result and judges whether the negotiated KEM is classical / hybrid PQ / pure PQ. Returns
{kem_id, kem_name, kem_kind, is_pq, is_valid}. Handles TLS-1.2-only servers gracefully ({kem: \"n/a\", reason: \"server does not support TLS 1.3\"}).6. New validator:
pq_chain(cert-type)Walks the cert chain (extending chain.py patterns) and reports per-cert
{depth, key_alg, sig_alg, is_pq}. Useful during the staged migration period when leaf, intermediate, and root rotate at different times. Note in docstring that chains terminating at public trust anchors will report classical at the root for the foreseeable future.Architectural decisions
#[pyfunction].DEFAULT_VALIDATORSuntil web PKI catches up; would be too noisy by default.is_valid=Truefor either hybrid (classical + PQ) or pure-PQ, with a separateis_hybridfield. Requiring pure PQ today would fail every real-world cert.Open questions
Suggested build order
key_infoto recognize PQ algorithms.pq_signaturevalidator.tls/module with handshake probe + PyO3 export.pq_key_exchangevalidator on top of (4).pq_chainvalidator.(1)–(3) and (4)–(5) can proceed in parallel once (1) is merged.