diff --git a/package.json b/package.json index 8c075ccd8..f86f62c0a 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@lexical/react": "^0.13.1", "@lexical/selection": "^0.13.1", "@lexical/utils": "^0.13.1", + "@noble/hashes": "^1.3.3", "@popperjs/core": "^2.11.5", "@reach/combobox": "^0.18.0", "@reach/menu-button": "^0.18.0", @@ -133,6 +134,7 @@ "mini-css-extract-plugin": "^2.6.0", "nostr-machina": "^0.1.0", "nostr-tools": "^1.14.2", + "nspec": "^0.1.0", "path-browserify": "^1.0.1", "postcss": "^8.4.29", "process": "^0.11.10", diff --git a/src/actions/accounts.ts b/src/actions/accounts.ts index 4c7df890c..2099cb63f 100644 --- a/src/actions/accounts.ts +++ b/src/actions/accounts.ts @@ -2,7 +2,7 @@ import { nip19 } from 'nostr-tools'; import { importEntities } from 'soapbox/entity-store/actions'; import { Entities } from 'soapbox/entity-store/entities'; -import { getPublicKey } from 'soapbox/features/nostr/sign'; +import { signer } from 'soapbox/features/nostr/sign'; import { selectAccount } from 'soapbox/selectors'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; @@ -134,7 +134,7 @@ const createAccount = (params: Record) => async (dispatch: AppDispatch, getState: () => RootState) => { const { instance } = getState(); const { nostrSignup } = getFeatures(instance); - const pubkey = nostrSignup ? await getPublicKey() : undefined; + const pubkey = (signer && nostrSignup) ? await signer.getPublicKey() : undefined; dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); return api(getState, 'app').post('/api/v1/accounts', params, { diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts index 4a155c435..21044140b 100644 --- a/src/actions/nostr.ts +++ b/src/actions/nostr.ts @@ -1,6 +1,6 @@ import { nip19 } from 'nostr-tools'; -import { getPublicKey } from 'soapbox/features/nostr/sign'; +import { signer } from 'soapbox/features/nostr/sign'; import { type AppDispatch } from 'soapbox/store'; import { verifyCredentials } from './auth'; @@ -8,7 +8,11 @@ import { verifyCredentials } from './auth'; /** Log in with a Nostr pubkey. */ function nostrLogIn() { return async (dispatch: AppDispatch) => { - const pubkey = await getPublicKey(); + if (!signer) { + throw new Error('No Nostr signer available'); + } + + const pubkey = await signer.getPublicKey(); const npub = nip19.npubEncode(pubkey); return dispatch(verifyCredentials(npub)); diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index c2a0b13a1..cc5d6403d 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,8 +1,8 @@ import { NiceRelay } from 'nostr-machina'; -import { type Event } from 'nostr-tools'; +import { type NostrEvent } from 'nspec'; import { useEffect, useMemo } from 'react'; -import { nip04, signEvent } from 'soapbox/features/nostr/sign'; +import { signer } from 'soapbox/features/nostr/sign'; import { useInstance } from 'soapbox/hooks'; import { connectRequestSchema, nwcRequestSchema } from 'soapbox/schemas/nostr'; import { jsonSchema } from 'soapbox/schemas/utils'; @@ -14,14 +14,14 @@ function useSignerStream() { const pubkey = instance.nostr?.pubkey; const relay = useMemo(() => { - if (relayUrl) { + if (relayUrl && signer) { return new NiceRelay(relayUrl); } - }, [relayUrl]); + }, [relayUrl, !!signer]); - async function handleConnectEvent(event: Event) { - if (!relay || !pubkey) return; - const decrypted = await nip04.decrypt(pubkey, event.content); + async function handleConnectEvent(event: NostrEvent) { + if (!relay || !pubkey || !signer) return; + const decrypted = await signer.nip04!.decrypt(pubkey, event.content); const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted); if (!reqMsg.success) { @@ -32,12 +32,12 @@ function useSignerStream() { const respMsg = { id: reqMsg.data.id, - result: await signEvent(reqMsg.data.params[0], reqMsg.data.params[1]), + result: await signer.signEvent(reqMsg.data.params[0]), }; - const respEvent = await signEvent({ + const respEvent = await signer.signEvent({ kind: 24133, - content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)), + content: await signer.nip04!.encrypt(pubkey, JSON.stringify(respMsg)), tags: [['p', pubkey]], created_at: Math.floor(Date.now() / 1000), }); @@ -45,10 +45,10 @@ function useSignerStream() { relay.send(['EVENT', respEvent]); } - async function handleWalletEvent(event: Event) { - if (!relay || !pubkey) return; + async function handleWalletEvent(event: NostrEvent) { + if (!relay || !pubkey || !signer) return; - const decrypted = await nip04.decrypt(pubkey, event.content); + const decrypted = await signer.nip04!.decrypt(pubkey, event.content); const reqMsg = jsonSchema.pipe(nwcRequestSchema).safeParse(decrypted); if (!reqMsg.success) { diff --git a/src/features/nostr/SoapboxSigner.ts b/src/features/nostr/SoapboxSigner.ts new file mode 100644 index 000000000..98e8db51e --- /dev/null +++ b/src/features/nostr/SoapboxSigner.ts @@ -0,0 +1,44 @@ +import { hexToBytes } from '@noble/hashes/utils'; +import { type NostrSigner, type NostrEvent, NSecSigner } from 'nspec'; + +/** Use key from `localStorage` if available, falling back to NIP-07. */ +export class SoapboxSigner implements NostrSigner { + + #signer: NostrSigner; + + constructor() { + const privateKey = localStorage.getItem('soapbox:nostr:privateKey'); + const signer = privateKey ? new NSecSigner(hexToBytes(privateKey)) : window.nostr; + + if (!signer) { + throw new Error('No Nostr signer available'); + } + + this.#signer = signer; + } + + async getPublicKey(): Promise { + return this.#signer.getPublicKey(); + } + + async signEvent(event: Omit): Promise { + return this.#signer.signEvent(event); + } + + nip04 = { + encrypt: (pubkey: string, plaintext: string): Promise => { + if (!this.#signer.nip04) { + throw new Error('NIP-04 not supported by signer'); + } + return this.#signer.nip04.encrypt(pubkey, plaintext); + }, + + decrypt: (pubkey: string, ciphertext: string): Promise => { + if (!this.#signer.nip04) { + throw new Error('NIP-04 not supported by signer'); + } + return this.#signer.nip04.decrypt(pubkey, ciphertext); + }, + }; + +} \ No newline at end of file diff --git a/src/features/nostr/sign.ts b/src/features/nostr/sign.ts index 8158d734d..d33cd926a 100644 --- a/src/features/nostr/sign.ts +++ b/src/features/nostr/sign.ts @@ -1,63 +1,13 @@ -import { - type Event, - type EventTemplate, - generatePrivateKey, - getPublicKey as _getPublicKey, - finishEvent, - nip04 as _nip04, -} from 'nostr-tools'; +import { type NostrSigner } from 'nspec'; -import { powWorker } from 'soapbox/workers'; +import { SoapboxSigner } from './SoapboxSigner'; -/** localStorage key for the Nostr private key (if not using NIP-07). */ -const LOCAL_KEY = 'soapbox:nostr:privateKey'; +let signer: NostrSigner | undefined; -/** Get the private key from the browser, or generate one. */ -const getPrivateKey = (): string => { - const local = localStorage.getItem(LOCAL_KEY); - - if (!local) { - const key = generatePrivateKey(); - localStorage.setItem(LOCAL_KEY, key); - return key; - } - - return local; -}; - -/** Get the user's public key from NIP-07, or generate one. */ -async function getPublicKey(): Promise { - return window.nostr ? window.nostr.getPublicKey() : _getPublicKey(getPrivateKey()); +try { + signer = new SoapboxSigner(); +} catch (_) { + // No signer available } -interface SignEventOpts { - pow?: number; -} - -/** Sign an event with NIP-07, or the locally generated key. */ -async function signEvent(template: EventTemplate, opts: SignEventOpts = {}): Promise> { - if (opts.pow) { - const event = await powWorker.mine({ ...template, pubkey: await getPublicKey() }, opts.pow) as Omit, 'sig'>; - return window.nostr ? window.nostr.signEvent(event) as Promise> : finishEvent(event, getPrivateKey()) ; - } else { - return window.nostr ? window.nostr.signEvent(template) as Promise> : finishEvent(template, getPrivateKey()) ; - } -} - -/** Crypto function with NIP-07, or the local key. */ -const nip04 = { - /** Encrypt with NIP-07, or the local key. */ - encrypt: async (pubkey: string, content: string) => { - return window.nostr?.nip04 - ? window.nostr.nip04.encrypt(pubkey, content) - : _nip04.encrypt(getPrivateKey(), pubkey, content); - }, - /** Decrypt with NIP-07, or the local key. */ - decrypt: async (pubkey: string, content: string) => { - return window.nostr?.nip04 - ? window.nostr.nip04.decrypt(pubkey, content) - : _nip04.decrypt(getPrivateKey(), pubkey, content); - }, -}; - -export { getPublicKey, signEvent, nip04 }; \ No newline at end of file +export { signer }; \ No newline at end of file diff --git a/src/types/nostr.ts b/src/types/nostr.ts deleted file mode 100644 index 1e1547fa9..000000000 --- a/src/types/nostr.ts +++ /dev/null @@ -1,12 +0,0 @@ -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/src/types/window.d.ts b/src/types/window.d.ts index b63397ce1..71fc2587e 100644 --- a/src/types/window.d.ts +++ b/src/types/window.d.ts @@ -1,7 +1,7 @@ -import type Nostr from './nostr'; +import type { NostrSigner } from 'nspec'; declare global { interface Window { - nostr?: Nostr; + nostr?: NostrSigner; } } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 338e14205..6a49d2e0a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1832,7 +1832,7 @@ resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-5.3.16.tgz#c3b6585c256461fe5e2eac85182b11b36ea2678b" integrity sha512-b0kKg2weqKDLI+Ai5+tocgUEIidccdSfzUndbS2YnwIp5aVvd3M0D+DCcbrsSOSgMyrV9QKMqogtqMIjKwvDxw== -"@noble/ciphers@^0.2.0": +"@noble/ciphers@0.2.0", "@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== @@ -1844,16 +1844,35 @@ dependencies: "@noble/hashes" "1.3.1" +"@noble/curves@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" + integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== + dependencies: + "@noble/hashes" "1.3.2" + +"@noble/curves@~1.3.0": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.3.0.tgz#01be46da4fd195822dab821e72f71bf4aeec635e" + integrity sha512-t01iSXPuN+Eqzb4eBX0S5oubSqXbK/xXa1Ne18Hj8f9pStxztHCE2gfboSp/dZRLSqfuLpRK2nDXDK+W9puocA== + dependencies: + "@noble/hashes" "1.3.3" + "@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.1": +"@noble/hashes@1.3.2", "@noble/hashes@~1.3.0", "@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== +"@noble/hashes@1.3.3", "@noble/hashes@^1.3.3", "@noble/hashes@~1.3.2": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" + integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -2108,6 +2127,11 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== +"@scure/base@^1.1.5", "@scure/base@~1.1.4": + version "1.1.5" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.5.tgz#1d85d17269fe97694b9c592552dd9e5e33552157" + integrity sha512-Brj9FiG2W1MRQSTB212YVPRrcbjkv48FoZi/u4l/zds/ieRrqsh7aUf6CLwkAq61oKXr/ZlTzlY66gLIj3TFTQ== + "@scure/bip32@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.1.tgz#7248aea723667f98160f593d621c47e208ccbb10" @@ -2117,6 +2141,15 @@ "@noble/hashes" "~1.3.1" "@scure/base" "~1.1.0" +"@scure/bip32@^1.3.3": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.3.tgz#a9624991dc8767087c57999a5d79488f48eae6c8" + integrity sha512-LJaN3HwRbfQK0X1xFSi0Q9amqOgzQnnDngIt+ZlsBC3Bm7/nE7K0kwshZHyaru79yIVRv/e1mQAjZyuZG6jOFQ== + dependencies: + "@noble/curves" "~1.3.0" + "@noble/hashes" "~1.3.2" + "@scure/base" "~1.1.4" + "@scure/bip39@1.2.1": version "1.2.1" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" @@ -2125,6 +2158,14 @@ "@noble/hashes" "~1.3.0" "@scure/base" "~1.1.0" +"@scure/bip39@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.2.tgz#f3426813f4ced11a47489cbcf7294aa963966527" + integrity sha512-HYf9TUXG80beW+hGAt3TRM8wU6pQoYur9iNypTROm42dorCGmLnFe3eWjz3gOq6G62H2WRh0FCzAR1PI+29zIA== + dependencies: + "@noble/hashes" "~1.3.2" + "@scure/base" "~1.1.4" + "@sentry-internal/tracing@7.74.1": version "7.74.1" resolved "https://registry.yarnpkg.com/@sentry-internal/tracing/-/tracing-7.74.1.tgz#55ff387e61d2c9533a9a0d099d376332426c8e08" @@ -6274,6 +6315,11 @@ lowercase-keys@^2.0.0: resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru-cache@^10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.2.0.tgz#0bd445ca57363465900f4d1f9bd8db343a4d95c3" + integrity sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q== + lru-cache@^4.1.2: version "4.1.5" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" @@ -6570,6 +6616,25 @@ nostr-tools@^1.14.0, nostr-tools@^1.14.2: "@scure/bip32" "1.3.1" "@scure/bip39" "1.2.1" +nostr-tools@^2.1.4: + version "2.1.5" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-2.1.5.tgz#d38ac1139343cf13654841b8727bab8dd70563eb" + integrity sha512-Gug/j54YGQ0ewB09dZW3mS9qfXWFlcOQMlyb1MmqQsuNO/95mfNOQSBi+jZ61O++Y+jG99SzAUPFLopUsKf0MA== + dependencies: + "@noble/ciphers" "0.2.0" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.1" + "@scure/base" "1.1.1" + "@scure/bip32" "1.3.1" + "@scure/bip39" "1.2.1" + optionalDependencies: + nostr-wasm v0.1.0 + +nostr-wasm@v0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nostr-wasm/-/nostr-wasm-0.1.0.tgz#17af486745feb2b7dd29503fdd81613a24058d94" + integrity sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA== + npm-run-path@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" @@ -6584,6 +6649,18 @@ npm-run-path@^5.1.0: dependencies: path-key "^4.0.0" +nspec@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nspec/-/nspec-0.1.0.tgz#abde817cf34cb042d7315a70cf515037e489401b" + integrity sha512-HPVyFFVR2x49K7HJzEjlvvBR7x5t79G6bh7/SQvfm25hXVFq9xvYBQ6i3nluwJkizcBxm+fvErM5yqJEnM/1tA== + dependencies: + "@scure/base" "^1.1.5" + "@scure/bip32" "^1.3.3" + "@scure/bip39" "^1.2.2" + lru-cache "^10.2.0" + nostr-tools "^2.1.4" + zod "^3.22.4" + nth-check@^2.0.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" @@ -9625,3 +9702,8 @@ zod@^3.21.0, zod@^3.21.4: version "3.22.3" resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== + +zod@^3.22.4: + version "3.22.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.4.tgz#f31c3a9386f61b1f228af56faa9255e845cf3fff" + integrity sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==