diff --git a/src/crypto.ts b/src/crypto.ts new file mode 100644 index 0000000..9cafb51 --- /dev/null +++ b/src/crypto.ts @@ -0,0 +1,14 @@ +import { Conf } from '@/config.ts'; +import { nip04 } from '@/deps.ts'; + +/** Encrypt a message as the Ditto server account. */ +function encryptAdmin(targetPubkey: string, message: string): Promise { + return nip04.encrypt(Conf.seckey, targetPubkey, message); +} + +/** Decrypt a message as the Ditto server account. */ +function decryptAdmin(targetPubkey: string, message: string): Promise { + return nip04.decrypt(Conf.seckey, targetPubkey, message); +} + +export { decryptAdmin, encryptAdmin }; diff --git a/src/deps.ts b/src/deps.ts index bcc3f42..dd887b6 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -20,6 +20,7 @@ export { getSignature, Kind, matchFilters, + nip04, nip05, nip19, nip21, diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index cbc6816..4147c3c 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -80,12 +80,19 @@ const relayInfoDocSchema = z.object({ icon: safeUrlSchema.optional().catch(undefined), }); +/** NIP-46 signer response. */ +const connectResponseSchema = z.object({ + id: z.string(), + result: signedEventSchema, +}); + export { type ClientCLOSE, type ClientEVENT, type ClientMsg, clientMsgSchema, type ClientREQ, + connectResponseSchema, filterSchema, jsonMetaContentSchema, metaContentSchema, diff --git a/src/sign.ts b/src/sign.ts index 732c769..20a0350 100644 --- a/src/sign.ts +++ b/src/sign.ts @@ -1,68 +1,98 @@ import { type AppContext } from '@/app.ts'; import { Conf } from '@/config.ts'; -import { type Event, type EventTemplate, finishEvent, HTTPException, z } from '@/deps.ts'; -import { signedEventSchema } from '@/schemas/nostr.ts'; -import { ws } from '@/stream.ts'; - -/** Get signing WebSocket from app context. */ -function getSignStream(c: AppContext): WebSocket | undefined { - const pubkey = c.get('pubkey'); - const session = c.get('session'); - - if (pubkey && session) { - const [socket] = ws.getSockets(`nostr:${pubkey}:${session}`); - return socket; - } -} - -const nostrStreamingEventSchema = z.object({ - type: z.literal('nostr.sign'), - data: signedEventSchema, -}); +import { decryptAdmin, encryptAdmin } from '@/crypto.ts'; +import { type Event, type EventTemplate, finishEvent, HTTPException } from '@/deps.ts'; +import { connectResponseSchema } from '@/schemas/nostr.ts'; +import { Sub } from '@/subs.ts'; +import { Time } from '@/utils.ts'; +import { createAdminEvent } from '@/utils/web.ts'; /** * Sign Nostr event using the app context. * * - If a secret key is provided, it will be used to sign the event. - * - If a signing WebSocket is provided, it will be used to sign the event. + * - If `X-Nostr-Sign` is passed, it will use a NIP-46 to sign the event. */ async function signEvent(event: EventTemplate, c: AppContext): Promise> { const seckey = c.get('seckey'); - const stream = getSignStream(c); + const header = c.req.headers.get('x-nostr-sign'); - if (!seckey && stream) { - try { - return await new Promise>((resolve, reject) => { - const handleMessage = (e: MessageEvent) => { - try { - const { data: event } = nostrStreamingEventSchema.parse(JSON.parse(e.data)); - stream.removeEventListener('message', handleMessage); - resolve(event as Event); - } catch (_e) { - // + if (seckey) { + return finishEvent(event, seckey); + } + + if (header) { + return await signNostrConnect(event, c); + } + + 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): Promise> { + const pubkey = c.get('pubkey'); + + if (!pubkey) { + throw new HTTPException(401); + } + + const messageId = crypto.randomUUID(); + + createAdminEvent({ + kind: 24133, + content: await encryptAdmin( + pubkey, + JSON.stringify({ + id: messageId, + method: 'sign_event', + params: [event], + }), + ), + tags: [['p', pubkey]], + }, c); + + return awaitSignedEvent(pubkey, messageId, c); +} + +/** Wait for signed event to be sent through Nostr relay. */ +function awaitSignedEvent( + pubkey: string, + messageId: string, + c: AppContext, +): Promise> { + const sub = Sub.sub(messageId, '1', [{ kinds: [24133], authors: [pubkey], '#p': [Conf.pubkey] }]); + + function close(): void { + Sub.close(messageId); + } + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + close(); + reject( + new HTTPException(408, { + res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }), + }), + ); + }, Time.minutes(1)); + + (async () => { + for await (const event of sub) { + if (event.kind === 24133) { + const decrypted = await decryptAdmin(event.pubkey, event.content); + const msg = connectResponseSchema.parse(decrypted); + + if (msg.id === messageId) { + close(); + clearTimeout(timeout); + resolve(msg.result as Event); } - }; - stream.addEventListener('message', handleMessage); - stream.send(JSON.stringify({ event: 'nostr.sign', payload: JSON.stringify(event) })); - setTimeout(() => { - stream.removeEventListener('message', handleMessage); - reject(); - }, 60000); - }); - } catch (_e) { - throw new HTTPException(408, { - res: c.json({ id: 'ditto.timeout', error: 'Signing timeout' }, 408), - }); - } - } - - if (!seckey) { - throw new HTTPException(400, { - res: c.json({ id: 'ditto.private_key', error: 'No private key' }, 400), - }); - } - - return finishEvent(event, seckey); + } + } + })(); + }); } /** Sign event as the Ditto server. */