release: WRNexusJS 0.4.0
This commit is contained in:
+160
-50
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user