New Captcha Package added
This commit is contained in:
@@ -0,0 +1,400 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user