From 1223e3b9b6c2663ba62eb970281671667d03ad86 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 27 Oct 2024 17:02:21 -0500 Subject: [PATCH 01/28] Add Nostr `bunker` slice to redux store --- src/reducers/bunker.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 src/reducers/bunker.ts diff --git a/src/reducers/bunker.ts b/src/reducers/bunker.ts new file mode 100644 index 000000000..d2079888b --- /dev/null +++ b/src/reducers/bunker.ts @@ -0,0 +1,42 @@ +import { createSlice } from '@reduxjs/toolkit'; + +/** + * 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, + * 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; + /** Secret key for this connection. NIP-46 responses will be signed by this key. */ + bunkerSeckey: Uint8Array; +} + +export default createSlice({ + name: 'bunker', + initialState: { + authorizations: [] as BunkerAuthorization[], + connections: [] as BunkerConnection[], + }, + reducers: {}, +}); \ No newline at end of file From aec9043c9b28f65cd2b0f40f19423515c6ffb6ff Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 27 Oct 2024 18:02:16 -0500 Subject: [PATCH 02/28] NBunker: auto-decrypt NIP-44/NIP-04 --- src/features/nostr/NBunker.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 3bbe67b90..169e8efbb 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -1,4 +1,11 @@ -import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify'; +import { + NRelay, + NostrConnectRequest, + NostrConnectResponse, + NostrEvent, + NostrSigner, + NSchema as n, +} from '@nostrify/nostrify'; interface NBunkerOpts { relay: NRelay; @@ -47,7 +54,7 @@ export class NBunker { } private async handleEvent(event: NostrEvent): Promise { - const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content); + const decrypted = await this.decrypt(event.pubkey, event.content); const request = n.json().pipe(n.connectRequest()).safeParse(decrypted); if (!request.success) { @@ -146,6 +153,15 @@ export class NBunker { await this.relay.event(event); } + /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ + private async decrypt(pubkey: string, ciphertext: string): Promise { + try { + return await this.signer.nip44!.decrypt(pubkey, ciphertext); + } catch { + return await this.signer.nip04!.decrypt(pubkey, ciphertext); + } + } + close() { this.controller.abort(); } From 14793ef0a9931630bc11e712bd9dd1778f7fcfb4 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sun, 27 Oct 2024 19:35:41 -0500 Subject: [PATCH 03/28] 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 From eb0f5b8e3e9ef863b4da7ed9a97420e31c9c451e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 15:27:07 -0500 Subject: [PATCH 04/28] Finish useBunkerStore? --- package.json | 3 +- src/reducers/auth.ts | 2 +- src/reducers/bunker.ts | 159 ++++++++++++++++++++++++++++++++++++++--- src/schemas/utils.ts | 18 ++--- yarn.lock | 5 ++ 5 files changed, 166 insertions(+), 21 deletions(-) diff --git a/package.json b/package.json index cca82ae30..9fbc258d6 100644 --- a/package.json +++ b/package.json @@ -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/reducers/auth.ts b/src/reducers/auth.ts index 64934a665..529d89060 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -37,7 +37,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; diff --git a/src/reducers/bunker.ts b/src/reducers/bunker.ts index 3da345ff0..586abfd5a 100644 --- a/src/reducers/bunker.ts +++ b/src/reducers/bunker.ts @@ -1,4 +1,18 @@ -import { createSlice } from '@reduxjs/toolkit'; +import { produce } from 'immer'; +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; +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'; + +/** User-facing authorization string. */ +interface BunkerURI { + pubkey: string; + relays: string[]; + secret?: string; +} /** * Temporary authorization details to establish a bunker connection with an app. @@ -13,7 +27,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: `nsec1${string}`; + bunkerSeckey: Uint8Array; } /** @@ -29,14 +43,137 @@ 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: `nsec1${string}`; + bunkerSeckey: Uint8Array; } -export default createSlice({ - name: 'bunker', - initialState: { - authorizations: [] as BunkerAuthorization[], - connections: [] as BunkerConnection[], - }, - reducers: {}, -}); \ No newline at end of file +/** Options for connecting to the bunker. */ +interface BunkerConnectRequest { + accessToken: string; + authorizedPubkey: string; + bunkerPubkey: string; + secret: string; +} + +const nsecSchema = z.custom<`nsec1${string}`>((v) => typeof v === 'string' && v.startsWith('nsec1')); + +const connectionSchema = z.object({ + pubkey: z.string(), + accessToken: z.string(), + authorizedPubkey: z.string(), + bunkerSeckey: nsecSchema, +}); + +const authorizationSchema = z.object({ + secret: z.string(), + pubkey: z.string(), + bunkerSeckey: nsecSchema, +}); + +const stateSchema = z.object({ + connections: filteredArray(connectionSchema), + authorizations: filteredArray(authorizationSchema), +}); + +interface BunkerState { + connections: BunkerConnection[]; + authorizations: BunkerAuthorization[]; +} + +export const useBunkerStore = create()( + persist( + (setState, getState) => ({ + connections: [], + authorizations: [], + + /** Generate a new authorization and persist it into the store. */ + authorize(pubkey: string): BunkerURI { + const authorization: BunkerAuthorization = { + pubkey, + secret: crypto.randomUUID(), + bunkerSeckey: generateSecretKey(), + }; + + setState((state) => { + return produce(state, (draft) => { + draft.authorizations.push(authorization); + }); + }); + + return { + pubkey: getPublicKey(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, + }; + + setState((state) => { + return produce(state, (draft) => { + draft.connections.push(connection); + draft.authorizations = draft.authorizations.filter((existing) => existing !== authorization); + }); + }); + }, + }), + { + name: 'soapbox:bunker', + storage: { + getItem(name) { + const connections = localStorage.getItem(`${name}:connections`); + const authorizations = sessionStorage.getItem(`${name}:authorizations`); + + const state = stateSchema.parse({ + connections: jsonSchema(nsecReviver).catch([]).parse(connections), + authorizations: jsonSchema(nsecReviver).catch([]).parse(authorizations), + }); + + return { state }; + }, + setItem(name, { state }) { + localStorage.setItem(`${name}:connections`, JSON.stringify(state.connections, nsecReplacer)); + sessionStorage.setItem(`${name}:authorizations`, JSON.stringify(state.authorizations, nsecReplacer)); + }, + removeItem(name) { + localStorage.removeItem(`${name}:connections`); + sessionStorage.removeItem(`${name}:authorizations`); + }, + }, + }, + ), +); + +/** Encode Uint8Arrays into nsec strings. */ +function nsecReplacer(_key: string, value: unknown): unknown { + if (value instanceof Uint8Array) { + return nip19.nsecEncode(value); + } + + return value; +} + +/** Decode nsec strings into Uint8Arrays. */ +function nsecReviver(_key: string, value: unknown): unknown { + if (typeof value === 'string' && value.startsWith('nsec1')) { + return nip19.decode(value as `nsec1${string}`).data; + } + + return value; +} \ 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== From 6788be671e7982ec860dd36c71f0a93f4f02904b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 15:27:45 -0500 Subject: [PATCH 05/28] Move useBunkerStore to src/hooks --- src/{reducers/bunker.ts => hooks/useBunkerStore.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/{reducers/bunker.ts => hooks/useBunkerStore.ts} (100%) diff --git a/src/reducers/bunker.ts b/src/hooks/useBunkerStore.ts similarity index 100% rename from src/reducers/bunker.ts rename to src/hooks/useBunkerStore.ts From f67abaa3cdc04482e0afcc70a6bd2ac0f3514492 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 16:15:06 -0500 Subject: [PATCH 06/28] useSignerStream -> useBunker --- src/api/hooks/nostr/{useSignerStream.ts => useBunker.ts} | 4 ++-- src/init/soapbox-load.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/api/hooks/nostr/{useSignerStream.ts => useBunker.ts} (97%) diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useBunker.ts similarity index 97% rename from src/api/hooks/nostr/useSignerStream.ts rename to src/api/hooks/nostr/useBunker.ts index 1dc0337b9..f0213da22 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useBunker.ts @@ -11,7 +11,7 @@ const secretStorageKey = 'soapbox:nip46:secret'; sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); -function useSignerStream() { +function useBunker() { const { relay } = useNostr(); const [isSubscribed, setIsSubscribed] = useState(false); @@ -88,4 +88,4 @@ function useSignerStream() { }; } -export { useSignerStream }; +export { useBunker }; diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 28808726e..68155fef4 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -3,7 +3,7 @@ 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 { useBunker } from 'soapbox/api/hooks/nostr/useBunker'; import LoadingScreen from 'soapbox/components/loading-screen'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { @@ -45,7 +45,7 @@ const SoapboxLoad: React.FC = ({ children }) => { const [isLoaded, setIsLoaded] = useState(false); const { hasNostr, isRelayOpen, signer } = useNostr(); - const { isSubscribed } = useSignerStream(); + const { isSubscribed } = useBunker(); const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed)); From 78bf71bdf9ed1f2b67f859a356b1645649436692 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 16:28:14 -0500 Subject: [PATCH 07/28] NBunker: DRY the subscription --- src/features/nostr/NBunker.ts | 38 +++++++++++++---------------------- 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 0d2600697..91ee10110 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -63,48 +63,43 @@ export class NBunker { private async subscribeAuthorization(authorization: NBunkerAuthorization): Promise { const { signers } = authorization; - const bunkerPubkey = await signers.bunker.getPublicKey(); - const signal = this.controller.signal; const filters: NostrFilter[] = [ { kinds: [24133], '#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); - - if (request.method === 'connect') { - this.handleConnect(event, request, authorization); - } - } catch (error) { - console.warn(error); - } + 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(); - const signal = this.controller.signal; const filters: NostrFilter[] = [ { kinds: [24133], authors: [authorizedPubkey], '#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 })) { if (msg[0] === 'EVENT') { const [,, event] = msg; try { - const request = await this.decryptRequest(event, signers); - this.handleRequest(event, request, connection); + const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content); + const request = n.json().pipe(n.connectRequest()).parse(decrypted); + yield { event, request }; } catch (error) { console.warn(error); } @@ -112,11 +107,6 @@ export class NBunker { } } - 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; From 333d3e76d605858e56d8882aba452abd9ddde73b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 17:00:46 -0500 Subject: [PATCH 08/28] Finish rewriting the bunker...? (now everything else is broken) --- src/api/hooks/nostr/useBunker.ts | 24 +++++------------------- src/features/nostr/NBunker.ts | 5 ----- src/hooks/useBunkerStore.ts | 11 +++++++++++ src/reducers/index.ts | 2 -- 4 files changed, 16 insertions(+), 26 deletions(-) diff --git a/src/api/hooks/nostr/useBunker.ts b/src/api/hooks/nostr/useBunker.ts index f0213da22..2b5e8c433 100644 --- a/src/api/hooks/nostr/useBunker.ts +++ b/src/api/hooks/nostr/useBunker.ts @@ -1,28 +1,23 @@ import { NSecSigner } from '@nostrify/nostrify'; -import { nip19 } from 'nostr-tools'; import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; 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()); +import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; function useBunker() { const { relay } = useNostr(); + const { authorizations, connections } = useBunkerStore(); const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribing, setIsSubscribing] = useState(true); - const authorizations = useAppSelector((state) => state.bunker.authorizations); - 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 connections.find((conn) => conn.accessToken === accessToken); } }); @@ -38,14 +33,11 @@ function useBunker() { 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), + bunker: new NSecSigner(bunkerSeckey), }, }; })(), @@ -55,22 +47,16 @@ function useBunker() { 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), + bunker: new NSecSigner(bunkerSeckey), }, }); return result; }, [] as NBunkerOpts['authorizations']), - onAuthorize(authorizedPubkey) { - sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); - }, onSubscribed() { setIsSubscribed(true); setIsSubscribing(false); diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 91ee10110..3b41a376e 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -27,7 +27,6 @@ export interface NBunkerOpts { relay: NRelay; connection?: NBunkerConnection; authorizations: NBunkerAuthorization[]; - onAuthorize(pubkey: string): void; onSubscribed(): void; } @@ -36,7 +35,6 @@ export class NBunker { private relay: NRelay; private connection?: NBunkerConnection; private authorizations: NBunkerAuthorization[]; - private onAuthorize: (pubkey: string) => void; private onSubscribed: () => void; private controller = new AbortController(); @@ -45,7 +43,6 @@ export class NBunker { this.relay = opts.relay; this.connection = opts.connection; this.authorizations = opts.authorizations; - this.onAuthorize = opts.onAuthorize; this.onSubscribed = opts.onSubscribed; this.open(); @@ -171,8 +168,6 @@ export class NBunker { const [, secret] = request.params; if (secret === authorization.secret) { - this.onAuthorize(event.pubkey); - await this.sendResponse(event.pubkey, { id: request.id, result: 'ack', diff --git a/src/hooks/useBunkerStore.ts b/src/hooks/useBunkerStore.ts index 586abfd5a..1ab4f2664 100644 --- a/src/hooks/useBunkerStore.ts +++ b/src/hooks/useBunkerStore.ts @@ -77,6 +77,8 @@ const stateSchema = z.object({ interface BunkerState { connections: BunkerConnection[]; authorizations: BunkerAuthorization[]; + authorize(pubkey: string): BunkerURI; + connect(request: BunkerConnectRequest): void; } export const useBunkerStore = create()( @@ -132,6 +134,15 @@ export const useBunkerStore = create()( }); }); }, + + /** Revoke any connections associated with the access token. */ + revoke(accessToken: string) { + setState((state) => { + return produce(state, (draft) => { + draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken); + }); + }); + }, }), { name: 'soapbox:bunker', diff --git a/src/reducers/index.ts b/src/reducers/index.ts index 685944cd6..2f5d286e6 100644 --- a/src/reducers/index.ts +++ b/src/reducers/index.ts @@ -7,7 +7,6 @@ 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'; @@ -112,5 +111,4 @@ export default combineReducers({ trending_statuses, trends, user_lists, - bunker: bunker.reducer, }); \ No newline at end of file From 5c4b73f94331b2bfa3ad051f2cf38804619e8a7c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 20:20:37 -0500 Subject: [PATCH 09/28] OKAY IT'S WORKING --- src/actions/nostr.ts | 75 +++++++++++++------ src/api/hooks/nostr/useSigner.ts | 34 +++++++++ src/contexts/nostr-context.tsx | 17 ++--- src/entity-store/reducer.ts | 4 +- .../compose/components/search-zap-split.tsx | 2 - src/features/nostr/NostrRPC.ts | 66 ++++++++++++++++ .../components/nostr-extension-indicator.tsx | 8 +- .../steps/extension-step.tsx | 8 +- .../nostr-login-modal/steps/key-add-step.tsx | 6 +- .../nostr-signup-modal/steps/keygen-step.tsx | 9 ++- src/hooks/useBunkerStore.ts | 20 ++--- src/main.tsx | 3 + 12 files changed, 189 insertions(+), 63 deletions(-) create mode 100644 src/api/hooks/nostr/useSigner.ts create mode 100644 src/features/nostr/NostrRPC.ts diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index d204fcd62..2bb326219 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,4 +1,8 @@ -import { RootState, type AppDispatch } from 'soapbox/store'; +import { NostrSigner, NRelay1 } from '@nostrify/nostrify'; + +import { NostrRPC } from 'soapbox/features/nostr/NostrRPC'; +import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; +import { type AppDispatch } from 'soapbox/store'; import { authLoggedIn, verifyCredentials } from './auth'; import { obtainOAuthToken } from './oauth'; @@ -6,42 +10,67 @@ 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, signal: AbortSignal) { + return async (dispatch: AppDispatch) => { + const pubkey = await signer.getPublicKey(); + const bunker = useBunkerStore.getState(); + const authorization = bunker.authorize(pubkey); + const bunkerPubkey = await authorization.signer.getPublicKey(); - const secret = sessionStorage.getItem('soapbox:nip46:secret'); - if (!secret) { - throw new Error('No secret found in session storage'); - } + const rpc = new NostrRPC(relay, authorization.signer); + const sub = rpc.req([{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 }], { signal: AbortSignal.timeout(1_000) }); - const relay = getState().instance.nostr?.relay; - - // HACK: waits 1 second to ensure the relay subscription is open - await new Promise((resolve) => setTimeout(resolve, 1000)); - - const token = await dispatch(obtainOAuthToken({ + const tokenPromise = 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)); + let authorizedPubkey: string | undefined; - dispatch(setNostrPubkey(undefined)); + for await (const { request, respond, requestEvent } of sub) { + if (request.method === 'connect') { + const [, secret] = request.params; + + if (secret === authorization.secret) { + authorizedPubkey = requestEvent.pubkey; + await respond({ result: 'ack' }); + } else { + await respond({ result: '', error: 'Invalid secret' }); + throw new Error('Invalid secret'); + } + } + if (request.method === 'get_public_key') { + await respond({ result: pubkey }); + break; + } + } + + if (!authorizedPubkey) { + throw new Error('Authorization failed'); + } + + const { access_token } = dispatch(authLoggedIn(await tokenPromise)); + + useBunkerStore.getState().connect({ + accessToken: access_token as string, + authorizedPubkey, + bunkerPubkey, + secret: authorization.secret, + }); + + await dispatch(verifyCredentials(access_token as string)); }; } /** Log in with a Nostr extension. */ -function nostrExtensionLogIn() { +function nostrExtensionLogIn(relay: NRelay1, signal: AbortSignal) { 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, signal)); }; } diff --git a/src/api/hooks/nostr/useSigner.ts b/src/api/hooks/nostr/useSigner.ts new file mode 100644 index 000000000..a9f59d6dd --- /dev/null +++ b/src/api/hooks/nostr/useSigner.ts @@ -0,0 +1,34 @@ +import { useQuery } from '@tanstack/react-query'; + +import { NKeys } from 'soapbox/features/nostr/keys'; +import { useAppSelector } from 'soapbox/hooks'; +import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; + +export function useSigner() { + const { connections } = useBunkerStore(); + + const pubkey = useAppSelector(({ auth }) => { + const accessToken = auth.me ? auth.tokens[auth.me]?.access_token : undefined; + if (accessToken) { + return connections.find((conn) => conn.accessToken === accessToken)?.pubkey; + } + }); + + const { data: signer, ...rest } = useQuery({ + queryKey: ['nostr', 'signer', pubkey], + queryFn: async () => { + if (!pubkey) return null; + + const signer = NKeys.get(pubkey); + if (signer) return signer; + + if (window.nostr && await window.nostr.getPublicKey() === pubkey) { + return window.nostr; + } + + return null; + }, + }); + + return { signer: signer ?? undefined, ...rest }; +} \ No newline at end of file diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index c9a70ed70..a92b45162 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -1,12 +1,11 @@ -import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; -import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; +import { NRelay1, NostrSigner } from '@nostrify/nostrify'; +import React, { createContext, useContext, useState, useEffect } from 'react'; -import { NKeys } from 'soapbox/features/nostr/keys'; -import { useAppSelector } from 'soapbox/hooks'; +import { useSigner } from 'soapbox/api/hooks/nostr/useSigner'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { - relay?: NRelay; + relay?: NRelay1; signer?: NostrSigner; hasNostr: boolean; isRelayOpen: boolean; @@ -20,18 +19,14 @@ interface NostrProviderProps { 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 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 handleRelayOpen = () => { setIsRelayOpen(true); 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/NostrRPC.ts b/src/features/nostr/NostrRPC.ts new file mode 100644 index 000000000..8248fbba4 --- /dev/null +++ b/src/features/nostr/NostrRPC.ts @@ -0,0 +1,66 @@ +import { + NRelay, + NostrConnectRequest, + NostrConnectResponse, + NostrEvent, + NostrFilter, + NostrSigner, + NSchema as n, +} from '@nostrify/nostrify'; + +export class NostrRPC { + + constructor( + private relay: NRelay, + private signer: NostrSigner, + ) {} + + async *req( + filters: NostrFilter[], + opts: { signal?: AbortSignal }, + ): AsyncIterable<{ + requestEvent: NostrEvent; + request: NostrConnectRequest; + respond: (response: Omit) => Promise; + }> { + for await (const msg of this.relay.req(filters, opts)) { + if (msg[0] === 'EVENT') { + const [,, requestEvent] = msg; + + const decrypted = await this.decrypt(this.signer, requestEvent.pubkey, requestEvent.content); + const request = n.json().pipe(n.connectRequest()).parse(decrypted); + + const respond = async (response: Omit): Promise => { + await this.respond(requestEvent, { ...response, id: request.id }); + }; + + yield { requestEvent, request, respond }; + } + + if (msg[0] === 'CLOSED') { + break; + } + } + } + + private async respond(requestEvent: NostrEvent, response: NostrConnectResponse): Promise { + const responseEvent = await this.signer.signEvent({ + kind: 24133, + content: await this.signer.nip04!.encrypt(requestEvent.pubkey, JSON.stringify(response)), + tags: [['p', requestEvent.pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.relay.event(responseEvent); + } + + /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ + private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise { + try { + return await signer.nip44!.decrypt(pubkey, ciphertext); + } catch { + return await signer.nip04!.decrypt(pubkey, ciphertext); + } + } + +} \ No newline at end of file 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..932a3d9d0 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, AbortSignal.timeout(30_000))); + 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..22d8d0638 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, AbortSignal.timeout(30_000))); + 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..a1066a708 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,6 +5,7 @@ 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 { useNostr } from 'soapbox/contexts/nostr-context'; import { NKeys } from 'soapbox/features/nostr/keys'; import { useAppDispatch } from 'soapbox/hooks'; @@ -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)); + dispatch(logInNostr(signer, relay, AbortSignal.timeout(30_000))); 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..696e43228 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 @@ -43,8 +43,9 @@ const KeygenStep: React.FC = ({ onClose }) => { }; const handleNext = async () => { + if (!relay) return; + const signer = NKeys.add(secretKey); - const pubkey = await signer.getPublicKey(); 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, AbortSignal.timeout(30_000))); if (isMobile) { dispatch(closeSidebar()); diff --git a/src/hooks/useBunkerStore.ts b/src/hooks/useBunkerStore.ts index 1ab4f2664..4535fe2b7 100644 --- a/src/hooks/useBunkerStore.ts +++ b/src/hooks/useBunkerStore.ts @@ -1,3 +1,4 @@ +import { NSchema as n, NostrSigner, NSecSigner } from '@nostrify/nostrify'; import { produce } from 'immer'; import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -7,13 +8,6 @@ import { persist } from 'zustand/middleware'; import { filteredArray, jsonSchema } from 'soapbox/schemas/utils'; -/** User-facing authorization string. */ -interface BunkerURI { - pubkey: string; - relays: string[]; - secret?: string; -} - /** * Temporary authorization details to establish a bunker connection with an app. * Will be upgraded to a `BunkerConnection` once the connection is established. @@ -54,19 +48,17 @@ interface BunkerConnectRequest { secret: string; } -const nsecSchema = z.custom<`nsec1${string}`>((v) => typeof v === 'string' && v.startsWith('nsec1')); - const connectionSchema = z.object({ pubkey: z.string(), accessToken: z.string(), authorizedPubkey: z.string(), - bunkerSeckey: nsecSchema, + bunkerSeckey: n.bech32('nsec'), }); const authorizationSchema = z.object({ secret: z.string(), pubkey: z.string(), - bunkerSeckey: nsecSchema, + bunkerSeckey: n.bech32('nsec'), }); const stateSchema = z.object({ @@ -77,7 +69,7 @@ const stateSchema = z.object({ interface BunkerState { connections: BunkerConnection[]; authorizations: BunkerAuthorization[]; - authorize(pubkey: string): BunkerURI; + authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string }; connect(request: BunkerConnectRequest): void; } @@ -88,7 +80,7 @@ export const useBunkerStore = create()( authorizations: [], /** Generate a new authorization and persist it into the store. */ - authorize(pubkey: string): BunkerURI { + authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string } { const authorization: BunkerAuthorization = { pubkey, secret: crypto.randomUUID(), @@ -102,7 +94,7 @@ export const useBunkerStore = create()( }); return { - pubkey: getPublicKey(authorization.bunkerSeckey), + signer: new NSecSigner(authorization.bunkerSeckey), secret: authorization.secret, relays: [], }; diff --git a/src/main.tsx b/src/main.tsx index c0f99b85c..16693ae46 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'; @@ -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'); From 1c1b56575c5fb73a0b9c98f50ef5b4640bf42033 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 20:28:18 -0500 Subject: [PATCH 10/28] Loosen browserlists --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9fbc258d6..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" ], From 4d99a6d4f64c74626a000ceb436155287fc1184b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 23:09:45 -0500 Subject: [PATCH 11/28] Move authorization out of bunkerStore, into loginNostr --- src/actions/nostr.ts | 32 +++++++--- src/api/hooks/nostr/useBunker.ts | 24 ++----- src/features/nostr/NBunker.ts | 37 ----------- src/hooks/useBunkerStore.ts | 106 ++++--------------------------- 4 files changed, 39 insertions(+), 160 deletions(-) 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); }, }, }, From df9cc197140494aa0112e1e6fd8b9f2a59b49384 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:00:49 -0500 Subject: [PATCH 12/28] useBunkerStore: fix parsing of saved connections --- src/hooks/useBunkerStore.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBunkerStore.ts b/src/hooks/useBunkerStore.ts index 0b05ec2fb..20816e8da 100644 --- a/src/hooks/useBunkerStore.ts +++ b/src/hooks/useBunkerStore.ts @@ -1,4 +1,3 @@ -import { NSchema as n } from '@nostrify/nostrify'; import { produce } from 'immer'; import { nip19 } from 'nostr-tools'; import { z } from 'zod'; @@ -28,7 +27,7 @@ const connectionSchema = z.object({ pubkey: z.string(), accessToken: z.string(), authorizedPubkey: z.string(), - bunkerSeckey: n.bech32('nsec'), + bunkerSeckey: z.custom((value) => value instanceof Uint8Array), }); interface BunkerState { @@ -63,10 +62,12 @@ export const useBunkerStore = create()( name: 'soapbox:bunker', storage: { getItem(name) { + const value = localStorage.getItem(name); + const connections = jsonSchema(nsecReviver) .pipe(filteredArray(connectionSchema)) .catch([]) - .parse(localStorage.getItem(name)); + .parse(value); return { state: { connections } }; }, From 47fa485f972c4a2152808120c1987227694213fa Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:04:09 -0500 Subject: [PATCH 13/28] useSigner: fix accessToken retrieval, return bunkerSigner along with user signer --- src/api/hooks/nostr/useSigner.ts | 26 +++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/src/api/hooks/nostr/useSigner.ts b/src/api/hooks/nostr/useSigner.ts index a9f59d6dd..22aadae1d 100644 --- a/src/api/hooks/nostr/useSigner.ts +++ b/src/api/hooks/nostr/useSigner.ts @@ -1,4 +1,6 @@ +import { NSecSigner } from '@nostrify/nostrify'; import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; import { useAppSelector } from 'soapbox/hooks'; @@ -7,15 +9,17 @@ import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; export function useSigner() { const { connections } = useBunkerStore(); - const pubkey = useAppSelector(({ auth }) => { - const accessToken = auth.me ? auth.tokens[auth.me]?.access_token : undefined; + const connection = useAppSelector(({ auth }) => { + const accessToken = auth.me ? auth.users[auth.me]?.access_token : undefined; if (accessToken) { - return connections.find((conn) => conn.accessToken === accessToken)?.pubkey; + return connections.find((conn) => conn.accessToken === accessToken); } }); + const { pubkey, bunkerSeckey, authorizedPubkey } = connection ?? {}; + const { data: signer, ...rest } = useQuery({ - queryKey: ['nostr', 'signer', pubkey], + queryKey: ['nostr', 'signer', pubkey ?? ''], queryFn: async () => { if (!pubkey) return null; @@ -28,7 +32,19 @@ export function useSigner() { return null; }, + enabled: !!pubkey, }); - return { signer: signer ?? undefined, ...rest }; + const bunkerSigner = useMemo(() => { + if (bunkerSeckey) { + return new NSecSigner(bunkerSeckey); + } + }, [bunkerSeckey]); + + return { + signer: signer ?? undefined, + bunkerSigner, + authorizedPubkey, + ...rest, + }; } \ No newline at end of file From d6579eecf70c2f37242a4d0033025efb7181bcc1 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:05:03 -0500 Subject: [PATCH 14/28] Rewrite NBunker to have a more flexible and modular interface --- src/actions/nostr.ts | 4 + src/api/hooks/nostr/useBunker.ts | 52 ++------- src/contexts/nostr-context.tsx | 21 ++-- src/features/nostr/NBunker.ts | 177 ++++++++++++++++++------------- src/init/soapbox-load.tsx | 9 +- 5 files changed, 133 insertions(+), 130 deletions(-) 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 = [ From 66d082934f5a3b97fa890a3b7f04a68219cf5e4f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:07:16 -0500 Subject: [PATCH 15/28] Move nostr hooks to hooks/nostr --- src/actions/nostr.ts | 2 +- src/contexts/nostr-context.tsx | 2 +- src/{api => }/hooks/nostr/useBunker.ts | 2 +- src/hooks/{ => nostr}/useBunkerStore.ts | 0 src/{api => }/hooks/nostr/useSigner.ts | 2 +- src/init/soapbox-load.tsx | 4 ++-- 6 files changed, 6 insertions(+), 6 deletions(-) rename src/{api => }/hooks/nostr/useBunker.ts (92%) rename src/hooks/{ => nostr}/useBunkerStore.ts (100%) rename src/{api => }/hooks/nostr/useSigner.ts (94%) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 8afc2abec..d58bc571a 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -2,7 +2,7 @@ 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'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; import { type AppDispatch } from 'soapbox/store'; import { authLoggedIn, verifyCredentials } from './auth'; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 2fec40a06..7230daf60 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -1,7 +1,7 @@ import { NRelay1, NostrSigner } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect } from 'react'; -import { useSigner } from 'soapbox/api/hooks/nostr/useSigner'; +import { useSigner } from 'soapbox/hooks/nostr/useSigner'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { diff --git a/src/api/hooks/nostr/useBunker.ts b/src/hooks/nostr/useBunker.ts similarity index 92% rename from src/api/hooks/nostr/useBunker.ts rename to src/hooks/nostr/useBunker.ts index 2b9249e3b..6cb7058ca 100644 --- a/src/api/hooks/nostr/useBunker.ts +++ b/src/hooks/nostr/useBunker.ts @@ -1,8 +1,8 @@ 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 { useSigner } from 'soapbox/hooks/nostr/useSigner'; function useBunker() { const { relay } = useNostr(); diff --git a/src/hooks/useBunkerStore.ts b/src/hooks/nostr/useBunkerStore.ts similarity index 100% rename from src/hooks/useBunkerStore.ts rename to src/hooks/nostr/useBunkerStore.ts diff --git a/src/api/hooks/nostr/useSigner.ts b/src/hooks/nostr/useSigner.ts similarity index 94% rename from src/api/hooks/nostr/useSigner.ts rename to src/hooks/nostr/useSigner.ts index 22aadae1d..a91422006 100644 --- a/src/api/hooks/nostr/useSigner.ts +++ b/src/hooks/nostr/useSigner.ts @@ -4,7 +4,7 @@ import { useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; import { useAppSelector } from 'soapbox/hooks'; -import { useBunkerStore } from 'soapbox/hooks/useBunkerStore'; +import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; export function useSigner() { const { connections } = useBunkerStore(); diff --git a/src/init/soapbox-load.tsx b/src/init/soapbox-load.tsx index 922ddd463..7ad79e5a0 100644 --- a/src/init/soapbox-load.tsx +++ b/src/init/soapbox-load.tsx @@ -3,8 +3,6 @@ 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 { @@ -14,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 */ From 1b54bcf5f3d6413148d9f188aea3a81599d3cef3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:12:52 -0500 Subject: [PATCH 16/28] useNostrReq: add deprecation warning --- src/features/nostr/hooks/useNostrReq.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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(); From cfd4908e8d3cae7a127c0b9b22f956b55f7a912e Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:37:51 -0500 Subject: [PATCH 17/28] logInNostr: rewrite with new Bunker API --- src/actions/nostr.ts | 56 ++++++++----------- src/features/nostr/NBunker.ts | 27 +++++++-- .../components/nostr-extension-indicator.tsx | 2 +- .../steps/extension-step.tsx | 2 +- .../nostr-login-modal/steps/key-add-step.tsx | 2 +- .../nostr-signup-modal/steps/keygen-step.tsx | 2 +- 6 files changed, 51 insertions(+), 40 deletions(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index d58bc571a..e1b638c58 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,7 +1,7 @@ import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify'; import { generateSecretKey } from 'nostr-tools'; -import { NostrRPC } from 'soapbox/features/nostr/NostrRPC'; +import { NBunker } from 'soapbox/features/nostr/NBunker'; import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; import { type AppDispatch } from 'soapbox/store'; @@ -11,52 +11,44 @@ import { obtainOAuthToken } from './oauth'; const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ -function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) { +function logInNostr(signer: NostrSigner, relay: NRelay1) { return async (dispatch: AppDispatch) => { const authorization = generateBunkerAuth(); const pubkey = await signer.getPublicKey(); const bunkerPubkey = await authorization.signer.getPublicKey(); - const rpc = new NostrRPC(relay, authorization.signer); - const sub = rpc.req([{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 }], { signal: AbortSignal.timeout(1_000) }); + let authorizedPubkey: string | undefined; - const tokenPromise = dispatch(obtainOAuthToken({ + using 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' }; + } + }, + }); + + const token = await dispatch(obtainOAuthToken({ grant_type: 'nostr_bunker', pubkey: bunkerPubkey, relays: [relay.socket.url], secret: authorization.secret, })); - let authorizedPubkey: string | undefined; - - for await (const { request, respond, requestEvent } of sub) { - if (request.method === 'connect') { - const [, secret] = request.params; - - if (secret === authorization.secret) { - authorizedPubkey = requestEvent.pubkey; - await respond({ result: 'ack' }); - } else { - await respond({ result: '', error: 'Invalid secret' }); - throw new Error('Invalid secret'); - } - } - if (request.method === 'get_public_key') { - 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) { throw new Error('Authorization failed'); } - const accessToken = dispatch(authLoggedIn(await tokenPromise)).access_token as string; + const accessToken = dispatch(authLoggedIn(token)).access_token as string; const bunkerState = useBunkerStore.getState(); bunkerState.connect({ @@ -71,12 +63,12 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) { } /** Log in with a Nostr extension. */ -function nostrExtensionLogIn(relay: NRelay1, signal: AbortSignal) { +function nostrExtensionLogIn(relay: NRelay1) { return async (dispatch: AppDispatch) => { if (!window.nostr) { throw new Error('No Nostr signer available'); } - return dispatch(logInNostr(window.nostr, relay, signal)); + return dispatch(logInNostr(window.nostr, relay)); }; } diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index 86cc76b9f..b3d1a69a6 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -19,10 +19,26 @@ export interface NBunkerOpts { /** * 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. + * 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 | void; + 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, @@ -86,7 +102,10 @@ export class NBunker { const { pubkey } = event; if (request.method === 'connect') { - onConnect?.(request, event); + if (onConnect) { + const response = await onConnect(request, event); + return this.sendResponse(pubkey, response); + } return; } @@ -191,7 +210,7 @@ export class NBunker { this.controller.abort(); } - [Symbol.asyncDispose](): void { + [Symbol.dispose](): void { this.close(); } 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 932a3d9d0..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 @@ -14,7 +14,7 @@ const NostrExtensionIndicator: React.FC = () => { const onClick = () => { if (relay) { - dispatch(nostrExtensionLogIn(relay, AbortSignal.timeout(30_000))); + dispatch(nostrExtensionLogIn(relay)); dispatch(closeModal()); } }; 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 22d8d0638..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 @@ -28,7 +28,7 @@ const ExtensionStep: React.FC = ({ isLogin, onClickAlt, onClose const onClick = () => { if (relay) { - dispatch(nostrExtensionLogIn(relay, AbortSignal.timeout(30_000))); + dispatch(nostrExtensionLogIn(relay)); onClose(); } }; 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 a1066a708..486afbe77 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 @@ -34,7 +34,7 @@ const KeyAddStep: React.FC = ({ onClose }) => { if (result.type === 'nsec') { const seckey = result.data; const signer = NKeys.add(seckey); - dispatch(logInNostr(signer, relay, AbortSignal.timeout(30_000))); + 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 696e43228..ae50f6a9f 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 @@ -63,7 +63,7 @@ const KeygenStep: React.FC = ({ onClose }) => { onClose(); - await dispatch(logInNostr(signer, relay, AbortSignal.timeout(30_000))); + await dispatch(logInNostr(signer, relay)); if (isMobile) { dispatch(closeSidebar()); From 9faf9942a95898b7dc5549e387b03af4b8224dd3 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 15:44:36 -0500 Subject: [PATCH 18/28] NBunker: add waitReady property --- src/actions/nostr.ts | 2 ++ src/features/nostr/NBunker.ts | 12 +++++++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index e1b638c58..e9aec6dd8 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -37,6 +37,8 @@ function logInNostr(signer: NostrSigner, relay: NRelay1) { }, }); + await bunker.waitReady; + const token = await dispatch(obtainOAuthToken({ grant_type: 'nostr_bunker', pubkey: bunkerPubkey, diff --git a/src/features/nostr/NBunker.ts b/src/features/nostr/NBunker.ts index b3d1a69a6..078d22a83 100644 --- a/src/features/nostr/NBunker.ts +++ b/src/features/nostr/NBunker.ts @@ -58,7 +58,14 @@ export class NBunker { private controller = new AbortController(); private authorizedPubkeys = new Set(); + /** 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(); } @@ -73,7 +80,10 @@ export class NBunker { { kinds: [24133], '#p': [bunkerPubkey], limit: 0 }, ]; - for await (const msg of relay.req(filters, { signal })) { + const sub = relay.req(filters, { signal }); + this.setReady(); + + for await (const msg of sub) { if (msg[0] === 'EVENT') { const [,, event] = msg; From 46687a0a029c140d3ad341edabf185e53317ab0b Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 16:42:40 -0500 Subject: [PATCH 19/28] Close the bunker manually due to `using` not working --- src/actions/nostr.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index e9aec6dd8..b639537a3 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -20,7 +20,7 @@ function logInNostr(signer: NostrSigner, relay: NRelay1) { let authorizedPubkey: string | undefined; - using bunker = new NBunker({ + const bunker = new NBunker({ relay, userSigner: signer, bunkerSigner: authorization.signer, @@ -61,6 +61,9 @@ function logInNostr(signer: NostrSigner, relay: NRelay1) { }); await dispatch(verifyCredentials(accessToken)); + + // TODO: get rid of `vite-plugin-require` and switch to `using` for the bunker. :( + bunker.close(); }; } From 0c4851f4cf9165c57274429cbdcc03903ac71a76 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 18:04:05 -0500 Subject: [PATCH 20/28] Remove signer from NostrContext --- src/contexts/nostr-context.tsx | 7 ++----- src/features/nostr-relays/index.tsx | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 7230daf60..b83c4b950 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -1,12 +1,10 @@ -import { NRelay1, NostrSigner } from '@nostrify/nostrify'; +import { NRelay1 } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect } from 'react'; -import { useSigner } from 'soapbox/hooks/nostr/useSigner'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { relay?: NRelay1; - signer?: NostrSigner; isRelayLoading: boolean; } @@ -18,7 +16,6 @@ interface NostrProviderProps { export const NostrProvider: React.FC = ({ children }) => { const { instance } = useInstance(); - const { signer } = useSigner(); const [relay, setRelay] = useState(); const [isRelayLoading, setIsRelayLoading] = useState(true); @@ -44,7 +41,7 @@ export const NostrProvider: React.FC = ({ children }) => { }, [relayUrl]); return ( - + {children} ); 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 From d3a341c0c65abe2bb17e5269833b23420e688a55 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 18:13:10 -0500 Subject: [PATCH 21/28] Revoke Nostr credentials on logout --- src/hooks/nostr/useBunkerStore.ts | 3 ++- src/reducers/auth.ts | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/hooks/nostr/useBunkerStore.ts b/src/hooks/nostr/useBunkerStore.ts index 20816e8da..b7a40342c 100644 --- a/src/hooks/nostr/useBunkerStore.ts +++ b/src/hooks/nostr/useBunkerStore.ts @@ -33,6 +33,7 @@ const connectionSchema = z.object({ interface BunkerState { connections: BunkerConnection[]; connect(connection: BunkerConnection): void; + revoke(accessToken: string): void; } export const useBunkerStore = create()( @@ -50,7 +51,7 @@ export const useBunkerStore = create()( }, /** Revoke any connections associated with the access token. */ - revoke(accessToken: string) { + revoke(accessToken: string): void { setState((state) => { return produce(state, (draft) => { draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken); diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index 529d89060..f8ff86274 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 { NKeys } from 'soapbox/features/nostr/keys'; +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'; @@ -105,7 +107,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(); + + /** User pubkey from token. */ + const pubkey = connections.find((conn) => conn.accessToken === accessToken)?.pubkey; + + // Revoke the Bunker connection. + revoke(accessToken); + + // Revoke the private key, if it exists. + if (pubkey) { + NKeys.delete(pubkey); + } +} + function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth { + revokeNostr(accessToken); + return produce(auth, draft => { delete draft.tokens[accessToken]; From fcee59a68f5ff4b98b20cf2fcc51323763469a36 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 18:39:00 -0500 Subject: [PATCH 22/28] Log out users with legacy auth --- src/reducers/auth.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index f8ff86274..a0f66eeba 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -26,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); From f066228b22c6bd581c7c37309b8b6e833917d7b8 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Tue, 29 Oct 2024 19:23:09 -0500 Subject: [PATCH 23/28] Remove unused NostrRPC class --- src/features/nostr/NostrRPC.ts | 66 ---------------------------------- 1 file changed, 66 deletions(-) delete mode 100644 src/features/nostr/NostrRPC.ts diff --git a/src/features/nostr/NostrRPC.ts b/src/features/nostr/NostrRPC.ts deleted file mode 100644 index 8248fbba4..000000000 --- a/src/features/nostr/NostrRPC.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - NRelay, - NostrConnectRequest, - NostrConnectResponse, - NostrEvent, - NostrFilter, - NostrSigner, - NSchema as n, -} from '@nostrify/nostrify'; - -export class NostrRPC { - - constructor( - private relay: NRelay, - private signer: NostrSigner, - ) {} - - async *req( - filters: NostrFilter[], - opts: { signal?: AbortSignal }, - ): AsyncIterable<{ - requestEvent: NostrEvent; - request: NostrConnectRequest; - respond: (response: Omit) => Promise; - }> { - for await (const msg of this.relay.req(filters, opts)) { - if (msg[0] === 'EVENT') { - const [,, requestEvent] = msg; - - const decrypted = await this.decrypt(this.signer, requestEvent.pubkey, requestEvent.content); - const request = n.json().pipe(n.connectRequest()).parse(decrypted); - - const respond = async (response: Omit): Promise => { - await this.respond(requestEvent, { ...response, id: request.id }); - }; - - yield { requestEvent, request, respond }; - } - - if (msg[0] === 'CLOSED') { - break; - } - } - } - - private async respond(requestEvent: NostrEvent, response: NostrConnectResponse): Promise { - const responseEvent = await this.signer.signEvent({ - kind: 24133, - content: await this.signer.nip04!.encrypt(requestEvent.pubkey, JSON.stringify(response)), - tags: [['p', requestEvent.pubkey]], - created_at: Math.floor(Date.now() / 1000), - }); - - await this.relay.event(responseEvent); - } - - /** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ - private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise { - try { - return await signer.nip44!.decrypt(pubkey, ciphertext); - } catch { - return await signer.nip04!.decrypt(pubkey, ciphertext); - } - } - -} \ No newline at end of file From ce5a4b9a03976b8bbab3a51298ffc7da0b749e86 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Oct 2024 11:37:14 -0500 Subject: [PATCH 24/28] NKeyStorage -> NKeyring --- src/features/nostr/{NKeyStorage.ts => NKeyring.ts} | 2 +- src/features/nostr/keys.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename src/features/nostr/{NKeyStorage.ts => NKeyring.ts} (97%) 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/keys.ts b/src/features/nostr/keys.ts index 92f9fc09f..e13d3b054 100644 --- a/src/features/nostr/keys.ts +++ b/src/features/nostr/keys.ts @@ -1,6 +1,6 @@ -import { NKeyStorage } from './NKeyStorage'; +import { NKeyring } from './NKeyring'; -export const NKeys = new NKeyStorage( +export const NKeys = new NKeyring( localStorage, 'soapbox:nostr:keys', ); From 36427f52d78e5953ed7d07f9ac07e7957b847e2a Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Oct 2024 11:42:10 -0500 Subject: [PATCH 25/28] NKeys -> keyring --- src/features/nostr/{keys.ts => keyring.ts} | 2 +- .../modals/nostr-login-modal/steps/key-add-step.tsx | 4 ++-- .../modals/nostr-signup-modal/steps/keygen-step.tsx | 4 ++-- src/hooks/nostr/useSigner.ts | 4 ++-- src/reducers/auth.ts | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) rename src/features/nostr/{keys.ts => keyring.ts} (69%) diff --git a/src/features/nostr/keys.ts b/src/features/nostr/keyring.ts similarity index 69% rename from src/features/nostr/keys.ts rename to src/features/nostr/keyring.ts index e13d3b054..6ae33502a 100644 --- a/src/features/nostr/keys.ts +++ b/src/features/nostr/keyring.ts @@ -1,6 +1,6 @@ import { NKeyring } from './NKeyring'; -export const NKeys = new NKeyring( +export const keyring = new NKeyring( localStorage, 'soapbox:nostr:keys', ); 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 486afbe77..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 @@ -6,7 +6,7 @@ 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 { useNostr } from 'soapbox/contexts/nostr-context'; -import { NKeys } from 'soapbox/features/nostr/keys'; +import { keyring } from 'soapbox/features/nostr/keyring'; import { useAppDispatch } from 'soapbox/hooks'; import NostrExtensionIndicator from '../components/nostr-extension-indicator'; @@ -33,7 +33,7 @@ const KeyAddStep: React.FC = ({ onClose }) => { const result = nip19.decode(nsec); if (result.type === 'nsec') { const seckey = result.data; - const signer = NKeys.add(seckey); + 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 ae50f6a9f..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'; @@ -45,7 +45,7 @@ const KeygenStep: React.FC = ({ onClose }) => { const handleNext = async () => { if (!relay) return; - const signer = NKeys.add(secretKey); + const signer = keyring.add(secretKey); const now = Math.floor(Date.now() / 1000); const [kind0, ...events] = await Promise.all([ diff --git a/src/hooks/nostr/useSigner.ts b/src/hooks/nostr/useSigner.ts index a91422006..fdae1c2a7 100644 --- a/src/hooks/nostr/useSigner.ts +++ b/src/hooks/nostr/useSigner.ts @@ -2,7 +2,7 @@ import { NSecSigner } from '@nostrify/nostrify'; import { useQuery } from '@tanstack/react-query'; import { useMemo } from 'react'; -import { NKeys } from 'soapbox/features/nostr/keys'; +import { keyring } from 'soapbox/features/nostr/keyring'; import { useAppSelector } from 'soapbox/hooks'; import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore'; @@ -23,7 +23,7 @@ export function useSigner() { queryFn: async () => { if (!pubkey) return null; - const signer = NKeys.get(pubkey); + const signer = keyring.get(pubkey); if (signer) return signer; if (window.nostr && await window.nostr.getPublicKey() === pubkey) { diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index a0f66eeba..9d55a1520 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -2,7 +2,7 @@ import { AxiosError } from 'axios'; import { produce } from 'immer'; import { z } from 'zod'; -import { NKeys } from 'soapbox/features/nostr/keys'; +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'; @@ -131,7 +131,7 @@ function revokeNostr(accessToken: string): void { // Revoke the private key, if it exists. if (pubkey) { - NKeys.delete(pubkey); + keyring.delete(pubkey); } } From fdb53c890697b3c3d3095710f9a18e4f8e9de176 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Oct 2024 11:59:53 -0500 Subject: [PATCH 26/28] Store the bunkerSeckey in the keyring, include only the pubkey in the plaintext connection storage --- src/actions/nostr.ts | 5 ++++- src/hooks/nostr/useBunkerStore.ts | 36 ++++++++----------------------- src/hooks/nostr/useSigner.ts | 12 ++--------- 3 files changed, 15 insertions(+), 38 deletions(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index b639537a3..b05ccb461 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -2,6 +2,7 @@ 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'; @@ -53,11 +54,13 @@ function logInNostr(signer: NostrSigner, relay: NRelay1) { const accessToken = dispatch(authLoggedIn(token)).access_token as string; const bunkerState = useBunkerStore.getState(); + keyring.add(authorization.seckey); + bunkerState.connect({ pubkey, accessToken, authorizedPubkey, - bunkerSeckey: authorization.seckey, + bunkerPubkey, }); await dispatch(verifyCredentials(accessToken)); diff --git a/src/hooks/nostr/useBunkerStore.ts b/src/hooks/nostr/useBunkerStore.ts index b7a40342c..2608df303 100644 --- a/src/hooks/nostr/useBunkerStore.ts +++ b/src/hooks/nostr/useBunkerStore.ts @@ -1,5 +1,5 @@ +import { NSchema as n } from '@nostrify/nostrify'; import { produce } from 'immer'; -import { nip19 } from 'nostr-tools'; import { z } from 'zod'; import { create } from 'zustand'; // eslint-disable-next-line import/extensions @@ -19,15 +19,15 @@ interface BunkerConnection { accessToken: string; /** 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; + /** 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.object({ - pubkey: z.string(), +const connectionSchema: z.ZodType = z.object({ + pubkey: n.id(), accessToken: z.string(), - authorizedPubkey: z.string(), - bunkerSeckey: z.custom((value) => value instanceof Uint8Array), + authorizedPubkey: n.id(), + bunkerPubkey: n.id(), }); interface BunkerState { @@ -65,7 +65,7 @@ export const useBunkerStore = create()( getItem(name) { const value = localStorage.getItem(name); - const connections = jsonSchema(nsecReviver) + const connections = jsonSchema() .pipe(filteredArray(connectionSchema)) .catch([]) .parse(value); @@ -73,7 +73,7 @@ export const useBunkerStore = create()( return { state: { connections } }; }, setItem(name, { state }) { - localStorage.setItem(name, JSON.stringify(state.connections, nsecReplacer)); + localStorage.setItem(name, JSON.stringify(state.connections)); }, removeItem(name) { localStorage.removeItem(name); @@ -82,21 +82,3 @@ export const useBunkerStore = create()( }, ), ); - -/** Encode Uint8Arrays into nsec strings. */ -function nsecReplacer(_key: string, value: unknown): unknown { - if (value instanceof Uint8Array) { - return nip19.nsecEncode(value); - } - - return value; -} - -/** Decode nsec strings into Uint8Arrays. */ -function nsecReviver(_key: string, value: unknown): unknown { - if (typeof value === 'string' && value.startsWith('nsec1')) { - return nip19.decode(value as `nsec1${string}`).data; - } - - return value; -} \ No newline at end of file diff --git a/src/hooks/nostr/useSigner.ts b/src/hooks/nostr/useSigner.ts index fdae1c2a7..13eed0f72 100644 --- a/src/hooks/nostr/useSigner.ts +++ b/src/hooks/nostr/useSigner.ts @@ -1,6 +1,4 @@ -import { NSecSigner } from '@nostrify/nostrify'; import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; import { keyring } from 'soapbox/features/nostr/keyring'; import { useAppSelector } from 'soapbox/hooks'; @@ -16,7 +14,7 @@ export function useSigner() { } }); - const { pubkey, bunkerSeckey, authorizedPubkey } = connection ?? {}; + const { pubkey, bunkerPubkey, authorizedPubkey } = connection ?? {}; const { data: signer, ...rest } = useQuery({ queryKey: ['nostr', 'signer', pubkey ?? ''], @@ -35,15 +33,9 @@ export function useSigner() { enabled: !!pubkey, }); - const bunkerSigner = useMemo(() => { - if (bunkerSeckey) { - return new NSecSigner(bunkerSeckey); - } - }, [bunkerSeckey]); - return { signer: signer ?? undefined, - bunkerSigner, + bunkerSigner: bunkerPubkey ? keyring.get(bunkerPubkey) : undefined, authorizedPubkey, ...rest, }; From 335f8eae6c95cffa907aaf7cc5a67e127d193c5c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Oct 2024 12:09:06 -0500 Subject: [PATCH 27/28] Fix import of keyring in main.tsx --- src/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.tsx b/src/main.tsx index 16693ae46..7382d69b2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -15,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'; From bee767308517d5ad6e69038fca5ec3c381def40c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 30 Oct 2024 12:20:52 -0500 Subject: [PATCH 28/28] auth: delete the bunker private key when an access token is revoked --- src/reducers/auth.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/reducers/auth.ts b/src/reducers/auth.ts index 9d55a1520..bb34609df 100644 --- a/src/reducers/auth.ts +++ b/src/reducers/auth.ts @@ -123,15 +123,15 @@ function importCredentials(auth: SoapboxAuth, accessToken: string, account: Acco function revokeNostr(accessToken: string): void { const { connections, revoke } = useBunkerStore.getState(); - /** User pubkey from token. */ - const pubkey = connections.find((conn) => conn.accessToken === accessToken)?.pubkey; - - // Revoke the Bunker connection. - revoke(accessToken); - - // Revoke the private key, if it exists. - if (pubkey) { - keyring.delete(pubkey); + 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); + } } }