From 14793ef0a9931630bc11e712bd9dd1778f7fcfb4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 27 Oct 2024 19:35:41 -0500 Subject: [PATCH] Rewrite everything (???) --- src/api/hooks/nostr/useSignerStream.ts | 77 +++++++---- src/features/nostr/NBunker.ts | 172 ++++++++++++++++--------- src/reducers/bunker.ts | 4 +- src/reducers/index.ts | 8 +- 4 files changed, 171 insertions(+), 90 deletions(-) diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 15662c262..1dc0337b9 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,59 +1,86 @@ +import { NSecSigner } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { NBunker } from 'soapbox/features/nostr/NBunker'; +import { NBunker, NBunkerOpts } from 'soapbox/features/nostr/NBunker'; +import { NKeys } from 'soapbox/features/nostr/keys'; +import { useAppSelector } from 'soapbox/hooks'; const secretStorageKey = 'soapbox:nip46:secret'; sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); function useSignerStream() { + const { relay } = useNostr(); + const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribing, setIsSubscribing] = useState(true); - const { relay, signer, hasNostr } = useNostr(); - const [pubkey, setPubkey] = useState(undefined); + const authorizations = useAppSelector((state) => state.bunker.authorizations); - const authStorageKey = `soapbox:nostr:auth:${pubkey}`; - - useEffect(() => { - let isCancelled = false; - - if (signer && hasNostr) { - signer.getPublicKey().then((newPubkey) => { - if (!isCancelled) { - setPubkey(newPubkey); - } - }).catch(console.warn); + const connection = useAppSelector((state) => { + const accessToken = state.auth.tokens[state.auth.me!]?.access_token; + if (accessToken) { + return state.bunker.connections.find((conn) => conn.accessToken === accessToken); } - - return () => { - isCancelled = true; - }; - }, [signer, hasNostr]); + }); useEffect(() => { - if (!relay || !signer || !pubkey) return; + if (!relay || (!connection && !authorizations.length)) return; const bunker = new NBunker({ relay, - signer, + connection: (() => { + if (!connection) return; + const { authorizedPubkey, bunkerSeckey, pubkey } = connection; + + const user = NKeys.get(pubkey) ?? window.nostr; + if (!user) return; + + const decoded = nip19.decode(bunkerSeckey); + if (decoded.type !== 'nsec') return; + + return { + authorizedPubkey, + signers: { + user, + bunker: new NSecSigner(decoded.data), + }, + }; + })(), + authorizations: authorizations.reduce((result, auth) => { + const { secret, pubkey, bunkerSeckey } = auth; + + const user = NKeys.get(pubkey) ?? window.nostr; + if (!user) return result; + + const decoded = nip19.decode(bunkerSeckey); + if (decoded.type !== 'nsec') return result; + + result.push({ + secret, + signers: { + user, + bunker: new NSecSigner(decoded.data), + }, + }); + + return result; + }, [] as NBunkerOpts['authorizations']), 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]); + }, [relay, connection, authorizations]); return { isSubscribed, diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 169e8efbb..0d2600697 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -3,124 +3,173 @@ import { NostrConnectRequest, NostrConnectResponse, NostrEvent, + NostrFilter, NostrSigner, NSchema as n, } from '@nostrify/nostrify'; -interface NBunkerOpts { +interface NBunkerSigners { + user: NostrSigner; + bunker: NostrSigner; +} + +interface NBunkerConnection { + authorizedPubkey: string; + signers: NBunkerSigners; +} + +interface NBunkerAuthorization { + secret: string; + signers: NBunkerSigners; +} + +export interface NBunkerOpts { relay: NRelay; - signer: NostrSigner; - authorizedPubkey: string | undefined; + connection?: NBunkerConnection; + authorizations: NBunkerAuthorization[]; onAuthorize(pubkey: string): void; onSubscribed(): void; - getSecret(): string; } export class NBunker { private relay: NRelay; - private signer: NostrSigner; - private authorizedPubkey: string | undefined; + private connection?: NBunkerConnection; + private authorizations: NBunkerAuthorization[]; private onAuthorize: (pubkey: string) => void; private onSubscribed: () => void; - private getSecret: () => string; private controller = new AbortController(); constructor(opts: NBunkerOpts) { this.relay = opts.relay; - this.signer = opts.signer; - this.authorizedPubkey = opts.authorizedPubkey; + this.connection = opts.connection; + this.authorizations = opts.authorizations; this.onAuthorize = opts.onAuthorize; this.onSubscribed = opts.onSubscribed; - this.getSecret = opts.getSecret; this.open(); } async open() { - const pubkey = await this.signer.getPublicKey(); + 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 signal = this.controller.signal; - const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal }); - this.onSubscribed(); + const filters: NostrFilter[] = [ + { kinds: [24133], '#p': [bunkerPubkey], limit: 0 }, + ]; - for await (const msg of sub) { + for await (const msg of this.relay.req(filters, { signal })) { if (msg[0] === 'EVENT') { - const event = msg[2]; - this.handleEvent(event); + const [,, event] = msg; + + try { + const request = await this.decryptRequest(event, signers); + + if (request.method === 'connect') { + this.handleConnect(event, request, authorization); + } + } catch (error) { + console.warn(error); + } } } } - private async handleEvent(event: NostrEvent): Promise { - const decrypted = await this.decrypt(event.pubkey, event.content); - const request = n.json().pipe(n.connectRequest()).safeParse(decrypted); + private async subscribeConnection(connection: NBunkerConnection): Promise { + const { authorizedPubkey, signers } = connection; - if (!request.success) { - console.warn(decrypted); - console.warn(request.error); - return; + const bunkerPubkey = await signers.bunker.getPublicKey(); + const signal = this.controller.signal; + + const filters: NostrFilter[] = [ + { kinds: [24133], authors: [authorizedPubkey], '#p': [bunkerPubkey], limit: 0 }, + ]; + + for await (const msg of this.relay.req(filters, { signal })) { + if (msg[0] === 'EVENT') { + const [,, event] = msg; + + try { + const request = await this.decryptRequest(event, signers); + this.handleRequest(event, request, connection); + } catch (error) { + console.warn(error); + } + } } - - 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' }); - } + private async decryptRequest(event: NostrEvent, signers: NBunkerSigners): Promise { + const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content); + return n.json().pipe(n.connectRequest()).parse(decrypted); + } + + private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise { + const { signers, authorizedPubkey } = connection; + const { user } = signers; // Prevent unauthorized access. - if (pubkey !== this.authorizedPubkey) { + if (event.pubkey !== authorizedPubkey) { return; } // Authorized methods. switch (request.method) { case 'sign_event': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))), + result: JSON.stringify(await user.signEvent(JSON.parse(request.params[0]))), }); case 'ping': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, result: 'pong', }); case 'get_relays': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: JSON.stringify(await this.signer.getRelays?.() ?? []), + result: JSON.stringify(await user.getRelays?.() ?? []), }); case 'get_public_key': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: await this.signer.getPublicKey(), + result: await user.getPublicKey(), }); case 'nip04_encrypt': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]), + result: await user.nip04!.encrypt(request.params[0], request.params[1]), }); case 'nip04_decrypt': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]), + result: await user.nip04!.decrypt(request.params[0], request.params[1]), }); case 'nip44_encrypt': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]), + result: await user.nip44!.encrypt(request.params[0], request.params[1]), }); case 'nip44_decrypt': - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, - result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]), + result: await user.nip44!.decrypt(request.params[0], request.params[1]), }); default: - return this.sendResponse(pubkey, { + return this.sendResponse(event.pubkey, { id: request.id, result: '', error: `Unrecognized method: ${request.method}`, @@ -128,24 +177,29 @@ export class NBunker { } } - private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { - const [remotePubkey, secret] = request.params; + private async handleConnect(event: NostrEvent, request: NostrConnectRequest, authorization: NBunkerAuthorization): Promise { + const [, secret] = request.params; - if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { - this.authorizedPubkey = pubkey; - this.onAuthorize(pubkey); + if (secret === authorization.secret) { + this.onAuthorize(event.pubkey); - await this.sendResponse(pubkey, { + await this.sendResponse(event.pubkey, { id: request.id, result: 'ack', }); } } - private async sendResponse(pubkey: string, response: NostrConnectResponse) { - const event = await this.signer.signEvent({ + private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise { + const { user } = this.connection?.signers ?? {}; + + if (!user) { + return; + } + + const event = await user.signEvent({ kind: 24133, - content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)), + content: await user.nip04!.encrypt(pubkey, JSON.stringify(response)), tags: [['p', pubkey]], created_at: Math.floor(Date.now() / 1000), }); @@ -154,11 +208,11 @@ export class NBunker { } /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ - private async decrypt(pubkey: string, ciphertext: string): Promise { + private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise { try { - return await this.signer.nip44!.decrypt(pubkey, ciphertext); + return await signer.nip44!.decrypt(pubkey, ciphertext); } catch { - return await this.signer.nip04!.decrypt(pubkey, ciphertext); + return await signer.nip04!.decrypt(pubkey, ciphertext); } } diff --git a/src/reducers/bunker.ts b/src/reducers/bunker.ts index d2079888b..3da345ff0 100644 --- a/src/reducers/bunker.ts +++ b/src/reducers/bunker.ts @@ -13,7 +13,7 @@ interface BunkerAuthorization { /** 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; + bunkerSeckey: `nsec1${string}`; } /** @@ -29,7 +29,7 @@ interface BunkerConnection { /** Pubkey of the app authorized to sign events with this connection. */ authorizedPubkey: string; /** Secret key for this connection. NIP-46 responses will be signed by this key. */ - bunkerSeckey: Uint8Array; + bunkerSeckey: `nsec1${string}`; } export default createSlice({ diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 65cbecfe4..685944cd6 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,6 +7,7 @@ import admin from './admin'; import aliases from './aliases'; import auth from './auth'; import backups from './backups'; +import bunker from './bunker'; import chat_message_lists from './chat-message-lists'; import chat_messages from './chat-messages'; import chats from './chats'; @@ -56,7 +57,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 +112,5 @@ const reducers = { trending_statuses, trends, user_lists, -}; - -export default combineReducers(reducers); \ No newline at end of file + bunker: bunker.reducer, +}); \ No newline at end of file