diff --git a/scripts/admin.ts b/scripts/admin.ts index 2c682e0..a9c08f1 100644 --- a/scripts/admin.ts +++ b/scripts/admin.ts @@ -1,5 +1,5 @@ import * as pipeline from '@/pipeline.ts'; -import { signAdminEvent } from '@/sign.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; import { type EventStub } from '@/utils/api.ts'; import { nostrNow } from '@/utils.ts'; @@ -12,7 +12,9 @@ switch (Deno.args[0]) { } async function publish(t: EventStub) { - const event = await signAdminEvent({ + const signer = new AdminSigner(); + + const event = await signer.signEvent({ content: '', created_at: nostrNow(), tags: [], diff --git a/src/db/users.ts b/src/db/users.ts index e4fdc32..9eda5b7 100644 --- a/src/db/users.ts +++ b/src/db/users.ts @@ -1,7 +1,7 @@ import { Conf } from '@/config.ts'; import { Debug, type NostrFilter } from '@/deps.ts'; import * as pipeline from '@/pipeline.ts'; -import { signAdminEvent } from '@/sign.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; import { eventsDB } from '@/storages.ts'; const debug = Debug('ditto:users'); @@ -15,8 +15,9 @@ interface User { function buildUserEvent(user: User) { const { origin, host } = Conf.url; + const signer = new AdminSigner(); - return signAdminEvent({ + return signer.signEvent({ kind: 30361, tags: [ ['d', user.pubkey], diff --git a/src/deps.ts b/src/deps.ts index 2019b35..201c16a 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -81,6 +81,7 @@ export * as Comlink from 'npm:comlink@^4.4.1'; export { EventEmitter } from 'npm:tseep@^1.1.3'; export { default as stringifyStable } from 'npm:fast-stable-stringify@^1.0.0'; export { default as Debug } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.2.0/debug.ts'; +export { Stickynotes } from 'https://gitlab.com/soapbox-pub/stickynotes/-/raw/v0.2.0/mod.ts'; export { LNURL, type LNURLDetails, diff --git a/src/middleware/auth98.ts b/src/middleware/auth98.ts index 1ee73c0..9f8db63 100644 --- a/src/middleware/auth98.ts +++ b/src/middleware/auth98.ts @@ -7,7 +7,7 @@ import { validateAuthEvent, } from '@/utils/nip98.ts'; import { localRequest } from '@/utils/api.ts'; -import { signEvent } from '@/sign.ts'; +import { APISigner } from '@/signers/APISigner.ts'; import { findUser, User } from '@/db/users.ts'; /** @@ -91,7 +91,7 @@ function withProof( async function obtainProof(c: AppContext, opts?: ParseAuthRequestOpts) { const req = localRequest(c); const reqEvent = await buildAuthEventTemplate(req, opts); - const resEvent = await signEvent(reqEvent, c, opts); + const resEvent = await new APISigner(c).signEvent(reqEvent); const result = await validateAuthEvent(req, resEvent, opts); if (result.success) { diff --git a/src/pipeline.ts b/src/pipeline.ts index 0fbce04..caa58fb 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -15,7 +15,7 @@ import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; import { verifyEventWorker } from '@/workers/verify.ts'; -import { signAdminEvent } from '@/sign.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; import { lnurlCache } from '@/utils/lnurl.ts'; const debug = Debug('ditto:pipeline'); @@ -194,7 +194,9 @@ async function payZap(event: DittoEvent, signal: AbortSignal) { { fetch: fetchWorker, signal }, ); - const nwcRequestEvent = await signAdminEvent({ + const signer = new AdminSigner(); + + const nwcRequestEvent = await signer.signEvent({ kind: 23194, content: await encryptAdmin( event.pubkey, diff --git a/src/sign.ts b/src/sign.ts deleted file mode 100644 index 247dd82..0000000 --- a/src/sign.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { type AppContext } from '@/app.ts'; -import { Conf } from '@/config.ts'; -import { decryptAdmin, encryptAdmin } from '@/crypto.ts'; -import { Debug, type EventTemplate, finalizeEvent, HTTPException, type NostrEvent } from '@/deps.ts'; -import { connectResponseSchema } from '@/schemas/nostr.ts'; -import { jsonSchema } from '@/schema.ts'; -import { Sub } from '@/subs.ts'; -import { eventMatchesTemplate } from '@/utils.ts'; -import { createAdminEvent } from '@/utils/api.ts'; - -const debug = Debug('ditto:sign'); - -interface SignEventOpts { - /** Target proof-of-work difficulty for the signed event. */ - pow?: number; -} - -/** - * Sign Nostr event using the app context. - * - * - If a secret key is provided, it will be used to sign the event. - * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. - */ -async function signEvent( - event: EventTemplate, - c: AppContext, - opts: SignEventOpts = {}, -): Promise { - const seckey = c.get('seckey'); - const header = c.req.header('x-nostr-sign'); - - if (seckey) { - debug(`Signing Event<${event.kind}> with secret key`); - return finalizeEvent(event, seckey); - } - - if (header) { - debug(`Signing Event<${event.kind}> with NIP-46`); - return await signNostrConnect(event, c, opts); - } - - throw new HTTPException(400, { - res: c.json({ id: 'ditto.sign', error: 'Unable to sign event' }, 400), - }); -} - -/** Sign event with NIP-46, waiting in the background for the signed event. */ -async function signNostrConnect( - event: EventTemplate, - c: AppContext, - opts: SignEventOpts = {}, -): Promise { - const pubkey = c.get('pubkey'); - - if (!pubkey) { - throw new HTTPException(401, { message: 'Missing pubkey' }); - } - - const messageId = crypto.randomUUID(); - - createAdminEvent({ - kind: 24133, - content: await encryptAdmin( - pubkey, - JSON.stringify({ - id: messageId, - method: 'sign_event', - params: [event, { - pow: opts.pow, - }], - }), - ), - tags: [['p', pubkey]], - }, c); - - return awaitSignedEvent(pubkey, messageId, event, c); -} - -/** Wait for signed event to be sent through Nostr relay. */ -async function awaitSignedEvent( - pubkey: string, - messageId: string, - template: EventTemplate, - c: AppContext, -): Promise { - const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]); - - function close(): void { - Sub.close(messageId); - c.req.raw.signal.removeEventListener('abort', close); - } - - c.req.raw.signal.addEventListener('abort', close); - - for await (const event of sub) { - const decrypted = await decryptAdmin(event.pubkey, event.content); - - const result = jsonSchema - .pipe(connectResponseSchema) - .refine((msg) => msg.id === messageId, 'Message ID mismatch') - .refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch') - .safeParse(decrypted); - - if (result.success) { - close(); - return result.data.result; - } - } - - throw new HTTPException(408, { - res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }), - }); -} - -/** Sign event as the Ditto server. */ -// deno-lint-ignore require-await -async function signAdminEvent(event: EventTemplate): Promise { - return finalizeEvent(event, Conf.seckey); -} - -export { signAdminEvent, signEvent }; diff --git a/src/signers/APISigner.ts b/src/signers/APISigner.ts new file mode 100644 index 0000000..3e20370 --- /dev/null +++ b/src/signers/APISigner.ts @@ -0,0 +1,114 @@ +import { type AppContext } from '@/app.ts'; +import { Conf } from '@/config.ts'; +import { decryptAdmin, encryptAdmin } from '@/crypto.ts'; +import { HTTPException, type NostrEvent, type NostrSigner, NSecSigner, Stickynotes } from '@/deps.ts'; +import { connectResponseSchema } from '@/schemas/nostr.ts'; +import { jsonSchema } from '@/schema.ts'; +import { Sub } from '@/subs.ts'; +import { eventMatchesTemplate } from '@/utils.ts'; +import { createAdminEvent } from '@/utils/api.ts'; + +/** + * Sign Nostr event using the app context. + * + * - If a secret key is provided, it will be used to sign the event. + * - If `X-Nostr-Sign` is passed, it will use NIP-46 to sign the event. + */ +export class APISigner implements NostrSigner { + #c: AppContext; + #console = new Stickynotes('ditto:sign'); + + constructor(c: AppContext) { + this.#c = c; + } + + // deno-lint-ignore require-await + async getPublicKey(): Promise { + const pubkey = this.#c.get('pubkey'); + if (pubkey) { + return pubkey; + } else { + throw new HTTPException(401, { message: 'Missing pubkey' }); + } + } + + async signEvent(event: Omit): Promise { + const seckey = this.#c.get('seckey'); + const header = this.#c.req.header('x-nostr-sign'); + + if (seckey) { + this.#console.debug(`Signing Event<${event.kind}> with secret key`); + return new NSecSigner(seckey).signEvent(event); + } + + if (header) { + this.#console.debug(`Signing Event<${event.kind}> with NIP-46`); + return await this.#signNostrConnect(event); + } + + throw new HTTPException(400, { + res: this.#c.json({ id: 'ditto.sign', error: 'Unable to sign event' }, 400), + }); + } + + /** Sign event with NIP-46, waiting in the background for the signed event. */ + async #signNostrConnect(event: Omit): Promise { + const pubkey = this.#c.get('pubkey'); + + if (!pubkey) { + throw new HTTPException(401, { message: 'Missing pubkey' }); + } + + const messageId = crypto.randomUUID(); + + createAdminEvent({ + kind: 24133, + content: await encryptAdmin( + pubkey, + JSON.stringify({ + id: messageId, + method: 'sign_event', + params: [event], + }), + ), + tags: [['p', pubkey]], + }, this.#c); + + return this.#awaitSignedEvent(pubkey, messageId, event); + } + + /** Wait for signed event to be sent through Nostr relay. */ + async #awaitSignedEvent( + pubkey: string, + messageId: string, + template: Omit, + ): Promise { + const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]); + + const close = (): void => { + Sub.close(messageId); + this.#c.req.raw.signal.removeEventListener('abort', close); + }; + + this.#c.req.raw.signal.addEventListener('abort', close); + + for await (const event of sub) { + const decrypted = await decryptAdmin(event.pubkey, event.content); + + const result = jsonSchema + .pipe(connectResponseSchema) + .refine((msg) => msg.id === messageId, 'Message ID mismatch') + .refine((msg) => eventMatchesTemplate(msg.result, template), 'Event template mismatch') + .safeParse(decrypted); + + if (result.success) { + close(); + return result.data.result; + } + } + + throw new HTTPException(408, { + res: this.#c.json({ id: 'ditto.timeout', error: 'Signing timeout' }), + }); + } +} diff --git a/src/signers/AdminSigner.ts b/src/signers/AdminSigner.ts new file mode 100644 index 0000000..d7205eb --- /dev/null +++ b/src/signers/AdminSigner.ts @@ -0,0 +1,9 @@ +import { Conf } from '@/config.ts'; +import { NSecSigner } from '@/deps.ts'; + +/** Sign events as the Ditto server. */ +export class AdminSigner extends NSecSigner { + constructor() { + super(Conf.seckey); + } +} diff --git a/src/utils/api.ts b/src/utils/api.ts index bf51132..5595844 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -12,7 +12,8 @@ import { z, } from '@/deps.ts'; import * as pipeline from '@/pipeline.ts'; -import { signAdminEvent, signEvent } from '@/sign.ts'; +import { AdminSigner } from '@/signers/AdminSigner.ts'; +import { APISigner } from '@/signers/APISigner.ts'; import { eventsDB } from '@/storages.ts'; import { nostrNow } from '@/utils.ts'; @@ -29,12 +30,14 @@ async function createEvent(t: EventStub, c: AppContext): Promise { throw new HTTPException(401); } - const event = await signEvent({ + const signer = new APISigner(c); + + const event = await signer.signEvent({ content: '', created_at: nostrNow(), tags: [], ...t, - }, c); + }); return publishEvent(event, c); } @@ -70,7 +73,9 @@ function updateListEvent( /** Publish an admin event through the pipeline. */ async function createAdminEvent(t: EventStub, c: AppContext): Promise { - const event = await signAdminEvent({ + const signer = new AdminSigner(); + + const event = await signer.signEvent({ content: '', created_at: nostrNow(), tags: [],