From 84ca2a269d16858b2b47edd7e3646099c6a9335f Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Oct 2023 17:07:02 -0500 Subject: [PATCH 1/5] nostr: add Nostr sign module --- src/features/nostr/sign.ts | 52 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/features/nostr/sign.ts diff --git a/src/features/nostr/sign.ts b/src/features/nostr/sign.ts new file mode 100644 index 000000000..5a9b72082 --- /dev/null +++ b/src/features/nostr/sign.ts @@ -0,0 +1,52 @@ +import { + type Event, + type EventTemplate, + generatePrivateKey, + getPublicKey as _getPublicKey, + finishEvent, + nip04 as _nip04, +} from 'nostr-tools'; + +/** localStorage key for the Nostr private key (if not using NIP-07). */ +const LOCAL_KEY = 'soapbox:nostr:privateKey'; + +/** 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()); +} + +/** Sign an event with NIP-07, or the locally generated key. */ +async function signEvent(event: EventTemplate): Promise> { + return window.nostr ? window.nostr.signEvent(event) as Promise> : finishEvent(event, 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 From 3cebd961cab9142aeda4f15761545670e222c9b6 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Oct 2023 17:19:19 -0500 Subject: [PATCH 2/5] useSignerStream: rework with nostr-machina --- package.json | 1 + src/api/hooks/nostr/useSignerStream.ts | 82 ++++++++++++++------------ yarn.lock | 25 ++++++++ 3 files changed, 69 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index cfe53c48b..3b7c598f5 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "localforage": "^1.10.0", "lodash": "^4.7.11", "mini-css-extract-plugin": "^2.6.0", + "nostr-machina": "^0.1.0", "nostr-tools": "^1.14.2", "path-browserify": "^1.0.1", "postcss": "^8.4.29", diff --git a/src/api/hooks/nostr/useSignerStream.ts b/src/api/hooks/nostr/useSignerStream.ts index b64fc132b..6780351c8 100644 --- a/src/api/hooks/nostr/useSignerStream.ts +++ b/src/api/hooks/nostr/useSignerStream.ts @@ -1,6 +1,7 @@ -import { relayInit, type Relay } from 'nostr-tools'; -import { useEffect } from 'react'; +import { NiceRelay } from 'nostr-machina'; +import { useEffect, useMemo } from 'react'; +import { nip04, signEvent } from 'soapbox/features/nostr/sign'; import { useInstance } from 'soapbox/hooks'; import { connectRequestSchema } from 'soapbox/schemas/nostr'; import { jsonSchema } from 'soapbox/schemas/utils'; @@ -11,47 +12,50 @@ function useSignerStream() { const relayUrl = instance.nostr?.relay; const pubkey = instance.nostr?.pubkey; - 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); - }); + const relay = useMemo(() => { + if (relayUrl) { + return new NiceRelay(relayUrl); } + }, [relayUrl]); + + useEffect(() => { + if (!relay || !pubkey) return; + + const sub = relay.req([{ kinds: [24133], authors: [pubkey], limit: 0 }]); + + const readEvents = async () => { + for await (const event of sub) { + const decrypted = await nip04.decrypt(pubkey, event.content); + + const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted); + if (!reqMsg.success) { + console.warn(decrypted); + console.warn(reqMsg.error); + return; + } + + const respMsg = { + id: reqMsg.data.id, + result: await signEvent(reqMsg.data.params[0]), + }; + + const respEvent = await signEvent({ + kind: 24133, + content: await nip04.encrypt(pubkey, JSON.stringify(respMsg)), + tags: [['p', pubkey]], + created_at: Math.floor(Date.now() / 1000), + }); + + relay.send(['EVENT', respEvent]); + } + }; + + readEvents(); + return () => { relay?.close(); }; - }, [relayUrl, pubkey]); + }, [relay, pubkey]); } export { useSignerStream }; diff --git a/yarn.lock b/yarn.lock index 838d535f5..5c8d8ca93 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6512,6 +6512,26 @@ 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-machina@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/nostr-machina/-/nostr-machina-0.1.0.tgz#e111e86eb51655e5de31862174d23de184e6e98a" + integrity sha512-sNswM9vgq7R/96YIJKZOlG0M/m2mZrb1TiPA7hpOMrnWHBGdDuAeON0vLWJaGbvpuDKYQ1b5ZiLZ8HM3EZPevw== + dependencies: + nostr-tools "^1.14.0" + zod "^3.21.0" + +nostr-tools@^1.14.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.16.0.tgz#5867f1d8bd055a5a3b27aadb199457dceb244314" + integrity sha512-sx/aOl0gmkeHVoIVbyOhEQhzF88NsrBXMC8bsjhPASqA6oZ8uSOAyEGgRLMfC3SKgzQD5Gr6KvDoAahaD6xKcg== + dependencies: + "@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" + nostr-tools@^1.14.2: version "1.14.2" resolved "https://registry.yarnpkg.com/nostr-tools/-/nostr-tools-1.14.2.tgz#161c9401467725e87c07fcf1c9924d31b12fd45c" @@ -9674,6 +9694,11 @@ yocto-queue@^1.0.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.0.0.tgz#7f816433fb2cbc511ec8bf7d263c3b58a1a3c251" integrity sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g== +zod@^3.21.0: + version "3.22.3" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.22.3.tgz#2fbc96118b174290d94e8896371c95629e87a060" + integrity sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug== + zod@^3.21.4: version "3.21.4" resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" From 28731f6087b32d00b3442f7b5f5e9da8266bb2b5 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Oct 2023 18:03:26 -0500 Subject: [PATCH 3/5] Nostr: enable Nostr login --- src/actions/nostr.ts | 18 ++++ .../components/registration-form.tsx | 74 ++++++++------- src/features/ui/components/navbar.tsx | 95 ++++++++++++------- src/utils/features.ts | 6 ++ 4 files changed, 123 insertions(+), 70 deletions(-) create mode 100644 src/actions/nostr.ts diff --git a/src/actions/nostr.ts b/src/actions/nostr.ts new file mode 100644 index 000000000..4a155c435 --- /dev/null +++ b/src/actions/nostr.ts @@ -0,0 +1,18 @@ +import { nip19 } from 'nostr-tools'; + +import { getPublicKey } from 'soapbox/features/nostr/sign'; +import { type AppDispatch } from 'soapbox/store'; + +import { verifyCredentials } from './auth'; + +/** Log in with a Nostr pubkey. */ +function nostrLogIn() { + return async (dispatch: AppDispatch) => { + const pubkey = await getPublicKey(); + const npub = nip19.npubEncode(pubkey); + + return dispatch(verifyCredentials(npub)); + }; +} + +export { nostrLogIn }; \ No newline at end of file diff --git a/src/features/auth-login/components/registration-form.tsx b/src/features/auth-login/components/registration-form.tsx index f3b4fbbdd..1b19b0b37 100644 --- a/src/features/auth-login/components/registration-form.tsx +++ b/src/features/auth-login/components/registration-form.tsx @@ -245,46 +245,52 @@ const RegistrationForm: React.FC = ({ inviteToken }) => { /> - - - - - + {!features.nostrSignup && ( - + )} + + {!features.nostrSignup && ( + <> + + + + + + + )} {birthdayRequired && ( { const onOpenSidebar = () => dispatch(openSidebar()); + const handleNostrLogin = async () => { + setLoading(true); + await dispatch(nostrLogIn()).catch(console.error); + setLoading(false); + }; + const handleSubmit: React.FormEventHandler = (event) => { event.preventDefault(); setLoading(true); @@ -107,50 +114,66 @@ const Navbar = () => { ) : ( <> -
- setUsername(event.target.value)} - type='text' - placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)} - className='max-w-[200px]' - /> + {features.nostrSignup ? ( +
+ +
+ ) : ( + + setUsername(event.target.value)} + type='text' + placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)} + className='max-w-[200px]' + /> - setPassword(event.target.value)} - type='password' - placeholder={intl.formatMessage(messages.password)} - className='max-w-[200px]' - /> + setPassword(event.target.value)} + type='password' + placeholder={intl.formatMessage(messages.password)} + className='max-w-[200px]' + /> - - - - - + + + + + + +
+ )} + +
- - -
- - {isOpen && ( + {(isOpen) && ( diff --git a/src/utils/features.ts b/src/utils/features.ts index f0207bfac..a1224eda2 100644 --- a/src/utils/features.ts +++ b/src/utils/features.ts @@ -685,6 +685,12 @@ const getInstanceFeatures = (instance: Instance) => { */ nostrSign: v.software === DITTO, + /** + * Whether the backend uses Ditto's Nosteric way of registration. + * @see POST /api/v1/accounts + */ + nostrSignup: v.software === DITTO, + /** * Add private notes to accounts. * @see POST /api/v1/accounts/:id/note From 3e6af89a9b0250c76208b3dfd904c56e59b78872 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Oct 2023 18:07:49 -0500 Subject: [PATCH 4/5] Enable Nostr registration --- src/actions/accounts.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/actions/accounts.ts b/src/actions/accounts.ts index 27cf94455..779966596 100644 --- a/src/actions/accounts.ts +++ b/src/actions/accounts.ts @@ -1,5 +1,8 @@ +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 { selectAccount } from 'soapbox/selectors'; import { isLoggedIn } from 'soapbox/utils/auth'; import { getFeatures, parseVersion, PLEROMA } from 'soapbox/utils/features'; @@ -128,9 +131,15 @@ const maybeRedirectLogin = (error: AxiosError, history?: History) => { const noOp = () => new Promise(f => f(undefined)); const createAccount = (params: Record) => - (dispatch: AppDispatch, getState: () => RootState) => { + async (dispatch: AppDispatch, getState: () => RootState) => { + const { instance } = getState(); + const { nostrSignup } = getFeatures(instance); + const pubkey = nostrSignup ? await getPublicKey() : undefined; + dispatch({ type: ACCOUNT_CREATE_REQUEST, params }); - return api(getState, 'app').post('/api/v1/accounts', params).then(({ data: token }) => { + return api(getState, 'app').post('/api/v1/accounts', params, { + headers: pubkey ? { authorization: `Bearer ${nip19.npubEncode(pubkey)}` } : undefined, + }).then(({ data: token }) => { return dispatch({ type: ACCOUNT_CREATE_SUCCESS, params, token }); }).catch(error => { dispatch({ type: ACCOUNT_CREATE_FAIL, error, params }); From eceb08287493e31ab90bfa8d7d7114d34add7297 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Wed, 4 Oct 2023 18:12:18 -0500 Subject: [PATCH 5/5] Fix kindSchema --- src/schemas/nostr.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schemas/nostr.ts b/src/schemas/nostr.ts index 41c3290c3..1dcd0c466 100644 --- a/src/schemas/nostr.ts +++ b/src/schemas/nostr.ts @@ -4,7 +4,7 @@ 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(); +const kindSchema = z.number().int().nonnegative(); /** Nostr event template schema. */ const eventTemplateSchema = z.object({