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') + }) +})