stealthreq 0.1.0

Trait-driven, human-like request mutation primitives for crawlers and scrapers.
Documentation
/// Simulated TLS fingerprint profiles for request behavior rotation.
///
/// The crate does not perform TLS handshakes itself; this data lets clients
/// bind profiles to their own TLS stack (rustls/boring/native-tls, etc.).
#[derive(Debug, Clone)]
pub struct TlsFingerprint {
    /// Human label for telemetry and observability.
    pub name: String,
    /// Simulated JA3-like digest.
    pub ja3: String,
    /// ALPN order for this profile.
    pub alpn: Vec<String>,
    /// Cipher suite preference.
    pub cipher_suites: Vec<String>,
    /// Extension ordering.
    pub extensions: Vec<String>,
}

impl TlsFingerprint {
    pub fn as_header_hints(&self) -> Vec<(String, String)> {
        vec![
            ("Sec-CH-UA-Platform".to_string(), "".to_string()),
            ("X-TLS-Fingerprint".to_string(), self.ja3.clone()),
        ]
    }
}

#[derive(Debug, Clone)]
pub struct TlsRotationPolicy {
    pub profiles: Vec<TlsFingerprint>,
    pub enable: bool,
    pub rotate: bool,
}

impl Default for TlsRotationPolicy {
    fn default() -> Self {
        Self::with_defaults()
    }
}

impl TlsRotationPolicy {
    pub fn from_config(cfg: TlsRotationConfig) -> Self {
        let mut policy = Self::with_defaults();
        policy.enable = cfg.enabled;
        policy.rotate = cfg.enabled;
        policy
    }

    pub fn with_defaults() -> Self {
        Self {
            profiles: Self::build_default_profiles(),
            enable: true,
            rotate: true,
        }
    }

    pub fn build_default_profiles() -> Vec<TlsFingerprint> {
        vec![
            TlsFingerprint {
                name: "chrome-desktop-121".to_string(),
                ja3: "770,4865-4866-4867,23-24,0-11-10-35-16-22".to_string(),
                alpn: vec!["h2".into(), "http/1.1".into()],
                cipher_suites: vec!["TLS_AES_128_GCM_SHA256".into(), "TLS_AES_256_GCM_SHA384".into()],
                extensions: vec!["server_name".into(), "application_layer_protocol_negotiation".into()],
            },
            TlsFingerprint {
                name: "chrome-mobile-121".to_string(),
                ja3: "771,4865-4867,23-24-25,0-11-10-35-16".to_string(),
                alpn: vec!["h2".into(), "http/1.1".into()],
                cipher_suites: vec!["TLS_CHACHA20_POLY1305_SHA256".into(), "TLS_AES_128_GCM_SHA256".into()],
                extensions: vec!["server_name".into(), "extended_master_secret".into()],
            },
            TlsFingerprint {
                name: "firefox-desktop".to_string(),
                ja3: "772,4867-4866-4865,23-24,0-10-11-16-35".to_string(),
                alpn: vec!["h2".into(), "http/1.1".into()],
                cipher_suites: vec!["TLS_AES_128_GCM_SHA256".into(), "TLS_CHACHA20_POLY1305_SHA256".into()],
                extensions: vec!["server_name".into(), "key_share".into()],
            },
        ]
    }

    pub fn rotate(&self, rng: &mut impl rand::Rng) -> TlsFingerprint {
        if self.profiles.is_empty() {
            return TlsFingerprint {
                name: "fallback".to_string(),
                ja3: "unknown".to_string(),
                alpn: vec!["h2".into()],
                cipher_suites: vec!["TLS_AES_128_GCM_SHA256".into()],
                extensions: vec!["server_name".into()],
            };
        }
        self.profiles[rng.gen_range(0..self.profiles.len())].clone()
    }
}

#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct TlsRotationConfig {
    pub enabled: bool,
}

impl Default for TlsRotationConfig {
    fn default() -> Self {
        Self { enabled: true }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::{SeedableRng, rngs::StdRng};

    #[test]
    fn default_profiles_are_stable() {
        let policy = TlsRotationPolicy::default();
        let mut rng = StdRng::seed_from_u64(99);
        let a = policy.rotate(&mut rng);
        let mut rng = StdRng::seed_from_u64(99);
        let b = policy.rotate(&mut rng);
        assert_eq!(a.name, b.name);
    }

    #[test]
    fn fallback_has_headers() {
        let profile = TlsRotationPolicy { profiles: vec![], enable: true, rotate: false };
        let fp = profile.rotate(&mut StdRng::seed_from_u64(1));
        assert_eq!(fp.name, "fallback");
        assert_eq!(fp.as_header_hints()[1].0, "X-TLS-Fingerprint");
    }
}