use boring::ssl::{
SslConnector, SslMethod, SslSession, SslSessionCacheMode, SslVerifyMode, SslVersion,
};
use dashmap::DashMap;
use foreign_types::ForeignTypeRef;
use parking_lot::Mutex;
use std::os::raw::c_int;
use std::sync::Arc;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use crate::impersonate::Profile;
use crate::{Error, Result};
const TICKET_TTL: Duration = Duration::from_secs(600);
#[derive(Clone)]
struct CachedSession {
der: Vec<u8>,
inserted: Instant,
}
fn ticket_cache() -> &'static Arc<DashMap<String, CachedSession>> {
static CACHE: OnceLock<Arc<DashMap<String, CachedSession>>> = OnceLock::new();
CACHE.get_or_init(|| Arc::new(DashMap::new()))
}
pub fn lookup_ticket(host: &str, port: u16) -> Option<SslSession> {
let key = format!("{host}:{port}");
let cache = ticket_cache();
let stale = cache
.get(&key)
.map(|e| e.inserted.elapsed() > TICKET_TTL)
.unwrap_or(false);
if stale {
cache.remove(&key);
return None;
}
let der = cache.get(&key)?.der.clone();
SslSession::from_der(&der).ok()
}
fn store_ticket(host: &str, port: u16, session: &SslSession) {
let der = match session.to_der() {
Ok(d) => d,
Err(_) => return,
};
ticket_cache().insert(
format!("{host}:{port}"),
CachedSession {
der,
inserted: Instant::now(),
},
);
}
fn pending_host_map() -> &'static DashMap<usize, (String, u16)> {
static M: OnceLock<DashMap<usize, (String, u16)>> = OnceLock::new();
M.get_or_init(DashMap::new)
}
pub fn pin_host_for_session(ssl: &boring::ssl::SslRef, host: &str, port: u16) {
let key = ssl.as_ptr() as usize;
pending_host_map().insert(key, (host.to_string(), port));
}
fn unpin_host(ssl_ptr: usize) -> Option<(String, u16)> {
pending_host_map().remove(&ssl_ptr).map(|(_, v)| v)
}
static CALLBACK_HITS: Mutex<u64> = Mutex::new(0);
pub fn session_ticket_callback_count() -> u64 {
*CALLBACK_HITS.lock()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TlsVariant {
Chrome,
Firefox,
}
impl TlsVariant {
fn from_profile(profile: Profile) -> Self {
if matches!(profile, Profile::Firefox { .. }) {
Self::Firefox
} else {
Self::Chrome
}
}
}
pub fn build_connector(profile: Profile) -> Result<SslConnector> {
let fp = profile
.tls()
.ok_or_else(|| Error::Tls(format!("no TLS fingerprint in catalog for {:?}", profile)))?;
let variant = TlsVariant::from_profile(profile);
let mut b =
SslConnector::builder(SslMethod::tls()).map_err(|e| Error::Tls(format!("builder: {e}")))?;
b.set_min_proto_version(Some(SslVersion::TLS1_2))
.map_err(|e| Error::Tls(format!("min proto: {e}")))?;
b.set_max_proto_version(Some(SslVersion::TLS1_3))
.map_err(|e| Error::Tls(format!("max proto: {e}")))?;
b.set_grease_enabled(true);
if variant == TlsVariant::Chrome {
b.set_permute_extensions(true);
}
let cipher_list = crate::impersonate::catalog::render_cipher_list(fp);
b.set_cipher_list(&cipher_list)
.map_err(|e| Error::Tls(format!("cipher_list: {e}")))?;
let curves = crate::impersonate::catalog::render_curves_list(fp);
b.set_curves_list(&curves)
.map_err(|e| Error::Tls(format!("curves: {e}")))?;
let sigalgs = crate::impersonate::catalog::render_sigalgs_list(fp);
b.set_sigalgs_list(&sigalgs)
.map_err(|e| Error::Tls(format!("sigalgs: {e}")))?;
let alpn_wire = crate::impersonate::catalog::encode_alpn_wire(fp.alpn);
b.set_alpn_protos(&alpn_wire)
.map_err(|e| Error::Tls(format!("alpn: {e}")))?;
if fp.has_status_request {
b.enable_ocsp_stapling();
}
if fp.has_signed_certificate_timestamp {
b.enable_signed_cert_timestamps();
}
b.set_verify(SslVerifyMode::PEER);
unsafe {
let ctx_ptr = b.as_ptr();
for &alg_id in fp.cert_compress_algs {
let decompress: boring_sys::ssl_cert_decompression_func_t = match alg_id {
1 => Some(cert_decompress_zlib),
2 => Some(cert_decompress_brotli),
3 => Some(cert_decompress_zstd),
_ => {
tracing::warn!(
target: "crawlex::impersonate::tls",
alg_id,
profile = fp.name,
"unknown cert_compression algorithm — skipping"
);
continue;
}
};
let rc = boring_sys::SSL_CTX_add_cert_compression_alg(
ctx_ptr,
alg_id,
Some(cert_compress_noop),
decompress,
);
if rc != 1 {
return Err(Error::Tls(format!(
"SSL_CTX_add_cert_compression_alg(alg={alg_id}) failed"
)));
}
}
}
b.set_session_cache_mode(SslSessionCacheMode::CLIENT | SslSessionCacheMode::NO_INTERNAL);
b.set_new_session_callback(|ssl, session| {
let ssl_ptr = ssl.as_ptr() as usize;
if let Some((host, port)) = unpin_host(ssl_ptr) {
store_ticket(&host, port, &session);
*CALLBACK_HITS.lock() += 1;
}
});
Ok(b.build())
}
pub fn configure_ssl(ssl: &mut boring::ssl::SslRef) -> Result<()> {
unsafe {
let ssl_ptr = ssl.as_ptr();
let proto = b"h2";
let settings = build_alps_h2_settings();
let rc = boring_sys::SSL_add_application_settings(
ssl_ptr,
proto.as_ptr(),
proto.len(),
settings.as_ptr(),
settings.len(),
);
if rc != 1 {
return Err(Error::Tls("SSL_add_application_settings failed".into()));
}
boring_sys::SSL_set_enable_ech_grease(ssl_ptr, 1);
}
Ok(())
}
fn build_alps_h2_settings() -> Vec<u8> {
fn pair(id: u16, value: u32) -> [u8; 6] {
let mut b = [0u8; 6];
b[0..2].copy_from_slice(&id.to_be_bytes());
b[2..6].copy_from_slice(&value.to_be_bytes());
b
}
let mut out = Vec::with_capacity(24);
out.extend_from_slice(&pair(0x1, 65_536));
out.extend_from_slice(&pair(0x2, 0));
out.extend_from_slice(&pair(0x4, 6_291_456));
out.extend_from_slice(&pair(0x6, 262_144));
out
}
unsafe extern "C" fn cert_compress_noop(
_ssl: *mut boring_sys::SSL,
_out: *mut boring_sys::CBB,
_in_buf: *const u8,
_in_len: usize,
) -> c_int {
0
}
const MAX_CERT_DECOMPRESSED_LEN: usize = 256 * 1024;
unsafe extern "C" fn cert_decompress_brotli(
_ssl: *mut boring_sys::SSL,
out: *mut *mut boring_sys::CRYPTO_BUFFER,
uncompressed_len: usize,
in_buf: *const u8,
in_len: usize,
) -> c_int {
use std::io::Read;
use std::slice;
if uncompressed_len == 0 || uncompressed_len > MAX_CERT_DECOMPRESSED_LEN {
return 0;
}
let input = slice::from_raw_parts(in_buf, in_len);
let mut output: Vec<u8> = Vec::with_capacity(uncompressed_len);
let mut reader =
brotli::Decompressor::new(input, 4096).take((uncompressed_len as u64).saturating_add(1));
if reader.read_to_end(&mut output).is_err() {
return 0;
}
finalize_decompressed(out, &output, uncompressed_len)
}
unsafe extern "C" fn cert_decompress_zlib(
_ssl: *mut boring_sys::SSL,
out: *mut *mut boring_sys::CRYPTO_BUFFER,
uncompressed_len: usize,
in_buf: *const u8,
in_len: usize,
) -> c_int {
use std::io::Read;
use std::slice;
if uncompressed_len == 0 || uncompressed_len > MAX_CERT_DECOMPRESSED_LEN {
return 0;
}
let input = slice::from_raw_parts(in_buf, in_len);
let mut output: Vec<u8> = Vec::with_capacity(uncompressed_len);
let mut reader =
flate2::read::ZlibDecoder::new(input).take((uncompressed_len as u64).saturating_add(1));
if reader.read_to_end(&mut output).is_err() {
return 0;
}
finalize_decompressed(out, &output, uncompressed_len)
}
unsafe extern "C" fn cert_decompress_zstd(
_ssl: *mut boring_sys::SSL,
out: *mut *mut boring_sys::CRYPTO_BUFFER,
uncompressed_len: usize,
in_buf: *const u8,
in_len: usize,
) -> c_int {
use std::slice;
if uncompressed_len == 0 || uncompressed_len > MAX_CERT_DECOMPRESSED_LEN {
return 0;
}
let input = slice::from_raw_parts(in_buf, in_len);
let output = match zstd::bulk::decompress(input, uncompressed_len) {
Ok(v) => v,
Err(_) => return 0,
};
finalize_decompressed(out, &output, uncompressed_len)
}
unsafe fn finalize_decompressed(
out: *mut *mut boring_sys::CRYPTO_BUFFER,
output: &[u8],
uncompressed_len: usize,
) -> c_int {
if output.len() != uncompressed_len {
return 0;
}
let buf = boring_sys::CRYPTO_BUFFER_new(output.as_ptr(), output.len(), std::ptr::null_mut());
if buf.is_null() {
return 0;
}
*out = buf;
1
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn alps_h2_settings_layout_matches_chrome() {
let s = build_alps_h2_settings();
assert_eq!(s.len(), 24);
assert_eq!(&s[0..2], &[0x00, 0x01]);
assert_eq!(&s[2..6], &65_536u32.to_be_bytes());
assert_eq!(&s[6..8], &[0x00, 0x02]);
assert_eq!(&s[8..12], &0u32.to_be_bytes());
assert_eq!(&s[12..14], &[0x00, 0x04]);
assert_eq!(&s[14..18], &6_291_456u32.to_be_bytes());
assert_eq!(&s[18..20], &[0x00, 0x06]);
assert_eq!(&s[20..24], &262_144u32.to_be_bytes());
}
#[test]
fn ticket_cache_round_trip_string_form() {
let cache = ticket_cache();
cache.insert(
"no-such-host:443".into(),
CachedSession {
der: vec![1, 2, 3, 4],
inserted: Instant::now() - Duration::from_secs(TICKET_TTL.as_secs() + 1),
},
);
let _ = lookup_ticket("no-such-host", 443);
assert!(cache.get("no-such-host:443").is_none());
}
}