release: WRNexusJS 0.4.0

This commit is contained in:
2026-07-27 12:42:18 +05:30
parent 8b728a3e5d
commit 30e5721e84
250 changed files with 10065 additions and 3923 deletions
+160 -50
View File
@@ -1,8 +1,19 @@
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 {
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,
@@ -40,13 +51,16 @@ function failure(
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");
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`);
if (!Number.isInteger(value) || value < 1)
throw new RangeError(`${name} must be a positive integer`);
return value;
}
@@ -54,7 +68,8 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
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");
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");
@@ -62,8 +77,13 @@ function normalizeDisturbance(value: number | undefined, difficulty: CaptchaDiff
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");
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;
@@ -74,10 +94,13 @@ function randomInteger(randomBytes: (length: number) => Uint8Array, min: number,
}
}
async function matchesHash(expected: string | undefined, raw: string | undefined): Promise<boolean> {
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) ?? "");
return constantTimeEqual(expected, (await bindingHash(raw)) ?? "");
}
export class DefaultCaptchaEngine implements CaptchaEngine {
@@ -106,9 +129,18 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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.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";
@@ -116,19 +148,22 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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}`);
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"
const requestedPresentation =
options.presentation ??
(requestedType === "honeypot" || requestedType === "timing" || requestedType === "not-robot"
? "invisible"
: "visual"
);
const actualType = requestedPresentation === "audio" && requestedType === "image" ? "number" : requestedType;
: "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();
@@ -161,19 +196,25 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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 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 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 presentation =
requestedPresentation === "audio" && generated.audioSequence?.length
? "audio"
: generated.presentation;
const publicChallenge: CaptchaChallenge = {
id,
@@ -190,7 +231,9 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
items: presentation === "audio" ? undefined : generated.items,
minSelections: generated.minSelections,
maxSelections: generated.maxSelections,
audioUrl: audioKey ? `${this.basePath}/audio/${encodeURIComponent(id)}?key=${encodeURIComponent(audioKey)}` : undefined,
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,
@@ -202,9 +245,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
requestedImageStyle: context.requestedImageStyle,
imageStylePool: [...context.imageStylePool],
locale: context.locale,
...(generated.answerKind === "invisible" ? { minCompletionMs: context.minCompletionMs } : {}),
...(generated.answerKind === "invisible"
? { minCompletionMs: context.minCompletionMs }
: {}),
...(generated.metadata?.interaction ? { interaction: generated.metadata.interaction } : {}),
...(requestedType === "image" && actualType !== requestedType ? { alternativeFor: requestedType } : {}),
...(requestedType === "image" && actualType !== requestedType
? { alternativeFor: requestedType }
: {}),
...options.metadata,
},
};
@@ -248,14 +295,23 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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);
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)
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);
@@ -269,7 +325,10 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const submitted = normalizedSubmittedAnswer(attempted, input);
const digest = await hmacSha256(this.secret, `${attempted.id}:${attempted.answerSalt}:${submitted}`);
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);
@@ -282,7 +341,13 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
}
const consumed = await this.store.consumeChallenge(attempted.id, now);
if (!consumed) return failure(action, "already-used", "The CAPTCHA challenge was already used", attempted.id);
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;
@@ -319,13 +384,33 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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);
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);
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",
@@ -338,13 +423,17 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
};
}
async renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined> {
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;
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" };
}
@@ -358,7 +447,8 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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 (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);
}
@@ -366,7 +456,12 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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 failure(
action,
"ip-mismatch",
"The CAPTCHA network binding does not match",
record.id,
);
}
return undefined;
}
@@ -377,13 +472,28 @@ export class DefaultCaptchaEngine implements CaptchaEngine {
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);
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);
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 failure(
action,
"ip-mismatch",
"The CAPTCHA network binding does not match",
record.challengeId,
);
}
return undefined;
}