Skip to content

feat(tls): chrome ClientHello via BoringSSL behind --features utls (#369)#963

Open
rezaisrad wants to merge 3 commits into
therealaleph:mainfrom
rezaisrad:feat/utls-fingerprint
Open

feat(tls): chrome ClientHello via BoringSSL behind --features utls (#369)#963
rezaisrad wants to merge 3 commits into
therealaleph:mainfrom
rezaisrad:feat/utls-fingerprint

Conversation

@rezaisrad
Copy link
Copy Markdown
Contributor

@rezaisrad rezaisrad commented May 9, 2026

Summary

Adds an opt-in Chrome-shaped ClientHello on the relay leg, defeating JA3/JA4 fingerprinting that distinguishes mhrv-rs from real Chrome traffic to Google. Tracks roadmap item #369 §2.

Default behavior is unchanged: the existing rustls path stays the default, the cross-compile build matrix is unaffected, and existing configs continue to work without edits. The new BoringSSL toolchain dependency (CMake + clang) is only paid by users who explicitly build with --features utls.

1. New tls_fingerprint config knob

tls_fingerprint: "rustls" (default) keeps the existing tokio-rustls path. tls_fingerprint: "chrome" selects a BoringSSL connector with Chrome's cipher list, curve preference order (X25519 → P-256 → P-384), signature algorithm order, OCSP stapling, and TLS 1.2/1.3 only — no SSLv2/v3/TLS1.0/1.1. Validation is case- and whitespace-tolerant; unknown values are rejected at config-load with a clear error.

On a binary built without --features utls, requesting tls_fingerprint: "chrome" silently downgrades to rustls and emits a single WARN at startup explaining why, so users don't think the chrome profile is in effect when it isn't.

2. Polymorphic TlsDialer abstraction

src/tls_dialer.rs introduces a TlsDialer enum (Rustls(...) | Utls(...)) with a DialedStream wrapper that captures the negotiated ALPN at handshake time. Callers in domain_fronter.rs no longer need to know which TLS backend produced the stream — the existing h2-fast-path detection (alpn_protocol() == "h2") keeps working through DialedStream::negotiated_alpn(). Errors from both backends are folded into io::Error so the ?-on-io::Error propagation in the relay path is unchanged.

The h2 / h1-only ALPN split (AlpnPolicy::H2Then11 vs AlpnPolicy::Http1Only) is preserved across both backends — the h1 connection pool still only holds sockets that the raw HTTP/1.1 fallback path can write to, regardless of which TLS stack negotiated them. force_http1: true continues to collapse both dialers to h1-only.

3. ISP-MITM diagnostic hint preserved on the chrome path

is_cert_validation_error now matches both rustls phrasing (UnknownIssuer, invalid peer certificate, CertificateExpired, CertNotValidYet, NotValidForName) and BoringSSL/OpenSSL phrasing (certificate verify failed, unable to get local issuer certificate, self[- ]signed certificate, certificate has expired, hostname mismatch). Without this, the single most useful error message in the codebase for IR users — the "UnknownIssuer means your ISP is MITMing you" hint — would silently vanish the moment users enabled the utls path.

4. UI form-state round-trip

src/bin/ui.rs doesn't surface a visible toggle yet (config-only for now), but the form-state preserves tls_fingerprint through Save so a hand-edited "tls_fingerprint": "chrome" in config.json isn't silently dropped on the next UI Save. Same defensive shape as the #773 block_doh regression guard. A visible UI control can land in a follow-up.

Alternatives considered

  • Pure rustls-utls / hand-shaped rustls extensions — the realistic Rust path, builds everywhere your existing matrix builds. Ruled out on fidelity grounds: you're approximating Chrome's ClientHello (extension order, GREASE, ECH, post-quantum hybrids) and have to chase Chrome upstream as it rolls. With BoringSSL you're using the same TLS stack Chrome ships, so wire-format alignment is a side effect of the dependency rather than a maintenance burden.
  • Go uTLS — the namesake, but not actually a Rust option (FFI / process boundary). Reference shape only.
  • Plain boring — would work, but rama-boring / rama-boring-tokio (the Rama project's fork) expose the shaping APIs this PR needs out of the box: set_curves_list, set_sigalgs_list, set_cipher_list, enable_ocsp_stapling. The plain boring fork would mean patching those in. Same upstream BoringSSL, lower friction.

Backwards compatibility

  • Existing configs untouched. Missing tls_fingerprint defaults to "rustls" via serde(default).
  • Default release-matrix binaries are built without --features utls, so the cross-compile targets (musl-static, mipsel-softfloat, Win7-i686, Android) are unaffected by the BoringSSL toolchain requirement.
  • A user on a default binary who hand-edits "chrome" into their config gets the rustls fallback + a startup WARN, never a crash or a silently wrong profile.

Test plan

  • cargo build --bins --lib — clean (default features)
  • cargo build --bins --lib --features utls — clean
  • cargo test --lib — passes (default features, no chrome path exercised)
  • cargo test --lib --features utls — passes (chrome path exercised end-to-end against a local rustls server)

Wire-shape coverage — these tests parse the actual ClientHello bytes that BoringSSL emits, so they pin the format on the wire rather than the connector's internal state:

  • chrome_clienthello_advertises_chrome_shape — ALPN exactly h2,http/1.1 in that order; supported_versions includes TLS 1.3; supported_groups starts with X25519, P-256, P-384; signature_algorithms starts with ecdsa_secp256r1_sha256; cipher set includes the three TLS 1.3 ciphers (0x1301/2/3) plus the top TLS 1.2 ECDHE GCM entries (0xc02b, 0xc02f).
  • chrome_clienthello_carries_sni_extension — pins set_use_server_name_indication(true). Without this, Google's edge can't pick the right cert and every relay request fails with a TLS handshake error — a hard-to-debug regression for an easy-to-flip flag.
  • chrome_clienthello_advertises_ocsp_status_request — pins enable_ocsp_stapling(). Real Chrome always sends status_request; dropping the call wouldn't break any other test but would silently diverge the JA3/JA4 fingerprint.
  • chrome_clienthello_with_http1_only_alpn — pins that force_http1: true threads through to BoringSSL's wire-level ALPN (http/1.1 only, no h2).

Backend-symmetry coverage:

  • utls_dialer_handshakes_against_local_h2_server / _h1_only_server — BoringSSL ALPN selection (h2 accepted, http/1.1 accepted on h1-only refusal).
  • chrome_dialer_with_verify_true_rejects_self_signed — verify=true on the chrome path actually checks certs (counterpart to the verify=false handshake test, prevents a refactor that hard-codes verify=false on the boring path).
  • log_relay_failure_cert_hint_fires_for_boringssl_messages / _rustls_messages / _does_not_fire_for_unrelated_errors — the ISP-MITM hint matcher covers both backends without firing on unrelated errors.
  • domain_fronter_new_with_chrome_warns_when_feature_disabled — on no-feature builds, the chrome → rustls fallback emits the explanatory WARN.

Build-and-config coverage:

  • tls_fingerprint_* × 5 in config.rs — defaults, chrome validates, rustls validates, unknown rejected with mention of the field name, case/whitespace variants accepted, serde round-trip preserved.
  • form_state_round_trips_chrome_tls_fingerprint + config_wire_emits_tls_fingerprint_chrome in ui.rs — UI Save preserves the field instead of silently dropping it (the 1.9.10 is faster and better than 1.9.13 #773-class regression guard).

Smoke run — does the chrome dialer get selected and complete a real handshake?

  1. Wrote /tmp/mhrv-smoke-chrome.json: minimal apps_script config
{
  "mode": "apps_script",
  "google_ip": "216.239.38.120",
  "front_domain": "www.google.com",
  "script_id": "AKfycbz_SMOKE_TEST_FAKE_DEPLOYMENT_ID_FOR_TLS_HANDSHAKE_ONLY",
  "auth_key": "smoke-test-secret-not-real",
  "listen_host": "127.0.0.1",
  "listen_port": 8085,
  "socks5_port": 8086,
  "log_level": "debug",
  "verify_ssl": true,
  "tls_fingerprint": "chrome",
  "hosts": {}
}
  1. Started the relay:
RUST_LOG=debug ./target/debug/mhrv-rs --config /tmp/mhrv-smoke-chrome.json --no-cert-check &.
  1. Read startup log for the absence of the "tls_fingerprint='chrome' requires --features utls" WARN at domain_fronter.rs:505-508. That WARN is the only signal that the dialer fell back; its absence on a --features utls build is the positive confirmation chrome was picked.
  2. Drove one request through the proxy:
curl -x http://127.0.0.1:8085 -k https://example.com/.
  1. Read the log for h2 connection established to relay edge (TLS handshake to Google succeeded) and h2 fast path active (ALPN returned h2, so DialedStream::negotiated_alpn() plumbing works). The 502 was an Apps Script 404 from the fake script_id — application-layer, post-TLS — expected.

Wire capture — do the actual bytes match Chrome's shape?

  1. First capture attempt missed the handshake — relay pre-warms its h2 pool to Google immediately on startup, so the handshake happened during my sleep 2
    after spawning the relay.
  2. Reordered: started tcpdump -i en0 -w /tmp/mhrv-chrome.pcap "host 216.239.38.120 and tcp port 443" first, then started the relay. Captured the warmup TLS
    plus the curl-driven flow — 51 packets, three ClientHellos (one per SNI in the rotation pool: www, mail, drive).
  3. Decoded frame 4 (first ClientHello, SNI www.google.com) with tshark -V and grepped for cipher list, supported_groups, signature_algorithms, ALPN, supported_versions, SNI, and status_request.
  4. Cross-checked each field against the assertions from the PR's wire-shape unit tests (chrome_clienthello_advertises_chrome_shape, _carries_sni_extension, _advertises_ocsp_status_request).

Output logs from test:

Relay log — tls_fingerprint: "chrome" was selected and the BoringSSL handshake to Google succeeded (note: the 404 is application-layer (fake script_id), post-TLS, expected):

INFO mhrv-rs 1.9.18 starting (mode: apps_script)
INFO Apps Script relay: SNI=www.google.com -> script.google.com (via 216.239.38.120)
INFO Script ID: AKfycbz_SMOKE_TEST_FAKE_DEPLOYMENT_ID_FOR_TLS_HANDSHAKE_ONLY
INFO h2 connection established to relay edge
INFO h2 fast path active; h1 fallback pool pre-warmed with 2 connection(s)
INFO dispatch example.com:443 -> MITM + Apps Script relay (TLS detected)
INFO MITM TLS -> example.com:443 (socks_host=example.com, sni=example.com)
INFO relay GET https://example.com/
ERROR Relay failed: relay error: Apps Script HTTP 404: <!DOCTYPE html>...

Wire capture — tshark -V on frame 4 (first outbound ClientHello, SNI www.google.com):

Cipher Suites (15 suites)
    Cipher Suite: TLS_AES_128_GCM_SHA256 (0x1301)
    Cipher Suite: TLS_AES_256_GCM_SHA384 (0x1302)
    Cipher Suite: TLS_CHACHA20_POLY1305_SHA256 (0x1303)
    Cipher Suite: TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256 (0xc02b)
    Cipher Suite: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 (0xc02f)
    ...

Extension: server_name (len=19) name=www.google.com
Extension: supported_groups (len=8)
    Supported Group: x25519 (0x001d)
    Supported Group: secp256r1 (0x0017)
    Supported Group: secp384r1 (0x0018)
Extension: application_layer_protocol_negotiation (len=14)
    ALPN Next Protocol: h2
    ALPN Next Protocol: http/1.1
Extension: status_request (len=5)
Extension: signature_algorithms (len=18)
    Signature Algorithm: ecdsa_secp256r1_sha256 (0x0403)
    ...
Extension: supported_versions (len=5) TLS 1.3, TLS 1.2
    Supported Version: TLS 1.3 (0x0304)
    Supported Version: TLS 1.2 (0x0303)

@github-actions github-actions Bot added the type: feature feat: PR — auto-applied by release-drafter label May 9, 2026
@therealaleph
Copy link
Copy Markdown
Owner

Reviewed via Anthropic Claude. Read PR body + structural diff (1719/77, 8 files including new utls_connector.rs).

This is the right approach for #369 (CF captcha + X.com login bot-detection). JA3/JA4 fingerprinting on stock rustls is exactly what those sites use to flag us — we look like Google datacenter Apps Script outbound, not Chrome. Mimicking Chrome's ClientHello via BoringSSL is the standard counter.

Important architectural questions before merge:

1. --features utls is opt-in. Good choice — keeps default builds rustls-only (smaller binary, no BoringSSL build complexity). Need to verify:

  • CI release matrix builds both flavors (rustls-only and +utls) for at least Linux x86_64 and Linux musl.
  • Default release artifact (mhrv-rs-linux-amd64.tar.gz) keeps rustls path so users on minimal containers don't hit BoringSSL link errors.
  • A separate mhrv-rs-linux-amd64-utls.tar.gz artifact ships the BoringSSL-fingerprinted build for users who specifically need it.

2. BoringSSL vendoring. Pulling BoringSSL into the build chain adds ~30 minutes to the Windows release and requires a system C compiler. Acceptable for an opt-in feature; not acceptable to gate the default release on. Will check the release.yml diff.

3. Fingerprint scope. Does utls_connector apply only to specific destinations (the configured exit-node URL, x.com, chatgpt.com), or to all outbound TLS? All outbound would re-fingerprint our connection to Apps Script too — and Apps Script's edge SHOULDN'T mind, but it's a non-zero risk because the cert chain validation gets routed through BoringSSL instead of webpki/rustls, and any subtle diff in trust anchor handling could break the relay path itself.

4. Test coverage. Want to see at least one integration test that:

  • Brings up a TLS server with known JA3/JA4 fingerprints
  • Confirms utls_connector produces a Chrome JA3 hash, not the rustls one.

Plan: leaving open for community testing AND I want to do a focused read of utls_connector.rs myself before merging — this is the kind of feature where "looks right" is not enough; the wire fingerprint has to actually match Chrome (not just look-similar). One way to verify: run mhrv-rs with --features utls against https://tls.peet.ws/api/all (community-run JA3 echo service) and check the returned JA3 hash matches a stock Chrome JA3.

Build verification on top of v1.9.18 — will do in a follow-up after I've read the connector module.

Queued for 5-7 days community testing alongside #903 and #977. Specifically asking testers:

  1. ChatGPT login + sustained chat session — does it stay accepted through exit-node + utls?
  2. X.com login flow — does the "extension warning" page disappear?
  3. Cloudflare Captcha (claude.ai is a good test) — does it solve faster / not loop?
  4. Build artifact size — what's the +utls binary size delta vs default?

Thanks @rezaisrad — this addresses one of the longest-standing user pain points. The opt-in feature flag is the right shape.

Copy link
Copy Markdown

@w0l4i w0l4i left a comment

Choose a reason for hiding this comment

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

Great commit, clever move !
keep it going champ 💪

@yyoyoian-pixel
Copy link
Copy Markdown
Contributor

please have this PR tested through in Iran.

@yyoyoian-pixel
Copy link
Copy Markdown
Contributor

and also let's have the option in android advance section too.

@greenchunk4me
Copy link
Copy Markdown

@therealaleph i hope you would review this PR, most of PRs have not been ckecked by you

regards

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature feat: PR — auto-applied by release-drafter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants