From 62966c53f845c1f992ee7e61fb6998ace750fd55 Mon Sep 17 00:00:00 2001 From: capskip Date: Sat, 12 Sep 2026 22:00:57 +0800 Subject: [PATCH 1/2] Add ALTCHA support (v1.2.0) ALTCHA is proof-of-work rather than recognition: the site issues a challenge and the client brute-forces a number that satisfies it. Solves are therefore deterministic and take milliseconds. `altcha(url, **kwargs)` on both CapSkip and AsyncCapSkip takes either `challenge_url`, which CapSkip fetches, or `challenge_json` with the document itself -- a JSON string, or a dict that is serialized for you, since a form body cannot carry Python's repr. Sending both is allowed and the inline document wins, matching the server, which is deliberately more permissive here than 2Captcha's mutually-exclusive spelling. The result exposes `token` (what the site's `altcha` form field expects, verbatim) and `number`, the counter that solved it, decoded from the base64 payload. `code` keeps the same raw string so callers porting from another solver's API keep working. Unlike GeeTest and reCAPTCHA this is CPU work, not a browser solve, so it uses defaultTimeout rather than the longer recaptchaTimeout. A proxy applies only to the `challenge_url` fetch; an inline challenge never touches the network. --- CHANGELOG.md | 21 ++++ README.md | 34 +++++-- capskip/__init__.py | 2 +- capskip/_api_params.py | 51 ++++++++++ capskip/async_solver.py | 33 ++++++ capskip/solver.py | 60 ++++++++++- docs/API_REFERENCE.md | 118 ++++++++++++++++++++- examples/altcha.py | 99 ++++++++++++++++++ pyproject.toml | 2 +- tests/conftest.py | 24 +++++ tests/test_altcha.py | 204 +++++++++++++++++++++++++++++++++++++ tests/test_async_altcha.py | 137 +++++++++++++++++++++++++ tests/test_integration.py | 47 ++++++++- 13 files changed, 820 insertions(+), 12 deletions(-) create mode 100644 examples/altcha.py create mode 100644 tests/test_altcha.py create mode 100644 tests/test_async_altcha.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e67f2b9..abcc38d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ 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`. + +### 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..51b3061 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 @@ -95,6 +95,32 @@ 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 _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. If the payload does not + decode, the result is returned untouched rather than masking the server's + reply. + """ + code = result.get('code') or '' + result['token'] = code + + try: + payload = json.loads(b64decode(code, validate=True)) + except (ValueError, TypeError): + return result + + if isinstance(payload, dict) and 'number' in payload: + result['number'] = payload['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 +213,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 +301,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..14c5618 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,106 @@ 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. + +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 +381,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 +421,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..5711fff --- /dev/null +++ b/tests/test_altcha.py @@ -0,0 +1,204 @@ +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: 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() + + +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_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 From b06d580bba8eb4ebd46c7dc930d47dadee4cb93c Mon Sep 17 00:00:00 2001 From: capskip Date: Sat, 12 Sep 2026 22:52:56 +0800 Subject: [PATCH 2/2] Report the ALTCHA counter for proof-of-work v2 answers too Testing against two live deployments -- bravenet.com (legacy SHA-256) and wollastonpreschool.org.uk (PBKDF2/SHA-256, cost 50000) -- showed `number` was silently missing for the second. The two ALTCHA generations shape their tokens differently. A legacy payload is the challenge document with a top-level `number` added. A proof-of-work v2 payload is `{"challenge": {...}, "solution": {"counter": N, ...}}` and has no `number` at all, so decoding the token and reading `number` -- which is what this did -- found nothing for every PBKDF2 site. That is the scheme altcha.org documents today and recommends as the default, so it is the common case, not an edge case. The counter is now taken from the server's own `solution` object, which reports both generations identically as `solution.number`, and is carried through the poll for that purpose. Only when a poll did not include one -- a plain-text poll -- is it dug out of the token, which now understands both nestings. A server counter is trusted over the token: the server worked the answer out, while the decode is only an inference from it. The `solution` object is transient plumbing and is removed before the result reaches the caller. Verified by recomputing both proofs-of-work independently against ALTCHA's own algorithm: SHA-256(salt + counter) for the legacy scheme, and PBKDF2(nonce || uint32be(counter), salt, cost, keyLength) for v2, whose derived key must begin with the challenge's keyPrefix. Both reproduce exactly, and the challenge signature survives in the token unmodified. --- CHANGELOG.md | 6 ++++ capskip/solver.py | 56 ++++++++++++++++++++++++++++++------ docs/API_REFERENCE.md | 5 ++++ tests/test_altcha.py | 66 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index abcc38d..efaac3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `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 diff --git a/capskip/solver.py b/capskip/solver.py index 51b3061..0a1f6f0 100644 --- a/capskip/solver.py +++ b/capskip/solver.py @@ -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 @@ -98,25 +105,56 @@ def _apply_geetest_solution(result: dict) -> dict: # 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. If the payload does not - decode, the result is returned untouched rather than masking the server's - reply. + 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 - try: - payload = json.loads(b64decode(code, validate=True)) - except (ValueError, TypeError): - return result + solution = result.pop('solution', None) + number = solution.get('number') if isinstance(solution, dict) else None + if number is None: + number = _token_counter(code) - if isinstance(payload, dict) and 'number' in payload: - result['number'] = payload['number'] + if number is not None: + result['number'] = number return result diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 14c5618..ac134cd 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -340,6 +340,11 @@ 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 diff --git a/tests/test_altcha.py b/tests/test_altcha.py index 5711fff..6d7a88e 100644 --- a/tests/test_altcha.py +++ b/tests/test_altcha.py @@ -20,11 +20,35 @@ } CHALLENGE_JSON = json.dumps(CHALLENGE_DOC) -# What CapSkip hands back: base64 of the solved challenge document, with the -# winning counter in `number`. +# 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`).""" @@ -138,6 +162,44 @@ def test_expands_token_and_number(self): 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):