diff --git a/CHANGELOG.md b/CHANGELOG.md index ac79dfa..d97bf58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ 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. +- Both ALTCHA generations are handled: the legacy scheme (SHA-1/256/384/512) and + proof-of-work v2 (PBKDF2 or SHA). Their tokens are shaped differently — a v2 + payload carries no top-level `number`, its counter sitting at + `solution.counter` — so the counter is taken from the server's own `solution` + object, the one field both report the same way, and dug out of the token only + when a poll did not carry it. + +### Notes + +- ALTCHA is CPU proof-of-work rather than a browser solve, so it uses + `defaultTimeout` instead of the longer `recaptchaTimeout` that reCAPTCHA, + Turnstile and GeeTest use. +- A proxy passed to `altcha()` applies only to the `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..048e853 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,114 @@ 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. + +`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 +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 +389,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 +429,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..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; } @@ -126,6 +133,90 @@ 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. + */ +/** + * 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 undefined; + } + payload = JSON.parse(decoded.toString('utf8')); + } catch (err) { + return undefined; + } + + if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) { + return undefined; + } + + if (payload.number !== undefined) { + 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; +} + // 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 +240,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 +321,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 +447,9 @@ class CapSkip { if (method === 'geetest') { return prepareSubmitParams(params, 'geetest'); } + if (method === 'altcha') { + return prepareSubmitParams(params, 'altcha'); + } return applyProxy(applyParamAliases(params)); } } @@ -330,4 +462,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..8117826 --- /dev/null +++ b/test/altcha.test.js @@ -0,0 +1,325 @@ +'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 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) { + 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 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 () => { + // 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 }); + + assert.strictEqual(result.code, 'not-base64-json'); + 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. + 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. */