New Captcha Package added

This commit is contained in:
2026-07-25 13:38:18 +05:30
parent d0aded0392
commit 8b728a3e5d
157 changed files with 12092 additions and 1913 deletions
+1
View File
@@ -0,0 +1 @@
export * from "./renderer.ts";
+239
View File
@@ -0,0 +1,239 @@
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import type { CaptchaAudioRenderer } from "../types.ts";
interface ParsedWav {
sampleRate: number;
channels: number;
bitsPerSample: number;
data: Uint8Array;
}
function u16(view: DataView, offset: number): number {
return view.getUint16(offset, true);
}
function u32(view: DataView, offset: number): number {
return view.getUint32(offset, true);
}
function parseWav(bytes: Uint8Array): ParsedWav {
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
if (bytes.length < 44 || String.fromCharCode(...bytes.slice(0, 4)) !== "RIFF") {
throw new Error("CAPTCHA audio asset is not a WAV file");
}
let offset = 12;
let sampleRate = 0;
let channels = 0;
let bitsPerSample = 0;
let data: Uint8Array | undefined;
while (offset + 8 <= bytes.length) {
const id = String.fromCharCode(...bytes.slice(offset, offset + 4));
const size = u32(view, offset + 4);
const start = offset + 8;
if (id === "fmt ") {
const format = u16(view, start);
if (format !== 1) throw new Error("CAPTCHA audio assets must use PCM WAV");
channels = u16(view, start + 2);
sampleRate = u32(view, start + 4);
bitsPerSample = u16(view, start + 14);
} else if (id === "data") {
data = bytes.slice(start, start + size);
}
offset = start + size + (size % 2);
}
if (!sampleRate || !channels || !bitsPerSample || !data) {
throw new Error("Invalid CAPTCHA WAV asset");
}
return { sampleRate, channels, bitsPerSample, data };
}
function writeAscii(target: Uint8Array, offset: number, value: string): void {
for (let index = 0; index < value.length; index++) {
target[offset + index] = value.charCodeAt(index);
}
}
function createWav(
parts: Uint8Array[],
sampleRate: number,
channels: number,
bitsPerSample: number,
): Uint8Array {
const dataLength = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(44 + dataLength);
const view = new DataView(out.buffer);
const blockAlign = channels * (bitsPerSample / 8);
const byteRate = sampleRate * blockAlign;
writeAscii(out, 0, "RIFF");
view.setUint32(4, 36 + dataLength, true);
writeAscii(out, 8, "WAVE");
writeAscii(out, 12, "fmt ");
view.setUint32(16, 16, true);
view.setUint16(20, 1, true);
view.setUint16(22, channels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, byteRate, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitsPerSample, true);
writeAscii(out, 36, "data");
view.setUint32(40, dataLength, true);
let cursor = 44;
for (const part of parts) {
out.set(part, cursor);
cursor += part.length;
}
return out;
}
function safeToken(value: string): string {
const token = value.toLowerCase().trim();
if (!/^[a-z0-9]+$/.test(token)) {
throw new Error(`Unsupported audio token: ${value}`);
}
return token;
}
function addCandidate(candidates: string[], value: string | undefined): void {
if (!value) return;
const normalized = value.trim();
if (normalized && !candidates.includes(normalized)) candidates.push(normalized);
}
/**
* Resolve the bundled audio directory in source, workspace, installed-package,
* and bundled-server layouts. A production server bundle changes import.meta.url,
* so package resolution and cwd fallbacks are required in addition to the
* source-relative path.
*/
export function resolveCaptchaAudioAssetsDir(explicitDir?: string): string {
const candidates: string[] = [];
addCandidate(candidates, explicitDir);
addCandidate(candidates, process.env.WRNEXUS_CAPTCHA_AUDIO_DIR);
try {
const packageEntry = createRequire(import.meta.url).resolve("@wrnexus/captcha/audio");
addCandidate(
candidates,
join(dirname(dirname(dirname(packageEntry))), "assets", "audio"),
);
} catch {
// The source-relative and cwd fallbacks below still support direct source use.
}
const sourcePackageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url))));
addCandidate(candidates, join(sourcePackageRoot, "assets", "audio"));
let current = process.cwd();
for (let depth = 0; depth < 8; depth += 1) {
addCandidate(candidates, join(current, "packages", "captcha", "assets", "audio"));
addCandidate(candidates, join(current, "node_modules", "@wrnexus", "captcha", "assets", "audio"));
const parent = dirname(current);
if (parent === current) break;
current = parent;
}
for (const candidate of candidates) {
if (existsSync(join(candidate, "en"))) return candidate;
}
throw new Error(
[
"Unable to locate WRNexusJS CAPTCHA audio assets.",
"Set WRNEXUS_CAPTCHA_AUDIO_DIR or pass assetsDir to AssetAudioRenderer.",
`Checked: ${candidates.join(", ")}`,
].join(" "),
);
}
export interface AssetAudioRendererOptions {
assetsDir?: string;
gapMs?: number;
}
/**
* Concatenates bundled English PCM word clips. Applications can replace this
* renderer with cloud TTS or their own localized renderer without changing the
* challenge engine.
*/
export class AssetAudioRenderer implements CaptchaAudioRenderer {
readonly contentType = "audio/wav";
private readonly assetsDir: string;
private readonly gapMs: number;
private readonly cache = new Map<string, ParsedWav>();
constructor(options: AssetAudioRendererOptions = {}) {
this.assetsDir = resolveCaptchaAudioAssetsDir(options.assetsDir);
this.gapMs = options.gapMs ?? 180;
}
async render(sequence: string[], locale: string): Promise<Uint8Array> {
const language = locale.toLowerCase().split("-")[0] || "en";
if (language !== "en") {
throw new Error(
`No bundled CAPTCHA audio assets for locale '${locale}'. Supply a custom CaptchaAudioRenderer.`,
);
}
if (!sequence.length) throw new Error("Cannot render an empty CAPTCHA audio sequence");
const clips = await Promise.all(
sequence.map((token) => this.load(language, safeToken(token))),
);
const first = clips[0]!;
for (const clip of clips) {
if (
clip.sampleRate !== first.sampleRate ||
clip.channels !== first.channels ||
clip.bitsPerSample !== first.bitsPerSample
) {
throw new Error("CAPTCHA audio assets must share one PCM format");
}
}
const bytesPerSample = first.channels * (first.bitsPerSample / 8);
const silenceBytes = Math.floor((first.sampleRate * this.gapMs) / 1000) * bytesPerSample;
const silence = new Uint8Array(silenceBytes);
const parts: Uint8Array[] = [];
for (let index = 0; index < clips.length; index += 1) {
if (index) parts.push(silence);
parts.push(clips[index]!.data);
}
return createWav(parts, first.sampleRate, first.channels, first.bitsPerSample);
}
private async load(language: string, token: string): Promise<ParsedWav> {
const key = `${language}/${token}`;
const cached = this.cache.get(key);
if (cached) return cached;
const bytes = new Uint8Array(
await readFile(join(this.assetsDir, language, `${token}.wav`)),
);
const parsed = parseWav(bytes);
this.cache.set(key, parsed);
return parsed;
}
}
export function createAssetAudioRenderer(
options?: AssetAudioRendererOptions,
): AssetAudioRenderer {
return new AssetAudioRenderer(options);
}
+45
View File
@@ -0,0 +1,45 @@
export const BITMAP_FONT: Record<string, readonly string[]> = {
"0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
"1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
"2": ["01110", "10001", "00001", "00010", "00100", "01000", "11111"],
"3": ["11110", "00001", "00001", "01110", "00001", "00001", "11110"],
"4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
"5": ["11111", "10000", "10000", "11110", "00001", "00001", "11110"],
"6": ["01110", "10000", "10000", "11110", "10001", "10001", "01110"],
"7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
"8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
"9": ["01110", "10001", "10001", "01111", "00001", "00001", "01110"],
A: ["01110", "10001", "10001", "11111", "10001", "10001", "10001"],
B: ["11110", "10001", "10001", "11110", "10001", "10001", "11110"],
C: ["01111", "10000", "10000", "10000", "10000", "10000", "01111"],
D: ["11110", "10001", "10001", "10001", "10001", "10001", "11110"],
E: ["11111", "10000", "10000", "11110", "10000", "10000", "11111"],
F: ["11111", "10000", "10000", "11110", "10000", "10000", "10000"],
G: ["01111", "10000", "10000", "10111", "10001", "10001", "01110"],
H: ["10001", "10001", "10001", "11111", "10001", "10001", "10001"],
I: ["01110", "00100", "00100", "00100", "00100", "00100", "01110"],
J: ["00111", "00010", "00010", "00010", "10010", "10010", "01100"],
K: ["10001", "10010", "10100", "11000", "10100", "10010", "10001"],
L: ["10000", "10000", "10000", "10000", "10000", "10000", "11111"],
M: ["10001", "11011", "10101", "10101", "10001", "10001", "10001"],
N: ["10001", "11001", "10101", "10011", "10001", "10001", "10001"],
O: ["01110", "10001", "10001", "10001", "10001", "10001", "01110"],
P: ["11110", "10001", "10001", "11110", "10000", "10000", "10000"],
Q: ["01110", "10001", "10001", "10001", "10101", "10010", "01101"],
R: ["11110", "10001", "10001", "11110", "10100", "10010", "10001"],
S: ["01111", "10000", "10000", "01110", "00001", "00001", "11110"],
T: ["11111", "00100", "00100", "00100", "00100", "00100", "00100"],
U: ["10001", "10001", "10001", "10001", "10001", "10001", "01110"],
V: ["10001", "10001", "10001", "10001", "10001", "01010", "00100"],
W: ["10001", "10001", "10001", "10101", "10101", "10101", "01010"],
X: ["10001", "10001", "01010", "00100", "01010", "10001", "10001"],
Y: ["10001", "10001", "01010", "00100", "00100", "00100", "00100"],
Z: ["11111", "00001", "00010", "00100", "01000", "10000", "11111"],
"+": ["00000", "00100", "00100", "11111", "00100", "00100", "00000"],
"-": ["00000", "00000", "00000", "11111", "00000", "00000", "00000"],
"*": ["00000", "10001", "01010", "00100", "01010", "10001", "00000"],
"/": ["00001", "00010", "00010", "00100", "01000", "01000", "10000"],
"=": ["00000", "11111", "00000", "11111", "00000", "00000", "00000"],
"?": ["01110", "10001", "00001", "00010", "00100", "00000", "00100"],
" ": ["00000", "00000", "00000", "00000", "00000", "00000", "00000"],
};
@@ -0,0 +1,60 @@
import type {
CaptchaChallengeGenerator,
CaptchaGeneratorContext,
GeneratedCaptchaChallenge,
} from "../types.ts";
import { renderTextChallenge } from "./visual.ts";
const OPERATOR_WORDS: Record<string, string[]> = {
"+": ["plus"],
"-": ["minus"],
"*": ["times"],
"/": ["divided", "by"],
};
function numberTokens(value: number): string[] {
const text = String(Math.abs(value));
return value < 0 ? ["minus", ...text] : [...text];
}
export class CalculationCaptchaGenerator implements CaptchaChallengeGenerator {
readonly type = "calculation" as const;
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
const max = context.difficulty === "easy" ? 9 : context.difficulty === "hard" ? 30 : 15;
const operations = context.difficulty === "easy" ? ["+", "-"] : ["+", "-", "*", "/"];
const operator = operations[context.randomInt(0, operations.length - 1)]!;
let left = context.randomInt(2, max);
let right = context.randomInt(1, max);
let answer: number;
if (operator === "+") answer = left + right;
else if (operator === "-") {
if (context.difficulty !== "hard" && right > left) [left, right] = [right, left];
answer = left - right;
} else if (operator === "*") {
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
left = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
answer = left * right;
} else {
right = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
answer = context.randomInt(2, context.difficulty === "hard" ? 12 : 9);
left = right * answer;
}
const expression = `${left} ${operator} ${right} = ?`;
return {
type: "calculation",
presentation: "visual",
prompt: "Solve the calculation",
answer: String(answer),
answerKind: "text",
image: renderTextChallenge(expression, context),
inputMode: "numeric",
audioSequence: ["what", "is", ...numberTokens(left), ...(OPERATOR_WORDS[operator] ?? []), ...numberTokens(right)],
metadata: { operator, imageStyle: context.imageStyle },
};
}
}
export const calculationCaptchaGenerator = new CalculationCaptchaGenerator();
+130
View File
@@ -0,0 +1,130 @@
import type {
CaptchaChallengeGenerator,
CaptchaGeneratorContext,
CaptchaImageItem,
GeneratedCaptchaChallenge,
} from "../types.ts";
import { createImage, drawLine, fillCircle, fillPolygon, fillRect, pngDataUri, setPixel } from "./png.ts";
const SHAPES = ["circle", "square", "triangle", "diamond", "star"] as const;
type Shape = (typeof SHAPES)[number];
const COLORS = [
[37, 99, 235, 255],
[22, 163, 74, 255],
[220, 38, 38, 255],
[147, 51, 234, 255],
[234, 88, 12, 255],
] as const;
function starPoints(cx: number, cy: number, outer: number, inner: number): Array<[number, number]> {
const points: Array<[number, number]> = [];
for (let index = 0; index < 10; index++) {
const radius = index % 2 === 0 ? outer : inner;
const angle = -Math.PI / 2 + (index * Math.PI) / 5;
points.push([cx + Math.cos(angle) * radius, cy + Math.sin(angle) * radius]);
}
return points;
}
function disturbanceRatio(context: CaptchaGeneratorContext): number {
return Math.max(0, Math.min(1, (context.disturbance - 25) / 50));
}
function shapeImage(shape: Shape, context: CaptchaGeneratorContext): string {
const image = createImage(96, 72, [248, 250, 252, 255]);
const color = COLORS[context.randomInt(0, COLORS.length - 1)]!;
const cx = 48 + context.randomInt(-5, 5);
const cy = 36 + context.randomInt(-4, 4);
const size = context.randomInt(19, 25);
if (shape === "circle") fillCircle(image, cx, cy, size, color);
else if (shape === "square") fillRect(image, cx - size, cy - size, size * 2, size * 2, color);
else if (shape === "triangle") {
fillPolygon(image, [[cx, cy - size], [cx - size, cy + size], [cx + size, cy + size]], color);
} else if (shape === "diamond") {
fillPolygon(image, [[cx, cy - size], [cx - size, cy], [cx, cy + size], [cx + size, cy]], color);
} else {
fillPolygon(image, starPoints(cx, cy, size, size * 0.45), color);
}
const ratio = disturbanceRatio(context);
const dots = Math.round(35 + ratio * 150);
for (let index = 0; index < dots; index++) {
setPixel(
image,
context.randomInt(0, 95),
context.randomInt(0, 71),
[context.randomInt(105, 225), context.randomInt(105, 225), context.randomInt(105, 225), Math.round(55 + ratio * 65)],
);
}
const lines = Math.round(1 + ratio * 4);
for (let index = 0; index < lines; index++) {
drawLine(
image,
context.randomInt(0, 95),
context.randomInt(0, 71),
context.randomInt(0, 95),
context.randomInt(0, 71),
[context.randomInt(100, 210), context.randomInt(100, 210), context.randomInt(100, 210), Math.round(45 + ratio * 55)],
ratio > 0.75 ? 2 : 1,
);
}
return pngDataUri(image);
}
function shuffle<T>(items: T[], context: CaptchaGeneratorContext): T[] {
for (let index = items.length - 1; index > 0; index--) {
const other = context.randomInt(0, index);
[items[index], items[other]] = [items[other]!, items[index]!];
}
return items;
}
export class ImageCaptchaGenerator implements CaptchaChallengeGenerator {
readonly type = "image" as const;
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
const target = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
const count = context.difficulty === "easy" ? 6 : 9;
const matches = context.difficulty === "hard" ? 2 : 3;
const entries: Array<{ id: string; shape: Shape; item: CaptchaImageItem }> = [];
for (let index = 0; index < count; index++) {
let shape: Shape;
if (index < matches) shape = target;
else {
do shape = SHAPES[context.randomInt(0, SHAPES.length - 1)]!;
while (shape === target);
}
const id = context.randomId(9);
entries.push({
id,
shape,
item: { id, image: shapeImage(shape, context), alt: `Challenge tile ${index + 1}` },
});
}
shuffle(entries, context);
const answer = entries.filter((entry) => entry.shape === target).map((entry) => entry.id).sort().join(",");
return {
type: "image",
presentation: "visual",
prompt: `Select every ${target}`,
answer,
answerKind: "selections",
items: entries.map((entry) => entry.item),
minSelections: matches,
maxSelections: matches,
inputMode: "none",
metadata: {
target,
count,
matches,
disturbance: context.disturbance,
accessibleAlternative: "Request an audio challenge",
},
};
}
}
export const imageCaptchaGenerator = new ImageCaptchaGenerator();
+31
View File
@@ -0,0 +1,31 @@
import type { CaptchaChallengeGenerator } from "../types.ts";
import { alphaCaptchaGenerator, alphanumericCaptchaGenerator, numberCaptchaGenerator } from "./text.ts";
import { calculationCaptchaGenerator } from "./calculation.ts";
import { imageCaptchaGenerator } from "./image.ts";
import { honeypotCaptchaGenerator, notRobotCaptchaGenerator, timingCaptchaGenerator } from "./invisible.ts";
export * from "./text.ts";
export * from "./calculation.ts";
export * from "./image.ts";
export * from "./invisible.ts";
export * from "./png.ts";
export * from "./styles.ts";
export function defineCaptchaGenerator<T extends CaptchaChallengeGenerator>(generator: T): T {
if (!generator.type) throw new TypeError("CAPTCHA generator requires a stable type");
if (typeof generator.generate !== "function") throw new TypeError("CAPTCHA generator requires generate()");
return generator;
}
export function defaultCaptchaGenerators(): CaptchaChallengeGenerator[] {
return [
numberCaptchaGenerator,
alphaCaptchaGenerator,
alphanumericCaptchaGenerator,
calculationCaptchaGenerator,
imageCaptchaGenerator,
honeypotCaptchaGenerator,
timingCaptchaGenerator,
notRobotCaptchaGenerator,
];
}
@@ -0,0 +1,38 @@
import type {
CaptchaChallengeGenerator,
CaptchaGeneratorContext,
GeneratedCaptchaChallenge,
} from "../types.ts";
type InvisibleCaptchaType = "honeypot" | "timing" | "not-robot";
export class InvisibleCaptchaGenerator implements CaptchaChallengeGenerator {
readonly type: InvisibleCaptchaType;
constructor(type: InvisibleCaptchaType) {
this.type = type;
}
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
const honeypotField = `website_${context.randomId(5)}`;
const timingToken = context.randomId(18);
return {
type: this.type,
presentation: "invisible",
prompt: this.type === "not-robot" ? "Confirm that you are not a robot" : "Automated abuse check",
answer: JSON.stringify({ honeypot: "", timingToken }),
answerKind: "invisible",
inputMode: "none",
metadata: {
honeypotField,
timingToken,
minCompletionMs: context.minCompletionMs,
interaction: this.type === "not-robot" ? "checkbox" : "automatic",
},
};
}
}
export const honeypotCaptchaGenerator = new InvisibleCaptchaGenerator("honeypot");
export const timingCaptchaGenerator = new InvisibleCaptchaGenerator("timing");
export const notRobotCaptchaGenerator = new InvisibleCaptchaGenerator("not-robot");
+240
View File
@@ -0,0 +1,240 @@
import { BITMAP_FONT } from "./bitmap.ts";
export interface RgbaImage {
width: number;
height: number;
data: Uint8Array;
}
export type Rgba = readonly [number, number, number, number?];
export function createImage(width: number, height: number, background: Rgba = [255, 255, 255, 255]): RgbaImage {
const data = new Uint8Array(width * height * 4);
const alpha = background[3] ?? 255;
for (let index = 0; index < data.length; index += 4) {
data[index] = background[0];
data[index + 1] = background[1];
data[index + 2] = background[2];
data[index + 3] = alpha;
}
return { width, height, data };
}
export function setPixel(image: RgbaImage, x: number, y: number, color: Rgba): void {
const px = Math.round(x);
const py = Math.round(y);
if (px < 0 || py < 0 || px >= image.width || py >= image.height) return;
const index = (py * image.width + px) * 4;
const alpha = (color[3] ?? 255) / 255;
const inverse = 1 - alpha;
image.data[index] = Math.round(color[0] * alpha + image.data[index] * inverse);
image.data[index + 1] = Math.round(color[1] * alpha + image.data[index + 1] * inverse);
image.data[index + 2] = Math.round(color[2] * alpha + image.data[index + 2] * inverse);
image.data[index + 3] = 255;
}
export function fillRect(image: RgbaImage, x: number, y: number, width: number, height: number, color: Rgba): void {
for (let py = Math.floor(y); py < Math.ceil(y + height); py++) {
for (let px = Math.floor(x); px < Math.ceil(x + width); px++) setPixel(image, px, py, color);
}
}
export function drawLine(image: RgbaImage, x0: number, y0: number, x1: number, y1: number, color: Rgba, thickness = 1): void {
let x = Math.round(x0);
let y = Math.round(y0);
const targetX = Math.round(x1);
const targetY = Math.round(y1);
const dx = Math.abs(targetX - x);
const dy = -Math.abs(targetY - y);
const sx = x < targetX ? 1 : -1;
const sy = y < targetY ? 1 : -1;
let error = dx + dy;
while (true) {
fillRect(image, x - Math.floor(thickness / 2), y - Math.floor(thickness / 2), thickness, thickness, color);
if (x === targetX && y === targetY) break;
const twice = 2 * error;
if (twice >= dy) {
error += dy;
x += sx;
}
if (twice <= dx) {
error += dx;
y += sy;
}
}
}
export function fillCircle(image: RgbaImage, centerX: number, centerY: number, radius: number, color: Rgba): void {
const r2 = radius * radius;
for (let y = Math.floor(centerY - radius); y <= Math.ceil(centerY + radius); y++) {
for (let x = Math.floor(centerX - radius); x <= Math.ceil(centerX + radius); x++) {
const dx = x - centerX;
const dy = y - centerY;
if (dx * dx + dy * dy <= r2) setPixel(image, x, y, color);
}
}
}
function pointInPolygon(x: number, y: number, points: readonly (readonly [number, number])[]): boolean {
let inside = false;
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
const xi = points[i]![0];
const yi = points[i]![1];
const xj = points[j]![0];
const yj = points[j]![1];
const intersects = yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi || 1) + xi;
if (intersects) inside = !inside;
}
return inside;
}
export function fillPolygon(image: RgbaImage, points: readonly (readonly [number, number])[], color: Rgba): void {
const minX = Math.floor(Math.min(...points.map((point) => point[0])));
const maxX = Math.ceil(Math.max(...points.map((point) => point[0])));
const minY = Math.floor(Math.min(...points.map((point) => point[1])));
const maxY = Math.ceil(Math.max(...points.map((point) => point[1])));
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) if (pointInPolygon(x + 0.5, y + 0.5, points)) setPixel(image, x, y, color);
}
}
export function drawGlyph(
image: RgbaImage,
character: string,
x: number,
y: number,
scale: number,
color: Rgba,
shear = 0,
): void {
const glyph = BITMAP_FONT[character.toUpperCase()] ?? BITMAP_FONT["?"]!;
for (let row = 0; row < glyph.length; row++) {
const line = glyph[row]!;
for (let column = 0; column < line.length; column++) {
if (line[column] !== "1") continue;
const offset = Math.round((glyph.length - row) * shear);
fillRect(image, x + column * scale + offset, y + row * scale, scale, scale, color);
}
}
}
export function drawText(
image: RgbaImage,
text: string,
options: {
x: number;
y: number;
scale: number;
color: Rgba;
spacing?: number;
jitter?: (index: number) => { x: number; y: number; shear: number };
},
): void {
const spacing = options.spacing ?? options.scale * 2;
let cursor = options.x;
for (let index = 0; index < text.length; index++) {
const jitter = options.jitter?.(index) ?? { x: 0, y: 0, shear: 0 };
drawGlyph(image, text[index]!, cursor + jitter.x, options.y + jitter.y, options.scale, options.color, jitter.shear);
cursor += options.scale * 5 + spacing;
}
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let index = 0; index < table.length; index++) {
let value = index;
for (let bit = 0; bit < 8; bit++) {
value = (value >>> 1) ^ (value & 1 ? 0xedb88320 : 0);
}
table[index] = value >>> 0;
}
return table;
})();
function crc32(bytes: Uint8Array): number {
let crc = 0xffffffff;
for (const byte of bytes) {
crc = (crc >>> 8) ^ CRC32_TABLE[(crc ^ byte) & 255]!;
}
return (crc ^ 0xffffffff) >>> 0;
}
function adler32(bytes: Uint8Array): number {
let a = 1;
let b = 0;
for (const byte of bytes) {
a = (a + byte) % 65521;
b = (b + a) % 65521;
}
return ((b << 16) | a) >>> 0;
}
function u32(value: number): Uint8Array {
return Uint8Array.of((value >>> 24) & 255, (value >>> 16) & 255, (value >>> 8) & 255, value & 255);
}
function concat(parts: readonly Uint8Array[]): Uint8Array {
const length = parts.reduce((sum, part) => sum + part.length, 0);
const out = new Uint8Array(length);
let offset = 0;
for (const part of parts) {
out.set(part, offset);
offset += part.length;
}
return out;
}
function chunk(type: string, data: Uint8Array): Uint8Array {
const typeBytes = new TextEncoder().encode(type);
return concat([u32(data.length), typeBytes, data, u32(crc32(concat([typeBytes, data])))]);
}
function deflateStored(data: Uint8Array): Uint8Array {
const blocks: Uint8Array[] = [Uint8Array.of(0x78, 0x01)];
for (let offset = 0; offset < data.length; offset += 65535) {
const size = Math.min(65535, data.length - offset);
const final = offset + size >= data.length;
const length = size;
const inverse = (~length) & 0xffff;
blocks.push(
Uint8Array.of(final ? 1 : 0, length & 255, (length >>> 8) & 255, inverse & 255, (inverse >>> 8) & 255),
data.slice(offset, offset + size),
);
}
blocks.push(u32(adler32(data)));
return concat(blocks);
}
export function encodePng(image: RgbaImage): Uint8Array {
const stride = image.width * 4;
const scanlines = new Uint8Array((stride + 1) * image.height);
for (let y = 0; y < image.height; y++) {
const target = y * (stride + 1);
scanlines[target] = 0;
scanlines.set(image.data.slice(y * stride, (y + 1) * stride), target + 1);
}
const header = new Uint8Array(13);
header.set(u32(image.width), 0);
header.set(u32(image.height), 4);
header[8] = 8;
header[9] = 6;
return concat([
Uint8Array.of(137, 80, 78, 71, 13, 10, 26, 10),
chunk("IHDR", header),
chunk("IDAT", deflateStored(scanlines)),
chunk("IEND", new Uint8Array()),
]);
}
export function bytesToBase64(bytes: Uint8Array): string {
let binary = "";
const size = 0x8000;
for (let offset = 0; offset < bytes.length; offset += size) {
binary += String.fromCharCode(...bytes.subarray(offset, Math.min(bytes.length, offset + size)));
}
return btoa(binary);
}
export function pngDataUri(image: RgbaImage): string {
return `data:image/png;base64,${bytesToBase64(encodePng(image))}`;
}
+131
View File
@@ -0,0 +1,131 @@
import type {
CaptchaConcreteImageStyle,
CaptchaImageStyle,
} from "../types.ts";
export const CAPTCHA_CONCRETE_IMAGE_STYLES = [
"classic",
"collision",
"snow",
"corrosion",
"spiderweb",
"cross-shadow",
"split",
"split2",
"cut",
"darts",
"distortion",
"stitch",
"striped",
"wave",
"grid-noise",
"scribble",
"pixel",
"broken-lines",
] as const satisfies readonly CaptchaConcreteImageStyle[];
export const CAPTCHA_IMAGE_STYLES = [
"random",
...CAPTCHA_CONCRETE_IMAGE_STYLES,
] as const satisfies readonly CaptchaImageStyle[];
const CONCRETE_STYLE_SET = new Set<string>(CAPTCHA_CONCRETE_IMAGE_STYLES);
const STYLE_SET = new Set<string>(CAPTCHA_IMAGE_STYLES);
function styleValues(value: unknown): string[] {
if (Array.isArray(value)) return value.flatMap((item) => styleValues(item));
if (typeof value !== "string") return [];
return value
.split(",")
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
}
export function isCaptchaImageStyle(value: unknown): value is CaptchaImageStyle {
return typeof value === "string" && STYLE_SET.has(value.trim().toLowerCase());
}
export function normalizeCaptchaImageStyle(
value: unknown,
fallback: CaptchaImageStyle = "random",
): CaptchaImageStyle {
if (value === undefined || value === null || value === "") return fallback;
const normalized = String(value).trim().toLowerCase();
if (!STYLE_SET.has(normalized)) {
throw new RangeError(
`imageStyle must be one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}`,
);
}
return normalized as CaptchaImageStyle;
}
export function normalizeCaptchaImageStyleList(
value: unknown,
name: "allowedStyles" | "excludedStyles",
): CaptchaConcreteImageStyle[] {
const styles: CaptchaConcreteImageStyle[] = [];
for (const entry of styleValues(value)) {
if (entry === "random") continue;
if (!CONCRETE_STYLE_SET.has(entry)) {
throw new RangeError(
`${name} contains an unknown image style: ${entry}`,
);
}
const style = entry as CaptchaConcreteImageStyle;
if (!styles.includes(style)) styles.push(style);
}
return styles;
}
export interface ResolveCaptchaImageStyleOptions {
imageStyle?: unknown;
allowedStyles?: unknown;
excludedStyles?: unknown;
randomizeStyle?: boolean;
randomInt(min: number, max: number): number;
}
export interface ResolvedCaptchaImageStyle {
requested: CaptchaImageStyle;
resolved: CaptchaConcreteImageStyle;
pool: CaptchaConcreteImageStyle[];
}
export function resolveCaptchaImageStyle(
options: ResolveCaptchaImageStyleOptions,
): ResolvedCaptchaImageStyle {
const requested = normalizeCaptchaImageStyle(options.imageStyle, "random");
const allowed = normalizeCaptchaImageStyleList(
options.allowedStyles,
"allowedStyles",
);
const excluded = new Set(
normalizeCaptchaImageStyleList(options.excludedStyles, "excludedStyles"),
);
const source = allowed.length
? allowed
: [...CAPTCHA_CONCRETE_IMAGE_STYLES];
const pool = source.filter((style) => !excluded.has(style));
if (!pool.length) {
throw new RangeError(
"No CAPTCHA image styles remain after applying allowedStyles and excludedStyles",
);
}
if (!options.randomizeStyle && requested !== "random") {
if (!pool.includes(requested)) {
throw new RangeError(
`imageStyle ${requested} is not available in the configured style pool`,
);
}
return { requested, resolved: requested, pool };
}
return {
requested,
resolved: pool[options.randomInt(0, pool.length - 1)]!,
pool,
};
}
+55
View File
@@ -0,0 +1,55 @@
import type {
CaptchaChallengeGenerator,
CaptchaChallengeType,
CaptchaGeneratorContext,
GeneratedCaptchaChallenge,
} from "../types.ts";
import { renderTextChallenge } from "./visual.ts";
const NUMBERS = "23456789";
const ALPHA = "ABCDEFGHJKMNPQRSTUVWXYZ";
const ALPHANUMERIC = `${ALPHA}${NUMBERS}`;
function defaultLength(context: CaptchaGeneratorContext): number {
return context.length ?? (context.difficulty === "easy" ? 4 : context.difficulty === "hard" ? 7 : 6);
}
function charset(type: CaptchaChallengeType): string {
if (type === "number") return NUMBERS;
if (type === "alpha") return ALPHA;
return ALPHANUMERIC;
}
function generateText(type: "number" | "alpha" | "alphanumeric", context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
const source = charset(type);
const length = Math.max(3, Math.min(10, defaultLength(context)));
let answer = "";
for (let index = 0; index < length; index++) answer += source[context.randomInt(0, source.length - 1)];
return {
type,
presentation: "visual",
prompt: type === "number" ? "Enter the numbers shown" : "Enter the characters shown",
answer,
answerKind: "text",
image: renderTextChallenge(answer, context),
inputMode: type === "number" ? "numeric" : "text",
audioSequence: [...answer.toLowerCase()],
metadata: { length, excludedAmbiguousCharacters: true, imageStyle: context.imageStyle },
};
}
export class TextCaptchaGenerator implements CaptchaChallengeGenerator {
readonly type: "number" | "alpha" | "alphanumeric";
constructor(type: "number" | "alpha" | "alphanumeric") {
this.type = type;
}
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge {
return generateText(this.type, context);
}
}
export const numberCaptchaGenerator = new TextCaptchaGenerator("number");
export const alphaCaptchaGenerator = new TextCaptchaGenerator("alpha");
export const alphanumericCaptchaGenerator = new TextCaptchaGenerator("alphanumeric");
+690
View File
@@ -0,0 +1,690 @@
import type {
CaptchaConcreteImageStyle,
CaptchaGeneratorContext,
} from "../types.ts";
import {
createImage,
drawGlyph,
drawLine,
fillCircle,
fillRect,
pngDataUri,
setPixel,
type Rgba,
type RgbaImage,
} from "./png.ts";
const LIGHT_BACKGROUND: Rgba = [248, 250, 252, 255];
const ALT_BACKGROUND: Rgba = [241, 245, 249, 255];
const INK: Rgba = [15, 23, 42, 255];
const ACCENTS: readonly Rgba[] = [
[30, 64, 175, 235],
[126, 34, 206, 235],
[190, 24, 93, 230],
[15, 118, 110, 230],
[194, 65, 12, 230],
];
interface TextLayout {
scale: number;
spacing: number;
startX: number;
startY: number;
textWidth: number;
textHeight: number;
}
function clamp(value: number, minimum: number, maximum: number): number {
return Math.max(minimum, Math.min(maximum, value));
}
function disturbanceRatio(context: CaptchaGeneratorContext): number {
return clamp((context.disturbance - 25) / 50, 0, 1);
}
function randomColor(
context: CaptchaGeneratorContext,
minimum = 70,
maximum = 205,
alpha = 100,
): Rgba {
return [
context.randomInt(minimum, maximum),
context.randomInt(minimum, maximum),
context.randomInt(minimum, maximum),
alpha,
];
}
function rawPixel(image: RgbaImage, x: number, y: number): readonly [number, number, number, number] {
const px = clamp(Math.round(x), 0, image.width - 1);
const py = clamp(Math.round(y), 0, image.height - 1);
const index = (py * image.width + px) * 4;
return [
image.data[index]!,
image.data[index + 1]!,
image.data[index + 2]!,
image.data[index + 3]!,
];
}
function putRawPixel(
image: RgbaImage,
x: number,
y: number,
color: readonly [number, number, number, number],
): void {
const px = Math.round(x);
const py = Math.round(y);
if (px < 0 || py < 0 || px >= image.width || py >= image.height) return;
const index = (py * image.width + px) * 4;
image.data[index] = color[0];
image.data[index + 1] = color[1];
image.data[index + 2] = color[2];
image.data[index + 3] = color[3];
}
function cloneImage(image: RgbaImage): RgbaImage {
return { width: image.width, height: image.height, data: image.data.slice() };
}
function replaceImage(target: RgbaImage, source: RgbaImage): void {
target.data.set(source.data);
}
function layoutFor(text: string, style: CaptchaConcreteImageStyle): TextLayout {
const width = 300;
const height = 104;
const scale = text.length > 9 ? 3 : text.length > 7 ? 4 : 5;
const glyphWidth = scale * 5;
const normalSpacing = scale + 3;
const spacing = style === "collision"
? Math.max(-Math.floor(scale * 0.45), -2)
: style === "cross-shadow"
? scale
: normalSpacing;
const textWidth = text.length * glyphWidth + Math.max(0, text.length - 1) * spacing;
const textHeight = scale * 7;
return {
scale,
spacing,
startX: Math.max(12, Math.floor((width - textWidth) / 2)),
startY: Math.floor((height - textHeight) / 2),
textWidth,
textHeight,
};
}
function drawCircleOutline(
image: RgbaImage,
centerX: number,
centerY: number,
radius: number,
color: Rgba,
thickness = 1,
): void {
const steps = Math.max(24, Math.round(radius * 8));
for (let index = 0; index < steps; index++) {
const angle = (index / steps) * Math.PI * 2;
const x = centerX + Math.cos(angle) * radius;
const y = centerY + Math.sin(angle) * radius;
fillCircle(image, x, y, Math.max(0.75, thickness / 2), color);
}
}
function drawDashedLine(
image: RgbaImage,
x0: number,
y0: number,
x1: number,
y1: number,
color: Rgba,
dash = 5,
gap = 4,
thickness = 1,
): void {
const distance = Math.hypot(x1 - x0, y1 - y0);
if (distance <= 0) return;
const dx = (x1 - x0) / distance;
const dy = (y1 - y0) / distance;
for (let offset = 0; offset < distance; offset += dash + gap) {
const end = Math.min(distance, offset + dash);
drawLine(
image,
x0 + dx * offset,
y0 + dy * offset,
x0 + dx * end,
y0 + dy * end,
color,
thickness,
);
}
}
function addDots(
image: RgbaImage,
context: CaptchaGeneratorContext,
count: number,
alpha: number,
minimum = 75,
maximum = 220,
radius = 0,
): void {
for (let index = 0; index < count; index++) {
const x = context.randomInt(0, image.width - 1);
const y = context.randomInt(0, image.height - 1);
const color = randomColor(context, minimum, maximum, alpha);
if (radius > 0) fillCircle(image, x, y, context.randomInt(1, radius), color);
else setPixel(image, x, y, color);
}
}
function addCrossingLines(
image: RgbaImage,
context: CaptchaGeneratorContext,
count: number,
alpha: number,
thickness = 1,
): void {
for (let index = 0; index < count; index++) {
drawLine(
image,
context.randomInt(0, image.width - 1),
context.randomInt(0, image.height - 1),
context.randomInt(0, image.width - 1),
context.randomInt(0, image.height - 1),
randomColor(context, 65, 195, alpha),
thickness,
);
}
}
function drawCharacters(
image: RgbaImage,
text: string,
context: CaptchaGeneratorContext,
layout: TextLayout,
style: CaptchaConcreteImageStyle,
): void {
const ratio = disturbanceRatio(context);
const jitterX = Math.round(1 + ratio * 3);
const jitterY = Math.round(2 + ratio * 4);
const shear = 0.12 + ratio * 0.42;
let cursor = layout.startX;
for (let index = 0; index < text.length; index++) {
const character = text[index]!;
const x = cursor + context.randomInt(-jitterX, jitterX);
const y = layout.startY + context.randomInt(-jitterY, jitterY);
const glyphShear = (context.randomFloat() - 0.5) * shear;
const accent = ACCENTS[context.randomInt(0, ACCENTS.length - 1)]!;
const color = style === "collision" || style === "cross-shadow" || style === "pixel"
? accent
: INK;
if (style === "cross-shadow") {
drawGlyph(image, character, x - 3, y + 3, layout.scale, [37, 99, 235, 95], glyphShear);
drawGlyph(image, character, x + 3, y - 2, layout.scale, [220, 38, 38, 85], glyphShear);
} else if (style === "collision") {
drawGlyph(image, character, x - 2, y + 2, layout.scale, [15, 23, 42, 75], glyphShear);
if (index > 0 && context.randomFloat() < 0.65) {
drawGlyph(image, character, x + context.randomInt(-4, 1), y, layout.scale, [2, 6, 23, 60], -glyphShear);
}
} else if (context.randomFloat() < 0.28 + ratio * 0.35) {
drawGlyph(image, character, x + 1, y + 1, layout.scale, [15, 23, 42, 70], glyphShear);
}
drawGlyph(image, character, x, y, layout.scale, color, glyphShear);
if (style === "pixel" && context.randomFloat() < 0.45) {
drawGlyph(image, character, x + 1, y, layout.scale, [15, 23, 42, 90], glyphShear);
}
cursor += layout.scale * 5 + layout.spacing;
}
}
function shiftRows(
image: RgbaImage,
shiftForY: (y: number) => number,
background: Rgba = LIGHT_BACKGROUND,
): void {
const source = cloneImage(image);
const output = createImage(image.width, image.height, background);
for (let y = 0; y < image.height; y++) {
const shift = Math.round(shiftForY(y));
for (let x = 0; x < image.width; x++) {
const sourceX = x - shift;
if (sourceX >= 0 && sourceX < image.width) {
putRawPixel(output, x, y, rawPixel(source, sourceX, y));
}
}
}
replaceImage(image, output);
}
function shiftColumns(
image: RgbaImage,
shiftForX: (x: number) => number,
background: Rgba = LIGHT_BACKGROUND,
): void {
const source = cloneImage(image);
const output = createImage(image.width, image.height, background);
for (let x = 0; x < image.width; x++) {
const shift = Math.round(shiftForX(x));
for (let y = 0; y < image.height; y++) {
const sourceY = y - shift;
if (sourceY >= 0 && sourceY < image.height) {
putRawPixel(output, x, y, rawPixel(source, x, sourceY));
}
}
}
replaceImage(image, output);
}
function applyClassic(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addCrossingLines(image, context, Math.round(3 + ratio * 5), Math.round(55 + ratio * 55), ratio > 0.7 ? 2 : 1);
addDots(image, context, Math.round(140 + ratio * 470), Math.round(45 + ratio * 50));
}
function applyCollision(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addCrossingLines(image, context, Math.round(5 + ratio * 7), Math.round(60 + ratio * 60), ratio > 0.55 ? 2 : 1);
addDots(image, context, Math.round(120 + ratio * 320), 70);
const bars = Math.round(2 + ratio * 5);
for (let index = 0; index < bars; index++) {
fillRect(
image,
context.randomInt(0, image.width - 35),
context.randomInt(18, image.height - 20),
context.randomInt(18, 50),
context.randomInt(1, ratio > 0.65 ? 3 : 2),
randomColor(context, 35, 160, 75),
);
}
}
function applySnow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
addDots(image, context, Math.round(420 + ratio * 950), Math.round(85 + ratio * 75), 120, 245, ratio > 0.5 ? 2 : 1);
for (let index = 0; index < Math.round(35 + ratio * 90); index++) {
const x = context.randomInt(0, image.width - 1);
const y = context.randomInt(0, image.height - 1);
drawLine(image, x - 2, y, x + 2, y, [255, 255, 255, 185], 1);
drawLine(image, x, y - 2, x, y + 2, [255, 255, 255, 185], 1);
}
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
}
function applyCorrosion(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
const ratio = disturbanceRatio(context);
const holes = Math.round(70 + ratio * 230);
for (let index = 0; index < holes; index++) {
const x = context.randomInt(layout.startX - 5, layout.startX + layout.textWidth + 5);
const y = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
const size = context.randomInt(1, ratio > 0.65 ? 4 : 3);
fillRect(image, x, y, size, size, context.randomFloat() < 0.6 ? LIGHT_BACKGROUND : [203, 213, 225, 210]);
}
addDots(image, context, Math.round(170 + ratio * 430), 85, 90, 190, 2);
addCrossingLines(image, context, Math.round(2 + ratio * 4), 60, 1);
}
function applySpiderweb(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const nodes = Array.from({ length: Math.round(8 + ratio * 12) }, () => ({
x: context.randomInt(0, image.width - 1),
y: context.randomInt(0, image.height - 1),
}));
for (let index = 0; index < nodes.length; index++) {
const current = nodes[index]!;
const next = nodes[(index + context.randomInt(1, Math.max(1, nodes.length - 1))) % nodes.length]!;
drawLine(image, current.x, current.y, next.x, next.y, [71, 85, 105, Math.round(65 + ratio * 65)], 1);
if (index % 2 === 0) drawCircleOutline(image, current.x, current.y, context.randomInt(2, 5), [100, 116, 139, 70], 1);
}
const anchorX = context.randomInt(Math.floor(image.width * 0.25), Math.floor(image.width * 0.75));
const anchorY = context.randomInt(Math.floor(image.height * 0.25), Math.floor(image.height * 0.75));
for (let index = 0; index < Math.round(7 + ratio * 7); index++) {
const angle = (index / Math.round(7 + ratio * 7)) * Math.PI * 2;
drawLine(
image,
anchorX,
anchorY,
anchorX + Math.cos(angle) * image.width,
anchorY + Math.sin(angle) * image.height,
[71, 85, 105, 65],
1,
);
}
addDots(image, context, Math.round(80 + ratio * 200), 60);
}
function applyCrossShadow(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const centerY = Math.floor(image.height / 2);
drawLine(image, 0, centerY - 6, image.width - 1, centerY + 6, [37, 99, 235, 75], ratio > 0.55 ? 2 : 1);
drawLine(image, 0, centerY + 7, image.width - 1, centerY - 8, [220, 38, 38, 65], ratio > 0.55 ? 2 : 1);
addCrossingLines(image, context, Math.round(2 + ratio * 5), 55, 1);
addDots(image, context, Math.round(120 + ratio * 300), 60);
}
function applySplit(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const bandHeight = context.randomInt(7, 13);
const amplitude = Math.round(4 + ratio * 13);
shiftRows(image, (y) => {
const band = Math.floor(y / bandHeight);
return band % 2 === 0 ? amplitude : -amplitude;
});
for (let y = bandHeight; y < image.height; y += bandHeight) {
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 75], 1);
}
addDots(image, context, Math.round(90 + ratio * 250), 65);
}
function applySplit2(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const stripWidth = context.randomInt(10, 18);
const amplitude = Math.round(3 + ratio * 10);
shiftColumns(image, (x) => {
const strip = Math.floor(x / stripWidth);
return strip % 2 === 0 ? amplitude : -amplitude;
});
for (let x = stripWidth; x < image.width; x += stripWidth) {
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 65], 1);
}
addCrossingLines(image, context, Math.round(2 + ratio * 4), 55, 1);
}
function applyCut(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
const ratio = disturbanceRatio(context);
const cuts = Math.round(4 + ratio * 8);
for (let index = 0; index < cuts; index++) {
const x0 = context.randomInt(layout.startX - 10, layout.startX + layout.textWidth);
const y0 = context.randomInt(layout.startY - 5, layout.startY + layout.textHeight + 5);
const x1 = x0 + context.randomInt(24, 72);
const y1 = y0 + context.randomInt(-18, 18);
drawLine(image, x0, y0, x1, y1, [248, 250, 252, 245], ratio > 0.6 ? 3 : 2);
drawLine(image, x0, y0 + 2, x1, y1 + 2, randomColor(context, 75, 170, 80), 1);
}
addDots(image, context, Math.round(90 + ratio * 260), 70);
}
function applyDarts(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const targets = Math.round(2 + ratio * 3);
for (let index = 0; index < targets; index++) {
const x = context.randomInt(25, image.width - 25);
const y = context.randomInt(16, image.height - 16);
const maxRadius = context.randomInt(9, Math.round(14 + ratio * 11));
for (let radius = maxRadius; radius >= 4; radius -= 5) {
drawCircleOutline(image, x, y, radius, randomColor(context, 55, 175, 70), 1);
}
for (let ray = 0; ray < Math.round(3 + ratio * 4); ray++) {
const angle = context.randomFloat() * Math.PI * 2;
drawLine(
image,
x,
y,
x + Math.cos(angle) * context.randomInt(25, 70),
y + Math.sin(angle) * context.randomInt(18, 55),
randomColor(context, 55, 175, 70),
1,
);
}
}
addDots(image, context, Math.round(100 + ratio * 240), 60);
}
function applyDistortion(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const amplitudeX = 3 + ratio * 9;
const amplitudeY = 2 + ratio * 5;
const phase = context.randomFloat() * Math.PI * 2;
shiftRows(image, (y) => Math.sin(y / (7 + ratio * 4) + phase) * amplitudeX);
shiftColumns(image, (x) => Math.sin(x / (20 - ratio * 6) + phase) * amplitudeY);
addCrossingLines(image, context, Math.round(3 + ratio * 5), 60, ratio > 0.7 ? 2 : 1);
addDots(image, context, Math.round(100 + ratio * 280), 60);
}
function applyStitch(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const rows = Math.round(4 + ratio * 5);
for (let index = 0; index < rows; index++) {
const y = Math.round(((index + 1) / (rows + 1)) * image.height);
drawDashedLine(image, 0, y, image.width - 1, y + context.randomInt(-3, 3), [71, 85, 105, 75], 4, 4, 1);
}
const columns = Math.round(2 + ratio * 4);
for (let index = 0; index < columns; index++) {
const x = context.randomInt(10, image.width - 10);
drawDashedLine(image, x, 0, x + context.randomInt(-5, 5), image.height - 1, [100, 116, 139, 65], 3, 5, 1);
}
for (let index = 0; index < Math.round(18 + ratio * 35); index++) {
const x = context.randomInt(0, image.width - 1);
const y = context.randomInt(0, image.height - 1);
drawLine(image, x - 2, y - 2, x + 2, y + 2, [100, 116, 139, 65], 1);
drawLine(image, x + 2, y - 2, x - 2, y + 2, [100, 116, 139, 65], 1);
}
}
function applyStriped(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const spacing = Math.round(10 - ratio * 4);
const offset = context.randomInt(-image.height, image.width);
for (let x = offset; x < image.width + image.height; x += spacing) {
drawLine(image, x, 0, x - image.height, image.height - 1, [71, 85, 105, Math.round(45 + ratio * 45)], ratio > 0.7 ? 2 : 1);
}
for (let y = context.randomInt(3, 9); y < image.height; y += context.randomInt(8, 14)) {
drawLine(image, 0, y, image.width - 1, y, [148, 163, 184, 45], 1);
}
addDots(image, context, Math.round(70 + ratio * 180), 55);
}
function applyWave(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const phase = context.randomFloat() * Math.PI * 2;
const amplitude = 5 + ratio * 13;
shiftRows(image, (y) => Math.sin(y / (5.5 + ratio * 3) + phase) * amplitude);
shiftColumns(image, (x) => Math.cos(x / (24 - ratio * 7) + phase) * (2 + ratio * 6));
for (let index = 0; index < Math.round(2 + ratio * 3); index++) {
const baseY = context.randomInt(10, image.height - 10);
let previousX = 0;
let previousY = baseY;
for (let x = 8; x < image.width; x += 8) {
const y = baseY + Math.sin(x / 17 + phase + index) * (5 + ratio * 5);
drawLine(image, previousX, previousY, x, y, randomColor(context, 75, 180, 55), 1);
previousX = x;
previousY = y;
}
}
}
function applyGridNoise(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const cell = Math.round(16 - ratio * 6);
for (let x = 0; x < image.width; x += cell) {
drawLine(image, x, 0, x, image.height - 1, [100, 116, 139, 55], 1);
}
for (let y = 0; y < image.height; y += cell) {
drawLine(image, 0, y, image.width - 1, y, [100, 116, 139, 55], 1);
}
for (let index = 0; index < Math.round(10 + ratio * 26); index++) {
const x = context.randomInt(0, Math.floor(image.width / cell)) * cell;
const y = context.randomInt(0, Math.floor(image.height / cell)) * cell;
fillRect(image, x, y, cell, cell, randomColor(context, 100, 220, Math.round(25 + ratio * 35)));
}
}
function applyScribble(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const scribbles = Math.round(3 + ratio * 6);
for (let index = 0; index < scribbles; index++) {
let x = context.randomInt(0, image.width - 1);
let y = context.randomInt(0, image.height - 1);
const segments = context.randomInt(7, Math.round(12 + ratio * 13));
const color = randomColor(context, 45, 180, Math.round(55 + ratio * 45));
for (let segment = 0; segment < segments; segment++) {
const nextX = clamp(x + context.randomInt(-28, 28), 0, image.width - 1);
const nextY = clamp(y + context.randomInt(-18, 18), 0, image.height - 1);
drawLine(image, x, y, nextX, nextY, color, ratio > 0.7 && segment % 3 === 0 ? 2 : 1);
x = nextX;
y = nextY;
}
}
addDots(image, context, Math.round(80 + ratio * 220), 55);
}
function applyPixel(image: RgbaImage, context: CaptchaGeneratorContext): void {
const ratio = disturbanceRatio(context);
const blocks = Math.round(28 + ratio * 75);
for (let index = 0; index < blocks; index++) {
const size = context.randomInt(2, Math.round(4 + ratio * 5));
fillRect(
image,
context.randomInt(0, image.width - size),
context.randomInt(0, image.height - size),
size,
size,
randomColor(context, 65, 220, Math.round(45 + ratio * 45)),
);
}
const source = cloneImage(image);
for (let index = 0; index < Math.round(4 + ratio * 8); index++) {
const blockWidth = context.randomInt(12, 28);
const blockHeight = context.randomInt(6, 16);
const x = context.randomInt(0, image.width - blockWidth);
const y = context.randomInt(0, image.height - blockHeight);
const shift = context.randomInt(-8, 8);
for (let py = 0; py < blockHeight; py++) {
for (let px = 0; px < blockWidth; px++) {
const sourceX = clamp(x + px, 0, image.width - 1);
const targetX = x + px + shift;
putRawPixel(image, targetX, y + py, rawPixel(source, sourceX, y + py));
}
}
}
}
function applyBrokenLines(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
const ratio = disturbanceRatio(context);
const gaps = Math.round(12 + ratio * 35);
for (let index = 0; index < gaps; index++) {
const horizontal = context.randomFloat() < 0.7;
const width = horizontal ? context.randomInt(6, 24) : context.randomInt(1, 4);
const height = horizontal ? context.randomInt(1, 3) : context.randomInt(7, 18);
fillRect(
image,
context.randomInt(layout.startX - 3, layout.startX + layout.textWidth),
context.randomInt(layout.startY - 3, layout.startY + layout.textHeight),
width,
height,
LIGHT_BACKGROUND,
);
}
for (let index = 0; index < Math.round(12 + ratio * 28); index++) {
const x = context.randomInt(0, image.width - 12);
const y = context.randomInt(0, image.height - 1);
drawLine(image, x, y, x + context.randomInt(4, 18), y + context.randomInt(-2, 2), randomColor(context, 45, 175, 75), 1);
}
addDots(image, context, Math.round(70 + ratio * 180), 60);
}
function applyStyle(
image: RgbaImage,
context: CaptchaGeneratorContext,
layout: TextLayout,
): void {
switch (context.imageStyle) {
case "collision":
applyCollision(image, context);
break;
case "snow":
applySnow(image, context);
break;
case "corrosion":
applyCorrosion(image, context, layout);
break;
case "spiderweb":
applySpiderweb(image, context);
break;
case "cross-shadow":
applyCrossShadow(image, context);
break;
case "split":
applySplit(image, context);
break;
case "split2":
applySplit2(image, context);
break;
case "cut":
applyCut(image, context, layout);
break;
case "darts":
applyDarts(image, context);
break;
case "distortion":
applyDistortion(image, context);
break;
case "stitch":
applyStitch(image, context);
break;
case "striped":
applyStriped(image, context);
break;
case "wave":
applyWave(image, context);
break;
case "grid-noise":
applyGridNoise(image, context);
break;
case "scribble":
applyScribble(image, context);
break;
case "pixel":
applyPixel(image, context);
break;
case "broken-lines":
applyBrokenLines(image, context, layout);
break;
default:
applyClassic(image, context);
}
}
function backgroundFor(style: CaptchaConcreteImageStyle): Rgba {
if (style === "snow" || style === "grid-noise" || style === "stitch") return ALT_BACKGROUND;
return LIGHT_BACKGROUND;
}
export function renderTextChallenge(
text: string,
context: CaptchaGeneratorContext,
): string {
const layout = layoutFor(text, context.imageStyle);
const image = createImage(300, 104, backgroundFor(context.imageStyle));
if (context.imageStyle === "grid-noise") applyGridNoise(image, context);
else if (context.imageStyle === "snow") addDots(image, context, 130, 45, 160, 235, 1);
else addDots(image, context, Math.round(35 + disturbanceRatio(context) * 80), 30);
drawCharacters(image, text, context, layout, context.imageStyle);
applyStyle(image, context, layout);
if (context.imageStyle !== "snow" && context.imageStyle !== "grid-noise") {
const ratio = disturbanceRatio(context);
addDots(image, context, Math.round(25 + ratio * 100), 35, 100, 220);
}
return pngDataUri(image);
}
+36
View File
@@ -0,0 +1,36 @@
export interface CaptchaClientProviderDefinition {
name: "turnstile" | "recaptcha" | "hcaptcha";
scriptUrl: string;
responseField: string;
globalName: string;
}
export const captchaClientProviders: Record<string, CaptchaClientProviderDefinition> = {
turnstile: {
name: "turnstile",
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
responseField: "cf-turnstile-response",
globalName: "turnstile",
},
recaptcha: {
name: "recaptcha",
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
responseField: "g-recaptcha-response",
globalName: "grecaptcha",
},
hcaptcha: {
name: "hcaptcha",
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
responseField: "h-captcha-response",
globalName: "hcaptcha",
},
};
export function getCaptchaResponse(form: HTMLFormElement, field = "wrn-captcha-response"): string {
const value = new FormData(form).get(field);
return typeof value === "string" ? value : "";
}
export function resetCaptchaElement(element: Element): void {
element.dispatchEvent(new CustomEvent("captcha-reset", { bubbles: true }));
}
+55
View File
@@ -0,0 +1,55 @@
const encoder = new TextEncoder();
export function defaultRandomBytes(length: number): Uint8Array {
if (!Number.isInteger(length) || length < 1) throw new RangeError("random byte length must be positive");
const bytes = new Uint8Array(length);
crypto.getRandomValues(bytes);
return bytes;
}
export function bytesToBase64Url(bytes: Uint8Array): string {
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
export function randomId(randomBytes = defaultRandomBytes, length = 24): string {
return bytesToBase64Url(randomBytes(length));
}
function bytesToHex(bytes: Uint8Array): string {
let out = "";
for (const byte of bytes) out += byte.toString(16).padStart(2, "0");
return out;
}
export async function sha256(value: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", encoder.encode(value));
return bytesToHex(new Uint8Array(digest));
}
export async function hmacSha256(secret: string, value: string): Promise<string> {
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(value));
return bytesToHex(new Uint8Array(signature));
}
export function constantTimeEqual(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let difference = 0;
for (let index = 0; index < left.length; index++) {
difference |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return difference === 0;
}
export async function bindingHash(value: string | undefined): Promise<string | undefined> {
if (!value) return undefined;
return sha256(value);
}
+400
View File
@@ -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);
}
+194
View File
@@ -0,0 +1,194 @@
import type { Context } from "@wrnexus/core";
import type {
CaptchaEngine,
CaptchaHttpHandlers,
CreateCaptchaOptions,
VerifyCaptchaInput,
} from "./types.ts";
export interface CaptchaHttpOptions {
allowedOrigins?: string[];
trustProxy?: boolean;
createLimit?: number;
verifyLimit?: number;
windowMs?: number;
}
interface Counter {
count: number;
resetAt: number;
}
function json(body: unknown, status = 200, headers: HeadersInit = {}): Response {
return new Response(JSON.stringify(body), {
status,
headers: {
"content-type": "application/json; charset=utf-8",
"cache-control": "no-store, max-age=0",
pragma: "no-cache",
"x-content-type-options": "nosniff",
...headers,
},
});
}
async function readPayload(request: Request): Promise<Record<string, unknown>> {
const contentType = request.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) return (await request.json()) as Record<string, unknown>;
if (contentType.includes("form")) {
const form = await request.formData();
const payload: Record<string, unknown> = {};
for (const [key, value] of form) {
if (key === "selections") {
const current = payload[key];
payload[key] = Array.isArray(current) ? [...current, String(value)] : [String(value)];
} else payload[key] = typeof value === "string" ? value : value.name;
}
return payload;
}
return {};
}
function clientIp(request: Request, ctx: Context | undefined, trustProxy: boolean): string {
if (trustProxy) {
const forwarded = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
if (forwarded) return forwarded;
const real = request.headers.get("x-real-ip");
if (real) return real;
}
return ctx?.ip ?? "global";
}
function requestBinding(request: Request, ctx: Context | undefined, trustProxy: boolean) {
const url = new URL(request.url);
return {
hostname: url.hostname,
sessionId: ctx?.session.id(),
ip: clientIp(request, ctx, trustProxy),
};
}
export function createCaptchaHttpHandlers(
engine: CaptchaEngine,
options: CaptchaHttpOptions = {},
): CaptchaHttpHandlers {
const counters = new Map<string, Counter>();
const windowMs = options.windowMs ?? 60_000;
const originAllowed = (request: Request): boolean => {
const origin = request.headers.get("origin");
if (!origin) return true;
const own = new URL(request.url).origin;
return origin === own || options.allowedOrigins?.includes(origin) === true;
};
const withinLimit = (key: string, maximum: number): { allowed: boolean; retryAfter: number } => {
const now = Date.now();
let counter = counters.get(key);
if (!counter || counter.resetAt <= now) {
counter = { count: 0, resetAt: now + windowMs };
counters.set(key, counter);
}
counter.count += 1;
return { allowed: counter.count <= maximum, retryAfter: Math.max(1, Math.ceil((counter.resetAt - now) / 1000)) };
};
const create = async (request: Request, ctx?: Context): Promise<Response> => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`create:${ip}`, options.createLimit ?? 30);
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
try {
const payload = await readPayload(request);
const challenge = await engine.create({
...(payload as unknown as CreateCaptchaOptions),
...requestBinding(request, ctx, options.trustProxy ?? false),
});
return json(challenge, 201);
} catch (error) {
return json(
{
success: false,
code: "invalid-input",
message: error instanceof Error ? error.message : "Unable to create CAPTCHA challenge",
},
400,
);
}
};
const verify = async (request: Request, ctx?: Context): Promise<Response> => {
if (!originAllowed(request)) return json({ success: false, code: "origin-rejected" }, 403);
if (request.method !== "POST") return json({ success: false, code: "method-not-allowed" }, 405, { allow: "POST" });
const ip = clientIp(request, ctx, options.trustProxy ?? false);
const limit = withinLimit(`verify:${ip}`, options.verifyLimit ?? 60);
if (!limit.allowed) return json({ success: false, code: "rate-limited" }, 429, { "retry-after": String(limit.retryAfter) });
try {
const payload = await readPayload(request);
const result = await engine.verify({
...(payload as unknown as VerifyCaptchaInput),
...requestBinding(request, ctx, options.trustProxy ?? false),
});
return json(result, result.success ? 200 : 400);
} catch (error) {
return json(
{
success: false,
code: "invalid-input",
message: error instanceof Error ? error.message : "Unable to verify CAPTCHA",
},
400,
);
}
};
const audio = async (request: Request): Promise<Response> => {
if (!originAllowed(request)) return new Response("Forbidden", { status: 403 });
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
const url = new URL(request.url);
const prefix = `${engine.basePath}/audio/`;
const id = decodeURIComponent(url.pathname.slice(prefix.length));
const key = url.searchParams.get("key") ?? "";
try {
const audio = await engine.renderAudio(id, key);
if (!audio) return new Response("Not Found", { status: 404 });
const body: BodyInit | null =
request.method === "HEAD" ? null : Uint8Array.from(audio.bytes).buffer;
return new Response(body, {
headers: {
"content-type": audio.contentType,
"content-length": String(audio.bytes.byteLength),
"cache-control": "private, no-store, max-age=0",
"x-content-type-options": "nosniff",
},
});
} catch (error) {
console.error(
"[wrnexus/captcha] Unable to render CAPTCHA audio:",
error instanceof Error ? error.message : error,
);
return new Response("CAPTCHA audio is unavailable", {
status: 503,
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store, max-age=0",
"x-content-type-options": "nosniff",
},
});
}
};
return {
create,
verify,
audio,
async handle(request, ctx) {
const pathname = new URL(request.url).pathname;
if (pathname === `${engine.basePath}/challenge`) return create(request, ctx);
if (pathname === `${engine.basePath}/verify`) return verify(request, ctx);
if (pathname.startsWith(`${engine.basePath}/audio/`)) return audio(request);
return undefined;
},
};
}
+14
View File
@@ -0,0 +1,14 @@
export * from "./types.ts";
export * from "./engine.ts";
export * from "./http.ts";
export * from "./middleware.ts";
export * from "./policy.ts";
export * from "./validation.ts";
export * from "./plugin.ts";
export * from "./stores/memory.ts";
export * from "./stores/sqlite.ts";
export * from "./stores/redis.ts";
export * from "./providers/index.ts";
export * from "./challenges/index.ts";
export * from "./audio/index.ts";
export * from "./crypto.ts";
+129
View File
@@ -0,0 +1,129 @@
import type { Context } from "@wrnexus/core";
import { shouldRequireCaptcha, validCaptchaGrant, type CaptchaSessionGrant } from "./policy.ts";
import { selfHostedProvider } from "./providers/self-hosted.ts";
import type {
CaptchaGuardOptions,
CaptchaPageGateOptions,
CaptchaProvider,
CaptchaVerificationResult,
} from "./types.ts";
const DEFAULT_FIELD = "wrn-captcha-response";
function resolveAction(value: CaptchaGuardOptions["action"], ctx: Context): string {
return typeof value === "function" ? value(ctx) : value;
}
async function bodyValue(request: Request, field: string): Promise<string | undefined> {
const header = request.headers.get("x-wrn-captcha-token");
if (header) return header;
if (request.method === "GET" || request.method === "HEAD") return undefined;
const clone = request.clone();
const contentType = clone.headers.get("content-type") ?? "";
try {
if (contentType.includes("application/json")) {
const body = (await clone.json()) as Record<string, unknown>;
const value = body[field] ?? body.captchaToken ?? body.responseToken;
return value === undefined ? undefined : String(value);
}
if (contentType.includes("form")) {
const form = await clone.formData();
const value = form.get(field);
return typeof value === "string" ? value : undefined;
}
} catch {
return undefined;
}
return undefined;
}
function providerFor(options: CaptchaGuardOptions): CaptchaProvider {
if (options.provider) return options.provider;
if (options.engine) return selfHostedProvider(options.engine);
throw new TypeError("captchaGuard requires provider or engine");
}
async function verifyRequest(
ctx: Context,
options: CaptchaGuardOptions,
): Promise<CaptchaVerificationResult> {
const provider = providerFor(options);
const action = resolveAction(options.action, ctx);
const field = options.responseField ?? provider.client.responseField ?? DEFAULT_FIELD;
const token = await bodyValue(ctx.req, field);
return provider.verify({
action,
providerToken: token,
responseToken: token,
hostname: options.bindHostname === false ? undefined : ctx.url.hostname,
sessionId: options.bindSession === false ? undefined : ctx.session.id(),
ip: options.bindIp ? ctx.ip : undefined,
});
}
function defaultFailure(options: CaptchaGuardOptions, result: CaptchaVerificationResult): Response {
return new Response(options.failureMessage ?? result.message ?? "CAPTCHA verification failed", {
status: options.failureStatus ?? 403,
headers: {
"content-type": "text/plain; charset=utf-8",
"cache-control": "no-store",
"x-wrn-captcha-error": result.code ?? "verification-failed",
},
});
}
export function captchaGuard(options: CaptchaGuardOptions) {
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (!result.success) return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
return next();
};
}
export function captchaPageGate(options: CaptchaPageGateOptions) {
const sessionKey = options.sessionKey ?? "wrnexus.captcha.grants";
const returnToParam = options.returnToParam ?? "returnTo";
const challengePath = options.challengePath ?? "/captcha";
const policy = options.policy ?? { mode: "session" as const };
return async (ctx: Context, next: () => Promise<Response> | Response): Promise<Response> => {
const action = resolveAction(options.action, ctx);
const now = Date.now();
const routeGroup = policy.routeGroups?.find((group) => ctx.url.pathname.startsWith(group));
const grants = ctx.session.get<CaptchaSessionGrant[]>(sessionKey) ?? [];
if (validCaptchaGrant(grants, action, now, routeGroup)) return next();
const signals = await options.signals?.(ctx) ?? {};
const decision = shouldRequireCaptcha(action, policy, signals);
ctx.locals.captchaRisk = decision;
if (!decision.challenge) return next();
const token = await bodyValue(ctx.req, options.responseField ?? DEFAULT_FIELD);
if (token) {
const result = await verifyRequest(ctx, options);
ctx.locals.captcha = result;
if (result.success) {
const grant: CaptchaSessionGrant = {
action,
routeGroup,
provider: result.provider,
expiresAt: now + (policy.verifiedForMs ?? 15 * 60_000),
};
ctx.session.set(sessionKey, [...grants.filter((item) => item.expiresAt > now), grant]);
return next();
}
if (ctx.req.method !== "GET" && ctx.req.method !== "HEAD") {
return options.onFailure ? options.onFailure(ctx, result) : defaultFailure(options, result);
}
}
const redirect = new URL(challengePath, ctx.url);
redirect.searchParams.set(returnToParam, `${ctx.url.pathname}${ctx.url.search}`);
redirect.searchParams.set("action", action);
return Response.redirect(redirect, 302);
};
}
export function clearCaptchaGrants(ctx: Context, sessionKey = "wrnexus.captcha.grants"): void {
ctx.session.delete(sessionKey);
}
+25
View File
@@ -0,0 +1,25 @@
import type { CaptchaChallengeRecord, VerifyCaptchaInput } from "./types.ts";
export function normalizeTextAnswer(value: unknown, caseSensitive: boolean): string {
const normalized = String(value ?? "")
.normalize("NFKC")
.trim()
.replace(/\s+/g, " ");
return caseSensitive ? normalized : normalized.toUpperCase();
}
export function normalizeSelections(values: unknown): string {
if (!Array.isArray(values)) return "";
return [...new Set(values.map((value) => String(value).trim()).filter(Boolean))].sort().join(",");
}
export function normalizedSubmittedAnswer(record: CaptchaChallengeRecord, input: VerifyCaptchaInput): string {
if (record.answerKind === "selections") return normalizeSelections(input.selections);
if (record.answerKind === "invisible") {
return JSON.stringify({
honeypot: String(input.honeypot ?? ""),
timingToken: String(input.timingToken ?? ""),
});
}
return normalizeTextAnswer(input.answer, record.caseSensitive);
}
+124
View File
@@ -0,0 +1,124 @@
import { copyFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { definePlugin } from "@wrnexus/plugin";
import { CAPTCHA_IMAGE_STYLES } from "./challenges/styles.ts";
export interface CaptchaPluginOptions {
componentDir?: string;
exposeComponentDirectory?: boolean;
enableDevToolbar?: boolean;
auditExternalProviders?: boolean;
}
export interface CaptchaAuditIssue {
id: string;
severity: "error" | "warning" | "suggestion";
title: string;
message: string;
file: string;
}
const packageRoot = dirname(dirname(fileURLToPath(import.meta.url)));
const captchaClientRuntime = join(packageRoot, "assets", "client", "captcha.js");
async function copyCaptchaClientRuntime(destinationDir: string): Promise<void> {
await mkdir(destinationDir, { recursive: true });
await copyFile(captchaClientRuntime, join(destinationDir, "captcha.js"));
}
async function installCaptchaClientRuntime(root: string, includeBuildOutput: boolean): Promise<void> {
await copyCaptchaClientRuntime(join(root, "public", "assets", "wrnexus"));
if (includeBuildOutput) {
await copyCaptchaClientRuntime(join(root, "dist", "public", "assets", "wrnexus"));
}
}
export function captchaComponentsDir(): string {
return join(packageRoot, "components");
}
function auditCaptchaSource(code: string, file: string, external: boolean): CaptchaAuditIssue[] {
if (!code.includes("<Captcha") && !code.includes('data-component="Captcha"')) return [];
const issues: CaptchaAuditIssue[] = [];
const push = (id: string, severity: CaptchaAuditIssue["severity"], title: string, message: string) =>
issues.push({ id: `${id}:${file}`, severity, title, message, file });
if (/secret(Key)?\s*=|providerSecret\s*=|captchaSecret\s*=/i.test(code)) {
push("client-secret", "error", "CAPTCHA secret exposed", "Never pass a provider secret or verification secret to a .wrn component.");
}
if (!/action\s*=/.test(code)) {
push("missing-action", "warning", "CAPTCHA action is missing", "Bind each challenge to a stable action such as signup, login, or contact-submit.");
}
if (/required\s*=\s*["']?false/i.test(code)) {
push("optional-captcha", "warning", "CAPTCHA is optional", "Protected forms should require a CAPTCHA response and verify it on the server.");
}
if (!/showAudio\s*=|presentation\s*=\s*["']audio/i.test(code)) {
push("audio-alternative", "suggestion", "Confirm an accessible alternative", "Visual challenges should offer an audio or non-visual alternative.");
}
const imageStyle = code.match(/imageStyle\s*=\s*["']([^"']+)["']/i)?.[1]?.trim().toLowerCase();
if (imageStyle && !CAPTCHA_IMAGE_STYLES.includes(imageStyle as (typeof CAPTCHA_IMAGE_STYLES)[number])) {
push(
"unknown-image-style",
"error",
"Unknown CAPTCHA image style",
`Use one of: ${CAPTCHA_IMAGE_STYLES.join(", ")}.`,
);
}
const disturbance = Number(code.match(/disturbance\s*=\s*["']?(\d+)/i)?.[1] ?? "");
if (Number.isFinite(disturbance) && disturbance >= 65 && /showAudio\s*=\s*["']?false/i.test(code)) {
push(
"hard-without-audio",
"warning",
"Hard CAPTCHA has no audio alternative",
"High disturbance should include audio or another non-visual challenge path.",
);
}
if (external && /provider\s*=\s*["'](?:turnstile|recaptcha|hcaptcha)/i.test(code) && !/siteKey\s*=/.test(code)) {
push("missing-site-key", "error", "Provider site key is missing", "External CAPTCHA providers require a public site key in the browser.");
}
push("server-verification", "suggestion", "Server verification required", "Confirm the receiving API uses captchaGuard(), parseWithCaptcha(), or provider.verify().");
return issues;
}
export function captchaPlugin(options: CaptchaPluginOptions = {}) {
const metadataKey = "@wrnexus/captcha:audit";
return definePlugin({
name: "@wrnexus/captcha",
version: "0.3.6",
enforce: "post",
async configure(config, context) {
const current = (config.captcha ?? {}) as Record<string, unknown>;
config.captcha = {
componentDir: options.componentDir ?? captchaComponentsDir(),
...current,
};
context.metadata.set("@wrnexus/captcha:component-dir", options.componentDir ?? captchaComponentsDir());
await installCaptchaClientRuntime(context.root, context.command === "build");
},
transformCode(code, context) {
if (context.mode !== "development") return;
const previous = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
const withoutFile = previous.filter((issue) => issue.file !== context.file);
context.metadata.set(
metadataKey,
[...withoutFile, ...auditCaptchaSource(code, context.file, options.auditExternalProviders ?? true)],
);
},
devToolbarPanels(context) {
if (options.enableDevToolbar === false) return [];
const issues = (context.metadata.get(metadataKey) as CaptchaAuditIssue[] | undefined) ?? [];
return [{
id: "wrnexus-captcha",
title: "CAPTCHA",
icon: "shield-check",
badge: issues.length,
description: "CAPTCHA security, accessibility, and integration checks",
issues,
}];
},
});
}
export default captchaPlugin;
+63
View File
@@ -0,0 +1,63 @@
import type {
CaptchaPolicyOptions,
CaptchaRiskResult,
CaptchaRiskSignals,
} from "./types.ts";
export function evaluateCaptchaRisk(
signals: CaptchaRiskSignals,
threshold = 50,
): CaptchaRiskResult {
let score = Math.max(0, Math.min(100, signals.customScore ?? 0));
const reasons: string[] = [];
const add = (points: number, reason: string): void => {
score = Math.min(100, score + points);
reasons.push(reason);
};
if ((signals.failedAttempts ?? 0) > 0) add(Math.min(35, (signals.failedAttempts ?? 0) * 10), "failed-attempts");
if ((signals.requestsInWindow ?? 0) > 20) add(Math.min(35, ((signals.requestsInWindow ?? 0) - 20) * 2), "request-rate");
if (signals.completionMs !== undefined && signals.completionMs < 700) add(25, "too-fast");
if (signals.missingBrowserSignals) add(20, "missing-browser-signals");
if (signals.suspiciousHeaders) add(20, "suspicious-headers");
if (signals.tokenReuse) add(70, "token-reuse");
if (signals.knownBadIp) add(60, "known-bad-ip");
return { score, challenge: score >= threshold, reasons };
}
export function shouldRequireCaptcha(
action: string,
options: CaptchaPolicyOptions = {},
signals: CaptchaRiskSignals = {},
): CaptchaRiskResult {
if (options.neverForActions?.includes(action) || options.mode === "never") {
return { score: 0, challenge: false, reasons: ["policy-never"] };
}
if (options.alwaysForActions?.includes(action) || options.mode === "always") {
return { score: 100, challenge: true, reasons: ["policy-always"] };
}
if ((options.mode ?? "adaptive") === "session") {
return { score: 100, challenge: true, reasons: ["session-unverified"] };
}
return evaluateCaptchaRisk(signals, options.threshold ?? 50);
}
export interface CaptchaSessionGrant {
action: string;
routeGroup?: string;
expiresAt: number;
provider: string;
}
export function validCaptchaGrant(
grants: CaptchaSessionGrant[] | undefined,
action: string,
now: number,
routeGroup?: string,
): CaptchaSessionGrant | undefined {
return grants?.find(
(grant) =>
grant.expiresAt > now &&
(grant.action === action || grant.action === "*") &&
(!routeGroup || !grant.routeGroup || grant.routeGroup === routeGroup),
);
}
+8
View File
@@ -0,0 +1,8 @@
import type { CaptchaProvider } from "../types.ts";
export function defineCaptchaProvider<T extends CaptchaProvider>(provider: T): T {
if (!provider.name) throw new TypeError("Custom CAPTCHA provider requires a stable name");
if (!provider.client?.responseField) throw new TypeError("Custom CAPTCHA provider requires client.responseField");
if (typeof provider.verify !== "function") throw new TypeError("Custom CAPTCHA provider requires verify()");
return provider;
}
@@ -0,0 +1,24 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class HcaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "hcaptcha",
endpoint: "https://api.hcaptcha.com/siteverify",
client: {
responseField: "h-captcha-response",
scriptUrl: "https://js.hcaptcha.com/1/api.js?render=explicit",
widgetClass: "h-captcha",
},
sendSiteKey: true,
scoreDirection: "higher-is-risk",
},
options,
);
}
}
export function hcaptchaProvider(options: SiteverifyProviderOptions): HcaptchaProvider {
return new HcaptchaProvider(options);
}
+7
View File
@@ -0,0 +1,7 @@
export * from "./self-hosted.ts";
export * from "./siteverify.ts";
export * from "./turnstile.ts";
export * from "./recaptcha.ts";
export * from "./hcaptcha.ts";
export * from "./managed.ts";
export * from "./custom.ts";
+69
View File
@@ -0,0 +1,69 @@
import type {
CaptchaChallenge,
CaptchaProvider,
CaptchaVerificationResult,
CreateCaptchaOptions,
VerifyCaptchaInput,
} from "../types.ts";
export interface ManagedCaptchaProviderOptions {
baseUrl: string;
siteKey: string;
secretKey: string;
timeoutMs?: number;
fetch?: typeof fetch;
}
export class ManagedCaptchaProvider implements CaptchaProvider {
readonly name = "wrnexus-managed" as const;
readonly client;
private readonly baseUrl: string;
constructor(private readonly options: ManagedCaptchaProviderOptions) {
if (!options.baseUrl) throw new TypeError("Managed CAPTCHA baseUrl is required");
if (!options.siteKey) throw new TypeError("Managed CAPTCHA siteKey is required");
if (!options.secretKey) throw new TypeError("Managed CAPTCHA secretKey is required");
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
this.client = {
responseField: "wrn-captcha-response",
siteKey: options.siteKey,
managedCreateUrl: `${this.baseUrl}/v1/challenges`,
};
}
async createChallenge(options: CreateCaptchaOptions): Promise<CaptchaChallenge> {
return this.request<CaptchaChallenge>("/v1/challenges", {
siteKey: this.options.siteKey,
...options,
}, false);
}
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
return this.request<CaptchaVerificationResult>("/v1/verify", input, true);
}
private async request<T>(path: string, payload: unknown, secret: boolean): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
try {
const response = await (this.options.fetch ?? fetch)(`${this.baseUrl}${path}`, {
method: "POST",
headers: {
"content-type": "application/json",
accept: "application/json",
...(secret ? { authorization: `Bearer ${this.options.secretKey}` } : {}),
},
body: JSON.stringify(payload),
signal: controller.signal,
});
if (!response.ok) throw new Error(`Managed CAPTCHA returned HTTP ${response.status}`);
return (await response.json()) as T;
} finally {
clearTimeout(timeout);
}
}
}
export function managedCaptchaProvider(options: ManagedCaptchaProviderOptions): ManagedCaptchaProvider {
return new ManagedCaptchaProvider(options);
}
@@ -0,0 +1,23 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class RecaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "recaptcha",
endpoint: "https://www.google.com/recaptcha/api/siteverify",
client: {
responseField: "g-recaptcha-response",
scriptUrl: "https://www.google.com/recaptcha/api.js?render=explicit",
widgetClass: "g-recaptcha",
},
scoreDirection: "higher-is-human",
},
options,
);
}
}
export function recaptchaProvider(options: SiteverifyProviderOptions): RecaptchaProvider {
return new RecaptchaProvider(options);
}
@@ -0,0 +1,32 @@
import type {
CaptchaEngine,
CaptchaProvider,
CreateCaptchaOptions,
VerifyCaptchaInput,
} from "../types.ts";
export class SelfHostedCaptchaProvider implements CaptchaProvider {
readonly name = "self-hosted" as const;
readonly client;
constructor(readonly engine: CaptchaEngine) {
this.client = {
responseField: "wrn-captcha-response",
managedCreateUrl: `${engine.basePath}/challenge`,
};
}
createChallenge(options: CreateCaptchaOptions) {
return this.engine.create(options);
}
verify(input: VerifyCaptchaInput) {
return input.responseToken || input.providerToken
? this.engine.verifyResponseToken({ ...input, responseToken: input.responseToken ?? input.providerToken })
: this.engine.verify(input);
}
}
export function selfHostedProvider(engine: CaptchaEngine): SelfHostedCaptchaProvider {
return new SelfHostedCaptchaProvider(engine);
}
@@ -0,0 +1,147 @@
import type {
CaptchaProvider,
CaptchaProviderClientConfig,
CaptchaProviderName,
CaptchaVerificationResult,
VerifyCaptchaInput,
} from "../types.ts";
export interface SiteverifyProviderOptions {
secretKey: string;
siteKey?: string;
expectedHostnames?: string[];
expectedAction?: string;
minScore?: number;
timeoutMs?: number;
fetch?: typeof fetch;
}
export interface SiteverifyPreset {
name: CaptchaProviderName;
endpoint: string;
client: CaptchaProviderClientConfig;
sendSiteKey?: boolean;
scoreDirection?: "higher-is-human" | "higher-is-risk";
}
interface SiteverifyPayload {
success?: boolean;
hostname?: string;
action?: string;
score?: number;
challenge_ts?: string;
"error-codes"?: string[];
error_codes?: string[];
[key: string]: unknown;
}
function providerFailure(
name: CaptchaProviderName,
action: string,
code: string,
message: string,
metadata?: Record<string, unknown>,
): CaptchaVerificationResult {
return { success: false, provider: name, action, code, message, metadata };
}
export class SiteverifyCaptchaProvider implements CaptchaProvider {
readonly name: CaptchaProviderName;
readonly client: CaptchaProviderClientConfig;
constructor(
private readonly preset: SiteverifyPreset,
private readonly options: SiteverifyProviderOptions,
) {
if (!options.secretKey) throw new TypeError(`${preset.name} secretKey is required`);
this.name = preset.name;
this.client = { ...preset.client, siteKey: options.siteKey };
}
async verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult> {
const token = input.providerToken ?? input.responseToken;
const action = input.action;
if (!token) return providerFailure(this.name, action, "missing-input", "Missing provider response token");
if (token.length > 4096) return providerFailure(this.name, action, "invalid-input", "Provider token is too long");
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), this.options.timeoutMs ?? 10_000);
const body = new URLSearchParams({ secret: this.options.secretKey, response: token });
if (input.ip) body.set("remoteip", input.ip);
if (this.preset.sendSiteKey && this.options.siteKey) body.set("sitekey", this.options.siteKey);
try {
const response = await (this.options.fetch ?? fetch)(this.preset.endpoint, {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body,
signal: controller.signal,
});
if (!response.ok) {
return providerFailure(this.name, action, "provider-error", `${this.name} verification returned HTTP ${response.status}`);
}
const data = (await response.json()) as SiteverifyPayload;
if (!data.success) {
const codes = data["error-codes"] ?? data.error_codes ?? [];
return providerFailure(
this.name,
action,
codes.includes("timeout-or-duplicate") || codes.includes("already-seen-response")
? "already-used"
: codes.includes("expired-input-response")
? "expired"
: "invalid-input",
"Provider verification failed",
{ errorCodes: codes },
);
}
const expectedAction = this.options.expectedAction ?? action;
if (data.action && expectedAction && data.action !== expectedAction) {
return providerFailure(this.name, action, "action-mismatch", "Provider action does not match", {
receivedAction: data.action,
});
}
if (
this.options.expectedHostnames?.length &&
(!data.hostname || !this.options.expectedHostnames.includes(data.hostname))
) {
return providerFailure(this.name, action, "hostname-mismatch", "Provider hostname does not match", {
hostname: data.hostname,
});
}
if (this.options.minScore !== undefined && typeof data.score === "number") {
const rejected = this.preset.scoreDirection === "higher-is-risk"
? data.score >= this.options.minScore
: data.score < this.options.minScore;
if (rejected) {
return providerFailure(this.name, action, "risk-rejected", "Provider risk score did not pass", {
score: data.score,
});
}
}
return {
success: true,
provider: this.name,
action,
score: data.score,
hostname: data.hostname,
metadata: {
challengeTimestamp: data.challenge_ts,
raw: data,
},
};
} catch (error) {
return providerFailure(
this.name,
action,
error instanceof DOMException && error.name === "AbortError" ? "network-error" : "provider-error",
error instanceof DOMException && error.name === "AbortError"
? `${this.name} verification timed out`
: `${this.name} verification failed`,
);
} finally {
clearTimeout(timeout);
}
}
}
@@ -0,0 +1,23 @@
import { SiteverifyCaptchaProvider, type SiteverifyProviderOptions } from "./siteverify.ts";
export class TurnstileCaptchaProvider extends SiteverifyCaptchaProvider {
constructor(options: SiteverifyProviderOptions) {
super(
{
name: "turnstile",
endpoint: "https://challenges.cloudflare.com/turnstile/v0/siteverify",
client: {
responseField: "cf-turnstile-response",
scriptUrl: "https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit",
widgetClass: "cf-turnstile",
},
scoreDirection: "higher-is-human",
},
options,
);
}
}
export function turnstileProvider(options: SiteverifyProviderOptions): TurnstileCaptchaProvider {
return new TurnstileCaptchaProvider(options);
}
+12
View File
@@ -0,0 +1,12 @@
export * from "../types.ts";
export * from "../engine.ts";
export * from "../http.ts";
export * from "../middleware.ts";
export * from "../policy.ts";
export * from "../validation.ts";
export * from "../stores/memory.ts";
export * from "../stores/sqlite.ts";
export * from "../stores/redis.ts";
export * from "../providers/index.ts";
export * from "../audio/index.ts";
export * from "../crypto.ts";
+107
View File
@@ -0,0 +1,107 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
function cloneChallenge(record: CaptchaChallengeRecord): CaptchaChallengeRecord {
return structuredClone(record);
}
function cloneToken(record: CaptchaResponseTokenRecord): CaptchaResponseTokenRecord {
return structuredClone(record);
}
export interface MemoryCaptchaStoreOptions {
maxChallenges?: number;
maxTokens?: number;
}
export class MemoryCaptchaStore implements CaptchaStore {
private readonly challenges = new Map<string, CaptchaChallengeRecord>();
private readonly tokens = new Map<string, CaptchaResponseTokenRecord>();
private readonly maxChallenges: number;
private readonly maxTokens: number;
constructor(options: MemoryCaptchaStoreOptions = {}) {
this.maxChallenges = options.maxChallenges ?? 25_000;
this.maxTokens = options.maxTokens ?? 50_000;
if (!Number.isInteger(this.maxChallenges) || this.maxChallenges < 1) {
throw new RangeError("maxChallenges must be a positive integer");
}
if (!Number.isInteger(this.maxTokens) || this.maxTokens < 1) {
throw new RangeError("maxTokens must be a positive integer");
}
}
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
this.evict(this.challenges, this.maxChallenges, record.createdAt);
this.challenges.set(record.id, cloneChallenge(record));
}
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
const record = this.challenges.get(id);
return record ? cloneChallenge(record) : undefined;
}
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
const record = this.challenges.get(id);
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
record.attempts += 1;
return cloneChallenge(record);
}
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
const record = this.challenges.get(id);
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
record.consumedAt = now;
return cloneChallenge(record);
}
async deleteChallenge(id: string): Promise<void> {
this.challenges.delete(id);
}
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
this.evict(this.tokens, this.maxTokens, record.createdAt);
this.tokens.set(record.tokenHash, cloneToken(record));
}
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
const record = this.tokens.get(tokenHash);
return record ? cloneToken(record) : undefined;
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
const record = this.tokens.get(tokenHash);
if (!record || record.expiresAt <= now || record.consumedAt) return undefined;
record.consumedAt = now;
return cloneToken(record);
}
async deleteToken(tokenHash: string): Promise<void> {
this.tokens.delete(tokenHash);
}
async gc(now: number): Promise<void> {
for (const [id, record] of this.challenges) {
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
this.challenges.delete(id);
}
}
for (const [hash, record] of this.tokens) {
if (record.expiresAt <= now || (record.consumedAt && record.consumedAt + 60_000 <= now)) {
this.tokens.delete(hash);
}
}
}
private evict<T extends { expiresAt: number }>(map: Map<string, T>, max: number, now: number): void {
for (const [key, record] of map) if (record.expiresAt <= now) map.delete(key);
while (map.size >= max) map.delete(map.keys().next().value!);
}
}
export function createMemoryCaptchaStore(options?: MemoryCaptchaStoreOptions): MemoryCaptchaStore {
return new MemoryCaptchaStore(options);
}
+182
View File
@@ -0,0 +1,182 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
export interface RedisCaptchaClient {
get(key: string): Promise<string | null> | string | null;
set(
key: string,
value: string,
options?: { px?: number; nx?: boolean },
): Promise<unknown> | unknown;
del(key: string): Promise<number> | number;
eval?(
script: string,
options: { keys: string[]; arguments: string[] },
): Promise<unknown> | unknown;
scanIterator?(options?: { match?: string; count?: number }): AsyncIterable<string>;
}
export interface RedisCaptchaStoreOptions {
prefix?: string;
}
const MUTATE_CHALLENGE = `
local raw = redis.call('GET', KEYS[1])
if not raw then return nil end
local value = cjson.decode(raw)
local now = tonumber(ARGV[1])
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
if ARGV[2] == 'attempt' then
value.attempts = tonumber(value.attempts) + 1
else
value.consumedAt = now
end
local encoded = cjson.encode(value)
local ttl = math.max(1, tonumber(value.expiresAt) - now)
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
return encoded
`;
const CONSUME_TOKEN = `
local raw = redis.call('GET', KEYS[1])
if not raw then return nil end
local value = cjson.decode(raw)
local now = tonumber(ARGV[1])
if tonumber(value.expiresAt) <= now or value.consumedAt ~= nil then return nil end
value.consumedAt = now
local encoded = cjson.encode(value)
local ttl = math.max(1, tonumber(value.expiresAt) - now)
redis.call('SET', KEYS[1], encoded, 'PX', ttl)
return encoded
`;
export class RedisCaptchaStore implements CaptchaStore {
private readonly prefix: string;
private readonly locks = new Map<string, Promise<void>>();
constructor(
private readonly redis: RedisCaptchaClient,
options: RedisCaptchaStoreOptions = {},
) {
this.prefix = options.prefix ?? "wrn:captcha:";
}
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
const ttl = Math.max(1, record.expiresAt - Date.now());
await this.redis.set(this.challengeKey(record.id), JSON.stringify(record), { px: ttl });
}
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
return this.read<CaptchaChallengeRecord>(this.challengeKey(id));
}
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
return this.mutateChallenge(id, now, "attempt");
}
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
return this.mutateChallenge(id, now, "consume");
}
async deleteChallenge(id: string): Promise<void> {
await this.redis.del(this.challengeKey(id));
}
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
const ttl = Math.max(1, record.expiresAt - Date.now());
await this.redis.set(this.tokenKey(record.tokenHash), JSON.stringify(record), { px: ttl });
}
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
return this.read<CaptchaResponseTokenRecord>(this.tokenKey(tokenHash));
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
const key = this.tokenKey(tokenHash);
if (this.redis.eval) {
const raw = await this.redis.eval(CONSUME_TOKEN, {
keys: [key],
arguments: [String(now)],
});
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaResponseTokenRecord) : undefined;
}
return this.withLock(key, async () => {
const current = await this.read<CaptchaResponseTokenRecord>(key);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
return current;
});
}
async deleteToken(tokenHash: string): Promise<void> {
await this.redis.del(this.tokenKey(tokenHash));
}
async gc(): Promise<void> {
// Redis TTLs are authoritative, so no explicit sweep is required.
}
private async mutateChallenge(
id: string,
now: number,
operation: "attempt" | "consume",
): Promise<CaptchaChallengeRecord | undefined> {
const key = this.challengeKey(id);
if (this.redis.eval) {
const raw = await this.redis.eval(MUTATE_CHALLENGE, {
keys: [key],
arguments: [String(now), operation],
});
return typeof raw === "string" ? (JSON.parse(raw) as CaptchaChallengeRecord) : undefined;
}
return this.withLock(key, async () => {
const current = await this.read<CaptchaChallengeRecord>(key);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
if (operation === "attempt") current.attempts += 1;
else current.consumedAt = now;
await this.redis.set(key, JSON.stringify(current), { px: Math.max(1, current.expiresAt - now) });
return current;
});
}
private async read<T>(key: string): Promise<T | undefined> {
const value = await this.redis.get(key);
return value ? (JSON.parse(value) as T) : undefined;
}
private challengeKey(id: string): string {
return `${this.prefix}challenge:${id}`;
}
private tokenKey(hash: string): string {
return `${this.prefix}token:${hash}`;
}
private async withLock<T>(key: string, task: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) ?? Promise.resolve();
let release!: () => void;
const current = new Promise<void>((resolve) => {
release = resolve;
});
const chain = previous.then(() => current);
this.locks.set(key, chain);
await previous;
try {
return await task();
} finally {
release();
if (this.locks.get(key) === chain) this.locks.delete(key);
}
}
}
export function createRedisCaptchaStore(
redis: RedisCaptchaClient,
options?: RedisCaptchaStoreOptions,
): RedisCaptchaStore {
return new RedisCaptchaStore(redis, options);
}
+170
View File
@@ -0,0 +1,170 @@
import type {
CaptchaChallengeRecord,
CaptchaResponseTokenRecord,
CaptchaStore,
} from "../types.ts";
export interface SqliteStatementLike {
run(...params: unknown[]): unknown;
get(...params: unknown[]): Record<string, unknown> | undefined;
}
export interface SqliteDatabaseLike {
exec(sql: string): unknown;
prepare(sql: string): SqliteStatementLike;
}
export interface SqliteCaptchaStoreOptions {
challengeTable?: string;
tokenTable?: string;
initialize?: boolean;
}
function safeIdentifier(value: string): string {
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) throw new TypeError(`Unsafe SQL identifier: ${value}`);
return value;
}
function parseChallenge(row: Record<string, unknown> | undefined): CaptchaChallengeRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaChallengeRecord;
}
function parseToken(row: Record<string, unknown> | undefined): CaptchaResponseTokenRecord | undefined {
if (!row) return undefined;
return JSON.parse(String(row.payload)) as CaptchaResponseTokenRecord;
}
export class SqliteCaptchaStore implements CaptchaStore {
private readonly challengeTable: string;
private readonly tokenTable: string;
constructor(
private readonly db: SqliteDatabaseLike,
options: SqliteCaptchaStoreOptions = {},
) {
this.challengeTable = safeIdentifier(options.challengeTable ?? "wrn_captcha_challenges");
this.tokenTable = safeIdentifier(options.tokenTable ?? "wrn_captcha_tokens");
if (options.initialize ?? true) this.initialize();
}
initialize(): void {
this.db.exec(`
CREATE TABLE IF NOT EXISTS ${this.challengeTable} (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER,
attempts INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS ${this.challengeTable}_expires_idx
ON ${this.challengeTable}(expires_at);
CREATE TABLE IF NOT EXISTS ${this.tokenTable} (
token_hash TEXT PRIMARY KEY,
payload TEXT NOT NULL,
expires_at INTEGER NOT NULL,
consumed_at INTEGER
);
CREATE INDEX IF NOT EXISTS ${this.tokenTable}_expires_idx
ON ${this.tokenTable}(expires_at);
`);
}
async createChallenge(record: CaptchaChallengeRecord): Promise<void> {
this.db
.prepare(
`INSERT OR REPLACE INTO ${this.challengeTable}
(id, payload, expires_at, consumed_at, attempts)
VALUES (?, ?, ?, ?, ?)`,
)
.run(record.id, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null, record.attempts);
}
async getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined> {
return parseChallenge(
this.db.prepare(`SELECT payload FROM ${this.challengeTable} WHERE id = ?`).get(id),
);
}
async incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
const current = await this.getChallenge(id);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.attempts += 1;
const result = this.db
.prepare(
`UPDATE ${this.challengeTable}
SET payload = ?, attempts = ?
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL AND attempts = ?`,
)
.run(JSON.stringify(current), current.attempts, id, now, current.attempts - 1) as { changes?: number };
return result?.changes === 0 ? undefined : current;
}
async consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined> {
const current = await this.getChallenge(id);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
const result = this.db
.prepare(
`UPDATE ${this.challengeTable}
SET payload = ?, consumed_at = ?
WHERE id = ? AND expires_at > ? AND consumed_at IS NULL`,
)
.run(JSON.stringify(current), now, id, now) as { changes?: number };
return result?.changes === 0 ? undefined : current;
}
async deleteChallenge(id: string): Promise<void> {
this.db.prepare(`DELETE FROM ${this.challengeTable} WHERE id = ?`).run(id);
}
async createToken(record: CaptchaResponseTokenRecord): Promise<void> {
this.db
.prepare(
`INSERT OR REPLACE INTO ${this.tokenTable}
(token_hash, payload, expires_at, consumed_at)
VALUES (?, ?, ?, ?)`,
)
.run(record.tokenHash, JSON.stringify(record), record.expiresAt, record.consumedAt ?? null);
}
async getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined> {
return parseToken(
this.db.prepare(`SELECT payload FROM ${this.tokenTable} WHERE token_hash = ?`).get(tokenHash),
);
}
async consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined> {
const current = await this.getToken(tokenHash);
if (!current || current.expiresAt <= now || current.consumedAt) return undefined;
current.consumedAt = now;
const result = this.db
.prepare(
`UPDATE ${this.tokenTable}
SET payload = ?, consumed_at = ?
WHERE token_hash = ? AND expires_at > ? AND consumed_at IS NULL`,
)
.run(JSON.stringify(current), now, tokenHash, now) as { changes?: number };
return result?.changes === 0 ? undefined : current;
}
async deleteToken(tokenHash: string): Promise<void> {
this.db.prepare(`DELETE FROM ${this.tokenTable} WHERE token_hash = ?`).run(tokenHash);
}
async gc(now: number): Promise<void> {
this.db
.prepare(`DELETE FROM ${this.challengeTable} WHERE expires_at <= ? OR consumed_at <= ?`)
.run(now, now - 60_000);
this.db
.prepare(`DELETE FROM ${this.tokenTable} WHERE expires_at <= ? OR consumed_at <= ?`)
.run(now, now - 60_000);
}
}
export function createSqliteCaptchaStore(
db: SqliteDatabaseLike,
options?: SqliteCaptchaStoreOptions,
): SqliteCaptchaStore {
return new SqliteCaptchaStore(db, options);
}
+339
View File
@@ -0,0 +1,339 @@
import type { Context, Middleware } from "@wrnexus/core";
export type CaptchaProviderName =
| "self-hosted"
| "wrnexus-managed"
| "turnstile"
| "recaptcha"
| "hcaptcha"
| (string & {});
export type CaptchaChallengeType =
| "number"
| "alpha"
| "alphanumeric"
| "calculation"
| "image"
| "honeypot"
| "timing"
| "not-robot"
| (string & {});
export type CaptchaDifficulty = "easy" | "normal" | "hard";
export type CaptchaConcreteImageStyle =
| "classic"
| "collision"
| "snow"
| "corrosion"
| "spiderweb"
| "cross-shadow"
| "split"
| "split2"
| "cut"
| "darts"
| "distortion"
| "stitch"
| "striped"
| "wave"
| "grid-noise"
| "scribble"
| "pixel"
| "broken-lines";
export type CaptchaImageStyle = "random" | CaptchaConcreteImageStyle;
export type CaptchaPresentation = "visual" | "audio" | "invisible";
export type CaptchaPolicyMode = "always" | "session" | "adaptive" | "never";
export type CaptchaFailureCode =
| "missing-input"
| "invalid-input"
| "incorrect-answer"
| "expired"
| "already-used"
| "attempts-exhausted"
| "action-mismatch"
| "hostname-mismatch"
| "session-mismatch"
| "ip-mismatch"
| "provider-error"
| "network-error"
| "risk-rejected"
| "internal-error";
export interface CaptchaBinding {
hostname?: string;
sessionId?: string;
ip?: string;
}
export interface CaptchaImageItem {
id: string;
image: string;
alt?: string;
}
export interface CaptchaChallenge {
id: string;
provider: CaptchaProviderName;
type: CaptchaChallengeType;
presentation: CaptchaPresentation;
action: string;
prompt: string;
createdAt: number;
expiresAt: number;
responseField: string;
inputMode?: "text" | "numeric" | "none";
image?: string;
audioUrl?: string;
refreshUrl?: string;
verifyUrl?: string;
items?: CaptchaImageItem[];
minSelections?: number;
maxSelections?: number;
honeypotField?: string;
timingToken?: string;
metadata?: Record<string, unknown>;
}
export interface CreateCaptchaOptions extends CaptchaBinding {
action: string;
type?: CaptchaChallengeType;
presentation?: CaptchaPresentation;
difficulty?: CaptchaDifficulty;
/** Visual disturbance percentage. 25 is easiest and 75 is hardest. */
disturbance?: number;
/** Renderer used for generated text and calculation CAPTCHA images. */
imageStyle?: CaptchaImageStyle;
/** Optional renderer pool, supplied as an array or comma-separated string. */
allowedStyles?: CaptchaConcreteImageStyle[] | string;
/** Renderers removed from the active pool. */
excludedStyles?: CaptchaConcreteImageStyle[] | string;
/** Force a new random renderer even when imageStyle names a concrete style. */
randomizeStyle?: boolean;
locale?: string;
length?: number;
caseSensitive?: boolean;
expiresInMs?: number;
maxAttempts?: number;
minCompletionMs?: number;
responseField?: string;
metadata?: Record<string, unknown>;
}
export interface VerifyCaptchaInput extends CaptchaBinding {
challengeId?: string;
responseToken?: string;
providerToken?: string;
answer?: string | number;
selections?: string[];
action: string;
honeypot?: string;
timingToken?: string;
consume?: boolean;
}
export interface CaptchaVerificationResult {
success: boolean;
provider: CaptchaProviderName;
action: string;
code?: CaptchaFailureCode | string;
message?: string;
responseToken?: string;
expiresAt?: number;
score?: number;
hostname?: string;
challengeId?: string;
metadata?: Record<string, unknown>;
}
export interface CaptchaChallengeRecord {
id: string;
provider: CaptchaProviderName;
type: CaptchaChallengeType;
presentation: CaptchaPresentation;
action: string;
publicChallenge: CaptchaChallenge;
answerDigest: string;
answerSalt: string;
answerKind: "text" | "selections" | "invisible";
caseSensitive: boolean;
createdAt: number;
expiresAt: number;
attempts: number;
maxAttempts: number;
consumedAt?: number;
hostnameHash?: string;
sessionHash?: string;
ipHash?: string;
metadata: Record<string, unknown>;
}
export interface CaptchaResponseTokenRecord {
tokenHash: string;
provider: CaptchaProviderName;
challengeId?: string;
action: string;
createdAt: number;
expiresAt: number;
consumedAt?: number;
hostnameHash?: string;
sessionHash?: string;
ipHash?: string;
score?: number;
metadata?: Record<string, unknown>;
}
export interface CaptchaStore {
createChallenge(record: CaptchaChallengeRecord): Promise<void>;
getChallenge(id: string): Promise<CaptchaChallengeRecord | undefined>;
incrementAttempts(id: string, now: number): Promise<CaptchaChallengeRecord | undefined>;
consumeChallenge(id: string, now: number): Promise<CaptchaChallengeRecord | undefined>;
deleteChallenge(id: string): Promise<void>;
createToken(record: CaptchaResponseTokenRecord): Promise<void>;
getToken(tokenHash: string): Promise<CaptchaResponseTokenRecord | undefined>;
consumeToken(tokenHash: string, now: number): Promise<CaptchaResponseTokenRecord | undefined>;
deleteToken(tokenHash: string): Promise<void>;
gc?(now: number): Promise<void>;
}
export interface GeneratedCaptchaChallenge {
type: CaptchaChallengeType;
presentation: CaptchaPresentation;
prompt: string;
answer: string;
answerKind: CaptchaChallengeRecord["answerKind"];
image?: string;
items?: CaptchaImageItem[];
minSelections?: number;
maxSelections?: number;
inputMode?: CaptchaChallenge["inputMode"];
audioSequence?: string[];
metadata?: Record<string, unknown>;
}
export interface CaptchaGeneratorContext {
difficulty: CaptchaDifficulty;
/** Normalized visual disturbance percentage in the inclusive range 25..75. */
disturbance: number;
/** Concrete renderer selected for this challenge. */
imageStyle: CaptchaConcreteImageStyle;
/** Renderer requested by the caller before random resolution. */
requestedImageStyle: CaptchaImageStyle;
/** Concrete renderer pool available to random selection. */
imageStylePool: readonly CaptchaConcreteImageStyle[];
locale: string;
length?: number;
caseSensitive: boolean;
minCompletionMs: number;
randomInt(min: number, max: number): number;
randomFloat(): number;
randomId(bytes?: number): string;
}
export interface CaptchaChallengeGenerator {
readonly type: CaptchaChallengeType;
generate(context: CaptchaGeneratorContext): GeneratedCaptchaChallenge | Promise<GeneratedCaptchaChallenge>;
}
export interface CaptchaAudioRenderer {
render(sequence: string[], locale: string): Promise<Uint8Array>;
contentType?: string;
}
export interface CaptchaEngineOptions {
secret: string;
store?: CaptchaStore;
generators?: CaptchaChallengeGenerator[];
audioRenderer?: CaptchaAudioRenderer;
basePath?: string;
challengeTtlMs?: number;
responseTokenTtlMs?: number;
maxAttempts?: number;
minCompletionMs?: number;
responseField?: string;
defaultType?: CaptchaChallengeType;
defaultDifficulty?: CaptchaDifficulty;
bindIp?: boolean;
now?: () => number;
randomBytes?: (length: number) => Uint8Array;
}
export interface CaptchaEngine {
readonly provider: "self-hosted";
readonly basePath: string;
create(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
verifyResponseToken(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
renderAudio(challengeId: string, key: string): Promise<{ bytes: Uint8Array; contentType: string } | undefined>;
gc(): Promise<void>;
}
export interface CaptchaProviderClientConfig {
responseField: string;
siteKey?: string;
scriptUrl?: string;
widgetClass?: string;
managedCreateUrl?: string;
}
export interface CaptchaProvider {
readonly name: CaptchaProviderName;
readonly client: CaptchaProviderClientConfig;
createChallenge?(options: CreateCaptchaOptions): Promise<CaptchaChallenge>;
verify(input: VerifyCaptchaInput): Promise<CaptchaVerificationResult>;
}
export interface CaptchaRiskSignals {
failedAttempts?: number;
requestsInWindow?: number;
completionMs?: number;
missingBrowserSignals?: boolean;
suspiciousHeaders?: boolean;
tokenReuse?: boolean;
knownBadIp?: boolean;
customScore?: number;
}
export interface CaptchaRiskResult {
score: number;
challenge: boolean;
reasons: string[];
}
export interface CaptchaPolicyOptions {
mode?: CaptchaPolicyMode;
threshold?: number;
verifiedForMs?: number;
alwaysForActions?: string[];
neverForActions?: string[];
routeGroups?: string[];
}
export interface CaptchaGuardOptions {
action: string | ((ctx: Context) => string);
responseField?: string;
provider?: CaptchaProvider;
engine?: CaptchaEngine;
failureStatus?: number;
failureMessage?: string;
bindHostname?: boolean;
bindSession?: boolean;
bindIp?: boolean;
onFailure?: (ctx: Context, result: CaptchaVerificationResult) => Response | Promise<Response>;
}
export interface CaptchaPageGateOptions extends CaptchaGuardOptions {
policy?: CaptchaPolicyOptions;
challengePath?: string;
returnToParam?: string;
sessionKey?: string;
signals?: (ctx: Context) => CaptchaRiskSignals | Promise<CaptchaRiskSignals>;
}
export type CaptchaMiddleware = Middleware;
export interface CaptchaHttpHandlers {
handle(request: Request, ctx?: Context): Promise<Response | undefined>;
create(request: Request, ctx?: Context): Promise<Response>;
verify(request: Request, ctx?: Context): Promise<Response>;
audio(request: Request, ctx?: Context): Promise<Response>;
}
+51
View File
@@ -0,0 +1,51 @@
import type { Context } from "@wrnexus/core";
import type { ObjectSchema, ParseResult } from "@wrnexus/validation";
import type {
CaptchaEngine,
CaptchaProvider,
CaptchaVerificationResult,
} from "./types.ts";
import { selfHostedProvider } from "./providers/self-hosted.ts";
export interface ParseWithCaptchaOptions {
action: string;
provider?: CaptchaProvider;
engine?: CaptchaEngine;
responseField?: string;
bindHostname?: boolean;
bindSession?: boolean;
bindIp?: boolean;
}
export interface CaptchaParseResult<T = Record<string, unknown>> extends ParseResult<T> {
captcha: CaptchaVerificationResult;
}
export async function parseWithCaptcha<T = Record<string, unknown>>(
schema: ObjectSchema,
input: Record<string, unknown>,
ctx: Context,
options: ParseWithCaptchaOptions,
): Promise<CaptchaParseResult<T>> {
const parsed = schema.parse(input) as ParseResult<T>;
const provider = options.provider ?? (options.engine ? selfHostedProvider(options.engine) : undefined);
if (!provider) throw new TypeError("parseWithCaptcha requires provider or engine");
const field = options.responseField ?? provider.client.responseField;
const token = input[field] ?? input.captchaToken ?? input.responseToken;
const captcha = await provider.verify({
action: options.action,
providerToken: token === undefined ? undefined : String(token),
responseToken: token === undefined ? undefined : String(token),
hostname: options.bindHostname === false ? undefined : ctx.url.hostname,
sessionId: options.bindSession === false ? undefined : ctx.session.id(),
ip: options.bindIp ? ctx.ip : undefined,
});
const errors = { ...parsed.errors };
if (!captcha.success) errors[field] = captcha.message ?? "CAPTCHA verification failed";
return {
...parsed,
ok: parsed.ok && captcha.success,
errors,
captcha,
};
}