From a2732642a53fb419a513dca013e9d33a7d153428 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 14:46:47 -0600 Subject: [PATCH 1/4] Break @ditto/captcha into a separate library --- deno.json | 1 + ... Body of Water Surrounded By Mountains.jpg | Bin .../bg/A Trail of Footprints In The Sand.jpg | Bin .../assets}/bg/Ashim DSilva.jpg | Bin .../assets}/bg/Canazei Granite Ridges.jpg | Bin .../assets}/bg/Martin Adams.jpg | Bin .../assets}/bg/Morskie Oko.jpg | Bin .../captcha => captcha/assets}/bg/Mr. Lee.jpg | Bin .../assets}/bg/Nattu Adnan.jpg | Bin .../assets}/bg/Photo by SpaceX.jpg | Bin .../assets}/bg/Photo of Valley.jpg | Bin .../assets}/bg/Snow-Capped Mountain.jpg | Bin .../assets}/bg/Sunset by the Pier.jpg | Bin .../assets}/bg/Tj Holowaychuk.jpg | Bin .../assets}/bg/Viktor Forgacs.jpg | Bin .../assets}/bg/copyright.txt | 0 .../assets/puzzle}/puzzle-hole.png | Bin .../assets/puzzle}/puzzle-hole.svg | 0 .../assets/puzzle}/puzzle-mask.png | Bin .../assets/puzzle}/puzzle-mask.svg | 0 packages/captcha/captcha.ts | 139 ++++++++++++++++++ packages/captcha/deno.json | 7 + packages/ditto/controllers/api/captcha.ts | 129 +--------------- 23 files changed, 150 insertions(+), 126 deletions(-) rename packages/{ditto/assets/captcha => captcha/assets}/bg/A Large Body of Water Surrounded By Mountains.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/A Trail of Footprints In The Sand.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Ashim DSilva.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Canazei Granite Ridges.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Martin Adams.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Morskie Oko.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Mr. Lee.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Nattu Adnan.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Photo by SpaceX.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Photo of Valley.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Snow-Capped Mountain.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Sunset by the Pier.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Tj Holowaychuk.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/Viktor Forgacs.jpg (100%) rename packages/{ditto/assets/captcha => captcha/assets}/bg/copyright.txt (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-hole.png (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-hole.svg (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-mask.png (100%) rename packages/{ditto/assets/captcha => captcha/assets/puzzle}/puzzle-mask.svg (100%) create mode 100644 packages/captcha/captcha.ts create mode 100644 packages/captcha/deno.json diff --git a/deno.json b/deno.json index 75f94cdd..c8a226af 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,7 @@ { "version": "1.1.0", "workspace": [ + "./packages/captcha", "./packages/conf", "./packages/db", "./packages/ditto", diff --git a/packages/ditto/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg b/packages/captcha/assets/bg/A Large Body of Water Surrounded By Mountains.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg rename to packages/captcha/assets/bg/A Large Body of Water Surrounded By Mountains.jpg diff --git a/packages/ditto/assets/captcha/bg/A Trail of Footprints In The Sand.jpg b/packages/captcha/assets/bg/A Trail of Footprints In The Sand.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/A Trail of Footprints In The Sand.jpg rename to packages/captcha/assets/bg/A Trail of Footprints In The Sand.jpg diff --git a/packages/ditto/assets/captcha/bg/Ashim DSilva.jpg b/packages/captcha/assets/bg/Ashim DSilva.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Ashim DSilva.jpg rename to packages/captcha/assets/bg/Ashim DSilva.jpg diff --git a/packages/ditto/assets/captcha/bg/Canazei Granite Ridges.jpg b/packages/captcha/assets/bg/Canazei Granite Ridges.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Canazei Granite Ridges.jpg rename to packages/captcha/assets/bg/Canazei Granite Ridges.jpg diff --git a/packages/ditto/assets/captcha/bg/Martin Adams.jpg b/packages/captcha/assets/bg/Martin Adams.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Martin Adams.jpg rename to packages/captcha/assets/bg/Martin Adams.jpg diff --git a/packages/ditto/assets/captcha/bg/Morskie Oko.jpg b/packages/captcha/assets/bg/Morskie Oko.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Morskie Oko.jpg rename to packages/captcha/assets/bg/Morskie Oko.jpg diff --git a/packages/ditto/assets/captcha/bg/Mr. Lee.jpg b/packages/captcha/assets/bg/Mr. Lee.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Mr. Lee.jpg rename to packages/captcha/assets/bg/Mr. Lee.jpg diff --git a/packages/ditto/assets/captcha/bg/Nattu Adnan.jpg b/packages/captcha/assets/bg/Nattu Adnan.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Nattu Adnan.jpg rename to packages/captcha/assets/bg/Nattu Adnan.jpg diff --git a/packages/ditto/assets/captcha/bg/Photo by SpaceX.jpg b/packages/captcha/assets/bg/Photo by SpaceX.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Photo by SpaceX.jpg rename to packages/captcha/assets/bg/Photo by SpaceX.jpg diff --git a/packages/ditto/assets/captcha/bg/Photo of Valley.jpg b/packages/captcha/assets/bg/Photo of Valley.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Photo of Valley.jpg rename to packages/captcha/assets/bg/Photo of Valley.jpg diff --git a/packages/ditto/assets/captcha/bg/Snow-Capped Mountain.jpg b/packages/captcha/assets/bg/Snow-Capped Mountain.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Snow-Capped Mountain.jpg rename to packages/captcha/assets/bg/Snow-Capped Mountain.jpg diff --git a/packages/ditto/assets/captcha/bg/Sunset by the Pier.jpg b/packages/captcha/assets/bg/Sunset by the Pier.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Sunset by the Pier.jpg rename to packages/captcha/assets/bg/Sunset by the Pier.jpg diff --git a/packages/ditto/assets/captcha/bg/Tj Holowaychuk.jpg b/packages/captcha/assets/bg/Tj Holowaychuk.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Tj Holowaychuk.jpg rename to packages/captcha/assets/bg/Tj Holowaychuk.jpg diff --git a/packages/ditto/assets/captcha/bg/Viktor Forgacs.jpg b/packages/captcha/assets/bg/Viktor Forgacs.jpg similarity index 100% rename from packages/ditto/assets/captcha/bg/Viktor Forgacs.jpg rename to packages/captcha/assets/bg/Viktor Forgacs.jpg diff --git a/packages/ditto/assets/captcha/bg/copyright.txt b/packages/captcha/assets/bg/copyright.txt similarity index 100% rename from packages/ditto/assets/captcha/bg/copyright.txt rename to packages/captcha/assets/bg/copyright.txt diff --git a/packages/ditto/assets/captcha/puzzle-hole.png b/packages/captcha/assets/puzzle/puzzle-hole.png similarity index 100% rename from packages/ditto/assets/captcha/puzzle-hole.png rename to packages/captcha/assets/puzzle/puzzle-hole.png diff --git a/packages/ditto/assets/captcha/puzzle-hole.svg b/packages/captcha/assets/puzzle/puzzle-hole.svg similarity index 100% rename from packages/ditto/assets/captcha/puzzle-hole.svg rename to packages/captcha/assets/puzzle/puzzle-hole.svg diff --git a/packages/ditto/assets/captcha/puzzle-mask.png b/packages/captcha/assets/puzzle/puzzle-mask.png similarity index 100% rename from packages/ditto/assets/captcha/puzzle-mask.png rename to packages/captcha/assets/puzzle/puzzle-mask.png diff --git a/packages/ditto/assets/captcha/puzzle-mask.svg b/packages/captcha/assets/puzzle/puzzle-mask.svg similarity index 100% rename from packages/ditto/assets/captcha/puzzle-mask.svg rename to packages/captcha/assets/puzzle/puzzle-mask.svg diff --git a/packages/captcha/captcha.ts b/packages/captcha/captcha.ts new file mode 100644 index 00000000..f71005c1 --- /dev/null +++ b/packages/captcha/captcha.ts @@ -0,0 +1,139 @@ +import { + type CanvasRenderingContext2D, + createCanvas, + type EmulatedCanvas2D, + type Image, + loadImage, +} from '@gfx/canvas-wasm'; + +export interface CaptchaImages { + bgImages: Image[]; + puzzleMask: Image; + puzzleHole: Image; +} + +interface Point { + x: number; + y: number; +} + +interface Dimensions { + w: number; + h: number; +} + +type Rectangle = Point & Dimensions; + +export async function getCaptchaImages(): Promise { + 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 { + 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; +} + +/** 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, + }; +} + +/** + * 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); +} + +export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean { + return areIntersecting( + { ...point, ...puzzleSize }, + { ...solution, ...puzzleSize }, + ); +} + +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; +} + +/** 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)), + }; +} diff --git a/packages/captcha/deno.json b/packages/captcha/deno.json new file mode 100644 index 00000000..e10a6b36 --- /dev/null +++ b/packages/captcha/deno.json @@ -0,0 +1,7 @@ +{ + "name": "@ditto/captcha", + "version": "1.0.0", + "exports": { + ".": "./captcha.ts" + } +} diff --git a/packages/ditto/controllers/api/captcha.ts b/packages/ditto/controllers/api/captcha.ts index 790913af..0f1f9ac4 100644 --- a/packages/ditto/controllers/api/captcha.ts +++ b/packages/ditto/controllers/api/captcha.ts @@ -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 { z } from 'zod'; @@ -10,13 +10,8 @@ interface Point { y: number; } -interface Dimensions { - w: number; - h: number; -} - const captchas = new TTLCache(); -const imagesAsync = getImages(); +const imagesAsync = getCaptchaImages(); const BG_SIZE = { w: 370, h: 400 }; const PUZZLE_SIZE = { w: 65, h: 65 }; @@ -47,104 +42,6 @@ export const captchaController: AppController = async (c) => { }); }; -interface CaptchaImages { - bgImages: Image[]; - puzzleMask: Image; - puzzleHole: Image; -} - -async function getImages(): Promise { - 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 { - 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(), @@ -168,7 +65,7 @@ export const captchaVerifyController: AppController = async (c) => { 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) { captchas.delete(id); @@ -178,23 +75,3 @@ export const captchaVerifyController: AppController = async (c) => { 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; -} From 5f617b2d1ad9ac3b3fd4a307487f08a7d1ebe838 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 14:53:13 -0600 Subject: [PATCH 2/4] Break captcha package into smaller modules --- packages/captcha/assets.ts | 36 +++++++++++++++ packages/captcha/canvas.ts | 21 +++++++++ packages/captcha/captcha.ts | 87 ++---------------------------------- packages/captcha/deno.json | 2 +- packages/captcha/geometry.ts | 22 +++++++++ packages/captcha/mod.ts | 2 + 6 files changed, 86 insertions(+), 84 deletions(-) create mode 100644 packages/captcha/assets.ts create mode 100644 packages/captcha/canvas.ts create mode 100644 packages/captcha/geometry.ts create mode 100644 packages/captcha/mod.ts diff --git a/packages/captcha/assets.ts b/packages/captcha/assets.ts new file mode 100644 index 00000000..8bb419b8 --- /dev/null +++ b/packages/captcha/assets.ts @@ -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 { + 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 { + 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; +} diff --git a/packages/captcha/canvas.ts b/packages/captcha/canvas.ts new file mode 100644 index 00000000..b7420189 --- /dev/null +++ b/packages/captcha/canvas.ts @@ -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); +} diff --git a/packages/captcha/captcha.ts b/packages/captcha/captcha.ts index f71005c1..27bf6c91 100644 --- a/packages/captcha/captcha.ts +++ b/packages/captcha/captcha.ts @@ -1,57 +1,9 @@ -import { - type CanvasRenderingContext2D, - createCanvas, - type EmulatedCanvas2D, - type Image, - loadImage, -} from '@gfx/canvas-wasm'; +import { createCanvas, type EmulatedCanvas2D } from '@gfx/canvas-wasm'; -export interface CaptchaImages { - bgImages: Image[]; - puzzleMask: Image; - puzzleHole: Image; -} +import { addNoise } from './canvas.ts'; +import { areIntersecting, type Dimensions, type Point } from './geometry.ts'; -interface Point { - x: number; - y: number; -} - -interface Dimensions { - w: number; - h: number; -} - -type Rectangle = Point & Dimensions; - -export async function getCaptchaImages(): Promise { - 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 { - 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; -} +import type { CaptchaImages } from './assets.ts'; /** Generate a puzzle captcha, returning canvases for the board and piece. */ export function generateCaptcha( @@ -92,26 +44,6 @@ export function generateCaptcha( }; } -/** - * 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); -} - export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solution: Point): boolean { return areIntersecting( { ...point, ...puzzleSize }, @@ -119,17 +51,6 @@ export function verifyCaptchaSolution(puzzleSize: Dimensions, point: Point, solu ); } -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; -} - /** Random coordinates such that the piece fits within the canvas. */ function generateSolution(bgSize: Dimensions, puzzleSize: Dimensions): Point { return { diff --git a/packages/captcha/deno.json b/packages/captcha/deno.json index e10a6b36..ce71ebf8 100644 --- a/packages/captcha/deno.json +++ b/packages/captcha/deno.json @@ -2,6 +2,6 @@ "name": "@ditto/captcha", "version": "1.0.0", "exports": { - ".": "./captcha.ts" + ".": "./mod.ts" } } diff --git a/packages/captcha/geometry.ts b/packages/captcha/geometry.ts new file mode 100644 index 00000000..b1993e0b --- /dev/null +++ b/packages/captcha/geometry.ts @@ -0,0 +1,22 @@ +export interface Point { + x: number; + y: number; +} + +export interface Dimensions { + w: number; + h: number; +} + +export type Rectangle = Point & Dimensions; + +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; +} diff --git a/packages/captcha/mod.ts b/packages/captcha/mod.ts new file mode 100644 index 00000000..352d6b5c --- /dev/null +++ b/packages/captcha/mod.ts @@ -0,0 +1,2 @@ +export { getCaptchaImages } from './assets.ts'; +export { generateCaptcha, verifyCaptchaSolution } from './captcha.ts'; From a2aaa55b89a582afa64df051df0b8decbe4f4943 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 15:09:01 -0600 Subject: [PATCH 3/4] Add tests to captcha modules --- packages/captcha/assets.test.ts | 9 +++++++++ packages/captcha/canvas.test.ts | 22 ++++++++++++++++++++++ packages/captcha/captcha.test.ts | 12 ++++++++++++ packages/captcha/geometry.test.ts | 8 ++++++++ packages/captcha/geometry.ts | 9 +++++++-- 5 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 packages/captcha/assets.test.ts create mode 100644 packages/captcha/canvas.test.ts create mode 100644 packages/captcha/captcha.test.ts create mode 100644 packages/captcha/geometry.test.ts diff --git a/packages/captcha/assets.test.ts b/packages/captcha/assets.test.ts new file mode 100644 index 00000000..c86eb203 --- /dev/null +++ b/packages/captcha/assets.test.ts @@ -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); +}); diff --git a/packages/captcha/canvas.test.ts b/packages/captcha/canvas.test.ts new file mode 100644 index 00000000..6e066a1d --- /dev/null +++ b/packages/captcha/canvas.test.ts @@ -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)); +}); diff --git a/packages/captcha/captcha.test.ts b/packages/captcha/captcha.test.ts new file mode 100644 index 00000000..285adae7 --- /dev/null +++ b/packages/captcha/captcha.test.ts @@ -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 }); +}); diff --git a/packages/captcha/geometry.test.ts b/packages/captcha/geometry.test.ts new file mode 100644 index 00000000..057ebf3d --- /dev/null +++ b/packages/captcha/geometry.test.ts @@ -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); +}); diff --git a/packages/captcha/geometry.ts b/packages/captcha/geometry.ts index b1993e0b..40e5f2fd 100644 --- a/packages/captcha/geometry.ts +++ b/packages/captcha/geometry.ts @@ -8,15 +8,20 @@ export interface Dimensions { h: number; } -export type Rectangle = Point & Dimensions; +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; + + return dist <= (e1 + e2) * threshold; } From 4b55acb796dfd8107e0f5e68515a3961a049e74c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 26 Feb 2025 15:10:41 -0600 Subject: [PATCH 4/4] Clean up captchaController --- packages/ditto/controllers/api/captcha.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ditto/controllers/api/captcha.ts b/packages/ditto/controllers/api/captcha.ts index 0f1f9ac4..d96e684a 100644 --- a/packages/ditto/controllers/api/captcha.ts +++ b/packages/ditto/controllers/api/captcha.ts @@ -10,6 +10,11 @@ interface Point { y: number; } +const pointSchema: z.ZodType = z.object({ + x: z.number(), + y: z.number(), +}); + const captchas = new TTLCache(); const imagesAsync = getCaptchaImages(); @@ -42,11 +47,6 @@ export const captchaController: AppController = async (c) => { }); }; -const pointSchema = z.object({ - x: z.number(), - y: z.number(), -}); - /** Verify the captcha solution and sign an event in the database. */ export const captchaVerifyController: AppController = async (c) => { const { user } = c.var;