feat(tls): chrome ClientHello via BoringSSL behind --features utls (#369)#963
feat(tls): chrome ClientHello via BoringSSL behind --features utls (#369)#963rezaisrad wants to merge 2 commits intotherealaleph:mainfrom
Conversation
|
Reviewed via Anthropic Claude. Read PR body + structural diff (1719/77, 8 files including new 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.
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 4. Test coverage. Want to see at least one integration test that:
Plan: leaving open for community testing AND I want to do a focused read of 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:
Thanks @rezaisrad — this addresses one of the longest-standing user pain points. The opt-in feature flag is the right shape. |
w0l4i
left a comment
There was a problem hiding this comment.
Great commit, clever move !
keep it going champ 💪
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_fingerprintconfig knobtls_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, requestingtls_fingerprint: "chrome"silently downgrades to rustls and emits a singleWARNat startup explaining why, so users don't think the chrome profile is in effect when it isn't.2. Polymorphic
TlsDialerabstractionsrc/tls_dialer.rsintroduces aTlsDialerenum (Rustls(...)|Utls(...)) with aDialedStreamwrapper that captures the negotiated ALPN at handshake time. Callers indomain_fronter.rsno longer need to know which TLS backend produced the stream — the existing h2-fast-path detection (alpn_protocol() == "h2") keeps working throughDialedStream::negotiated_alpn(). Errors from both backends are folded intoio::Errorso the?-on-io::Errorpropagation in the relay path is unchanged.The h2 / h1-only ALPN split (
AlpnPolicy::H2Then11vsAlpnPolicy::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: truecontinues to collapse both dialers to h1-only.3. ISP-MITM diagnostic hint preserved on the chrome path
is_cert_validation_errornow 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.rsdoesn't surface a visible toggle yet (config-only for now), but the form-state preservestls_fingerprintthrough Save so a hand-edited"tls_fingerprint": "chrome"inconfig.jsonisn't silently dropped on the next UI Save. Same defensive shape as the #773block_dohregression guard. A visible UI control can land in a follow-up.Alternatives considered
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.boring— would work, butrama-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 plainboringfork would mean patching those in. Same upstream BoringSSL, lower friction.Backwards compatibility
tls_fingerprintdefaults to"rustls"viaserde(default).--features utls, so the cross-compile targets (musl-static, mipsel-softfloat, Win7-i686, Android) are unaffected by the BoringSSL toolchain requirement."chrome"into their config gets the rustls fallback + a startupWARN, never a crash or a silently wrong profile.Test plan
cargo build --bins --lib— clean (default features)cargo build --bins --lib --features utls— cleancargo 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 exactlyh2,http/1.1in that order;supported_versionsincludes TLS 1.3;supported_groupsstarts withX25519, P-256, P-384;signature_algorithmsstarts withecdsa_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— pinsset_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— pinsenable_ocsp_stapling(). Real Chrome always sendsstatus_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 thatforce_http1: truethreads through to BoringSSL's wire-level ALPN (http/1.1only, noh2).Backend-symmetry coverage:
utls_dialer_handshakes_against_local_h2_server/_h1_only_server— BoringSSL ALPN selection (h2accepted,http/1.1accepted 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 explanatoryWARN.Build-and-config coverage:
tls_fingerprint_*× 5 inconfig.rs— defaults,chromevalidates,rustlsvalidates, 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_chromeinui.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?
/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": {} }Wire capture — do the actual bytes match Chrome's shape?
after spawning the relay.
plus the curl-driven flow — 51 packets, three ClientHellos (one per SNI in the rotation pool: www, mail, drive).
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):
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)