diff --git a/deno.json b/deno.json index 7df7a010..f97d4fa7 100644 --- a/deno.json +++ b/deno.json @@ -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", diff --git a/deno.lock b/deno.lock index d11a5fbd..84969bb2 100644 --- a/deno.lock +++ b/deno.lock @@ -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", diff --git a/src/DittoWallet.ts b/src/DittoWallet.ts index 25412f62..95616325 100644 --- a/src/DittoWallet.ts +++ b/src/DittoWallet.ts @@ -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); diff --git a/src/app.ts b/src/app.ts index 9fb67ced..e9ab44fc 100644 --- a/src/app.ts +++ b/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); diff --git a/src/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg b/src/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg new file mode 100644 index 00000000..91430877 Binary files /dev/null and b/src/assets/captcha/bg/A Large Body of Water Surrounded By Mountains.jpg differ diff --git a/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg b/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg new file mode 100644 index 00000000..a2bf0463 Binary files /dev/null and b/src/assets/captcha/bg/A Trail of Footprints In The Sand.jpg differ diff --git a/src/assets/captcha/bg/Ashim DSilva.jpg b/src/assets/captcha/bg/Ashim DSilva.jpg new file mode 100644 index 00000000..2a1f85a4 Binary files /dev/null and b/src/assets/captcha/bg/Ashim DSilva.jpg differ diff --git a/src/assets/captcha/bg/Canazei Granite Ridges.jpg b/src/assets/captcha/bg/Canazei Granite Ridges.jpg new file mode 100644 index 00000000..b8b10abe Binary files /dev/null and b/src/assets/captcha/bg/Canazei Granite Ridges.jpg differ diff --git a/src/assets/captcha/bg/Martin Adams.jpg b/src/assets/captcha/bg/Martin Adams.jpg new file mode 100644 index 00000000..1a9040d6 Binary files /dev/null and b/src/assets/captcha/bg/Martin Adams.jpg differ diff --git a/src/assets/captcha/bg/Morskie Oko.jpg b/src/assets/captcha/bg/Morskie Oko.jpg new file mode 100644 index 00000000..0a0acdf2 Binary files /dev/null and b/src/assets/captcha/bg/Morskie Oko.jpg differ diff --git a/src/assets/captcha/bg/Mr. Lee.jpg b/src/assets/captcha/bg/Mr. Lee.jpg new file mode 100644 index 00000000..1b91ece5 Binary files /dev/null and b/src/assets/captcha/bg/Mr. Lee.jpg differ diff --git a/src/assets/captcha/bg/Nattu Adnan.jpg b/src/assets/captcha/bg/Nattu Adnan.jpg new file mode 100644 index 00000000..4017b8f6 Binary files /dev/null and b/src/assets/captcha/bg/Nattu Adnan.jpg differ diff --git a/src/assets/captcha/bg/Photo by SpaceX.jpg b/src/assets/captcha/bg/Photo by SpaceX.jpg new file mode 100644 index 00000000..72ec3286 Binary files /dev/null and b/src/assets/captcha/bg/Photo by SpaceX.jpg differ diff --git a/src/assets/captcha/bg/Photo of Valley.jpg b/src/assets/captcha/bg/Photo of Valley.jpg new file mode 100644 index 00000000..ccab4249 Binary files /dev/null and b/src/assets/captcha/bg/Photo of Valley.jpg differ diff --git a/src/assets/captcha/bg/Snow-Capped Mountain.jpg b/src/assets/captcha/bg/Snow-Capped Mountain.jpg new file mode 100644 index 00000000..cb01d6ee Binary files /dev/null and b/src/assets/captcha/bg/Snow-Capped Mountain.jpg differ diff --git a/src/assets/captcha/bg/Sunset by the Pier.jpg b/src/assets/captcha/bg/Sunset by the Pier.jpg new file mode 100644 index 00000000..d2ce7269 Binary files /dev/null and b/src/assets/captcha/bg/Sunset by the Pier.jpg differ diff --git a/src/assets/captcha/bg/Tj Holowaychuk.jpg b/src/assets/captcha/bg/Tj Holowaychuk.jpg new file mode 100644 index 00000000..3524983e Binary files /dev/null and b/src/assets/captcha/bg/Tj Holowaychuk.jpg differ diff --git a/src/assets/captcha/bg/Viktor Forgacs.jpg b/src/assets/captcha/bg/Viktor Forgacs.jpg new file mode 100644 index 00000000..740e1f79 Binary files /dev/null and b/src/assets/captcha/bg/Viktor Forgacs.jpg differ diff --git a/src/assets/captcha/bg/copyright.txt b/src/assets/captcha/bg/copyright.txt new file mode 100644 index 00000000..04374e83 --- /dev/null +++ b/src/assets/captcha/bg/copyright.txt @@ -0,0 +1,22 @@ +Unsplash photos published before June 8, 2017 are CC0 (public domain): + +Ashim D'Silva +Canazei Granite Ridges +Mr. Lee +Photo by SpaceX +Sunset by the Pier + +Unsplash photos published on or after June 8, 2017 are free to use, modify, and redistribute subject to the Unsplash license : + +Martin Adams +Morskie Oko +Nattu Adnan +Tj Holowaychuk +Viktor Forgacs +“A Large Body of Water Surrounded By Mountains” by Peter Thomas +“A Trail of Footprints In The Sand” by David Emrich +“Photo of Valley” by Aniket Doele + +Pexels photos are free to use, modify, and redistribute subject to the Pexels license : + +Snow-Capped Mountain diff --git a/src/assets/captcha/puzzle-hole.png b/src/assets/captcha/puzzle-hole.png new file mode 100644 index 00000000..2dd9bb0b Binary files /dev/null and b/src/assets/captcha/puzzle-hole.png differ diff --git a/src/assets/captcha/puzzle-hole.svg b/src/assets/captcha/puzzle-hole.svg new file mode 100644 index 00000000..20eecfe6 --- /dev/null +++ b/src/assets/captcha/puzzle-hole.svg @@ -0,0 +1,23 @@ + + diff --git a/src/assets/captcha/puzzle-mask.png b/src/assets/captcha/puzzle-mask.png new file mode 100644 index 00000000..ffc3a78e Binary files /dev/null and b/src/assets/captcha/puzzle-mask.png differ diff --git a/src/assets/captcha/puzzle-mask.svg b/src/assets/captcha/puzzle-mask.svg new file mode 100644 index 00000000..a72bd3f5 --- /dev/null +++ b/src/assets/captcha/puzzle-mask.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/config.ts b/src/config.ts index d471076d..c7f5e7cb 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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'"; diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index f27b20e1..c4a2bc40 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -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); }; diff --git a/src/controllers/api/captcha.ts b/src/controllers/api/captcha.ts new file mode 100644 index 00000000..ef266745 --- /dev/null +++ b/src/controllers/api/captcha.ts @@ -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(); +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 { + 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(), +}); + +/** 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; +} diff --git a/src/controllers/api/oauth.ts b/src/controllers/api/oauth.ts index c4c3eca4..61f8e1cd 100644 --- a/src/controllers/api/oauth.ts +++ b/src/controllers/api/oauth.ts @@ -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(); diff --git a/src/db/migrations/037_auth_tokens.ts b/src/db/migrations/037_auth_tokens.ts index 71e971d3..2f6d1890 100644 --- a/src/db/migrations/037_auth_tokens.ts +++ b/src/db/migrations/037_auth_tokens.ts @@ -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): Promise { 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(); diff --git a/src/entities/MastodonAccount.ts b/src/entities/MastodonAccount.ts index d0514185..b713d70c 100644 --- a/src/entities/MastodonAccount.ts +++ b/src/entities/MastodonAccount.ts @@ -34,6 +34,9 @@ export interface MastodonAccount { nostr: { nip05?: string; }; + ditto: { + captcha_solved: boolean; + }; }; statuses_count: number; uri: string; diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 8200ae1d..b4cab1ec 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -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 { diff --git a/src/utils/aes.bench.ts b/src/utils/aes.bench.ts new file mode 100644 index 00000000..9d8dd3b9 --- /dev/null +++ b/src/utils/aes.bench.ts @@ -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); +}); diff --git a/src/utils/aes.test.ts b/src/utils/aes.test.ts new file mode 100644 index 00000000..583b96a2 --- /dev/null +++ b/src/utils/aes.test.ts @@ -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)); +}); diff --git a/src/utils/aes.ts b/src/utils/aes.ts new file mode 100644 index 00000000..983fc39c --- /dev/null +++ b/src/utils/aes.ts @@ -0,0 +1,17 @@ +/** Encrypt data with AES-GCM and a secret key. */ +export async function aesEncrypt(sk: Uint8Array, plaintext: Uint8Array): Promise { + 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 { + 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); +} diff --git a/src/utils/auth.bench.ts b/src/utils/auth.bench.ts index 8c3da7cf..fbffc857 100644 --- a/src/utils/auth.bench.ts +++ b/src/utils/auth.bench.ts @@ -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); -}); diff --git a/src/utils/auth.test.ts b/src/utils/auth.test.ts index e9e610c1..cf462d5d 100644 --- a/src/utils/auth.test.ts +++ b/src/utils/auth.test.ts @@ -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)); -}); diff --git a/src/utils/auth.ts b/src/utils/auth.ts index 05e838a9..8d71ed6f 100644 --- a/src/utils/auth.ts +++ b/src/utils/auth.ts @@ -28,27 +28,3 @@ export async function getTokenHash(token: `token1${string}`): Promise { - 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 { - 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); -} diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index b3efdc82..1e10c4ff 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -87,6 +87,9 @@ async function renderAccount( nostr: { nip05, }, + ditto: { + captcha_solved: false, + }, } : undefined, statuses_count: event.author_stats?.notes_count ?? 0, diff --git a/src/workers/policy.ts b/src/workers/policy.ts index 941b5cf1..debfe242 100644 --- a/src/workers/policy.ts +++ b/src/workers/policy.ts @@ -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}`); diff --git a/src/workers/policy.worker.ts b/src/workers/policy.worker.ts index 6b256a21..86cea87c 100644 --- a/src/workers/policy.worker.ts +++ b/src/workers/policy.worker.ts @@ -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 { + async init({ path, databaseUrl, pubkey }: PolicyInit): Promise { 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 }); } }