Skip to content

feat: add ML-DSA signature algorithms to fingerprint emulation - #22

Open
jackc625 wants to merge 2 commits into
apify:impit-mainfrom
jackc625:feat/ml-dsa-fingerprint-sigalgs
Open

feat: add ML-DSA signature algorithms to fingerprint emulation#22
jackc625 wants to merge 2 commits into
apify:impit-mainfrom
jackc625:feat/ml-dsa-fingerprint-sigalgs

Conversation

@jackc625

@jackc625 jackc625 commented Aug 4, 2026

Copy link
Copy Markdown

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_algorithms list with 0x0904/0x0905/0x0906, and that is the entire reason chrome142 produces a different JA4 from current Chrome. The SignatureScheme::ML_DSA_44/65/87 constants already existed in enums.rs; only the emulation plumbing was missing. The impit-side PR adding the chrome151 profile will follow once this lands and the rev pin 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() feeds WebPkiSupportedAlgorithms.mapping, which becomes supported_schemes() and thus the ClientHello extension. Following the Ed448 precedent and returning None compiles fine and silently emits nothing — I hit exactly that, and the JA4 came back identical to chrome142. Note to_signature_scheme() has no callers; it does not put anything on the wire.

The mapped slice must be non-empty. verify_tls13_signature does convert_scheme(dss.scheme)?[0], and supported_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.

Ed25519 was 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 a CertificateVerify mislabelled 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/:220 to .first().ok_or(...) and mapping ML-DSA to an empty slice. That is fail-closed and strictly cleaner, but it puts a delta in core verify.rs and so permanent merge friction against upstream. Happy to switch if you prefer it.

The send_ticket_request commit

impit-main does not currently compile — the merge of upstream v/0.23.43 added ClientConfig::send_ticket_request and updated the default builder, but the parallel initializer under #[cfg(feature = "impit")] was missed:

error[E0063]: missing field `send_ticket_request` in initializer of `ClientConfig`
  --> rustls/src/client/builder.rs:309

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 no ticket_request extension is sent and emulated ClientHellos are unchanged.

Separately and not addressed here: the examples workspace member has a stale lockfile (zerovec pinned 0.11.5, icu_normalizer 2.2.0 wants ^0.11.6), which fails cargo build workspace-wide. Left alone since it needs a Cargo.lock change and renovate manages deps here, but flagging it in case CI goes red for that reason.

Verification

Built against a local impit with a chrome151 profile listing these three algorithms first, requesting https://tls.peet.ws/api/all:

JA4
Real Chrome 151.0.7922.72 t13d1516h2_8daaf6152771_806a8c22fdea
impit with this change t13d1516h2_8daaf6152771_806a8c22fdea
impit chrome142 today t13d1516h2_8daaf6152771_d8a2da3f94cd

ja4_r is byte-identical to the browser, sigalgs included:

t13d1516h2_002f,...,cca9_0005,...,ff01_0904,0905,0906,0403,0804,0401,0503,0805,0501,0806,0601

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

  • The draft is IESG-approved but not yet an RFC; IANA lists all three with Recommended=N and the action state on hold. The codepoint values are unchanged across draft -00 through -05 and already ship in BoringSSL and Chrome 150+, so they are stable in practice.
  • Advertise-only. to_webpki_algs() returns EMPTY, 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.

jackc625 and others added 2 commits August 4, 2026 08:48
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>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment on lines +483 to +484
static ML_DSA_FALLBACK: &[&dyn pki_types::SignatureVerificationAlgorithm] =
&[webpki_algs::ED25519];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge 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 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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".

@B4nan
B4nan requested a review from barjin August 4, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants