Skip to main content

synapse_pingora/fingerprint/
ja4.rs

1//! JA4+ TLS/HTTP Fingerprinting Implementation
2//!
3//! Port of risk-server/src/fingerprint/ja4.ts to Rust for high-performance
4//! fingerprint generation in the proxy layer.
5//!
6//! ## Performance Targets
7//! - JA4 parsing: <5μs
8//! - JA4H generation: <10μs
9//! - Combined fingerprint: <15μs
10
11use http::header::{HeaderName, HeaderValue};
12use once_cell::sync::Lazy;
13use regex::Regex;
14use sha2::{Digest, Sha256};
15use std::collections::{HashMap, HashSet};
16
17// ============================================================================
18// Types
19// ============================================================================
20
21/// Protocol type for JA4 fingerprint
22#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
23pub enum Ja4Protocol {
24    TCP,
25    QUIC,
26}
27
28impl std::fmt::Display for Ja4Protocol {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self {
31            Ja4Protocol::TCP => write!(f, "TCP"),
32            Ja4Protocol::QUIC => write!(f, "QUIC"),
33        }
34    }
35}
36
37/// SNI type for JA4 fingerprint
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub enum Ja4SniType {
40    Domain,
41    IP,
42    None,
43}
44
45impl std::fmt::Display for Ja4SniType {
46    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47        match self {
48            Ja4SniType::Domain => write!(f, "Domain"),
49            Ja4SniType::IP => write!(f, "IP"),
50            Ja4SniType::None => write!(f, "None"),
51        }
52    }
53}
54
55/// JA4 TLS fingerprint (from ClientHello)
56///
57/// Note: In dual-running mode, JA4 is provided via X-JA4-Fingerprint header
58/// from upstream TLS terminator. In native Pingora mode, JA4 can be calculated
59/// directly from ClientHello (requires TLS access).
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct Ja4Fingerprint {
62    /// Full fingerprint string (e.g., t13d1516h2_8daaf6152771_e5627efa2ab1)
63    pub raw: String,
64
65    // Parsed components for filtering
66    /// Transport protocol (TCP or QUIC)
67    pub protocol: Ja4Protocol,
68    /// TLS version (10=1.0, 11=1.1, 12=1.2, 13=1.3)
69    pub tls_version: u8,
70    /// SNI type (Domain, IP, or None)
71    pub sni_type: Ja4SniType,
72    /// Number of cipher suites offered
73    pub cipher_count: u8,
74    /// Number of extensions offered
75    pub ext_count: u8,
76    /// ALPN protocol (h1, h2, h3, etc.)
77    pub alpn: String,
78    /// First 12 chars of SHA256 of sorted cipher suites
79    pub cipher_hash: String,
80    /// First 12 chars of SHA256 of sorted extensions
81    pub ext_hash: String,
82}
83
84/// JA4H HTTP fingerprint (from HTTP headers)
85///
86/// Can be generated directly from HTTP request - no external dependencies.
87#[derive(Debug, Clone, PartialEq, Eq)]
88pub struct Ja4hFingerprint {
89    /// Full fingerprint string (e.g., ge11cnrn_a1b2c3d4e5f6_000000000000)
90    pub raw: String,
91
92    // Parsed components
93    /// HTTP method code (ge, po, pu, de, he, op, pa, co, tr)
94    pub method: String,
95    /// HTTP version (10, 11, 20, 30)
96    pub http_version: u8,
97    /// Whether Cookie header is present
98    pub has_cookie: bool,
99    /// Whether Referer header is present
100    pub has_referer: bool,
101    /// First 2 chars of Accept-Language or "00"
102    pub accept_lang: String,
103    /// First 12 chars of SHA256 of sorted header names
104    pub header_hash: String,
105    /// First 12 chars of SHA256 of sorted cookie names
106    pub cookie_hash: String,
107}
108
109/// Combined client fingerprint (JA4 + JA4H)
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct ClientFingerprint {
112    /// JA4 TLS fingerprint (None if not available)
113    pub ja4: Option<Ja4Fingerprint>,
114    /// JA4H HTTP fingerprint (always available)
115    pub ja4h: Ja4hFingerprint,
116    /// Combined hash: SHA256(ja4 + ja4h) first 16 chars
117    pub combined_hash: String,
118}
119
120/// JA4 analysis result
121#[derive(Debug, Clone)]
122pub struct Ja4Analysis {
123    pub fingerprint: Ja4Fingerprint,
124    pub suspicious: bool,
125    pub issues: Vec<String>,
126    pub estimated_client: String,
127}
128
129/// JA4H analysis result
130#[derive(Debug, Clone)]
131pub struct Ja4hAnalysis {
132    pub fingerprint: Ja4hFingerprint,
133    pub suspicious: bool,
134    pub issues: Vec<String>,
135}
136
137// ============================================================================
138// Constants
139// ============================================================================
140
141/// Pre-compiled JA4 regex for performance (case-insensitive)
142static JA4_REGEX: Lazy<Regex> = Lazy::new(|| {
143    Regex::new(r"(?i)^([tq])(\d{2})([di]?)([0-9a-f]{2})([0-9a-f]{2})([a-z0-9]{2})_([0-9a-f]{12})_([0-9a-f]{12})$")
144        .expect("JA4 regex should compile")
145});
146
147/// Pre-compiled JA4H validation regex
148static JA4H_REGEX: Lazy<Regex> = Lazy::new(|| {
149    Regex::new(r"^[a-z]{2}\d{2}[cn][rn][a-z0-9]{2}_[0-9a-f]{12}_[0-9a-f]{12}$")
150        .expect("JA4H regex should compile")
151});
152
153/// HTTP method to 2-char code mapping
154static METHOD_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
155    let mut m = HashMap::new();
156    m.insert("GET", "ge");
157    m.insert("POST", "po");
158    m.insert("PUT", "pu");
159    m.insert("DELETE", "de");
160    m.insert("HEAD", "he");
161    m.insert("OPTIONS", "op");
162    m.insert("PATCH", "pa");
163    m.insert("CONNECT", "co");
164    m.insert("TRACE", "tr");
165    m
166});
167
168/// ALPN code to protocol name mapping
169static ALPN_MAP: Lazy<HashMap<&'static str, &'static str>> = Lazy::new(|| {
170    let mut m = HashMap::new();
171    m.insert("h1", "http/1.1");
172    m.insert("h2", "h2");
173    m.insert("h3", "h3");
174    m.insert("00", "unknown");
175    m
176});
177
178/// Headers to exclude from JA4H header hash
179static EXCLUDED_HEADERS: Lazy<HashSet<&'static str>> = Lazy::new(|| {
180    let mut s = HashSet::new();
181    s.insert("cookie");
182    s.insert("referer");
183    // Also exclude pseudo-headers and connection-specific
184    s.insert(":method");
185    s.insert(":path");
186    s.insert(":scheme");
187    s.insert(":authority");
188    s.insert("host");
189    s.insert("content-length");
190    s.insert("content-type");
191    s
192});
193
194// ============================================================================
195// JA4 Parsing (from X-JA4-Fingerprint header)
196// ============================================================================
197
198/// Parse JA4 fingerprint from X-JA4-Fingerprint header
199///
200/// The header is set by upstream TLS terminator (nginx, HAProxy, Thunder)
201/// that has access to the raw TLS ClientHello.
202///
203/// Format: t13d1516h2_8daaf6152771_e5627efa2ab1
204///         │││ │ │ │  │              │
205///         │││ │ │ │  │              └─ Extension hash
206///         │││ │ │ │  └─ Cipher hash
207///         │││ │ │ └─ ALPN (h2 = HTTP/2)
208///         │││ │ └─ Extension count (hex)
209///         │││ └─ Cipher count (hex)
210///         ││└─ SNI present (d=domain, i=IP, empty=none)
211///         │└─ TLS version (13 = 1.3)
212///         └─ Protocol (t=TCP, q=QUIC)
213///
214/// Returns None on invalid input for safety (never panics).
215pub fn parse_ja4_from_header(header: Option<&str>) -> Option<Ja4Fingerprint> {
216    let header = header?;
217
218    // Normalize: trim whitespace, reject overly long inputs (security)
219    let normalized = header.trim();
220    if normalized.is_empty() || normalized.len() > 100 {
221        return None;
222    }
223
224    // Use pre-compiled regex for performance
225    let caps = JA4_REGEX.captures(normalized)?;
226
227    // Extract capture groups
228    let protocol_char = caps.get(1)?.as_str();
229    let version_str = caps.get(2)?.as_str();
230    let sni_char = caps.get(3).map(|m| m.as_str()).unwrap_or("");
231    let cipher_count_str = caps.get(4)?.as_str();
232    let ext_count_str = caps.get(5)?.as_str();
233    let alpn_str = caps.get(6)?.as_str();
234    let cipher_hash = caps.get(7)?.as_str();
235    let ext_hash = caps.get(8)?.as_str();
236
237    // Parse numeric values
238    let tls_version = version_str.parse::<u8>().ok()?;
239    let cipher_count = u8::from_str_radix(cipher_count_str, 16).ok()?;
240    let ext_count = u8::from_str_radix(ext_count_str, 16).ok()?;
241
242    // Validate TLS version range
243    if !(10..=13).contains(&tls_version) {
244        return None;
245    }
246
247    // Validate hash lengths
248    if cipher_hash.len() != 12 || ext_hash.len() != 12 {
249        return None;
250    }
251
252    // Map values
253    let protocol = if protocol_char.to_lowercase() == "q" {
254        Ja4Protocol::QUIC
255    } else {
256        Ja4Protocol::TCP
257    };
258
259    let sni_type = match sni_char {
260        "d" => Ja4SniType::Domain,
261        "i" => Ja4SniType::IP,
262        _ => Ja4SniType::None,
263    };
264
265    let alpn = ALPN_MAP
266        .get(alpn_str.to_lowercase().as_str())
267        .copied()
268        .unwrap_or(alpn_str)
269        .to_string();
270
271    Some(Ja4Fingerprint {
272        raw: normalized.to_lowercase(),
273        protocol,
274        tls_version,
275        sni_type,
276        cipher_count,
277        ext_count,
278        alpn,
279        cipher_hash: cipher_hash.to_lowercase(),
280        ext_hash: ext_hash.to_lowercase(),
281    })
282}
283
284// ============================================================================
285// JA4H Generation (from HTTP request)
286// ============================================================================
287
288/// HTTP headers representation for JA4H generation
289pub struct HttpHeaders<'a> {
290    /// All headers as (name, value) pairs
291    pub headers: &'a [(HeaderName, HeaderValue)],
292    /// HTTP method (GET, POST, etc.)
293    pub method: &'a str,
294    /// HTTP version string ("1.0", "1.1", "2.0", "3.0")
295    pub http_version: &'a str,
296}
297
298/// Generate JA4H fingerprint from HTTP request headers
299///
300/// Format: {method}{version}{cookie}{referer}{accept_lang}_{header_hash}_{cookie_hash}
301/// Example: ge11cnrn_a1b2c3d4e5f6_000000000000
302///          │ │ ││││  │              │
303///          │ │ ││││  │              └─ Cookie hash (no cookies = zeros)
304///          │ │ ││││  └─ Header hash
305///          │ │ │││└─ Accept-Language (none)
306///          │ │ ││└─ Referer (none)
307///          │ │ │└─ Cookie (no)
308///          │ │ └─
309///          │ └─ HTTP 1.1
310///          └─ GET
311pub fn generate_ja4h(request: &HttpHeaders<'_>) -> Ja4hFingerprint {
312    // 1. Method (2 chars)
313    let method = METHOD_MAP
314        .get(request.method.to_uppercase().as_str())
315        .copied()
316        .unwrap_or("xx")
317        .to_string();
318
319    // 2. HTTP version (10, 11, 20, 30)
320    let http_version = get_http_version(request.http_version);
321
322    // 3. Find Cookie and Referer headers
323    let mut cookie_value: Option<&str> = None;
324    let mut referer_value: Option<&str> = None;
325    let mut accept_lang_value: Option<&str> = None;
326
327    for (name, value) in request.headers.iter() {
328        let Ok(value_str) = value.to_str() else {
329            continue;
330        };
331        match name.as_str() {
332            "cookie" => cookie_value = Some(value_str),
333            "referer" => referer_value = Some(value_str),
334            "accept-language" => accept_lang_value = Some(value_str),
335            _ => {}
336        }
337    }
338
339    let has_cookie = cookie_value.is_some();
340    let has_referer = referer_value.is_some();
341    let cookie_flag = if has_cookie { "c" } else { "n" };
342    let referer_flag = if has_referer { "r" } else { "n" };
343
344    // 5. Accept-Language (first 2 chars of first language, or "00")
345    let accept_lang = extract_accept_lang(accept_lang_value);
346
347    // 6. Header hash (sorted header names, excluding cookie/referer)
348    let header_hash = hash_headers(request.headers);
349
350    // 7. Cookie hash (sorted cookie names, or zeros if no cookies)
351    let cookie_hash = if let Some(cookies) = cookie_value {
352        hash_cookies(cookies)
353    } else {
354        "000000000000".to_string()
355    };
356
357    // Construct fingerprint
358    let raw = format!(
359        "{}{}{}{}{}_{}_{}",
360        method, http_version, cookie_flag, referer_flag, accept_lang, header_hash, cookie_hash
361    );
362
363    Ja4hFingerprint {
364        raw,
365        method,
366        http_version,
367        has_cookie,
368        has_referer,
369        accept_lang,
370        header_hash,
371        cookie_hash,
372    }
373}
374
375/// Get HTTP version code from version string
376fn get_http_version(version: &str) -> u8 {
377    match version {
378        "2.0" | "2" => 20,
379        "3.0" | "3" => 30,
380        "1.0" => 10,
381        _ => 11, // Default to HTTP/1.1
382    }
383}
384
385/// Extract Accept-Language first 2 chars
386///
387/// Examples:
388///   "en-US,en;q=0.9" -> "en"
389///   "fr-FR,fr;q=0.9,en;q=0.8" -> "fr"
390///   None -> "00"
391fn extract_accept_lang(header: Option<&str>) -> String {
392    let Some(value) = header else {
393        return "00".to_string();
394    };
395
396    if value.is_empty() {
397        return "00".to_string();
398    }
399
400    // Get first language, strip quality and region
401    let first_lang = value
402        .split(',')
403        .next()
404        .and_then(|s| s.split(';').next())
405        .and_then(|s| s.split('-').next())
406        .map(|s| s.trim().to_lowercase())
407        .unwrap_or_default();
408
409    if first_lang.len() < 2 {
410        return "00".to_string();
411    }
412
413    first_lang[..2].to_string()
414}
415
416/// Hash header names for JA4H
417///
418/// - Exclude cookie, referer, and pseudo-headers
419/// - Sort alphabetically
420/// - Join with commas
421/// - SHA256, take first 12 chars
422fn hash_headers(headers: &[(HeaderName, HeaderValue)]) -> String {
423    let mut names: Vec<&str> = headers
424        .iter()
425        .map(|(name, _)| name.as_str())
426        .filter(|name| !EXCLUDED_HEADERS.contains(*name))
427        .collect();
428
429    if names.is_empty() {
430        return "000000000000".to_string();
431    }
432
433    names.sort();
434    sha256_first12(&names.join(","))
435}
436
437/// Hash cookie names for JA4H
438///
439/// - Parse cookie header
440/// - Extract cookie names (before =)
441/// - Sort alphabetically
442/// - Join with commas
443/// - SHA256, take first 12 chars
444fn hash_cookies(cookie_header: &str) -> String {
445    let mut cookie_names: Vec<String> = cookie_header
446        .split(';')
447        .filter_map(|c| {
448            let name = c.split('=').next()?.trim().to_lowercase();
449            if name.is_empty() {
450                None
451            } else {
452                Some(name)
453            }
454        })
455        .collect();
456
457    if cookie_names.is_empty() {
458        return "000000000000".to_string();
459    }
460
461    cookie_names.sort();
462    sha256_first12(&cookie_names.join(","))
463}
464
465// ============================================================================
466// Combined Fingerprint
467// ============================================================================
468
469/// Extract complete client fingerprint (JA4 + JA4H)
470///
471/// Uses streaming hash to avoid string concatenation overhead.
472pub fn extract_client_fingerprint(
473    ja4_header: Option<&str>,
474    request: &HttpHeaders<'_>,
475) -> ClientFingerprint {
476    let ja4 = parse_ja4_from_header(ja4_header);
477    let ja4h = generate_ja4h(request);
478
479    // Combined hash: SHA256(ja4.raw + ja4h.raw), first 16 chars
480    let mut hasher = Sha256::new();
481    if let Some(ref fp) = ja4 {
482        hasher.update(fp.raw.as_bytes());
483    }
484    hasher.update(ja4h.raw.as_bytes());
485    let result = hasher.finalize();
486    let combined_hash = hex::encode(&result[..8]); // 16 hex chars = 8 bytes
487
488    ClientFingerprint {
489        ja4,
490        ja4h,
491        combined_hash,
492    }
493}
494
495// ============================================================================
496// Utility Functions
497// ============================================================================
498
499/// SHA256 hash, first 12 hex characters
500pub fn sha256_first12(input: &str) -> String {
501    let mut hasher = Sha256::new();
502    hasher.update(input.as_bytes());
503    let result = hasher.finalize();
504    hex::encode(&result[..6]) // 12 hex chars = 6 bytes
505}
506
507/// Check if JA4 fingerprint is valid format
508pub fn is_valid_ja4(fingerprint: &str) -> bool {
509    parse_ja4_from_header(Some(fingerprint)).is_some()
510}
511
512/// Check if JA4H fingerprint is valid format
513pub fn is_valid_ja4h(fingerprint: &str) -> bool {
514    JA4H_REGEX.is_match(&fingerprint.to_lowercase())
515}
516
517/// Compare two fingerprints for equality (case-insensitive)
518pub fn fingerprints_match(fp1: Option<&str>, fp2: Option<&str>) -> bool {
519    match (fp1, fp2) {
520        (Some(a), Some(b)) => a.to_lowercase() == b.to_lowercase(),
521        _ => false,
522    }
523}
524
525/// Check if fingerprint matches a pattern (supports wildcards)
526///
527/// Patterns:
528///   t13* - Any TLS 1.3 TCP fingerprint
529///   t12d* - TLS 1.2 with domain SNI
530///   *_8daaf6152771_* - Specific cipher hash
531pub fn matches_pattern(fingerprint: &str, pattern: &str) -> bool {
532    if fingerprint.is_empty() || pattern.is_empty() {
533        return false;
534    }
535
536    // Convert pattern to regex
537    let escaped = regex::escape(pattern);
538    let regex_pattern = escaped.replace(r"\*", ".*");
539
540    match Regex::new(&format!("^(?i){}$", regex_pattern)) {
541        Ok(re) => re.is_match(fingerprint),
542        Err(_) => false,
543    }
544}
545
546// ============================================================================
547// Analysis
548// ============================================================================
549
550/// Analyze JA4 fingerprint for suspicious characteristics
551pub fn analyze_ja4(fingerprint: &Ja4Fingerprint) -> Ja4Analysis {
552    let mut issues = Vec::new();
553
554    // Check for old TLS versions
555    if fingerprint.tls_version < 12 {
556        issues.push(format!(
557            "Outdated TLS version: 1.{}",
558            fingerprint.tls_version - 10
559        ));
560    }
561
562    // Check for missing ALPN with TLS 1.3 (unusual for browsers)
563    if fingerprint.tls_version >= 13 && fingerprint.alpn == "unknown" {
564        issues.push("Missing ALPN with TLS 1.3 (unusual for browsers)".to_string());
565    }
566
567    // Very few ciphers might indicate a script/bot
568    if fingerprint.cipher_count < 5 {
569        issues.push(format!(
570            "Low cipher count: {} (typical browsers offer 10+)",
571            fingerprint.cipher_count
572        ));
573    }
574
575    // Very few extensions might indicate a script/bot
576    if fingerprint.ext_count < 5 {
577        issues.push(format!(
578            "Low extension count: {} (typical browsers have 10+)",
579            fingerprint.ext_count
580        ));
581    }
582
583    let estimated_client = estimate_client_from_ja4(fingerprint);
584
585    Ja4Analysis {
586        fingerprint: fingerprint.clone(),
587        suspicious: !issues.is_empty(),
588        issues,
589        estimated_client,
590    }
591}
592
593/// Estimate client type from JA4 fingerprint
594fn estimate_client_from_ja4(fingerprint: &Ja4Fingerprint) -> String {
595    // Modern browsers typically use TLS 1.3 with HTTP/2 and many ciphers/extensions
596    if fingerprint.tls_version >= 13 && fingerprint.alpn == "h2" && fingerprint.cipher_count >= 10 {
597        return "modern-browser".to_string();
598    }
599
600    // TLS 1.2 with HTTP/2 is still common
601    if fingerprint.tls_version == 12 && fingerprint.alpn == "h2" {
602        return "browser".to_string();
603    }
604
605    // HTTP/1.1 only with TLS 1.2+ could be API client or script
606    if fingerprint.alpn == "http/1.1" && fingerprint.tls_version >= 12 {
607        return "api-client".to_string();
608    }
609
610    // Old TLS or minimal ciphers/extensions suggests script or legacy client
611    if fingerprint.tls_version < 12 || fingerprint.cipher_count < 5 || fingerprint.ext_count < 5 {
612        return "bot-or-script".to_string();
613    }
614
615    "unknown".to_string()
616}
617
618/// Analyze JA4H fingerprint for suspicious characteristics
619pub fn analyze_ja4h(fingerprint: &Ja4hFingerprint) -> Ja4hAnalysis {
620    let mut issues = Vec::new();
621
622    // No Accept-Language is unusual for browsers
623    if fingerprint.accept_lang == "00" {
624        issues.push("No Accept-Language header (unusual for browsers)".to_string());
625    }
626
627    // HTTP/1.0 is very rare in modern usage
628    if fingerprint.http_version == 10 {
629        issues.push("HTTP/1.0 (very rare, possibly script)".to_string());
630    }
631
632    Ja4hAnalysis {
633        fingerprint: fingerprint.clone(),
634        suspicious: !issues.is_empty(),
635        issues,
636    }
637}
638
639// ============================================================================
640// Tests
641// ============================================================================
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    fn test_limit_usize(env_key: &str, default: usize, min: usize) -> usize {
648        std::env::var(env_key)
649            .ok()
650            .and_then(|value| value.parse::<usize>().ok())
651            .map(|value| value.max(min).min(default))
652            .unwrap_or(default)
653    }
654
655    fn header(name: &str, value: &str) -> (HeaderName, HeaderValue) {
656        let header_name = HeaderName::from_bytes(name.as_bytes()).expect("valid header name");
657        let header_value = HeaderValue::from_str(value).expect("valid header value");
658        (header_name, header_value)
659    }
660
661    // ==================== JA4 Parsing Tests ====================
662
663    #[test]
664    fn test_parse_ja4_valid_tcp_tls13() {
665        let result = parse_ja4_from_header(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"));
666        assert!(result.is_some());
667        let fp = result.unwrap();
668        assert_eq!(fp.protocol, Ja4Protocol::TCP);
669        assert_eq!(fp.tls_version, 13);
670        assert_eq!(fp.sni_type, Ja4SniType::Domain);
671        assert_eq!(fp.cipher_count, 0x15); // 21 decimal
672        assert_eq!(fp.ext_count, 0x16); // 22 decimal
673        assert_eq!(fp.alpn, "h2");
674        assert_eq!(fp.cipher_hash, "8daaf6152771");
675        assert_eq!(fp.ext_hash, "e5627efa2ab1");
676    }
677
678    #[test]
679    fn test_parse_ja4_valid_quic_tls13() {
680        let result = parse_ja4_from_header(Some("q13d0a0bh3_1234567890ab_abcdef123456"));
681        assert!(result.is_some());
682        let fp = result.unwrap();
683        assert_eq!(fp.protocol, Ja4Protocol::QUIC);
684        assert_eq!(fp.tls_version, 13);
685        assert_eq!(fp.sni_type, Ja4SniType::Domain);
686        assert_eq!(fp.alpn, "h3");
687    }
688
689    #[test]
690    fn test_parse_ja4_valid_tls12_ip_sni() {
691        let result = parse_ja4_from_header(Some("t12i0c10h1_aabbccddeeff_112233445566"));
692        assert!(result.is_some());
693        let fp = result.unwrap();
694        assert_eq!(fp.tls_version, 12);
695        assert_eq!(fp.sni_type, Ja4SniType::IP);
696        assert_eq!(fp.alpn, "http/1.1");
697    }
698
699    #[test]
700    fn test_parse_ja4_valid_no_sni() {
701        let result = parse_ja4_from_header(Some("t130510h2_aabbccddeeff_112233445566"));
702        assert!(result.is_some());
703        let fp = result.unwrap();
704        assert_eq!(fp.sni_type, Ja4SniType::None);
705    }
706
707    #[test]
708    fn test_parse_ja4_invalid_format() {
709        assert!(parse_ja4_from_header(Some("invalid")).is_none());
710        assert!(parse_ja4_from_header(Some("")).is_none());
711        assert!(parse_ja4_from_header(None).is_none());
712        assert!(parse_ja4_from_header(Some("t13d1516h2_short_hash")).is_none());
713    }
714
715    #[test]
716    fn test_parse_ja4_too_long() {
717        let long_input = "a".repeat(200);
718        assert!(parse_ja4_from_header(Some(&long_input)).is_none());
719    }
720
721    #[test]
722    fn test_parse_ja4_case_insensitive() {
723        let result1 = parse_ja4_from_header(Some("T13D1516H2_8DAAF6152771_E5627EFA2AB1"));
724        let result2 = parse_ja4_from_header(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"));
725        assert!(result1.is_some());
726        assert!(result2.is_some());
727        // Both should normalize to lowercase
728        assert_eq!(result1.unwrap().raw, result2.unwrap().raw);
729    }
730
731    // ==================== JA4H Generation Tests ====================
732
733    #[test]
734    fn test_generate_ja4h_basic() {
735        let headers = vec![
736            header("Accept", "text/html"),
737            header("User-Agent", "Mozilla/5.0"),
738        ];
739        let request = HttpHeaders {
740            headers: &headers,
741            method: "GET",
742            http_version: "1.1",
743        };
744
745        let result = generate_ja4h(&request);
746
747        assert_eq!(result.method, "ge");
748        assert_eq!(result.http_version, 11);
749        assert!(!result.has_cookie);
750        assert!(!result.has_referer);
751        assert_eq!(result.accept_lang, "00");
752        assert_eq!(result.cookie_hash, "000000000000");
753    }
754
755    #[test]
756    fn test_generate_ja4h_with_cookie() {
757        let headers = vec![
758            header("Cookie", "session=abc123; user=test"),
759            header("Accept", "text/html"),
760        ];
761        let request = HttpHeaders {
762            headers: &headers,
763            method: "POST",
764            http_version: "2.0",
765        };
766
767        let result = generate_ja4h(&request);
768
769        assert_eq!(result.method, "po");
770        assert_eq!(result.http_version, 20);
771        assert!(result.has_cookie);
772        assert!(!result.has_referer);
773        assert_ne!(result.cookie_hash, "000000000000");
774    }
775
776    #[test]
777    fn test_generate_ja4h_with_referer() {
778        let headers = vec![
779            header("Referer", "https://example.com"),
780            header("Accept", "text/html"),
781        ];
782        let request = HttpHeaders {
783            headers: &headers,
784            method: "GET",
785            http_version: "1.1",
786        };
787
788        let result = generate_ja4h(&request);
789
790        assert!(!result.has_cookie);
791        assert!(result.has_referer);
792    }
793
794    #[test]
795    fn test_generate_ja4h_accept_language() {
796        let headers = vec![header("Accept-Language", "en-US,en;q=0.9,fr;q=0.8")];
797        let request = HttpHeaders {
798            headers: &headers,
799            method: "GET",
800            http_version: "1.1",
801        };
802
803        let result = generate_ja4h(&request);
804        assert_eq!(result.accept_lang, "en");
805    }
806
807    #[test]
808    fn test_generate_ja4h_french_language() {
809        let headers = vec![header("Accept-Language", "fr-FR,fr;q=0.9,en;q=0.8")];
810        let request = HttpHeaders {
811            headers: &headers,
812            method: "GET",
813            http_version: "1.1",
814        };
815
816        let result = generate_ja4h(&request);
817        assert_eq!(result.accept_lang, "fr");
818    }
819
820    #[test]
821    fn test_generate_ja4h_http_versions() {
822        for (version, expected) in [("1.0", 10), ("1.1", 11), ("2.0", 20), ("3.0", 30)] {
823            let headers: Vec<(HeaderName, HeaderValue)> = Vec::new();
824            let request = HttpHeaders {
825                headers: &headers,
826                method: "GET",
827                http_version: version,
828            };
829            let result = generate_ja4h(&request);
830            assert_eq!(
831                result.http_version, expected,
832                "Failed for version {}",
833                version
834            );
835        }
836    }
837
838    #[test]
839    fn test_generate_ja4h_all_methods() {
840        let methods = [
841            ("GET", "ge"),
842            ("POST", "po"),
843            ("PUT", "pu"),
844            ("DELETE", "de"),
845            ("HEAD", "he"),
846            ("OPTIONS", "op"),
847            ("PATCH", "pa"),
848            ("CONNECT", "co"),
849            ("TRACE", "tr"),
850        ];
851
852        for (method, expected) in methods {
853            let headers: Vec<(HeaderName, HeaderValue)> = Vec::new();
854            let request = HttpHeaders {
855                headers: &headers,
856                method,
857                http_version: "1.1",
858            };
859            let result = generate_ja4h(&request);
860            assert_eq!(result.method, expected, "Failed for method {}", method);
861        }
862    }
863
864    // ==================== Combined Fingerprint Tests ====================
865
866    #[test]
867    fn test_extract_client_fingerprint_with_ja4() {
868        let headers = vec![header("Accept", "text/html")];
869        let request = HttpHeaders {
870            headers: &headers,
871            method: "GET",
872            http_version: "1.1",
873        };
874
875        let result =
876            extract_client_fingerprint(Some("t13d1516h2_8daaf6152771_e5627efa2ab1"), &request);
877
878        assert!(result.ja4.is_some());
879        assert_eq!(result.combined_hash.len(), 16);
880    }
881
882    #[test]
883    fn test_extract_client_fingerprint_without_ja4() {
884        let headers = vec![header("Accept", "text/html")];
885        let request = HttpHeaders {
886            headers: &headers,
887            method: "GET",
888            http_version: "1.1",
889        };
890
891        let result = extract_client_fingerprint(None, &request);
892
893        assert!(result.ja4.is_none());
894        assert_eq!(result.combined_hash.len(), 16);
895    }
896
897    // ==================== Utility Function Tests ====================
898
899    #[test]
900    fn test_sha256_first12() {
901        let result = sha256_first12("test");
902        assert_eq!(result.len(), 12);
903        // SHA256("test") = 9f86d081884c7d659a2feaa0c55ad015...
904        assert_eq!(result, "9f86d081884c");
905    }
906
907    #[test]
908    fn test_is_valid_ja4() {
909        assert!(is_valid_ja4("t13d1516h2_8daaf6152771_e5627efa2ab1"));
910        assert!(!is_valid_ja4("invalid"));
911        assert!(!is_valid_ja4(""));
912    }
913
914    #[test]
915    fn test_is_valid_ja4h() {
916        assert!(is_valid_ja4h("ge11cnrn_a1b2c3d4e5f6_000000000000"));
917        assert!(!is_valid_ja4h("invalid"));
918        assert!(!is_valid_ja4h(""));
919    }
920
921    #[test]
922    fn test_fingerprints_match() {
923        assert!(fingerprints_match(Some("ABC"), Some("abc")));
924        assert!(fingerprints_match(Some("abc"), Some("ABC")));
925        assert!(!fingerprints_match(Some("abc"), Some("def")));
926        assert!(!fingerprints_match(None, Some("abc")));
927        assert!(!fingerprints_match(Some("abc"), None));
928        assert!(!fingerprints_match(None, None));
929    }
930
931    #[test]
932    fn test_matches_pattern() {
933        // Prefix wildcard
934        assert!(matches_pattern(
935            "t13d1516h2_8daaf6152771_e5627efa2ab1",
936            "t13*"
937        ));
938        assert!(!matches_pattern(
939            "t12d1516h2_8daaf6152771_e5627efa2ab1",
940            "t13*"
941        ));
942
943        // Middle wildcard
944        assert!(matches_pattern(
945            "t13d1516h2_8daaf6152771_e5627efa2ab1",
946            "*_8daaf6152771_*"
947        ));
948
949        // Suffix wildcard
950        assert!(matches_pattern(
951            "t13d1516h2_8daaf6152771_e5627efa2ab1",
952            "*e5627efa2ab1"
953        ));
954    }
955
956    // ==================== Analysis Tests ====================
957
958    #[test]
959    fn test_analyze_ja4_modern_browser() {
960        let fp = Ja4Fingerprint {
961            raw: "t13d1516h2_8daaf6152771_e5627efa2ab1".to_string(),
962            protocol: Ja4Protocol::TCP,
963            tls_version: 13,
964            sni_type: Ja4SniType::Domain,
965            cipher_count: 15,
966            ext_count: 16,
967            alpn: "h2".to_string(),
968            cipher_hash: "8daaf6152771".to_string(),
969            ext_hash: "e5627efa2ab1".to_string(),
970        };
971
972        let analysis = analyze_ja4(&fp);
973        assert!(!analysis.suspicious);
974        assert_eq!(analysis.estimated_client, "modern-browser");
975    }
976
977    #[test]
978    fn test_analyze_ja4_suspicious_bot() {
979        let fp = Ja4Fingerprint {
980            raw: "t100302h1_8daaf6152771_e5627efa2ab1".to_string(),
981            protocol: Ja4Protocol::TCP,
982            tls_version: 10,
983            sni_type: Ja4SniType::None,
984            cipher_count: 3,
985            ext_count: 2,
986            alpn: "http/1.1".to_string(),
987            cipher_hash: "8daaf6152771".to_string(),
988            ext_hash: "e5627efa2ab1".to_string(),
989        };
990
991        let analysis = analyze_ja4(&fp);
992        assert!(analysis.suspicious);
993        assert!(!analysis.issues.is_empty());
994        assert_eq!(analysis.estimated_client, "bot-or-script");
995    }
996
997    #[test]
998    fn test_analyze_ja4h_normal() {
999        let fp = Ja4hFingerprint {
1000            raw: "ge11cren_a1b2c3d4e5f6_aabbccddeeff".to_string(),
1001            method: "ge".to_string(),
1002            http_version: 11,
1003            has_cookie: true,
1004            has_referer: true,
1005            accept_lang: "en".to_string(),
1006            header_hash: "a1b2c3d4e5f6".to_string(),
1007            cookie_hash: "aabbccddeeff".to_string(),
1008        };
1009
1010        let analysis = analyze_ja4h(&fp);
1011        assert!(!analysis.suspicious);
1012        assert!(analysis.issues.is_empty());
1013    }
1014
1015    #[test]
1016    fn test_analyze_ja4h_suspicious() {
1017        let fp = Ja4hFingerprint {
1018            raw: "ge10nn00_a1b2c3d4e5f6_000000000000".to_string(),
1019            method: "ge".to_string(),
1020            http_version: 10,
1021            has_cookie: false,
1022            has_referer: false,
1023            accept_lang: "00".to_string(),
1024            header_hash: "a1b2c3d4e5f6".to_string(),
1025            cookie_hash: "000000000000".to_string(),
1026        };
1027
1028        let analysis = analyze_ja4h(&fp);
1029        assert!(analysis.suspicious);
1030        assert!(analysis.issues.iter().any(|i| i.contains("HTTP/1.0")));
1031        assert!(analysis
1032            .issues
1033            .iter()
1034            .any(|i| i.contains("Accept-Language")));
1035    }
1036
1037    // ==================== Performance Benchmark Hints ====================
1038
1039    #[test]
1040    #[cfg_attr(not(feature = "heavy-tests"), ignore)]
1041    fn test_ja4_parsing_performance() {
1042        // This test verifies that parsing is fast by running many iterations
1043        let input = "t13d1516h2_8daaf6152771_e5627efa2ab1";
1044        let iterations = test_limit_usize("SYNAPSE_TEST_PERF_ITERATIONS", 10_000, 100);
1045        eprintln!("test_ja4_parsing_performance: iterations={}", iterations);
1046        let start = std::time::Instant::now();
1047
1048        for _ in 0..iterations {
1049            let _ = parse_ja4_from_header(Some(input));
1050        }
1051
1052        let elapsed = start.elapsed();
1053        // Should complete 10K parses in under 500ms in debug mode (50μs each)
1054        // Note: Release builds are ~5x faster, but tests run in debug by default
1055        assert!(
1056            elapsed.as_millis() < 500,
1057            "JA4 parsing too slow: {:?}",
1058            elapsed
1059        );
1060    }
1061
1062    #[test]
1063    #[cfg_attr(not(feature = "heavy-tests"), ignore)]
1064    fn test_ja4h_generation_performance() {
1065        let headers = vec![
1066            header("Accept", "text/html"),
1067            header("User-Agent", "Mozilla/5.0"),
1068            header("Accept-Language", "en-US"),
1069            header("Cookie", "session=abc; user=test"),
1070        ];
1071        let request = HttpHeaders {
1072            headers: &headers,
1073            method: "GET",
1074            http_version: "1.1",
1075        };
1076
1077        let start = std::time::Instant::now();
1078
1079        let iterations = test_limit_usize("SYNAPSE_TEST_PERF_ITERATIONS", 10_000, 100);
1080        eprintln!(
1081            "test_ja4h_generation_performance: iterations={}",
1082            iterations
1083        );
1084
1085        for _ in 0..iterations {
1086            let _ = generate_ja4h(&request);
1087        }
1088
1089        let elapsed = start.elapsed();
1090        // Should complete 10K generations in under 200ms (20μs each) in release mode
1091        // Debug mode is ~5x slower, so allow 1000ms
1092        #[cfg(debug_assertions)]
1093        let max_time_ms = 1000;
1094        #[cfg(not(debug_assertions))]
1095        let max_time_ms = 200;
1096
1097        assert!(
1098            elapsed.as_millis() < max_time_ms,
1099            "JA4H generation too slow: {:?}",
1100            elapsed
1101        );
1102    }
1103}