diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index e639b1389..8afc2abec 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -46,6 +46,10 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) { await respond({ result: pubkey }); break; } + + // FIXME: this needs to actually be a full bunker that handles all methods... + // maybe NBunker can be modular? It should be okay to make multiple instances of it at once. + // Then it could be used for knox too. } if (!authorizedPubkey) { diff --git a/src/api/hooks/nostr/useBunker.ts b/src/api/hooks/nostr/useBunker.ts index 5d0b4aefd..2b9249e3b 100644 --- a/src/api/hooks/nostr/useBunker.ts +++ b/src/api/hooks/nostr/useBunker.ts @@ -1,61 +1,31 @@ -import { NSecSigner } from '@nostrify/nostrify'; -import { useEffect, useState } from 'react'; +import { useEffect } from 'react'; +import { useSigner } from 'soapbox/api/hooks/nostr/useSigner'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { NBunker } from 'soapbox/features/nostr/NBunker'; -import { NKeys } from 'soapbox/features/nostr/keys'; -import { useAppSelector } from 'soapbox/hooks'; -import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; function useBunker() { const { relay } = useNostr(); - const { connections } = useBunkerStore(); - - const [isSubscribed, setIsSubscribed] = useState(false); - const [isSubscribing, setIsSubscribing] = useState(true); - - const connection = useAppSelector((state) => { - const accessToken = state.auth.tokens[state.auth.me!]?.access_token; - if (accessToken) { - return connections.find((conn) => conn.accessToken === accessToken); - } - }); + const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner(); useEffect(() => { - if (!relay || !connection) return; + if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return; const bunker = new NBunker({ relay, - connection: (() => { - if (!connection) return; - const { authorizedPubkey, bunkerSeckey, pubkey } = connection; - - const user = NKeys.get(pubkey) ?? window.nostr; - if (!user) return; - - return { - authorizedPubkey, - signers: { - user, - bunker: new NSecSigner(bunkerSeckey), - }, - }; - })(), - onSubscribed() { - setIsSubscribed(true); - setIsSubscribing(false); + userSigner, + bunkerSigner, + onError(error, event) { + console.warn('Bunker error:', error, event); }, }); + bunker.authorize(authorizedPubkey); + return () => { bunker.close(); }; - }, [relay, connection]); - - return { - isSubscribed, - isSubscribing, - }; + }, [relay, userSigner, bunkerSigner, authorizedPubkey]); } export { useBunker }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index a92b45162..2fec40a06 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -7,8 +7,7 @@ import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { relay?: NRelay1; signer?: NostrSigner; - hasNostr: boolean; - isRelayOpen: boolean; + isRelayLoading: boolean; } const NostrContext = createContext(undefined); @@ -21,31 +20,31 @@ export const NostrProvider: React.FC = ({ children }) => { const { instance } = useInstance(); const { signer } = useSigner(); - const hasNostr = !!instance.nostr; - const [relay, setRelay] = useState(); - const [isRelayOpen, setIsRelayOpen] = useState(false); + const [isRelayLoading, setIsRelayLoading] = useState(true); - const url = instance.nostr?.relay; + const relayUrl = instance.nostr?.relay; const handleRelayOpen = () => { - setIsRelayOpen(true); + setIsRelayLoading(false); }; useEffect(() => { - if (url) { - const relay = new NRelay1(url); + if (relayUrl) { + const relay = new NRelay1(relayUrl); relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen); setRelay(relay); + } else { + setIsRelayLoading(false); } return () => { relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen); relay?.close(); }; - }, [url]); + }, [relayUrl]); return ( - + {children} ); diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 7b2233e9f..86cc76b9f 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -8,129 +8,141 @@ import { NSchema as n, } from '@nostrify/nostrify'; -interface NBunkerSigners { - user: NostrSigner; - bunker: NostrSigner; -} - -interface NBunkerConnection { - authorizedPubkey: string; - signers: NBunkerSigners; -} - +/** Options passed to `NBunker`. */ export interface NBunkerOpts { + /** Relay to subscribe to for NIP-46 requests. */ relay: NRelay; - connection?: NBunkerConnection; - onSubscribed(): void; + /** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */ + userSigner: NostrSigner; + /** Signer to sign, encrypt, and decrypt the kind 24133 transport events events. */ + bunkerSigner: NostrSigner; + /** + * Callback when a `connect` request has been received. + * This is a good place to call `bunker.authorize()` with the remote client's pubkey. + * It's up to the caller to verify the request parameters and secret. + * All other methods are handled by the bunker automatically. + */ + onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise | void; + /** + * Callback when an error occurs while parsing a request event. + * Client errors are not captured here, only errors that occur before arequest's `id` can be known, + * eg when decrypting the event content or parsing the request object. + */ + onError?(error: unknown, event: NostrEvent): void; } +/** + * Modular NIP-46 remote signer bunker class. + * + * Runs a remote signer against a given relay, using `bunkerSigner` to sign transport events, + * and `userSigner` to complete NIP-46 requests. + */ export class NBunker { - private relay: NRelay; - private connection?: NBunkerConnection; - private onSubscribed: () => void; - private controller = new AbortController(); + private authorizedPubkeys = new Set(); - constructor(opts: NBunkerOpts) { - this.relay = opts.relay; - this.connection = opts.connection; - this.onSubscribed = opts.onSubscribed; - + constructor(private opts: NBunkerOpts) { this.open(); } - async open() { - if (this.connection) { - this.subscribeConnection(this.connection); - } - this.onSubscribed(); - } + /** Open the signer subscription to the relay. */ + private async open() { + const { relay, bunkerSigner, onError } = this.opts; - private async subscribeConnection(connection: NBunkerConnection): Promise { - const { authorizedPubkey, signers } = connection; - const bunkerPubkey = await signers.bunker.getPublicKey(); + const signal = this.controller.signal; + const bunkerPubkey = await bunkerSigner.getPublicKey(); const filters: NostrFilter[] = [ - { kinds: [24133], authors: [authorizedPubkey], '#p': [bunkerPubkey], limit: 0 }, + { kinds: [24133], '#p': [bunkerPubkey], limit: 0 }, ]; - for await (const { event, request } of this.subscribe(filters, signers)) { - this.handleRequest(event, request, connection); - } - } - - private async *subscribe(filters: NostrFilter[], signers: NBunkerSigners): AsyncIterable<{ event: NostrEvent; request: NostrConnectRequest }> { - const signal = this.controller.signal; - - for await (const msg of this.relay.req(filters, { signal })) { + for await (const msg of relay.req(filters, { signal })) { if (msg[0] === 'EVENT') { const [,, event] = msg; try { - const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content); + const decrypted = await this.decrypt(event.pubkey, event.content); const request = n.json().pipe(n.connectRequest()).parse(decrypted); - yield { event, request }; + await this.handleRequest(request, event); } catch (error) { - console.warn(error); + onError?.(error, event); } } } } - private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise { - const { signers, authorizedPubkey } = connection; - const { user } = signers; + /** + * Handle NIP-46 requests. + * + * The `connect` method must be handled passing an `onConnect` option into the class + * and then calling `bunker.authorize()` within that callback to authorize the pubkey. + * + * All other methods are handled automatically, as long as the key is authorized, + * by invoking the appropriate method on the `userSigner`. + */ + private async handleRequest(request: NostrConnectRequest, event: NostrEvent): Promise { + const { userSigner, onConnect } = this.opts; + const { pubkey } = event; + + if (request.method === 'connect') { + onConnect?.(request, event); + return; + } // Prevent unauthorized access. - if (event.pubkey !== authorizedPubkey) { - return; + if (!this.authorizedPubkeys.has(pubkey)) { + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: 'Unauthorized', + }); } // Authorized methods. switch (request.method) { case 'sign_event': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: JSON.stringify(await user.signEvent(JSON.parse(request.params[0]))), + result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))), }); case 'ping': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, result: 'pong', }); case 'get_relays': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: JSON.stringify(await user.getRelays?.() ?? []), + result: JSON.stringify(await userSigner.getRelays?.() ?? []), }); case 'get_public_key': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: await user.getPublicKey(), + result: await userSigner.getPublicKey(), }); case 'nip04_encrypt': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: await user.nip04!.encrypt(request.params[0], request.params[1]), + result: await userSigner.nip04!.encrypt(request.params[0], request.params[1]), }); case 'nip04_decrypt': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: await user.nip04!.decrypt(request.params[0], request.params[1]), + result: await userSigner.nip04!.decrypt(request.params[0], request.params[1]), }); case 'nip44_encrypt': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: await user.nip44!.encrypt(request.params[0], request.params[1]), + result: await userSigner.nip44!.encrypt(request.params[0], request.params[1]), }); case 'nip44_decrypt': - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, - result: await user.nip44!.decrypt(request.params[0], request.params[1]), + result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]), }); default: - return this.sendResponse(event.pubkey, { + return this.sendResponse(pubkey, { id: request.id, result: '', error: `Unrecognized method: ${request.method}`, @@ -138,34 +150,49 @@ export class NBunker { } } + /** Encrypt the response with the bunker key, then publish it to the relay. */ private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise { - const { user } = this.connection?.signers ?? {}; + const { bunkerSigner, relay } = this.opts; - if (!user) { - return; - } + const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response)); - const event = await user.signEvent({ + const event = await bunkerSigner.signEvent({ kind: 24133, - content: await user.nip04!.encrypt(pubkey, JSON.stringify(response)), + content, tags: [['p', pubkey]], created_at: Math.floor(Date.now() / 1000), }); - await this.relay.event(event); + await relay.event(event); } /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ - private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise { + private async decrypt(pubkey: string, ciphertext: string): Promise { + const { bunkerSigner } = this.opts; try { - return await signer.nip44!.decrypt(pubkey, ciphertext); + return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext); } catch { - return await signer.nip04!.decrypt(pubkey, ciphertext); + return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext); } } - close() { + /** Authorize the pubkey to perform signer actions (ie any other actions besides `connect`). */ + authorize(pubkey: string): void { + this.authorizedPubkeys.add(pubkey); + } + + /** Revoke authorization for the pubkey. */ + revoke(pubkey: string): void { + this.authorizedPubkeys.delete(pubkey); + } + + /** Stop the bunker and unsubscribe relay subscriptions. */ + close(): void { this.controller.abort(); } + [Symbol.asyncDispose](): void { + this.close(); + } + } \ No newline at end of file diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 68155fef4..922ddd463 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -4,6 +4,7 @@ import { IntlProvider } from 'react-intl'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig } from 'soapbox/actions/soapbox'; import { useBunker } from 'soapbox/api/hooks/nostr/useBunker'; +import { useSigner } from 'soapbox/api/hooks/nostr/useSigner'; import LoadingScreen from 'soapbox/components/loading-screen'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { @@ -44,10 +45,12 @@ const SoapboxLoad: React.FC = ({ children }) => { const [localeLoading, setLocaleLoading] = useState(true); const [isLoaded, setIsLoaded] = useState(false); - const { hasNostr, isRelayOpen, signer } = useNostr(); - const { isSubscribed } = useBunker(); + const nostr = useNostr(); + const signer = useSigner(); - const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed)); + const nostrLoading = Boolean(nostr.isRelayLoading || signer.isLoading); + + useBunker(); /** Whether to display a loading indicator. */ const showLoading = [