use crate::stealth::{DeviceClass, StealthProfile};
use boring2::ssl::{
CertCompressionAlgorithm, ConnectConfiguration, SslConnector, SslCurve, SslMethod, SslOptions,
SslVersion,
};
use boring2::x509::store::X509StoreBuilder;
use boring2::x509::X509;
use foreign_types::ForeignTypeRef;
use tokio::net::TcpStream;
use tokio_boring2::SslStream;
use crate::net::error::NetError;
pub const TLS_CHROME_MAJOR: u32 = 147;
pub const UA_CHROME_MAJOR: u32 = 148;
const CIPHER_LIST: &str = concat!(
"TLS_AES_128_GCM_SHA256",
":TLS_AES_256_GCM_SHA384",
":TLS_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
":TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
":TLS_RSA_WITH_AES_128_GCM_SHA256",
":TLS_RSA_WITH_AES_256_GCM_SHA384",
":TLS_RSA_WITH_AES_128_CBC_SHA",
":TLS_RSA_WITH_AES_256_CBC_SHA",
);
const SIGALGS_LIST: &str = concat!(
"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",
);
const CURVES_DESKTOP: &[SslCurve] = &[
SslCurve::X25519_MLKEM768,
SslCurve::X25519,
SslCurve::SECP256R1,
SslCurve::SECP384R1,
];
const CURVES_ANDROID: &[SslCurve] = CURVES_DESKTOP;
const CIPHER_LIST_SAFARI_IOS: &str = concat!(
"TLS_AES_128_GCM_SHA256",
":TLS_AES_256_GCM_SHA384",
":TLS_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
":TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
":TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
":TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
":TLS_RSA_WITH_AES_256_GCM_SHA384",
":TLS_RSA_WITH_AES_128_GCM_SHA256",
":TLS_RSA_WITH_AES_256_CBC_SHA",
":TLS_RSA_WITH_AES_128_CBC_SHA",
":TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
":TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
":TLS_RSA_WITH_3DES_EDE_CBC_SHA",
);
const SIGALGS_LIST_SAFARI_IOS: &str = concat!(
"ecdsa_secp256r1_sha256",
":rsa_pss_rsae_sha256",
":rsa_pkcs1_sha256",
":ecdsa_secp384r1_sha384",
":rsa_pss_rsae_sha384",
":rsa_pss_rsae_sha384",
":rsa_pkcs1_sha384",
":rsa_pss_rsae_sha512",
":rsa_pkcs1_sha512",
":rsa_pkcs1_sha1",
);
const CURVES_SAFARI_IOS: &[SslCurve] = &[
SslCurve::X25519,
SslCurve::SECP256R1,
SslCurve::SECP384R1,
SslCurve::SECP521R1,
];
const SAFARI_IOS_EXTENSION_PERMUTATION: &[u8] = &[
0, 2, 3, 4, 5, 7, 8, 9, 11, 14, 15, 17, 21, ];
const CIPHER_LIST_FIREFOX: &str = concat!(
"TLS_AES_128_GCM_SHA256",
":TLS_CHACHA20_POLY1305_SHA256",
":TLS_AES_256_GCM_SHA384",
":TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256",
":TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256",
":TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384",
":TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
":TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
":TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
":TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
":TLS_RSA_WITH_AES_128_GCM_SHA256",
":TLS_RSA_WITH_AES_256_GCM_SHA384",
":TLS_RSA_WITH_AES_128_CBC_SHA",
":TLS_RSA_WITH_AES_256_CBC_SHA",
);
const SIGALGS_LIST_FIREFOX: &str = concat!(
"ecdsa_secp256r1_sha256",
":ecdsa_secp384r1_sha384",
":ecdsa_secp521r1_sha512",
":rsa_pss_rsae_sha256",
":rsa_pss_rsae_sha384",
":rsa_pss_rsae_sha512",
":rsa_pkcs1_sha256",
":rsa_pkcs1_sha384",
":rsa_pkcs1_sha512",
":ecdsa_sha1",
":rsa_pkcs1_sha1",
);
const CURVES_FIREFOX: &[SslCurve] = &[
SslCurve::X25519_MLKEM768,
SslCurve::X25519,
SslCurve::SECP256R1,
SslCurve::SECP384R1,
SslCurve::SECP521R1,
SslCurve::FFDHE2048,
SslCurve::FFDHE3072,
];
const FIREFOX_DELEGATED_CREDENTIALS: &str = concat!(
"ecdsa_secp256r1_sha256",
":ecdsa_secp384r1_sha384",
":ecdsa_secp521r1_sha512",
":ecdsa_sha1",
);
const FIREFOX_RECORD_SIZE_LIMIT: u16 = 0x4001;
const FIREFOX_EXTENSION_PERMUTATION: &[u8] = &[
0, 2, 3, 4, 5, 6, 7, 8, 22, 14, 17, 9, 15, 25, 1, ];
const ALPN_PROTOS: &[u8] = b"\x02h2\x08http/1.1";
use rand::prelude::SliceRandom;
const CHROME_EXTENSION_PERMUTATION: &[u8] = &[
14, 1, 4, 11, 15, 2, 24, 21, 17, 0, 3, 5, 8, 7, 6, 9, ];
fn shuffled_chrome_extension_permutation() -> Vec<u8> {
let mut rng = rand::rng();
let mut permutation = CHROME_EXTENSION_PERMUTATION.to_vec();
permutation.shuffle(&mut rng);
permutation
}
pub fn chrome_connector(profile: &StealthProfile) -> Result<SslConnector, NetError> {
let is_safari_ios = profile.device_class == DeviceClass::MobileIOS;
let is_firefox = profile.browser_name == "Firefox";
let curves: &[SslCurve] = if is_firefox {
CURVES_FIREFOX
} else {
match profile.device_class {
DeviceClass::MobileAndroid => CURVES_ANDROID,
DeviceClass::MobileIOS => CURVES_SAFARI_IOS,
DeviceClass::Desktop => CURVES_DESKTOP,
}
};
let cipher_list: &str = if is_safari_ios {
CIPHER_LIST_SAFARI_IOS
} else if is_firefox {
CIPHER_LIST_FIREFOX
} else {
CIPHER_LIST
};
let sigalgs_list: &str = if is_safari_ios {
SIGALGS_LIST_SAFARI_IOS
} else if is_firefox {
SIGALGS_LIST_FIREFOX
} else {
SIGALGS_LIST
};
let mut builder =
SslConnector::builder(SslMethod::tls()).map_err(|e| NetError::Tls(e.to_string()))?;
builder
.set_cipher_list(cipher_list)
.map_err(|e| NetError::Tls(e.to_string()))?;
builder
.set_curves(curves)
.map_err(|e| NetError::Tls(e.to_string()))?;
builder
.set_sigalgs_list(sigalgs_list)
.map_err(|e| NetError::Tls(e.to_string()))?;
builder
.set_alpn_protos(ALPN_PROTOS)
.map_err(|e| NetError::Tls(e.to_string()))?;
let min_version = if is_safari_ios {
SslVersion::TLS1
} else {
SslVersion::TLS1_2
};
builder
.set_min_proto_version(Some(min_version))
.map_err(|e| NetError::Tls(e.to_string()))?;
builder
.set_max_proto_version(Some(SslVersion::TLS1_3))
.map_err(|e| NetError::Tls(e.to_string()))?;
builder.set_grease_enabled(!is_firefox);
builder.set_permute_extensions(false);
builder.enable_ocsp_stapling();
builder.enable_signed_cert_timestamps();
builder.set_key_shares_limit(2);
if is_firefox {
builder
.set_delegated_credentials(FIREFOX_DELEGATED_CREDENTIALS)
.map_err(|e| NetError::Tls(e.to_string()))?;
builder.set_record_size_limit(FIREFOX_RECORD_SIZE_LIMIT);
}
if is_firefox {
builder
.add_cert_compression_alg(CertCompressionAlgorithm::Zlib)
.map_err(|e| NetError::Tls(e.to_string()))?;
builder
.add_cert_compression_alg(CertCompressionAlgorithm::Brotli)
.map_err(|e| NetError::Tls(e.to_string()))?;
} else {
let cert_compress_alg = if is_safari_ios {
CertCompressionAlgorithm::Zlib
} else {
CertCompressionAlgorithm::Brotli
};
builder
.add_cert_compression_alg(cert_compress_alg)
.map_err(|e| NetError::Tls(e.to_string()))?;
}
if is_safari_ios {
builder.set_options(SslOptions::NO_TICKET);
}
let mut cert_store = X509StoreBuilder::new().map_err(|e| NetError::Tls(e.to_string()))?;
for cert_der in webpki_root_certs::TLS_SERVER_ROOT_CERTS {
let x509 = X509::from_der(cert_der.as_ref())
.map_err(|e| NetError::Tls(format!("failed to parse root cert: {e}")))?;
let _ = cert_store.add_cert(x509);
}
builder.set_cert_store(cert_store.build());
let connector = builder.build();
let permutation = if is_safari_ios {
SAFARI_IOS_EXTENSION_PERMUTATION.to_vec()
} else if is_firefox {
FIREFOX_EXTENSION_PERMUTATION.to_vec()
} else {
shuffled_chrome_extension_permutation()
};
unsafe {
boring_sys2::SSL_CTX_set_extension_permutation(
connector.context().as_ptr(),
permutation.as_ptr(),
permutation.len(),
);
}
Ok(connector)
}
pub fn configure_connection(
connector: &SslConnector,
profile: &StealthProfile,
domain: &str,
) -> Result<ConnectConfiguration, NetError> {
let mut config = connector
.configure()
.map_err(|e| NetError::Tls(e.to_string()))?;
let is_safari_ios = profile.device_class == DeviceClass::MobileIOS;
let is_firefox = profile.browser_name == "Firefox";
if !is_safari_ios {
config.set_enable_ech_grease(true);
}
if !is_safari_ios && !is_firefox {
let alps_payload: &[u8] = &[
0x00, 0x00, 0x18, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x04, 0x00, 0x60, 0x00, 0x00, 0x00, 0x06, 0x00, 0x04, 0x00, 0x00,
0x00, 0x00, 0x00, 0x89, 0x00, 0x00, 0x00, 0x00, 0x00,
];
unsafe {
if boring_sys2::SSL_add_application_settings(
config.as_ptr(),
b"h2".as_ptr(),
2,
alps_payload.as_ptr(),
alps_payload.len(),
) != 1
{
return Err(NetError::Tls("failed to add ALPS settings".into()));
}
}
config.set_alps_use_new_codepoint(true);
}
let sni_domain = domain.trim_start_matches('[').trim_end_matches(']');
if sni_domain.parse::<std::net::IpAddr>().is_ok() {
config.set_use_server_name_indication(false);
} else {
config
.set_hostname(sni_domain)
.map_err(|e| NetError::Tls(e.to_string()))?;
}
Ok(config)
}
pub async fn connect_tls(
connector: &SslConnector,
profile: &StealthProfile,
domain: &str,
stream: TcpStream,
) -> Result<SslStream<TcpStream>, NetError> {
let config = configure_connection(connector, profile, domain)?;
let sni_domain = domain.trim_start_matches('[').trim_end_matches(']');
tokio_boring2::connect(config, sni_domain, stream)
.await
.map_err(|e| NetError::Tls(format!("TLS handshake failed: {e}")))
}
pub fn negotiated_alpn(stream: &SslStream<TcpStream>) -> Option<&[u8]> {
stream.ssl().selected_alpn_protocol()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tls_fingerprint_vectors_no_silent_drift() {
const EXPECT_CIPHERS: &str = "TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:\
TLS_CHACHA20_POLY1305_SHA256:TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256:\
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256:TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384:\
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384:TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256:\
TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256:TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA:\
TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA:TLS_RSA_WITH_AES_128_GCM_SHA256:\
TLS_RSA_WITH_AES_256_GCM_SHA384:TLS_RSA_WITH_AES_128_CBC_SHA:\
TLS_RSA_WITH_AES_256_CBC_SHA";
assert_eq!(
CIPHER_LIST, EXPECT_CIPHERS,
"Chrome cipher list drifted from the verified-real reference \
— JA4 cipher hash would change"
);
const EXPECT_SIGALGS: &str = "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";
assert_eq!(
SIGALGS_LIST, EXPECT_SIGALGS,
"Chrome sigalg list drifted — JA4 sigalg hash would change"
);
assert_eq!(
CURVES_DESKTOP,
&[
SslCurve::X25519_MLKEM768,
SslCurve::X25519,
SslCurve::SECP256R1,
SslCurve::SECP384R1,
],
"Chrome desktop curve order drifted (post-quantum MLKEM768 \
must lead) — JA4 supported_groups would change"
);
assert_eq!(
CHROME_EXTENSION_PERMUTATION.len(),
16,
"Chrome extension count drifted — JA4 extension-count digit \
would change"
);
assert_eq!(TLS_CHROME_MAJOR, 147);
assert_eq!(UA_CHROME_MAJOR, 148);
fn ua_chrome_major(ua: &str) -> Option<u32> {
let i = ua.find("Chrome/")? + "Chrome/".len();
ua[i..].split('.').next()?.parse().ok()
}
for profile in [
crate::stealth::presets::chrome_148_macos(),
crate::stealth::presets::chrome_148_windows(),
] {
assert_eq!(
ua_chrome_major(&profile.user_agent),
Some(UA_CHROME_MAJOR),
"desktop Chrome preset UA major must equal UA_CHROME_MAJOR \
(the coherence single-source-of-truth); UA was {:?}",
profile.user_agent
);
assert_eq!(
profile.tls_impersonate, "chrome_147",
"desktop Chrome preset TLS profile must be the verified-real \
chrome_147 reference (wire-equivalent to Chrome \
{UA_CHROME_MAJOR}); see TLS_CHROME_MAJOR docs"
);
}
}
#[tokio::test]
async fn safari_ios_emits_tls_1_0_record_version() {
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 5];
tokio::time::timeout(
std::time::Duration::from_secs(3),
stream.read_exact(&mut buf),
)
.await
.unwrap()
.unwrap();
buf
});
let profile = crate::stealth::presets::iphone_15_pro_safari_18();
let connector = chrome_connector(&profile).expect("connector");
let tcp = TcpStream::connect(addr).await.unwrap();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(3),
connect_tls(&connector, &profile, "localhost", tcp),
)
.await;
let bytes = tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("server timeout")
.expect("server task");
let content_type = bytes[0];
let record_version = ((bytes[1] as u16) << 8) | (bytes[2] as u16);
assert_eq!(
content_type, 22,
"expected TLS handshake (22), got {content_type}"
);
assert_eq!(
record_version, 0x0301,
"iOS Safari record version: got 0x{record_version:04x}, expected 0x0301 (TLS 1.0). \
If this is 0x0303 then a BoringSSL source patch IS needed; if 0x0301 then \
our current build already matches Safari."
);
}
#[tokio::test]
async fn desktop_chrome_emits_tls_1_0_record_version() {
use tokio::io::AsyncReadExt;
use tokio::net::{TcpListener, TcpStream};
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let server = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 5];
tokio::time::timeout(
std::time::Duration::from_secs(3),
stream.read_exact(&mut buf),
)
.await
.unwrap()
.unwrap();
buf
});
let profile = crate::stealth::presets::chrome_148_macos();
let connector = chrome_connector(&profile).expect("connector");
let tcp = TcpStream::connect(addr).await.unwrap();
let _ = tokio::time::timeout(
std::time::Duration::from_secs(3),
connect_tls(&connector, &profile, "localhost", tcp),
)
.await;
let bytes = tokio::time::timeout(std::time::Duration::from_secs(2), server)
.await
.expect("server timeout")
.expect("server task");
let record_version = ((bytes[1] as u16) << 8) | (bytes[2] as u16);
assert_eq!(
record_version, 0x0301,
"Chrome desktop record version: got 0x{record_version:04x}, expected 0x0301."
);
}
#[test]
fn test_shuffle_is_full_fisher_yates() {
let p1 = shuffled_chrome_extension_permutation();
let p2 = shuffled_chrome_extension_permutation();
assert_eq!(p1.len(), 16);
assert_eq!(p2.len(), 16);
let mut sorted = p1.clone();
sorted.sort();
let mut expected = CHROME_EXTENSION_PERMUTATION.to_vec();
expected.sort();
assert_eq!(sorted, expected, "shuffle must preserve the set");
assert_ne!(p1, p2, "Shuffle should be non-deterministic");
}
}