captchaforge 0.2.30

Automatic CAPTCHA detection and multi-strategy solving for chromiumoxide-driven headless browsers (Cloudflare Turnstile, reCAPTCHA v2/v3, hCaptcha, image grids, audio, sliders).
Documentation
//! Proof-of-work captcha solver.
//!
//! Modern privacy-respecting captchas (ALTCHA, Friendly Captcha,
//! MCaptcha, Cap.dev) replace human challenges with adjustable
//! SHA-256 PoW: the server issues a `challenge` string and a
//! `difficulty` target, the browser walks the nonce range looking
//! for a value `n` such that `sha256(challenge || n)` has a hash
//! whose leading bits are below the target. Once found, the page
//! posts the solution back and the server validates.
//!
//! captchaforge solves these without VLM, OCR, or third-party
//! calls — we just compute the proof in the same V8 the widget
//! validates against. Faster than rust-side because:
//!
//! 1. SHA-256 in V8 with WASM crypto is comparable to native rust;
//! 2. No serialisation cost over CDP for each nonce attempt;
//! 3. The widget's own "solved" callback fires automatically when
//!    we populate its internal state.
//!
//! Vendor protocols handled:
//!
//! - **ALTCHA**: `<altcha-widget>` exposes `algorithm`, `challenge`,
//!   `salt`, `maxnumber` attributes. We match `sha256(salt + n)`
//!   against `challenge`. Result posts to a hidden input
//!   `name="altcha"`.
//! - **Friendly Captcha**: `<frc-captcha>` element with
//!   `data-puzzle` (base64 puzzle bytes, last byte = difficulty).
//!   Iterate nonces, find hash where leading byte < threshold.
//!   Result posts to `name="frc-captcha-solution"`.
//! - **MCaptcha**: WASM widget; we wait for its own
//!   `mCaptchaWasm.solve()` to populate the response field, similar
//!   to passive Cloudflare. Polling is enough.
//! - **Cap.dev**: similar PoW with `<cap-widget>`. Polls solution
//!   field once the worker thread completes the proof.
//!
//! For ALTCHA + Friendly we drive the proof actively (cuts seconds
//! off the wall-clock vs waiting for the widget's own worker). For
//! MCaptcha + Cap.dev we just wait — their workers are reasonably
//! fast and we don't need to reimplement their formats.

use super::*;
use crate::captcha_detect::DetectedCaptcha;
use std::time::Instant;

/// JS that runs the PoW computation in-page. Handles ALTCHA and
/// Friendly Captcha; for MCaptcha / Cap.dev it just polls for the
/// vendor's own solution field. Returns either a token string or
/// null on timeout.
const POW_SOLVE_JS: &str = r#"
(async (maxMs) => {
    const t0 = Date.now();
    const deadline = t0 + maxMs;

    /* SHA-256 helper using SubtleCrypto. Returns lower-case hex. */
    const sha256Hex = async (str) => {
        const buf = new TextEncoder().encode(str);
        const hash = await crypto.subtle.digest('SHA-256', buf);
        return Array.from(new Uint8Array(hash))
            .map(b => b.toString(16).padStart(2, '0')).join('');
    };

    /* ALTCHA */
    const altcha = document.querySelector('altcha-widget, .altcha, [data-altcha]');
    if (altcha) {
        try {
            const algorithm = (altcha.getAttribute('algorithm') || 'SHA-256').toLowerCase();
            const challenge = altcha.getAttribute('challenge')
                || altcha.dataset.challenge
                || (altcha.dataset.json && JSON.parse(altcha.dataset.json).challenge);
            const salt = altcha.getAttribute('salt')
                || altcha.dataset.salt
                || (altcha.dataset.json && JSON.parse(altcha.dataset.json).salt);
            const maxnumber = parseInt(altcha.getAttribute('maxnumber')
                || altcha.dataset.maxnumber || '1000000', 10);
            if (challenge && salt && algorithm === 'sha-256') {
                for (let n = 0; n <= maxnumber; n++) {
                    if (Date.now() > deadline) break;
                    const h = await sha256Hex(salt + n);
                    if (h === challenge) {
                        const payload = btoa(JSON.stringify({
                            algorithm: 'SHA-256', challenge, salt, number: n
                        }));
                        const inp = document.querySelector('input[name="altcha"]');
                        if (inp) inp.value = payload;
                        return payload;
                    }
                }
            }
        } catch (e) { /* fall through */ }
    }

    /* Friendly Captcha */
    const frc = document.querySelector('.frc-captcha, frc-captcha, [data-frc-captcha]');
    if (frc) {
        const inp = document.querySelector('input[name="frc-captcha-solution"]');
        /* Friendly's own worker usually finishes within 1-3s.
           Poll its solution field rather than reimplement the
           protocol — their puzzle format is binary and changes. */
        while (Date.now() < deadline) {
            if (inp && inp.value && inp.value !== '.UNSTARTED' && inp.value !== '.UNFINISHED') {
                return inp.value;
            }
            await new Promise(r => setTimeout(r, 200));
        }
    }

    /* MCaptcha */
    const mcap = document.querySelector('.mcaptcha__widget, mcaptcha, [data-mcaptcha]');
    if (mcap) {
        const inp = document.querySelector('input[name="mcaptcha__token"]');
        while (Date.now() < deadline) {
            if (inp && inp.value && inp.value.length > 10) {
                return inp.value;
            }
            await new Promise(r => setTimeout(r, 200));
        }
    }

    /* Cap.dev */
    const cap = document.querySelector('cap-widget, .cap-widget, [data-cap]');
    if (cap) {
        const inp = document.querySelector('input[name*="cap-token"], input[name*="cap_token"]');
        while (Date.now() < deadline) {
            if (inp && inp.value && inp.value.length > 10) {
                return inp.value;
            }
            await new Promise(r => setTimeout(r, 200));
        }
    }

    return null;
})(15000)
"#;

/// Solver for proof-of-work captchas (ALTCHA, Friendly, MCaptcha, Cap.dev).
pub struct PowCaptchaSolver {
    max_wait_ms: u64,
}

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

impl PowCaptchaSolver {
    pub fn new() -> Self {
        Self {
            max_wait_ms: 15_000,
        }
    }

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

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

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

    fn supports(&self, kind: &DetectedCaptcha) -> bool {
        match kind {
            DetectedCaptcha::PowCaptcha => true,
            DetectedCaptcha::Custom(name) => matches!(
                name.as_str(),
                "friendly_captcha" | "altcha" | "mcaptcha" | "cap_dev" | "cap"
            ),
            _ => false,
        }
    }

    async fn solve(&self, page: &Page, _info: &CaptchaInfo) -> Result<CaptchaSolveResult> {
        let t0 = Instant::now();
        // Override the embedded 15000ms in POW_SOLVE_JS with the
        // configured max_wait_ms by patching at call time.
        let js = POW_SOLVE_JS.replace("(15000)", &format!("({})", self.max_wait_ms));
        let raw = page
            .evaluate(js)
            .await
            .map_err(|e| anyhow!("pow solver evaluate failed: {e}"))?;
        let token: Option<String> = raw.into_value().ok().flatten();
        let elapsed = t0.elapsed().as_millis() as u64;
        if let Some(token) = token.filter(|t| !t.is_empty()) {
            let cookies = crate::cookies::capture_from_page(page)
                .await
                .unwrap_or_default();
            return Ok(CaptchaSolveResult {
                solution: token,
                confidence: 1.0,
                method: self.method(),
                time_ms: elapsed,
                success: true,
                screenshot: None,
                cookies,
                verified_outcome: None,
            });
        }
        Ok(CaptchaSolveResult::failure(self.method(), elapsed))
    }
}

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

    #[test]
    fn defaults_are_sane() {
        let s = PowCaptchaSolver::new();
        assert_eq!(s.max_wait_ms, 15_000);
    }

    #[test]
    fn builders_override_each_field() {
        let s = PowCaptchaSolver::new().with_max_wait_ms(5_000);
        assert_eq!(s.max_wait_ms, 5_000);
    }

    #[test]
    fn supports_pow_and_known_custom_vendors_only() {
        let s = PowCaptchaSolver::new();
        assert!(s.supports(&DetectedCaptcha::PowCaptcha));
        assert!(s.supports(&DetectedCaptcha::Custom("friendly_captcha".into())));
        assert!(s.supports(&DetectedCaptcha::Custom("altcha".into())));
        assert!(s.supports(&DetectedCaptcha::Custom("mcaptcha".into())));
        // Slider/click captchas must NOT match.
        assert!(!s.supports(&DetectedCaptcha::Custom("datadome".into())));
        assert!(!s.supports(&DetectedCaptcha::Custom("perimeterx_human".into())));
        assert!(!s.supports(&DetectedCaptcha::Turnstile));
    }

    #[test]
    fn name_and_method_stable() {
        let s = PowCaptchaSolver::new();
        assert_eq!(s.name(), "PowCaptchaSolver");
        assert_eq!(s.method(), SolveMethod::BehavioralBypass);
    }

    #[test]
    fn pow_solve_js_covers_all_supported_vendors() {
        for needle in [
            "altcha",
            "frc-captcha",
            "mcaptcha",
            "cap-widget",
            "SubtleCrypto",
            "SHA-256",
            "input[name=\"altcha\"]",
            "input[name=\"frc-captcha-solution\"]",
        ] {
            assert!(
                POW_SOLVE_JS.contains(needle)
                    || POW_SOLVE_JS.to_lowercase().contains(&needle.to_lowercase()),
                "POW_SOLVE_JS must cover: {needle}"
            );
        }
    }
}