From 1f221ddd8bc07a81b4f6537f3a0f326af0419dde Mon Sep 17 00:00:00 2001 From: latent-9 <296084221+latent-9@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:49:58 +1200 Subject: [PATCH] fix(engine): make a challenge token single-use to enforce the attempt budget submitAnswer read the attempt count from the client-carried sealed token and kept no server-side record of consumed tokens, so the original attempts:0 token could be replayed unchanged. The attempt budget never tripped and the escalate-on-miss difficulty never rose: a scripted solver replays the pristine token against the small deterministic answer space at the issued level and collects a genuine proof, which is exactly what the attempt budget is meant to prevent. Record each answered token's jti and reject reuse, pruning entries as their challenge TTL elapses so the set stays bounded. This binds the budget and escalation to one logical session. Adds tests for replay rejection, that a brute-forced replay cannot mint a proof, and that the legitimate one-token-at- a-time escalation flow still passes. --- src/engine.ts | 20 +++++++++++++ test/replay.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 test/replay.test.ts diff --git a/src/engine.ts b/src/engine.ts index ee6a0cc..8819f79 100644 --- a/src/engine.ts +++ b/src/engine.ts @@ -38,6 +38,9 @@ export class Engine { private readonly sealer: TokenSealer readonly prover: Prover private readonly now: () => number + // Consumed challenge tokens (jti -> expiry, unix sec). A token may be + // answered once; entries are pruned as they expire so this stays bounded. + private readonly seen = new Map() constructor(cfg: ICaptchaConfig, deps: EngineDeps = {}) { this.cfg = cfg @@ -51,6 +54,14 @@ export class Engine { return Math.floor(this.now() / 1000) } + /** Drop consumed-token records whose challenge TTL has elapsed. */ + private pruneSeen(): void { + const now = this.nowSec() + for (const [jti, exp] of this.seen) { + if (exp <= now) this.seen.delete(jti) + } + } + /** Pick a generator for `difficulty` (optionally constrained to `types`) and * generate a problem. Falls back to a deterministic generator if an LLM * generator errors, so issuance never hard-fails. */ @@ -122,6 +133,15 @@ export class Engine { } if (this.nowSec() > state.exp) throw new ExpiredChallengeError('challenge expired') + // Single-use: bind the attempt budget and difficulty escalation to one + // logical session. Without this the client can replay the original + // (attempts: 0) token unchanged, so the budget never trips and the + // difficulty never rises — a scripted solver just brute-forces the small + // answer space at the issued level and collects a genuine proof. + this.pruneSeen() + if (this.seen.has(state.jti)) throw new InvalidTokenError('challenge token already used') + this.seen.set(state.jti, state.exp) + const gen = this.registry.byType.get(state.type) const correct = gen?.grade ? await gen.grade(answer ?? '', state.answer) diff --git a/test/replay.test.ts b/test/replay.test.ts new file mode 100644 index 0000000..8d2d431 --- /dev/null +++ b/test/replay.test.ts @@ -0,0 +1,69 @@ +import { expect, test, describe } from 'bun:test' +import { Engine, InvalidTokenError } from '../src/engine.ts' +import { loadConfig, type ICaptchaConfig } from '../src/config.ts' +import { generateSigningKey } from '../src/proof.ts' +import { TokenSealer } from '../src/token.ts' +import type { SealedState } from '../src/types.ts' + +function makeCfg(over: Partial = {}): ICaptchaConfig { + return { + ...loadConfig(), + secret: 'replay-test-secret', + signingKey: generateSigningKey(), + gatewayKey: '', + enabledTypes: ['arithmetic', 'algebra', 'sequence', 'anagram', 'logic'], + requiredLevel: 3, + maxAttempts: 1, + challengeTtlSeconds: 120, + proofTtlSeconds: 300, + ...over, + } +} + +describe('challenge token single-use', () => { + test('the original token cannot be replayed after an answer', async () => { + const engine = new Engine(makeCfg()) + const challenge = await engine.issueChallenge({ requiredLevel: 3 }) + + // First submission (wrong) consumes the token. + const first = await engine.submitAnswer(challenge.token, '__wrong__') + expect(first.status).toBe('failed') // maxAttempts: 1 + + // Replaying the same pristine token must be rejected, not re-graded. + await expect(engine.submitAnswer(challenge.token, '__wrong__')).rejects.toBeInstanceOf( + InvalidTokenError, + ) + }) + + test('brute-forcing the same token cannot mint a proof', async () => { + const cfg = makeCfg() + const engine = new Engine(cfg) + const sealer = new TokenSealer(cfg.secret) + const challenge = await engine.issueChallenge({ requiredLevel: 3 }) + const trueAnswer = ((await sealer.unseal(challenge.token)) as SealedState).answer + + // Burn the single use on a wrong guess. + await engine.submitAnswer(challenge.token, '__wrong__') + + // Now replay the same token with the correct answer: must be refused. + await expect(engine.submitAnswer(challenge.token, trueAnswer)).rejects.toBeInstanceOf( + InvalidTokenError, + ) + }) + + test('the legitimate escalation flow still works one token at a time', async () => { + const cfg = makeCfg({ maxAttempts: 4 }) + const engine = new Engine(cfg) + const sealer = new TokenSealer(cfg.secret) + + let challenge = await engine.issueChallenge({ requiredLevel: 3 }) + // Answer the first token wrong -> get a fresh escalated token. + const r = await engine.submitAnswer(challenge.token, '__wrong__') + expect(r.status).toBe('continue') + if (r.status !== 'continue') throw new Error('unreachable') + // The fresh token is usable once and grades a correct answer to a pass. + const nextAnswer = ((await sealer.unseal(r.challenge.token)) as SealedState).answer + const pass = await engine.submitAnswer(r.challenge.token, nextAnswer) + expect(pass.status).toBe('passed') + }) +})