diff --git a/CHANGELOG.md b/CHANGELOG.md index e67f2b9..efaac3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,33 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-09-12 + +### Added + +- **ALTCHA support** via `altcha(url, **kwargs)` on both `CapSkip` and + `AsyncCapSkip`. Pass `challenge_url` for CapSkip to fetch the challenge, or + `challenge_json` with the document itself (a JSON string, or a `dict` which is + serialized for you). Sending both is allowed — the inline document wins. The + result exposes `token` (the value the site's `altcha` form field expects) and + `number`, the counter that solved it; `code` keeps the same raw string. +- Parameter aliases `challengeUrl`/`challengeURL` for `challenge_url` and + `challengeJson`/`challengeJSON` for `challenge_json`. +- Both ALTCHA generations are handled: the legacy scheme (SHA-1/256/384/512) and + proof-of-work v2 (PBKDF2 or SHA). Their tokens are shaped differently — a v2 + payload carries no top-level `number`, its counter sitting at + `solution.counter` — so the counter is taken from the server's own `solution` + object, the one field both report the same way, and dug out of the token only + when a poll did not carry it. + +### Notes + +- ALTCHA is CPU proof-of-work rather than a browser solve, so it uses + `defaultTimeout` instead of the longer `recaptchaTimeout` that reCAPTCHA, + Turnstile and GeeTest use. +- A proxy passed to `altcha()` applies only to the `challenge_url` fetch; a task + carrying its challenge inline never touches the network. + ## [1.1.0] - 2026-07-26 ### Added diff --git a/README.md b/README.md index 277d322..95fb2e5 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) [![Tests](https://github.com/capskip/capskip-python/actions/workflows/ci.yml/badge.svg)](https://github.com/capskip/capskip-python/actions/workflows/ci.yml) -**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest and image captchas from Python.** +**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest, ALTCHA and image captchas from Python.** Official Python client for [CapSkip](https://capskip.com), a **local captcha solver** that runs on your own machine. Licensed once, not billed per solve. @@ -19,7 +19,7 @@ pip install capskip CapSkip is a desktop app. It does the solving on your machine and exposes the standard captcha-solver HTTP API — the same `in.php` / `res.php` endpoints every 2captcha-compatible client already speaks — on `127.0.0.1:8080`. -This SDK is a thin wrapper over that API, with the method names you would expect: `normal()`, `recaptcha()`, `turnstile()`, `geetest()`. Nothing leaves your network, and there is no credit balance to keep an eye on. +This SDK is a thin wrapper over that API, with the method names you would expect: `normal()`, `recaptcha()`, `turnstile()`, `geetest()`, `altcha()`. Nothing leaves your network, and there is no credit balance to keep an eye on. ## Supported captcha types @@ -34,6 +34,7 @@ This SDK is a thin wrapper over that API, with the method names you would expect | **Cloudflare Turnstile solver** (widget) | `solver.turnstile(sitekey, url)` | | Cloudflare Turnstile (challenge page) | `solver.turnstile(..., data=..., pagedata=...)` | | **GeeTest v3 solver** (slide puzzle) | `solver.geetest(gt, challenge, url)` | +| **ALTCHA solver** (proof-of-work) | `solver.altcha(url, challenge_url=...)` | **hCaptcha and FunCaptcha/Arkose are not supported.** hCaptcha is the one people misidentify most often, since it also puts a `data-sitekey` on the widget — check for `class="h-captcha"` or a `js.hcaptcha.com` script before reaching for `recaptcha()`. @@ -127,6 +128,7 @@ solver = CapSkip( port=8080, # CapSkip port from app settings defaultTimeout=120, # seconds — image captcha polling timeout recaptchaTimeout=300, # seconds — reCAPTCHA / Turnstile / GeeTest polling timeout + # (ALTCHA uses defaultTimeout — it is CPU work, not a browser solve) pollingInterval=5, # max seconds between res.php polls (starts at 0.25s, backs off to this) ) ``` @@ -212,7 +214,26 @@ result = solver.geetest( result["challenge"], result["validate"], result["seccode"] ``` -### With a proxy (reCAPTCHA, Turnstile & GeeTest only) +### ALTCHA + +ALTCHA is proof-of-work, not recognition — there is nothing to read, so a solve +is deterministic and takes milliseconds. Give CapSkip the endpoint that serves +the challenge, or the challenge document itself. + +```python +result = solver.altcha( + url="https://example.com/signup", + challenge_url="https://example.com/captcha/api/altcha/challenge", +) + +# Post this back in the form field the widget uses, named `altcha` +result["token"] +``` + +Challenges expire fast — some sites inside two minutes — so fetch one +immediately before solving and submit the token promptly. + +### With a proxy (reCAPTCHA, Turnstile, GeeTest & ALTCHA only) ```python # Proxy is not supported for image captcha @@ -300,7 +321,8 @@ Every solve method returns: ``` GeeTest additionally expands its answer into `challenge`, `validate`, and -`seccode`, while `code` keeps the raw JSON string. +`seccode`, while `code` keeps the raw JSON string. ALTCHA adds `token` (the same +string as `code`) and `number`, the counter that solved it. --- @@ -327,7 +349,7 @@ except TimeoutException: ### How do I solve a captcha in Python? -Install the CapSkip desktop app, `pip install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()` or `normal()`. Each one polls until CapSkip has an answer, then returns a token, or the recognized text in the case of an image captcha. +Install the CapSkip desktop app, `pip install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()`, `altcha()` or `normal()`. Each one polls until CapSkip has an answer, then returns a token, or the recognized text in the case of an image captcha. ### Is this a free captcha solver? @@ -335,7 +357,7 @@ The SDK itself is MIT-licensed and free. Solving needs the CapSkip app, which is ### Which captchas can it solve? -reCAPTCHA v2 (checkbox and invisible), reCAPTCHA v3, reCAPTCHA Enterprise, Cloudflare Turnstile, GeeTest v3, and image/text captchas. Not hCaptcha, and not FunCaptcha/Arkose. +reCAPTCHA v2 (checkbox and invisible), reCAPTCHA v3, reCAPTCHA Enterprise, Cloudflare Turnstile, GeeTest v3, ALTCHA, and image/text captchas. Not hCaptcha, and not FunCaptcha/Arkose. ### Does it work with Selenium and Playwright? diff --git a/capskip/__init__.py b/capskip/__init__.py index a306c76..d335b13 100644 --- a/capskip/__init__.py +++ b/capskip/__init__.py @@ -26,4 +26,4 @@ 'TimeoutException', ] -__version__ = '1.1.0' +__version__ = '1.2.0' diff --git a/capskip/_api_params.py b/capskip/_api_params.py index 567a1df..91b2ea1 100644 --- a/capskip/_api_params.py +++ b/capskip/_api_params.py @@ -1,5 +1,7 @@ """CapSkip API parameter validation (https://capskip.com/api-docs/).""" +import json + from .exceptions import ValidationException NORMAL_SUBMIT = frozenset({'method', 'body', 'json', 'file'}) @@ -24,6 +26,11 @@ 'proxy', 'proxytype', }) +ALTCHA_SUBMIT = frozenset({ + 'method', 'pageurl', 'challenge_url', 'challenge_json', 'json', + 'proxy', 'proxytype', +}) + # The only values CapSkip maps to a proxy scheme; it answers # ERROR_BAD_PARAMETERS for anything else, SOCKS4 included. Matched # case-insensitively, as the server does. @@ -37,6 +44,10 @@ 'data_s': 'data-s', 'apiServer': 'api_server', 'api_subdomain': 'api_server', + 'challengeUrl': 'challenge_url', + 'challengeURL': 'challenge_url', + 'challengeJson': 'challenge_json', + 'challengeJSON': 'challenge_json', } @@ -132,6 +143,43 @@ def validate_geetest_submit(params: dict) -> None: ) +def normalize_altcha_submit(params: dict) -> dict: + """Drop unset challenge params and serialize an inline challenge document. + + `altcha(url, challenge_url=a, challenge_json=b)` is normally called with one + of the two left as None, and the form body can only carry a string — so a + document passed as a dict is serialized rather than stringified into Python's + repr. Mirrors the server, which reads a JSON-body `null` as "not sent". + """ + out = {k: v for k, v in params.items() if v is not None} + + challenge = out.get('challenge_json') + if isinstance(challenge, (dict, list)): + out['challenge_json'] = json.dumps(challenge) + + return out + + +def validate_altcha_submit(params: dict) -> None: + if not params.get('pageurl'): + raise ValidationException("'pageurl' is required for ALTCHA.") + + # CapSkip answers ERROR_BAD_PARAMETERS when neither is sent. Sending both is + # deliberately allowed -- the inline document simply wins, because fetching + # would only re-obtain what the caller already supplied. + if not params.get('challenge_url') and not params.get('challenge_json'): + raise ValidationException( + "ALTCHA needs a challenge: pass 'challenge_url' for CapSkip to fetch " + "it, or 'challenge_json' with the challenge document itself." + ) + + unknown = _unknown_keys(params, ALTCHA_SUBMIT) + if unknown: + raise ValidationException( + f"Unsupported parameters for ALTCHA: {sorted(unknown)}." + ) + + def validate_proxy_type(params: dict) -> None: proxytype = params.get('proxytype') if proxytype in (None, ''): @@ -155,6 +203,9 @@ def prepare_submit_params(params: dict, captcha_type: str, version: str = 'v2') validate_turnstile_submit(params) elif captcha_type == 'geetest': validate_geetest_submit(params) + elif captcha_type == 'altcha': + params = normalize_altcha_submit(params) + validate_altcha_submit(params) # Skipped for 'normal', which rejects proxy outright with a clearer message. if captcha_type != 'normal': diff --git a/capskip/async_solver.py b/capskip/async_solver.py index b8d1399..e75e815 100644 --- a/capskip/async_solver.py +++ b/capskip/async_solver.py @@ -10,6 +10,7 @@ from .exceptions import NetworkException, TimeoutException, ValidationException, SolverExceptions from .solver import ( INITIAL_POLLING_INTERVAL, + _apply_altcha_solution, _apply_geetest_solution, _apply_poll_result, _next_poll_interval, @@ -92,6 +93,36 @@ async def geetest(self, gt, challenge, url, **kwargs): params.setdefault('timeout', self.recaptcha_timeout) return _apply_geetest_solution(await self.solve(**params)) + async def altcha(self, url, **kwargs): + """Solve an ALTCHA proof-of-work challenge. + + Pass `challenge_url` for CapSkip to fetch the challenge itself, or + `challenge_json` with the document you already have (a JSON string, or a + dict which is serialized for you). Sending both is allowed -- the inline + document wins. A proxy applies only to the `challenge_url` fetch. + + Challenges expire fast -- some sites inside two minutes -- and an expired + one is refused with a bare "verification failed" that looks exactly like + a wrong answer. Fetch the challenge immediately before calling, and post + the token promptly. + + The result carries the raw answer as `code`, the same string as `token` + (what the site's `altcha` form field expects, verbatim), and the counter + that solved it as `number`. + """ + params = { + 'url': url, + 'method': 'altcha', + 'poll_json': 1, + # An unset challenge param is dropped rather than sent as None, so + # `altcha(url, challenge_url=a, challenge_json=b)` works with either + # one left out. + **{k: v for k, v in kwargs.items() if v is not None}, + } + # Unlike GeeTest and reCAPTCHA this is CPU proof-of-work measured in + # milliseconds, not a browser solve, so it keeps the default timeout. + return _apply_altcha_solution(await self.solve(**params)) + async def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs): poll_json = int(kwargs.pop('poll_json', poll_json) or 0) captcha_id = await self.send(**kwargs) @@ -152,4 +183,6 @@ def _prepare_send_params(self, params: dict) -> dict: return prepare_submit_params(params, 'turnstile') if method == 'geetest': return prepare_submit_params(params, 'geetest') + if method == 'altcha': + return prepare_submit_params(params, 'altcha') return apply_proxy(apply_param_aliases(params)) diff --git a/capskip/solver.py b/capskip/solver.py index 9896e21..0a1f6f0 100644 --- a/capskip/solver.py +++ b/capskip/solver.py @@ -1,7 +1,7 @@ import json import os import time -from base64 import b64encode +from base64 import b64decode, b64encode import requests @@ -58,6 +58,13 @@ def _apply_poll_result(result: dict, polled) -> dict: user_agent = polled.get('useragent') or polled.get('userAgent') if user_agent: result['userAgent'] = user_agent + # ALTCHA's createTask-shaped `solution` object. Carried through so + # _apply_altcha_solution can read the counter the server already worked + # out, which is the only reliable source for a proof-of-work v2 answer; + # that method pops it, so it never reaches the caller. + solution = polled.get('solution') + if isinstance(solution, dict): + result['solution'] = solution else: result['code'] = polled return result @@ -95,6 +102,63 @@ def _apply_geetest_solution(result: dict) -> dict: return result +# ALTCHA answers come back as a base64 payload: the challenge document with the +# winning counter added. That payload is what the site's own `altcha` form field +# carries, so it is posted back verbatim. +def _token_counter(code: str): + """Dig the winning counter out of a token, whichever scheme produced it. + + The two ALTCHA generations nest it differently: a legacy payload is the + challenge document with a top-level `number` added, while a proof-of-work v2 + payload is `{"challenge": {...}, "solution": {"counter": N, ...}}` and has no + `number` at all. Returns None if the payload does not decode. + """ + try: + payload = json.loads(b64decode(code, validate=True)) + except (ValueError, TypeError): + return None + + if not isinstance(payload, dict): + return None + + if 'number' in payload: + return payload['number'] + + solution = payload.get('solution') + if isinstance(solution, dict): + return solution.get('counter') + + return None + + +def _apply_altcha_solution(result: dict) -> dict: + """Expose the answer as `token`, and the winning counter as `number`. + + `code` keeps the raw answer so callers that forward it verbatim (or that were + written against another solver's API) keep working; `token` is the same + string, named for the form field it goes into. + + The counter comes from the server's own `solution` object when the poll + carried one, because that is the single field both ALTCHA generations report + the same way. Only if it is absent -- a plain-text poll -- is it dug out of + the token, which is shaped differently per scheme. If neither yields one, the + result keeps its token and simply has no `number`, rather than masking the + server's reply. + """ + code = result.get('code') or '' + result['token'] = code + + solution = result.pop('solution', None) + number = solution.get('number') if isinstance(solution, dict) else None + if number is None: + number = _token_counter(code) + + if number is not None: + result['number'] = number + + return result + + def _parse_submit_response(response: str) -> str: # CapSkip's in.php returns OK| by default, or {"status":1,"request":""} # when the submit carried json=1. Accept both so submitting with json=1 works. @@ -187,6 +251,36 @@ def geetest(self, gt, challenge, url, **kwargs): params.setdefault('timeout', self.recaptcha_timeout) return _apply_geetest_solution(self.solve(**params)) + def altcha(self, url, **kwargs): + """Solve an ALTCHA proof-of-work challenge. + + Pass `challenge_url` for CapSkip to fetch the challenge itself, or + `challenge_json` with the document you already have (a JSON string, or a + dict which is serialized for you). Sending both is allowed -- the inline + document wins. A proxy applies only to the `challenge_url` fetch. + + Challenges expire fast -- some sites inside two minutes -- and an expired + one is refused with a bare "verification failed" that looks exactly like + a wrong answer. Fetch the challenge immediately before calling, and post + the token promptly. + + The result carries the raw answer as `code`, the same string as `token` + (what the site's `altcha` form field expects, verbatim), and the counter + that solved it as `number`. + """ + params = { + 'url': url, + 'method': 'altcha', + 'poll_json': 1, + # An unset challenge param is dropped rather than sent as None, so + # `altcha(url, challenge_url=a, challenge_json=b)` works with either + # one left out. + **{k: v for k, v in kwargs.items() if v is not None}, + } + # Unlike GeeTest and reCAPTCHA this is CPU proof-of-work measured in + # milliseconds, not a browser solve, so it keeps the default timeout. + return _apply_altcha_solution(self.solve(**params)) + def solve(self, timeout=0, polling_interval=0, poll_json=0, **kwargs): poll_json = int(kwargs.pop('poll_json', poll_json) or 0) captcha_id = self.send(**kwargs) @@ -245,4 +339,6 @@ def _prepare_send_params(self, params: dict) -> dict: return prepare_submit_params(params, 'turnstile') if method == 'geetest': return prepare_submit_params(params, 'geetest') + if method == 'altcha': + return prepare_submit_params(params, 'altcha') return apply_proxy(apply_param_aliases(params)) diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index fb7ef12..ac134cd 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -22,8 +22,10 @@ The SDK only supports the captcha types documented by CapSkip. | reCAPTCHA v3 | `recaptcha(..., version='v3')` | `userrecaptcha` + `version=v3` | | Cloudflare Turnstile | `turnstile()` | `turnstile` | | GeeTest v3 (slide) | `geetest()` | `geetest` | +| ALTCHA (proof-of-work) | `altcha()` | `altcha` | -**Proxy** is supported for reCAPTCHA, Turnstile, and GeeTest — not for image captcha. +**Proxy** is supported for reCAPTCHA, Turnstile, GeeTest, and ALTCHA — not for image +captcha. For ALTCHA the proxy is used only for the `challenge_url` fetch. --- @@ -267,6 +269,111 @@ rather than `defaultTimeout`. --- +## 6. ALTCHA — `altcha(url, ...)` + +ALTCHA is not a recognition captcha. There is no image, audio or text to read: +the site issues a proof-of-work challenge and the client must brute-force a +number that satisfies it. A solve is therefore deterministic and cheap — +typically milliseconds. + +### POST `/in.php` + +| Parameter | Type | Required | Description | +|---|---|---|---| +| `key` | string | Yes | CapSkip API key | +| `method` | string | Yes | `altcha` | +| `pageurl` | string | Yes | Full URL of the page the challenge came from | +| `challenge_url` | string | One of the two | Endpoint CapSkip fetches the challenge from | +| `challenge_json` | string | One of the two | The challenge document itself, as a JSON string | +| `json` | int | No | `0` plain text, `1` JSON | +| `proxy` | string | No | Proxy address — used **only** for the `challenge_url` fetch | +| `proxytype` | string | No | Proxy type | + +Sending both challenge parameters is allowed: the inline `challenge_json` wins, +because fetching would only re-obtain what you already supplied. + +### Getting the challenge + +Open DevTools → Network on the target page and look for the request the +`` makes for its challenge (often something like +`/altcha/challenge`). The request URL is your `challenge_url`; its JSON response +is your `challenge_json`. + +The widget attribute that names that endpoint depends on the widget version, so +read the page source rather than assuming: + +| Widget | Attribute | +|---|---| +| v1 / v2 | `challengeurl="…"`, with a separate `challengejson="…"` for an inline challenge | +| v3+ | `challenge="…"` — the same attribute takes either a URL or the challenge data | + +> **Challenges expire, and the window is short** — some sites inside two minutes. +> Once expired, the site refuses the solution with a bare "verification failed" +> that looks exactly like a wrong answer. Fetch the challenge immediately before +> solving and submit the token promptly; do not fetch a batch in advance, and do +> not hold a token while a user fills in a form. +> +> CapSkip refuses an already-expired inline challenge immediately rather than +> burning CPU on a token that cannot work. If you passed `challenge_url` and the +> challenge expired while the job queued, it fetches a fresh one automatically. + +### SDK usage + +```python +# CapSkip fetches the challenge for you +result = solver.altcha( + url="https://example.com/signup", + challenge_url="https://example.com/captcha/api/altcha/challenge", +) + +# …or hand it the document you already have. No network request at all. +result = solver.altcha( + url="https://example.com/signup", + challenge_json={"algorithm": "SHA-256", "challenge": "…", "salt": "…", + "signature": "…", "maxnumber": 1000000}, +) + +result["token"] # the base64 payload to post back +result["number"] # the counter that solved it +result["code"] # the same string as token +``` + +`challenge_json` accepts a `dict` (serialized for you) or a JSON string. + +`number` is reported for both ALTCHA generations. Their tokens differ — a legacy +payload carries it as a top-level `number`, while a proof-of-work v2 payload has +none, its counter sitting at `solution.counter` — so it is read from the server's +own `solution` object, which reports both the same way. + +Post the token back in the form field the widget uses, named `altcha`: + +```python +requests.post(SIGNUP_URL, data={ + "email": "someone@example.com", + "altcha": result["token"], +}) +``` + +Do not re-encode, trim or re-order the token: it is base64 of a JSON document +whose fields are covered by the server's HMAC signature, so any modification +invalidates it. Some integrations read the payload from a JSON body field +instead — check what the page's own submit sends and mirror it. + +Unlike GeeTest and reCAPTCHA this is CPU proof-of-work rather than a browser +solve, so it uses `defaultTimeout`, not `recaptchaTimeout`. + +### Algorithms + +CapSkip supports the legacy scheme (SHA-1/256/384/512) and PoW v2 with PBKDF2 or +SHA. **Argon2id and scrypt are refused**, not attempted: a task using one returns +`ERROR_CAPTCHA_UNSOLVABLE` and is never retried. ALTCHA itself recommends PBKDF2 +as the default, so this affects a minority of sites. + +All three widget types (`native`, `checkbox`, `switch`) work — the distinction is +purely visual and never reaches CapSkip. + +--- + ## Return value Every solve method returns: @@ -279,6 +386,18 @@ Every solve method returns: } ``` +ALTCHA additionally exposes `token` (the same string as `code`, named for the +form field it goes in) and `number`, the counter that solved it: + +```python +{ + "captchaId": "12345", + "code": "eyJhbGdvcml0aG0iOiJTSEEtMjU2Iiwi…", + "token": "eyJhbGdvcml0aG0iOiJTSEEtMjU2Iiwi…", + "number": 9661, +} +``` + GeeTest additionally expands its answer into `challenge`, `validate`, and `seccode` (`code` keeps the raw JSON string): @@ -307,6 +426,8 @@ Convenience aliases mapped before sending to CapSkip: | `data_s` | `data-s` | | `apiServer` | `api_server` | | `api_subdomain` | `api_server` | +| `challengeUrl` / `challengeURL` | `challenge_url` | +| `challengeJson` / `challengeJSON` | `challenge_json` | | `proxy` dict | `proxy` + `proxytype` strings | ```python diff --git a/examples/altcha.py b/examples/altcha.py new file mode 100644 index 0000000..1aeb317 --- /dev/null +++ b/examples/altcha.py @@ -0,0 +1,99 @@ +"""Solve an ALTCHA proof-of-work challenge. + +ALTCHA is not a recognition captcha -- there is nothing to read. The site issues +a challenge and the browser must brute-force a number that satisfies it. CapSkip +does that work for you, in milliseconds. + +You need the challenge, in one of two forms: + + * ``challenge_url`` - the endpoint that serves it; CapSkip fetches it for you + * ``challenge_json`` - the challenge document itself, if you already have it + +To find them, open DevTools -> Network on the target page and look for the +request the ```` makes for its challenge (often something like +``/altcha/challenge``). The request URL is your ``challenge_url``; its JSON +response is your ``challenge_json``. + +Note the widget attribute that names the endpoint changed between versions: +v1/v2 use ``challengeurl="..."``, while v3+ uses ``challenge="..."`` for both a +URL and inline data. Read the page source rather than assuming. + +Challenges expire fast -- some sites inside two minutes -- so fetch one +immediately before solving and post the token promptly. An expired challenge is +rejected with a bare "verification failed" that looks exactly like a wrong +answer. + +This example issues its own challenge the way a site's server would, so it runs +as-is with no third-party dependency. Swap in your target's endpoint to use it +for real. +""" + +import hashlib +import json +import os +import secrets +import time + +from capskip import CapSkip + +solver = CapSkip( + apiKey=os.getenv('CAPSKIP_API_KEY', 'capskip'), + host=os.getenv('CAPSKIP_HOST', '127.0.0.1'), + port=int(os.getenv('CAPSKIP_PORT', '8080')), +) + +PAGE_URL = 'https://example.com/signup' + + +def issue_challenge(number=54321): + """Mint an ALTCHA challenge, exactly as a site's own server would. + + Replace this with a fetch of your target's challenge endpoint -- or skip it + entirely and pass ``challenge_url`` so CapSkip does the fetching. + """ + salt = f'{secrets.token_hex(12)}?expires={int(time.time()) + 600}' + return { + 'algorithm': 'SHA-256', + 'challenge': hashlib.sha256(f'{salt}{number}'.encode()).hexdigest(), + 'salt': salt, + 'signature': '0' * 64, + 'maxnumber': 100000, + } + + +# --- Option A: you already have the challenge document ----------------------- +# No network request at all: CapSkip solves it locally. +challenge = issue_challenge() + +result = solver.altcha(url=PAGE_URL, challenge_json=challenge) + +print('Captcha ID:', result['captchaId']) +print('Number: ', result['number']) +print('Token: ', result['token'][:60] + '...') + +# --- Option B: let CapSkip fetch the challenge -------------------------------- +# Point it at the endpoint the widget calls. Add `proxy=...` if the endpoint +# should be fetched from a particular IP -- the proxy is used only for that +# fetch, never for the solve itself. +# +# result = solver.altcha( +# url=PAGE_URL, +# challenge_url='https://example.com/captcha/api/altcha/challenge', +# proxy={'type': 'HTTP', 'uri': 'login:password@1.2.3.4:8080'}, +# ) + +# Post the token back in the form field the widget uses, named `altcha`: +# +# requests.post(SIGNUP_URL, data={ +# 'email': 'someone@example.com', +# 'altcha': result['token'], +# }) +# +# Do not re-encode, trim or re-order it. The token is base64 of a JSON document +# whose fields are covered by the server's HMAC signature, so any modification +# invalidates it. +print('Form field:', json.dumps({'altcha': result['token'][:60] + '...'}, indent=2)) + +# `code` holds the same string as `token`, which is what you forward if you are +# porting code written against another solver's API. +assert result['code'] == result['token'] diff --git a/pyproject.toml b/pyproject.toml index 4394e15..a10b25c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "capskip" -version = "1.1.0" +version = "1.2.0" description = "Captcha solver for Python: solve reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest and image captchas with CapSkip, a local unlimited captcha solver with no per-solve fees." readme = "README.md" license = { text = "MIT" } diff --git a/tests/conftest.py b/tests/conftest.py index d850094..432528f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,4 +1,6 @@ +import base64 import itertools +import json import struct import threading import zlib @@ -10,6 +12,19 @@ CODE = 'SOLVED_TOKEN_abc123' USER_AGENT = 'CapSkipUA/1.0' +# ALTCHA answers are base64 of the challenge document with the winning counter +# added, so the mock has to return a real one for the token/number parsing to +# mean anything. +ALTCHA_NUMBER = 9661 +ALTCHA_TOKEN = base64.b64encode(json.dumps({ + 'algorithm': 'SHA-256', + 'challenge': '3dd28253be6cc0c54d95f7f98c517e68', + 'number': ALTCHA_NUMBER, + 'salt': '46d5b1c8871e5152d902ee3f?expires=1893456000', + 'signature': '4b1cf0e0be0f4e5247e50b0f9a449830', + 'took': 16.58, +}).encode()).decode() + def _png_bytes(): def chunk(tag, data): @@ -105,6 +120,15 @@ def _res(self, q): self._send('{"status":0,"request":"CAPCHA_NOT_READY"}' if want_json else 'CAPCHA_NOT_READY', 'application/json' if want_json else 'text/plain') + elif id_type.get(cid) == 'altcha': + # CapSkip emits a superset: the legacy status/request pair plus + # the createTask-shaped solution object. + self._send(json.dumps({ + 'status': 1, + 'request': ALTCHA_TOKEN, + 'solution': {'token': ALTCHA_TOKEN, 'number': ALTCHA_NUMBER}, + }), 'application/json') if want_json else self._send( + 'OK|' + ALTCHA_TOKEN) elif want_json and id_type.get(cid) == 'turnstile': self._send( f'{{"status":1,"request":"{CODE}","useragent":"{USER_AGENT}"}}', diff --git a/tests/test_altcha.py b/tests/test_altcha.py new file mode 100644 index 0000000..6d7a88e --- /dev/null +++ b/tests/test_altcha.py @@ -0,0 +1,266 @@ +import base64 +import json +import unittest + +try: + from .abstract import AbstractTest +except ImportError: + from abstract import AbstractTest + +from capskip.exceptions import ValidationException + +URL = 'https://mysite.com/signup' +CHALLENGE_URL = 'https://mysite.com/captcha/api/altcha/challenge' +CHALLENGE_DOC = { + 'algorithm': 'SHA-256', + 'challenge': '3dd28253be6cc0c54d95f7f98c517e68', + 'salt': '46d5b1c8871e5152d902ee3f?expires=1893456000', + 'signature': '4b1cf0e0be0f4e5247e50b0f9a449830', + 'maxnumber': 1000000, +} +CHALLENGE_JSON = json.dumps(CHALLENGE_DOC) + +# What CapSkip hands back for a *legacy* challenge: base64 of the solved +# challenge document, with the winning counter in `number`. +SOLVED = dict(CHALLENGE_DOC, number=9661) +TOKEN = base64.b64encode(json.dumps(SOLVED).encode()).decode() + +# A PoW v2 answer is shaped completely differently: no top-level `number`, and +# the counter sits at `solution.counter`. Captured from a real PBKDF2/SHA-256 +# deployment (captcha.seventy9.co.uk), the scheme altcha.org documents today. +V2_PAYLOAD = { + 'challenge': { + 'parameters': { + 'algorithm': 'PBKDF2/SHA-256', + 'cost': 50000, + 'expiresAt': 1789224090, + 'keyLength': 32, + 'keyPrefix': '00', + 'nonce': '634c4f591fd086beb40d67312b85808a', + 'salt': '511e1c75edbf295278c9bfb68191053c', + }, + 'signature': '9197e4a35ebff399d669e747c7c5e6ab079b30fe3437df268dc7caf34cf9e281', + }, + 'solution': { + 'counter': 47, + 'derivedKey': '0099db7cb36864d8875ff8305c9a3d2649b1f72cb774de1c', + }, +} +V2_TOKEN = base64.b64encode(json.dumps(V2_PAYLOAD).encode()).decode() +V2_NUMBER = 47 + + +class AltchaApiClient(): + """Mock client returning a realistic ALTCHA answer (base64 token in `request`).""" + + def in_(self, files={}, **kwargs): + self.incomings = kwargs + self.incoming_files = files + return 'OK|123' + + def res(self, **kwargs): + if kwargs.get('json') in (1, '1'): + return json.dumps({ + 'status': 1, + 'request': TOKEN, + 'solution': {'token': TOKEN, 'number': 9661}, + }) + return 'OK|' + TOKEN + + +class AltchaTest(AbstractTest): + + def setUp(self): + super().setUp() + self.solver.api_client = AltchaApiClient() + + def solve(self, **kwargs): + params = {'url': URL, 'challenge_url': CHALLENGE_URL} + params.update(kwargs) + return self.solver.altcha(**params) + + def assert_sent(self, expected): + expected.update({'key': 'API_KEY'}) + self.assertEqual(self.solver.api_client.incomings, expected) + + def test_basic(self): + result = self.solve() + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_url': CHALLENGE_URL, + }) + self.assertEqual(result['captchaId'], '123') + + def test_challenge_json_string(self): + self.solve(challenge_url=None, challenge_json=CHALLENGE_JSON) + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_json': CHALLENGE_JSON, + }) + + def test_challenge_json_accepts_a_dict(self): + # The form body can only carry a string, so a document passed as a dict + # is serialized rather than stringified into Python's repr. + self.solve(challenge_url=None, challenge_json=CHALLENGE_DOC) + + sent = self.solver.api_client.incomings['challenge_json'] + self.assertEqual(json.loads(sent), CHALLENGE_DOC) + + def test_camel_case_aliases(self): + self.solve(challenge_url=None, challengeUrl=CHALLENGE_URL) + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_url': CHALLENGE_URL, + }) + + def test_challenge_json_camel_case_alias(self): + self.solve(challenge_url=None, challengeJSON=CHALLENGE_JSON) + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_json': CHALLENGE_JSON, + }) + + def test_both_challenge_params_are_allowed(self): + # CapSkip is deliberately more permissive than 2Captcha here: sending + # both is not an error, the inline document simply wins. + self.solve(challenge_json=CHALLENGE_JSON) + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_url': CHALLENGE_URL, + 'challenge_json': CHALLENGE_JSON, + }) + + def test_proxy(self): + self.solve(proxy={'type': 'HTTP', 'uri': '1.2.3.4:3128'}) + + self.assert_sent({ + 'method': 'altcha', + 'pageurl': URL, + 'challenge_url': CHALLENGE_URL, + 'proxy': '1.2.3.4:3128', + 'proxytype': 'HTTP', + }) + + def test_returns_raw_token_in_code(self): + result = self.solve() + + self.assertEqual(result['code'], TOKEN) + + def test_expands_token_and_number(self): + result = self.solve() + + self.assertEqual(result['token'], TOKEN) + self.assertEqual(result['number'], 9661) + + def test_expands_number_for_a_proof_of_work_v2_answer(self): + # A v2 token carries no top-level `number` -- the counter is at + # `solution.counter`, and the server reports it as `solution.number` in + # the poll payload. Reading only the token's own `number` silently drops + # it for every PBKDF2 site, which is the scheme ALTCHA recommends. + class V2Client(AltchaApiClient): + def res(self, **kwargs): + return json.dumps({ + 'status': 1, + 'request': V2_TOKEN, + 'solution': {'token': V2_TOKEN, 'number': V2_NUMBER}, + }) + + self.solver.api_client = V2Client() + result = self.solve() + + self.assertEqual(result['token'], V2_TOKEN) + self.assertEqual(result['number'], V2_NUMBER) + + def test_proof_of_work_v2_number_falls_back_to_the_token(self): + # Without the server's solution object -- a plain-text poll -- the + # counter is still recoverable from inside the payload. + class V2NoSolution(AltchaApiClient): + def res(self, **kwargs): + return json.dumps({'status': 1, 'request': V2_TOKEN}) + + self.solver.api_client = V2NoSolution() + result = self.solve() + + self.assertEqual(result['number'], V2_NUMBER) + + def test_solution_is_not_leaked_into_the_result(self): + # The poll's `solution` object is plumbing: its two fields are already + # exposed as `token` and `number`. + result = self.solve() + + self.assertNotIn('solution', result) + + def test_undecodable_token_is_left_alone(self): + class PlainClient(AltchaApiClient): + def res(self, **kwargs): + return json.dumps({'status': 1, 'request': 'not-base64-json'}) + + self.solver.api_client = PlainClient() + result = self.solve() + + self.assertEqual(result['code'], 'not-base64-json') + self.assertNotIn('number', result) + + def test_uses_the_default_timeout_not_the_recaptcha_one(self): + # ALTCHA is CPU proof-of-work measured in milliseconds, not a browser + # solve, so it must not inherit reCAPTCHA's much longer budget. + captured = {} + original = self.solver.wait_result + + def spy(id_, timeout, polling_interval, json=0): + captured['timeout'] = timeout + return original(id_, timeout, polling_interval, json=json) + + self.solver.wait_result = spy + self.solve() + + self.assertEqual(captured['timeout'], self.solver.default_timeout) + self.assertNotEqual(captured['timeout'], self.solver.recaptcha_timeout) + + def test_missing_url_raises(self): + with self.assertRaises(ValidationException): + self.solver.altcha(url='', challenge_url=CHALLENGE_URL) + + def test_missing_both_challenge_params_raises(self): + # CapSkip answers ERROR_BAD_PARAMETERS; fail locally instead of paying + # for the round-trip. + with self.assertRaises(ValidationException): + self.solver.altcha(url=URL) + + def test_empty_challenge_params_raise(self): + with self.assertRaises(ValidationException): + self.solver.altcha(url=URL, challenge_url='', challenge_json='') + + def test_unsupported_parameter_raises(self): + with self.assertRaises(ValidationException): + self.solve(sitekey='not-an-altcha-param') + + def test_accepted_proxy_types(self): + for proxytype in ('HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H', 'socks5h'): + with self.subTest(proxytype=proxytype): + self.solve(proxy={'type': proxytype, 'uri': '1.2.3.4:3128'}) + self.assertEqual( + self.solver.api_client.incomings['proxytype'], proxytype + ) + + def test_socks4_is_rejected(self): + with self.assertRaises(ValidationException): + self.solve(proxy={'type': 'SOCKS4', 'uri': '1.2.3.4:3128'}) + + def test_unknown_proxy_type_is_rejected(self): + with self.assertRaises(ValidationException): + self.solve(proxy='1.2.3.4:3128', proxytype='FTP') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_async_altcha.py b/tests/test_async_altcha.py new file mode 100644 index 0000000..6fc1cc0 --- /dev/null +++ b/tests/test_async_altcha.py @@ -0,0 +1,137 @@ +import base64 +import json + +import pytest + +try: + from .abstract_async import make_solver +except ImportError: + from abstract_async import make_solver + +from capskip.exceptions import ValidationException + +URL = 'https://mysite.com/signup' +CHALLENGE_URL = 'https://mysite.com/captcha/api/altcha/challenge' +CHALLENGE_DOC = { + 'algorithm': 'SHA-256', + 'challenge': '3dd28253be6cc0c54d95f7f98c517e68', + 'salt': '46d5b1c8871e5152d902ee3f?expires=1893456000', + 'signature': '4b1cf0e0be0f4e5247e50b0f9a449830', + 'maxnumber': 1000000, +} +CHALLENGE_JSON = json.dumps(CHALLENGE_DOC) + +SOLVED = dict(CHALLENGE_DOC, number=9661) +TOKEN = base64.b64encode(json.dumps(SOLVED).encode()).decode() + + +class AsyncAltchaApiClient(): + """Mock async client returning a realistic ALTCHA answer.""" + + async def in_(self, files={}, **kwargs): + self.incomings = kwargs + self.incoming_files = files + return 'OK|123' + + async def res(self, **kwargs): + if kwargs.get('json') in (1, '1'): + return json.dumps({ + 'status': 1, + 'request': TOKEN, + 'solution': {'token': TOKEN, 'number': 9661}, + }) + return 'OK|' + TOKEN + + +def make_altcha_solver(): + solver = make_solver() + solver.api_client = AsyncAltchaApiClient() + return solver + + +@pytest.mark.asyncio +async def test_basic(): + solver = make_altcha_solver() + + result = await solver.altcha(url=URL, challenge_url=CHALLENGE_URL) + + assert solver.api_client.incomings == { + 'key': 'API_KEY', + 'method': 'altcha', + 'pageurl': URL, + 'challenge_url': CHALLENGE_URL, + } + assert result['captchaId'] == '123' + + +@pytest.mark.asyncio +async def test_challenge_json_accepts_a_dict(): + solver = make_altcha_solver() + + await solver.altcha(url=URL, challenge_json=CHALLENGE_DOC) + + sent = solver.api_client.incomings['challenge_json'] + assert json.loads(sent) == CHALLENGE_DOC + + +@pytest.mark.asyncio +async def test_expands_token_and_number(): + solver = make_altcha_solver() + + result = await solver.altcha(url=URL, challenge_url=CHALLENGE_URL) + + assert result['code'] == TOKEN + assert result['token'] == TOKEN + assert result['number'] == 9661 + + +@pytest.mark.asyncio +async def test_proxy(): + solver = make_altcha_solver() + + await solver.altcha( + url=URL, + challenge_url=CHALLENGE_URL, + proxy={'type': 'SOCKS5', 'uri': 'user:pass@1.2.3.4:1080'}, + ) + + assert solver.api_client.incomings['proxy'] == 'user:pass@1.2.3.4:1080' + assert solver.api_client.incomings['proxytype'] == 'SOCKS5' + + +@pytest.mark.asyncio +async def test_missing_both_challenge_params_raises(): + solver = make_altcha_solver() + + with pytest.raises(ValidationException): + await solver.altcha(url=URL) + + +@pytest.mark.asyncio +async def test_missing_url_raises(): + solver = make_altcha_solver() + + with pytest.raises(ValidationException): + await solver.altcha(url='', challenge_url=CHALLENGE_URL) + + +@pytest.mark.asyncio +async def test_unsupported_parameter_raises(): + solver = make_altcha_solver() + + with pytest.raises(ValidationException): + await solver.altcha( + url=URL, challenge_url=CHALLENGE_URL, sitekey='not-an-altcha-param' + ) + + +@pytest.mark.asyncio +async def test_socks4_is_rejected(): + solver = make_altcha_solver() + + with pytest.raises(ValidationException): + await solver.altcha( + url=URL, + challenge_url=CHALLENGE_URL, + proxy={'type': 'SOCKS4', 'uri': '1.2.3.4:3128'}, + ) diff --git a/tests/test_integration.py b/tests/test_integration.py index a1f6053..a434a8e 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -11,9 +11,9 @@ ) try: - from .conftest import CODE, USER_AGENT, PNG + from .conftest import ALTCHA_NUMBER, ALTCHA_TOKEN, CODE, USER_AGENT, PNG except ImportError: - from conftest import CODE, USER_AGENT, PNG + from conftest import ALTCHA_NUMBER, ALTCHA_TOKEN, CODE, USER_AGENT, PNG SITEKEY = '6Le-wvkSVVABCPBMRTvw0Q4Muexq1bi0DJwx_mJ-' TS_SITEKEY = '0x4AAAAAAABUYP0XeMJF0xoy' @@ -199,3 +199,46 @@ async def test_async_low_level_client(capskip_server): c = AsyncApiClient(host=host, port=port) resp = await c.in_(method='turnstile', key='capskip', sitekey=TS_SITEKEY, pageurl=URL) assert resp.startswith('OK|') + + +CHALLENGE_URL = 'https://example.com/captcha/api/altcha/challenge' +CHALLENGE_DOC = { + 'algorithm': 'SHA-256', + 'challenge': '3dd28253be6cc0c54d95f7f98c517e68', + 'salt': '46d5b1c8871e5152d902ee3f?expires=1893456000', + 'signature': '4b1cf0e0be0f4e5247e50b0f9a449830', + 'maxnumber': 1000000, +} + + +def test_altcha_challenge_url(solver): + r = solver.altcha(url=URL, challenge_url=CHALLENGE_URL) + assert r['code'] == ALTCHA_TOKEN + assert r['token'] == ALTCHA_TOKEN + assert r['number'] == ALTCHA_NUMBER + assert r['captchaId'] + + +def test_altcha_challenge_json(solver): + import json as _json + r = solver.altcha(url=URL, challenge_json=_json.dumps(CHALLENGE_DOC)) + assert r['token'] == ALTCHA_TOKEN + + +def test_altcha_challenge_json_as_dict_survives_the_wire(solver): + # A dict has to reach the server as JSON, not as Python's repr, or the + # server answers ERROR_BAD_PARAMETERS. + r = solver.altcha(url=URL, challenge_json=CHALLENGE_DOC) + assert r['number'] == ALTCHA_NUMBER + + +def test_altcha_without_a_challenge_is_refused_locally(solver): + with pytest.raises(ValidationException): + solver.altcha(url=URL) + + +@pytest.mark.asyncio +async def test_async_altcha(async_solver): + r = await async_solver.altcha(url=URL, challenge_url=CHALLENGE_URL) + assert r['token'] == ALTCHA_TOKEN + assert r['number'] == ALTCHA_NUMBER