captchaforge 0.2.39

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
//! Arkose Labs (FunCaptcha) dedicated solver.
//!
//! Arkose Labs (acquired by JumpCloud, still trades under the
//! FunCaptcha brand for the consumer-facing puzzle) is the dominant
//! rotation/orientation challenge vendor. Used by Twitter/X, EA,
//! Roblox, LinkedIn at-scale anti-abuse, and many others. Two
//! variants surface in the wild:
//!
//! - **Silent / passive**: `enforcement.html` runs in a hidden
//!   iframe, gathers fingerprint, submits without UI. On a clean
//!   session the JS surfaces a `verification-token` (newer SDK) or
//!   `fc-token` (legacy SDK) on a hidden form input or as a
//!   callback parameter. No interaction needed.
//! - **Interactive puzzle**: rotation, orientation, "pick the
//!   image that matches", click-the-icon variants in an iframe at
//!   `client-api.arkoselabs.com` or `funcaptcha.com`. The actual
//!   solve requires a vision model, handled by
//!   [`super::super::VlmCaptchaSolver`] when this solver yields with
//!   a screenshot.
//!
//! Strategy here: poll for the token. If it lands, return success
//! with the token in the solution payload. If a puzzle iframe
//! surfaces, yield to VLM via screenshot+failure.

use super::super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::{Duration, Instant};

/// Default total wait budget. Arkose silent enforcement typically
/// resolves within 4–7s on a clean fingerprint; 12s upper bound
/// matches sibling vendor solvers.
const DEFAULT_MAX_WAIT_MS: u64 = 12_000;

/// Arkose Labs / FunCaptcha token-watch + iframe-detect solver.
pub struct ArkoseSolver {
    max_wait_ms: u64,
}

impl Default for ArkoseSolver {
    fn default() -> Self {
        Self::new()
    }
}

impl ArkoseSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: DEFAULT_MAX_WAIT_MS,
        }
    }

    pub fn with_max_wait_ms(mut self, ms: u64) -> Self {
        self.max_wait_ms = ms;
        self
    }
}

/// Pure validator for an Arkose `verification-token` (or legacy
/// `fc-token`) value.
///
/// Documented shape: `<32-hex-id>.<numeric-timestamp>.<hash>`
///: three dot-separated segments. The legacy `fc-token` shape is
/// `<base64>|r=<region>|metabgclr=...|...` (URL-encoded query-style
/// pipe-separated parameters). We accept either by structural
/// detection: dot-separated 3-tuple OR pipe-separated with `r=`.
///
/// # Examples
///
/// ```
/// use captchaforge::solver::vendors::arkose::is_valid_arkose_token;
///
/// assert!(!is_valid_arkose_token(""));
/// assert!(!is_valid_arkose_token("short"));
/// // New SDK shape
/// let new = "abcdef0123456789abcdef0123456789.1736899200.deadbeefdeadbeef";
/// assert!(is_valid_arkose_token(new));
/// // Legacy fc-token shape
/// let legacy = "abc123|r=us-east-1|metabgclr=transparent|guitextcolor=ffffff";
/// assert!(is_valid_arkose_token(legacy));
/// ```
pub fn is_valid_arkose_token(value: &str) -> bool {
    if value.len() < 30 {
        return false;
    }
    let trimmed = value.trim_matches('"');
    // New SDK: dot-separated 3-tuple, middle is numeric epoch
    if let Some((a, rest)) = trimmed.split_once('.') {
        if let Some((b, c)) = rest.split_once('.') {
            if !a.is_empty() && !c.is_empty() {
                if let Ok(t) = b.parse::<u64>() {
                    if (1_577_836_800..4_102_444_800).contains(&t) {
                        return true;
                    }
                }
            }
        }
    }
    // Legacy fc-token: pipe-separated with `r=<region>` segment
    if trimmed.contains("|r=") && trimmed.contains('|') {
        return true;
    }
    false
}

/// JS that probes the page for Arkose state. Returns:
///   - `{ phase: "passed", token: "..." }`: verification-token /
///     fc-token populated
///   - `{ phase: "puzzle" }`: challenge iframe rendered
///   - `{ phase: "interstitial" }`: SDK loaded, no token yet
///   - `{ phase: "unknown" }`: no Arkose signals
const PHASE_PROBE_JS: &str = r#"
(() => {
    const readField = (sel) => {
        const el = document.querySelector(sel);
        return el ? (el.value || el.getAttribute('value') || '') : '';
    };
    /* Token surfaces on a hidden form input (canonical) OR a
       window global (some custom integrations). */
    const token =
        readField('input[name="verification-token"], input[name="fc-token"], input[name="arkose-token"]') ||
        (window.arkoseToken || '') ||
        (window.fcToken || '');
    if (token) return { phase: 'passed', token };
    /* Visible challenge iframe, yield to VLM. */
    if (document.querySelector('iframe[src*="arkoselabs.com"], iframe[src*="funcaptcha.com"], #funcaptcha, .arkose-iframe')) {
        return { phase: 'puzzle' };
    }
    /* SDK loaded but nothing surfaced yet. */
    const sdkPresent =
        !!document.querySelector('script[src*="client-api.arkoselabs.com"], script[src*="funcaptcha.com"], [data-pkey]') ||
        typeof window.ArkoseEnforcement !== 'undefined' ||
        typeof window.FunCaptcha !== 'undefined';
    if (sdkPresent) return { phase: 'interstitial' };
    return { phase: 'unknown' };
})()
"#;

#[derive(Debug, serde::Deserialize)]
struct PhaseProbe {
    phase: String,
    #[serde(default)]
    token: String,
}

#[async_trait]
impl CaptchaSolver for ArkoseSolver {
    fn name(&self) -> &'static str {
        "ArkoseSolver"
    }

    fn method(&self) -> SolveMethod {
        SolveMethod::AutoPass
    }

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        // Arkose is detected as Custom("arkose_funcaptcha"); also
        // accept ImageCaptcha because the visual puzzle variant
        // sometimes surfaces via the image-captcha detector first.
        matches!(
            kind,
            DetectedCaptcha::Custom(_) | DetectedCaptcha::ImageCaptcha
        )
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        let deadline = t0 + Duration::from_millis(self.max_wait_ms);

        loop {
            let raw = page
                .evaluate(PHASE_PROBE_JS)
                .await
                .map_err(|e| anyhow!("Arkose probe failed: {e}"))?;
            let probe: PhaseProbe = match raw.into_value() {
                Ok(p) => p,
                Err(_) => PhaseProbe {
                    phase: "interstitial".into(),
                    token: String::new(),
                },
            };

            match probe.phase.as_str() {
                "passed" if is_valid_arkose_token(&probe.token) => {
                    let cookies = crate::cookies::capture_from_page(page)
                        .await
                        .unwrap_or_default();
                    return Ok(CaptchaSolveResult {
                        solution: probe.token,
                        confidence: 0.95,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: true,
                        screenshot: None,
                        cookies,
                        verified_outcome: None,
                    });
                }
                "passed" => {
                    // Token surfaced but malformed (keep waiting).
                }
                "puzzle" => {
                    // Yield to VLM via screenshot+failure.
                    let shot = super::super::screenshot_b64(page).await.ok();
                    return Ok(CaptchaSolveResult {
                        solution: String::new(),
                        confidence: 0.0,
                        method: SolveMethod::AutoPass,
                        time_ms: t0.elapsed().as_millis() as u64,
                        success: false,
                        screenshot: shot,
                        cookies: Vec::new(),
                        verified_outcome: None,
                    });
                }
                _ => {} // interstitial / unknown, keep waiting
            }

            if Instant::now() >= deadline {
                return Ok(CaptchaSolveResult::failure(
                    SolveMethod::AutoPass,
                    t0.elapsed().as_millis() as u64,
                ));
            }
            tokio::time::sleep(Duration::from_millis(400)).await;
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn defaults_are_sane() {
        assert_eq!(ArkoseSolver::new().max_wait_ms, 12_000);
    }

    #[test]
    fn builders_override() {
        assert_eq!(
            ArkoseSolver::new().with_max_wait_ms(5_000).max_wait_ms,
            5_000
        );
    }

    #[test]
    fn name_method_stable() {
        let s = ArkoseSolver::new();
        assert_eq!(s.name(), "ArkoseSolver");
        assert_eq!(s.method(), SolveMethod::AutoPass);
    }

    #[test]
    fn supports_custom_and_image_captcha() {
        let s = ArkoseSolver::new();
        assert!(s.supports(&DetectedCaptcha::Custom("arkose_funcaptcha".into())));
        assert!(s.supports(&DetectedCaptcha::ImageCaptcha));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
        assert!(!s.supports(&DetectedCaptcha::HCaptcha));
        assert!(!s.supports(&DetectedCaptcha::RecaptchaV2));
    }

    #[test]
    fn token_rejects_short_or_empty() {
        assert!(!is_valid_arkose_token(""));
        assert!(!is_valid_arkose_token("short"));
        assert!(!is_valid_arkose_token(&"a".repeat(29))); // below 30 floor
    }

    #[test]
    fn token_accepts_new_sdk_shape() {
        let new = "abcdef0123456789abcdef0123456789.1736899200.deadbeefdeadbeef";
        assert!(is_valid_arkose_token(new));
    }

    #[test]
    fn token_rejects_new_sdk_with_unparseable_timestamp() {
        let bad = "abcdef0123456789abcdef0123456789.NOT-A-TIME.deadbeefdeadbeef";
        assert!(!is_valid_arkose_token(bad));
    }

    #[test]
    fn token_rejects_new_sdk_with_out_of_range_timestamp() {
        let too_old = "abcdef0123456789abcdef0123456789.1000000000.deadbeefdeadbeef"; // 2001
        let too_new = "abcdef0123456789abcdef0123456789.5000000000.deadbeefdeadbeef"; // 2128
        assert!(!is_valid_arkose_token(too_old));
        assert!(!is_valid_arkose_token(too_new));
    }

    #[test]
    fn token_accepts_legacy_fc_token() {
        let legacy = "abc123|r=us-east-1|metabgclr=transparent|guitextcolor=ffffff";
        assert!(is_valid_arkose_token(legacy));
    }

    #[test]
    fn token_rejects_legacy_without_region_segment() {
        // Pipes alone aren't enough (must include r=<region>).
        let bad = "abc123|metabgclr=transparent|guitextcolor=ffffff|extra=xx";
        assert!(!is_valid_arkose_token(bad));
    }

    #[test]
    fn token_accepts_quoted_value() {
        let real = "\"abcdef0123456789abcdef0123456789.1736899200.deadbeef\"";
        assert!(is_valid_arkose_token(real));
    }

    #[test]
    fn phase_probe_js_covers_documented_signals() {
        for needle in [
            "verification-token",
            "fc-token",
            "arkose-token",
            "arkoselabs.com",
            "funcaptcha.com",
            "client-api.arkoselabs.com",
            "data-pkey",
            "arkoseenforcement",
            "funcaptcha",
        ] {
            assert!(
                PHASE_PROBE_JS.to_lowercase().contains(needle),
                "PHASE_PROBE_JS must reference: {needle}"
            );
        }
    }

    #[test]
    fn phase_probe_js_emits_canonical_phases() {
        for phase in ["passed", "puzzle", "interstitial", "unknown"] {
            assert!(
                PHASE_PROBE_JS.contains(&format!("'{phase}'")),
                "PHASE_PROBE_JS must emit phase: {phase}"
            );
        }
    }
}