feat: add ML-DSA signature algorithms to fingerprint emulation - #22
feat: add ML-DSA signature algorithms to fingerprint emulation#22jackc625 wants to merge 2 commits into
Conversation
The merge of upstream v/0.23.43 added `ClientConfig::send_ticket_request`
and updated the default builder to initialize it, but the parallel
`ClientConfig` initializer guarded by `#[cfg(feature = "impit")]` was not
updated, so building with the `impit` feature fails:
error[E0063]: missing field `send_ticket_request` in initializer of
`ClientConfig` --> rustls/src/client/builder.rs:309
Defaults to `None`, matching both the field's documented default and the
non-impit builder a few lines above, so no ticket_request extension is
sent and emulated ClientHellos are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Chrome 150+ leads its ClientHello signature_algorithms list with the three ML-DSA codepoints from draft-ietf-tls-mldsa: 0x0904 (mldsa44), 0x0905 (mldsa65) and 0x0906 (mldsa87). The SignatureScheme constants already existed, but FingerprintSignatureAlgorithm had no variants for them, so no emulated fingerprint could put them on the wire. Two things here are easy to get wrong, hence the comments in the code. The mapping returned by to_mapping_entry() doubles as the list of schemes advertised in the ClientHello, so ML-DSA needs an entry there rather than the None used for Ed448 - returning None compiles but silently omits the codepoints, and to_signature_scheme() has no callers. That entry also has to be non-empty: verify_tls13_signature indexes the first element of the returned slice, and supported_in_tls13() is a denylist that lets ML-DSA through, so an empty slice would panic on attacker-reachable input. The placeholder verifier cannot validate an ML-DSA signature, so a server that selects one fails the handshake cleanly instead. Note this is advertise-only: webpki has no ML-DSA verifier, so unlike real Chrome we cannot complete a handshake against a server that presents an ML-DSA certificate. No publicly-trusted CA issues those today. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a4a314ea0c
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| static ML_DSA_FALLBACK: &[&dyn pki_types::SignatureVerificationAlgorithm] = | ||
| &[webpki_algs::ED25519]; |
There was a problem hiding this comment.
Reject ML-DSA rather than verifying it as Ed25519
When an emulated fingerprint advertises an ML-DSA scheme and a TLS 1.3 peer selects it while presenting an Ed25519 key, an ordinary Ed25519 CertificateVerify mislabeled as ML-DSA is accepted because verify_tls13_signature invokes the first algorithm in this mapping. This silently treats two distinct signature schemes as interchangeable; keep the scheme advertise-only but make verification fail closed, including handling an empty mapping without indexing it.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Real finding, and it is the tradeoff described in the PR body rather than something new — but the severity is lower than P1 implies, and there is a smaller fix than the one suggested.
Severity. Triggering this requires a valid Ed25519 signature over the handshake transcript, which only the legitimate key holder can produce. An attacker without the key cannot forge one, and an attacker who has it gains nothing that an ordinary handshake would not already give them. So this is a conformance defect — RFC 8446 §4.4.3, two distinct schemes treated as interchangeable — not an authentication bypass. Reachability is narrow as well: it needs a server that presents an Ed25519 certificate and deliberately mislabels its CertificateVerify, and no publicly-trusted CA issues Ed25519 TLS server certificates today.
Still worth fixing. Fail-closed is the right default here.
Fix. Instead of handling the empty case at each indexing site, convert_scheme is the single choke point for all three verify paths (verify.rs:161, :198, :220):
.filter_map(|item| if item.0 == scheme { Some(item.1) } else { None })
.filter(|algs| !algs.is_empty())
.next()
.ok_or_else(|| PeerMisbehaved::SignedHandshakeWithUnadvertisedSigScheme.into())with ML_DSA_FALLBACK replaced by the existing EMPTY.
That keeps ML-DSA advertised — supported_schemes() reads only the mapping keys, so the ClientHello and the emulated JA4 are unchanged — while making both [0] indexing sites structurally unreachable rather than safe by convention. It is a no-op for current behaviour: all thirteen existing mapping entries are non-empty, so the filter never fires today.
This is one line in webpki/verify.rs, so whether the fork wants that delta against upstream is your call. Happy to push it, or to drop the placeholder approach some other way and keep the change confined to the emulation module. Either way one imprecision remains: SignedHandshakeWithUnadvertisedSigScheme is not quite the right name for "advertised, but no verifier available".
Adds the three ML-DSA signature algorithm codepoints from draft-ietf-tls-mldsa to the fingerprint emulation layer, so an emulated fingerprint can advertise them.
Prerequisite for apify/impit#508 — Chrome 150+ leads its ClientHello
signature_algorithmslist with0x0904/0x0905/0x0906, and that is the entire reasonchrome142produces a different JA4 from current Chrome. TheSignatureScheme::ML_DSA_44/65/87constants already existed inenums.rs; only the emulation plumbing was missing. The impit-side PR adding thechrome151profile will follow once this lands and therevpin can be bumped.Two things here are easy to get wrong
Both are commented in the code, because a future cleanup would plausibly "simplify" either one and reintroduce the problem.
The mapping is the advertise list.
to_mapping_entry()feedsWebPkiSupportedAlgorithms.mapping, which becomessupported_schemes()and thus the ClientHello extension. Following theEd448precedent and returningNonecompiles fine and silently emits nothing — I hit exactly that, and the JA4 came back identical tochrome142. Noteto_signature_scheme()has no callers; it does not put anything on the wire.The mapped slice must be non-empty.
verify_tls13_signaturedoesconvert_scheme(dss.scheme)?[0], andsupported_in_tls13()is a denylist that lets ML-DSA through, so an empty slice would be an index-out-of-bounds panic reachable from a misbehaving server. Hence the placeholder verifier.Ed25519was chosen as that placeholder deliberately. It can never validate a genuine ML-DSA signature, so a server selecting one fails the handshake cleanly. Because the placeholder does get run, a server whose certificate key matches it could in principle have aCertificateVerifymislabelled as ML-DSA accepted — only by the legitimate key holder, but the window is real. P-256 is the common case for publicly-trusted server certs; Ed25519 is not issued by public CAs, so this keeps that window as small as the fallback approach allows. The same pattern already exists for the SHA-1 schemes (RsaPkcs1Sha1,EcdsaSha1Legacy), though those are unreachable in TLS 1.3 — ML-DSA is the first one that isn't.If you would rather not carry that tradeoff, the alternative is changing
verify.rs:198/:220to.first().ok_or(...)and mapping ML-DSA to an empty slice. That is fail-closed and strictly cleaner, but it puts a delta in coreverify.rsand so permanent merge friction against upstream. Happy to switch if you prefer it.The
send_ticket_requestcommitimpit-maindoes not currently compile — the merge of upstreamv/0.23.43addedClientConfig::send_ticket_requestand updated the default builder, but the parallel initializer under#[cfg(feature = "impit")]was missed:Included as a separate first commit so it is reviewable (or cherry-pickable) on its own, and so the branch builds at every commit. It defaults to
None, matching the field's documented default and the non-impit builder twenty lines above, so noticket_requestextension is sent and emulated ClientHellos are unchanged.Separately and not addressed here: the
examplesworkspace member has a stale lockfile (zerovecpinned 0.11.5,icu_normalizer 2.2.0wants^0.11.6), which failscargo buildworkspace-wide. Left alone since it needs aCargo.lockchange and renovate manages deps here, but flagging it in case CI goes red for that reason.Verification
Built against a local
impitwith achrome151profile listing these three algorithms first, requestinghttps://tls.peet.ws/api/all:t13d1516h2_8daaf6152771_806a8c22fdeat13d1516h2_8daaf6152771_806a8c22fdeachrome142todayt13d1516h2_8daaf6152771_d8a2da3f94cdja4_ris byte-identical to the browser, sigalgs included:A real HTTPS request completes with status 200 while ML-DSA is advertised, so nothing in the handshake or certificate-verification path rejects it.
Caveats
Nand the action state on hold. The codepoint values are unchanged across draft-00through-05and already ship in BoringSSL and Chrome 150+, so they are stable in practice.to_webpki_algs()returnsEMPTY, so these contribute nothing to chain validation. Real Chrome genuinely verifies ML-DSA end-to-end; against a server presenting an ML-DSA certificate this client fails the handshake cleanly where Chrome would succeed. Not reachable on the public web today — no publicly-trusted CA issues ML-DSA TLS certificates.