diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 2bb326219..e639b1389 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,4 +1,5 @@ -import { NostrSigner, NRelay1 } from '@nostrify/nostrify'; +import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify'; +import { generateSecretKey } from 'nostr-tools'; import { NostrRPC } from 'soapbox/features/nostr/NostrRPC'; import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; @@ -12,9 +13,9 @@ const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) { return async (dispatch: AppDispatch) => { + const authorization = generateBunkerAuth(); + const pubkey = await signer.getPublicKey(); - const bunker = useBunkerStore.getState(); - const authorization = bunker.authorize(pubkey); const bunkerPubkey = await authorization.signer.getPublicKey(); const rpc = new NostrRPC(relay, authorization.signer); @@ -51,16 +52,17 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) { throw new Error('Authorization failed'); } - const { access_token } = dispatch(authLoggedIn(await tokenPromise)); + const accessToken = dispatch(authLoggedIn(await tokenPromise)).access_token as string; + const bunkerState = useBunkerStore.getState(); - useBunkerStore.getState().connect({ - accessToken: access_token as string, + bunkerState.connect({ + pubkey, + accessToken, authorizedPubkey, - bunkerPubkey, - secret: authorization.secret, + bunkerSeckey: authorization.seckey, }); - await dispatch(verifyCredentials(access_token as string)); + await dispatch(verifyCredentials(accessToken)); }; } @@ -74,6 +76,18 @@ function nostrExtensionLogIn(relay: NRelay1, signal: AbortSignal) { }; } +/** Generate a bunker authorization object. */ +function generateBunkerAuth() { + const secret = crypto.randomUUID(); + const seckey = generateSecretKey(); + + return { + secret, + seckey, + signer: new NSecSigner(seckey), + }; +} + function setNostrPubkey(pubkey: string | undefined) { return { type: NOSTR_PUBKEY_SET, diff --git a/src/api/hooks/nostr/useBunker.ts b/src/api/hooks/nostr/useBunker.ts index 2b5e8c433..5d0b4aefd 100644 --- a/src/api/hooks/nostr/useBunker.ts +++ b/src/api/hooks/nostr/useBunker.ts @@ -2,14 +2,14 @@ import { NSecSigner } from '@nostrify/nostrify'; import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { NBunker, NBunkerOpts } from 'soapbox/features/nostr/NBunker'; +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 { authorizations, connections } = useBunkerStore(); + const { connections } = useBunkerStore(); const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribing, setIsSubscribing] = useState(true); @@ -22,7 +22,7 @@ function useBunker() { }); useEffect(() => { - if (!relay || (!connection && !authorizations.length)) return; + if (!relay || !connection) return; const bunker = new NBunker({ relay, @@ -41,22 +41,6 @@ function useBunker() { }, }; })(), - authorizations: authorizations.reduce((result, auth) => { - const { secret, pubkey, bunkerSeckey } = auth; - - const user = NKeys.get(pubkey) ?? window.nostr; - if (!user) return result; - - result.push({ - secret, - signers: { - user, - bunker: new NSecSigner(bunkerSeckey), - }, - }); - - return result; - }, [] as NBunkerOpts['authorizations']), onSubscribed() { setIsSubscribed(true); setIsSubscribing(false); @@ -66,7 +50,7 @@ function useBunker() { return () => { bunker.close(); }; - }, [relay, connection, authorizations]); + }, [relay, connection]); return { isSubscribed, diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 3b41a376e..7b2233e9f 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -18,15 +18,9 @@ interface NBunkerConnection { signers: NBunkerSigners; } -interface NBunkerAuthorization { - secret: string; - signers: NBunkerSigners; -} - export interface NBunkerOpts { relay: NRelay; connection?: NBunkerConnection; - authorizations: NBunkerAuthorization[]; onSubscribed(): void; } @@ -34,7 +28,6 @@ export class NBunker { private relay: NRelay; private connection?: NBunkerConnection; - private authorizations: NBunkerAuthorization[]; private onSubscribed: () => void; private controller = new AbortController(); @@ -42,7 +35,6 @@ export class NBunker { constructor(opts: NBunkerOpts) { this.relay = opts.relay; this.connection = opts.connection; - this.authorizations = opts.authorizations; this.onSubscribed = opts.onSubscribed; this.open(); @@ -52,27 +44,9 @@ export class NBunker { if (this.connection) { this.subscribeConnection(this.connection); } - for (const authorization of this.authorizations) { - this.subscribeAuthorization(authorization); - } this.onSubscribed(); } - private async subscribeAuthorization(authorization: NBunkerAuthorization): Promise { - const { signers } = authorization; - const bunkerPubkey = await signers.bunker.getPublicKey(); - - const filters: NostrFilter[] = [ - { kinds: [24133], '#p': [bunkerPubkey], limit: 0 }, - ]; - - for await (const { event, request } of this.subscribe(filters, signers)) { - if (request.method === 'connect') { - this.handleConnect(event, request, authorization); - } - } - } - private async subscribeConnection(connection: NBunkerConnection): Promise { const { authorizedPubkey, signers } = connection; const bunkerPubkey = await signers.bunker.getPublicKey(); @@ -164,17 +138,6 @@ export class NBunker { } } - private async handleConnect(event: NostrEvent, request: NostrConnectRequest, authorization: NBunkerAuthorization): Promise { - const [, secret] = request.params; - - if (secret === authorization.secret) { - await this.sendResponse(event.pubkey, { - id: request.id, - result: 'ack', - }); - } - } - private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise { const { user } = this.connection?.signers ?? {}; diff --git a/src/hooks/useBunkerStore.ts b/src/hooks/useBunkerStore.ts index 4535fe2b7..0b05ec2fb 100644 --- a/src/hooks/useBunkerStore.ts +++ b/src/hooks/useBunkerStore.ts @@ -1,6 +1,6 @@ -import { NSchema as n, NostrSigner, NSecSigner } from '@nostrify/nostrify'; +import { NSchema as n } from '@nostrify/nostrify'; import { produce } from 'immer'; -import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { create } from 'zustand'; // eslint-disable-next-line import/extensions @@ -8,22 +8,6 @@ import { persist } from 'zustand/middleware'; import { filteredArray, jsonSchema } from 'soapbox/schemas/utils'; -/** - * Temporary authorization details to establish a bunker connection with an app. - * Will be upgraded to a `BunkerConnection` once the connection is established. - */ -interface BunkerAuthorization { - /** - * Authorization secret generated by the bunker. - * The app should return it to us in its `connect` call to establish a connection. - */ - secret: string; - /** User pubkey. Events will be signed by this pubkey. */ - pubkey: string; - /** Secret key for this connection. NIP-46 responses will be signed by this key. */ - bunkerSeckey: Uint8Array; -} - /** * 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, @@ -40,14 +24,6 @@ interface BunkerConnection { bunkerSeckey: Uint8Array; } -/** Options for connecting to the bunker. */ -interface BunkerConnectRequest { - accessToken: string; - authorizedPubkey: string; - bunkerPubkey: string; - secret: string; -} - const connectionSchema = z.object({ pubkey: z.string(), accessToken: z.string(), @@ -55,74 +31,21 @@ const connectionSchema = z.object({ bunkerSeckey: n.bech32('nsec'), }); -const authorizationSchema = z.object({ - secret: z.string(), - pubkey: z.string(), - bunkerSeckey: n.bech32('nsec'), -}); - -const stateSchema = z.object({ - connections: filteredArray(connectionSchema), - authorizations: filteredArray(authorizationSchema), -}); - interface BunkerState { connections: BunkerConnection[]; - authorizations: BunkerAuthorization[]; - authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string }; - connect(request: BunkerConnectRequest): void; + connect(connection: BunkerConnection): void; } export const useBunkerStore = create()( persist( - (setState, getState) => ({ + (setState) => ({ connections: [], - authorizations: [], - - /** Generate a new authorization and persist it into the store. */ - authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string } { - const authorization: BunkerAuthorization = { - pubkey, - secret: crypto.randomUUID(), - bunkerSeckey: generateSecretKey(), - }; - - setState((state) => { - return produce(state, (draft) => { - draft.authorizations.push(authorization); - }); - }); - - return { - signer: new NSecSigner(authorization.bunkerSeckey), - secret: authorization.secret, - relays: [], - }; - }, /** Connect to a bunker using the authorization secret. */ - connect(request: BunkerConnectRequest): void { - const { authorizations } = getState(); - - const authorization = authorizations.find( - (existing) => existing.secret === request.secret && getPublicKey(existing.bunkerSeckey) === request.bunkerPubkey, - ); - - if (!authorization) { - throw new Error('Authorization not found'); - } - - const connection: BunkerConnection = { - pubkey: authorization.pubkey, - accessToken: request.accessToken, - authorizedPubkey: request.authorizedPubkey, - bunkerSeckey: authorization.bunkerSeckey, - }; - + connect(connection: BunkerConnection): void { setState((state) => { return produce(state, (draft) => { draft.connections.push(connection); - draft.authorizations = draft.authorizations.filter((existing) => existing !== authorization); }); }); }, @@ -140,23 +63,18 @@ export const useBunkerStore = create()( name: 'soapbox:bunker', storage: { getItem(name) { - const connections = localStorage.getItem(`${name}:connections`); - const authorizations = sessionStorage.getItem(`${name}:authorizations`); + const connections = jsonSchema(nsecReviver) + .pipe(filteredArray(connectionSchema)) + .catch([]) + .parse(localStorage.getItem(name)); - const state = stateSchema.parse({ - connections: jsonSchema(nsecReviver).catch([]).parse(connections), - authorizations: jsonSchema(nsecReviver).catch([]).parse(authorizations), - }); - - return { state }; + return { state: { connections } }; }, setItem(name, { state }) { - localStorage.setItem(`${name}:connections`, JSON.stringify(state.connections, nsecReplacer)); - sessionStorage.setItem(`${name}:authorizations`, JSON.stringify(state.authorizations, nsecReplacer)); + localStorage.setItem(name, JSON.stringify(state.connections, nsecReplacer)); }, removeItem(name) { - localStorage.removeItem(`${name}:connections`); - sessionStorage.removeItem(`${name}:authorizations`); + localStorage.removeItem(name); }, }, },