Merge branch 'main' into translate-status

This commit is contained in:
P. Reis 2024-10-07 15:02:47 -03:00
commit 01a16473ab
41 changed files with 456 additions and 73 deletions

View file

@ -1,6 +1,4 @@
FROM denoland/deno:1.44.2
EXPOSE 5000
ENV PORT 5000
WORKDIR /app

View file

@ -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",
@ -44,6 +45,7 @@
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.36.0",
"@nostrify/policies": "jsr:@nostrify/policies@^0.35.0",
"@scure/base": "npm:@scure/base@^1.1.6",
"@scure/bip32": "npm:@scure/bip32@^1.5.0",
"@sentry/deno": "https://deno.land/x/sentry@7.112.2/index.mjs",
"@soapbox/kysely-pglite": "jsr:@soapbox/kysely-pglite@^1.0.0",
"@soapbox/stickynotes": "jsr:@soapbox/stickynotes@^0.4.0",

36
deno.lock generated
View file

@ -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",
@ -73,6 +75,7 @@
"npm:@noble/secp256k1@^2.0.0": "npm:@noble/secp256k1@2.1.0",
"npm:@scure/base@^1.1.6": "npm:@scure/base@1.1.6",
"npm:@scure/bip32@^1.4.0": "npm:@scure/bip32@1.4.0",
"npm:@scure/bip32@^1.5.0": "npm:@scure/bip32@1.5.0",
"npm:@scure/bip39@^1.3.0": "npm:@scure/bip39@1.3.0",
"npm:@types/node": "npm:@types/node@18.16.19",
"npm:comlink-async-generator": "npm:comlink-async-generator@0.0.1",
@ -137,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": [
@ -469,6 +478,9 @@
"@std/encoding@0.224.3": {
"integrity": "5e861b6d81be5359fad4155e591acf17c0207b595112d1840998bb9f476dbdaf"
},
"@std/encoding@1.0.5": {
"integrity": "ecf363d4fc25bd85bd915ff6733a7e79b67e0e7806334af15f4645c569fefc04"
},
"@std/fmt@0.213.1": {
"integrity": "a06d31777566d874b9c856c10244ac3e6b660bdec4c82506cd46be052a1082c3"
},
@ -604,6 +616,12 @@
"@noble/hashes": "@noble/hashes@1.4.0"
}
},
"@noble/curves@1.6.0": {
"integrity": "sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ==",
"dependencies": {
"@noble/hashes": "@noble/hashes@1.5.0"
}
},
"@noble/hashes@1.3.1": {
"integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==",
"dependencies": {}
@ -616,6 +634,10 @@
"integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==",
"dependencies": {}
},
"@noble/hashes@1.5.0": {
"integrity": "sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA==",
"dependencies": {}
},
"@noble/secp256k1@2.1.0": {
"integrity": "sha512-XLEQQNdablO0XZOIniFQimiXsZDNwaYgL96dZwC54Q30imSbAOFf3NKtepc+cXyuZf5Q1HCgbqgZ2UFFuHVcEw==",
"dependencies": {}
@ -632,6 +654,10 @@
"integrity": "sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g==",
"dependencies": {}
},
"@scure/base@1.1.9": {
"integrity": "sha512-8YKhl8GHiNI/pU2VMaofa2Tor7PJRAjwQLBBuilkJ9L5+13yVbC7JO/wS7piioAvPSwR3JKM1IJ/u4xQzbcXKg==",
"dependencies": {}
},
"@scure/bip32@1.3.1": {
"integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==",
"dependencies": {
@ -648,6 +674,14 @@
"@scure/base": "@scure/base@1.1.6"
}
},
"@scure/bip32@1.5.0": {
"integrity": "sha512-8EnFYkqEQdnkuGBVpCzKxyIwDCBLDVj3oiX0EKUFre/tOjL/Hqba1D6n/8RcmaQy4f95qQFrO2A8Sr6ybh4NRw==",
"dependencies": {
"@noble/curves": "@noble/curves@1.6.0",
"@noble/hashes": "@noble/hashes@1.5.0",
"@scure/base": "@scure/base@1.1.9"
}
},
"@scure/bip39@1.2.1": {
"integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==",
"dependencies": {
@ -2112,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",
@ -2132,6 +2167,7 @@
"npm:@isaacs/ttlcache@^1.4.1",
"npm:@noble/secp256k1@^2.0.0",
"npm:@scure/base@^1.1.6",
"npm:@scure/bip32@^1.5.0",
"npm:comlink-async-generator@^0.0.1",
"npm:comlink@^4.4.1",
"npm:commander@12.1.0",

View file

@ -63,7 +63,7 @@ export function buildFilter(args: ExportFilter) {
if (invalid) throw new Error(`ERROR: Invalid pubkey ${invalid} supplied.`);
filter.authors = authors;
}
if (ids) {
if (ids && ids.length) {
const invalid = findInvalid(ids);
if (invalid) throw new Error(`ERROR: Invalid event ID ${invalid} supplied.`);
filter.ids = ids;

46
src/DittoWallet.ts Normal file
View file

@ -0,0 +1,46 @@
import { HDKey } from '@scure/bip32';
import { Conf } from '@/config.ts';
/**
* HD wallet based on the `DITTO_NSEC`.
* The wallet is used to derive keys for various purposes.
* It is a singleton with static methods, and the keys are cached.
*/
export class DittoWallet {
static #root = HDKey.fromMasterSeed(Conf.seckey);
static #keys = new Map<string, HDKey>();
/** Derive the key cached. */
static derive(path: string): HDKey {
const existing = this.#keys.get(path);
if (existing) {
return existing;
} else {
const key = this.#root.derive(path);
this.#keys.set(path, key);
return key;
}
}
/** Derive the key and return the bytes. */
static deriveKey(path: string): Uint8Array {
const { privateKey } = this.derive(path);
if (!privateKey) {
throw new Error('Private key not available');
}
return privateKey;
}
/** Database encryption key for AES-GCM encryption of database columns. */
static get dbKey(): Uint8Array {
return this.deriveKey(Conf.wallet.dbKeyPath);
}
/** VAPID secret key, used for web push notifications. ES256. */
static get vapidKey(): Uint8Array {
return this.deriveKey(Conf.wallet.vapidKeyPath);
}
}

View file

@ -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,
@ -114,7 +117,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';
@ -283,6 +285,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);

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View 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/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 B

View 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

View file

@ -99,6 +99,25 @@ 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.
* Keys can be rotated by changing the derviation path.
*/
static wallet = {
/** Private key for AES-GCM encryption in the Postgres database. */
get dbKeyPath(): string {
return Deno.env.get('WALLET_DB_KEY_PATH') || "m/0'/1'";
},
/** VAPID private key path. */
get vapidKeyPath(): string {
return Deno.env.get('WALLET_VAPID_KEY_PATH') || "m/0'/3'";
},
};
/** Character limit to enforce for posts made through Mastodon API. */
static get postCharLimit(): number {
return Number(Deno.env.get('POST_CHAR_LIMIT') || 5000);

View file

@ -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);
};

View 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;
}

View file

@ -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();

View file

@ -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();

View file

@ -34,6 +34,9 @@ export interface MastodonAccount {
nostr: {
nip05?: string;
};
ditto: {
captcha_solved: boolean;
};
};
statuses_count: number;
uri: string;

View file

@ -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
View 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
View 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
View 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);
}

View file

@ -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);
});

View file

@ -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));
});

View file

@ -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);
}

View file

@ -87,6 +87,9 @@ async function renderAccount(
nostr: {
nip05,
},
ditto: {
captcha_solved: false,
},
}
: undefined,
statuses_count: event.author_stats?.notes_count ?? 0,

View file

@ -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}`);

View file

@ -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 });
}
}