diff --git a/package.json b/package.json index cca82ae30..8ada79d3e 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ }, "license": "AGPL-3.0-or-later", "browserslist": [ - "> 0.5%", + "> 1%", "last 2 versions", "not dead" ], @@ -155,7 +155,8 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-require": "^1.2.14", "vite-plugin-static-copy": "^1.0.6", - "zod": "^3.23.5" + "zod": "^3.23.5", + "zustand": "^5.0.0" }, "devDependencies": { "@formatjs/cli": "^6.2.0", diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index d204fcd62..b05ccb461 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,4 +1,10 @@ -import { RootState, type AppDispatch } from 'soapbox/store'; +import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey } from 'nostr-tools'; + +import { NBunker } from 'soapbox/features/nostr/NBunker'; +import { keyring } from 'soapbox/features/nostr/keyring'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; +import { type AppDispatch } from 'soapbox/store'; import { authLoggedIn, verifyCredentials } from './auth'; import { obtainOAuthToken } from './oauth'; @@ -6,42 +12,83 @@ import { obtainOAuthToken } from './oauth'; const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ -function logInNostr(pubkey: string) { - return async (dispatch: AppDispatch, getState: () => RootState) => { - dispatch(setNostrPubkey(pubkey)); +function logInNostr(signer: NostrSigner, relay: NRelay1) { + return async (dispatch: AppDispatch) => { + const authorization = generateBunkerAuth(); - const secret = sessionStorage.getItem('soapbox:nip46:secret'); - if (!secret) { - throw new Error('No secret found in session storage'); - } + const pubkey = await signer.getPublicKey(); + const bunkerPubkey = await authorization.signer.getPublicKey(); - const relay = getState().instance.nostr?.relay; + let authorizedPubkey: string | undefined; - // HACK: waits 1 second to ensure the relay subscription is open - await new Promise((resolve) => setTimeout(resolve, 1000)); + const bunker = new NBunker({ + relay, + userSigner: signer, + bunkerSigner: authorization.signer, + onConnect(request, event) { + const [, secret] = request.params; + + if (secret === authorization.secret) { + bunker.authorize(event.pubkey); + authorizedPubkey = event.pubkey; + return { id: request.id, result: 'ack' }; + } else { + return { id: request.id, result: '', error: 'Invalid secret' }; + } + }, + }); + + await bunker.waitReady; const token = await dispatch(obtainOAuthToken({ grant_type: 'nostr_bunker', - pubkey, - relays: relay ? [relay] : undefined, - secret, + pubkey: bunkerPubkey, + relays: [relay.socket.url], + secret: authorization.secret, })); - const { access_token } = dispatch(authLoggedIn(token)); - await dispatch(verifyCredentials(access_token as string)); + if (!authorizedPubkey) { + throw new Error('Authorization failed'); + } - dispatch(setNostrPubkey(undefined)); + const accessToken = dispatch(authLoggedIn(token)).access_token as string; + const bunkerState = useBunkerStore.getState(); + + keyring.add(authorization.seckey); + + bunkerState.connect({ + pubkey, + accessToken, + authorizedPubkey, + bunkerPubkey, + }); + + await dispatch(verifyCredentials(accessToken)); + + // TODO: get rid of `vite-plugin-require` and switch to `using` for the bunker. :( + bunker.close(); }; } /** Log in with a Nostr extension. */ -function nostrExtensionLogIn() { +function nostrExtensionLogIn(relay: NRelay1) { return async (dispatch: AppDispatch) => { if (!window.nostr) { throw new Error('No Nostr signer available'); } - const pubkey = await window.nostr.getPublicKey(); - return dispatch(logInNostr(pubkey)); + return dispatch(logInNostr(window.nostr, relay)); + }; +} + +/** Generate a bunker authorization object. */ +function generateBunkerAuth() { + const secret = crypto.randomUUID(); + const seckey = generateSecretKey(); + + return { + secret, + seckey, + signer: new NSecSigner(seckey), }; } diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts deleted file mode 100644 index 15662c262..000000000 --- a/src/api/hooks/nostr/useSignerStream.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useState } from 'react'; - -import { useNostr } from 'soapbox/contexts/nostr-context'; -import { NBunker } from 'soapbox/features/nostr/NBunker'; - -const secretStorageKey = 'soapbox:nip46:secret'; - -sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); - -function useSignerStream() { - const [isSubscribed, setIsSubscribed] = useState(false); - const [isSubscribing, setIsSubscribing] = useState(true); - - const { relay, signer, hasNostr } = useNostr(); - const [pubkey, setPubkey] = useState(undefined); - - const authStorageKey = `soapbox:nostr:auth:${pubkey}`; - - useEffect(() => { - let isCancelled = false; - - if (signer && hasNostr) { - signer.getPublicKey().then((newPubkey) => { - if (!isCancelled) { - setPubkey(newPubkey); - } - }).catch(console.warn); - } - - return () => { - isCancelled = true; - }; - }, [signer, hasNostr]); - - useEffect(() => { - if (!relay || !signer || !pubkey) return; - - const bunker = new NBunker({ - relay, - signer, - onAuthorize(authorizedPubkey) { - localStorage.setItem(authStorageKey, authorizedPubkey); - sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); - }, - onSubscribed() { - setIsSubscribed(true); - setIsSubscribing(false); - }, - authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined, - getSecret: () => sessionStorage.getItem(secretStorageKey)!, - }); - - return () => { - bunker.close(); - }; - }, [relay, signer, pubkey]); - - return { - isSubscribed, - isSubscribing, - }; -} - -export { useSignerStream }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index c9a70ed70..b83c4b950 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -1,15 +1,11 @@ -import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; -import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import { NRelay1 } from '@nostrify/nostrify'; +import React, { createContext, useContext, useState, useEffect } from 'react'; -import { NKeys } from 'soapbox/features/nostr/keys'; -import { useAppSelector } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { - relay?: NRelay; - signer?: NostrSigner; - hasNostr: boolean; - isRelayOpen: boolean; + relay?: NRelay1; + isRelayLoading: boolean; } const NostrContext = createContext(undefined); @@ -20,37 +16,32 @@ interface NostrProviderProps { export const NostrProvider: React.FC = ({ children }) => { const { instance } = useInstance(); - const hasNostr = !!instance.nostr; const [relay, setRelay] = useState(); - const [isRelayOpen, setIsRelayOpen] = useState(false); + const [isRelayLoading, setIsRelayLoading] = useState(true); - const url = instance.nostr?.relay; - const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id); - - const signer = useMemo( - () => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined, - [accountPubkey, window.nostr], - ); + 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/entity-store/reducer.ts b/src/entity-store/reducer.ts index a9444c1d7..2e53656af 100644 --- a/src/entity-store/reducer.ts +++ b/src/entity-store/reducer.ts @@ -1,4 +1,4 @@ -import { produce, enableMapSet } from 'immer'; +import { produce } from 'immer'; import { ENTITIES_IMPORT, @@ -17,8 +17,6 @@ import { createCache, createList, updateStore, updateList } from './utils'; import type { DeleteEntitiesOpts } from './actions'; import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types'; -enableMapSet(); - /** Entity reducer state. */ interface State { [entityType: string]: EntityCache | undefined; diff --git a/src/features/compose/components/search-zap-split.tsx b/src/features/compose/components/search-zap-split.tsx index aa222f5e9..d2873f828 100644 --- a/src/features/compose/components/search-zap-split.tsx +++ b/src/features/compose/components/search-zap-split.tsx @@ -96,8 +96,6 @@ const SearchZapSplit = (props: ISearchZapSplit) => { const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => { const account = selectAccount(getState(), accountId); - console.log(account); - props.onChange(account!); }; diff --git a/src/features/nostr-relays/index.tsx b/src/features/nostr-relays/index.tsx index 345abeb0a..6b64adbaa 100644 --- a/src/features/nostr-relays/index.tsx +++ b/src/features/nostr-relays/index.tsx @@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, Stack } from 'soapbox/components/ui' import { useNostr } from 'soapbox/contexts/nostr-context'; import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq'; import { useOwnAccount } from 'soapbox/hooks'; +import { useSigner } from 'soapbox/hooks/nostr/useSigner'; import RelayEditor, { RelayData } from './components/relay-editor'; @@ -15,7 +16,8 @@ const messages = defineMessages({ const NostrRelays = () => { const intl = useIntl(); const { account } = useOwnAccount(); - const { relay, signer } = useNostr(); + const { relay } = useNostr(); + const { signer } = useSigner(); const { events } = useNostrReq( account?.nostr?.pubkey diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 3bbe67b90..078d22a83 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -1,73 +1,131 @@ -import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify'; +import { + NRelay, + NostrConnectRequest, + NostrConnectResponse, + NostrEvent, + NostrFilter, + NostrSigner, + NSchema as n, +} from '@nostrify/nostrify'; -interface NBunkerOpts { +/** Options passed to `NBunker`. */ +export interface NBunkerOpts { + /** Relay to subscribe to for NIP-46 requests. */ relay: NRelay; - signer: NostrSigner; - authorizedPubkey: string | undefined; - onAuthorize(pubkey: string): void; - onSubscribed(): void; - getSecret(): string; + /** 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, and then return a response object. + * All other methods are handled by the bunker automatically. + * + * ```ts + * const bunker = new Bunker({ + * ...opts, + * onConnect(request, event) { + * const [, secret] = request.params; + * + * if (secret === authorization.secret) { + * bunker.authorize(event.pubkey); // Authorize the pubkey for signer actions. + * return { id: request.id, result: 'ack' }; // Return a success response. + * } else { + * return { id: request.id, result: '', error: 'Invalid secret' }; + * } + * }, + * }); + * ``` + */ + onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise | NostrConnectResponse; + /** + * 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 signer: NostrSigner; - private authorizedPubkey: string | undefined; - private onAuthorize: (pubkey: string) => void; - private onSubscribed: () => void; - private getSecret: () => string; - private controller = new AbortController(); + private authorizedPubkeys = new Set(); - constructor(opts: NBunkerOpts) { - this.relay = opts.relay; - this.signer = opts.signer; - this.authorizedPubkey = opts.authorizedPubkey; - this.onAuthorize = opts.onAuthorize; - this.onSubscribed = opts.onSubscribed; - this.getSecret = opts.getSecret; + /** Wait for the bunker to be ready before sending requests. */ + public waitReady: Promise; + private setReady!: () => void; + constructor(private opts: NBunkerOpts) { + this.waitReady = new Promise((resolve) => { + this.setReady = resolve; + }); this.open(); } - async open() { - const pubkey = await this.signer.getPublicKey(); - const signal = this.controller.signal; + /** Open the signer subscription to the relay. */ + private async open() { + const { relay, bunkerSigner, onError } = this.opts; - const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal }); - this.onSubscribed(); + const signal = this.controller.signal; + const bunkerPubkey = await bunkerSigner.getPublicKey(); + + const filters: NostrFilter[] = [ + { kinds: [24133], '#p': [bunkerPubkey], limit: 0 }, + ]; + + const sub = relay.req(filters, { signal }); + this.setReady(); for await (const msg of sub) { if (msg[0] === 'EVENT') { - const event = msg[2]; - this.handleEvent(event); + const [,, event] = msg; + + try { + const decrypted = await this.decrypt(event.pubkey, event.content); + const request = n.json().pipe(n.connectRequest()).parse(decrypted); + await this.handleRequest(request, event); + } catch (error) { + onError?.(error, 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); + /** + * 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.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' }); + if (onConnect) { + const response = await onConnect(request, event); + return this.sendResponse(pubkey, response); + } + return; } // Prevent unauthorized access. - if (pubkey !== this.authorizedPubkey) { - return; + if (!this.authorizedPubkeys.has(pubkey)) { + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: 'Unauthorized', + }); } // Authorized methods. @@ -75,7 +133,7 @@ export class NBunker { case 'sign_event': return this.sendResponse(pubkey, { id: request.id, - result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))), + result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))), }); case 'ping': return this.sendResponse(pubkey, { @@ -85,32 +143,32 @@ export class NBunker { case 'get_relays': return this.sendResponse(pubkey, { id: request.id, - result: JSON.stringify(await this.signer.getRelays?.() ?? []), + result: JSON.stringify(await userSigner.getRelays?.() ?? []), }); case 'get_public_key': return this.sendResponse(pubkey, { id: request.id, - result: await this.signer.getPublicKey(), + result: await userSigner.getPublicKey(), }); case 'nip04_encrypt': return this.sendResponse(pubkey, { id: request.id, - result: await this.signer.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(pubkey, { id: request.id, - result: await this.signer.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(pubkey, { id: request.id, - result: await this.signer.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(pubkey, { id: request.id, - result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]), + result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]), }); default: return this.sendResponse(pubkey, { @@ -121,33 +179,49 @@ export class NBunker { } } - private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { - const [remotePubkey, secret] = request.params; + /** Encrypt the response with the bunker key, then publish it to the relay. */ + private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise { + const { bunkerSigner, relay } = this.opts; - if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { - this.authorizedPubkey = pubkey; - this.onAuthorize(pubkey); + const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response)); - await this.sendResponse(pubkey, { - id: request.id, - result: 'ack', - }); - } - } - - private async sendResponse(pubkey: string, response: NostrConnectResponse) { - const event = await this.signer.signEvent({ + const event = await bunkerSigner.signEvent({ kind: 24133, - content: await this.signer.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); } - close() { + /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ + private async decrypt(pubkey: string, ciphertext: string): Promise { + const { bunkerSigner } = this.opts; + try { + return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext); + } catch { + return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext); + } + } + + /** 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.dispose](): void { + this.close(); + } + } \ No newline at end of file diff --git a/src/features/nostr/NKeyStorage.ts b/src/features/nostr/NKeyring.ts similarity index 97% rename from src/features/nostr/NKeyStorage.ts rename to src/features/nostr/NKeyring.ts index a17435e6a..81ac2b1ed 100644 --- a/src/features/nostr/NKeyStorage.ts +++ b/src/features/nostr/NKeyring.ts @@ -8,7 +8,7 @@ import { z } from 'zod'; * When instantiated, it will lock the storage key to prevent tampering. * Changes to the object will sync to storage. */ -export class NKeyStorage implements ReadonlyMap { +export class NKeyring implements ReadonlyMap { #keypairs = new Map(); #storage: Storage; diff --git a/src/features/nostr/hooks/useNostrReq.ts b/src/features/nostr/hooks/useNostrReq.ts index 586f51536..1d4a01a6e 100644 --- a/src/features/nostr/hooks/useNostrReq.ts +++ b/src/features/nostr/hooks/useNostrReq.ts @@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { useForceUpdate } from 'soapbox/hooks/useForceUpdate'; -/** Streams events from the relay for the given filters. */ +/** + * Streams events from the relay for the given filters. + * + * @deprecated Add a custom HTTP endpoint to Ditto instead. + * Integrating Nostr directly has too many problems. + * Soapbox should only connect to the Nostr relay to sign events, because it's required for Nostr to work. + */ export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } { const { relay } = useNostr(); diff --git a/src/features/nostr/keyring.ts b/src/features/nostr/keyring.ts new file mode 100644 index 000000000..6ae33502a --- /dev/null +++ b/src/features/nostr/keyring.ts @@ -0,0 +1,6 @@ +import { NKeyring } from './NKeyring'; + +export const keyring = new NKeyring( + localStorage, + 'soapbox:nostr:keys', +); diff --git a/src/features/nostr/keys.ts b/src/features/nostr/keys.ts deleted file mode 100644 index 92f9fc09f..000000000 --- a/src/features/nostr/keys.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { NKeyStorage } from './NKeyStorage'; - -export const NKeys = new NKeyStorage( - localStorage, - 'soapbox:nostr:keys', -); diff --git a/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx b/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx index 3e24808f7..adc56b707 100644 --- a/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx +++ b/src/features/ui/components/modals/nostr-login-modal/components/nostr-extension-indicator.tsx @@ -5,14 +5,18 @@ import { closeModal } from 'soapbox/actions/modals'; import { nostrExtensionLogIn } from 'soapbox/actions/nostr'; import Stack from 'soapbox/components/ui/stack/stack'; import Text from 'soapbox/components/ui/text/text'; +import { useNostr } from 'soapbox/contexts/nostr-context'; import { useAppDispatch } from 'soapbox/hooks'; const NostrExtensionIndicator: React.FC = () => { const dispatch = useAppDispatch(); + const { relay } = useNostr(); const onClick = () => { - dispatch(nostrExtensionLogIn()); - dispatch(closeModal()); + if (relay) { + dispatch(nostrExtensionLogIn(relay)); + dispatch(closeModal()); + } }; function renderBody(): React.ReactNode { diff --git a/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx b/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx index b46df75d0..36688eb16 100644 --- a/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx +++ b/src/features/ui/components/modals/nostr-login-modal/steps/extension-step.tsx @@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals'; import { nostrExtensionLogIn } from 'soapbox/actions/nostr'; import EmojiGraphic from 'soapbox/components/emoji-graphic'; import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui'; +import { useNostr } from 'soapbox/contexts/nostr-context'; import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks'; interface IExtensionStep { @@ -18,6 +19,7 @@ const ExtensionStep: React.FC = ({ isLogin, onClickAlt, onClose const dispatch = useAppDispatch(); const { instance } = useInstance(); const { logo } = useSoapboxConfig(); + const { relay } = useNostr(); const handleClose = () => { onClose(); @@ -25,8 +27,10 @@ const ExtensionStep: React.FC = ({ isLogin, onClickAlt, onClose }; const onClick = () => { - dispatch(nostrExtensionLogIn()); - onClose(); + if (relay) { + dispatch(nostrExtensionLogIn(relay)); + onClose(); + } }; return ( diff --git a/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx b/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx index a9fbec91a..44f5b8cf7 100644 --- a/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx +++ b/src/features/ui/components/modals/nostr-login-modal/steps/key-add-step.tsx @@ -5,7 +5,8 @@ import { FormattedMessage } from 'react-intl'; import { logInNostr } from 'soapbox/actions/nostr'; import EmojiGraphic from 'soapbox/components/emoji-graphic'; import { Button, Stack, Modal, Input, FormGroup, Form, Divider } from 'soapbox/components/ui'; -import { NKeys } from 'soapbox/features/nostr/keys'; +import { useNostr } from 'soapbox/contexts/nostr-context'; +import { keyring } from 'soapbox/features/nostr/keyring'; import { useAppDispatch } from 'soapbox/hooks'; import NostrExtensionIndicator from '../components/nostr-extension-indicator'; @@ -19,6 +20,7 @@ const KeyAddStep: React.FC = ({ onClose }) => { const [error, setError] = useState(); const dispatch = useAppDispatch(); + const { relay } = useNostr(); const handleChange = (e: React.ChangeEvent) => { setNsec(e.target.value); @@ -26,13 +28,13 @@ const KeyAddStep: React.FC = ({ onClose }) => { }; const handleSubmit = async () => { + if (!relay) return; try { const result = nip19.decode(nsec); if (result.type === 'nsec') { const seckey = result.data; - const signer = NKeys.add(seckey); - const pubkey = await signer.getPublicKey(); - dispatch(logInNostr(pubkey)); + const signer = keyring.add(seckey); + dispatch(logInNostr(signer, relay)); onClose(); return; } diff --git a/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx index 0fb4e8178..7ae2e6164 100644 --- a/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx +++ b/src/features/ui/components/modals/nostr-signup-modal/steps/keygen-step.tsx @@ -8,7 +8,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar'; import EmojiGraphic from 'soapbox/components/emoji-graphic'; import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack, Input } from 'soapbox/components/ui'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { NKeys } from 'soapbox/features/nostr/keys'; +import { keyring } from 'soapbox/features/nostr/keyring'; import { useAppDispatch, useInstance } from 'soapbox/hooks'; import { useIsMobile } from 'soapbox/hooks/useIsMobile'; import { download } from 'soapbox/utils/download'; @@ -43,8 +43,9 @@ const KeygenStep: React.FC = ({ onClose }) => { }; const handleNext = async () => { - const signer = NKeys.add(secretKey); - const pubkey = await signer.getPublicKey(); + if (!relay) return; + + const signer = keyring.add(secretKey); const now = Math.floor(Date.now() / 1000); const [kind0, ...events] = await Promise.all([ @@ -57,12 +58,12 @@ const KeygenStep: React.FC = ({ onClose }) => { signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }), ]); - await relay?.event(kind0); - await Promise.all(events.map((event) => relay?.event(event))); + await relay.event(kind0); + await Promise.all(events.map((event) => relay.event(event))); onClose(); - await dispatch(logInNostr(pubkey)); + await dispatch(logInNostr(signer, relay)); if (isMobile) { dispatch(closeSidebar()); diff --git a/src/hooks/nostr/useBunker.ts b/src/hooks/nostr/useBunker.ts new file mode 100644 index 000000000..6cb7058ca --- /dev/null +++ b/src/hooks/nostr/useBunker.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; + +import { useNostr } from 'soapbox/contexts/nostr-context'; +import { NBunker } from 'soapbox/features/nostr/NBunker'; +import { useSigner } from 'soapbox/hooks/nostr/useSigner'; + +function useBunker() { + const { relay } = useNostr(); + const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner(); + + useEffect(() => { + if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return; + + const bunker = new NBunker({ + relay, + userSigner, + bunkerSigner, + onError(error, event) { + console.warn('Bunker error:', error, event); + }, + }); + + bunker.authorize(authorizedPubkey); + + return () => { + bunker.close(); + }; + }, [relay, userSigner, bunkerSigner, authorizedPubkey]); +} + +export { useBunker }; diff --git a/src/hooks/nostr/useBunkerStore.ts b/src/hooks/nostr/useBunkerStore.ts new file mode 100644 index 000000000..2608df303 --- /dev/null +++ b/src/hooks/nostr/useBunkerStore.ts @@ -0,0 +1,84 @@ +import { NSchema as n } from '@nostrify/nostrify'; +import { produce } from 'immer'; +import { z } from 'zod'; +import { create } from 'zustand'; +// eslint-disable-next-line import/extensions +import { persist } from 'zustand/middleware'; + +import { filteredArray, jsonSchema } from 'soapbox/schemas/utils'; + +/** + * A bunker connection maps an OAuth token from Mastodon API to a user pubkey and bunker keypair. + * The user pubkey is used to determine whether to use keys from localStorage or a browser extension, + * and the bunker keypair is used to sign and encrypt NIP-46 messages. + */ +interface BunkerConnection { + /** User pubkey. Events will be signed by this pubkey. */ + pubkey: string; + /** Mastodon API access token associated with this connection. */ + accessToken: string; + /** Pubkey of the app authorized to sign events with this connection. */ + authorizedPubkey: string; + /** Pubkey for this connection. Secret key is stored in the keyring. NIP-46 responses will be signed by this key. */ + bunkerPubkey: string; +} + +const connectionSchema: z.ZodType = z.object({ + pubkey: n.id(), + accessToken: z.string(), + authorizedPubkey: n.id(), + bunkerPubkey: n.id(), +}); + +interface BunkerState { + connections: BunkerConnection[]; + connect(connection: BunkerConnection): void; + revoke(accessToken: string): void; +} + +export const useBunkerStore = create()( + persist( + (setState) => ({ + connections: [], + + /** Connect to a bunker using the authorization secret. */ + connect(connection: BunkerConnection): void { + setState((state) => { + return produce(state, (draft) => { + draft.connections.push(connection); + }); + }); + }, + + /** Revoke any connections associated with the access token. */ + revoke(accessToken: string): void { + setState((state) => { + return produce(state, (draft) => { + draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken); + }); + }); + }, + }), + { + name: 'soapbox:bunker', + storage: { + getItem(name) { + const value = localStorage.getItem(name); + + const connections = jsonSchema() + .pipe(filteredArray(connectionSchema)) + .catch([]) + .parse(value); + + return { state: { connections } }; + }, + setItem(name, { state }) { + localStorage.setItem(name, JSON.stringify(state.connections)); + }, + removeItem(name) { + localStorage.removeItem(name); + }, + }, + }, + ), +); diff --git a/src/hooks/nostr/useSigner.ts b/src/hooks/nostr/useSigner.ts new file mode 100644 index 000000000..13eed0f72 --- /dev/null +++ b/src/hooks/nostr/useSigner.ts @@ -0,0 +1,42 @@ +import { useQuery } from '@tanstack/react-query'; + +import { keyring } from 'soapbox/features/nostr/keyring'; +import { useAppSelector } from 'soapbox/hooks'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; + +export function useSigner() { + const { connections } = useBunkerStore(); + + const connection = useAppSelector(({ auth }) => { + const accessToken = auth.me ? auth.users[auth.me]?.access_token : undefined; + if (accessToken) { + return connections.find((conn) => conn.accessToken === accessToken); + } + }); + + const { pubkey, bunkerPubkey, authorizedPubkey } = connection ?? {}; + + const { data: signer, ...rest } = useQuery({ + queryKey: ['nostr', 'signer', pubkey ?? ''], + queryFn: async () => { + if (!pubkey) return null; + + const signer = keyring.get(pubkey); + if (signer) return signer; + + if (window.nostr && await window.nostr.getPublicKey() === pubkey) { + return window.nostr; + } + + return null; + }, + enabled: !!pubkey, + }); + + return { + signer: signer ?? undefined, + bunkerSigner: bunkerPubkey ? keyring.get(bunkerPubkey) : undefined, + authorizedPubkey, + ...rest, + }; +} \ No newline at end of file diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 28808726e..7ad79e5a0 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -3,7 +3,6 @@ import { IntlProvider } from 'react-intl'; import { fetchMe } from 'soapbox/actions/me'; import { loadSoapboxConfig } from 'soapbox/actions/soapbox'; -import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream'; import LoadingScreen from 'soapbox/components/loading-screen'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { @@ -13,6 +12,8 @@ import { useLocale, useInstance, } from 'soapbox/hooks'; +import { useBunker } from 'soapbox/hooks/nostr/useBunker'; +import { useSigner } from 'soapbox/hooks/nostr/useSigner'; import MESSAGES from 'soapbox/messages'; /** Load initial data from the backend */ @@ -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 } = useSignerStream(); + 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 = [ diff --git a/src/main.tsx b/src/main.tsx index c0f99b85c..7382d69b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,3 +1,4 @@ +import { enableMapSet } from 'immer'; import React from 'react'; import { createRoot } from 'react-dom/client'; @@ -14,7 +15,7 @@ import '@fontsource/inter/700.css'; import '@fontsource/inter/900.css'; import '@fontsource/roboto-mono/400.css'; import 'line-awesome/dist/font-awesome-line-awesome/css/all.css'; -import 'soapbox/features/nostr/keys'; +import 'soapbox/features/nostr/keyring'; import './iframe'; import './styles/i18n/arabic.css'; @@ -25,6 +26,8 @@ import './styles/tailwind.css'; import ready from './ready'; import { registerSW, lockSW } from './utils/sw'; +enableMapSet(); + if (BuildConfig.NODE_ENV === 'production') { printConsoleWarning(); registerSW('/sw.js'); diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index 64934a665..bb34609df 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -2,6 +2,8 @@ import { AxiosError } from 'axios'; import { produce } from 'immer'; import { z } from 'zod'; +import { keyring } from 'soapbox/features/nostr/keyring'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; import { Account, accountSchema } from 'soapbox/schemas'; import { Application, applicationSchema } from 'soapbox/schemas/application'; import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth'; @@ -24,6 +26,17 @@ import type { UnknownAction } from 'redux'; const STORAGE_KEY = 'soapbox:auth'; const SESSION_KEY = 'soapbox:auth:me'; +// Log out legacy Nostr/Ditto users. +for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i); + + if (key && /^soapbox:nostr:auth:[0-9a-f]{64}$/.test(key)) { + localStorage.clear(); + sessionStorage.clear(); + location.reload(); + } +} + /** Get current user's URL from session storage. */ function getSessionUser(): string | undefined { const value = sessionStorage.getItem(SESSION_KEY); @@ -37,7 +50,7 @@ function getSessionUser(): string | undefined { /** Retrieve state from browser storage. */ function getLocalState(): SoapboxAuth | undefined { const data = localStorage.getItem(STORAGE_KEY); - const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data); + const result = jsonSchema().pipe(soapboxAuthSchema).safeParse(data); if (!result.success) { return undefined; @@ -105,7 +118,26 @@ function importCredentials(auth: SoapboxAuth, accessToken: string, account: Acco }); } +/** Delete Nostr credentials when an access token is revoked. */ +// TODO: Rework auth so this can all be conrolled from one place. +function revokeNostr(accessToken: string): void { + const { connections, revoke } = useBunkerStore.getState(); + + for (const conn of connections) { + if (conn.accessToken === accessToken) { + // Revoke the Bunker connection. + revoke(accessToken); + // Revoke the user's private key. + keyring.delete(conn.pubkey); + // Revoke the bunker's private key. + keyring.delete(conn.bunkerPubkey); + } + } +} + function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth { + revokeNostr(accessToken); + return produce(auth, draft => { delete draft.tokens[accessToken]; diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 65cbecfe4..2f5d286e6 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -56,7 +56,7 @@ import trending_statuses from './trending-statuses'; import trends from './trends'; import user_lists from './user-lists'; -const reducers = { +export default combineReducers({ accounts_meta, admin, aliases, @@ -111,6 +111,4 @@ const reducers = { trending_statuses, trends, user_lists, -}; - -export default combineReducers(reducers); \ No newline at end of file +}); \ No newline at end of file diff --git a/src/schemas/utils.ts b/src/schemas/utils.ts index 7d46b664c..2110cd0bb 100644 --- a/src/schemas/utils.ts +++ b/src/schemas/utils.ts @@ -30,14 +30,16 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -const jsonSchema = z.string().transform((value, ctx) => { - try { - return JSON.parse(value) as unknown; - } catch (_e) { - ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' }); - return z.NEVER; - } -}); +function jsonSchema(reviver?: (this: any, key: string, value: any) => any) { + return z.string().transform((value, ctx) => { + try { + return JSON.parse(value, reviver) as unknown; + } catch (_e) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' }); + return z.NEVER; + } + }); +} /** MIME schema, eg `image/png`. */ const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/); diff --git a/yarn.lock b/yarn.lock index 52938252f..b6f1e70d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9292,3 +9292,8 @@ zod@^3.23.4, zod@^3.23.5: version "3.23.5" resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f" integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA== + +zustand@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9" + integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==