diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 918d40ba3..7685d1732 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,137 +1,35 @@ -import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { nwcRequestSchema } from 'soapbox/schemas/nostr'; +import { NConnect } from 'soapbox/features/nostr/NConnect'; function useSignerStream() { - const { relay, pubkey, signer } = useNostr(); + const { relay, signer } = useNostr(); + const [pubkey, setPubkey] = useState(undefined); - async function sendConnect(response: NostrConnectResponse) { - if (!relay || !pubkey || !signer) return; - - const event = await signer.signEvent({ - kind: 24133, - content: await signer.nip04!.encrypt(pubkey, JSON.stringify(response)), - tags: [['p', pubkey]], - created_at: Math.floor(Date.now() / 1000), - }); - - relay.event(event); - } - - async function handleConnectEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(n.connectRequest()).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - const request = reqMsg.data; - - switch (request.method) { - case 'connect': - return sendConnect({ - id: request.id, - result: 'ack', - }); - case 'sign_event': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.signEvent(JSON.parse(request.params[0]))), - }); - case 'ping': - return sendConnect({ - id: request.id, - result: 'pong', - }); - case 'get_relays': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.getRelays?.() ?? []), - }); - case 'get_public_key': - return sendConnect({ - id: request.id, - result: await signer.getPublicKey(), - }); - case 'nip04_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.encrypt(request.params[0], request.params[1]), - }); - case 'nip04_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.decrypt(request.params[0], request.params[1]), - }); - case 'nip44_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.encrypt(request.params[0], request.params[1]), - }); - case 'nip44_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.decrypt(request.params[0], request.params[1]), - }); - default: - return sendConnect({ - id: request.id, - result: '', - error: `Unrecognized method: ${request.method}`, - }); - } - } - - async function handleWalletEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(nwcRequestSchema).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - await window.webln?.enable(); - await window.webln?.sendPayment(reqMsg.data.params.invoice); - } - - async function handleEvent(event: NostrEvent) { - switch (event.kind) { - case 24133: - await handleConnectEvent(event); - break; - case 23194: - await handleWalletEvent(event); - break; - } - } + const storageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { - if (!relay || !pubkey) return; + if (signer) { + signer.getPublicKey().then(setPubkey).catch(console.warn); + } + }, [signer]); - const controller = new AbortController(); - const signal = controller.signal; + useEffect(() => { + if (!relay || !signer || !pubkey) return; - (async() => { - for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }], { signal })) { - if (msg[0] === 'EVENT') handleEvent(msg[2]); - } - })(); + const connect = new NConnect({ + relay, + signer, + onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), + authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + }); return () => { - controller.abort(); + connect.close(); }; - }, [relay, pubkey, signer]); + }, [relay, signer, pubkey]); } export { useSignerStream }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index fc8129b2b..1338d6ac5 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -7,7 +7,6 @@ import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { relay?: NRelay; - pubkey?: string; signer?: NostrSigner; } @@ -24,7 +23,6 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const pubkey = instance.nostr?.pubkey; const accountPubkey = account?.nostr.pubkey; const signer = useMemo( @@ -42,7 +40,7 @@ export const NostrProvider: React.FC = ({ children }) => { }, [url]); return ( - + {children} ); diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts new file mode 100644 index 000000000..8679e62bc --- /dev/null +++ b/src/features/nostr/NConnect.ts @@ -0,0 +1,150 @@ +import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify'; + +interface NConnectOpts { + relay: NRelay; + signer: NostrSigner; + authorizedPubkey: string | undefined; + onAuthorize(pubkey: string): void; +} + +export class NConnect { + + private relay: NRelay; + private signer: NostrSigner; + private authorizedPubkey: string | undefined; + private onAuthorize: (pubkey: string) => void; + + public secret = crypto.randomUUID(); + private controller = new AbortController(); + + constructor(opts: NConnectOpts) { + this.relay = opts.relay; + this.signer = opts.signer; + this.authorizedPubkey = opts.authorizedPubkey; + this.onAuthorize = opts.onAuthorize; + + this.open(); + } + + async open() { + const pubkey = await this.signer.getPublicKey(); + const signal = this.controller.signal; + + for await (const msg of this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal })) { + if (msg[0] === 'EVENT') { + const event = msg[2]; + this.handleEvent(event); + } + } + } + + private async handleEvent(event: NostrEvent): Promise { + const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content); + const request = n.json().pipe(n.connectRequest()).safeParse(decrypted); + + if (!request.success) { + console.warn(decrypted); + console.warn(request.error); + return; + } + + await this.handleRequest(event.pubkey, request.data); + } + + private async handleRequest(pubkey: string, request: NostrConnectRequest): Promise { + // Connect is a special case. Any pubkey can try to request it. + if (request.method === 'connect') { + return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' }); + } + + // Prevent unauthorized access. + if (pubkey !== this.authorizedPubkey) { + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: 'Unauthorized', + }); + } + + // Authorized methods. + switch (request.method) { + case 'sign_event': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))), + }); + case 'ping': + return this.sendResponse(pubkey, { + id: request.id, + result: 'pong', + }); + case 'get_relays': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.getRelays?.() ?? []), + }); + case 'get_public_key': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.getPublicKey(), + }); + case 'nip04_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]), + }); + case 'nip04_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]), + }); + case 'nip44_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]), + }); + case 'nip44_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]), + }); + default: + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: `Unrecognized method: ${request.method}`, + }); + } + } + + private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { + const [remotePubkey, secret] = request.params; + + if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { + this.secret = crypto.randomUUID(); + this.authorizedPubkey = pubkey; + this.onAuthorize(pubkey); + + await this.sendResponse(pubkey, { + id: request.id, + result: 'ack', + }); + } + } + + private async sendResponse(pubkey: string, response: NostrConnectResponse) { + const event = await this.signer.signEvent({ + kind: 24133, + content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.relay.event(event); + } + + close() { + this.controller.abort(); + } + +} \ No newline at end of file diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 59cdd9d68..c94152d22 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,16 +1,7 @@ import { NSchema as n } from '@nostrify/nostrify'; import { verifyEvent } from 'nostr-tools'; -import { z } from 'zod'; /** Nostr event schema that also verifies the event's signature. */ const signedEventSchema = n.event().refine(verifyEvent); -/** NIP-47 signer response. */ -const nwcRequestSchema = z.object({ - method: z.literal('pay_invoice'), - params: z.object({ - invoice: z.string(), - }), -}); - -export { signedEventSchema, nwcRequestSchema }; \ No newline at end of file +export { signedEventSchema }; \ No newline at end of file