diff --git a/README.md b/README.md index f6ca09f..e23c161 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ It asks for a toy. You drive the claw with the joystick (or arrow keys), hit the No physics engine behind it, just damped springs and scripted phases in one rAF loop. React state only changes when the phase changes, the rest is transforms written through refs, so it stays smooth even on weak devices. -Obvious but worth saying: this checks that someone is *playing*, not who they are. Keep it in front of your real checks, don't replace them with it. +Obvious but worth saying: out of the box this checks that someone is *playing*, not who they are — `onVerify()` is a client callback a bot can call directly. For a real guarantee, wire up the [server module](#server-verification) below (signed challenges, proof-of-work, behavioral scoring). Keep it in front of your real checks either way, don't replace them with it. @@ -37,7 +37,82 @@ Leave `target` off and every mount asks for a different random toy. Or pin one: The 12 toy ids: duck, bear, panda, bunny, dino, penguin, fox, frog, whale, cat, puppy, unicorn. -Other props: `title` (the heading), `assetBase`, `className`. That's all of them. +Other props: `title` (the heading), `assetBase`, `className`. That's all of them — unless you want server-backed verification (see below). + +## server verification + +The widget above is **client-only**: `onVerify()` fires the moment the right toy lands, and there's nothing a backend can trust in that. A bot can read this source and call the callback directly. That's fine for friction gates (slow spammers down, keep real users happy), not for anything that needs an actual guarantee. + +For real guarantees, ship the companion server module. It issues HMAC-signed challenges, the client solves a proof-of-work and plays the game (collecting behavioral + anti-automation signals), and only the server mints a token you can verify on your protected endpoints. The token can't be forged without your secret. + +```bash +npm install playcaptcha +``` + +**Server** (Node, any framework — the module is framework-agnostic): + +```ts +import { createVerifier, createFetchHandler } from 'playcaptcha/server' + +// keep this secret on the server — long random string, in env, never bundled +const verifier = createVerifier({ + secret: process.env.PLAYCAPTCHA_SECRET!, + powBits: 14, // leading-zero bits; 14 ≈ a few hundred-k iters, <1s on a laptop + challengeTtlMs: 5 * 60_000, + tokenTtlMs: 10 * 60_000, + minScore: 0.5, // behavioral humanness threshold (0..1) + // replayStore: new MemoryStore(), // opt into single-use challenges (blocks replay) +}) + +// ready-made Fetch handler (GET issue / POST verify) — Cloudflare Workers, +// Hono, Next.js route handlers, Bun.serve all speak this shape +export const onRequest = createFetchHandler(verifier) +``` + +Mount it on any path. `GET /` issues a challenge, `POST /` verifies one. On your protected endpoints, validate the token: + +```ts +import { createVerifier } from 'playcaptcha/server' +const verifier = createVerifier({ secret: process.env.PLAYCAPTCHA_SECRET! }) + +function isHuman(req: Request): boolean { + const token = req.headers.get('X-Captcha-Token') ?? '' + const { ok } = verifier.verifyToken(token) // stateless — checks HMAC + expiry + return ok +} +``` + +**Client** — point the widget at those endpoints: + +```tsx +import { ClawCaptcha } from 'playcaptcha' +import 'playcaptcha/clawcaptcha.css' + + { + if (result?.ok) { + // result.token is the signed token — send it with your real requests + localStorage.setItem('captcha', result.token) + unlock() + } + }} +/> +``` + +In server mode the green "Verified" state only flips **after** the server confirms — a bot that fakes the client can't make the UI lie. The props: `challengeUrl`/`getChallenge` (fetch a challenge) and `verifyUrl`/`verifyAttempt` (post it back). Bring your own transport by passing the `*Challenge`/`verifyAttempt` functions instead of URLs. + +### how it actually defends + +- **Signed challenges & tokens** — an attacker can't mint a valid token without your server secret. This is the load-bearing piece; everything else raises the bar. +- **Proof-of-work** — the client grinds SHA-256 to a target difficulty before it can verify. Free for one real user, costly at spam scale. Defaults to 14 bits (≈ a few hundred-thousand hashes). +- **Behavioral signals** — joystick trajectory (curvature, jitter, reversals), time-to-first-action, total duration, grab-attempt count. Bots that drive in straight lines at constant speed score low. +- **Anti-automation** — `event.isTrusted` on every input (synthetic `dispatchEvent` calls are `isTrusted=false`), `navigator.webdriver`, and automation user-agent strings trip a hard veto. + +### honest limits + +The client reports its own signals and its own PoW solution — the server can't *prove* the grab happened, only that a valid challenge came back with a valid solution and a high score. The real defenses are the unforgeable token and the PoW cost; the behavioral score catches naive bot scripts. This is the same shape as reCAPTCHA / hCaptcha. If you need true proof-of-work-at-the-edge or ML risk scoring, layer something stronger on top. For multi-instance deployments, implement `ReplayStore` against Redis so challenges and tokens can't be replayed across instances. ## theming diff --git a/package-lock.json b/package-lock.json index 159f817..efa83ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,18 @@ { "name": "playcaptcha", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "playcaptcha", - "version": "0.1.0", + "version": "0.2.0", "license": "MIT", "dependencies": { "motion": "^12.0.0" }, "devDependencies": { + "@types/node": "^26.1.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tsup": "^8.3.0", @@ -860,13 +861,22 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -1002,7 +1012,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -1263,7 +1272,6 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -1432,7 +1440,8 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/source-map": { "version": "0.7.6", @@ -1596,7 +1605,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -1611,6 +1619,13 @@ "integrity": "sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==", "dev": true, "license": "MIT" + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 31dbb27..7033f00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "playcaptcha", - "version": "0.1.0", + "version": "0.2.0", "description": "claw machine captcha for react - grab the right toy to prove you're human", "type": "module", "license": "MIT", @@ -12,6 +12,10 @@ "types": "./dist/index.d.ts", "import": "./dist/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js" + }, "./clawcaptcha.css": "./dist/clawcaptcha.css" }, "sideEffects": [ @@ -33,6 +37,7 @@ "motion": "^12.0.0" }, "devDependencies": { + "@types/node": "^26.1.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "tsup": "^8.3.0", diff --git a/src/ClawCaptcha.tsx b/src/ClawCaptcha.tsx index 69aa870..0ff935e 100644 --- a/src/ClawCaptcha.tsx +++ b/src/ClawCaptcha.tsx @@ -2,6 +2,12 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { AnimatePresence, motion, MotionConfig, useReducedMotion } from 'motion/react' import { TOY_META, type ToyId } from './toys.ts' import { CLAW_ARM_L, CLAW_ARM_R, CLAW_BODY, CLAW_PIVOT } from './clawArt.ts' +import { + solvePow, + SignalCollector, + type Challenge, + type VerifyResult, +} from './verify/index.ts' /* * ClawCaptcha — a claw-machine human check, operated like the real thing. @@ -212,15 +218,29 @@ type Soft = { type Phase = 'idle' | 'seq' | 'carry' | 'toTray' | 'celebrate' | 'deny' | 'return' | 'done' export interface ClawCaptchaProps { - /** Which toy the challenge asks for. A random toy each mount when omitted. */ + /** Which toy the challenge asks for. A random toy each mount when omitted. + * In server mode, the challenge's target wins — pass this only to pin a toy + * when NOT using server verification. */ target?: ToyId - /** Fired once when the right toy lands in the tray. */ - onVerify?: () => void + /** Fired once when the right toy lands in the tray. + * - Client-only mode: called with no argument (backward compatible). + * - Server mode: called with `{ ok, token, score }` once the server confirms. */ + onVerify?: (result?: VerifyResult) => void /** Heading shown above the machine. */ title?: string /** Where the toy PNGs are served from. */ assetBase?: string className?: string + + // ---- server-backed verification (optional — omit all to stay client-only) ---- + /** GET a challenge from this URL. Enables server verification. */ + challengeUrl?: string + /** Or supply your own challenge fetcher (overrides challengeUrl). */ + getChallenge?: (target?: ToyId) => Promise + /** POST the attempt to this URL for verification. */ + verifyUrl?: string + /** Or supply your own verifier (overrides verifyUrl). */ + verifyAttempt?: (input: import('./verify/types.ts').AttemptInput) => Promise } export function ClawCaptcha({ @@ -229,16 +249,29 @@ export function ClawCaptcha({ title = 'Verify you’re human', assetBase = '/toys/', className, + challengeUrl, + getChallenge, + verifyUrl, + verifyAttempt, }: ClawCaptchaProps) { const reduce = useReducedMotion() + // server-backed mode is on if ANY of the four server props is set. In that + // mode the server's challenge decides the target, PoW runs in the background, + // and the green "verified" state only flips after the server confirms. + const serverMode = !!(challengeUrl || getChallenge || verifyUrl || verifyAttempt) + // unpinned challenges ask for a different toy every mount (stable within one) const [autoTarget] = useState(() => TOY_SET[Math.floor(Math.random() * TOY_SET.length)].toy) - const target = targetProp ?? autoTarget + + // the challenge's target wins in server mode; otherwise the prop / auto target + const [challenge, setChallenge] = useState(null) + const target: ToyId = challenge?.target ?? targetProp ?? autoTarget const [phase, setPhase] = useState('idle') const [infoOpen, setInfoOpen] = useState(false) const [verified, setVerified] = useState(false) + const [verifying, setVerifying] = useState(false) const [message, setMessage] = useState(null) const [overTray, setOverTray] = useState(false) const [trayMode, setTrayMode] = useState<'' | 'open' | 'win' | 'no'>('') @@ -264,6 +297,14 @@ export function ClawCaptcha({ const onVerifyRef = useRef(onVerify) onVerifyRef.current = onVerify + // server-mode plumbing: the PoW solution (resolved before the user finishes), + // the signal collector (per-mount), and refs the rAF loop reads to know + // whether to gate the verified flip on a server round-trip. + const powRef = useRef(null) + const signalsRef = useRef(null) + if (signalsRef.current === null) signalsRef.current = new SignalCollector() + const serverVerifyRef = useRef<(() => Promise) | null>(null) + const sim = useRef({ x: GW / 2, y: HOME_Y, @@ -309,8 +350,11 @@ export function ClawCaptcha({ phaseRef.current = p setPhase(p) } - const api = useRef({ setPhaseBoth, setMessage, setVerified, setOverTray, setTrayMode }) - api.current = { setPhaseBoth, setMessage, setVerified, setOverTray, setTrayMode } + const api = useRef({ setPhaseBoth, setMessage, setVerified, setVerifying, setOverTray, setTrayMode }) + api.current = { setPhaseBoth, setMessage, setVerified, setVerifying, setOverTray, setTrayMode } + + // set once on mount; the rAF loop reads it to gate the verdict + const verifyingRef = useRef(false) const targetIdx = useMemo(() => pile.findIndex((p) => p.toy === target), [pile, target]) @@ -721,9 +765,48 @@ export function ClawCaptcha({ // green ring + check + dimmed glass. No hop, no waggle. if (s.stage === 'beat') { if (stageP(0.28, dt) >= 1) { - nextStage('shine') - api.current.setVerified(true) // ring + check + dim run together - onVerifyRef.current?.() + // server-backed: gate the verdict on an async round-trip. The rAF + // loop can't await, so we flip a flag, kick off the POST, and let + // its .then() do the verified/deny transition. The loop idles here + // (stage stays 'beat', already at p>=1) until the promise resolves. + const run = serverVerifyRef.current + if (run && !verifyingRef.current) { + verifyingRef.current = true + api.current.setVerifying(true) + run() + .then((result) => { + if (result.ok) { + nextStage('shine') + api.current.setVerified(true) // ring + check + dim run together + api.current.setVerifying(false) + verifyingRef.current = false + onVerifyRef.current?.(result) + } else { + // server rejected — treat like a wrong-toy deny so the user + // can retry, but surface the server's reason if it gave one + api.current.setVerifying(false) + verifyingRef.current = false + api.current.setTrayMode('no') + api.current.setMessage( + result.reason ? `Couldn’t verify: ${result.reason}` : 'Verification failed. Try again.', + ) + api.current.setPhaseBoth('deny') + } + }) + .catch(() => { + api.current.setVerifying(false) + verifyingRef.current = false + api.current.setTrayMode('no') + api.current.setMessage('Network error during verification. Try again.') + api.current.setPhaseBoth('deny') + }) + } + // client-only: fire immediately, same as before + if (!run) { + nextStage('shine') + api.current.setVerified(true) + onVerifyRef.current?.() + } } } else if (s.stage === 'shine') { if (stageP(0.7, dt) >= 1) api.current.setPhaseBoth('done') @@ -768,11 +851,78 @@ export function ClawCaptcha({ return () => cancelAnimationFrame(raf) }, [reduce, targetIdx, target, pile]) + // ---- server-backed verification: fetch the challenge on mount, solve the + // PoW in the background (it finishes well before a human does), and stand up + // a verify closure the rAF loop calls when the right toy lands. Skipped + // entirely in client-only mode. ---- + useEffect(() => { + if (!serverMode) { + serverVerifyRef.current = null + return + } + let cancelled = false + const powAbort = new AbortController() + + const fetcher = + getChallenge ?? + (async (t?: ToyId) => { + const url = challengeUrl + (t ? `?target=${encodeURIComponent(t)}` : '') + const res = await fetch(url, { headers: { accept: 'application/json' } }) + if (!res.ok) throw new Error(`challenge fetch failed: ${res.status}`) + return (await res.json()) as Challenge + }) + + fetcher(targetProp) + .then((ch) => { + if (cancelled) return + setChallenge(ch) + // kick off the PoW — it resolves into powRef; the verify closure waits + // on the same promise so it can't fire before the solve is done + const powPromise = solvePow(ch.pow, { signal: powAbort.signal }).then( + (sol) => { + powRef.current = sol + return sol + }, + ) + // build the verify closure the rAF loop will invoke. Returns the + // server's verdict; the loop's .then() handles the UI transition. + serverVerifyRef.current = async () => { + const signals = signalsRef.current!.build() + const pow = await powPromise + const input = { challenge: ch, pow, signals } + if (verifyAttempt) return verifyAttempt(input) + const res = await fetch(verifyUrl!, { + method: 'POST', + headers: { 'content-type': 'application/json', accept: 'application/json' }, + body: JSON.stringify(input), + }) + if (!res.ok) throw new Error(`verify failed: ${res.status}`) + return (await res.json()) as VerifyResult + } + }) + .catch(() => { + if (!cancelled) setMessage('Couldn’t reach the verification service.') + }) + + return () => { + cancelled = true + powAbort.abort() + serverVerifyRef.current = null + } + // re-run only if the server-mode config identity changes; target is handled + // by the challenge that comes back, not by re-fetching + }, [serverMode, challengeUrl, getChallenge, verifyUrl, verifyAttempt, targetProp]) + // ---- controls ---- const action = () => { const s = sim.current if (verified) return + // server mode: no-op until the challenge + PoW are staged (button is also + // disabled, but keyboard Space bypasses that) + if (serverMode && !serverVerifyRef.current) return if (phaseRef.current === 'idle') { + // server mode: count every grab attempt for the behavioral score + if (serverMode) signalsRef.current?.grabAttempt() setMessage(null) s.close = 0 s.stage = 'antic' @@ -812,6 +962,10 @@ export function ClawCaptcha({ if (!d || e.pointerId !== d.id) return const dx = Math.max(-26, Math.min(26, e.clientX - d.startX)) dir.current = dx / 26 + // server mode: record the joystick trajectory + whether the event was + // dispatched by real hardware (isTrusted). Synthetic events are isTrusted + // false — a strong automation tell. + if (serverMode) signalsRef.current?.pointerMove(dx, e.nativeEvent.isTrusted) // a stick only ROTATES around its ball joint — it never leaves the socket if (stickEl.current) stickEl.current.style.transform = `rotate(${(dx * 1.05).toFixed(1)}deg)` } @@ -834,9 +988,11 @@ export function ClawCaptcha({ if (e.key === 'ArrowLeft') { e.preventDefault() dir.current = -1 + if (serverMode) signalsRef.current?.keyPress(-1, e.nativeEvent.isTrusted) } else if (e.key === 'ArrowRight') { e.preventDefault() dir.current = 1 + if (serverMode) signalsRef.current?.keyPress(1, e.nativeEvent.isTrusted) } else if ((e.key === ' ' || e.key === 'Enter') && !e.repeat) { e.preventDefault() action() @@ -847,7 +1003,10 @@ export function ClawCaptcha({ } const t = TOY_META[target] - const busy = phase !== 'idle' && phase !== 'carry' + // busy = mid-animation OR the server is mid-verify OR (server mode and the + // challenge/PoW isn't ready yet — the user would just hit a missing closure) + const challengeLoading = serverMode && !serverVerifyRef.current + const busy = verifying || challengeLoading || (phase !== 'idle' && phase !== 'carry') const stepNo = verified || phase === 'carry' || phase === 'toTray' || phase === 'celebrate' ? 3 : phase === 'seq' ? 2 : 1 const carried = sim.current.carried const carriedW = carried >= 0 ? pile[carried].w : 80 @@ -870,8 +1029,14 @@ export function ClawCaptcha({ >