Files
WRNexusJS/packages/captcha/src/engine.ts
T
2026-07-27 12:42:18 +05:30

511 lines
19 KiB
TypeScript

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";
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<boolean> {
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<CaptchaChallengeType, CaptchaChallengeGenerator>();
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.generators.has(this.defaultType))
throw new Error(`No CAPTCHA generator registered for ${this.defaultType}`);
}
async create(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
const action = assertAction(options.action);
const requestedType = options.type ?? this.defaultType;
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 randomInt = (min: number, max: number): number =>
randomInteger(this.randomBytes, min, max);
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<CaptchaVerificationResult> {
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<CaptchaVerificationResult> {
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<void> {
await this.store.gc?.(this.now());
}
private async checkChallengeBinding(
record: CaptchaChallengeRecord,
input: CaptchaBinding,
action: string,
): Promise<CaptchaVerificationResult | undefined> {
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<CaptchaVerificationResult | undefined> {
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<string, unknown> {
const metadata = { ...record.publicChallenge.metadata };
delete metadata.secret;
return metadata;
}
export function createCaptchaEngine(options: CaptchaEngineOptions): CaptchaEngine {
return new DefaultCaptchaEngine(options);
}