From dbff3fee9a31d80e833fe0d3bf6a681c82fd7a3d Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Feb 2024 11:40:17 -0600 Subject: [PATCH 1/3] Upgrade nostr-tools to v2.1.5 --- src/app.ts | 2 +- src/config.ts | 4 ++-- src/deps.ts | 9 +++++---- src/schemas/nostr.ts | 7 +++---- src/sign.ts | 6 +++--- src/utils/nip98.ts | 2 +- src/workers/verify.worker.ts | 4 ++-- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/src/app.ts b/src/app.ts index 159bff84..92eaed4f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,7 +88,7 @@ interface AppEnv extends HonoEnv { /** Hex pubkey for the current user. If provided, the user is considered "logged in." */ pubkey?: string; /** Hex secret key for the current user. Optional, but easiest way to use legacy Mastodon apps. */ - seckey?: string; + seckey?: Uint8Array; /** NIP-98 signed event proving the pubkey is owned by the user. */ proof?: NostrEvent; /** User associated with the pubkey, if any. */ diff --git a/src/config.ts b/src/config.ts index 2f98c1e5..cdd07ba2 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,4 @@ -import { dotenv, getPublicKey, nip19, secp, z } from '@/deps.ts'; +import { dotenv, getPublicKey, nip19, z } from '@/deps.ts'; /** Load environment config from `.env` */ await dotenv.load({ @@ -32,7 +32,7 @@ const Conf = { get cryptoKey() { return crypto.subtle.importKey( 'raw', - secp.etc.hexToBytes(Conf.seckey), + Conf.seckey, { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify'], diff --git a/src/deps.ts b/src/deps.ts index e2090181..2019b351 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -12,10 +12,9 @@ export { z } from 'https://deno.land/x/zod@v3.21.4/mod.ts'; export { RelayPoolWorker } from 'npm:nostr-relaypool2@0.6.34'; export { type EventTemplate, - finishEvent, + finalizeEvent, getEventHash, getPublicKey, - getSignature, matchFilter, matchFilters, nip04, @@ -25,8 +24,8 @@ export { nip21, type UnsignedEvent, type VerifiedEvent, - verifySignature, -} from 'npm:nostr-tools@^1.17.0'; + verifyEvent, +} from 'npm:nostr-tools@^2.1.5'; export { parseFormData } from 'npm:formdata-helper@^0.3.0'; // @deno-types="npm:@types/lodash@4.14.194" export { default as lodash } from 'https://esm.sh/lodash@4.17.21'; @@ -89,6 +88,8 @@ export { NIP05, type NostrEvent, type NostrFilter, + type NostrSigner, + NSecSigner, NSet, type NStore, type NStoreOpts, diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index c6decbaf..708ba96d 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,6 +1,5 @@ -import { getEventHash, verifySignature, z } from '@/deps.ts'; - -import { jsonSchema, safeUrlSchema } from '../schema.ts'; +import { getEventHash, verifyEvent, z } from '@/deps.ts'; +import { jsonSchema, safeUrlSchema } from '@/schema.ts'; /** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/); @@ -21,7 +20,7 @@ const eventSchema = z.object({ /** Nostr event schema that also verifies the event's signature. */ const signedEventSchema = eventSchema .refine((event) => event.id === getEventHash(event), 'Event ID does not match hash') - .refine(verifySignature, 'Event signature is invalid'); + .refine(verifyEvent, 'Event signature is invalid'); /** Nostr relay filter schema. */ const filterSchema = z.object({ diff --git a/src/sign.ts b/src/sign.ts index efe88c1b..247dd824 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,7 +1,7 @@ import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; import { decryptAdmin, encryptAdmin } from '@/crypto.ts'; -import { Debug, type EventTemplate, finishEvent, HTTPException, type NostrEvent } from '@/deps.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'; @@ -31,7 +31,7 @@ async function signEvent( if (seckey) { debug(`Signing Event<${event.kind}> with secret key`); - return finishEvent(event, seckey); + return finalizeEvent(event, seckey); } if (header) { @@ -115,7 +115,7 @@ async function awaitSignedEvent( /** Sign event as the Ditto server. */ // deno-lint-ignore require-await async function signAdminEvent(event: EventTemplate): Promise { - return finishEvent(event, Conf.seckey); + return finalizeEvent(event, Conf.seckey); } export { signAdminEvent, signEvent }; diff --git a/src/utils/nip98.ts b/src/utils/nip98.ts index 0b832836..f41fd680 100644 --- a/src/utils/nip98.ts +++ b/src/utils/nip98.ts @@ -50,7 +50,7 @@ function validateAuthEvent(req: Request, event: NostrEvent, opts: ParseAuthReque } /** Create an auth EventTemplate from a Request. */ -async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise> { +async function buildAuthEventTemplate(req: Request, opts: ParseAuthRequestOpts = {}): Promise { const { validatePayload = true } = opts; const { method, url } = req; diff --git a/src/workers/verify.worker.ts b/src/workers/verify.worker.ts index 32ef1263..01377fe6 100644 --- a/src/workers/verify.worker.ts +++ b/src/workers/verify.worker.ts @@ -1,8 +1,8 @@ -import { Comlink, type NostrEvent, type VerifiedEvent, verifySignature } from '@/deps.ts'; +import { Comlink, type NostrEvent, type VerifiedEvent, verifyEvent } from '@/deps.ts'; export const VerifyWorker = { verifySignature(event: NostrEvent): event is VerifiedEvent { - return verifySignature(event); + return verifyEvent(event); }, }; From 1e3f6373589a5c1853558a92478dc867fe02033a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Feb 2024 11:42:25 -0600 Subject: [PATCH 2/3] verifySignatureWorker -> verifyEventWorker --- src/pipeline.ts | 4 ++-- src/workers/verify.ts | 6 +++--- src/workers/verify.worker.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/pipeline.ts b/src/pipeline.ts index 4037a97a..0fbce04d 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -14,7 +14,7 @@ import { getTagSet } from '@/tags.ts'; import { eventAge, isRelay, nostrDate, nostrNow, Time } from '@/utils.ts'; import { fetchWorker } from '@/workers/fetch.ts'; import { TrendsWorker } from '@/workers/trends.ts'; -import { verifySignatureWorker } from '@/workers/verify.ts'; +import { verifyEventWorker } from '@/workers/verify.ts'; import { signAdminEvent } from '@/sign.ts'; import { lnurlCache } from '@/utils/lnurl.ts'; @@ -25,7 +25,7 @@ const debug = Debug('ditto:pipeline'); * It is idempotent, so it can be called multiple times for the same event. */ async function handleEvent(event: DittoEvent, signal: AbortSignal): Promise { - if (!(await verifySignatureWorker(event))) return; + if (!(await verifyEventWorker(event))) return; const wanted = reqmeister.isWanted(event); if (await encounterEvent(event, signal)) return; debug(`NostrEvent<${event.kind}> ${event.id}`); diff --git a/src/workers/verify.ts b/src/workers/verify.ts index 1f71322e..21147cf7 100644 --- a/src/workers/verify.ts +++ b/src/workers/verify.ts @@ -6,8 +6,8 @@ const worker = Comlink.wrap( new Worker(new URL('./verify.worker.ts', import.meta.url), { type: 'module' }), ); -function verifySignatureWorker(event: NostrEvent): Promise { - return worker.verifySignature(event); +function verifyEventWorker(event: NostrEvent): Promise { + return worker.verifyEvent(event); } -export { verifySignatureWorker }; +export { verifyEventWorker }; diff --git a/src/workers/verify.worker.ts b/src/workers/verify.worker.ts index 01377fe6..be664244 100644 --- a/src/workers/verify.worker.ts +++ b/src/workers/verify.worker.ts @@ -1,7 +1,7 @@ import { Comlink, type NostrEvent, type VerifiedEvent, verifyEvent } from '@/deps.ts'; export const VerifyWorker = { - verifySignature(event: NostrEvent): event is VerifiedEvent { + verifyEvent(event: NostrEvent): event is VerifiedEvent { return verifyEvent(event); }, }; From 59d53c4a2fd210841053e18c6ada9b44f981aebc Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 12 Feb 2024 11:52:05 -0600 Subject: [PATCH 3/3] Add APISigner and AdminSigner classes, implement NostrSigner interface --- scripts/admin.ts | 6 +- src/db/users.ts | 5 +- src/deps.ts | 1 + src/middleware/auth98.ts | 4 +- src/pipeline.ts | 6 +- src/sign.ts | 121 ------------------------------------- src/signers/APISigner.ts | 114 ++++++++++++++++++++++++++++++++++ src/signers/AdminSigner.ts | 9 +++ src/utils/api.ts | 13 ++-- 9 files changed, 146 insertions(+), 133 deletions(-) delete mode 100644 src/sign.ts create mode 100644 src/signers/APISigner.ts create mode 100644 src/signers/AdminSigner.ts diff --git a/scripts/admin.ts b/scripts/admin.ts index 2c682e09..a9c08f12 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 e4fdc324..9eda5b73 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 2019b351..201c16a8 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 1ee73c0c..9f8db634 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 0fbce04d..caa58fb5 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 247dd824..00000000 --- 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 00000000..3e20370d --- /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 00000000..d7205eb6 --- /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 bf511329..5595844e 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: [],