From 9eaeeb323456704a68ccbff5c16da77883f1b45b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 22 Oct 2024 18:44:02 -0500 Subject: [PATCH 1/4] Rework Streaming controller to use signerMiddleware --- src/app.ts | 2 +- src/controllers/api/streaming.ts | 29 +++-------- src/middleware/signerMiddleware.ts | 80 ++++++++++++++++-------------- 3 files changed, 49 insertions(+), 62 deletions(-) diff --git a/src/app.ts b/src/app.ts index fdcacf29..20912121 100644 --- a/src/app.ts +++ b/src/app.ts @@ -177,7 +177,7 @@ app.use('/users/*', metricsMiddleware, logger(debug)); app.use('/nodeinfo/*', metricsMiddleware, logger(debug)); app.use('/oauth/*', metricsMiddleware, logger(debug)); -app.get('/api/v1/streaming', metricsMiddleware, streamingController); +app.get('/api/v1/streaming', metricsMiddleware, signerMiddleware, streamingController); app.get('/relay', metricsMiddleware, relayController); app.use( diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index 9693a16c..a73e2ccf 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -14,8 +14,7 @@ import { MuteListPolicy } from '@/policies/MuteListPolicy.ts'; import { getFeedPubkeys } from '@/queries.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { Storages } from '@/storages.ts'; -import { getTokenHash } from '@/utils/auth.ts'; -import { bech32ToPubkey, Time } from '@/utils.ts'; +import { Time } from '@/utils.ts'; import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { renderNotification } from '@/views/mastodon/notifications.ts'; @@ -70,7 +69,8 @@ const connections = new Set(); const streamingController: AppController = async (c) => { const upgrade = c.req.header('upgrade'); - const token = c.req.header('sec-websocket-protocol'); + const protocol = c.req.header('sec-websocket-protocol'); + const signer = c.get('signer'); const stream = streamSchema.optional().catch(undefined).parse(c.req.query('stream')); const controller = new AbortController(); @@ -78,8 +78,8 @@ const streamingController: AppController = async (c) => { return c.text('Please use websocket protocol', 400); } - const pubkey = token ? await getTokenPubkey(token) : undefined; - if (token && !pubkey) { + const pubkey = await signer?.getPublicKey(); + if (!pubkey) { return c.json({ error: 'Invalid access token' }, 401); } @@ -91,7 +91,7 @@ const streamingController: AppController = async (c) => { } } - const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol: token, idleTimeout: 30 }); + const { socket, response } = Deno.upgradeWebSocket(c.req.raw, { protocol, idleTimeout: 30 }); const store = await Storages.db(); const pubsub = await Storages.pubsub(); @@ -231,21 +231,4 @@ async function topicToFilter( } } -async function getTokenPubkey(token: string): Promise { - if (token.startsWith('token1')) { - const kysely = await Storages.kysely(); - const tokenHash = await getTokenHash(token as `token1${string}`); - - const { pubkey } = await kysely - .selectFrom('auth_tokens') - .select('pubkey') - .where('token_hash', '=', tokenHash) - .executeTakeFirstOrThrow(); - - return pubkey; - } else { - return bech32ToPubkey(token); - } -} - export { streamingController }; diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index b4cab1ec..de6c859f 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -10,54 +10,58 @@ import { Storages } from '@/storages.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})$`); - /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ export const signerMiddleware: AppMiddleware = async (c, next) => { - const header = c.req.header('authorization'); - const match = header?.match(BEARER_REGEX); + const accessToken = getAccessToken(c.req.raw); - if (match) { - const [_, bech32] = match; + if (accessToken?.startsWith('token1')) { + try { + const kysely = await Storages.kysely(); + const tokenHash = await getTokenHash(accessToken as `token1${string}`); - if (bech32.startsWith('token1')) { - try { - const kysely = await Storages.kysely(); - const tokenHash = await getTokenHash(bech32 as `token1${string}`); + const { pubkey, nip46_sk_enc, nip46_relays } = await kysely + .selectFrom('auth_tokens') + .select(['pubkey', 'nip46_sk_enc', 'nip46_relays']) + .where('token_hash', '=', tokenHash) + .executeTakeFirstOrThrow(); - const { pubkey, nip46_sk_enc, nip46_relays } = await kysely - .selectFrom('auth_tokens') - .select(['pubkey', 'nip46_sk_enc', 'nip46_relays']) - .where('token_hash', '=', tokenHash) - .executeTakeFirstOrThrow(); + const nep46Seckey = await aesDecrypt(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 { + throw new HTTPException(401); + } + } else { + try { + const decoded = nip19.decode(accessToken!); - c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays)); - } catch { - throw new HTTPException(401); - } - } else { - try { - const decoded = nip19.decode(bech32!); - - switch (decoded.type) { - case 'npub': - c.set('signer', new ReadOnlySigner(decoded.data)); - break; - case 'nprofile': - c.set('signer', new ReadOnlySigner(decoded.data.pubkey)); - break; - case 'nsec': - c.set('signer', new NSecSigner(decoded.data)); - break; - } - } catch { - throw new HTTPException(401); + switch (decoded.type) { + case 'npub': + c.set('signer', new ReadOnlySigner(decoded.data)); + break; + case 'nprofile': + c.set('signer', new ReadOnlySigner(decoded.data.pubkey)); + break; + case 'nsec': + c.set('signer', new NSecSigner(decoded.data)); + break; } + } catch { + throw new HTTPException(401); } } await next(); }; + +/** Extract the access token from the request headers. */ +function getAccessToken(req: Request): string | undefined { + const upgrade = req.headers.get('upgrade'); + + // WebSockets use the `sec-websocket-protocol` header instead of `authorization`. + if (upgrade === 'websocket') { + return req.headers.get('sec-websocket-protocol') ?? undefined; + } + + return req.headers.get('authorization')?.match(/^Bearer (.+)$/)?.[1]; +} From 6a6372486433a01ce6239d221f14215eaf8c7e20 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 22 Oct 2024 18:48:13 -0500 Subject: [PATCH 2/4] ConnectSigner: do call getPublicKey of the upstream signer --- src/signers/ConnectSigner.ts | 32 ++++++++++++++++++++------------ 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/src/signers/ConnectSigner.ts b/src/signers/ConnectSigner.ts index 26a9cbc9..782fdc2a 100644 --- a/src/signers/ConnectSigner.ts +++ b/src/signers/ConnectSigner.ts @@ -30,8 +30,8 @@ export class ConnectSigner implements NostrSigner { const signer = await this.signer; try { return await signer.signEvent(event); - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'The event was not signed quickly enough' }); } else { throw e; @@ -44,8 +44,8 @@ export class ConnectSigner implements NostrSigner { const signer = await this.signer; try { return await signer.nip04.encrypt(pubkey, plaintext); - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'Text was not encrypted quickly enough', }); @@ -59,8 +59,8 @@ export class ConnectSigner implements NostrSigner { const signer = await this.signer; try { return await signer.nip04.decrypt(pubkey, ciphertext); - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'Text was not decrypted quickly enough', }); @@ -76,8 +76,8 @@ export class ConnectSigner implements NostrSigner { const signer = await this.signer; try { return await signer.nip44.encrypt(pubkey, plaintext); - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'Text was not encrypted quickly enough', }); @@ -91,8 +91,8 @@ export class ConnectSigner implements NostrSigner { const signer = await this.signer; try { return await signer.nip44.decrypt(pubkey, ciphertext); - } catch (e: any) { - if (e.name === 'AbortError') { + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'Text was not decrypted quickly enough', }); @@ -103,9 +103,17 @@ export class ConnectSigner implements NostrSigner { }, }; - // Prevent unnecessary NIP-46 round-trips. async getPublicKey(): Promise { - return this.pubkey; + const signer = await this.signer; + try { + return await signer.getPublicKey(); + } catch (e) { + if (e instanceof Error && e.name === 'AbortError') { + throw new HTTPException(408, { message: 'Public key not received quickly enough' }); + } else { + throw e; + } + } } /** Get the user's relays if they passed in an `nprofile` auth token. */ From efae29209986ff1fe2b3b83de9b7f3f15eab2a0d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 22 Oct 2024 18:57:10 -0500 Subject: [PATCH 3/4] Fix signerMiddleware, don't throw in streamingController --- src/controllers/api/streaming.ts | 6 +----- src/middleware/signerMiddleware.ts | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index a73e2ccf..e3e5533d 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -78,11 +78,6 @@ const streamingController: AppController = async (c) => { return c.text('Please use websocket protocol', 400); } - const pubkey = await signer?.getPublicKey(); - if (!pubkey) { - return c.json({ error: 'Invalid access token' }, 401); - } - const ip = c.req.header('x-real-ip'); if (ip) { const count = limiter.get(ip) ?? 0; @@ -96,6 +91,7 @@ const streamingController: AppController = async (c) => { const store = await Storages.db(); const pubsub = await Storages.pubsub(); + const pubkey = await signer?.getPublicKey(); const policy = pubkey ? new MuteListPolicy(pubkey, await Storages.admin()) : undefined; function send(e: StreamingEvent) { diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index de6c859f..61507173 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -31,7 +31,7 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { } catch { throw new HTTPException(401); } - } else { + } else if (accessToken) { try { const decoded = nip19.decode(accessToken!); @@ -47,7 +47,7 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { break; } } catch { - throw new HTTPException(401); + throw new HTTPException(401, { message: 'Invalid access token' }); } } From 5ef87a9119101de2b12e63e8cd4b2114eb61fd34 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 22 Oct 2024 20:22:21 -0500 Subject: [PATCH 4/4] Cache signers, cache nip46 getPublicKey --- src/config.ts | 7 +++++++ src/metrics.ts | 5 +++++ src/middleware/signerMiddleware.ts | 18 +++++++++++++++--- src/signers/ConnectSigner.ts | 13 ++++++++----- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/config.ts b/src/config.ts index fba65159..e01cd21a 100644 --- a/src/config.ts +++ b/src/config.ts @@ -342,6 +342,13 @@ class Conf { ttl: Number(Deno.env.get('DITTO_CACHE_TRANSLATION_TTL') || 6 * 60 * 60 * 1000), }; }, + /** Signer cache settings. */ + get signer(): { max: number; ttl: number } { + return { + max: Number(Deno.env.get('DITTO_CACHE_SIGNER_MAX') || 1000), + ttl: Number(Deno.env.get('DITTO_CACHE_SIGNER_TTL') || 1 * 60 * 60 * 1000), + }; + }, }; } diff --git a/src/metrics.ts b/src/metrics.ts index 7fe75a8f..07759f8a 100644 --- a/src/metrics.ts +++ b/src/metrics.ts @@ -126,6 +126,11 @@ export const cachedTranslationsSizeGauge = new Gauge({ help: 'Number of translated statuses in cache', }); +export const cachedSignersSizeGauge = new Gauge({ + name: 'ditto_cached_signers_size', + help: 'Number of signers in cache', +}); + export const internalSubscriptionsSizeGauge = new Gauge({ name: 'ditto_internal_subscriptions_size', help: "Number of active subscriptions to Ditto's internal relay", diff --git a/src/middleware/signerMiddleware.ts b/src/middleware/signerMiddleware.ts index 61507173..610839c4 100644 --- a/src/middleware/signerMiddleware.ts +++ b/src/middleware/signerMiddleware.ts @@ -1,20 +1,28 @@ import { HTTPException } from '@hono/hono/http-exception'; -import { NSecSigner } from '@nostrify/nostrify'; +import { type NostrSigner, NSecSigner } from '@nostrify/nostrify'; +import { LRUCache } from 'lru-cache'; import { nip19 } from 'nostr-tools'; import { AppMiddleware } from '@/app.ts'; import { Conf } from '@/config.ts'; +import { cachedSignersSizeGauge } from '@/metrics.ts'; import { ConnectSigner } from '@/signers/ConnectSigner.ts'; import { ReadOnlySigner } from '@/signers/ReadOnlySigner.ts'; import { Storages } from '@/storages.ts'; import { aesDecrypt } from '@/utils/aes.ts'; import { getTokenHash } from '@/utils/auth.ts'; +const signerCache = new LRUCache(Conf.caches.signer); + /** Make a `signer` object available to all controllers, or unset if the user isn't logged in. */ export const signerMiddleware: AppMiddleware = async (c, next) => { const accessToken = getAccessToken(c.req.raw); - if (accessToken?.startsWith('token1')) { + const cached = accessToken ? signerCache.get(accessToken) : undefined; + + if (cached) { + c.set('signer', cached); + } else if (accessToken?.startsWith('token1')) { try { const kysely = await Storages.kysely(); const tokenHash = await getTokenHash(accessToken as `token1${string}`); @@ -26,8 +34,12 @@ export const signerMiddleware: AppMiddleware = async (c, next) => { .executeTakeFirstOrThrow(); const nep46Seckey = await aesDecrypt(Conf.seckey, nip46_sk_enc); + const signer = new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays); - c.set('signer', new ConnectSigner(pubkey, new NSecSigner(nep46Seckey), nip46_relays)); + signerCache.set(accessToken, signer); + cachedSignersSizeGauge.set(signerCache.size); + + c.set('signer', signer); } catch { throw new HTTPException(401); } diff --git a/src/signers/ConnectSigner.ts b/src/signers/ConnectSigner.ts index 782fdc2a..20b9a11c 100644 --- a/src/signers/ConnectSigner.ts +++ b/src/signers/ConnectSigner.ts @@ -11,14 +11,15 @@ import { Storages } from '@/storages.ts'; */ export class ConnectSigner implements NostrSigner { private signer: Promise; + private cachedPubkey: Promise | undefined; - constructor(private pubkey: string, signer: NostrSigner, private relays?: string[]) { - this.signer = this.init(signer); + constructor(pubkey: string, signer: NostrSigner, private relays?: string[]) { + this.signer = this.init(pubkey, signer); } - async init(signer: NostrSigner): Promise { + async init(pubkey: string, signer: NostrSigner): Promise { return new NConnectSigner({ - pubkey: this.pubkey, + pubkey, // TODO: use a remote relay for `nprofile` signing (if present and `Conf.relay` isn't already in the list) relay: await Storages.pubsub(), signer, @@ -105,9 +106,11 @@ export class ConnectSigner implements NostrSigner { async getPublicKey(): Promise { const signer = await this.signer; + this.cachedPubkey = this.cachedPubkey ?? signer.getPublicKey(); try { - return await signer.getPublicKey(); + return await this.cachedPubkey; } catch (e) { + this.cachedPubkey = undefined; if (e instanceof Error && e.name === 'AbortError') { throw new HTTPException(408, { message: 'Public key not received quickly enough' }); } else {