From 5c4b73f94331b2bfa3ad051f2cf38804619e8a7c Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Mon, 28 Oct 2024 20:20:37 -0500 Subject: [PATCH] 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');