Merge branch 'captcha' into 'main'
Puzzle Captcha API See merge request soapbox-pub/ditto!536
|
|
@ -36,6 +36,7 @@
|
|||
"@b-fuze/deno-dom": "jsr:@b-fuze/deno-dom@^0.1.47",
|
||||
"@bradenmacdonald/s3-lite-client": "jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||
"@electric-sql/pglite": "npm:@electric-sql/pglite@^0.2.8",
|
||||
"@gfx/canvas-wasm": "jsr:@gfx/canvas-wasm@^0.4.2",
|
||||
"@hono/hono": "jsr:@hono/hono@^4.4.6",
|
||||
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
|
||||
"@lambdalisue/async": "jsr:@lambdalisue/async@^2.1.1",
|
||||
|
|
|
|||
12
deno.lock
generated
|
|
@ -5,6 +5,7 @@
|
|||
"jsr:@b-fuze/deno-dom@^0.1.47": "jsr:@b-fuze/deno-dom@0.1.48",
|
||||
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4": "jsr:@bradenmacdonald/s3-lite-client@0.7.6",
|
||||
"jsr:@denosaurs/plug@1.0.3": "jsr:@denosaurs/plug@1.0.3",
|
||||
"jsr:@gfx/canvas-wasm@^0.4.2": "jsr:@gfx/canvas-wasm@0.4.2",
|
||||
"jsr:@gleasonator/policy": "jsr:@gleasonator/policy@0.2.0",
|
||||
"jsr:@gleasonator/policy@0.2.0": "jsr:@gleasonator/policy@0.2.0",
|
||||
"jsr:@gleasonator/policy@0.4.0": "jsr:@gleasonator/policy@0.4.0",
|
||||
|
|
@ -53,6 +54,7 @@
|
|||
"jsr:@std/crypto@^0.224.0": "jsr:@std/crypto@0.224.0",
|
||||
"jsr:@std/dotenv@^0.224.0": "jsr:@std/dotenv@0.224.2",
|
||||
"jsr:@std/encoding@0.213.1": "jsr:@std/encoding@0.213.1",
|
||||
"jsr:@std/encoding@1.0.5": "jsr:@std/encoding@1.0.5",
|
||||
"jsr:@std/encoding@^0.224.0": "jsr:@std/encoding@0.224.3",
|
||||
"jsr:@std/encoding@^0.224.1": "jsr:@std/encoding@0.224.3",
|
||||
"jsr:@std/fmt@0.213.1": "jsr:@std/fmt@0.213.1",
|
||||
|
|
@ -138,6 +140,12 @@
|
|||
"jsr:@std/path@0.213.1"
|
||||
]
|
||||
},
|
||||
"@gfx/canvas-wasm@0.4.2": {
|
||||
"integrity": "d653be3bd12cb2fa9bbe5d1b1f041a81b91d80b68502761204aaf60e4592532a",
|
||||
"dependencies": [
|
||||
"jsr:@std/encoding@1.0.5"
|
||||
]
|
||||
},
|
||||
"@gleasonator/policy@0.2.0": {
|
||||
"integrity": "3fe58b853ab203b2b67e65b64391dbcf5c07bc1caaf46e97b2f8ed5b14f30fdf",
|
||||
"dependencies": [
|
||||
|
|
@ -470,6 +478,9 @@
|
|||
"@std/encoding@0.224.3": {
|
||||
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
|
||||
},
|
||||
"@std/encoding@1.0.5": {
|
||||
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
|
||||
},
|
||||
"@std/fmt@0.213.1": {
|
||||
"integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3"
|
||||
},
|
||||
|
|
@ -2135,6 +2146,7 @@
|
|||
"dependencies": [
|
||||
"jsr:@b-fuze/deno-dom@^0.1.47",
|
||||
"jsr:@bradenmacdonald/s3-lite-client@^0.7.4",
|
||||
"jsr:@gfx/canvas-wasm@^0.4.2",
|
||||
"jsr:@hono/hono@^4.4.6",
|
||||
"jsr:@lambdalisue/async@^2.1.1",
|
||||
"jsr:@nostrify/db@^0.35.0",
|
||||
|
|
|
|||
|
|
@ -39,11 +39,6 @@ export class DittoWallet {
|
|||
return this.deriveKey(Conf.wallet.dbKeyPath);
|
||||
}
|
||||
|
||||
/** Captcha encryption key for encrypting answer data in AES-GCM. */
|
||||
static get captchaKey(): Uint8Array {
|
||||
return this.deriveKey(Conf.wallet.captchaKeyPath);
|
||||
}
|
||||
|
||||
/** VAPID secret key, used for web push notifications. ES256. */
|
||||
static get vapidKey(): Uint8Array {
|
||||
return this.deriveKey(Conf.wallet.vapidKeyPath);
|
||||
|
|
|
|||
12
src/app.ts
|
|
@ -5,6 +5,8 @@ import { logger } from '@hono/hono/logger';
|
|||
import { NostrEvent, NostrSigner, NStore, NUploader } from '@nostrify/nostrify';
|
||||
import Debug from '@soapbox/stickynotes/debug';
|
||||
|
||||
import '@/startup.ts';
|
||||
|
||||
import { Time } from '@/utils/time.ts';
|
||||
|
||||
import {
|
||||
|
|
@ -36,6 +38,7 @@ import {
|
|||
import { appCredentialsController, createAppController } from '@/controllers/api/apps.ts';
|
||||
import { blocksController } from '@/controllers/api/blocks.ts';
|
||||
import { bookmarksController } from '@/controllers/api/bookmarks.ts';
|
||||
import { captchaController, captchaVerifyController } from '@/controllers/api/captcha.ts';
|
||||
import {
|
||||
adminRelaysController,
|
||||
adminSetRelaysController,
|
||||
|
|
@ -113,7 +116,6 @@ import { errorHandler } from '@/controllers/error.ts';
|
|||
import { frontendController } from '@/controllers/frontend.ts';
|
||||
import { metricsController } from '@/controllers/metrics.ts';
|
||||
import { indexController } from '@/controllers/site.ts';
|
||||
import '@/startup.ts';
|
||||
import { manifestController } from '@/controllers/manifest.ts';
|
||||
import { nodeInfoController, nodeInfoSchemaController } from '@/controllers/well-known/nodeinfo.ts';
|
||||
import { nostrController } from '@/controllers/well-known/nostr.ts';
|
||||
|
|
@ -277,6 +279,14 @@ app.put('/api/v1/admin/ditto/relays', requireRole('admin'), adminSetRelaysContro
|
|||
app.post('/api/v1/ditto/names', requireSigner, nameRequestController);
|
||||
app.get('/api/v1/ditto/names', requireSigner, nameRequestsController);
|
||||
|
||||
app.get('/api/v1/ditto/captcha', rateLimitMiddleware(3, Time.minutes(1)), captchaController);
|
||||
app.post(
|
||||
'/api/v1/ditto/captcha/:id/verify',
|
||||
rateLimitMiddleware(8, Time.minutes(1)),
|
||||
requireProof(),
|
||||
captchaVerifyController,
|
||||
);
|
||||
|
||||
app.get('/api/v1/ditto/zap_splits', getZapSplitsController);
|
||||
app.get('/api/v1/ditto/:id{[0-9a-f]{64}}/zap_splits', statusZapSplitsController);
|
||||
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 26 KiB |
BIN
src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Ashim DSilva.jpg
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src/assets/captcha/bg/Canazei Granite Ridges.jpg
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
src/assets/captcha/bg/Martin Adams.jpg
Normal file
|
After Width: | Height: | Size: 9.5 KiB |
BIN
src/assets/captcha/bg/Morskie Oko.jpg
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
src/assets/captcha/bg/Mr. Lee.jpg
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src/assets/captcha/bg/Nattu Adnan.jpg
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
src/assets/captcha/bg/Photo by SpaceX.jpg
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
src/assets/captcha/bg/Photo of Valley.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
src/assets/captcha/bg/Snow-Capped Mountain.jpg
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
src/assets/captcha/bg/Sunset by the Pier.jpg
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src/assets/captcha/bg/Tj Holowaychuk.jpg
Normal file
|
After Width: | Height: | Size: 19 KiB |
BIN
src/assets/captcha/bg/Viktor Forgacs.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
22
src/assets/captcha/bg/copyright.txt
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
Unsplash photos published before June 8, 2017 are CC0 (public domain):
|
||||
|
||||
Ashim D'Silva <https://unsplash.com/photos/WeYamle9fDM>
|
||||
Canazei Granite Ridges <https://unsplash.com/photos/yrwpJwDNSHE>
|
||||
Mr. Lee <https://unsplash.com/photos/v7r8kZStqFw>
|
||||
Photo by SpaceX <https://unsplash.com/photos/VBNb52J8Trk>
|
||||
Sunset by the Pier <https://unsplash.com/photos/ces8_Bo7bhQ>
|
||||
|
||||
Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license <https://unsplash.com/license>:
|
||||
|
||||
Martin Adams <https://unsplash.com/photos/MpTdvXlAsVE>
|
||||
Morskie Oko <https://unsplash.com/photos/_1UF_3TlKcQ>
|
||||
Nattu Adnan <https://unsplash.com/photos/Ai2TRdvI6gM>
|
||||
Tj Holowaychuk <https://unsplash.com/photos/iGrsa9rL11o>
|
||||
Viktor Forgacs <https://unsplash.com/photos/q8XSCZYh6D8>
|
||||
“A Large Body of Water Surrounded By Mountains” by Peter Thomas <https://unsplash.com/photos/Dxod5pdRtsk>
|
||||
“A Trail of Footprints In The Sand” by David Emrich <https://unsplash.com/photos/A9mr3TPoj0k>
|
||||
“Photo of Valley” by Aniket Doele <https://unsplash.com/photos/M6XC789HLe8>
|
||||
|
||||
Pexels photos are free to use, modify, and redistribute subject to the Pexels license <https://www.pexels.com/license/>:
|
||||
|
||||
Snow-Capped Mountain <https://www.pexels.com/photo/photo-of-snow-capped-mountain-during-evening-2440024/>
|
||||
BIN
src/assets/captcha/puzzle-hole.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
23
src/assets/captcha/puzzle-hole.svg
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"
|
||||
version="1.1"
|
||||
id="svg2"
|
||||
xml:space="preserve"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"><defs
|
||||
id="defs2" /><path
|
||||
stroke="none"
|
||||
d="M0 0h24v24H0z"
|
||||
fill="none"
|
||||
id="path1" /><path
|
||||
d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z"
|
||||
id="path2"
|
||||
style="opacity:0.7" /><path
|
||||
id="path2-3"
|
||||
style="color:#000000;fill:#ffffff;-inkscape-stroke:none;opacity:0.9"
|
||||
d="M 10,1 C 7.80271,1 6,2.80271 6,5 H 3.9824219 l -0.1875,0.00586 -0.019531,0.00195 C 2.2191757,5.1248607 0.99950612,6.4393901 1,8 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1244168,12.774924 2.429603,13.991286 3.9824219,14 l -0.1875,0.0059 -0.019531,0.002 C 2.2191756,14.12477 0.99950612,15.43939 1,17 v 3.017578 l 0.00586,0.1875 0.00195,0.01953 C 1.1248607,21.780826 2.4393901,23.000494 4,23 h 3.0175781 l 0.1875,-0.0059 0.019531,-0.002 C 8.7749229,22.875673 9.9912857,21.570397 10,20.017578 l 0.0059,0.1875 0.002,0.01953 C 10.12477,21.780824 11.43939,23.000494 13,23 h 3.017578 l 0.1875,-0.0059 0.01953,-0.002 C 17.780826,22.87523 19.000494,21.56061 19,20 v -2 h 0.01367 l 0.205078,-0.0059 h 0.01563 c 1.21751,-0.07037 2.231032,-0.615044 2.871094,-1.396485 0.64006,-0.78144 0.924537,-1.759302 0.896484,-2.714844 C 22.973903,12.92727 22.63402,11.969108 21.949219,11.226562 21.264413,10.484057 20.219542,9.9988247 19,10 V 7.9824219 l -0.0059,-0.1875 -0.002,-0.019531 C 18.87523,6.2191757 17.56061,4.9995061 16,5 H 14 V 4.9863281 L 13.994141,4.78125 V 4.765625 C 13.870457,2.6610112 12.108245,0.99988256 10,1 Z m 0,1 a 3,3 0 0 1 2.994141,2.8242188 L 13,5 v 1 h 3 a 2,2 0 0 1 1.994141,1.8496094 L 18,8 v 3 h 1 a 3,3 0 0 1 0.175781,5.994141 L 19,17 h -1 v 3 a 2,2 0 0 1 -1.849609,1.994141 L 16,22 H 13 A 2,2 0 0 1 11.005859,20.150391 L 11,20 V 19 A 1,1 0 0 0 9.0078125,18.882812 L 9,19 v 1 A 2,2 0 0 1 7.1503906,21.994141 L 7,22 H 4 A 2,2 0 0 1 2.0058594,20.150391 L 2,20 V 17 A 2,2 0 0 1 3.8496094,15.005859 L 4,15 H 5 A 1,1 0 0 0 5.1171875,13.007812 L 5,13 H 4 A 2,2 0 0 1 2.0058594,11.150391 L 2,11 V 8 A 2,2 0 0 1 3.8496094,6.0058594 L 4,6 H 7 V 5 a 3,3 0 0 1 3,-3 z" /></svg>
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
BIN
src/assets/captcha/puzzle-mask.png
Normal file
|
After Width: | Height: | Size: 997 B |
1
src/assets/captcha/puzzle-mask.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" class="icon icon-tabler icons-tabler-filled icon-tabler-puzzle"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M10 2a3 3 0 0 1 2.995 2.824l.005 .176v1h3a2 2 0 0 1 1.995 1.85l.005 .15v3h1a3 3 0 0 1 .176 5.995l-.176 .005h-1v3a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-1a1 1 0 0 0 -1.993 -.117l-.007 .117v1a2 2 0 0 1 -1.85 1.995l-.15 .005h-3a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h1a1 1 0 0 0 .117 -1.993l-.117 -.007h-1a2 2 0 0 1 -1.995 -1.85l-.005 -.15v-3a2 2 0 0 1 1.85 -1.995l.15 -.005h3v-1a3 3 0 0 1 3 -3z" /></svg>
|
||||
|
After Width: | Height: | Size: 696 B |
|
|
@ -99,6 +99,10 @@ class Conf {
|
|||
},
|
||||
},
|
||||
};
|
||||
/** Time-to-live for captchas in milliseconds. */
|
||||
static get captchaTTL(): number {
|
||||
return Number(Deno.env.get('CAPTCHA_TTL') || 5 * 60 * 1000);
|
||||
}
|
||||
/**
|
||||
* BIP-32 derivation paths for different crypto use-cases.
|
||||
* The `DITTO_NSEC` is used as the seed.
|
||||
|
|
@ -109,10 +113,6 @@ class Conf {
|
|||
get dbKeyPath(): string {
|
||||
return Deno.env.get('WALLET_DB_KEY_PATH') || "m/0'/1'";
|
||||
},
|
||||
/** Private key for AES-GCM encryption of captcha answer data. */
|
||||
get captchaKeyPath(): string {
|
||||
return Deno.env.get('WALLET_CAPTCHA_KEY_PATH') || "m/0'/2'";
|
||||
},
|
||||
/** VAPID private key path. */
|
||||
get vapidKeyPath(): string {
|
||||
return Deno.env.get('WALLET_VAPID_KEY_PATH') || "m/0'/3'";
|
||||
|
|
|
|||
|
|
@ -49,17 +49,26 @@ const verifyCredentialsController: AppController = async (c) => {
|
|||
const signer = c.get('signer')!;
|
||||
const pubkey = await signer.getPublicKey();
|
||||
|
||||
const eventsDB = await Storages.db();
|
||||
const store = await Storages.db();
|
||||
|
||||
const [author, [settingsStore]] = await Promise.all([
|
||||
const [author, [settingsStore], [captcha]] = await Promise.all([
|
||||
getAuthor(pubkey, { signal: AbortSignal.timeout(5000) }),
|
||||
|
||||
eventsDB.query([{
|
||||
authors: [pubkey],
|
||||
store.query([{
|
||||
kinds: [30078],
|
||||
authors: [pubkey],
|
||||
'#d': ['pub.ditto.pleroma_settings_store'],
|
||||
limit: 1,
|
||||
}]),
|
||||
|
||||
store.query([{
|
||||
kinds: [1985],
|
||||
authors: [Conf.pubkey],
|
||||
'#L': ['pub.ditto.captcha'],
|
||||
'#l': ['solved'],
|
||||
'#p': [pubkey],
|
||||
limit: 1,
|
||||
}]),
|
||||
]);
|
||||
|
||||
const account = author
|
||||
|
|
@ -74,6 +83,10 @@ const verifyCredentialsController: AppController = async (c) => {
|
|||
}
|
||||
}
|
||||
|
||||
if (captcha && account.source) {
|
||||
account.source.ditto.captcha_solved = true;
|
||||
}
|
||||
|
||||
return c.json(account);
|
||||
};
|
||||
|
||||
|
|
|
|||
206
src/controllers/api/captcha.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { CanvasRenderingContext2D, createCanvas, Image, loadImage } from '@gfx/canvas-wasm';
|
||||
import TTLCache from '@isaacs/ttlcache';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AppController } from '@/app.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { createAdminEvent } from '@/utils/api.ts';
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
interface Dimensions {
|
||||
w: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
const captchas = new TTLCache<string, Point>();
|
||||
const imagesAsync = getImages();
|
||||
|
||||
const BG_SIZE = { w: 370, h: 400 };
|
||||
const PUZZLE_SIZE = { w: 65, h: 65 };
|
||||
|
||||
/** Puzzle captcha controller. */
|
||||
export const captchaController: AppController = async (c) => {
|
||||
const { bg, puzzle, solution } = generateCaptcha(
|
||||
await imagesAsync,
|
||||
BG_SIZE,
|
||||
PUZZLE_SIZE,
|
||||
);
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date();
|
||||
const ttl = Conf.captchaTTL;
|
||||
|
||||
captchas.set(id, solution, { ttl });
|
||||
|
||||
return c.json({
|
||||
id,
|
||||
type: 'puzzle',
|
||||
bg: bg.toDataURL(),
|
||||
puzzle: puzzle.toDataURL(),
|
||||
created_at: now.toISOString(),
|
||||
expires_at: new Date(now.getTime() + ttl).toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
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. */
|
||||
export const captchaVerifyController: AppController = async (c) => {
|
||||
const id = c.req.param('id');
|
||||
const result = pointSchema.safeParse(await c.req.json());
|
||||
const pubkey = await c.get('signer')!.getPublicKey();
|
||||
|
||||
if (!result.success) {
|
||||
return c.json({ error: 'Invalid input' }, { status: 422 });
|
||||
}
|
||||
|
||||
const solution = captchas.get(id);
|
||||
|
||||
if (!solution) {
|
||||
return c.json({ error: 'Captcha expired' }, { status: 410 });
|
||||
}
|
||||
|
||||
const solved = verifySolution(PUZZLE_SIZE, result.data, solution);
|
||||
|
||||
if (solved) {
|
||||
captchas.delete(id);
|
||||
|
||||
await createAdminEvent({
|
||||
kind: 1985,
|
||||
tags: [
|
||||
['L', 'pub.ditto.captcha'],
|
||||
['l', 'solved', 'pub.ditto.captcha'],
|
||||
['p', pubkey, Conf.relay],
|
||||
],
|
||||
}, c);
|
||||
|
||||
return new Response(null, { status: 204 });
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -8,7 +8,8 @@ import { Conf } from '@/config.ts';
|
|||
import { Storages } from '@/storages.ts';
|
||||
import { nostrNow } from '@/utils.ts';
|
||||
import { parseBody } from '@/utils/api.ts';
|
||||
import { encryptSecretKey, generateToken } from '@/utils/auth.ts';
|
||||
import { aesEncrypt } from '@/utils/aes.ts';
|
||||
import { generateToken } from '@/utils/auth.ts';
|
||||
|
||||
const passwordGrantSchema = z.object({
|
||||
grant_type: z.literal('password'),
|
||||
|
|
@ -98,7 +99,7 @@ async function getToken(
|
|||
await kysely.insertInto('auth_tokens').values({
|
||||
token_hash: hash,
|
||||
pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, nip46Seckey),
|
||||
nip46_sk_enc: await aesEncrypt(Conf.seckey, nip46Seckey),
|
||||
nip46_relays: relays,
|
||||
created_at: new Date(),
|
||||
}).execute();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
import { encryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
import { Conf } from '@/config.ts';
|
||||
import { aesEncrypt } from '@/utils/aes.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
interface DB {
|
||||
nip46_tokens: {
|
||||
|
|
@ -38,7 +39,7 @@ export async function up(db: Kysely<DB>): Promise<void> {
|
|||
await db.insertInto('auth_tokens').values({
|
||||
token_hash: await getTokenHash(token.api_token),
|
||||
pubkey: token.user_pubkey,
|
||||
nip46_sk_enc: await encryptSecretKey(Conf.seckey, token.server_seckey),
|
||||
nip46_sk_enc: await aesEncrypt(Conf.seckey, token.server_seckey),
|
||||
nip46_relays: JSON.parse(token.relays),
|
||||
created_at: token.connected_at,
|
||||
}).execute();
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ export interface MastodonAccount {
|
|||
nostr: {
|
||||
nip05?: string;
|
||||
};
|
||||
ditto: {
|
||||
captcha_solved: boolean;
|
||||
};
|
||||
};
|
||||
statuses_count: number;
|
||||
uri: string;
|
||||
|
|
|
|||
|
|
@ -7,7 +7,8 @@ import { Conf } from '@/config.ts';
|
|||
import { ConnectSigner } from '@/signers/ConnectSigner.ts';
|
||||
import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts';
|
||||
import { Storages } from '@/storages.ts';
|
||||
import { decryptSecretKey, getTokenHash } from '@/utils/auth.ts';
|
||||
import { aesDecrypt } from '@/utils/aes.ts';
|
||||
import { getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
/** We only accept "Bearer" type. */
|
||||
const BEARER_REGEX = new RegExp(`^Bearer (${nip19.BECH32_REGEX.source})$`);
|
||||
|
|
@ -31,7 +32,7 @@ export const signerMiddleware: AppMiddleware = async (c, next) => {
|
|||
.where('token_hash', '=', tokenHash)
|
||||
.executeTakeFirstOrThrow();
|
||||
|
||||
const nep46Seckey = await decryptSecretKey(Conf.seckey, nip46_sk_enc);
|
||||
const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc);
|
||||
|
||||
c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays));
|
||||
} catch {
|
||||
|
|
|
|||
18
src/utils/aes.bench.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts';
|
||||
|
||||
Deno.bench('aesEncrypt', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
b.start();
|
||||
await aesEncrypt(sk, decrypted);
|
||||
});
|
||||
|
||||
Deno.bench('aesDecrypt', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
const encrypted = await aesEncrypt(sk, decrypted);
|
||||
b.start();
|
||||
await aesDecrypt(sk, encrypted);
|
||||
});
|
||||
15
src/utils/aes.test.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { encodeHex } from '@std/encoding/hex';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { aesDecrypt, aesEncrypt } from '@/utils/aes.ts';
|
||||
|
||||
Deno.test('aesDecrypt & aesEncrypt', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const data = generateSecretKey();
|
||||
|
||||
const encrypted = await aesEncrypt(sk, data);
|
||||
const decrypted = await aesDecrypt(sk, encrypted);
|
||||
|
||||
assertEquals(encodeHex(decrypted), encodeHex(data));
|
||||
});
|
||||
17
src/utils/aes.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
/** Encrypt data with AES-GCM and a secret key. */
|
||||
export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, plaintext);
|
||||
|
||||
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
|
||||
}
|
||||
|
||||
/** Decrypt data with AES-GCM and a secret key. */
|
||||
export async function aesDecrypt(sk: Uint8Array, ciphertext: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const iv = ciphertext.slice(0, 12);
|
||||
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, ciphertext.slice(12));
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
|
@ -1,6 +1,4 @@
|
|||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
Deno.bench('generateToken', async () => {
|
||||
await generateToken();
|
||||
|
|
@ -11,18 +9,3 @@ Deno.bench('getTokenHash', async (b) => {
|
|||
b.start();
|
||||
await getTokenHash(token);
|
||||
});
|
||||
|
||||
Deno.bench('encryptSecretKey', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
b.start();
|
||||
await encryptSecretKey(sk, decrypted);
|
||||
});
|
||||
|
||||
Deno.bench('decryptSecretKey', async (b) => {
|
||||
const sk = generateSecretKey();
|
||||
const decrypted = generateSecretKey();
|
||||
const encrypted = await encryptSecretKey(sk, decrypted);
|
||||
b.start();
|
||||
await decryptSecretKey(sk, encrypted);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { assertEquals } from '@std/assert';
|
||||
import { decodeHex, encodeHex } from '@std/encoding/hex';
|
||||
import { generateSecretKey } from 'nostr-tools';
|
||||
|
||||
import { decryptSecretKey, encryptSecretKey, generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
import { generateToken, getTokenHash } from '@/utils/auth.ts';
|
||||
|
||||
Deno.test('generateToken', async () => {
|
||||
const sk = decodeHex('a0968751df8fd42f362213f08751911672f2a037113b392403bbb7dd31b71c95');
|
||||
|
|
@ -17,13 +16,3 @@ Deno.test('getTokenHash', async () => {
|
|||
const hash = await getTokenHash('token15ztgw5wl3l2z7d3zz0cgw5v3zee09gphzyanjfqrhwma6vdhrj2sauwknd');
|
||||
assertEquals(encodeHex(hash), 'ab4c4ead4d1c72a38fffd45b999937b7e3f25f867b19aaf252df858e77b66a8a');
|
||||
});
|
||||
|
||||
Deno.test('encryptSecretKey & decryptSecretKey', async () => {
|
||||
const sk = generateSecretKey();
|
||||
const data = generateSecretKey();
|
||||
|
||||
const encrypted = await encryptSecretKey(sk, data);
|
||||
const decrypted = await decryptSecretKey(sk, encrypted);
|
||||
|
||||
assertEquals(encodeHex(decrypted), encodeHex(data));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,27 +28,3 @@ export async function getTokenHash(token: `token1${string}`): Promise<Uint8Array
|
|||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a secret key with AES-GCM.
|
||||
* This function is used to store the secret key in the database.
|
||||
*/
|
||||
export async function encryptSecretKey(sk: Uint8Array, decrypted: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['encrypt']);
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
const buffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, secretKey, decrypted);
|
||||
|
||||
return new Uint8Array([...iv, ...new Uint8Array(buffer)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a secret key with AES-GCM.
|
||||
* This function is used to retrieve the secret key from the database.
|
||||
*/
|
||||
export async function decryptSecretKey(sk: Uint8Array, encrypted: Uint8Array): Promise<Uint8Array> {
|
||||
const secretKey = await crypto.subtle.importKey('raw', sk, { name: 'AES-GCM' }, false, ['decrypt']);
|
||||
const iv = encrypted.slice(0, 12);
|
||||
const buffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, secretKey, encrypted.slice(12));
|
||||
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,6 +87,9 @@ async function renderAccount(
|
|||
nostr: {
|
||||
nip05,
|
||||
},
|
||||
ditto: {
|
||||
captcha_solved: false,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
statuses_count: event.author_stats?.notes_count ?? 0,
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ class PolicyWorker implements NPolicy {
|
|||
await this.worker.init({
|
||||
path: Conf.policy,
|
||||
databaseUrl: Conf.databaseUrl,
|
||||
adminPubkey: Conf.pubkey,
|
||||
pubkey: Conf.pubkey,
|
||||
});
|
||||
|
||||
console.warn(`Using custom policy: ${Conf.policy}`);
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ interface PolicyInit {
|
|||
/** Database URL to connect to. */
|
||||
databaseUrl: string;
|
||||
/** Admin pubkey to use for EventsDB checks. */
|
||||
adminPubkey: string;
|
||||
pubkey: string;
|
||||
}
|
||||
|
||||
export class CustomPolicy implements NPolicy {
|
||||
|
|
@ -29,18 +29,18 @@ export class CustomPolicy implements NPolicy {
|
|||
return this.policy.call(event, signal);
|
||||
}
|
||||
|
||||
async init({ path, databaseUrl, adminPubkey }: PolicyInit): Promise<void> {
|
||||
async init({ path, databaseUrl, pubkey }: PolicyInit): Promise<void> {
|
||||
const Policy = (await import(path)).default;
|
||||
|
||||
const { kysely } = DittoDB.create(databaseUrl, { poolSize: 1 });
|
||||
|
||||
const store = new EventsDB({
|
||||
kysely,
|
||||
pubkey: adminPubkey,
|
||||
pubkey,
|
||||
timeout: 1_000,
|
||||
});
|
||||
|
||||
this.policy = new Policy({ store });
|
||||
this.policy = new Policy({ store, pubkey });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||