511 lines
19 KiB
TypeScript
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);
|
|
}
|