From 63f24a1b69a3875de1e2be91bd4815d20e7ba983 Mon Sep 17 00:00:00 2001 From: capskip Date: Sat, 12 Sep 2026 22:06:02 +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, options)` takes either `challengeUrl`, which CapSkip fetches, or `challengeJson` with the document itself -- a JSON string, or an object that is serialized for you, since a form body would otherwise carry "[object Object]". 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. Buffer.from drops invalid base64 characters instead of throwing, so the decode round-trips to confirm the input really was base64 before trusting it. 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 `challengeUrl` fetch; an inline challenge never touches the network. --- CHANGELOG.md | 23 ++++ README.md | 33 +++++- docs/API_REFERENCE.md | 121 ++++++++++++++++++- examples/altcha.js | 103 ++++++++++++++++ package.json | 2 +- src/apiParams.js | 63 ++++++++++ src/solver.js | 84 ++++++++++++- test/altcha.test.js | 235 +++++++++++++++++++++++++++++++++++++ test/helpers/mockServer.js | 27 +++++ test/integration.test.js | 30 ++++- types/index.d.ts | 40 ++++++- 11 files changed, 750 insertions(+), 11 deletions(-) create mode 100644 examples/altcha.js create mode 100644 test/altcha.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index ac79dfa..ad1b26d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,29 @@ 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, options)`. Pass `challengeUrl` for CapSkip + to fetch the challenge, or `challengeJson` with the document itself (a JSON + string, or an object 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`. +- `AltchaOptions` and the `token`/`number` result fields in the TypeScript + definitions. + +### 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 `challengeUrl` 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 68b7131..4affb60 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-node/actions/workflows/ci.yml/badge.svg)](https://github.com/capskip/capskip-node/actions/workflows/ci.yml) -**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest and image captchas from Node.js.** +**Solve reCAPTCHA v2, reCAPTCHA v3, Cloudflare Turnstile, GeeTest, ALTCHA and image captchas from Node.js.** Official Node.js client for [CapSkip](https://capskip.com), a **local captcha solver** that runs on your own machine. Licensed once, not billed per solve. TypeScript definitions included. @@ -19,7 +19,7 @@ npm 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(sitekey, url, { data, pagedata })` | | **GeeTest v3 solver** (slide puzzle) | `solver.geetest(gt, challenge, url)` | +| **ALTCHA solver** (proof-of-work) | `solver.altcha(url, { challengeUrl })` | **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()`. @@ -131,6 +132,7 @@ const solver = new 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 — CPU work, not a browser solve) pollingInterval: 5, // max seconds between res.php polls (starts at 0.25s, backs off to this) }); ``` @@ -210,7 +212,25 @@ const result = await 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. + +```js +const result = await solver.altcha('https://example.com/signup', { + challengeUrl: '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) ```js // Proxy is not supported for image captcha @@ -305,7 +325,8 @@ Every solve method resolves to: ``` 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. --- @@ -350,7 +371,7 @@ const result: SolveResult = await solver.recaptcha('...', 'https://example.com') ### How do I solve a captcha in Node.js? -Install the CapSkip desktop app, `npm install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()` or `normal()`. Each returns a Promise that resolves once CapSkip has an answer, giving you a token, or the recognized text in the case of an image captcha. +Install the CapSkip desktop app, `npm install capskip`, then call the method that matches the widget — `recaptcha()`, `turnstile()`, `geetest()`, `altcha()` or `normal()`. Each returns a Promise that resolves once CapSkip has an answer, giving you a token, or the recognized text in the case of an image captcha. ### Is this a free captcha solver? @@ -358,7 +379,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 Puppeteer and Playwright? diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 01458d2..263a7ce 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -23,8 +23,10 @@ method returns a `Promise`. | reCAPTCHA v3 | `recaptcha(sitekey, url, { 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 `challengeUrl` fetch. --- @@ -267,6 +269,109 @@ 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 `challengeJson` 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 `challengeUrl`; its JSON response +is your `challengeJson`. + +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 `challengeUrl` and the +> challenge expired while the job queued, it fetches a fresh one automatically. + +### SDK usage + +```js +// CapSkip fetches the challenge for you +const result = await solver.altcha('https://example.com/signup', { + challengeUrl: 'https://example.com/captcha/api/altcha/challenge', +}); + +// …or hand it the document you already have. No network request at all. +const inline = await solver.altcha('https://example.com/signup', { + challengeJson: { + 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 +``` + +`challengeJson` accepts an object (serialized for you) or a JSON string. + +Post the token back in the form field the widget uses, named `altcha`: + +```js +await fetch(SIGNUP_URL, { + method: 'POST', + body: new URLSearchParams({ + 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 resolves to: @@ -279,6 +384,18 @@ Every solve method resolves to: } ``` +ALTCHA additionally exposes `token` (the same string as `code`, named for the +form field it goes in) and `number`, the counter that solved it: + +```js +{ + 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 +424,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` object | `proxy` + `proxytype` strings | ```js diff --git a/examples/altcha.js b/examples/altcha.js new file mode 100644 index 0000000..c68808a --- /dev/null +++ b/examples/altcha.js @@ -0,0 +1,103 @@ +'use strict'; + +/** + * 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: + * + * * `challengeUrl` - the endpoint that serves it; CapSkip fetches it for you + * * `challengeJson` - 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 `challengeUrl`; its JSON + * response is your `challengeJson`. + * + * 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. + */ + +const crypto = require('crypto'); + +const { CapSkip } = require('../src'); + +const solver = new CapSkip({ + apiKey: process.env.CAPSKIP_API_KEY || 'capskip', + host: process.env.CAPSKIP_HOST || '127.0.0.1', + port: Number(process.env.CAPSKIP_PORT || 8080), +}); + +const PAGE_URL = 'https://example.com/signup'; + +/** + * 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 `challengeUrl` so CapSkip does the fetching. + */ +function issueChallenge(number = 54321) { + const salt = `${crypto.randomBytes(12).toString('hex')}?expires=${ + Math.floor(Date.now() / 1000) + 600}`; + return { + algorithm: 'SHA-256', + challenge: crypto.createHash('sha256').update(`${salt}${number}`).digest('hex'), + salt, + signature: '0'.repeat(64), + maxnumber: 100000, + }; +} + +(async () => { + // --- Option A: you already have the challenge document --------------------- + // No network request at all: CapSkip solves it locally. + const result = await solver.altcha(PAGE_URL, { challengeJson: issueChallenge() }); + + console.log('Captcha ID:', result.captchaId); + console.log('Number: ', result.number); + console.log('Token: ', `${result.token.slice(0, 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. + // + // const result = await solver.altcha(PAGE_URL, { + // challengeUrl: '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`: + // + // await fetch(SIGNUP_URL, { + // method: 'POST', + // body: new URLSearchParams({ + // 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. + + // `code` holds the same string as `token`, which is what you forward if you + // are porting code written against another solver's API. + console.assert(result.code === result.token); +})().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/package.json b/package.json index 14e4707..eda3a8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "capskip", - "version": "1.1.0", + "version": "1.2.0", "description": "Captcha solver for Node.js: solve reCAPTCHA v2/v3, Cloudflare Turnstile, GeeTest and image captchas with CapSkip, a local unlimited captcha solver with no per-solve fees.", "main": "src/index.js", "types": "types/index.d.ts", diff --git a/src/apiParams.js b/src/apiParams.js index 9ae3923..b26edef 100644 --- a/src/apiParams.js +++ b/src/apiParams.js @@ -26,6 +26,11 @@ const GEETEST_SUBMIT = new Set([ 'proxy', 'proxytype', ]); +const ALTCHA_SUBMIT = new Set([ + '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. @@ -39,6 +44,10 @@ const PARAM_ALIASES = { data_s: 'data-s', apiServer: 'api_server', api_subdomain: 'api_server', + challengeUrl: 'challenge_url', + challengeURL: 'challenge_url', + challengeJson: 'challenge_json', + challengeJSON: 'challenge_json', }; function has(obj, key) { @@ -176,6 +185,54 @@ function validateGeetestSubmit(params) { } } +/** + * Drop unset challenge params and serialize an inline challenge document. + * + * `altcha(url, { challengeUrl, challengeJson })` is normally called with one of + * the two left out, and the form body can only carry a string -- so a document + * passed as an object is serialized rather than stringified into + * "[object Object]". Mirrors the server, which reads a JSON-body `null` as + * "not sent". + */ +function normalizeAltchaSubmit(params) { + const out = {}; + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + out[key] = value; + } + } + + const challenge = out.challenge_json; + if (typeof challenge === 'object') { + out.challenge_json = JSON.stringify(challenge); + } + + return out; +} + +function validateAltchaSubmit(params) { + if (!params.pageurl) { + throw new 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 (!params.challenge_url && !params.challenge_json) { + throw new ValidationException( + "ALTCHA needs a challenge: pass 'challenge_url' for CapSkip to fetch it, " + + "or 'challenge_json' with the challenge document itself.", + ); + } + + const unknown = unknownKeys(params, ALTCHA_SUBMIT); + if (unknown.length > 0) { + throw new ValidationException( + `Unsupported parameters for ALTCHA: ${reprList(unknown)}.`, + ); + } +} + function validateProxyType(params) { const proxytype = params.proxytype; if (proxytype === undefined || proxytype === null || proxytype === '') { @@ -201,6 +258,9 @@ function prepareSubmitParams(params, captchaType, version = 'v2') { validateTurnstileSubmit(prepared); } else if (captchaType === 'geetest') { validateGeetestSubmit(prepared); + } else if (captchaType === 'altcha') { + prepared = normalizeAltchaSubmit(prepared); + validateAltchaSubmit(prepared); } // Skipped for 'normal', which rejects proxy outright with a clearer message. @@ -217,6 +277,7 @@ module.exports = { RECAPTCHA_V3_SUBMIT, TURNSTILE_SUBMIT, GEETEST_SUBMIT, + ALTCHA_SUBMIT, PROXY_TYPES, PARAM_ALIASES, applyParamAliases, @@ -225,6 +286,8 @@ module.exports = { validateRecaptchaSubmit, validateTurnstileSubmit, validateGeetestSubmit, + normalizeAltchaSubmit, + validateAltchaSubmit, validateProxyType, prepareSubmitParams, }; diff --git a/src/solver.js b/src/solver.js index b1d8d57..81a958a 100644 --- a/src/solver.js +++ b/src/solver.js @@ -126,6 +126,46 @@ function applyGeetestSolution(result) { 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. + +/** + * 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. + */ +function applyAltchaSolution(result) { + const code = result.code || ''; + result.token = code; + + let payload; + try { + const decoded = Buffer.from(code, 'base64'); + // Buffer.from is lenient: it drops invalid characters instead of throwing, + // so round-trip to confirm the input really was base64 before trusting it. + if (decoded.toString('base64').replace(/=+$/, '') !== code.replace(/=+$/, '')) { + return result; + } + payload = JSON.parse(decoded.toString('utf8')); + } catch (err) { + return result; + } + + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return result; + } + + if (payload.number !== undefined) { + result.number = payload.number; + } + + return result; +} + // 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. function parseSubmitResponse(response) { @@ -149,7 +189,7 @@ function parseSubmitResponse(response) { throw new ApiException(`cannot recognize response ${response}`); } -/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile, GeeTest v3). */ +/** Client for the CapSkip local captcha solver (image, reCAPTCHA, Turnstile, GeeTest v3, ALTCHA). */ class CapSkip { constructor({ apiKey = 'capskip', @@ -230,6 +270,44 @@ class CapSkip { return applyGeetestSolution(result); } + /** + * Solve an ALTCHA proof-of-work challenge. + * + * Pass `challengeUrl` for CapSkip to fetch the challenge itself, or + * `challengeJson` with the document you already have (a JSON string, or an + * object which is serialized for you). Sending both is allowed -- the inline + * document wins. A proxy applies only to the `challengeUrl` 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`. + */ + async altcha(url, options = {}) { + // An unset challenge param is dropped rather than sent as undefined, so + // `altcha(url, { challengeUrl, challengeJson })` works with either left out. + const given = {}; + for (const [key, value] of Object.entries(options)) { + if (value !== undefined && value !== null) { + given[key] = value; + } + } + + // Unlike GeeTest and reCAPTCHA this is CPU proof-of-work measured in + // milliseconds, not a browser solve, so it keeps the default timeout. + const result = await this.solve({ + url, + ...given, + method: 'altcha', + poll_json: 1, + }); + return applyAltchaSolution(result); + } + async solve(options = {}) { const { timeout = 0, @@ -318,6 +396,9 @@ class CapSkip { if (method === 'geetest') { return prepareSubmitParams(params, 'geetest'); } + if (method === 'altcha') { + return prepareSubmitParams(params, 'altcha'); + } return applyProxy(applyParamAliases(params)); } } @@ -330,4 +411,5 @@ module.exports = { parseSubmitResponse, applyPollResult, applyGeetestSolution, + applyAltchaSolution, }; diff --git a/test/altcha.test.js b/test/altcha.test.js new file mode 100644 index 0000000..98a4f57 --- /dev/null +++ b/test/altcha.test.js @@ -0,0 +1,235 @@ +'use strict'; + +const { test } = require('node:test'); +const assert = require('node:assert'); + +const { CapSkip } = require('../src'); +const { ValidationException } = require('../src/exceptions'); + +const URL = 'https://mysite.com/signup'; +const CHALLENGE_URL = 'https://mysite.com/captcha/api/altcha/challenge'; +const CHALLENGE_DOC = { + algorithm: 'SHA-256', + challenge: '3dd28253be6cc0c54d95f7f98c517e68', + salt: '46d5b1c8871e5152d902ee3f?expires=1893456000', + signature: '4b1cf0e0be0f4e5247e50b0f9a449830', + maxnumber: 1000000, +}; +const CHALLENGE_JSON = JSON.stringify(CHALLENGE_DOC); + +// What CapSkip hands back: base64 of the solved challenge document, with the +// winning counter in `number`. +const NUMBER = 9661; +const TOKEN = Buffer.from( + JSON.stringify({ ...CHALLENGE_DOC, number: NUMBER }), +).toString('base64'); + +// Mock client returning a realistic ALTCHA answer (base64 token in `request`). +class AltchaApiClient { + constructor(request = TOKEN) { + this.request = request; + } + + async in_(options = {}) { + const { files = {}, ...fields } = options; + this.incomings = fields; + this.incomingFiles = files; + return 'OK|123'; + } + + async res(params = {}) { + if (params.json === 1 || params.json === '1') { + return JSON.stringify({ + status: 1, + request: this.request, + solution: { token: this.request, number: NUMBER }, + }); + } + return `OK|${this.request}`; + } +} + +function makeSolver(request) { + const solver = new CapSkip({ apiKey: 'API_KEY', pollingInterval: 1 }); + solver.apiClient = new AltchaApiClient(request); + return solver; +} + +function assertSent(solver, expected) { + assert.deepStrictEqual(solver.apiClient.incomings, { ...expected, key: 'API_KEY' }); +} + +test('altcha submits challengeUrl', async () => { + const solver = makeSolver(); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assertSent(solver, { + method: 'altcha', + pageurl: URL, + challenge_url: CHALLENGE_URL, + }); + assert.strictEqual(result.captchaId, '123'); +}); + +test('altcha accepts the snake_case challenge_url too', async () => { + const solver = makeSolver(); + + await solver.altcha(URL, { challenge_url: CHALLENGE_URL }); + + assertSent(solver, { method: 'altcha', pageurl: URL, challenge_url: CHALLENGE_URL }); +}); + +test('altcha submits challengeJson as a string', async () => { + const solver = makeSolver(); + + await solver.altcha(URL, { challengeJson: CHALLENGE_JSON }); + + assertSent(solver, { method: 'altcha', pageurl: URL, challenge_json: CHALLENGE_JSON }); +}); + +test('altcha serializes an inline challenge object', async () => { + // The form body can only carry a string, so an object has to be serialized + // rather than stringified into "[object Object]". + const solver = makeSolver(); + + await solver.altcha(URL, { challengeJson: CHALLENGE_DOC }); + + assert.deepStrictEqual( + JSON.parse(solver.apiClient.incomings.challenge_json), + CHALLENGE_DOC, + ); +}); + +test('altcha allows both challenge params, letting the inline one win', async () => { + const solver = makeSolver(); + + await solver.altcha(URL, { + challengeUrl: CHALLENGE_URL, + challengeJson: CHALLENGE_JSON, + }); + + assertSent(solver, { + method: 'altcha', + pageurl: URL, + challenge_url: CHALLENGE_URL, + challenge_json: CHALLENGE_JSON, + }); +}); + +test('altcha ignores an undefined challenge param', async () => { + const solver = makeSolver(); + + await solver.altcha(URL, { challengeUrl: CHALLENGE_URL, challengeJson: undefined }); + + assertSent(solver, { method: 'altcha', pageurl: URL, challenge_url: CHALLENGE_URL }); +}); + +test('altcha sends a proxy', async () => { + const solver = makeSolver(); + + await solver.altcha(URL, { + challengeUrl: CHALLENGE_URL, + proxy: { type: 'HTTP', uri: '1.2.3.4:3128' }, + }); + + assertSent(solver, { + method: 'altcha', + pageurl: URL, + challenge_url: CHALLENGE_URL, + proxy: '1.2.3.4:3128', + proxytype: 'HTTP', + }); +}); + +test('altcha exposes token and number', async () => { + const solver = makeSolver(); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.code, TOKEN); + assert.strictEqual(result.token, TOKEN); + assert.strictEqual(result.number, NUMBER); +}); + +test('altcha leaves an undecodable answer alone', async () => { + const solver = makeSolver('not-base64-json'); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.code, 'not-base64-json'); + assert.strictEqual(result.number, undefined); +}); + +test('altcha uses the default timeout, not the reCAPTCHA one', async () => { + // ALTCHA is CPU proof-of-work measured in milliseconds, not a browser solve, + // so it must not inherit reCAPTCHA's much longer budget. + const solver = makeSolver(); + let seen; + const original = solver.waitResult.bind(solver); + solver.waitResult = (id, timeout, interval, json) => { + seen = timeout; + return original(id, timeout, interval, json); + }; + + await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(seen, solver.defaultTimeout); + assert.notStrictEqual(seen, solver.recaptchaTimeout); +}); + +test('altcha rejects a missing url', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.altcha('', { challengeUrl: CHALLENGE_URL }), + ValidationException, + ); +}); + +test('altcha rejects a missing challenge', async () => { + const solver = makeSolver(); + + await assert.rejects(() => solver.altcha(URL), ValidationException); +}); + +test('altcha rejects empty challenge params', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.altcha(URL, { challengeUrl: '', challengeJson: '' }), + ValidationException, + ); +}); + +test('altcha rejects an unsupported parameter', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.altcha(URL, { challengeUrl: CHALLENGE_URL, sitekey: 'nope' }), + ValidationException, + ); +}); + +test('altcha accepts every proxy type CapSkip maps', async () => { + for (const proxytype of ['HTTP', 'HTTPS', 'SOCKS5', 'SOCKS5H', 'socks5h']) { + const solver = makeSolver(); + await solver.altcha(URL, { + challengeUrl: CHALLENGE_URL, + proxy: { type: proxytype, uri: '1.2.3.4:3128' }, + }); + assert.strictEqual(solver.apiClient.incomings.proxytype, proxytype); + } +}); + +test('altcha rejects SOCKS4', async () => { + const solver = makeSolver(); + + await assert.rejects( + () => solver.altcha(URL, { + challengeUrl: CHALLENGE_URL, + proxy: { type: 'SOCKS4', uri: '1.2.3.4:3128' }, + }), + ValidationException, + ); +}); diff --git a/test/helpers/mockServer.js b/test/helpers/mockServer.js index 415e03f..8cee6ea 100644 --- a/test/helpers/mockServer.js +++ b/test/helpers/mockServer.js @@ -9,6 +9,19 @@ const { URL } = require('url'); const CODE = 'SOLVED_TOKEN_abc123'; const 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. +const ALTCHA_NUMBER = 9661; +const ALTCHA_TOKEN = Buffer.from(JSON.stringify({ + algorithm: 'SHA-256', + challenge: '3dd28253be6cc0c54d95f7f98c517e68', + number: ALTCHA_NUMBER, + salt: '46d5b1c8871e5152d902ee3f?expires=1893456000', + signature: '4b1cf0e0be0f4e5247e50b0f9a449830', + took: 16.58, +})).toString('base64'); + // A minimal valid 1x1 PNG. The mock returns these bytes for /image.png and the // SDK never inspects the content, so exact pixels do not matter. const PNG = Buffer.from( @@ -49,6 +62,18 @@ function createMockServer() { wantJson ? '{"status":0,"request":"CAPCHA_NOT_READY"}' : 'CAPCHA_NOT_READY', wantJson ? 'application/json' : 'text/plain', ); + } else if (idType[cid] === 'altcha') { + // CapSkip emits a superset: the legacy status/request pair plus the + // createTask-shaped solution object. + if (wantJson) { + send(res, JSON.stringify({ + status: 1, + request: ALTCHA_TOKEN, + solution: { token: ALTCHA_TOKEN, number: ALTCHA_NUMBER }, + }), 'application/json'); + } else { + send(res, `OK|${ALTCHA_TOKEN}`); + } } else if (wantJson && idType[cid] === 'turnstile') { send(res, `{"status":1,"request":"${CODE}","useragent":"${USER_AGENT}"}`, 'application/json'); } else if (wantJson) { @@ -134,6 +159,8 @@ function startMockServer() { module.exports = { CODE, USER_AGENT, + ALTCHA_TOKEN, + ALTCHA_NUMBER, PNG, createMockServer, startMockServer, diff --git a/test/integration.test.js b/test/integration.test.js index adc7551..889293f 100644 --- a/test/integration.test.js +++ b/test/integration.test.js @@ -14,7 +14,9 @@ const { CapSkip, ApiClient, ApiException, NetworkException, TimeoutException, } = require('../src'); -const { startMockServer, CODE, USER_AGENT, PNG } = require('./helpers/mockServer'); +const { + startMockServer, CODE, USER_AGENT, PNG, ALTCHA_TOKEN, ALTCHA_NUMBER, +} = require('./helpers/mockServer'); const SITEKEY = '6Le-wvkSVVABCPBMRTvw0Q4Muexq1bi0DJwx_mJ-'; const TS_SITEKEY = '0x4AAAAAAABUYP0XeMJF0xoy'; @@ -180,3 +182,29 @@ test('concurrent solves', async () => { ]); assert.ok(results.every((r) => r.code === CODE)); }); + +const CHALLENGE_URL = 'https://example.com/captcha/api/altcha/challenge'; +const CHALLENGE_DOC = { + algorithm: 'SHA-256', + challenge: '3dd28253be6cc0c54d95f7f98c517e68', + salt: '46d5b1c8871e5152d902ee3f?expires=1893456000', + signature: '4b1cf0e0be0f4e5247e50b0f9a449830', + maxnumber: 1000000, +}; + +test('altcha over HTTP with a challenge url', async () => { + const solver = makeSolver(); + const r = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + assert.strictEqual(r.code, ALTCHA_TOKEN); + assert.strictEqual(r.token, ALTCHA_TOKEN); + assert.strictEqual(r.number, ALTCHA_NUMBER); + assert.ok(r.captchaId); +}); + +test('altcha over HTTP with an inline challenge object', async () => { + // An object has to reach the server as JSON, not as "[object Object]", or the + // server answers ERROR_BAD_PARAMETERS. + const solver = makeSolver(); + const r = await solver.altcha(URL, { challengeJson: CHALLENGE_DOC }); + assert.strictEqual(r.number, ALTCHA_NUMBER); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 21c5ef4..45c67bb 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -31,7 +31,8 @@ export interface SolveResult { /** * The solution: recognized text for images, a token otherwise. For GeeTest * this is the raw JSON string CapSkip returns — prefer the parsed - * `challenge` / `validate` / `seccode` fields below. + * `challenge` / `validate` / `seccode` fields below. For ALTCHA it is the + * base64 token, also exposed as `token`. */ code: string; /** Turnstile only — the User-Agent to use when submitting the token. */ @@ -42,6 +43,13 @@ export interface SolveResult { validate?: string; /** GeeTest only — the `geetest_seccode` value to post back. */ seccode?: string; + /** + * ALTCHA only — the base64 payload to post back in the site's `altcha` form + * field. The same string as `code`, named for where it goes. + */ + token?: string; + /** ALTCHA only — the counter that solved the challenge. */ + number?: number; } /** Extra options for {@link CapSkip.normal}. */ @@ -111,6 +119,28 @@ export interface GeetestOptions { [key: string]: unknown; } +/** Extra options for {@link CapSkip.altcha}. */ +export interface AltchaOptions { + /** Endpoint CapSkip fetches the challenge from. */ + challengeUrl?: string; + /** Endpoint CapSkip fetches the challenge from. */ + challenge_url?: string; + /** + * The challenge document itself. An object is serialized for you; a string is + * sent as-is. + */ + challengeJson?: string | Record; + /** The challenge document itself. */ + challenge_json?: string | Record; + /** `1` to request the raw JSON response from CapSkip. */ + json?: number; + /** Proxy — used only for the `challengeUrl` fetch, never for the solve. */ + proxy?: Proxy | string; + /** Proxy type when `proxy` is a bare string. */ + proxytype?: string; + [key: string]: unknown; +} + /** Options for the {@link CapSkip.solve} manual workflow. */ export interface SolveOptions { /** Poll timeout in seconds (falls back to the configured default). */ @@ -150,6 +180,14 @@ export class CapSkip { url: string, options?: GeetestOptions, ): Promise; + /** + * Solve an ALTCHA proof-of-work challenge. + * + * Pass `challengeUrl` for CapSkip to fetch the challenge, or `challengeJson` + * with the document itself. Sending both is allowed — the inline document + * wins. Challenges expire fast, so fetch one immediately before calling. + */ + altcha(url: string, options?: AltchaOptions): Promise; /** Submit then poll to completion. Used by the higher-level solve methods. */ solve(options?: SolveOptions): Promise; /** Submit a captcha without polling; resolves to the captcha id. */ From 6ec0b96f2d811f81399134bc1b500d3bb22b985e 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 +++ docs/API_REFERENCE.md | 5 +++ src/solver.js | 67 ++++++++++++++++++++++++++---- test/altcha.test.js | 96 +++++++++++++++++++++++++++++++++++++++++-- 4 files changed, 163 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1b26d..d97bf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `challengeJson`/`challengeJSON` for `challenge_json`. - `AltchaOptions` and the `token`/`number` result fields in the TypeScript definitions. +- 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/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 263a7ce..048e853 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -340,6 +340,11 @@ result.code; // the same string as token `challengeJson` accepts an object (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`: ```js diff --git a/src/solver.js b/src/solver.js index 81a958a..49d1bc4 100644 --- a/src/solver.js +++ b/src/solver.js @@ -83,6 +83,13 @@ function applyPollResult(result, polled) { if (userAgent) { result.userAgent = userAgent; } + // ALTCHA's createTask-shaped `solution` object. Carried through so + // applyAltchaSolution can read the counter the server already worked out, + // which is the only reliable source for a proof-of-work v2 answer; that + // function deletes it, so it never reaches the caller. + if (polled.solution !== null && typeof polled.solution === 'object') { + result.solution = polled.solution; + } } else { result.code = polled; } @@ -138,29 +145,73 @@ function applyGeetestSolution(result) { * 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. */ -function applyAltchaSolution(result) { - const code = result.code || ''; - result.token = code; - +/** + * 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 undefined if the payload does not decode. + */ +function tokenCounter(code) { let payload; try { const decoded = Buffer.from(code, 'base64'); // Buffer.from is lenient: it drops invalid characters instead of throwing, // so round-trip to confirm the input really was base64 before trusting it. if (decoded.toString('base64').replace(/=+$/, '') !== code.replace(/=+$/, '')) { - return result; + return undefined; } payload = JSON.parse(decoded.toString('utf8')); } catch (err) { - return result; + return undefined; } if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { - return result; + return undefined; } if (payload.number !== undefined) { - result.number = payload.number; + return payload.number; + } + + if (payload.solution !== null && typeof payload.solution === 'object') { + return payload.solution.counter; + } + + return undefined; +} + +/** + * 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. + */ +function applyAltchaSolution(result) { + const code = result.code || ''; + result.token = code; + + const { solution } = result; + delete result.solution; + + let number = solution !== null && typeof solution === 'object' + ? solution.number + : undefined; + if (number === undefined) { + number = tokenCounter(code); + } + + if (number !== undefined) { + result.number = number; } return result; diff --git a/test/altcha.test.js b/test/altcha.test.js index 98a4f57..8117826 100644 --- a/test/altcha.test.js +++ b/test/altcha.test.js @@ -17,13 +17,53 @@ const CHALLENGE_DOC = { }; const CHALLENGE_JSON = JSON.stringify(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`. const NUMBER = 9661; const TOKEN = Buffer.from( JSON.stringify({ ...CHALLENGE_DOC, number: NUMBER }), ).toString('base64'); +// 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. +const V2_NUMBER = 47; +const V2_TOKEN = Buffer.from(JSON.stringify({ + challenge: { + parameters: { + algorithm: 'PBKDF2/SHA-256', + cost: 50000, + expiresAt: 1789224090, + keyLength: 32, + keyPrefix: '00', + nonce: '634c4f591fd086beb40d67312b85808a', + salt: '511e1c75edbf295278c9bfb68191053c', + }, + signature: '9197e4a35ebff399d669e747c7c5e6ab079b30fe3437df268dc7caf34cf9e281', + }, + solution: { + counter: V2_NUMBER, + derivedKey: '0099db7cb36864d8875ff8305c9a3d2649b1f72cb774de1c', + }, +})).toString('base64'); + +/** A client whose poll returns exactly `payload`, for the v2 shapes. */ +function makeRawSolver(payload) { + const solver = new CapSkip({ apiKey: 'API_KEY', pollingInterval: 1 }); + solver.apiClient = { + async in_(options = {}) { + const { files = {}, ...fields } = options; + this.incomings = fields; + this.incomingFiles = files; + return 'OK|123'; + }, + async res() { + return JSON.stringify(payload); + }, + }; + return solver; +} + // Mock client returning a realistic ALTCHA answer (base64 token in `request`). class AltchaApiClient { constructor(request = TOKEN) { @@ -152,8 +192,44 @@ test('altcha exposes token and number', async () => { assert.strictEqual(result.number, NUMBER); }); +test('altcha exposes the counter for a proof-of-work v2 answer', async () => { + // 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. + const solver = makeRawSolver({ + status: 1, + request: V2_TOKEN, + solution: { token: V2_TOKEN, number: V2_NUMBER }, + }); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.token, V2_TOKEN); + assert.strictEqual(result.number, V2_NUMBER); +}); + +test('altcha recovers a v2 counter from the token when the poll carries no solution', async () => { + const solver = makeRawSolver({ status: 1, request: V2_TOKEN }); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.number, V2_NUMBER); +}); + +test('altcha does not leak the poll solution object into the result', async () => { + // Its two fields are already exposed as `token` and `number`. + const solver = makeSolver(); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.solution, undefined); +}); + test('altcha leaves an undecodable answer alone', async () => { - const solver = makeSolver('not-base64-json'); + // No `solution` object either — a server returning something that is not a + // token has no counter to report, so there is nothing to fall back on. + const solver = makeRawSolver({ status: 1, request: 'not-base64-json' }); const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); @@ -161,6 +237,20 @@ test('altcha leaves an undecodable answer alone', async () => { assert.strictEqual(result.number, undefined); }); +test('altcha trusts the server counter over an unreadable token', async () => { + // If the two ever disagree, the server worked the answer out and the decode + // is only an inference from it. + const solver = makeRawSolver({ + status: 1, + request: 'not-base64-json', + solution: { token: 'not-base64-json', number: 512 }, + }); + + const result = await solver.altcha(URL, { challengeUrl: CHALLENGE_URL }); + + assert.strictEqual(result.number, 512); +}); + test('altcha uses the default timeout, not the reCAPTCHA one', async () => { // ALTCHA is CPU proof-of-work measured in milliseconds, not a browser solve, // so it must not inherit reCAPTCHA's much longer budget.