Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>()

constructor(cfg: ICaptchaConfig, deps: EngineDeps = {}) {
this.cfg = cfg
Expand All @@ -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. */
Expand Down Expand Up @@ -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)
Expand Down
69 changes: 69 additions & 0 deletions test/replay.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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')
})
})