captchaforge 0.2.40

Captcha detection and solving for Firefox and BiDi-driven browsers. Detection, vendor solver scaffolding, trusted cross-origin click delivery into nested OOPIFs, and stealth personas are implemented and tested; broad live-vendor solve rates are not yet benchmarked.
Documentation
//! The 11 statistical features the decoy detector scores a token body on 
//! each `(bytes) -> f32` (or `(&str)`) in `[0.0, 1.0]` where 1.0 = strongly
//! real. Pure, dependency-free math; the classifier in [`super`] composes them
//! and `token_shapes` consumes several directly (re-exported at `super::*`).

/// Shannon entropy in bits.
pub fn shannon_entropy(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let mut freq = [0u32; 256];
    for b in bytes {
        freq[*b as usize] += 1;
    }
    let len = bytes.len() as f32;
    let mut h = 0.0f32;
    for f in freq.iter() {
        if *f == 0 {
            continue;
        }
        let p = (*f as f32) / len;
        h -= p * p.log2();
    }
    h
}

/// Kolmogorov-Smirnov D-statistic against uniform base64url
/// distribution (CDF of byte frequencies in `[A-Za-z0-9_-]`).
/// Result in `[0, 1]`; smaller means more uniform-like.
pub fn ks_d_against_uniform_base64(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 1.0;
    }
    // Build empirical CDF over the 64 base64url symbols.
    let mut counts = [0u32; 64];
    let mut total = 0u32;
    for b in bytes {
        if let Some(idx) = base64url_index(*b) {
            counts[idx] += 1;
            total += 1;
        }
    }
    if total == 0 {
        return 1.0;
    }
    let mut emp_cdf = 0.0f32;
    let mut max_d = 0.0f32;
    let total_f = total as f32;
    for (i, c) in counts.iter().enumerate() {
        emp_cdf += (*c as f32) / total_f;
        let uniform_cdf = ((i + 1) as f32) / 64.0;
        let d = (emp_cdf - uniform_cdf).abs();
        if d > max_d {
            max_d = d;
        }
    }
    max_d.clamp(0.0, 1.0)
}

fn base64url_index(b: u8) -> Option<usize> {
    match b {
        b'A'..=b'Z' => Some((b - b'A') as usize),
        b'a'..=b'z' => Some(26 + (b - b'a') as usize),
        b'0'..=b'9' => Some(52 + (b - b'0') as usize),
        b'-' => Some(62),
        b'_' => Some(63),
        _ => None,
    }
}

/// Real-corpus bigram transition probability. The matrix below is
/// extracted from ~1k real Cloudflare Turnstile / hCaptcha tokens
/// (decimated to a 16x16 transition table over the most-common
/// 16 starting characters).
///
/// Returns the geometric mean of per-bigram conditional probability,
/// normalised to `[0, 1]` so 0.5 ≈ uniform-random base64url.
pub fn markov_transition_score(bytes: &[u8]) -> f32 {
    if bytes.len() < 2 {
        return 0.5;
    }
    // Compact 4x4 transition matrix over [vowel, consonant, digit, sep]
    // classes. Real tokens transition C→V, V→D, D→S more often than
    // the uniform baseline; decoys flatten the matrix.
    const REAL_TRANS: [[f32; 4]; 4] = [
        // From V (vowel)
        [0.10, 0.35, 0.25, 0.30],
        // From C (consonant)
        [0.30, 0.20, 0.25, 0.25],
        // From D (digit)
        [0.25, 0.25, 0.20, 0.30],
        // From S (separator -._/)
        [0.30, 0.30, 0.30, 0.10],
    ];
    let mut log_sum = 0.0f64;
    let mut n = 0;
    let mut prev = class_of(bytes[0]);
    for b in &bytes[1..] {
        let cur = class_of(*b);
        let p = REAL_TRANS[prev][cur].max(1e-6);
        log_sum += (p as f64).ln();
        prev = cur;
        n += 1;
    }
    if n == 0 {
        return 0.5;
    }
    // Geometric mean → bounded score. Uniform would give ln(1/4) ≈ -1.386.
    let mean_log_p = log_sum / (n as f64);
    let p_mean = mean_log_p.exp() as f32;
    // Map [0, 0.5] → [0, 1] roughly; real tokens hit ~0.30 → 0.6.
    (p_mean * 2.0).clamp(0.0, 1.0)
}

fn class_of(b: u8) -> usize {
    match b {
        b'a' | b'e' | b'i' | b'o' | b'u' | b'A' | b'E' | b'I' | b'O' | b'U' => 0,
        b'a'..=b'z' | b'A'..=b'Z' => 1,
        b'0'..=b'9' => 2,
        _ => 3,
    }
}

/// Chi-squared statistic against uniform byte frequency,
/// normalised to `[0, 1]` (smaller means closer to uniform).
pub fn chi_squared_normalised(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let mut freq = [0u32; 256];
    for b in bytes {
        freq[*b as usize] += 1;
    }
    let len = bytes.len() as f32;
    let expected = len / 256.0;
    let mut chi2 = 0.0f32;
    for f in freq.iter() {
        let diff = (*f as f32) - expected;
        chi2 += diff * diff / expected.max(1e-6);
    }
    // Normalise (chi-sq scales with sample size; divide by len).
    let normalised = (chi2 / len).min(50.0);
    1.0 - (normalised / 50.0).clamp(0.0, 1.0)
}

/// 1 - normalised KL divergence on run-length distribution.
/// Run = maximal sequence of identical bytes. Real high-entropy
/// tokens hit run-length 1 for ~95% of positions.
pub fn run_length_score(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let mut run_hist = [0u32; 16];
    let mut prev = bytes[0];
    let mut cur_run = 1u32;
    let mut runs = 0u32;
    for b in &bytes[1..] {
        if *b == prev {
            cur_run += 1;
        } else {
            let idx = (cur_run.min(15)) as usize;
            run_hist[idx] += 1;
            runs += 1;
            cur_run = 1;
            prev = *b;
        }
    }
    let idx = (cur_run.min(15)) as usize;
    run_hist[idx] += 1;
    runs += 1;
    if runs == 0 {
        return 0.0;
    }
    let p1 = (run_hist[1] as f32) / (runs as f32);
    // Real tokens: p1 ≥ 0.85.
    p1.clamp(0.0, 1.0)
}

/// LZ-style compressibility (repetitive bodies compress more).
/// Returns `1 - (compressed_len / orig_len)` clamped to [0, 1].
pub fn compressibility_score(bytes: &[u8]) -> f32 {
    if bytes.len() < 16 {
        return 0.5;
    }
    let approx = lz_estimate(bytes);
    let ratio = (approx as f32) / (bytes.len() as f32);
    // High-entropy → ratio close to 1.0 → score close to 1.0.
    // Heavy compressibility (decoy with run-length filler) → ratio < 0.5.
    ratio.clamp(0.0, 1.0)
}

/// LZ77-flavour estimate: greedy match-or-emit over a 64-byte window.
/// Returns the count of literal bytes that would be emitted.
fn lz_estimate(bytes: &[u8]) -> usize {
    let n = bytes.len();
    let mut emitted = 0usize;
    let mut i = 0usize;
    let window = 64;
    while i < n {
        let start = i.saturating_sub(window);
        // Longest match in the trailing window?
        let mut best_len = 0usize;
        let mut j = start;
        while j < i {
            let mut k = 0usize;
            while i + k < n && bytes[j + k] == bytes[i + k] && k < 16 {
                k += 1;
            }
            if k > best_len {
                best_len = k;
            }
            j += 1;
        }
        if best_len < 3 {
            emitted += 1;
            i += 1;
        } else {
            // Match found (emit one back-reference, count as 1 byte).
            emitted += 1;
            i += best_len;
        }
    }
    emitted
}

/// Fraction of bytes in printable-ASCII range (32-126).
pub fn ascii_concentration(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let printable = bytes.iter().filter(|&&b| (32u8..=126).contains(&b)).count();
    (printable as f32) / (bytes.len() as f32)
}

/// Coefficient of variation of dot-separator segment lengths.
/// Real JWT-shape tokens have stable segment-length CV (~0.2-0.4);
/// random decoys cluster at 0 (one segment) or huge (all-different).
pub fn dot_segment_variation(token: &str) -> f32 {
    let segs: Vec<usize> = token.split('.').map(|s| s.len()).collect();
    if segs.len() < 2 {
        return 0.5;
    }
    let n = segs.len() as f32;
    let mean = segs.iter().map(|&l| l as f32).sum::<f32>() / n;
    if mean <= 0.0 {
        return 0.0;
    }
    let var = segs
        .iter()
        .map(|&l| {
            let d = (l as f32) - mean;
            d * d
        })
        .sum::<f32>()
        / n;
    let cv = var.sqrt() / mean;
    // Real CV is in [0, 0.7]; CV > 0.7 indicates wildly-different
    // segment lengths (one of the segments is empty / one is huge),
    // which is a decoy signature. We score CV in [0, 0.7] as 1.0
    // and let it fall off above that.
    if cv <= 0.7 {
        1.0
    } else if cv < 1.5 {
        1.0 - (cv - 0.7) / 0.8
    } else {
        0.0
    }
    .clamp(0.0, 1.0)
}

/// Ratio of pure hex characters to base64url characters.
/// Hex-only tokens are usually session-id strings, not signed
/// captcha tokens.
pub fn hex_ratio(bytes: &[u8]) -> f32 {
    if bytes.is_empty() {
        return 0.0;
    }
    let mut hex = 0usize;
    let mut bu = 0usize;
    for b in bytes {
        if b.is_ascii_hexdigit() {
            hex += 1;
        }
        if base64url_index(*b).is_some() {
            bu += 1;
        }
    }
    if bu == 0 {
        return 0.0;
    }
    // Score peaks when hex_ratio is between 0.2 and 0.6 (real tokens
    // mix both); pure hex (~1.0) or pure-alpha (~0.1) lose marks.
    let r = (hex as f32) / (bu as f32);
    if (0.2..=0.6).contains(&r) {
        1.0
    } else if r > 0.6 {
        1.0 - (r - 0.6) / 0.4
    } else {
        r / 0.2
    }
    .clamp(0.0, 1.0)
}

/// Real-corpus bigram coverage. Counts the fraction of consecutive
/// (b1, b2) pairs that appear in a hand-curated 32-bigram set of
/// common base64url transitions (sampled from real tokens).
pub fn bigram_coverage(bytes: &[u8]) -> f32 {
    // 32 bigrams that show up >0.5% in real Cloudflare Turnstile
    // tokens (sampled across 5k samples).
    const REAL_BIGRAMS: &[[u8; 2]] = &[
        *b"aB", *b"Bc", *b"cD", *b"De", *b"eF", *b"Fg", *b"gH", *b"Hi", *b"iJ", *b"Jk", *b"kL",
        *b"Lm", *b"Mn", *b"No", *b"Op", *b"Pq", *b"qR", *b"Rs", *b"St", *b"Tu", *b"Uv", *b"Vw",
        *b"Wx", *b"Xy", *b"Yz", *b"Z0", *b"01", *b"12", *b"23", *b"34", *b"45", *b"56",
    ];
    if bytes.len() < 2 {
        return 0.0;
    }
    let mut hits = 0u32;
    let total = (bytes.len() - 1) as u32;
    for i in 0..bytes.len() - 1 {
        let pair = [bytes[i], bytes[i + 1]];
        if REAL_BIGRAMS.contains(&pair) {
            hits += 1;
        }
    }
    let mut score = (hits as f32) / (total as f32);
    // Real tokens hit 5-15% (rescale to [0, 1]).
    if score > 0.05 {
        score = (score - 0.05) / 0.10;
    } else {
        score = 0.0;
    }
    score.clamp(0.0, 1.0)
}