Merge branch 'nostr-machina' into 'main'

Implement Nostr Machina

See merge request soapbox-pub/soapbox!2775
environments/review-main-yi2y9f/deployments/4091
Alex Gleason 2023-10-04 23:33:33 +00:00
commit f02255ff6a
10 zmienionych plików z 256 dodań i 112 usunięć

Wyświetl plik

@ -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",

Wyświetl plik

@ -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<string, any>) =>
(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 });

Wyświetl plik

@ -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 };

Wyświetl plik

@ -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 };

Wyświetl plik

@ -245,46 +245,52 @@ const RegistrationForm: React.FC<IRegistrationForm> = ({ inviteToken }) => {
/>
</FormGroup>
<Input
type='email'
name='email'
placeholder={intl.formatMessage(messages.email)}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
onChange={onInputChange}
value={params.get('email', '')}
required
/>
<Input
type='password'
name='password'
placeholder={intl.formatMessage(messages.password)}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
onChange={onPasswordChange}
value={params.get('password', '')}
required
/>
<FormGroup
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
>
{!features.nostrSignup && (
<Input
type='password'
name='password_confirmation'
placeholder={intl.formatMessage(messages.confirm)}
type='email'
name='email'
placeholder={intl.formatMessage(messages.email)}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
onChange={onPasswordConfirmChange}
onBlur={onPasswordConfirmBlur}
value={passwordConfirmation}
onChange={onInputChange}
value={params.get('email', '')}
required
/>
</FormGroup>
)}
{!features.nostrSignup && (
<>
<Input
type='password'
name='password'
placeholder={intl.formatMessage(messages.password)}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
onChange={onPasswordChange}
value={params.get('password', '')}
required
/>
<FormGroup
errors={passwordMismatch ? [intl.formatMessage(messages.passwordMismatch)] : undefined}
>
<Input
type='password'
name='password_confirmation'
placeholder={intl.formatMessage(messages.confirm)}
autoComplete='off'
autoCorrect='off'
autoCapitalize='off'
onChange={onPasswordConfirmChange}
onBlur={onPasswordConfirmBlur}
value={passwordConfirmation}
required
/>
</FormGroup>
</>
)}
{birthdayRequired && (
<BirthdayInput

Wyświetl plik

@ -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<string> {
return window.nostr ? window.nostr.getPublicKey() : _getPublicKey(getPrivateKey());
}
/** Sign an event with NIP-07, or the locally generated key. */
async function signEvent<K extends number>(event: EventTemplate<K>): Promise<Event<K>> {
return window.nostr ? window.nostr.signEvent(event) as Promise<Event<K>> : 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 };

Wyświetl plik

@ -5,6 +5,7 @@ import { Link, Redirect } from 'react-router-dom';
import { logIn, verifyCredentials } from 'soapbox/actions/auth';
import { fetchInstance } from 'soapbox/actions/instance';
import { nostrLogIn } from 'soapbox/actions/nostr';
import { openSidebar } from 'soapbox/actions/sidebar';
import SiteLogo from 'soapbox/components/site-logo';
import { Avatar, Button, Form, HStack, IconButton, Input, Tooltip } from 'soapbox/components/ui';
@ -38,6 +39,12 @@ const Navbar = () => {
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 = () => {
</div>
) : (
<>
<Form className='hidden items-center space-x-2 rtl:space-x-reverse lg:flex' onSubmit={handleSubmit}>
<Input
required
value={username}
onChange={(event) => setUsername(event.target.value)}
type='text'
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
className='max-w-[200px]'
/>
{features.nostrSignup ? (
<div className='hidden items-center xl:flex'>
<Button
theme='primary'
onClick={handleNostrLogin}
disabled={isLoading}
>
{intl.formatMessage(messages.login)}
</Button>
</div>
) : (
<Form className='hidden items-center space-x-2 rtl:space-x-reverse xl:flex' onSubmit={handleSubmit}>
<Input
required
value={username}
onChange={(event) => setUsername(event.target.value)}
type='text'
placeholder={intl.formatMessage(features.logInWithUsername ? messages.username : messages.email)}
className='max-w-[200px]'
/>
<Input
required
value={password}
onChange={(event) => setPassword(event.target.value)}
type='password'
placeholder={intl.formatMessage(messages.password)}
className='max-w-[200px]'
/>
<Input
required
value={password}
onChange={(event) => setPassword(event.target.value)}
type='password'
placeholder={intl.formatMessage(messages.password)}
className='max-w-[200px]'
/>
<Link to='/reset-password'>
<Tooltip text={intl.formatMessage(messages.forgotPassword)}>
<IconButton
src={require('@tabler/icons/help.svg')}
className='cursor-pointer bg-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200'
iconClassName='h-5 w-5'
/>
</Tooltip>
</Link>
<Link to='/reset-password'>
<Tooltip text={intl.formatMessage(messages.forgotPassword)}>
<IconButton
src={require('@tabler/icons/help.svg')}
className='cursor-pointer bg-transparent text-gray-400 hover:text-gray-700 dark:text-gray-500 dark:hover:text-gray-200'
iconClassName='h-5 w-5'
/>
</Tooltip>
</Link>
<Button
theme='primary'
type='submit'
disabled={isLoading}
>
{intl.formatMessage(messages.login)}
</Button>
</Form>
)}
<div className='space-x-1.5 xl:hidden'>
<Button
theme='primary'
type='submit'
disabled={isLoading}
theme='tertiary'
size='sm'
{...(features.nostrSignup ? { onClick: handleNostrLogin } : { to: '/login' })}
>
{intl.formatMessage(messages.login)}
</Button>
</Form>
<div className='space-x-1.5 lg:hidden'>
<Button theme='tertiary' to='/login' size='sm'>
<FormattedMessage id='account.login' defaultMessage='Log In' />
</Button>
{isOpen && (
{(isOpen) && (
<Button theme='primary' to='/signup' size='sm'>
<FormattedMessage id='account.register' defaultMessage='Sign up' />
</Button>

Wyświetl plik

@ -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({

Wyświetl plik

@ -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

Wyświetl plik

@ -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"