From 41f676fdfb4af10ae501dba8d6bc23ec06d0f208 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 14:26:26 -0500 Subject: [PATCH 1/3] Add initial version of NConnect class --- src/api/hooks/nostr/useSignerStream.ts | 140 ++++------------------- src/contexts/nostr-context.tsx | 4 +- src/features/nostr/NConnect.ts | 150 +++++++++++++++++++++++++ src/schemas/nostr.ts | 11 +- 4 files changed, 171 insertions(+), 134 deletions(-) create mode 100644 src/features/nostr/NConnect.ts diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 918d40ba3..7685d1732 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,137 +1,35 @@ -import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify'; -import { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; -import { nwcRequestSchema } from 'soapbox/schemas/nostr'; +import { NConnect } from 'soapbox/features/nostr/NConnect'; function useSignerStream() { - const { relay, pubkey, signer } = useNostr(); + const { relay, signer } = useNostr(); + const [pubkey, setPubkey] = useState(undefined); - async function sendConnect(response: NostrConnectResponse) { - if (!relay || !pubkey || !signer) return; - - const event = await signer.signEvent({ - kind: 24133, - content: await signer.nip04!.encrypt(pubkey, JSON.stringify(response)), - tags: [['p', pubkey]], - created_at: Math.floor(Date.now() / 1000), - }); - - relay.event(event); - } - - async function handleConnectEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(n.connectRequest()).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - const request = reqMsg.data; - - switch (request.method) { - case 'connect': - return sendConnect({ - id: request.id, - result: 'ack', - }); - case 'sign_event': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.signEvent(JSON.parse(request.params[0]))), - }); - case 'ping': - return sendConnect({ - id: request.id, - result: 'pong', - }); - case 'get_relays': - return sendConnect({ - id: request.id, - result: JSON.stringify(await signer.getRelays?.() ?? []), - }); - case 'get_public_key': - return sendConnect({ - id: request.id, - result: await signer.getPublicKey(), - }); - case 'nip04_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.encrypt(request.params[0], request.params[1]), - }); - case 'nip04_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip04!.decrypt(request.params[0], request.params[1]), - }); - case 'nip44_encrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.encrypt(request.params[0], request.params[1]), - }); - case 'nip44_decrypt': - return sendConnect({ - id: request.id, - result: await signer.nip44!.decrypt(request.params[0], request.params[1]), - }); - default: - return sendConnect({ - id: request.id, - result: '', - error: `Unrecognized method: ${request.method}`, - }); - } - } - - async function handleWalletEvent(event: NostrEvent) { - if (!relay || !pubkey || !signer) return; - - const decrypted = await signer.nip04!.decrypt(pubkey, event.content); - - const reqMsg = n.json().pipe(nwcRequestSchema).safeParse(decrypted); - if (!reqMsg.success) { - console.warn(decrypted); - console.warn(reqMsg.error); - return; - } - - await window.webln?.enable(); - await window.webln?.sendPayment(reqMsg.data.params.invoice); - } - - async function handleEvent(event: NostrEvent) { - switch (event.kind) { - case 24133: - await handleConnectEvent(event); - break; - case 23194: - await handleWalletEvent(event); - break; - } - } + const storageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { - if (!relay || !pubkey) return; + if (signer) { + signer.getPublicKey().then(setPubkey).catch(console.warn); + } + }, [signer]); - const controller = new AbortController(); - const signal = controller.signal; + useEffect(() => { + if (!relay || !signer || !pubkey) return; - (async() => { - for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }], { signal })) { - if (msg[0] === 'EVENT') handleEvent(msg[2]); - } - })(); + const connect = new NConnect({ + relay, + signer, + onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), + authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + }); return () => { - controller.abort(); + connect.close(); }; - }, [relay, pubkey, signer]); + }, [relay, signer, pubkey]); } export { useSignerStream }; diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index fc8129b2b..1338d6ac5 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -7,7 +7,6 @@ import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { relay?: NRelay; - pubkey?: string; signer?: NostrSigner; } @@ -24,7 +23,6 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const pubkey = instance.nostr?.pubkey; const accountPubkey = account?.nostr.pubkey; const signer = useMemo( @@ -42,7 +40,7 @@ export const NostrProvider: React.FC = ({ children }) => { }, [url]); return ( - + {children} ); diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts new file mode 100644 index 000000000..8679e62bc --- /dev/null +++ b/src/features/nostr/NConnect.ts @@ -0,0 +1,150 @@ +import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify'; + +interface NConnectOpts { + relay: NRelay; + signer: NostrSigner; + authorizedPubkey: string | undefined; + onAuthorize(pubkey: string): void; +} + +export class NConnect { + + private relay: NRelay; + private signer: NostrSigner; + private authorizedPubkey: string | undefined; + private onAuthorize: (pubkey: string) => void; + + public secret = crypto.randomUUID(); + private controller = new AbortController(); + + constructor(opts: NConnectOpts) { + this.relay = opts.relay; + this.signer = opts.signer; + this.authorizedPubkey = opts.authorizedPubkey; + this.onAuthorize = opts.onAuthorize; + + this.open(); + } + + async open() { + const pubkey = await this.signer.getPublicKey(); + const signal = this.controller.signal; + + for await (const msg of this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal })) { + if (msg[0] === 'EVENT') { + const event = msg[2]; + this.handleEvent(event); + } + } + } + + private async handleEvent(event: NostrEvent): Promise { + const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content); + const request = n.json().pipe(n.connectRequest()).safeParse(decrypted); + + if (!request.success) { + console.warn(decrypted); + console.warn(request.error); + return; + } + + await this.handleRequest(event.pubkey, request.data); + } + + private async handleRequest(pubkey: string, request: NostrConnectRequest): Promise { + // Connect is a special case. Any pubkey can try to request it. + if (request.method === 'connect') { + return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' }); + } + + // Prevent unauthorized access. + if (pubkey !== this.authorizedPubkey) { + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: 'Unauthorized', + }); + } + + // Authorized methods. + switch (request.method) { + case 'sign_event': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))), + }); + case 'ping': + return this.sendResponse(pubkey, { + id: request.id, + result: 'pong', + }); + case 'get_relays': + return this.sendResponse(pubkey, { + id: request.id, + result: JSON.stringify(await this.signer.getRelays?.() ?? []), + }); + case 'get_public_key': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.getPublicKey(), + }); + case 'nip04_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]), + }); + case 'nip04_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]), + }); + case 'nip44_encrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]), + }); + case 'nip44_decrypt': + return this.sendResponse(pubkey, { + id: request.id, + result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]), + }); + default: + return this.sendResponse(pubkey, { + id: request.id, + result: '', + error: `Unrecognized method: ${request.method}`, + }); + } + } + + private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { + const [remotePubkey, secret] = request.params; + + if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { + this.secret = crypto.randomUUID(); + this.authorizedPubkey = pubkey; + this.onAuthorize(pubkey); + + await this.sendResponse(pubkey, { + id: request.id, + result: 'ack', + }); + } + } + + private async sendResponse(pubkey: string, response: NostrConnectResponse) { + const event = await this.signer.signEvent({ + kind: 24133, + content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + await this.relay.event(event); + } + + close() { + this.controller.abort(); + } + +} \ No newline at end of file diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 59cdd9d68..c94152d22 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -1,16 +1,7 @@ import { NSchema as n } from '@nostrify/nostrify'; import { verifyEvent } from 'nostr-tools'; -import { z } from 'zod'; /** Nostr event schema that also verifies the event's signature. */ const signedEventSchema = n.event().refine(verifyEvent); -/** NIP-47 signer response. */ -const nwcRequestSchema = z.object({ - method: z.literal('pay_invoice'), - params: z.object({ - invoice: z.string(), - }), -}); - -export { signedEventSchema, nwcRequestSchema }; \ No newline at end of file +export { signedEventSchema }; \ No newline at end of file From fd13ff70e376ead6da54f6a4e029521d56d10e80 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 15:46:25 -0500 Subject: [PATCH 2/3] Nostr OAuth actions --- src/actions/nostr.ts | 38 +++++++++++++++++++++----- src/actions/oauth.ts | 2 +- src/api/hooks/nostr/useSignerStream.ts | 14 ++++++++-- src/contexts/nostr-context.tsx | 4 +-- src/features/nostr/NConnect.ts | 7 +++-- src/reducers/meta.ts | 5 ++++ 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 6908f06b6..f40f85813 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,14 +1,31 @@ -import { nip19 } from 'nostr-tools'; +import { RootState, type AppDispatch } from 'soapbox/store'; -import { type AppDispatch } from 'soapbox/store'; +import { authLoggedIn, verifyCredentials } from './auth'; +import { obtainOAuthToken } from './oauth'; -import { verifyCredentials } from './auth'; +const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET'; /** Log in with a Nostr pubkey. */ function logInNostr(pubkey: string) { - return (dispatch: AppDispatch) => { - const npub = nip19.npubEncode(pubkey); - return dispatch(verifyCredentials(npub)); + return async (dispatch: AppDispatch, getState: () => RootState) => { + dispatch(setNostrPubkey(pubkey)); + + const secret = sessionStorage.getItem('soapbox:nip46:secret'); + if (!secret) { + throw new Error('No secret found in session storage'); + } + + const relay = getState().instance.nostr?.relay; + + const token = await dispatch(obtainOAuthToken({ + grant_type: 'nostr', + pubkey, + relays: relay ? [relay] : undefined, + secret, + })); + + const { access_token } = dispatch(authLoggedIn(token)); + return await dispatch(verifyCredentials(access_token as string)); }; } @@ -23,4 +40,11 @@ function nostrExtensionLogIn() { }; } -export { logInNostr, nostrExtensionLogIn }; \ No newline at end of file +function setNostrPubkey(pubkey: string) { + return { + type: NOSTR_PUBKEY_SET, + pubkey, + }; +} + +export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET }; \ No newline at end of file diff --git a/src/actions/oauth.ts b/src/actions/oauth.ts index 1c3c8a748..4147c9409 100644 --- a/src/actions/oauth.ts +++ b/src/actions/oauth.ts @@ -20,7 +20,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST'; export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS'; export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL'; -export const obtainOAuthToken = (params: Record, baseURL?: string) => +export const obtainOAuthToken = (params: Record, baseURL?: string) => (dispatch: AppDispatch) => { dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params }); return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => { diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index 7685d1732..00517bd0e 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -3,11 +3,15 @@ import { useEffect, useState } from 'react'; import { useNostr } from 'soapbox/contexts/nostr-context'; import { NConnect } from 'soapbox/features/nostr/NConnect'; +const secretStorageKey = 'soapbox:nip46:secret'; + +sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + function useSignerStream() { const { relay, signer } = useNostr(); const [pubkey, setPubkey] = useState(undefined); - const storageKey = `soapbox:nostr:auth:${pubkey}`; + const authStorageKey = `soapbox:nostr:auth:${pubkey}`; useEffect(() => { if (signer) { @@ -21,8 +25,12 @@ function useSignerStream() { const connect = new NConnect({ relay, signer, - onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey), - authorizedPubkey: localStorage.getItem(storageKey) ?? undefined, + onAuthorize(authorizedPubkey) { + localStorage.setItem(authStorageKey, authorizedPubkey); + sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); + }, + authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined, + getSecret: () => sessionStorage.getItem(secretStorageKey)!, }); return () => { diff --git a/src/contexts/nostr-context.tsx b/src/contexts/nostr-context.tsx index 1338d6ac5..f138a1754 100644 --- a/src/contexts/nostr-context.tsx +++ b/src/contexts/nostr-context.tsx @@ -2,7 +2,7 @@ import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify'; import React, { createContext, useContext, useState, useEffect, useMemo } from 'react'; import { NKeys } from 'soapbox/features/nostr/keys'; -import { useOwnAccount } from 'soapbox/hooks'; +import { useAppSelector, useOwnAccount } from 'soapbox/hooks'; import { useInstance } from 'soapbox/hooks/useInstance'; interface NostrContextType { @@ -23,7 +23,7 @@ export const NostrProvider: React.FC = ({ children }) => { const { account } = useOwnAccount(); const url = instance.nostr?.relay; - const accountPubkey = account?.nostr.pubkey; + const accountPubkey = useAppSelector((state) => account?.nostr.pubkey ?? state.meta.pubkey); const signer = useMemo( () => (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr, diff --git a/src/features/nostr/NConnect.ts b/src/features/nostr/NConnect.ts index 8679e62bc..bfbea45da 100644 --- a/src/features/nostr/NConnect.ts +++ b/src/features/nostr/NConnect.ts @@ -5,6 +5,7 @@ interface NConnectOpts { signer: NostrSigner; authorizedPubkey: string | undefined; onAuthorize(pubkey: string): void; + getSecret(): string; } export class NConnect { @@ -13,8 +14,8 @@ export class NConnect { private signer: NostrSigner; private authorizedPubkey: string | undefined; private onAuthorize: (pubkey: string) => void; + private getSecret: () => string; - public secret = crypto.randomUUID(); private controller = new AbortController(); constructor(opts: NConnectOpts) { @@ -22,6 +23,7 @@ export class NConnect { this.signer = opts.signer; this.authorizedPubkey = opts.authorizedPubkey; this.onAuthorize = opts.onAuthorize; + this.getSecret = opts.getSecret; this.open(); } @@ -120,8 +122,7 @@ export class NConnect { private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { const [remotePubkey, secret] = request.params; - if (secret === this.secret && remotePubkey === await this.signer.getPublicKey()) { - this.secret = crypto.randomUUID(); + if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { this.authorizedPubkey = pubkey; this.onAuthorize(pubkey); diff --git a/src/reducers/meta.ts b/src/reducers/meta.ts index 923a89c1b..a89f5f323 100644 --- a/src/reducers/meta.ts +++ b/src/reducers/meta.ts @@ -1,6 +1,7 @@ import { Record as ImmutableRecord } from 'immutable'; import { fetchInstance } from 'soapbox/actions/instance'; +import { NOSTR_PUBKEY_SET } from 'soapbox/actions/nostr'; import { SW_UPDATING } from 'soapbox/actions/sw'; import type { AnyAction } from 'redux'; @@ -10,6 +11,8 @@ const ReducerRecord = ImmutableRecord({ instance_fetch_failed: false, /** Whether the ServiceWorker is currently updating (and we should display a loading screen). */ swUpdating: false, + /** User's nostr pubkey. */ + pubkey: undefined as string | undefined, }); export default function meta(state = ReducerRecord(), action: AnyAction) { @@ -21,6 +24,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) { return state; case SW_UPDATING: return state.set('swUpdating', action.isUpdating); + case NOSTR_PUBKEY_SET: + return state.set('pubkey', action.pubkey); default: return state; } From 1430149292e08c1080c0b97db6426501e09a3c99 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 29 May 2024 16:15:04 -0500 Subject: [PATCH 3/3] grant_type nostr -> nostr_bunker --- src/actions/nostr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index f40f85813..3ef64339d 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -18,7 +18,7 @@ function logInNostr(pubkey: string) { const relay = getState().instance.nostr?.relay; const token = await dispatch(obtainOAuthToken({ - grant_type: 'nostr', + grant_type: 'nostr_bunker', pubkey, relays: relay ? [relay] : undefined, secret,