From 1011be5333453cd2fef50fb6410b3866b8fb8a09 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Sat, 26 Aug 2023 23:24:18 -0500 Subject: [PATCH] Nostr: sign events with NIP-46 --- app/soapbox/actions/streaming.ts | 5 -- app/soapbox/api/hooks/index.ts | 1 - .../api/hooks/nostr/useSignerStream.ts | 57 +++++++++++++ .../api/hooks/streaming/useNostrStream.ts | 20 ----- app/soapbox/api/index.ts | 1 + app/soapbox/features/ui/index.tsx | 5 +- .../normalizers/__tests__/instance.test.ts | 4 + app/soapbox/normalizers/instance.ts | 4 + app/soapbox/schemas/nostr.ts | 34 ++++++++ app/soapbox/schemas/utils.ts | 11 ++- app/soapbox/types/nostr.ts | 4 + package.json | 2 +- yarn.lock | 80 +++++++++---------- 13 files changed, 158 insertions(+), 70 deletions(-) create mode 100644 app/soapbox/api/hooks/nostr/useSignerStream.ts delete mode 100644 app/soapbox/api/hooks/streaming/useNostrStream.ts create mode 100644 app/soapbox/schemas/nostr.ts diff --git a/app/soapbox/actions/streaming.ts b/app/soapbox/actions/streaming.ts index 6a4219af6f..fa117d6901 100644 --- a/app/soapbox/actions/streaming.ts +++ b/app/soapbox/actions/streaming.ts @@ -173,11 +173,6 @@ const connectTimelineStream = ( case 'marker': dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) }); break; - case 'nostr.sign': - window.nostr?.signEvent(JSON.parse(data.payload)) - .then((data) => websocket.send(JSON.stringify({ type: 'nostr.sign', data }))) - .catch(() => console.warn('Failed to sign Nostr event.')); - break; } }, }; diff --git a/app/soapbox/api/hooks/index.ts b/app/soapbox/api/hooks/index.ts index ee5733c9fe..ce9c9fad15 100644 --- a/app/soapbox/api/hooks/index.ts +++ b/app/soapbox/api/hooks/index.ts @@ -53,4 +53,3 @@ export { useHashtagStream } from './streaming/useHashtagStream'; export { useListStream } from './streaming/useListStream'; export { useGroupStream } from './streaming/useGroupStream'; export { useRemoteStream } from './streaming/useRemoteStream'; -export { useNostrStream } from './streaming/useNostrStream'; \ No newline at end of file diff --git a/app/soapbox/api/hooks/nostr/useSignerStream.ts b/app/soapbox/api/hooks/nostr/useSignerStream.ts new file mode 100644 index 0000000000..3fe6a3ac73 --- /dev/null +++ b/app/soapbox/api/hooks/nostr/useSignerStream.ts @@ -0,0 +1,57 @@ +import { relayInit, type Relay } from 'nostr-tools'; +import { useEffect } from 'react'; + +import { useInstance } from 'soapbox/hooks'; +import { connectRequestSchema } from 'soapbox/schemas/nostr'; +import { jsonSchema } from 'soapbox/schemas/utils'; + +function useSignerStream() { + const { nostr } = useInstance(); + + const relayUrl = nostr.get('relay') as string | undefined; + const pubkey = nostr.get('pubkey') as string | undefined; + + useEffect(() => { + let relay: Relay | undefined; + + if (relayUrl && pubkey && window.nostr?.nip04) { + relay = relayInit(relayUrl); + relay.connect(); + + relay + .sub([{ kinds: [24133], authors: [pubkey], limit: 0 }]) + .on('event', async (event) => { + if (!relay || !window.nostr?.nip04) return; + + const decrypted = await window.nostr.nip04.decrypt(pubkey, event.content); + const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted); + + if (!reqMsg.success) { + console.warn(decrypted); + console.warn(reqMsg.error); + return; + } + + const signed = await window.nostr.signEvent(reqMsg.data.params[0]); + const respMsg = { + id: reqMsg.data.id, + result: signed, + }; + + const respEvent = await window.nostr.signEvent({ + kind: 24133, + content: await window.nostr.nip04.encrypt(pubkey, JSON.stringify(respMsg)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + relay.publish(respEvent); + }); + } + return () => { + relay?.close(); + }; + }, [relayUrl, pubkey]); +} + +export { useSignerStream }; \ No newline at end of file diff --git a/app/soapbox/api/hooks/streaming/useNostrStream.ts b/app/soapbox/api/hooks/streaming/useNostrStream.ts deleted file mode 100644 index 6748f95ea7..0000000000 --- a/app/soapbox/api/hooks/streaming/useNostrStream.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { useFeatures, useLoggedIn } from 'soapbox/hooks'; - -import { useTimelineStream } from './useTimelineStream'; - -function useNostrStream() { - const features = useFeatures(); - const { isLoggedIn } = useLoggedIn(); - - return useTimelineStream( - 'nostr', - 'nostr', - null, - null, - { - enabled: isLoggedIn && features.nostrSign && Boolean(window.nostr), - }, - ); -} - -export { useNostrStream }; \ No newline at end of file diff --git a/app/soapbox/api/index.ts b/app/soapbox/api/index.ts index 850f8478ef..664085653a 100644 --- a/app/soapbox/api/index.ts +++ b/app/soapbox/api/index.ts @@ -66,6 +66,7 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A baseURL: isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseURL, headers: Object.assign(accessToken ? { 'Authorization': `Bearer ${accessToken}`, + 'X-Nostr-Sign': 'true', } : {}), transformResponse: [maybeParseJSON], }); diff --git a/app/soapbox/features/ui/index.tsx b/app/soapbox/features/ui/index.tsx index 16e1861f39..cb2da25d19 100644 --- a/app/soapbox/features/ui/index.tsx +++ b/app/soapbox/features/ui/index.tsx @@ -16,7 +16,8 @@ import { register as registerPushNotifications } from 'soapbox/actions/push-noti import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses'; import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions'; import { expandHomeTimeline } from 'soapbox/actions/timelines'; -import { useNostrStream, useUserStream } from 'soapbox/api/hooks'; +import { useUserStream } from 'soapbox/api/hooks'; +import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream'; import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc'; import withHoc from 'soapbox/components/hoc/with-hoc'; import SidebarNavigation from 'soapbox/components/sidebar-navigation'; @@ -443,7 +444,7 @@ const UI: React.FC = ({ children }) => { }, []); useUserStream(); - useNostrStream(); + useSignerStream(); // The user has logged in useEffect(() => { diff --git a/app/soapbox/normalizers/__tests__/instance.test.ts b/app/soapbox/normalizers/__tests__/instance.test.ts index f2bac48670..3e950dcdcc 100644 --- a/app/soapbox/normalizers/__tests__/instance.test.ts +++ b/app/soapbox/normalizers/__tests__/instance.test.ts @@ -62,6 +62,10 @@ describe('normalizeInstance()', () => { uri: '', urls: {}, version: '0.0.0', + nostr: { + pubkey: undefined, + relay: undefined, + }, }; const result = normalizeInstance(ImmutableMap()); diff --git a/app/soapbox/normalizers/instance.ts b/app/soapbox/normalizers/instance.ts index 77233c1430..b198c7b6aa 100644 --- a/app/soapbox/normalizers/instance.ts +++ b/app/soapbox/normalizers/instance.ts @@ -69,6 +69,10 @@ export const InstanceRecord = ImmutableRecord({ status_count: 0, user_count: 0, }), + nostr: ImmutableMap({ + relay: undefined as string | undefined, + pubkey: undefined as string | undefined, + }), title: '', thumbnail: '', uri: '', diff --git a/app/soapbox/schemas/nostr.ts b/app/soapbox/schemas/nostr.ts new file mode 100644 index 0000000000..41c3290c35 --- /dev/null +++ b/app/soapbox/schemas/nostr.ts @@ -0,0 +1,34 @@ +import { verifySignature } from 'nostr-tools'; +import { z } from 'zod'; + +/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */ +const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/); +/** Nostr kinds are positive integers. */ +const kindSchema = z.number().int().positive(); + +/** Nostr event template schema. */ +const eventTemplateSchema = z.object({ + kind: kindSchema, + tags: z.array(z.array(z.string())), + content: z.string(), + created_at: z.number(), +}); + +/** Nostr event schema. */ +const eventSchema = eventTemplateSchema.extend({ + id: nostrIdSchema, + pubkey: nostrIdSchema, + sig: z.string(), +}); + +/** Nostr event schema that also verifies the event's signature. */ +const signedEventSchema = eventSchema.refine(verifySignature); + +/** NIP-46 signer request. */ +const connectRequestSchema = z.object({ + id: z.string(), + method: z.literal('sign_event'), + params: z.tuple([eventTemplateSchema]), +}); + +export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema }; \ No newline at end of file diff --git a/app/soapbox/schemas/utils.ts b/app/soapbox/schemas/utils.ts index c85b2b2b16..e8172ad9e2 100644 --- a/app/soapbox/schemas/utils.ts +++ b/app/soapbox/schemas/utils.ts @@ -30,4 +30,13 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) { }, {}); } -export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema }; \ No newline at end of file +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; + } +}); + +export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema }; \ No newline at end of file diff --git a/app/soapbox/types/nostr.ts b/app/soapbox/types/nostr.ts index b395268ef8..d17a1055f0 100644 --- a/app/soapbox/types/nostr.ts +++ b/app/soapbox/types/nostr.ts @@ -3,6 +3,10 @@ import type { Event, EventTemplate } from 'nostr-tools'; interface Nostr { getPublicKey(): Promise signEvent(event: EventTemplate): Promise + nip04?: { + encrypt: (pubkey: string, plaintext: string) => Promise + decrypt: (pubkey: string, ciphertext: string) => Promise + } } export default Nostr; \ No newline at end of file diff --git a/package.json b/package.json index 074afadc94..0d7f20cbd9 100644 --- a/package.json +++ b/package.json @@ -131,7 +131,7 @@ "localforage": "^1.10.0", "lodash": "^4.7.11", "mini-css-extract-plugin": "^2.6.0", - "nostr-tools": "^1.8.1", + "nostr-tools": "^1.14.2", "path-browserify": "^1.0.1", "postcss": "^8.4.14", "postcss-loader": "^7.0.0", diff --git a/yarn.lock b/yarn.lock index 1c425cb89f..8a5befd7df 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2184,27 +2184,32 @@ dependencies: eslint-scope "5.1.1" -"@noble/curves@~0.8.3": - version "0.8.3" - resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-0.8.3.tgz#ad6d48baf2599cf1d58dcb734c14d5225c8996e0" - integrity sha512-OqaOf4RWDaCRuBKJLDURrgVxjLmneGsiCXGuzYB5y95YithZMA6w4uk34DHSm0rKMrrYiaeZj48/81EvaAScLQ== +"@noble/ciphers@^0.2.0": + version "0.2.0" + resolved "https://registry.yarnpkg.com/@noble/ciphers/-/ciphers-0.2.0.tgz#a12cda60f3cf1ab5d7c77068c3711d2366649ed7" + integrity sha512-6YBxJDAapHSdd3bLDv6x2wRPwq4QFMUaB3HvljNBUTThDd12eSm7/3F+2lnfzx2jvM+S6Nsy0jEt9QbPqSwqRw== + +"@noble/curves@1.1.0", "@noble/curves@~1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" + integrity sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA== dependencies: - "@noble/hashes" "1.3.0" + "@noble/hashes" "1.3.1" -"@noble/hashes@1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.0.0.tgz#d5e38bfbdaba174805a4e649f13be9a9ed3351ae" - integrity sha512-DZVbtY62kc3kkBtMHqwCOfXrT/hnoORy5BJ4+HU1IR59X0KWAOqsfzQPcUl/lQLlG7qXbe/fZ3r/emxtAl+sqg== +"@noble/hashes@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" + integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== -"@noble/hashes@1.3.0", "@noble/hashes@~1.3.0": +"@noble/hashes@~1.3.0": version "1.3.0" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.0.tgz#085fd70f6d7d9d109671090ccae1d3bec62554a1" integrity sha512-ilHEACi9DwqJB0pw7kv+Apvh50jiiSyR/cQ3y4W7lOR5mhvn/50FLUfsnfJz0BDZtl/RR16kXvptiv6q1msYZg== -"@noble/secp256k1@^1.7.1": - version "1.7.1" - resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.7.1.tgz#b251c70f824ce3ca7f8dc3df08d58f005cc0507c" - integrity sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw== +"@noble/hashes@~1.3.1": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" + integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -2471,24 +2476,24 @@ resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.2.1.tgz#812edd4104a15a493dda1ccac0b352270d7a188c" integrity sha512-XiY0IsyHR+DXYS5vBxpoBe/8veTeoRpMHP+vDosLZxL5bnpetzI0igkxkLZS235ldLzyfkxF+2divEwWHP3vMQ== -"@scure/base@^1.1.1", "@scure/base@~1.1.0": +"@scure/base@1.1.1", "@scure/base@~1.1.0": version "1.1.1" resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== -"@scure/bip32@^1.1.5": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.2.0.tgz#35692d8f8cc3207200239fc119f9e038e5f465df" - integrity sha512-O+vT/hBVk+ag2i6j2CDemwd1E1MtGt+7O1KzrPNsaNvSsiEK55MyPIxJIMI2PS8Ijj464B2VbQlpRoQXxw1uHg== +"@scure/bip32@1.3.1": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10" + integrity sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A== dependencies: - "@noble/curves" "~0.8.3" - "@noble/hashes" "~1.3.0" + "@noble/curves" "~1.1.0" + "@noble/hashes" "~1.3.1" "@scure/base" "~1.1.0" -"@scure/bip39@^1.1.1": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.0.tgz#a207e2ef96de354de7d0002292ba1503538fc77b" - integrity sha512-SX/uKq52cuxm4YFXWFaVByaSHJh2w3BnokVSeUJVCv6K7WulT9u2BuNRBhuFl8vAuYnzx9bEu9WgpcNYTrYieg== +"@scure/bip39@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" + integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== dependencies: "@noble/hashes" "~1.3.0" "@scure/base" "~1.1.0" @@ -8730,17 +8735,17 @@ normalize-url@^6.0.1: resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== -nostr-tools@^1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.8.1.tgz#4e54a354cc88ea0200634da3ee5a1c3466e1794c" - integrity sha512-/2IUe5xINUYT5hYBoEz51dfRaodbRHnyF8n+ZbKWCoh0ZRX6AL88OoDNrWaWWo7tP5j5OyzSL9g/z4TP7bshEA== +nostr-tools@^1.14.2: + version "1.14.2" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.14.2.tgz#161c9401467725e87c07fcf1c9924d31b12fd45c" + integrity sha512-QEe8+tMDW0632eNDcQ+EG1edmsCXLV4WPiWLDcdT3uoE+GM15pVcy18sKwbN7SmgO4GKFEqQ49k45eANC6++SQ== dependencies: - "@noble/hashes" "1.0.0" - "@noble/secp256k1" "^1.7.1" - "@scure/base" "^1.1.1" - "@scure/bip32" "^1.1.5" - "@scure/bip39" "^1.1.1" - prettier "^2.8.4" + "@noble/ciphers" "^0.2.0" + "@noble/curves" "1.1.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" npm-run-path@^4.0.1: version "4.0.1" @@ -9512,11 +9517,6 @@ prelude-ls@~1.1.2: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" integrity sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ= -prettier@^2.8.4: - version "2.8.7" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.7.tgz#bb79fc8729308549d28fe3a98fce73d2c0656450" - integrity sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw== - pretty-error@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6"