Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions shared/captcha.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,11 @@ export interface SlidePayload {
game: "slide";
target: number; // 0–100 along the track
color: string;
/** Base acceptance half-width (same units as target) the server validates
* against, so the client's "feels aligned" gate matches the server exactly
* instead of guessing. Not a secret: a bot submits the exact target anyway —
* this only governs how forgiving imprecise human input is. */
tol: number;
}

export interface TapMatchPayload {
Expand All @@ -105,6 +110,9 @@ export interface RotatePayload {
arrow: { pos: ScenePoint; size: number; angle: number; color: string };
/** Marker the arrow must point at, on a ring around the arrow's center. */
dot: { angle: number; radius: number; size: number; color: string };
/** Base acceptance half-angle (degrees) the server validates against, so the
* client gate matches the server exactly. Not a secret (see SlidePayload). */
tol: number;
}

export interface ConnectPayload {
Expand Down Expand Up @@ -235,6 +243,11 @@ export interface CaptchaChallengeDTO {
gamesTotal: number;
gameIndex: number; // 0-based
limits: { maxEvents: number };
/** Geometry-forgiveness multiplier (the admin touch-tolerance profile). The
* client mirrors it in its "feels aligned" gates so a lenient setting feels
* lenient on the client too — the server still holds the authoritative
* tolerance and re-validates every answer. Not a secret. */
tolerance: number;
}

export interface CaptchaVerifyRequestDTO {
Expand Down
1 change: 1 addition & 0 deletions src/components/HumanCheck.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export function HumanCheck({
rec={recRef.current}
disabled={phase !== "playing"}
onAnswer={handleAnswer}
tolerance={chRef.current?.tolerance ?? 1}
/>
</CaptchaPaletteContext.Provider>
</div>
Expand Down
4 changes: 2 additions & 2 deletions src/components/captcha/games/ConnectGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { GameProps } from "./types";

/** "Draw a line between the two diamonds" — one stroke, or select both pieces
* with the keyboard. */
export function ConnectGame({ game, rec, disabled, onAnswer }: GameProps) {
export function ConnectGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) {
const payload = game.payload as ConnectPayload;
const { toScene, surfaceProps } = useGameSurface(rec);
const [fromId, setFromId] = useState<string | null>(null);
Expand All @@ -28,7 +28,7 @@ export function ConnectGame({ game, rec, disabled, onAnswer }: GameProps) {
setLineEnd(null);
if (!start) return;
const target = payload.objects.find(
(o) => o.id !== start && distance(p, o.pos) <= Math.max(o.size * 1.8, 8),
(o) => o.id !== start && distance(p, o.pos) <= Math.max(o.size * 1.8, 8) * tolerance,
);
if (target) onAnswer({ a: start, b: target.id });
else shake();
Expand Down
4 changes: 2 additions & 2 deletions src/components/captcha/games/DragTargetGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const clampPos = (v: number) => Math.min(96, Math.max(4, v));

/** "Drag the star into the dashed ring" — one drag, or pick-up/move/drop with
* the keyboard (Enter grabs, arrows move, Enter drops). */
export function DragTargetGame({ game, rec, disabled, onAnswer }: GameProps) {
export function DragTargetGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) {
const payload = game.payload as DragTargetPayload;
const { toScene, surfaceProps } = useGameSurface(rec);
const [dragId, setDragId] = useState<string | null>(null);
Expand All @@ -27,7 +27,7 @@ export function DragTargetGame({ game, rec, disabled, onAnswer }: GameProps) {
};

const drop = (id: string, p: ScenePoint) => {
if (distance(p, ring.pos) <= ring.size) {
if (distance(p, ring.pos) <= ring.size * tolerance) {
onAnswer({ objectId: id });
} else {
shake();
Expand Down
4 changes: 2 additions & 2 deletions src/components/captcha/games/PathTraceGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import type { GameProps } from "./types";
/** "Drag through the dots in order" — one continuous stroke 1→2→3(→4), or
* activate the dots in order with the keyboard. Releasing early just resets
* the visual progress; nothing is submitted until the trace completes. */
export function PathTraceGame({ game, rec, disabled, onAnswer }: GameProps) {
export function PathTraceGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) {
const payload = game.payload as PathTracePayload;
const { toScene, surfaceProps } = useGameSurface(rec);
const [progress, setProgress] = useState(0);
Expand All @@ -21,7 +21,7 @@ export function PathTraceGame({ game, rec, disabled, onAnswer }: GameProps) {
[...payload.dots].sort((a, b) => Number(a.label ?? 0) - Number(b.label ?? 0)),
[payload.dots],
);
const hitR = (d: SceneObject) => Math.max(d.size * 2, 8);
const hitR = (d: SceneObject) => Math.max(d.size * 2, 8) * tolerance;

const done = Math.max(progress, kbProgress);

Expand Down
8 changes: 3 additions & 5 deletions src/components/captcha/games/RotateGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,13 @@ import { cn } from "@/lib/utils";
import { useGameSurface, usePieceColor } from "../scene";
import type { GameProps } from "./types";

const ALIGN_DEG = 12; // client-side "feels aligned" check (UX only — the
// server holds the real tolerance and validates the submitted angle itself)

function angDiff(a: number, b: number): number {
return Math.abs(((a - b + 540) % 360) - 180);
}

/** "Turn the arrow to point at the dot" — drag around the pivot, or arrow keys
* then Enter. Submits when the arrow is released roughly on target. */
export function RotateGame({ game, rec, disabled, onAnswer }: GameProps) {
export function RotateGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) {
const payload = game.payload as RotatePayload;
const { toScene, surfaceProps } = useGameSurface(rec);
const { arrow, dot } = payload;
Expand All @@ -35,7 +32,8 @@ export function RotateGame({ game, rec, disabled, onAnswer }: GameProps) {
};

const submitIfAligned = (a: number) => {
if (angDiff(a, dot.angle) <= ALIGN_DEG) {
// Mirror the server's acceptance angle (payload.tol) exactly.
if (angDiff(a, dot.angle) <= payload.tol * tolerance) {
onAnswer({ angle: ((a % 360) + 360) % 360 });
} else {
setMiss(true);
Expand Down
7 changes: 4 additions & 3 deletions src/components/captcha/games/SlideGame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,13 @@ import { darken, lighten } from "@/lib/pixel";
import { useGameSurface, usePieceColor } from "../scene";
import type { GameProps } from "./types";

const ALIGN = 7; // client "looks aligned" feel (server holds the real tolerance)
const KB_STEP = 3;
const clampPos = (v: number) => Math.min(100, Math.max(0, v));

/** A chunky pixel handle on a track; slide it into the notch. Pointer drag or
* arrow keys. The visible style is pixel-art; the maths is the same 0–100 the
* server validated. */
export function SlideGame({ game, rec, disabled, onAnswer }: GameProps) {
export function SlideGame({ game, rec, disabled, onAnswer, tolerance }: GameProps) {
const payload = game.payload as SlidePayload;
const { ref, toScene, surfaceProps } = useGameSurface(rec);
const [pos, setPos] = useState(8);
Expand All @@ -25,7 +24,9 @@ export function SlideGame({ game, rec, disabled, onAnswer }: GameProps) {

const release = (p: number) => {
setDrag(false);
if (Math.abs(p - payload.target) <= ALIGN) onAnswer({ pos: p });
// Mirror the server's acceptance (payload.tol) so a gesture the client
// accepts is one the server accepts — no client-only over/under-shoot.
if (Math.abs(p - payload.target) <= payload.tol * tolerance) onAnswer({ pos: p });
else {
setMiss(true);
setTimeout(() => {
Expand Down
6 changes: 6 additions & 0 deletions src/components/captcha/games/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,10 @@ export interface GameProps {
/** Submit the answer for server-side validation (the client never knows
* whether it is correct — it only knows the gesture finished). */
onAnswer: (answer: unknown) => void;
/** The admin tolerance profile (geometry-forgiveness multiplier) the game
* applies to its acceptance gate, so the gate mirrors the server's and a
* lenient setting feels lenient on the client too. The server holds the
* authoritative tolerance and re-validates. Games without an alignment gate
* (tap-match, sort) simply ignore it. */
tolerance: number;
}
4 changes: 2 additions & 2 deletions src/pages/admin/settings/HumanCheckCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ function HumanCheckForm({ initial, patch }: { initial: SettingsDTO; patch: Setti
const [captchaRiskMedium, setCaptchaRiskMedium] = useState(initial.captchaRiskMedium ?? 30);
const [captchaRiskHigh, setCaptchaRiskHigh] = useState(initial.captchaRiskHigh ?? 60);
const [captchaTolerance, setCaptchaTolerance] = useState<"lenient" | "standard" | "strict">(
initial.captchaTolerance ?? "standard",
initial.captchaTolerance ?? "lenient",
);
const [captchaCreateLimit, setCaptchaCreateLimit] = useState(initial.captchaCreateLimit ?? 10);
const [captchaVerifyLimit, setCaptchaVerifyLimit] = useState(initial.captchaVerifyLimit ?? 30);
Expand Down Expand Up @@ -300,7 +300,7 @@ function HumanCheckForm({ initial, patch }: { initial: SettingsDTO; patch: Setti
}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<option value="lenient">Lenient — most forgiving</option>
<option value="lenient">Lenient — most forgiving (default)</option>
<option value="standard">Standard</option>
<option value="strict">Strict</option>
</select>
Expand Down
5 changes: 4 additions & 1 deletion worker/lib/captcha/games/rotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const rotate: GamePlugin = {
let initial = targetAngle + randFloat(70, 290);
initial = ((initial % 360) + 360) % 360;

const tol = TOLERANCE[difficulty];
const payload: RotatePayload = {
game: "rotate",
arrow: {
Expand All @@ -42,10 +43,12 @@ export const rotate: GamePlugin = {
size: randFloat(3.5, 4.5),
color: pick(COLORS),
},
// tol mirrors the secret so the client gate matches server acceptance.
tol,
};
const secret: Secret = {
targetAngle,
tolerance: TOLERANCE[difficulty],
tolerance: tol,
};
return {
type: "rotate",
Expand Down
7 changes: 5 additions & 2 deletions worker/lib/captcha/games/slide.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,11 @@ export const slide: GamePlugin = {
generate({ difficulty }) {
// Keep the notch away from the edges so "do nothing" never lands it.
const target = randInt(22, 82);
const payload: SlidePayload = { game: "slide", target, color: pick(COLORS) };
const secret: Secret = { target, tolerance: TOLERANCE[difficulty] };
const tol = TOLERANCE[difficulty];
// tol travels in the public payload too so the client's auto-submit gate
// matches the server's acceptance exactly (the secret stays authoritative).
const payload: SlidePayload = { game: "slide", target, color: pick(COLORS), tol };
const secret: Secret = { target, tolerance: tol };
return {
type: "slide",
prompt: "Slide the handle into the notch",
Expand Down
2 changes: 2 additions & 0 deletions worker/lib/captcha/service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ export async function createChallenge(
gamesTotal: 1,
gameIndex: 0,
limits: { maxEvents: cfg.maxEvents },
tolerance: cfg.toleranceMult,
...generateDecoys(),
} as CaptchaChallengeDTO,
};
Expand Down Expand Up @@ -194,6 +195,7 @@ export async function createChallenge(
gamesTotal,
gameIndex: 0,
limits: { maxEvents: cfg.maxEvents },
tolerance: cfg.toleranceMult,
...generateDecoys(),
} as CaptchaChallengeDTO,
};
Expand Down
6 changes: 5 additions & 1 deletion worker/lib/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -475,7 +475,11 @@ export function captchaToleranceFrom(
map: Record<string, unknown>,
): CaptchaTolerance {
const v = map[SETTING_KEYS.captchaTolerance];
return v === "lenient" || v === "strict" ? v : "standard";
// Default LENIENT: this is a public link shortener whose audience includes
// older / less dexterous users, and the captcha's security never rested on
// tight geometry — it's the PoW economics + single-use + interaction risk +
// bindings (see docs/human-check-v3.md). An admin can still pick strict.
return v === "lenient" || v === "strict" || v === "standard" ? v : "lenient";
}

const TOLERANCE_MULT: Record<CaptchaTolerance, number> = {
Expand Down
Loading