Skip to content

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

Open
rezaisrad wants to merge 2 commits intotherealaleph:mainfrom
rezaisrad:feat/utls-fingerprint
Open

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

Conversation

@rezaisrad
Copy link
Copy Markdown

@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 💪

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.

3 participants