Skip to content

client: let the ClientHello be shaped like someone else - #11

Closed
Rushan4eg wants to merge 5 commits into
Watfaq-legacy:watfaq/0.23.40from
Rushan4eg:watfaq/utls
Closed

Rushan4eg wants to merge 5 commits into
Watfaq-legacy:watfaq/0.23.40from
Rushan4eg:watfaq/utls

Conversation

@Rushan4eg

@Rushan4eg Rushan4eg commented Aug 22, 2026 •

Copy link
Copy Markdown

Why

clash-rs answers client-fingerprint in a VLESS or AnyTLS proxy with:

WARN clash-lib/src/proxy/converters/vless.rs:35:
client-fingerprint (uTLS) is not yet implemented, ignored for main-out

It has to, because there is nothing in rustls to answer it with. Reality connects and carries traffic, but the ClientHello it sends is recognisably rustls: no GREASE, no signed_certificate_timestamp, no ALPS, no padding, and a cipher list that is exactly the set rustls can negotiate. Any one of those is enough to separate it from a browser before a byte of application data moves — which is the one thing Reality is supposed to prevent.

This adds the parts of that job which belong in the TLS stack, and only those.

What

ClientConfig::client_hello_profile carries a ClientHelloProfile:

field effect
grease RFC 8701 values in the cipher list, supported groups, key shares, supported versions, plus two extensions — one leading and empty, one trailing with a zero byte, as BoringSSL emits them
prepend_extensions verbatim extensions before everything rustls builds
append_extensions and after it, ahead of the ECH and PSK extensions the standard pins to the end
padding the BoringSSL rule by default: a hello landing in [0x100, 0x200) is padded out to 0x200
cipher_suites the list to put on the wire, in that order

Which extensions a given browser sends, with what bodies, is deliberately not modelled here. That is the caller's knowledge, expressed through RawExtension { typ, payload }. rustls holds the mechanism; the browser table belongs in clash-rs.

Extension ordering needed no new machinery — contiguous_extensions and order_seed were already there.

The one loaded gun

cipher_suites advertises suites the provider does not implement. If a peer selects one, the handshake fails. It is documented as such at the field.

It exists because the suite count and order feed every common fingerprint, and no browser's list is a subset of what rustls implements — static RSA key exchange being the obvious case. For a Reality client the risk is nil (TLS 1.3 is mandatory there); for a general client it is real, and the doc comment says so plainly.

Defaults change nothing

With an untouched profile the hello is byte for byte what it was, TLS_EMPTY_RENEGOTIATION_INFO_SCSV included. There is a test asserting exactly that, because a "fingerprinting" feature that quietly alters every other connection would be a bad trade.

Tests

Eighteen new ones. They read the bytes that actually left rather than the parsed ClientHelloPayload, since parsing discards precisely what is under test — GREASE, unmodelled extensions, and the order everything went out in.

  • wire layout of each field, including the two GREASE extension bodies and the GREASE key share being a single zero byte
  • the padding arithmetic against its edges, including the case four bytes short of the target where BoringSSL sends an empty extension and overshoots
  • padding measured after GREASE and verbatim extensions, so it lands on the target rather than short of it
  • two end-to-end handshakes — a fully shaped hello, and a dictated cipher list containing suites rustls cannot negotiate — across every version and key type

That last group is the one that matters: a hello that fingerprints well and fails to connect is worse than no change at all.

cargo test -p rustls --lib --test api --test ech --no-default-features --features aws_lc_rs,std,tls12,logging
test result: ok. 256 passed; 0 failed
test result: ok. 213 passed; 0 failed

Three GREASE ECH fixes came out of the same work

Separate commits, all about the same thing - a placeholder that does not resemble the placeholders it hides among is not doing its job.

  • The KEM was fixed to P-256 regardless of the HPKE suite handed in. The KEM decides the length of enc (32 bytes for X25519, 65 for an uncompressed P-256 point) and that length is on the wire, so GREASEing with an X25519 suite emitted a P-256 sized key. Now it uses the suite's own KEM.

  • with_ech pinned TLS 1.3 for both modes. Real ECH requires it; GREASE negotiates nothing and has no such requirement, so the pin cost the connection TLS 1.2 and changed the hello. Browsers offer both versions alongside the placeholder.

  • GREASE required a full HPKE implementation, and rustls ships one only for aws-lc-rs. That rules out ring — which is what every MIPS and embedded build uses, aws-lc-rs supporting neither — so the clients that most need to look ordinary were the ones that could not GREASE at all.

    EchGreaseConfig::without_provider takes an HpkeSuite instead of an &dyn Hpke and fills the encapsulated key with random bytes of the length the KEM implies. For X25519 that is not a distinction anything on the wire can draw. It refuses a KEM whose length it does not know rather than guessing: a key of the wrong size is worse than no extension, since it looks like a client confused about its own configuration.

    Found the hard way — reading a ClientHello off a GL-XE300 with tcpdump and counting fifteen extensions where x86 had sixteen.

Not in scope

  • signature_algorithms order — comes from the verifier, so a caller can already control it
  • supported_groups order — comes from CryptoProvider::kx_groups, likewise
  • the browser profiles themselves — those go in clash-rs, where a follow-up PR wires client-fingerprint to this

Note on the branch

Based on watfaq/0.23.40. Cargo.lock in that branch is missing an x25519-dalek entry and a build regenerates it; I left that alone to keep the diff to the change.

Reality, ShadowTLS and everything built on them rest on one assumption:
the ClientHello is indistinguishable from a browser's. rustls sends the
hello rustls needs, and that hello is recognisably rustls - no GREASE, no
signed_certificate_timestamp, no ALPS, no padding, and a cipher list that
is exactly the set rustls can negotiate. Any of those alone is enough to
tell it apart before a byte of application data moves.

clash-rs currently answers `client-fingerprint` in the config with

    client-fingerprint (uTLS) is not yet implemented, ignored

because there is nothing in rustls to answer it with. This adds the parts
that belong in the TLS stack, and only those.

ClientConfig::client_hello_profile carries a ClientHelloProfile:

  grease               RFC 8701 values in the cipher list, the supported
                       groups, the key shares, the supported versions, and
                       two extensions - one leading and empty, one trailing
                       with a zero byte, as BoringSSL emits them.
  prepend_extensions   verbatim extensions before everything rustls builds
  append_extensions    and after it, ahead of the ECH and PSK extensions
                       the standard pins to the end
  padding              the BoringSSL rule by default: a hello landing in
                       [0x100, 0x200) is padded out to 0x200
  cipher_suites        the list to put on the wire, in that order

Which extensions a given browser sends, with what bodies, is deliberately
not modelled here - that is the caller's knowledge, expressed through
RawExtension. rustls holds the mechanism, not the browser table.

cipher_suites is the one loaded gun and is documented as such: suites the
provider does not implement are advertised and nothing more, so a peer
that selects one gets a failed handshake. It exists because the suite
count and order feed every common fingerprint, and no browser's list is a
subset of what rustls implements - static RSA key exchange being the
obvious case.

Defaults change nothing: with an untouched profile the hello is byte for
byte what it was, SCSV included.

Extension ordering needed no new machinery - contiguous_extensions and
order_seed were already there.

Tests read the bytes that actually left rather than the parsed structure,
since parsing discards exactly what is under test. Sixteen new ones: the
wire layout of each field, the padding arithmetic against its edges, and
two end-to-end handshakes - a fully shaped hello and a dictated cipher
list - across every version and key type, because a hello that fingerprints
well and fails to connect is worse than useless.
@Rushan4eg

Copy link
Copy Markdown
Author

The consumer side is now up as a draft: Watfaq/clash-rs#1503. It answers client-fingerprint with a Chrome 133 profile built on this API — cipher list, GREASE, group order, and signed_certificate_timestamp / ALPS / renegotiation_info as RawExtensions. The browser table lives there, so nothing browser-specific needs to land in rustls.

Rushan4eg added a commit to Rushan4eg/clash-rs that referenced this pull request Aug 22, 2026
CI runs cargo +nightly fmt --check and my code was not run through it.

The rustls dependency temporarily points at the branch of
Watfaq-legacy/rustls#11 so the build can actually happen - a PR that cannot
compile is hard to review. Marked in Cargo.toml as not for merge; it goes
back to Watfaq/rustls once that PR lands.
CI checks formatting and the new code had not been run through it. Only
files this branch touches; reality.rs on the base branch is not fmt-clean
either, and reformatting it here would be noise in a PR about something
else.
Two things kept the placeholder extension from resembling the one
browsers send.

The KEM was fixed to P-256 no matter which HPKE suite the caller handed
in. The KEM decides the length of `enc` - 32 bytes for X25519, 65 for an
uncompressed P-256 point - and that length is on the wire. A client
GREASEing with an X25519 suite while sending a P-256 sized key is
distinguishable from one that means it, which defeats the purpose. Now it
uses the suite's own KEM.

And `with_ech` pinned TLS 1.3 as the only version for both modes. Real
ECH requires it. GREASE negotiates nothing and has no such requirement,
so pinning cost the connection TLS 1.2 and changed the hello - the very
thing a client GREASEing to blend in is trying not to do. Browsers offer
both versions alongside the placeholder.

Two tests, both reading the bytes: `enc` is the length its KEM implies,
and a GREASE-ECH hello still offers TLS 1.2.

Copilot AI 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.

Pull request overview

This PR adds a client-side “ClientHello shaping” mechanism to rustls so applications can emit a browser-like ClientHello on the wire (GREASE, verbatim/unknown extensions, padding rules, and dictated cipher suite ordering) without changing negotiated behavior by default, plus it fixes two GREASE-ECH wire-shape issues uncovered during that work.

Changes:

  • Introduces ClientConfig::client_hello_profile with ClientHelloProfile (GREASE, raw prepend/append extensions, padding, optional dictated cipher suite list).
  • Adds RawExtension support and updates ClientHello extension encoding/sent-extension tracking accordingly.
  • Fixes GREASE ECH KEM selection and stops pinning TLS 1.3 for ECH GREASE mode; adds extensive wire-level tests.

Reviewed changes

Copilot reviewed 12 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
rustls/tests/api.rs Adds API-level handshake tests validating shaped hellos still complete.
rustls/src/server/test.rs Updates test ClientHello construction for new SupportedProtocolVersions field.
rustls/src/msgs/handshake.rs Adds RawExtension, GREASE-able supported versions, and client extension encoding changes.
rustls/src/msgs/handshake_test.rs Updates handshake tests for new SupportedProtocolVersions field.
rustls/src/lib.rs Re-exports RawExtension and exposes ClientHelloProfile/Padding in rustls::client.
rustls/src/client/test.rs Adds wire-capture tests for GREASE/ext ordering/padding/cipher suite dictation and ECH GREASE fixes.
rustls/src/client/hs.rs Implements shaping: GREASE insertion, raw extension placement, optional padding, optional cipher list override.
rustls/src/client/ech.rs Fixes GREASE ECH KEM selection to match the configured suite.
rustls/src/client/common.rs Stores per-connection GREASE values to keep them stable across HRR.
rustls/src/client/client_hello_profile.rs New shaping profile types and padding/GREASE helpers + unit tests.
rustls/src/client/client_conn.rs Adds client_hello_profile to ClientConfig.
rustls/src/client/builder.rs Changes with_ech to pin TLS 1.3 only for real ECH, not GREASE.
Cargo.lock Adds missing x25519-dalek entry.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread rustls/src/client/hs.rs
Comment on lines +544 to +549
if let Some(body_len) = padding.body_len(measured.len()) {
chp_payload
.extensions
.appended_extensions
.push(RawExtension::padding(body_len));
}
Comment on lines +50 to +51
/// Extensions written before everything rustls generates.
pub prepend_extensions: Vec<RawExtension>,
Comment on lines 1071 to +1075
pub(crate) fn used_extensions_in_encoding_order(&self) -> Vec<ExtensionType> {
let mut exts = self.body_extensions_in_encoding_order();
exts.extend(self.trailing_extensions());
exts
}
Comment on lines +53 to +57
/// Extensions written after everything rustls generates.
///
/// They still precede the extensions the standard pins to the end of the
/// list - encrypted client hello and the pre-shared key offer.
pub append_extensions: Vec<RawExtension>,
@ibigbug

ibigbug commented Aug 22, 2026

Copy link
Copy Markdown
Member

@Rushan4eg thanks for the PR - plz ask you agent to address the copilot reviews?

GREASE addresses nobody. The payload is random by construction and the
encapsulated key is aimed at a public key no one holds, so the one thing
it never needed was a working KEM - yet building the extension required a
full HPKE implementation, and rustls ships one only for aws-lc-rs.

That rules out `ring`, which is what every MIPS and embedded build uses,
since aws-lc-rs supports neither. Those builds could not GREASE at all,
which is the exact opposite of what GREASE is for: the clients that most
need to look ordinary were the ones sending a hello one extension short.

EchGreaseConfig::without_provider takes an HpkeSuite rather than an Hpke
and fills the encapsulated key with random bytes of the length the KEM
implies - which for X25519 is not a distinction anything on the wire can
draw, keys there being indistinguishable from random. It refuses a KEM
whose length is unknown rather than guessing one, since a key of the
wrong size is worse than no extension: it looks like a client confused
about its own configuration.

EchState::sender becomes optional to go with it. A GREASE state is still
built - sizing the random payload like a real inner hello is the whole
point - it just never seals one.

Verified against a GL-XE300 running the ring build: the hello had fifteen
extensions where aws-lc-rs produced sixteen, and encrypted_client_hello
was the one missing.
An import left behind when the KEM stopped being hardcoded, a binding
matched but never read, and a helper on RawExtension that never found a
caller. CI runs clippy with warnings denied, so all three would have
stopped it.

The remaining warnings on this branch are in reality.rs, anchors.rs and
key_log_file.rs, none of which this work touches.
@Rushan4eg

Copy link
Copy Markdown
Author

Closing — keeping this in a fork for now rather than asking for review time. Thanks.

@Rushan4eg Rushan4eg closed this Aug 22, 2026
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.

3 participants