Merge branch 'ditto-captcha' into 'main'

Add @ditto/captcha package

See merge request soapbox-pub/ditto!689
This commit is contained in:
Alex Gleason 2025-02-26 21:14:25 +00:00
commit c889cd68d2
31 changed files with 212 additions and 130 deletions

View file

@ -1,6 +1,7 @@
{ {
"version": "1.1.0", "version": "1.1.0",
"workspace": [ "workspace": [
"./packages/captcha",
"./packages/conf", "./packages/conf",
"./packages/db", "./packages/db",
"./packages/ditto", "./packages/ditto",

View file

@ -0,0 +1,9 @@
import { assert } from '@std/assert';
import { getCaptchaImages } from './assets.ts';
Deno.test('getCaptchaImages', async () => {
// If this function runs at all, it most likely worked.
const { bgImages } = await getCaptchaImages();
assert(bgImages.length);
});

View file

@ -0,0 +1,36 @@
import { type Image, loadImage } from '@gfx/canvas-wasm';
export interface CaptchaImages {
bgImages: Image[];
puzzleMask: Image;
puzzleHole: Image;
}
export async function getCaptchaImages(): Promise<CaptchaImages> {
const bgImages = await getBackgroundImages();
const puzzleMask = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-mask.png', import.meta.url)),
);
const puzzleHole = await loadImage(
await Deno.readFile(new URL('./assets/puzzle/puzzle-hole.png', import.meta.url)),
);
return { bgImages, puzzleMask, puzzleHole };
}
async function getBackgroundImages(): Promise<Image[]> {
const path = new URL('./assets/bg/', import.meta.url);
const images: Image[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) {
const file = await Deno.readFile(new URL(dirEntry.name, path));
const image = await loadImage(file);
images.push(image);
}
}
return images;
}

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 32 KiB

View file

Before

Width:  |  Height:  |  Size: 9.5 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

Before

Width:  |  Height:  |  Size: 997 B

After

Width:  |  Height:  |  Size: 997 B

View file

Before

Width:  |  Height:  |  Size: 696 B

After

Width:  |  Height:  |  Size: 696 B

View file

@ -0,0 +1,22 @@
import { createCanvas } from '@gfx/canvas-wasm';
import { assertNotEquals } from '@std/assert';
import { encodeHex } from '@std/encoding/hex';
import { addNoise } from './canvas.ts';
// This is almost impossible to truly test,
// but we can at least check that the image on the canvas changes.
Deno.test('addNoise', async () => {
const canvas = createCanvas(100, 100);
const ctx = canvas.getContext('2d');
const dataBefore = ctx.getImageData(0, 0, canvas.width, canvas.height);
const hashBefore = await crypto.subtle.digest('SHA-256', dataBefore.data);
addNoise(ctx, canvas.width, canvas.height);
const dataAfter = ctx.getImageData(0, 0, canvas.width, canvas.height);
const hashAfter = await crypto.subtle.digest('SHA-256', dataAfter.data);
assertNotEquals(encodeHex(hashBefore), encodeHex(hashAfter));
});

View file

@ -0,0 +1,21 @@
import type { CanvasRenderingContext2D } from '@gfx/canvas-wasm';
/**
* Add a small amount of noise to the image.
* This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup.
*/
export function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const imageData = ctx.getImageData(0, 0, width, height);
// Loop over every pixel.
for (let i = 0; i < imageData.data.length; i += 4) {
// Add/subtract a small amount from each color channel.
// We skip i+3 because that's the alpha channel, which we don't want to modify.
for (let j = 0; j < 3; j++) {
const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5
imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255);
}
}
ctx.putImageData(imageData, 0, 0);
}

View file

@ -0,0 +1,12 @@
import { getCaptchaImages } from './assets.ts';
import { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';
Deno.test('generateCaptcha', async () => {
const images = await getCaptchaImages();
generateCaptcha(images, { w: 370, h: 400 }, { w: 65, h: 65 });
});
Deno.test('verifyCaptchaSolution', () => {
verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 0, y: 0 });
verifyCaptchaSolution({ w: 65, h: 65 }, { x: 0, y: 0 }, { x: 10, y: 10 });
});

View file

@ -0,0 +1,60 @@
import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm';
import { addNoise } from './canvas.ts';
import { areIntersecting, type Dimensions, type Point } from './geometry.ts';
import type { CaptchaImages } from './assets.ts';
/** Generate a puzzle captcha, returning canvases for the board and piece. */
export function generateCaptcha(
{ bgImages, puzzleMask, puzzleHole }: CaptchaImages,
bgSize: Dimensions,
puzzleSize: Dimensions,
): {
bg: EmulatedCanvas2D;
puzzle: EmulatedCanvas2D;
solution: Point;
} {
const bg = createCanvas(bgSize.w, bgSize.h);
const puzzle = createCanvas(puzzleSize.w, puzzleSize.h);
const ctx = bg.getContext('2d');
const pctx = puzzle.getContext('2d');
const solution = generateSolution(bgSize, puzzleSize);
const bgImage = bgImages[Math.floor(Math.random() * bgImages.length)];
// Draw the background image.
ctx.drawImage(bgImage, 0, 0, bg.width, bg.height);
addNoise(ctx, bg.width, bg.height);
// Draw the puzzle piece.
pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height);
pctx.globalCompositeOperation = 'source-in';
pctx.drawImage(bg, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height);
// Draw the hole.
ctx.globalCompositeOperation = 'source-atop';
ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height);
return {
bg,
puzzle,
solution,
};
}
export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean {
return areIntersecting(
{ ...point, ...puzzleSize },
{ ...solution, ...puzzleSize },
);
}
/** Random coordinates such that the piece fits within the canvas. */
function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point {
return {
x: Math.floor(Math.random() * (bgSize.w - puzzleSize.w)),
y: Math.floor(Math.random() * (bgSize.h - puzzleSize.h)),
};
}

View file

@ -0,0 +1,7 @@
{
"name": "@ditto/captcha",
"version": "1.0.0",
"exports": {
".": "./mod.ts"
}
}

View file

@ -0,0 +1,8 @@
import { assertEquals } from '@std/assert';
import { areIntersecting } from './geometry.ts';
Deno.test('areIntersecting', () => {
assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 5, y: 5, w: 10, h: 10 }), true);
assertEquals(areIntersecting({ x: 0, y: 0, w: 10, h: 10 }, { x: 15, y: 15, w: 10, h: 10 }), false);
});

View file

@ -0,0 +1,27 @@
export interface Point {
x: number;
y: number;
}
export interface Dimensions {
w: number;
h: number;
}
type Rectangle = Point & Dimensions;
/** Check if the two rectangles intersect by at least `threshold` percent. */
export function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5): boolean {
const r1cx = rect1.x + rect1.w / 2;
const r2cx = rect2.x + rect2.w / 2;
const r1cy = rect1.y + rect1.h / 2;
const r2cy = rect2.y + rect2.h / 2;
const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2);
const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2;
const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2;
return dist <= (e1 + e2) * threshold;
}

2
packages/captcha/mod.ts Normal file
View file

@ -0,0 +1,2 @@
export { getCaptchaImages } from './assets.ts';
export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts';

View file

@ -1,4 +1,4 @@
import { CanvasRenderingContext2D, createCanvas, Image, loadImage } from '@gfx/canvas-wasm'; import { generateCaptcha, getCaptchaImages, verifyCaptchaSolution } from '@ditto/captcha';
import TTLCache from '@isaacs/ttlcache'; import TTLCache from '@isaacs/ttlcache';
import { z } from 'zod'; import { z } from 'zod';
@ -10,13 +10,13 @@ interface Point {
y: number; y: number;
} }
interface Dimensions { const pointSchema: z.ZodType<Point> = z.object({
w: number; x: z.number(),
h: number; y: z.number(),
} });
const captchas = new TTLCache<string, Point>(); const captchas = new TTLCache<string, Point>();
const imagesAsync = getImages(); const imagesAsync = getCaptchaImages();
const BG_SIZE = { w: 370, h: 400 }; const BG_SIZE = { w: 370, h: 400 };
const PUZZLE_SIZE = { w: 65, h: 65 }; const PUZZLE_SIZE = { w: 65, h: 65 };
@ -47,109 +47,6 @@ export const captchaController: AppController = async (c) => {
}); });
}; };
interface CaptchaImages {
bgImages: Image[];
puzzleMask: Image;
puzzleHole: Image;
}
async function getImages(): Promise<CaptchaImages> {
const bgImages = await getBackgroundImages();
const puzzleMask = await loadImage(
await Deno.readFile(new URL('../../assets/captcha/puzzle-mask.png', import.meta.url)),
);
const puzzleHole = await loadImage(
await Deno.readFile(new URL('../../assets/captcha/puzzle-hole.png', import.meta.url)),
);
return { bgImages, puzzleMask, puzzleHole };
}
async function getBackgroundImages(): Promise<Image[]> {
const path = new URL('../../assets/captcha/bg/', import.meta.url);
const images: Image[] = [];
for await (const dirEntry of Deno.readDir(path)) {
if (dirEntry.isFile && dirEntry.name.endsWith('.jpg')) {
const file = await Deno.readFile(new URL(dirEntry.name, path));
const image = await loadImage(file);
images.push(image);
}
}
return images;
}
/** Generate a puzzle captcha, returning canvases for the board and piece. */
function generateCaptcha(
{ bgImages, puzzleMask, puzzleHole }: CaptchaImages,
bgSize: Dimensions,
puzzleSize: Dimensions,
) {
const bg = createCanvas(bgSize.w, bgSize.h);
const puzzle = createCanvas(puzzleSize.w, puzzleSize.h);
const ctx = bg.getContext('2d');
const pctx = puzzle.getContext('2d');
const solution = generateSolution(bgSize, puzzleSize);
const bgImage = bgImages[Math.floor(Math.random() * bgImages.length)];
// Draw the background image.
ctx.drawImage(bgImage, 0, 0, bg.width, bg.height);
addNoise(ctx, bg.width, bg.height);
// Draw the puzzle piece.
pctx.drawImage(puzzleMask, 0, 0, puzzle.width, puzzle.height);
pctx.globalCompositeOperation = 'source-in';
pctx.drawImage(bg, solution.x, solution.y, puzzle.width, puzzle.height, 0, 0, puzzle.width, puzzle.height);
// Draw the hole.
ctx.globalCompositeOperation = 'source-atop';
ctx.drawImage(puzzleHole, solution.x, solution.y, puzzle.width, puzzle.height);
return {
bg,
puzzle,
solution,
};
}
/**
* Add a small amount of noise to the image.
* This protects against an attacker pregenerating every possible solution and then doing a reverse-lookup.
*/
function addNoise(ctx: CanvasRenderingContext2D, width: number, height: number): void {
const imageData = ctx.getImageData(0, 0, width, height);
// Loop over every pixel.
for (let i = 0; i < imageData.data.length; i += 4) {
// Add/subtract a small amount from each color channel.
// We skip i+3 because that's the alpha channel, which we don't want to modify.
for (let j = 0; j < 3; j++) {
const alteration = Math.floor(Math.random() * 11) - 5; // Vary between -5 and +5
imageData.data[i + j] = Math.min(Math.max(imageData.data[i + j] + alteration, 0), 255);
}
}
ctx.putImageData(imageData, 0, 0);
}
/** Random coordinates such that the piece fits within the canvas. */
function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point {
return {
x: Math.floor(Math.random() * (bgSize.w - puzzleSize.w)),
y: Math.floor(Math.random() * (bgSize.h - puzzleSize.h)),
};
}
const pointSchema = z.object({
x: z.number(),
y: z.number(),
});
/** Verify the captcha solution and sign an event in the database. */ /** Verify the captcha solution and sign an event in the database. */
export const captchaVerifyController: AppController = async (c) => { export const captchaVerifyController: AppController = async (c) => {
const { user } = c.var; const { user } = c.var;
@ -168,7 +65,7 @@ export const captchaVerifyController: AppController = async (c) => {
return c.json({ error: 'Captcha expired' }, { status: 410 }); return c.json({ error: 'Captcha expired' }, { status: 410 });
} }
const solved = verifySolution(PUZZLE_SIZE, result.data, solution); const solved = verifyCaptchaSolution(PUZZLE_SIZE, result.data, solution);
if (solved) { if (solved) {
captchas.delete(id); captchas.delete(id);
@ -178,23 +75,3 @@ export const captchaVerifyController: AppController = async (c) => {
return c.json({ error: 'Incorrect solution' }, { status: 400 }); return c.json({ error: 'Incorrect solution' }, { status: 400 });
}; };
function verifySolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean {
return areIntersecting(
{ ...point, ...puzzleSize },
{ ...solution, ...puzzleSize },
);
}
type Rectangle = Point & Dimensions;
function areIntersecting(rect1: Rectangle, rect2: Rectangle, threshold = 0.5) {
const r1cx = rect1.x + rect1.w / 2;
const r2cx = rect2.x + rect2.w / 2;
const r1cy = rect1.y + rect1.h / 2;
const r2cy = rect2.y + rect2.h / 2;
const dist = Math.sqrt((r2cx - r1cx) ** 2 + (r2cy - r1cy) ** 2);
const e1 = Math.sqrt(rect1.h ** 2 + rect1.w ** 2) / 2;
const e2 = Math.sqrt(rect2.h ** 2 + rect2.w ** 2) / 2;
return dist < (e1 + e2) * threshold;
}