Repository navigation
Conversation
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.
|
The consumer side is now up as a draft: Watfaq/clash-rs#1503. It answers |
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.
There was a problem hiding this comment.
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_profilewithClientHelloProfile(GREASE, raw prepend/append extensions, padding, optional dictated cipher suite list). - Adds
RawExtensionsupport 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.
| if let Some(body_len) = padding.body_len(measured.len()) { | ||
| chp_payload | ||
| .extensions | ||
| .appended_extensions | ||
| .push(RawExtension::padding(body_len)); | ||
| } |
| /// Extensions written before everything rustls generates. | ||
| pub prepend_extensions: Vec<RawExtension>, |
| 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 | ||
| } |
| /// 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>, |
|
@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.
|
Closing — keeping this in a fork for now rather than asking for review time. Thanks. |
Why
clash-rs answers
client-fingerprintin a VLESS or AnyTLS proxy with: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_profilecarries aClientHelloProfile:greaseprepend_extensionsappend_extensionspadding[0x100, 0x200)is padded out to0x200cipher_suitesWhich 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_extensionsandorder_seedwere already there.The one loaded gun
cipher_suitesadvertises 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_SCSVincluded. 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.That last group is the one that matters: a hello that fingerprints well and fails to connect is worse than no change at all.
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_echpinned 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_providertakes anHpkeSuiteinstead of an&dyn Hpkeand 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_algorithmsorder — comes from the verifier, so a caller can already control itsupported_groupsorder — comes fromCryptoProvider::kx_groups, likewiseclient-fingerprintto thisNote on the branch
Based on
watfaq/0.23.40.Cargo.lockin that branch is missing anx25519-dalekentry and a build regenerates it; I left that alone to keep the diff to the change.