import { defaultCaptchaGenerators } from "./challenges/index.ts"; import { resolveCaptchaImageStyle } from "./challenges/styles.ts"; import { AssetAudioRenderer } from "./audio/renderer.ts"; import { bindingHash, constantTimeEqual, defaultRandomBytes, hmacSha256, randomId, sha256, } from "./crypto.ts"; import { normalizeSelections, normalizeTextAnswer, normalizedSubmittedAnswer, } from "./normalize.ts"; import { MemoryCaptchaStore } from "./stores/memory.ts"; import type { CaptchaBinding, CaptchaChallenge, CaptchaChallengeGenerator, CaptchaChallengeRecord, CaptchaChallengeType, CaptchaDifficulty, CaptchaEngine, CaptchaEngineOptions, CaptchaGeneratorContext, CaptchaResponseTokenRecord, CaptchaStore, CaptchaAudioRenderer, CaptchaVerificationResult, CreateCaptchaOptions, VerifyCaptchaInput, } from "./types.ts"; const DEFAULT_CHALLENGE_TTL_MS = 120_000; const DEFAULT_TOKEN_TTL_MS = 300_000; const DEFAULT_MAX_ATTEMPTS = 3; const DEFAULT_MIN_COMPLETION_MS = 800; const DEFAULT_RESPONSE_FIELD = "wrn-captcha-response"; const RANDOM_CHALLENGE_TYPES: CaptchaChallengeType[] = [ "number", "alpha", "alphanumeric", "calculation", "image", ]; function failure( action: string, code: string, message: string, challengeId?: string, ): CaptchaVerificationResult { return { success: false, provider: "self-hosted", action, code, message, challengeId }; } function assertAction(action: string): string { const value = action.trim(); if (!value || value.length > 128 || !/^[a-z0-9][a-z0-9:._/-]*$/i.test(value)) { throw new TypeError( "CAPTCHA action must be a non-empty stable identifier up to 128 characters", ); } return value; } function assertPositiveInteger(value: number, name: string): number { if (!Number.isInteger(value) || value < 1) throw new RangeError(`${name} must be a positive integer`); return value; } function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDifficulty): number { const fallback = difficulty === "easy" ? 25 : difficulty === "hard" ? 75 : 50; if (value === undefined) return fallback; const numeric = Number(value); if (!Number.isFinite(numeric)) throw new RangeError("disturbance must be a finite number between 25 and 75"); const normalized = Math.round(numeric); if (normalized < 25 || normalized > 75) { throw new RangeError("disturbance must be between 25 and 75"); } return normalized; } function randomInteger( randomBytes: (length: number) => Uint8Array, min: number, max: number, ): number { if (!Number.isInteger(min) || !Number.isInteger(max) || max < min) throw new RangeError("invalid random range"); const span = max - min + 1; if (span === 1) return min; const limit = Math.floor(0x1_0000_0000 / span) * span; while (true) { const bytes = randomBytes(4); const value = ((bytes[0]! << 24) | (bytes[1]! << 16) | (bytes[2]! << 8) | bytes[3]!) >>> 0; if (value < limit) return min + (value % span); } } async function matchesHash( expected: string | undefined, raw: string | undefined, ): Promise { if (!expected) return true; if (!raw) return false; return constantTimeEqual(expected, (await bindingHash(raw)) ?? ""); } export class DefaultCaptchaEngine implements CaptchaEngine { readonly provider = "self-hosted" as const; readonly basePath: string; private readonly secret: string; private readonly store: CaptchaStore; private readonly generators = new Map(); private readonly audioRenderer: CaptchaAudioRenderer; private readonly challengeTtlMs: number; private readonly responseTokenTtlMs: number; private readonly maxAttempts: number; private readonly minCompletionMs: number; private readonly responseField: string; private readonly defaultType: CaptchaChallengeType; private readonly defaultDifficulty: CaptchaDifficulty; private readonly bindIp: boolean; private readonly now: () => number; private readonly randomBytes: (length: number) => Uint8Array; constructor(options: CaptchaEngineOptions) { if (!options.secret || options.secret.length < 32) { throw new TypeError("CAPTCHA secret must contain at least 32 characters"); } this.secret = options.secret; this.store = options.store ?? new MemoryCaptchaStore(); this.audioRenderer = options.audioRenderer ?? new AssetAudioRenderer(); this.basePath = `/${(options.basePath ?? "/__wrnexus/captcha").replace(/^\/+|\/+$/g, "")}`; this.challengeTtlMs = assertPositiveInteger( options.challengeTtlMs ?? DEFAULT_CHALLENGE_TTL_MS, "challengeTtlMs", ); this.responseTokenTtlMs = assertPositiveInteger( options.responseTokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, "responseTokenTtlMs", ); this.maxAttempts = assertPositiveInteger( options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS, "maxAttempts", ); this.minCompletionMs = Math.max(0, options.minCompletionMs ?? DEFAULT_MIN_COMPLETION_MS); this.responseField = options.responseField ?? DEFAULT_RESPONSE_FIELD; this.defaultType = options.defaultType ?? "alphanumeric"; this.defaultDifficulty = options.defaultDifficulty ?? "normal"; this.bindIp = options.bindIp ?? false; this.now = options.now ?? Date.now; this.randomBytes = options.randomBytes ?? defaultRandomBytes; for (const generator of options.generators ?? defaultCaptchaGenerators()) this.generators.set(generator.type, generator); if (this.defaultType !== "random" && !this.generators.has(this.defaultType)) throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`); } async create(options: CreateCaptchaOptions): Promise { const action = assertAction(options.action); const configuredType = options.type ?? this.defaultType; const randomInt = (min: number, max: number): number => randomInteger(this.randomBytes, min, max); const randomTypes = RANDOM_CHALLENGE_TYPES.filter((type) => this.generators.has(type)); if (configuredType === "random" && randomTypes.length === 0) throw new Error("No interactive CAPTCHA generators are registered"); const requestedType = configuredType === "random" ? randomTypes[randomInt(0, randomTypes.length - 1)] : configuredType; const requestedPresentation = options.presentation ?? (requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot" ? "invisible" : "visual"); const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType; const generator = this.generators.get(actualType); if (!generator) throw new Error(`No CAPTCHA generator registered for ${actualType}`); const now = this.now(); const difficulty = options.difficulty ?? this.defaultDifficulty; const disturbance = normalizeDisturbance(options.disturbance, difficulty); const imageStyle = resolveCaptchaImageStyle({ imageStyle: options.imageStyle, allowedStyles: options.allowedStyles, excludedStyles: options.excludedStyles, randomizeStyle: options.randomizeStyle ?? false, randomInt, }); const context: CaptchaGeneratorContext = { difficulty, disturbance, imageStyle: imageStyle.resolved, requestedImageStyle: imageStyle.requested, imageStylePool: imageStyle.pool, locale: options.locale ?? "en", length: options.length, caseSensitive: options.caseSensitive ?? false, minCompletionMs: Math.max(0, options.minCompletionMs ?? this.minCompletionMs), randomInt, randomFloat: () => randomInteger(this.randomBytes, 0, 0xffff_ffff) / 0xffff_ffff, randomId: (bytes = 18) => randomId(this.randomBytes, bytes), }; const generated = await generator.generate(context); const id = randomId(this.randomBytes, 24); const answerSalt = randomId(this.randomBytes, 16); const caseSensitive = options.caseSensitive ?? false; const normalizedAnswer = generated.answerKind === "selections" ? normalizeSelections(generated.answer.split(",")) : generated.answerKind === "text" ? normalizeTextAnswer(generated.answer, caseSensitive) : generated.answer; const answerDigest = await hmacSha256(this.secret, `${id}:${answerSalt}:${normalizedAnswer}`); const expiresAt = now + assertPositiveInteger(options.expiresInMs ?? this.challengeTtlMs, "expiresInMs"); const maxAttempts = assertPositiveInteger( options.maxAttempts ?? this.maxAttempts, "maxAttempts", ); const audioKey = generated.audioSequence?.length ? randomId(this.randomBytes, 18) : undefined; const responseField = options.responseField ?? this.responseField; const presentation = requestedPresentation === "audio" && generated.audioSequence?.length ? "audio" : generated.presentation; const publicChallenge: CaptchaChallenge = { id, provider: "self-hosted", type: generated.type, presentation, action, prompt: presentation === "audio" ? "Listen and enter the spoken answer" : generated.prompt, createdAt: now, expiresAt, responseField, inputMode: generated.inputMode, image: presentation === "audio" ? undefined : generated.image, items: presentation === "audio" ? undefined : generated.items, minSelections: generated.minSelections, maxSelections: generated.maxSelections, audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined, refreshUrl: `${this.basePath}/challenge`, verifyUrl: `${this.basePath}/verify`, honeypotField: String(generated.metadata?.honeypotField ?? "") || undefined, timingToken: String(generated.metadata?.timingToken ?? "") || undefined, metadata: { difficulty, disturbance, imageStyle: context.imageStyle, requestedImageStyle: context.requestedImageStyle, imageStylePool: [...context.imageStylePool], locale: context.locale, ...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}), ...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}), ...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}), ...options.metadata, }, }; const record: CaptchaChallengeRecord = { id, provider: "self-hosted", type: generated.type, presentation, action, publicChallenge, answerDigest, answerSalt, answerKind: generated.answerKind, caseSensitive, createdAt: now, expiresAt, attempts: 0, maxAttempts, hostnameHash: await bindingHash(options.hostname), sessionHash: await bindingHash(options.sessionId), ipHash: this.bindIp ? await bindingHash(options.ip) : undefined, metadata: { ...generated.metadata, audioKey, audioSequence: generated.audioSequence, locale: context.locale, imageStyle: context.imageStyle, requestedImageStyle: context.requestedImageStyle, imageStylePool: [...context.imageStylePool], minCompletionMs: context.minCompletionMs, }, }; await this.store.createChallenge(record); return structuredClone(publicChallenge); } async verify(input: VerifyCaptchaInput): Promise { if (input.responseToken) return this.verifyResponseToken(input); const action = assertAction(input.action); if (!input.challengeId) return failure(action, "missing-input", "Missing CAPTCHA challenge id"); const now = this.now(); const record = await this.store.getChallenge(input.challengeId); if (!record) return failure(action, "invalid-input", "Unknown CAPTCHA challenge", input.challengeId); if (record.expiresAt <= now) return failure(action, "expired", "The CAPTCHA challenge expired", record.id); if (record.consumedAt) return failure(action, "already-used", "The CAPTCHA challenge was already used", record.id); const bindingFailure = await this.checkChallengeBinding(record, input, action); if (bindingFailure) return bindingFailure; const attempted = await this.store.incrementAttempts(record.id, now); if (!attempted) return failure( action, "already-used", "The CAPTCHA challenge is no longer available", record.id, ); if (attempted.attempts > attempted.maxAttempts) { await this.store.consumeChallenge(attempted.id, now); return failure(action, "attempts-exhausted", "Too many CAPTCHA attempts", record.id); } if (attempted.answerKind === "invisible") { const minimum = Number(attempted.metadata.minCompletionMs ?? this.minCompletionMs); if (now - attempted.createdAt < minimum) { return failure(action, "risk-rejected", "The form was completed too quickly", record.id); } } const submitted = normalizedSubmittedAnswer(attempted, input); const digest = await hmacSha256( this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`, ); if (!constantTimeEqual(attempted.answerDigest, digest)) { const exhausted = attempted.attempts >= attempted.maxAttempts; if (exhausted) await this.store.consumeChallenge(attempted.id, now); return failure( action, exhausted ? "attempts-exhausted" : "incorrect-answer", exhausted ? "Too many CAPTCHA attempts" : "The CAPTCHA answer is incorrect", attempted.id, ); } const consumed = await this.store.consumeChallenge(attempted.id, now); if (!consumed) return failure( action, "already-used", "The CAPTCHA challenge was already used", attempted.id, ); const plainToken = randomId(this.randomBytes, 32); const tokenHash = await sha256(plainToken); const expiresAt = now + this.responseTokenTtlMs; const tokenRecord: CaptchaResponseTokenRecord = { tokenHash, provider: "self-hosted", challengeId: consumed.id, action, createdAt: now, expiresAt, hostnameHash: consumed.hostnameHash, sessionHash: consumed.sessionHash, ipHash: consumed.ipHash, metadata: optionsMetadata(consumed), }; await this.store.createToken(tokenRecord); return { success: true, provider: "self-hosted", action, responseToken: plainToken, expiresAt, hostname: input.hostname, challengeId: consumed.id, }; } async verifyResponseToken(input: VerifyCaptchaInput): Promise { const action = assertAction(input.action); const token = input.responseToken ?? input.providerToken; if (!token) return failure(action, "missing-input", "Missing CAPTCHA response token"); if (token.length > 4096) return failure(action, "invalid-input", "CAPTCHA token is too long"); const now = this.now(); const tokenHash = await sha256(token); const existing = await this.store.getToken(tokenHash); if (!existing) return failure(action, "invalid-input", "Unknown CAPTCHA response token"); if (existing.expiresAt <= now) return failure(action, "expired", "The CAPTCHA response token expired", existing.challengeId); if (existing.consumedAt) return failure( action, "already-used", "The CAPTCHA response token was already used", existing.challengeId, ); if (existing.action !== action) return failure( action, "action-mismatch", "The CAPTCHA action does not match", existing.challengeId, ); const bindingFailure = await this.checkTokenBinding(existing, input, action); if (bindingFailure) return bindingFailure; const record = input.consume === false ? existing : await this.store.consumeToken(tokenHash, now); if (!record) return failure( action, "already-used", "The CAPTCHA response token was already used", existing.challengeId, ); return { success: true, provider: "self-hosted", action, expiresAt: record.expiresAt, hostname: input.hostname, challengeId: record.challengeId, score: record.score, metadata: record.metadata, }; } async renderAudio( challengeId: string, key: string, ): Promise<{ bytes: Uint8Array; contentType: string } | undefined> { const record = await this.store.getChallenge(challengeId); if (!record || record.expiresAt <= this.now() || record.consumedAt) return undefined; const expectedKey = String(record.metadata.audioKey ?? ""); if (!expectedKey || !constantTimeEqual(expectedKey, key)) return undefined; const sequence = record.metadata.audioSequence; if (!Array.isArray(sequence) || !sequence.every((value) => typeof value === "string")) return undefined; const bytes = await this.audioRenderer.render(sequence, String(record.metadata.locale ?? "en")); return { bytes, contentType: this.audioRenderer.contentType ?? "audio/wav" }; } async gc(): Promise { await this.store.gc?.(this.now()); } private async checkChallengeBinding( record: CaptchaChallengeRecord, input: CaptchaBinding, action: string, ): Promise { if (record.action !== action) return failure(action, "action-mismatch", "The CAPTCHA action does not match", record.id); if (!(await matchesHash(record.hostnameHash, input.hostname))) { return failure(action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.id); } if (!(await matchesHash(record.sessionHash, input.sessionId))) { return failure(action, "session-mismatch", "The CAPTCHA session does not match", record.id); } if (!(await matchesHash(record.ipHash, input.ip))) { return failure( action, "ip-mismatch", "The CAPTCHA network binding does not match", record.id, ); } return undefined; } private async checkTokenBinding( record: CaptchaResponseTokenRecord, input: CaptchaBinding, action: string, ): Promise { if (!(await matchesHash(record.hostnameHash, input.hostname))) { return failure( action, "hostname-mismatch", "The CAPTCHA hostname does not match", record.challengeId, ); } if (!(await matchesHash(record.sessionHash, input.sessionId))) { return failure( action, "session-mismatch", "The CAPTCHA session does not match", record.challengeId, ); } if (!(await matchesHash(record.ipHash, input.ip))) { return failure( action, "ip-mismatch", "The CAPTCHA network binding does not match", record.challengeId, ); } return undefined; } } function optionsMetadata(record: CaptchaChallengeRecord): Record { const metadata = { ...record.publicChallenge.metadata }; delete metadata.secret; return metadata; } export function createCaptchaEngine(options: CaptchaEngineOptions): CaptchaEngine { return new DefaultCaptchaEngine(options); }