diff --git a/Cargo.lock b/Cargo.lock index ff3a16d2..33e1ffc4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -581,6 +581,24 @@ dependencies = [ "virtue", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.11.1", + "cexpr", + "clang-sys", + "itertools", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.2", + "shlex", + "syn 2.0.117", +] + [[package]] name = "bit-set" version = "0.5.3" @@ -819,6 +837,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -879,6 +906,17 @@ dependencies = [ "serde", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading 0.8.9", +] + [[package]] name = "clap" version = "4.6.1" @@ -929,6 +967,15 @@ dependencies = [ "error-code", ] +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "codespan-reporting" version = "0.11.1" @@ -1382,6 +1429,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + [[package]] name = "emath" version = "0.28.1" @@ -1637,6 +1690,22 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "fslock" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.32" @@ -1821,6 +1890,12 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "glow" version = "0.13.1" @@ -2298,6 +2373,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2638,6 +2722,8 @@ dependencies = [ "jni 0.21.1", "libc", "portable-atomic", + "rama-boring", + "rama-boring-tokio", "rand 0.8.6", "rcgen", "rustls", @@ -3154,6 +3240,17 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -3443,6 +3540,42 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rama-boring" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc9b815c8dce0288dc1ecebf53039913c82977604766e48b72e80db99b06327" +dependencies = [ + "bitflags 2.11.1", + "foreign-types", + "libc", + "openssl-macros", + "rama-boring-sys", +] + +[[package]] +name = "rama-boring-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7b68b0916fb03989d677548d40c3e25cd24d16da95d32b8c5861ce9805a03ce" +dependencies = [ + "bindgen", + "cmake", + "fs_extra", + "fslock", +] + +[[package]] +name = "rama-boring-tokio" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ff8d421e3e7f5b7a08efcdf1c5292ad11307fb9a97847731f0696e3e15c9047" +dependencies = [ + "rama-boring", + "rama-boring-sys", + "tokio", +] + [[package]] name = "rand" version = "0.8.6" @@ -5135,7 +5268,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 23490691..b7b381ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,11 @@ required-features = ["ui"] [features] default = [] ui = ["dep:eframe"] +# Opt-in: BoringSSL needs CMake + clang at build time, awkward for +# musl-static / mipsel-softfloat / Win7-i686 release targets. When on, +# `tls_fingerprint: "chrome"` in config.json selects a Chrome-shaped +# ClientHello. Roadmap item #369 §2. +utls = ["dep:rama-boring", "dep:rama-boring-tokio"] [dependencies] tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "time", "io-util", "signal", "sync"] } @@ -58,6 +63,11 @@ futures-util = { version = "0.3", default-features = false, features = ["std"] } # down to the native instructions with no overhead. portable-atomic = { version = "1", features = ["fallback"] } +# BoringSSL connector for the `utls` feature — drop-in at the +# tokio_rustls::TlsConnector::connect API surface. +rama-boring = { version = "0.6", optional = true } +rama-boring-tokio = { version = "0.6", optional = true } + # Optional UI dep: only pulled in when --features ui is set. # Both `glow` (OpenGL 2+) and `wgpu` (DX12/Vulkan/Metal) are compiled in; # the binary picks one at startup — glow by default for compat with the diff --git a/src/bin/ui.rs b/src/bin/ui.rs index 44442206..eea565f6 100644 --- a/src/bin/ui.rs +++ b/src/bin/ui.rs @@ -294,6 +294,9 @@ struct FormState { /// claude.ai / grok.com / x.com). Config-only — no UI editor yet. /// See `assets/exit_node/` for the generic exit-node handler. exit_node: mhrv_rs::config::ExitNodeConfig, + /// "rustls" (default) or "chrome". Config-only round-trip until the + /// UI editor lands; #369 §2. + tls_fingerprint: String, } #[derive(Clone, Debug)] @@ -398,6 +401,7 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: c.auto_blacklist_cooldown_secs, request_timeout_secs: c.request_timeout_secs, exit_node: c.exit_node.clone(), + tls_fingerprint: c.tls_fingerprint.clone(), } } else { FormState { @@ -439,6 +443,7 @@ fn load_form() -> (FormState, Option) { auto_blacklist_cooldown_secs: 120, request_timeout_secs: 30, exit_node: mhrv_rs::config::ExitNodeConfig::default(), + tls_fingerprint: "rustls".into(), } }; (form, load_err) @@ -626,6 +631,10 @@ impl FormState { // / grok.com / x.com). Round-trip through FormState — config-only // editing for now, UI editor planned for v1.9.x desktop UI batch. exit_node: self.exit_node.clone(), + // tls_fingerprint isn't yet exposed in the UI form; preserve + // whatever was loaded from disk so config.json hand-edits + // round-trip through Save. UI editor queued behind #369 §2. + tls_fingerprint: self.tls_fingerprint.clone(), }) } } @@ -714,6 +723,12 @@ struct ConfigWire<'a> { /// Save preserves user-edited values. #[serde(skip_serializing_if = "is_default_exit_node")] exit_node: &'a mhrv_rs::config::ExitNodeConfig, + /// TLS fingerprint profile (#369 §2). Default `"rustls"` — skip when + /// matching default so unchanged configs stay clean. Without this + /// field on the wire struct, a hand-edited `"tls_fingerprint": "chrome"` + /// is silently dropped on the next UI Save. + #[serde(skip_serializing_if = "is_default_tls_fingerprint")] + tls_fingerprint: &'a str, } fn is_default_strikes(v: &u32) -> bool { *v == 3 } @@ -728,6 +743,10 @@ fn is_default_exit_node(en: &&mhrv_rs::config::ExitNodeConfig) -> bool { && (en.mode.is_empty() || en.mode == "selective") } +fn is_default_tls_fingerprint(v: &&str) -> bool { + *v == "rustls" +} + fn is_false(b: &bool) -> bool { !*b } @@ -788,6 +807,7 @@ impl<'a> From<&'a Config> for ConfigWire<'a> { request_timeout_secs: c.request_timeout_secs, force_http1: c.force_http1, exit_node: &c.exit_node, + tls_fingerprint: c.tls_fingerprint.as_str(), } } } @@ -2688,3 +2708,138 @@ fn push_log(shared: &Shared, msg: &str) { s.log.pop_front(); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Mirror the default-state literal at the bottom of `load_form()` + /// so individual round-trip tests can mutate one field and assert + /// preservation without re-typing every field. If `load_form` grows + /// a new field, this builder must grow too — that's the regression + /// hook we want. + fn default_form() -> FormState { + FormState { + mode: "apps_script".into(), + script_id: "X".into(), + auth_key: "secretkey123".into(), + google_ip: "216.239.38.120".into(), + front_domain: "www.google.com".into(), + listen_host: "127.0.0.1".into(), + listen_port: "8085".into(), + socks5_port: "8086".into(), + log_level: "info".into(), + verify_ssl: true, + upstream_socks5: String::new(), + parallel_relay: 0, + show_auth_key: false, + sni_pool: sni_pool_for_form(None, "www.google.com"), + sni_custom_input: String::new(), + sni_editor_open: false, + show_log: true, + fetch_ips_from_api: false, + max_ips_to_scan: 100, + google_ip_validation: true, + scan_batch_size: 500, + normalize_x_graphql: false, + youtube_via_relay: false, + passthrough_hosts: Vec::new(), + block_quic: true, + disable_padding: false, + force_http1: false, + tunnel_doh: true, + bypass_doh_hosts: Vec::new(), + block_doh: true, + fronting_groups: Vec::new(), + auto_blacklist_strikes: 3, + auto_blacklist_window_secs: 30, + auto_blacklist_cooldown_secs: 120, + request_timeout_secs: 30, + exit_node: mhrv_rs::config::ExitNodeConfig::default(), + tls_fingerprint: "rustls".into(), + } + } + + #[test] + fn form_state_default_round_trips_tls_fingerprint_rustls() { + let form = default_form(); + let cfg = form.to_config().expect("default form must convert cleanly"); + assert_eq!(cfg.tls_fingerprint, "rustls"); + } + + #[test] + fn form_state_round_trips_chrome_tls_fingerprint() { + // Regression guard: if anyone deletes + // `tls_fingerprint: self.tls_fingerprint.clone()` + // from `to_config`, a user with `"tls_fingerprint": "chrome"` in + // config.json silently reverts to "rustls" on the next Save. + let mut form = default_form(); + form.tls_fingerprint = "chrome".into(); + let cfg = form.to_config().expect("chrome form must convert cleanly"); + assert_eq!(cfg.tls_fingerprint, "chrome"); + } + + #[test] + fn form_state_round_trips_arbitrary_tls_fingerprint_string() { + // `to_config` itself doesn't validate — Config::validate() does. + // This pins the to_config copy: whatever the form holds, to_config + // must hand back. Validation is the next layer's job. + let mut form = default_form(); + form.tls_fingerprint = " ChRoMe ".into(); + let cfg = form.to_config().expect("to_config must not validate"); + assert_eq!(cfg.tls_fingerprint, " ChRoMe "); + } + + /// REGRESSION TEST — covers a bug where `ConfigWire` (the actual + /// on-disk save format) didn't include `tls_fingerprint`, so the UI + /// silently dropped a hand-edited `"chrome"` value on every Save. + /// The earlier `to_config` tests passed because they tested only + /// the FormState→Config conversion, not the Config→JSON wire path + /// that `save_config` actually uses. Pin both layers now. + #[test] + fn config_wire_emits_tls_fingerprint_chrome() { + let mut form = default_form(); + form.tls_fingerprint = "chrome".into(); + let cfg = form.to_config().unwrap(); + let wire = ConfigWire::from(&cfg); + let json = serde_json::to_string(&wire).unwrap(); + assert!( + json.contains("\"tls_fingerprint\":\"chrome\""), + "ConfigWire must serialize tls_fingerprint=chrome; got: {}", + json + ); + } + + #[test] + fn config_wire_omits_default_tls_fingerprint_to_keep_configs_clean() { + // Mirror the convention used by other recent additions + // (block_doh, force_http1, scan/blacklist tunables): the + // default value is skipped on save so unchanged configs don't + // accumulate noise. Pin that convention against an accidental + // unconditional emit. + let form = default_form(); + let cfg = form.to_config().unwrap(); + assert_eq!(cfg.tls_fingerprint, "rustls"); + let wire = ConfigWire::from(&cfg); + let json = serde_json::to_string(&wire).unwrap(); + assert!( + !json.contains("tls_fingerprint"), + "default rustls fingerprint must be skipped; got: {}", + json + ); + } + + #[test] + fn config_wire_round_trip_preserves_chrome_through_disk_format() { + // End-to-end: form → Config → ConfigWire JSON → parse JSON back + // into Config. Confirms what a user would see if they Saved a + // chrome config and re-loaded it. If anything in the chain + // stops carrying the field, this test fails. + let mut form = default_form(); + form.tls_fingerprint = "chrome".into(); + let cfg = form.to_config().unwrap(); + let json = serde_json::to_string(&ConfigWire::from(&cfg)).unwrap(); + let reparsed: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(reparsed.tls_fingerprint, "chrome"); + } +} diff --git a/src/config.rs b/src/config.rs index 02ae2aad..47b3682e 100644 --- a/src/config.rs +++ b/src/config.rs @@ -389,6 +389,18 @@ pub struct Config { /// Setup walkthrough at `assets/exit_node/README.md`. Default off. #[serde(default)] pub exit_node: ExitNodeConfig, + + /// TLS fingerprint profile for the relay leg's outbound TLS. + /// `"rustls"` (default) keeps the existing tokio-rustls path. + /// `"chrome"` uses a BoringSSL Chrome-shaped ClientHello, defeating + /// JA3/JA4 fingerprinting that tries to distinguish mhrv-rs from + /// real Chrome traffic to Google. + /// + /// `"chrome"` requires the binary to be built with `--features utls`. + /// Builds without the feature fall back to rustls with a startup + /// warning. Roadmap item #369 §2. + #[serde(default = "default_tls_fingerprint")] + pub tls_fingerprint: String, } /// Configuration for the optional second-hop exit node. @@ -520,6 +532,7 @@ fn default_auto_blacklist_cooldown_secs() -> u64 { 120 } /// Default for `request_timeout_secs`: 30s, matching the historical /// hard-coded `BATCH_TIMEOUT` and Apps Script's typical response cliff. fn default_request_timeout_secs() -> u64 { 30 } +fn default_tls_fingerprint() -> String { "rustls".into() } fn default_google_ip() -> String { "216.239.38.120".into() @@ -576,6 +589,15 @@ impl Config { "scan_batch_size must be greater than 0".into(), )); } + match self.tls_fingerprint.trim().to_ascii_lowercase().as_str() { + "rustls" | "chrome" => {} + other => { + return Err(ConfigError::Invalid(format!( + "tls_fingerprint must be 'rustls' (default) or 'chrome'; got '{}'", + other + ))); + } + } if self.socks5_port == Some(self.listen_port) { return Err(ConfigError::Invalid(format!( "listen_port and socks5_port must differ on the same host \ @@ -958,4 +980,90 @@ mod rt_tests { assert_eq!(cfg.mode, "apps_script"); let _ = std::fs::remove_file(&tmp); } + + #[test] + fn tls_fingerprint_defaults_to_rustls() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X" +}"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.tls_fingerprint, "rustls"); + } + + #[test] + fn tls_fingerprint_chrome_validates() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "tls_fingerprint": "chrome" +}"#; + let tmp = std::env::temp_dir().join("mhrv-tlsfp-chrome.json"); + std::fs::write(&tmp, json).unwrap(); + let cfg = Config::load(&tmp).expect("chrome fingerprint must validate"); + assert_eq!(cfg.tls_fingerprint, "chrome"); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn tls_fingerprint_rustls_validates() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "tls_fingerprint": "rustls" +}"#; + let tmp = std::env::temp_dir().join("mhrv-tlsfp-rustls.json"); + std::fs::write(&tmp, json).unwrap(); + Config::load(&tmp).expect("explicit rustls must validate"); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn tls_fingerprint_unknown_rejected() { + let json = r#"{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "tls_fingerprint": "firefox" +}"#; + let tmp = std::env::temp_dir().join("mhrv-tlsfp-bad.json"); + std::fs::write(&tmp, json).unwrap(); + let result = Config::load(&tmp); + assert!(result.is_err(), "unknown profile must be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("tls_fingerprint"), + "error must mention tls_fingerprint: {}", + err + ); + let _ = std::fs::remove_file(&tmp); + } + + #[test] + fn tls_fingerprint_case_insensitive() { + for value in ["Chrome", "CHROME", " chrome ", "RUSTLS"] { + let json = format!( + r#"{{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "tls_fingerprint": "{}" +}}"#, + value + ); + let tmp = std::env::temp_dir().join(format!( + "mhrv-tlsfp-case-{}.json", + value.trim().replace(' ', "_") + )); + std::fs::write(&tmp, &json).unwrap(); + Config::load(&tmp).unwrap_or_else(|e| { + panic!("case variant '{}' must validate, got: {}", value, e) + }); + let _ = std::fs::remove_file(&tmp); + } + } + } diff --git a/src/domain_fronter.rs b/src/domain_fronter.rs index ed8e3554..07018ab5 100644 --- a/src/domain_fronter.rs +++ b/src/domain_fronter.rs @@ -30,15 +30,13 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; use tokio::sync::{broadcast, Mutex}; use tokio::time::timeout; -use tokio_rustls::client::TlsStream; -use tokio_rustls::TlsConnector; - use rustls::client::danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}; use rustls::pki_types::{CertificateDer, ServerName, UnixTime}; -use rustls::{ClientConfig, DigitallySignedStruct, SignatureScheme}; +use rustls::{DigitallySignedStruct, SignatureScheme}; use crate::cache::{cache_key, is_cacheable_method, parse_ttl, ResponseCache}; use crate::config::Config; +use crate::tls_dialer::{self, AlpnPolicy, DialedStream, TlsDialer}; #[derive(Debug, thiserror::Error)] pub enum FronterError { @@ -90,7 +88,7 @@ impl FronterError { } } -type PooledStream = TlsStream; +type PooledStream = DialedStream; const POOL_TTL_SECS: u64 = 60; const POOL_MIN: usize = 8; const POOL_REFILL_INTERVAL_SECS: u64 = 5; @@ -209,6 +207,27 @@ impl From for FronterError { } } +/// Detect cert-validation failures across both TLS backends. rustls +/// uses `UnknownIssuer` / `invalid peer certificate` / etc.; BoringSSL +/// (utls path) uses OpenSSL-style `certificate verify failed` / `unable +/// to get local issuer certificate` / `self signed certificate` — +/// either set means the ISP-MITM diagnostic should fire. +pub(crate) fn is_cert_validation_error(msg: &str) -> bool { + // rustls phrasing + msg.contains("UnknownIssuer") + || msg.contains("invalid peer certificate") + || msg.contains("CertificateExpired") + || msg.contains("CertNotValidYet") + || msg.contains("NotValidForName") + // BoringSSL / OpenSSL phrasing — surfaces via UtlsError::Handshake + || msg.contains("certificate verify failed") + || msg.contains("unable to get local issuer certificate") + || msg.contains("self signed certificate") + || msg.contains("self-signed certificate") + || msg.contains("certificate has expired") + || msg.contains("hostname mismatch") +} + pub struct DomainFronter { connect_host: String, /// Pool of SNI domains to rotate through per outbound connection. All of @@ -235,17 +254,13 @@ pub struct DomainFronter { /// Set once we've emitted the "UnknownIssuer means ISP MITM" hint, /// so we don't spam it every time a cert-validation error repeats. cert_hint_shown: std::sync::atomic::AtomicBool, - /// Connector used by `open_h2`: advertises ALPN `["h2", "http/1.1"]` - /// when the h2 fast path is enabled, else just `["http/1.1"]`. Never - /// used by the h1 pool path — see `tls_connector_h1`. - tls_connector: TlsConnector, - /// Connector used by `open()` (h1 pool warm/refill/acquire). ALPN - /// is forced to `["http/1.1"]` so a Google edge that would have - /// preferred h2 still negotiates h1 here. Without this, pooled - /// sockets could end up speaking h2 frames after handshake, and - /// the `write_all(b"GET / HTTP/1.1\r\n...")` fallback would land - /// on a server that has no idea what we're doing. - tls_connector_h1: TlsConnector, + /// Used by `open_h2`. ALPN advertises h2 first (or h1 only when + /// `force_http1`). + dialer: TlsDialer, + /// Used by `open()` (h1 pool). ALPN forced to http/1.1 so a Google + /// edge that would otherwise prefer h2 still negotiates h1 — the + /// raw HTTP/1.1 fallback path can't speak h2 frames. + dialer_h1: TlsDialer, pool: Arc>>, /// HTTP/2 fast path. `None` until first relay opens it; cleared on /// connection failure or expiry so the next call reopens. Skipped @@ -466,43 +481,32 @@ impl DomainFronter { if script_ids.is_empty() { return Err(FronterError::Relay("no script_id configured".into())); } - // Helper that builds a fresh ClientConfig with the verifier - // policy from config. We need two of these so the h2-capable - // and h1-only paths can advertise different ALPN sets without - // mutating one shared config across calls. - let build_tls_config = || { - if config.verify_ssl { - let mut roots = rustls::RootCertStore::empty(); - roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); - ClientConfig::builder() - .with_root_certificates(roots) - .with_no_client_auth() - } else { - ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth() - } + let alpn_primary = if config.force_http1 { + AlpnPolicy::Http1Only + } else { + AlpnPolicy::H2Then11 }; + let (dialer, fp_fallback_h2) = tls_dialer::build_dialer( + &config.tls_fingerprint, + config.verify_ssl, + alpn_primary, + ) + .map_err(|e| FronterError::Relay(format!("tls dialer init: {}", e)))?; + let (dialer_h1, fp_fallback_h1) = tls_dialer::build_dialer( + &config.tls_fingerprint, + config.verify_ssl, + AlpnPolicy::Http1Only, + ) + .map_err(|e| FronterError::Relay(format!("tls dialer init: {}", e)))?; - // Connector for `open_h2`: advertises h2 first (or just h1 if - // the kill switch is set, in which case both connectors end up - // identical — fine, just slightly redundant). - let mut tls_h2 = build_tls_config(); - if !config.force_http1 { - tls_h2.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - } else { - tls_h2.alpn_protocols = vec![b"http/1.1".to_vec()]; + if (fp_fallback_h2 || fp_fallback_h1) + && config.tls_fingerprint.eq_ignore_ascii_case("chrome") + { + tracing::warn!( + "tls_fingerprint='chrome' requires a build with --features utls; \ + this binary was built without it, falling back to rustls." + ); } - let tls_connector = TlsConnector::from(Arc::new(tls_h2)); - - // Connector for `open()` (h1 pool path). ALPN is forced to - // http/1.1 so a Google edge that would otherwise prefer h2 - // still negotiates h1 here — pooled sockets always speak the - // protocol the fallback path expects. - let mut tls_h1 = build_tls_config(); - tls_h1.alpn_protocols = vec![b"http/1.1".to_vec()]; - let tls_connector_h1 = TlsConnector::from(Arc::new(tls_h1)); Ok(Self { connect_host: config.google_ip.clone(), @@ -518,8 +522,8 @@ impl DomainFronter { cert_hint_shown: std::sync::atomic::AtomicBool::new(false), script_ids, script_idx: AtomicUsize::new(0), - tls_connector, - tls_connector_h1, + dialer, + dialer_h1, pool: Arc::new(Mutex::new(Vec::new())), h2_cell: Arc::new(Mutex::new(None)), h2_open_lock: Arc::new(Mutex::new(())), @@ -825,11 +829,7 @@ impl DomainFronter { /// fill the log. fn log_relay_failure(&self, e: &FronterError) { let msg = e.to_string(); - let is_cert_issue = msg.contains("UnknownIssuer") - || msg.contains("invalid peer certificate") - || msg.contains("CertificateExpired") - || msg.contains("CertNotValidYet") - || msg.contains("NotValidForName"); + let is_cert_issue = is_cert_validation_error(&msg); if is_cert_issue && !self .cert_hint_shown @@ -867,13 +867,12 @@ impl DomainFronter { let tcp = TcpStream::connect((self.connect_host.as_str(), 443u16)).await?; let _ = tcp.set_nodelay(true); let sni = self.next_sni(); - let name = ServerName::try_from(sni)?; - // Always use the h1-only connector here — the pool only holds + // Always use the h1-only dialer here — the pool only holds // sockets that the raw HTTP/1.1 fallback path can write to. - // Using the shared connector would let some pooled sockets + // Using the shared dialer would let some pooled sockets // negotiate h2, which would then misframe every fallback // request that lands on them. - let tls = self.tls_connector_h1.connect(name, tcp).await?; + let tls = self.dialer_h1.connect(&sni, tcp).await?; Ok(tls) } @@ -1217,8 +1216,7 @@ impl DomainFronter { let tcp = TcpStream::connect((self.connect_host.as_str(), 443u16)).await?; let _ = tcp.set_nodelay(true); let sni = self.next_sni(); - let name = ServerName::try_from(sni)?; - let tls = self.tls_connector.connect(name, tcp).await?; + let tls = self.dialer.connect(&sni, tcp).await?; Self::h2_handshake_post_tls(tls).await } @@ -1230,9 +1228,7 @@ impl DomainFronter { tls: PooledStream, ) -> Result, OpenH2Error> { let alpn_h2 = tls - .get_ref() - .1 - .alpn_protocol() + .negotiated_alpn() .map(|p| p == b"h2") .unwrap_or(false); if !alpn_h2 { @@ -5665,18 +5661,14 @@ hello"; tokio::time::sleep(Duration::from_millis(50)).await; }); - // Client side: open TLS with ALPN advertising h2 + h1.1; the - // server picks h1 → alpn_protocol() returns "http/1.1" not "h2". - let mut client_cfg = rustls::ClientConfig::builder() - .dangerous() - .with_custom_certificate_verifier(Arc::new(NoVerify)) - .with_no_client_auth(); - client_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; - let connector = tokio_rustls::TlsConnector::from(Arc::new(client_cfg)); + // Server-side ALPN is h1-only (above), client-side advertises + // h2 + h1.1, so server picks h1 and negotiated_alpn() != "h2". + // verify=false because the test cert is self-signed. + let (dialer, _) = + tls_dialer::build_dialer("rustls", false, AlpnPolicy::H2Then11).unwrap(); let tcp = TcpStream::connect(addr).await.unwrap(); - let name = rustls::pki_types::ServerName::try_from("127.0.0.1").unwrap(); - let tls = connector.connect(name, tcp).await.unwrap(); + let tls = dialer.connect("127.0.0.1", tcp).await.unwrap(); let result = DomainFronter::h2_handshake_post_tls(tls).await; match result { @@ -5686,4 +5678,180 @@ hello"; } server.await.unwrap(); } + + fn minimal_apps_script_config(tls_fingerprint: &str) -> Config { + let json = format!( + r#"{{ + "mode": "apps_script", + "auth_key": "secretkey123", + "script_id": "X", + "tls_fingerprint": "{}" +}}"#, + tls_fingerprint + ); + let cfg: Config = serde_json::from_str(&json).unwrap(); + cfg + } + + #[test] + fn domain_fronter_new_with_rustls_fingerprint_constructs() { + let cfg = minimal_apps_script_config("rustls"); + let f = DomainFronter::new(&cfg); + assert!(f.is_ok(), "rustls fingerprint must construct: {:?}", f.err()); + } + + #[test] + fn domain_fronter_new_with_chrome_fingerprint_does_not_panic() { + // On no-feature builds this exercises the rustls fallback path + // (warning logged, no error). With --features utls it builds + // the BoringSSL connector. Either way: must not panic, must + // return Ok. + let cfg = minimal_apps_script_config("chrome"); + let f = DomainFronter::new(&cfg); + assert!(f.is_ok(), "chrome fingerprint must construct: {:?}", f.err()); + } + + #[test] + fn domain_fronter_new_default_threads_h2_alpn_to_main_dialer() { + // The h2 fast path must advertise h2-then-h1; the h1 pool must + // advertise h1-only. If `DomainFronter::new` accidentally swaps + // them, pooled sockets would negotiate h2 frames and the raw + // HTTP/1.1 fallback path would misframe every request. + let cfg = minimal_apps_script_config("rustls"); + let f = DomainFronter::new(&cfg).unwrap(); + assert_eq!(f.dialer.alpn_policy(), AlpnPolicy::H2Then11); + assert_eq!(f.dialer_h1.alpn_policy(), AlpnPolicy::Http1Only); + } + + #[test] + fn domain_fronter_new_with_force_http1_pins_both_dialers_to_h1() { + // The kill switch in config.json (`force_http1: true`) must + // collapse the h2 dialer down to h1-only — otherwise the relay + // could still negotiate h2 on the fast path despite the user + // asking us not to. + let mut cfg = minimal_apps_script_config("rustls"); + cfg.force_http1 = true; + let f = DomainFronter::new(&cfg).unwrap(); + assert_eq!(f.dialer.alpn_policy(), AlpnPolicy::Http1Only); + assert_eq!(f.dialer_h1.alpn_policy(), AlpnPolicy::Http1Only); + } + + #[test] + fn log_relay_failure_cert_hint_fires_for_rustls_messages() { + // Sanity-anchor the cert-hint matcher against the original + // rustls phrasing. If a future refactor narrows the matcher, + // this test catches the regression. + for phrase in [ + "UnknownIssuer", + "invalid peer certificate: BadSignature", + "CertificateExpired", + "CertNotValidYet", + "NotValidForName", + ] { + assert!( + is_cert_validation_error(phrase), + "rustls phrase '{}' must trigger cert hint", + phrase + ); + } + } + + #[test] + fn log_relay_failure_cert_hint_fires_for_boringssl_messages() { + // BoringSSL surfaces cert failures via OpenSSL-style strings. + // Without these in the matcher, users on the chrome path get + // a bare "Relay failed: ..." with no MITM diagnostic — the + // single most useful error message in the codebase for IR + // users vanishes the moment they enable utls. + for phrase in [ + "tls handshake error: certificate verify failed", + "unable to get local issuer certificate", + "self signed certificate", + "self-signed certificate in certificate chain", + "certificate has expired", + "hostname mismatch", + ] { + assert!( + is_cert_validation_error(phrase), + "boringssl phrase '{}' must trigger cert hint", + phrase + ); + } + } + + #[test] + fn log_relay_failure_cert_hint_does_not_fire_for_unrelated_errors() { + for phrase in [ + "connection reset by peer", + "broken pipe", + "alpn refused h2", + "h2 handshake: GOAWAY", + "json error: missing field", + ] { + assert!( + !is_cert_validation_error(phrase), + "unrelated phrase '{}' must not trigger cert hint", + phrase + ); + } + } + + // -- Task #11: fallback warning emitted on chrome-without-feature ---- + // + // On binaries built without `--features utls`, requesting + // `tls_fingerprint=chrome` silently downgrades to rustls. The user + // must see a WARN telling them why, otherwise they think the chrome + // profile is in effect when it isn't. This test only makes sense in + // the no-feature configuration. + + #[cfg(not(feature = "utls"))] + #[test] + fn domain_fronter_new_with_chrome_warns_when_feature_disabled() { + use std::io::Write; + use std::sync::{Arc as StdArc, Mutex as StdMutex}; + use tracing_subscriber::fmt::MakeWriter; + + #[derive(Clone)] + struct BufWriter(StdArc>>); + + impl Write for BufWriter { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + impl<'a> MakeWriter<'a> for BufWriter { + type Writer = BufWriter; + fn make_writer(&'a self) -> Self::Writer { + self.clone() + } + } + + let buf = StdArc::new(StdMutex::new(Vec::new())); + let writer = BufWriter(buf.clone()); + let subscriber = tracing_subscriber::fmt() + .with_writer(writer) + .with_max_level(tracing::Level::WARN) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let cfg = minimal_apps_script_config("chrome"); + let _ = DomainFronter::new(&cfg).expect("must construct on fallback"); + + let captured = String::from_utf8(buf.lock().unwrap().clone()).unwrap(); + assert!( + captured.contains("--features utls"), + "expected fallback warning mentioning '--features utls', got:\n{}", + captured + ); + assert!( + captured.contains("chrome"), + "expected fallback warning mentioning 'chrome', got:\n{}", + captured + ); + } } diff --git a/src/lib.rs b/src/lib.rs index 6b53a32b..95b28531 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,7 +13,10 @@ pub mod tunnel_client; pub mod scan_ips; pub mod scan_sni; pub mod test_cmd; +pub mod tls_dialer; pub mod update_check; +#[cfg(feature = "utls")] +pub mod utls_connector; #[cfg(target_os = "android")] pub mod android_jni; diff --git a/src/tls_dialer.rs b/src/tls_dialer.rs new file mode 100644 index 00000000..77530753 --- /dev/null +++ b/src/tls_dialer.rs @@ -0,0 +1,900 @@ +//! Polymorphic TLS dialer over rustls or BoringSSL (uTLS). +//! +//! Captures negotiated ALPN at handshake time so callers don't need +//! to know which backend produced the stream — the relay's h2-fast- +//! path sticky-disable check still works after the swap. Tracks +//! roadmap item #369 §2. + +use std::io; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; + +use rustls::pki_types::ServerName; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; +use tokio::net::TcpStream; +use tokio_rustls::TlsConnector as RustlsTlsConnector; + +#[cfg(feature = "utls")] +use crate::utls_connector::UtlsConnector; + +pub trait AsyncReadWrite: AsyncRead + AsyncWrite + Send + Unpin {} +impl AsyncReadWrite for T {} + +pub struct DialedStream { + inner: Box, + alpn: Option>, +} + +impl DialedStream { + fn new(inner: S, alpn: Option>) -> Self { + Self { + inner: Box::new(inner), + alpn, + } + } + + pub fn negotiated_alpn(&self) -> Option<&[u8]> { + self.alpn.as_deref() + } +} + +impl AsyncRead for DialedStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut *self.get_mut().inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for DialedStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut *self.get_mut().inner).poll_write(cx, buf) + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut *self.get_mut().inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut *self.get_mut().inner).poll_shutdown(cx) + } +} + +#[derive(Clone)] +pub enum TlsDialer { + Rustls(RustlsTlsConnector, AlpnPolicy), + #[cfg(feature = "utls")] + Utls(Arc, AlpnPolicy), +} + +impl TlsDialer { + // SNI is supplied separately so callers can keep the SNI-rewrite + // trick (dial Google IP X but hand the server `www.google.com`). + // All handshake errors fold into io::Error so callers' existing + // `?`-on-io::Error propagation still works. + pub async fn connect(&self, sni: &str, tcp: TcpStream) -> io::Result { + match self { + TlsDialer::Rustls(c, _) => { + let name = ServerName::try_from(sni.to_string()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidInput, e))?; + let tls = c.connect(name, tcp).await?; + let alpn = tls.get_ref().1.alpn_protocol().map(|p| p.to_vec()); + Ok(DialedStream::new(tls, alpn)) + } + #[cfg(feature = "utls")] + TlsDialer::Utls(c, _) => { + let stream = c + .connect(sni, tcp) + .await + .map_err(|e| io::Error::new(io::ErrorKind::Other, e))?; + let alpn = stream.ssl().selected_alpn_protocol().map(|p| p.to_vec()); + Ok(DialedStream::new(stream, alpn)) + } + } + } + + /// Policy this dialer was built with — exposes the ALPN intent so + /// `DomainFronter` tests can confirm the right policy was wired to + /// the right slot (`dialer` vs `dialer_h1`) without round-tripping + /// a real handshake. + pub fn alpn_policy(&self) -> AlpnPolicy { + match self { + TlsDialer::Rustls(_, a) => *a, + #[cfg(feature = "utls")] + TlsDialer::Utls(_, a) => *a, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlpnPolicy { + H2Then11, + Http1Only, +} + +impl AlpnPolicy { + fn rustls_protos(self) -> Vec> { + match self { + AlpnPolicy::H2Then11 => vec![b"h2".to_vec(), b"http/1.1".to_vec()], + AlpnPolicy::Http1Only => vec![b"http/1.1".to_vec()], + } + } +} + +fn build_rustls_config(verify: bool, alpn: AlpnPolicy) -> Arc { + let mut cfg = if verify { + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + rustls::ClientConfig::builder() + .with_root_certificates(roots) + .with_no_client_auth() + } else { + rustls::ClientConfig::builder() + .dangerous() + .with_custom_certificate_verifier(Arc::new(NoVerify)) + .with_no_client_auth() + }; + cfg.alpn_protocols = alpn.rustls_protos(); + Arc::new(cfg) +} + +// Returns (dialer, fell_back_to_rustls). The flag is true when the +// caller asked for a non-rustls profile but the binary wasn't built +// with the matching feature — caller logs once at startup. +pub fn build_dialer( + fingerprint: &str, + verify: bool, + alpn: AlpnPolicy, +) -> Result<(TlsDialer, bool), DialerBuildError> { + let want_chrome = fingerprint.trim().eq_ignore_ascii_case("chrome"); + + if want_chrome { + #[cfg(feature = "utls")] + { + use crate::utls_connector::{AlpnPolicy as UAlpn, FingerprintProfile, UtlsConnector}; + let ualpn = match alpn { + AlpnPolicy::H2Then11 => UAlpn::H2Then11, + AlpnPolicy::Http1Only => UAlpn::Http1Only, + }; + let conn = UtlsConnector::new(FingerprintProfile::Chrome, ualpn, verify) + .map_err(|e| DialerBuildError::Utls(e.to_string()))?; + return Ok((TlsDialer::Utls(Arc::new(conn), alpn), false)); + } + #[cfg(not(feature = "utls"))] + { + let cfg = build_rustls_config(verify, alpn); + return Ok((TlsDialer::Rustls(RustlsTlsConnector::from(cfg), alpn), true)); + } + } + + let cfg = build_rustls_config(verify, alpn); + Ok((TlsDialer::Rustls(RustlsTlsConnector::from(cfg), alpn), false)) +} + +#[derive(Debug, thiserror::Error)] +pub enum DialerBuildError { + #[error("utls connector init failed: {0}")] + Utls(String), +} + +// Duplicated from domain_fronter so this module is independent of the +// relay implementation file. +#[derive(Debug)] +struct NoVerify; + +impl rustls::client::danger::ServerCertVerifier for NoVerify { + fn verify_server_cert( + &self, + _end_entity: &rustls::pki_types::CertificateDer<'_>, + _intermediates: &[rustls::pki_types::CertificateDer<'_>], + _server_name: &rustls::pki_types::ServerName<'_>, + _ocsp_response: &[u8], + _now: rustls::pki_types::UnixTime, + ) -> Result { + Ok(rustls::client::danger::ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &rustls::pki_types::CertificateDer<'_>, + _dss: &rustls::DigitallySignedStruct, + ) -> Result { + Ok(rustls::client::danger::HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + vec![ + rustls::SignatureScheme::RSA_PKCS1_SHA256, + rustls::SignatureScheme::RSA_PKCS1_SHA384, + rustls::SignatureScheme::RSA_PKCS1_SHA512, + rustls::SignatureScheme::ECDSA_NISTP256_SHA256, + rustls::SignatureScheme::ECDSA_NISTP384_SHA384, + rustls::SignatureScheme::ECDSA_NISTP521_SHA512, + rustls::SignatureScheme::RSA_PSS_SHA256, + rustls::SignatureScheme::RSA_PSS_SHA384, + rustls::SignatureScheme::RSA_PSS_SHA512, + rustls::SignatureScheme::ED25519, + ] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[test] + fn rustls_dialer_builds_default() { + let (d, fallback) = build_dialer("rustls", true, AlpnPolicy::H2Then11).unwrap(); + assert!(!fallback); + match d { + TlsDialer::Rustls(_, _) => {} + #[cfg(feature = "utls")] + TlsDialer::Utls(_, _) => panic!("expected Rustls"), + } + } + + #[test] + fn unknown_fingerprint_falls_back_to_rustls() { + let (d, _) = build_dialer("not-a-real-profile", true, AlpnPolicy::Http1Only).unwrap(); + match d { + TlsDialer::Rustls(_, _) => {} + #[cfg(feature = "utls")] + TlsDialer::Utls(_, _) => panic!("unknown profile must not select Utls"), + } + } + + #[test] + fn chrome_without_feature_reports_fallback() { + let (_, fallback) = build_dialer("chrome", true, AlpnPolicy::H2Then11).unwrap(); + #[cfg(not(feature = "utls"))] + assert!(fallback); + #[cfg(feature = "utls")] + assert!(!fallback); + } + + #[cfg(feature = "utls")] + #[test] + fn chrome_with_feature_selects_utls_variant() { + let (d, fallback) = build_dialer("chrome", true, AlpnPolicy::H2Then11).unwrap(); + assert!(!fallback); + match d { + TlsDialer::Utls(_, _) => {} + TlsDialer::Rustls(_, _) => panic!("chrome with --features utls must pick Utls"), + } + } + + #[test] + fn alpn_h2_then_11_protos() { + assert_eq!( + AlpnPolicy::H2Then11.rustls_protos(), + vec![b"h2".to_vec(), b"http/1.1".to_vec()] + ); + } + + #[test] + fn alpn_http1_only_protos() { + assert_eq!( + AlpnPolicy::Http1Only.rustls_protos(), + vec![b"http/1.1".to_vec()] + ); + } + + #[test] + fn dialed_stream_alpn_getter_round_trips() { + let (a, _b) = tokio::io::duplex(64); + let s = DialedStream::new(a, Some(b"h2".to_vec())); + assert_eq!(s.negotiated_alpn(), Some(b"h2".as_slice())); + } + + #[test] + fn dialed_stream_alpn_none_when_unset() { + let (a, _b) = tokio::io::duplex(64); + let s = DialedStream::new(a, None); + assert_eq!(s.negotiated_alpn(), None); + } + + #[tokio::test(flavor = "current_thread")] + async fn dialed_stream_round_trips_data() { + // Catches Pin/AsyncRead/AsyncWrite delegation bugs in the + // boxed-trait-object glue. If poll_read/poll_write don't + // forward to inner correctly, this hangs or drops bytes. + let (a, b) = tokio::io::duplex(1024); + let mut wrapped = DialedStream::new(a, None); + let mut peer = b; + + let payload = b"hello via dialed stream"; + wrapped.write_all(payload).await.unwrap(); + wrapped.flush().await.unwrap(); + + let mut buf = vec![0u8; payload.len()]; + peer.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, payload); + + // Reverse direction. + peer.write_all(b"and back").await.unwrap(); + let mut back = [0u8; 8]; + wrapped.read_exact(&mut back).await.unwrap(); + assert_eq!(&back, b"and back"); + } + + #[tokio::test(flavor = "current_thread")] + async fn rustls_dialer_handshakes_against_local_server() { + // End-to-end: build a TlsDialer::Rustls, dial a self-signed + // local rustls server with ALPN=h2, confirm handshake succeeds + // and negotiated_alpn() reads back what the server picked. + let cert = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap(); + let cert_der = rustls::pki_types::CertificateDer::from(cert.cert.der().to_vec()); + let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()), + ); + + let mut server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (sock, _) = listener.accept().await.unwrap(); + let _tls = acceptor.accept(sock).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }); + + let (dialer, _) = build_dialer("rustls", false, AlpnPolicy::H2Then11).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let stream = dialer.connect("127.0.0.1", tcp).await.unwrap(); + assert_eq!(stream.negotiated_alpn(), Some(b"h2".as_slice())); + + let _ = server.await; + } + + #[tokio::test(flavor = "current_thread")] + async fn rustls_dialer_returns_invalid_input_on_bad_sni() { + // Pin SNI parse-error wrapping: dialer must surface as + // io::ErrorKind::InvalidInput, not panic. We still need a + // listener so TcpStream::connect succeeds — the error happens + // during the handshake setup, before any TLS bytes flow. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _server = tokio::spawn(async move { + let _ = listener.accept().await; + }); + + let (dialer, _) = build_dialer("rustls", false, AlpnPolicy::Http1Only).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let err = match dialer.connect("not a valid sni!!", tcp).await { + Err(e) => e, + Ok(_) => panic!("malformed SNI must error"), + }; + assert_eq!(err.kind(), io::ErrorKind::InvalidInput); + } + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn utls_dialer_handshakes_against_local_h2_server() { + // Equivalent of rustls_dialer_handshakes_against_local_server + // but exercising the BoringSSL path. Confirms ALPN selection + // is extracted correctly from the boring SslStream. + let cert = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap(); + let cert_der = rustls::pki_types::CertificateDer::from(cert.cert.der().to_vec()); + let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()), + ); + + let mut server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (sock, _) = listener.accept().await.unwrap(); + let _tls = acceptor.accept(sock).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }); + + let (dialer, _) = build_dialer("chrome", false, AlpnPolicy::H2Then11).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let stream = dialer.connect("127.0.0.1", tcp).await.unwrap(); + assert_eq!(stream.negotiated_alpn(), Some(b"h2".as_slice())); + + let _ = server.await; + } + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn utls_dialer_handshakes_against_local_h1_only_server() { + // BoringSSL ALPN-refused path: server only advertises h1, + // client requested h2-then-h1, BoringSSL must report + // selected_alpn_protocol() == "http/1.1". + let cert = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap(); + let cert_der = rustls::pki_types::CertificateDer::from(cert.cert.der().to_vec()); + let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()), + ); + + let mut server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + server_cfg.alpn_protocols = vec![b"http/1.1".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let server = tokio::spawn(async move { + let (sock, _) = listener.accept().await.unwrap(); + let _tls = acceptor.accept(sock).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + }); + + let (dialer, _) = build_dialer("chrome", false, AlpnPolicy::H2Then11).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let stream = dialer.connect("127.0.0.1", tcp).await.unwrap(); + assert_eq!(stream.negotiated_alpn(), Some(b"http/1.1".as_slice())); + + let _ = server.await; + } + + // -- Task #6: build_dialer trim/case tolerance ------------------------ + + #[test] + fn build_dialer_accepts_chrome_case_and_whitespace_variants() { + // The config layer trims/lowercases before validation, but + // `build_dialer` is also called from places (tests, future + // direct callers) that don't go through Config. Pin that the + // dialer itself tolerates the same input shape. Without this, + // an upstream refactor that drops one of the two normalisations + // would let one path accept " Chrome " and the other reject. + for value in ["chrome", "Chrome", "CHROME", " chrome", "chrome ", " CHROME "] { + let (dialer, fell_back) = build_dialer(value, true, AlpnPolicy::H2Then11) + .unwrap_or_else(|e| panic!("variant '{}' must build: {}", value, e)); + #[cfg(feature = "utls")] + { + assert!(!fell_back, "variant '{}' must select Utls, not fall back", value); + assert!(matches!(dialer, TlsDialer::Utls(_, _))); + } + #[cfg(not(feature = "utls"))] + { + assert!(fell_back, "variant '{}' must report fallback flag", value); + assert!(matches!(dialer, TlsDialer::Rustls(_, _))); + } + } + } + + #[test] + fn build_dialer_unknown_value_does_not_match_chrome_loosely() { + // Adjacent regression: matcher is `eq_ignore_ascii_case` after + // `trim()`, NOT `contains`. `"chromeish"` must not be treated + // as chrome. + for value in ["chromeish", "chrom", "rustls-extra", "ChromeSafari"] { + let (dialer, fell_back) = build_dialer(value, true, AlpnPolicy::Http1Only).unwrap(); + assert!( + !fell_back, + "variant '{}' must not be treated as a recognised non-rustls profile", + value + ); + assert!( + matches!(dialer, TlsDialer::Rustls(_, _)), + "variant '{}' must default to Rustls", + value + ); + } + } + + // -- Task #7: DialedStream poll_shutdown delegates -------------------- + + #[test] + fn dialed_stream_is_send_and_unpin() { + // The pool holds these inside `Arc>>`, + // and h2 handshakes spawn tasks that move the stream across + // threads — so DialedStream must remain Send + Unpin. If a + // future refactor relaxes the AsyncReadWrite trait bound to + // drop Send, this test fails to compile, which is the goal. + fn assert_send() {} + fn assert_unpin() {} + assert_send::(); + assert_unpin::(); + } + + #[tokio::test(flavor = "current_thread")] + async fn dialed_stream_survives_arc_mutex_pool_storage() { + // Mirror the relay's pool data structure exactly: + // `Arc>>`. A regression + // that, say, accidentally adds a `!Send` field to DialedStream + // would break compilation here. Cheap insurance for a hot path. + use std::sync::Arc; + use tokio::sync::Mutex; + + let (a, _b) = tokio::io::duplex(64); + let stream = DialedStream::new(a, Some(b"h2".to_vec())); + let pool: Arc>> = Arc::new(Mutex::new(Vec::new())); + pool.lock().await.push(stream); + // Spawn requires Send+'static; if DialedStream isn't Send the + // task move below won't compile. + let pool2 = pool.clone(); + tokio::spawn(async move { + let popped = pool2.lock().await.pop(); + assert!(popped.is_some(), "pushed stream must be retrievable"); + assert_eq!(popped.unwrap().negotiated_alpn(), Some(b"h2".as_slice())); + }) + .await + .unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn dialed_stream_poll_shutdown_propagates_to_inner() { + // If `poll_shutdown` doesn't delegate to the boxed inner + // stream, the peer never sees EOF and a half-closed read on + // the other side hangs forever. Cheap regression hook for the + // boxed-trait-object glue. + let (a, mut peer) = tokio::io::duplex(64); + let mut wrapped = DialedStream::new(a, None); + wrapped.write_all(b"bye").await.unwrap(); + wrapped.shutdown().await.unwrap(); + + let mut buf = Vec::new(); + // read_to_end returns once the inner half is closed; if shutdown + // didn't propagate, this hangs and the test times out. + peer.read_to_end(&mut buf).await.unwrap(); + assert_eq!(buf, b"bye"); + } + + // -- Task #8: bad SNI surfaces FronterError variant cleanly ----------- + // + // The variant-collapse from `FronterError::Dns(InvalidDnsNameError)` + // (pre-branch) to `FronterError::Io(InvalidInput)` (post-branch) is + // a deliberate consequence of folding handshake errors through + // `io::Error` so the dialer abstraction hides which backend ran. + // The existing `rustls_dialer_returns_invalid_input_on_bad_sni` test + // pins the kind. This test pins the message content so callers that + // grep error strings (logs, user-visible diagnostics) still see + // "invalid dns name" semantics surfaced through the io error. + + #[tokio::test(flavor = "current_thread")] + async fn bad_sni_io_error_message_mentions_dns() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _server = tokio::spawn(async move { + let _ = listener.accept().await; + }); + let (dialer, _) = build_dialer("rustls", false, AlpnPolicy::Http1Only).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let err = match dialer.connect("not a valid sni!!", tcp).await { + Err(e) => e, + Ok(_) => panic!("malformed SNI must error"), + }; + let msg = err.to_string().to_lowercase(); + assert!( + msg.contains("dns") || msg.contains("name") || msg.contains("invalid"), + "bad-SNI io error must surface dns/name semantics, got: {}", + err + ); + } + + // -- Task #5: Chrome ClientHello shape characterization --------------- + // + // The whole point of `tls_fingerprint=chrome` is to make the bytes + // on the wire match what real Chrome would send. Constructing the + // connector without error proves wiring; only inspecting the actual + // ClientHello proves shape. This test runs only on `--features utls` + // because the rustls path doesn't pretend to be Chrome. + + #[cfg(feature = "utls")] + async fn capture_chrome_client_hello(sni: &str, alpn: AlpnPolicy) -> Vec { + use std::time::Duration; + // Spin a TCP listener, capture the first ClientHello frame, then + // close — the dialer's handshake will fail (which we ignore), but + // the bytes we care about have already crossed the socket. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let capture = tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = vec![0u8; 4096]; + let n = tokio::time::timeout(Duration::from_secs(2), sock.read(&mut buf)) + .await + .ok() + .and_then(|r| r.ok()) + .unwrap_or(0); + buf.truncate(n); + drop(sock); + buf + }); + let (dialer, _) = build_dialer("chrome", false, alpn).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let _ = + tokio::time::timeout(Duration::from_secs(2), dialer.connect(sni, tcp)).await; + capture.await.unwrap() + } + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn chrome_clienthello_advertises_chrome_shape() { + let bytes = capture_chrome_client_hello("example.com", AlpnPolicy::H2Then11).await; + let hello = parse_client_hello(&bytes).expect("must parse a ClientHello"); + + // ALPN extension (type 0x0010) must be present and exactly the + // h2,http/1.1 vector — anything else means the policy didn't + // thread through, or BoringSSL silently mutated it. + let alpn = hello.extension(0x0010).expect("ALPN extension required"); + assert_eq!( + alpn, + b"\x00\x0c\x02h2\x08http/1.1", + "ALPN extension must contain exactly h2,http/1.1 in that order" + ); + + // supported_versions (0x002b) must list TLS 1.3 (0x0304). Real + // Chrome always advertises TLS 1.3. + let sv = hello + .extension(0x002b) + .expect("supported_versions extension required"); + assert!( + sv.windows(2).any(|w| w == [0x03, 0x04]), + "supported_versions must include TLS 1.3 (0x0304); got {:02x?}", + sv + ); + + // supported_groups (0x000a) must start with X25519 (0x001d), then + // P-256 (0x0017), then P-384 (0x0018) — Chrome's preference order. + // The order is part of the JA3 fingerprint. + let sg = hello + .extension(0x000a) + .expect("supported_groups extension required"); + // first 2 bytes are the list length, then 2 bytes per group + assert!(sg.len() >= 2 + 6, "supported_groups too short: {:?}", sg); + assert_eq!(&sg[2..4], &[0x00, 0x1d], "first group must be X25519"); + assert_eq!(&sg[4..6], &[0x00, 0x17], "second group must be P-256"); + assert_eq!(&sg[6..8], &[0x00, 0x18], "third group must be P-384"); + + // signature_algorithms (0x000d) must start with + // ecdsa_secp256r1_sha256 (0x0403) — first sigalg in Chrome's list. + let sa = hello + .extension(0x000d) + .expect("signature_algorithms extension required"); + assert!(sa.len() >= 4, "signature_algorithms too short: {:?}", sa); + assert_eq!( + &sa[2..4], + &[0x04, 0x03], + "first sigalg must be ecdsa_secp256r1_sha256" + ); + + // Cipher-suites: must contain all three TLS 1.3 ciphers BoringSSL + // hardcodes (0x1301, 0x1302, 0x1303) AND the top TLS 1.2 ECDHE + // GCM ciphers from the Chrome list (0xc02b, 0xc02f). We don't + // pin exact order across the whole list because BoringSSL + // version bumps may shuffle TLS 1.2 entries; we DO pin the + // TLS 1.3 set since that's the part of the fingerprint that + // diverges most from a default rustls connector. + for needle in [ + [0x13u8, 0x01], + [0x13, 0x02], + [0x13, 0x03], + [0xc0, 0x2b], + [0xc0, 0x2f], + ] { + assert!( + hello + .cipher_suites + .chunks(2) + .any(|c| c == needle), + "cipher 0x{:02x}{:02x} missing from ClientHello", + needle[0], + needle[1] + ); + } + } + + // -- Task #13: SNI extension on chrome ClientHello ------------------- + // + // BoringSSL only sends SNI when `set_use_server_name_indication(true)` + // is in effect — `UtlsConnector::connect` sets it. If a refactor + // flips that to false, this test fails: the ClientHello won't carry + // the SNI extension, the Google edge can't pick the right cert, and + // every relay request fails with a TLS handshake error. Fast hint + // for a hard-to-debug regression. + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn chrome_clienthello_carries_sni_extension() { + let bytes = capture_chrome_client_hello("www.google.com", AlpnPolicy::H2Then11).await; + let hello = parse_client_hello(&bytes).expect("must parse a ClientHello"); + + // server_name extension wire format: + // list_length(2) | name_type(1=0x00) | name_length(2) | name + let sni = hello + .extension(0x0000) + .expect("server_name (SNI) extension required"); + assert!(sni.len() >= 5, "SNI extension too short: {:?}", sni); + assert_eq!(sni[2], 0x00, "SNI name_type must be host_name (0x00)"); + let name_len = u16::from_be_bytes([sni[3], sni[4]]) as usize; + let name = &sni[5..5 + name_len]; + assert_eq!( + name, b"www.google.com", + "SNI hostname mismatch — set_use_server_name_indication likely off" + ); + } + + // -- Task #14: OCSP status_request on chrome ClientHello ------------- + // + // `apply_chrome_profile` calls `enable_ocsp_stapling()`. Real Chrome + // always sends the status_request extension. A future refactor that + // drops the call would not break any existing test — but JA3/JA4 + // fingerprints would silently diverge. Pin the on-the-wire result. + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn chrome_clienthello_advertises_ocsp_status_request() { + let bytes = capture_chrome_client_hello("example.com", AlpnPolicy::H2Then11).await; + let hello = parse_client_hello(&bytes).expect("must parse a ClientHello"); + + let sr = hello + .extension(0x0005) + .expect("status_request (OCSP) extension required"); + // status_request body: certificate_status_type(1=0x01 ocsp) + + // responder_id_list_length(2) + responder_id_list + + // request_extensions_length(2) + request_extensions. + // Minimum well-formed body = 1 + 2 + 0 + 2 + 0 = 5 bytes. + assert!(sr.len() >= 5, "status_request too short: {:?}", sr); + assert_eq!( + sr[0], 0x01, + "status_request certificate_status_type must be ocsp (0x01)" + ); + } + + // -- Task #15: Http1Only ALPN on chrome path ------------------------- + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn chrome_clienthello_with_http1_only_alpn() { + // Pairs with chrome_clienthello_advertises_chrome_shape: when + // `force_http1=true` threads `AlpnPolicy::Http1Only` to the + // chrome dialer, the wire-level ALPN extension must drop "h2". + let bytes = capture_chrome_client_hello("example.com", AlpnPolicy::Http1Only).await; + let hello = parse_client_hello(&bytes).expect("must parse a ClientHello"); + + let alpn = hello.extension(0x0010).expect("ALPN extension required"); + assert_eq!( + alpn, + b"\x00\x09\x08http/1.1", + "Http1Only ALPN must encode http/1.1 as the only entry; got {:02x?}", + alpn + ); + } + + /// Minimal TLS 1.x ClientHello parser — just enough to look up + /// extensions by type and read the cipher-suites list. Not a full + /// TLS implementation; intentionally fails fast on truncated input + /// so test failures point at where the parse went off the rails. + #[cfg(feature = "utls")] + struct ClientHello<'a> { + cipher_suites: &'a [u8], + extensions: &'a [u8], + } + + #[cfg(feature = "utls")] + impl<'a> ClientHello<'a> { + fn extension(&self, ty: u16) -> Option<&'a [u8]> { + let mut p = self.extensions; + while p.len() >= 4 { + let t = u16::from_be_bytes([p[0], p[1]]); + let len = u16::from_be_bytes([p[2], p[3]]) as usize; + if p.len() < 4 + len { + return None; + } + if t == ty { + return Some(&p[4..4 + len]); + } + p = &p[4 + len..]; + } + None + } + } + + #[cfg(feature = "utls")] + fn parse_client_hello(buf: &[u8]) -> Option> { + // Record layer: type(1) + version(2) + length(2) + if buf.len() < 5 || buf[0] != 0x16 { + return None; + } + let rec_len = u16::from_be_bytes([buf[3], buf[4]]) as usize; + let record = buf.get(5..5 + rec_len)?; + // Handshake header: type(1) + length(3) + if record.len() < 4 || record[0] != 0x01 { + return None; + } + let hs_len = ((record[1] as usize) << 16) | ((record[2] as usize) << 8) | record[3] as usize; + let body = record.get(4..4 + hs_len)?; + // client_version(2) + random(32) = 34 bytes + let mut p = body.get(34..)?; + // session_id + let sid_len = *p.first()? as usize; + p = p.get(1 + sid_len..)?; + // cipher_suites + if p.len() < 2 { + return None; + } + let cs_len = u16::from_be_bytes([p[0], p[1]]) as usize; + let cipher_suites = p.get(2..2 + cs_len)?; + p = p.get(2 + cs_len..)?; + // compression_methods + let cm_len = *p.first()? as usize; + p = p.get(1 + cm_len..)?; + // extensions + if p.len() < 2 { + return None; + } + let ext_len = u16::from_be_bytes([p[0], p[1]]) as usize; + let extensions = p.get(2..2 + ext_len)?; + Some(ClientHello { + cipher_suites, + extensions, + }) + } + + // -- Task #10: verify_ssl=false on chrome path actually skips check --- + + #[cfg(feature = "utls")] + #[tokio::test(flavor = "current_thread")] + async fn chrome_dialer_with_verify_true_rejects_self_signed() { + // Adjacent contract: with verify=true, the chrome dialer MUST + // reject a self-signed cert. The matching success-path test + // (`utls_dialer_handshakes_against_local_h2_server`) builds the + // dialer with verify=false; without this counterpart, a + // refactor that hard-codes verify=false on the boring path + // would silently disable cert checking everywhere. + let cert = rcgen::generate_simple_self_signed(vec!["127.0.0.1".to_string()]).unwrap(); + let cert_der = rustls::pki_types::CertificateDer::from(cert.cert.der().to_vec()); + let key_der = rustls::pki_types::PrivateKeyDer::Pkcs8( + rustls::pki_types::PrivatePkcs8KeyDer::from(cert.key_pair.serialize_der()), + ); + + let mut server_cfg = rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(vec![cert_der], key_der) + .unwrap(); + server_cfg.alpn_protocols = vec![b"h2".to_vec(), b"http/1.1".to_vec()]; + let acceptor = tokio_rustls::TlsAcceptor::from(Arc::new(server_cfg)); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let _server = tokio::spawn(async move { + // The accept will likely fail mid-handshake when the client + // bails on the bad cert; ignore the result. + if let Ok((sock, _)) = listener.accept().await { + let _ = acceptor.accept(sock).await; + } + }); + + let (dialer, _) = build_dialer("chrome", true, AlpnPolicy::H2Then11).unwrap(); + let tcp = TcpStream::connect(addr).await.unwrap(); + let result = dialer.connect("127.0.0.1", tcp).await; + assert!( + result.is_err(), + "chrome dialer with verify=true must reject a self-signed cert" + ); + } +} diff --git a/src/utls_connector.rs b/src/utls_connector.rs new file mode 100644 index 00000000..c4a9781b --- /dev/null +++ b/src/utls_connector.rs @@ -0,0 +1,165 @@ +//! BoringSSL-backed TLS connector with Chrome ClientHello shape. +//! +//! Real Chrome ships BoringSSL, so the bytes on the wire match what +//! a real browser would send. Tracks roadmap item #369 §2. Compiled +//! only with `--features utls` because BoringSSL needs CMake + clang +//! at build time. + +use std::io; +use std::sync::Arc; + +use rama_boring::ssl::{SslConnector, SslMethod, SslOptions, SslVerifyMode, SslVersion}; +use rama_boring_tokio::SslStream; +use tokio::net::TcpStream; + +#[derive(Debug, thiserror::Error)] +pub enum UtlsError { + #[error("boringssl error: {0}")] + Ssl(#[from] rama_boring::error::ErrorStack), + #[error("tls handshake error: {0}")] + Handshake(String), + #[error("io error: {0}")] + Io(#[from] io::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AlpnPolicy { + H2Then11, + Http1Only, +} + +impl AlpnPolicy { + fn wire_bytes(self) -> Vec { + // ALPN wire format: each protocol prefixed with its length byte. + match self { + AlpnPolicy::H2Then11 => b"\x02h2\x08http/1.1".to_vec(), + AlpnPolicy::Http1Only => b"\x08http/1.1".to_vec(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FingerprintProfile { + Chrome, +} + +pub struct UtlsConnector { + inner: SslConnector, + verify: bool, +} + +impl UtlsConnector { + pub fn new( + profile: FingerprintProfile, + alpn: AlpnPolicy, + verify: bool, + ) -> Result { + let mut builder = SslConnector::builder(SslMethod::tls_client())?; + match profile { + FingerprintProfile::Chrome => apply_chrome_profile(&mut builder)?, + } + builder.set_alpn_protos(&alpn.wire_bytes())?; + if !verify { + builder.set_verify(SslVerifyMode::NONE); + } + Ok(Self { + inner: builder.build(), + verify, + }) + } + + pub async fn connect( + &self, + sni: &str, + tcp: TcpStream, + ) -> Result, UtlsError> { + let mut config = self.inner.configure()?; + config.set_use_server_name_indication(true); + config.set_verify_hostname(self.verify); + rama_boring_tokio::connect(config, Some(sni), tcp) + .await + .map_err(|e| UtlsError::Handshake(e.to_string())) + } +} + +// Cipher list, curves, and sigalgs are in Chrome's preference order — +// order is part of the JA3/JA4 fingerprint, so changing it changes the +// shape on the wire. +fn apply_chrome_profile( + builder: &mut rama_boring::ssl::SslConnectorBuilder, +) -> Result<(), UtlsError> { + builder.set_min_proto_version(Some(SslVersion::TLS1_2))?; + builder.set_max_proto_version(Some(SslVersion::TLS1_3))?; + // BoringSSL hardcodes the TLS 1.3 cipher set to match Chrome, so + // there's no `set_ciphersuites` to call here. Only the TLS 1.2 + // cipher list is settable, below. + builder.set_cipher_list( + "ECDHE-ECDSA-AES128-GCM-SHA256:\ + ECDHE-RSA-AES128-GCM-SHA256:\ + ECDHE-ECDSA-AES256-GCM-SHA384:\ + ECDHE-RSA-AES256-GCM-SHA384:\ + ECDHE-ECDSA-CHACHA20-POLY1305:\ + ECDHE-RSA-CHACHA20-POLY1305:\ + ECDHE-RSA-AES128-SHA:\ + ECDHE-RSA-AES256-SHA:\ + AES128-GCM-SHA256:\ + AES256-GCM-SHA384:\ + AES128-SHA:\ + AES256-SHA", + )?; + builder.set_curves_list("X25519:P-256:P-384")?; + builder.set_sigalgs_list( + "ecdsa_secp256r1_sha256:\ + rsa_pss_rsae_sha256:\ + rsa_pkcs1_sha256:\ + ecdsa_secp384r1_sha384:\ + rsa_pss_rsae_sha384:\ + rsa_pkcs1_sha384:\ + rsa_pss_rsae_sha512:\ + rsa_pkcs1_sha512", + )?; + builder.enable_ocsp_stapling(); + builder.set_options( + SslOptions::NO_SSLV2 | SslOptions::NO_SSLV3 | SslOptions::NO_TLSV1 | SslOptions::NO_TLSV1_1, + ); + Ok(()) +} + +pub type SharedUtlsConnector = Arc; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn alpn_h2_then_11_wire_format() { + let bytes = AlpnPolicy::H2Then11.wire_bytes(); + assert_eq!(bytes.len(), 12); + assert_eq!(&bytes[..3], b"\x02h2"); + assert_eq!(&bytes[3..], b"\x08http/1.1"); + } + + #[test] + fn alpn_http1_only_wire_format() { + let bytes = AlpnPolicy::Http1Only.wire_bytes(); + assert_eq!(bytes.len(), 9); + assert_eq!(&bytes[..], b"\x08http/1.1"); + } + + #[test] + fn chrome_profile_builds_clean() { + let conn = UtlsConnector::new(FingerprintProfile::Chrome, AlpnPolicy::H2Then11, true); + assert!( + conn.is_ok(), + "Chrome profile failed to build: {:?}", + conn.err() + ); + } + + #[test] + fn no_verify_path_builds() { + let conn = UtlsConnector::new(FingerprintProfile::Chrome, AlpnPolicy::Http1Only, false); + assert!(conn.is_ok()); + assert!(!conn.unwrap().verify); + } +}