Merge branch 'bunker' into 'main'

Rewrite the Nostr Bunker to prevent conflicts with other signers

See merge request soapbox-pub/soapbox!3201
This commit is contained in:
Alex Gleason 2024-10-30 21:06:25 +00:00
commit 2806f5afbc
25 changed files with 487 additions and 223 deletions

View File

@ -35,7 +35,7 @@
},
"license": "AGPL-3.0-or-later",
"browserslist": [
"> 0.5%",
"> 1%",
"last 2 versions",
"not dead"
],
@ -155,7 +155,8 @@
"vite-plugin-html": "^3.2.2",
"vite-plugin-require": "^1.2.14",
"vite-plugin-static-copy": "^1.0.6",
"zod": "^3.23.5"
"zod": "^3.23.5",
"zustand": "^5.0.0"
},
"devDependencies": {
"@formatjs/cli": "^6.2.0",

View File

@ -1,4 +1,10 @@
import { RootState, type AppDispatch } from 'soapbox/store';
import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey } from 'nostr-tools';
import { NBunker } from 'soapbox/features/nostr/NBunker';
import { keyring } from 'soapbox/features/nostr/keyring';
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
import { type AppDispatch } from 'soapbox/store';
import { authLoggedIn, verifyCredentials } from './auth';
import { obtainOAuthToken } from './oauth';
@ -6,42 +12,83 @@ import { obtainOAuthToken } from './oauth';
const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
/** Log in with a Nostr pubkey. */
function logInNostr(pubkey: string) {
return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(setNostrPubkey(pubkey));
function logInNostr(signer: NostrSigner, relay: NRelay1) {
return async (dispatch: AppDispatch) => {
const authorization = generateBunkerAuth();
const secret = sessionStorage.getItem('soapbox:nip46:secret');
if (!secret) {
throw new Error('No secret found in session storage');
const pubkey = await signer.getPublicKey();
const bunkerPubkey = await authorization.signer.getPublicKey();
let authorizedPubkey: string | undefined;
const bunker = new NBunker({
relay,
userSigner: signer,
bunkerSigner: authorization.signer,
onConnect(request, event) {
const [, secret] = request.params;
if (secret === authorization.secret) {
bunker.authorize(event.pubkey);
authorizedPubkey = event.pubkey;
return { id: request.id, result: 'ack' };
} else {
return { id: request.id, result: '', error: 'Invalid secret' };
}
},
});
const relay = getState().instance.nostr?.relay;
// HACK: waits 1 second to ensure the relay subscription is open
await new Promise((resolve) => setTimeout(resolve, 1000));
await bunker.waitReady;
const token = await dispatch(obtainOAuthToken({
grant_type: 'nostr_bunker',
pubkey,
relays: relay ? [relay] : undefined,
secret,
pubkey: bunkerPubkey,
relays: [relay.socket.url],
secret: authorization.secret,
}));
const { access_token } = dispatch(authLoggedIn(token));
await dispatch(verifyCredentials(access_token as string));
if (!authorizedPubkey) {
throw new Error('Authorization failed');
}
dispatch(setNostrPubkey(undefined));
const accessToken = dispatch(authLoggedIn(token)).access_token as string;
const bunkerState = useBunkerStore.getState();
keyring.add(authorization.seckey);
bunkerState.connect({
pubkey,
accessToken,
authorizedPubkey,
bunkerPubkey,
});
await dispatch(verifyCredentials(accessToken));
// TODO: get rid of `vite-plugin-require` and switch to `using` for the bunker. :(
bunker.close();
};
}
/** Log in with a Nostr extension. */
function nostrExtensionLogIn() {
function nostrExtensionLogIn(relay: NRelay1) {
return async (dispatch: AppDispatch) => {
if (!window.nostr) {
throw new Error('No Nostr signer available');
}
const pubkey = await window.nostr.getPublicKey();
return dispatch(logInNostr(pubkey));
return dispatch(logInNostr(window.nostr, relay));
};
}
/** Generate a bunker authorization object. */
function generateBunkerAuth() {
const secret = crypto.randomUUID();
const seckey = generateSecretKey();
return {
secret,
seckey,
signer: new NSecSigner(seckey),
};
}

View File

@ -1,64 +0,0 @@
import { useEffect, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NBunker } from 'soapbox/features/nostr/NBunker';
const secretStorageKey = 'soapbox:nip46:secret';
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
function useSignerStream() {
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSubscribing, setIsSubscribing] = useState(true);
const { relay, signer, hasNostr } = useNostr();
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
const authStorageKey = `soapbox:nostr:auth:${pubkey}`;
useEffect(() => {
let isCancelled = false;
if (signer && hasNostr) {
signer.getPublicKey().then((newPubkey) => {
if (!isCancelled) {
setPubkey(newPubkey);
}
}).catch(console.warn);
}
return () => {
isCancelled = true;
};
}, [signer, hasNostr]);
useEffect(() => {
if (!relay || !signer || !pubkey) return;
const bunker = new NBunker({
relay,
signer,
onAuthorize(authorizedPubkey) {
localStorage.setItem(authStorageKey, authorizedPubkey);
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
},
onSubscribed() {
setIsSubscribed(true);
setIsSubscribing(false);
},
authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined,
getSecret: () => sessionStorage.getItem(secretStorageKey)!,
});
return () => {
bunker.close();
};
}, [relay, signer, pubkey]);
return {
isSubscribed,
isSubscribing,
};
}
export { useSignerStream };

View File

@ -1,15 +1,11 @@
import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify';
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { NRelay1 } from '@nostrify/nostrify';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppSelector } from 'soapbox/hooks';
import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay;
signer?: NostrSigner;
hasNostr: boolean;
isRelayOpen: boolean;
relay?: NRelay1;
isRelayLoading: boolean;
}
const NostrContext = createContext<NostrContextType | undefined>(undefined);
@ -20,37 +16,32 @@ interface NostrProviderProps {
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const { instance } = useInstance();
const hasNostr = !!instance.nostr;
const [relay, setRelay] = useState<NRelay1>();
const [isRelayOpen, setIsRelayOpen] = useState(false);
const [isRelayLoading, setIsRelayLoading] = useState(true);
const url = instance.nostr?.relay;
const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id);
const signer = useMemo(
() => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined,
[accountPubkey, window.nostr],
);
const relayUrl = instance.nostr?.relay;
const handleRelayOpen = () => {
setIsRelayOpen(true);
setIsRelayLoading(false);
};
useEffect(() => {
if (url) {
const relay = new NRelay1(url);
if (relayUrl) {
const relay = new NRelay1(relayUrl);
relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen);
setRelay(relay);
} else {
setIsRelayLoading(false);
}
return () => {
relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen);
relay?.close();
};
}, [url]);
}, [relayUrl]);
return (
<NostrContext.Provider value={{ relay, signer, isRelayOpen, hasNostr }}>
<NostrContext.Provider value={{ relay, isRelayLoading }}>
{children}
</NostrContext.Provider>
);

View File

@ -1,4 +1,4 @@
import { produce, enableMapSet } from 'immer';
import { produce } from 'immer';
import {
ENTITIES_IMPORT,
@ -17,8 +17,6 @@ import { createCache, createList, updateStore, updateList } from './utils';
import type { DeleteEntitiesOpts } from './actions';
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
enableMapSet();
/** Entity reducer state. */
interface State {
[entityType: string]: EntityCache | undefined;

View File

@ -96,8 +96,6 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => {
const account = selectAccount(getState(), accountId);
console.log(account);
props.onChange(account!);
};

View File

@ -5,6 +5,7 @@ import { Button, Column, Form, FormActions, Stack } from 'soapbox/components/ui'
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useNostrReq } from 'soapbox/features/nostr/hooks/useNostrReq';
import { useOwnAccount } from 'soapbox/hooks';
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
import RelayEditor, { RelayData } from './components/relay-editor';
@ -15,7 +16,8 @@ const messages = defineMessages({
const NostrRelays = () => {
const intl = useIntl();
const { account } = useOwnAccount();
const { relay, signer } = useNostr();
const { relay } = useNostr();
const { signer } = useSigner();
const { events } = useNostrReq(
account?.nostr?.pubkey

View File

@ -1,73 +1,131 @@
import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify';
import {
NRelay,
NostrConnectRequest,
NostrConnectResponse,
NostrEvent,
NostrFilter,
NostrSigner,
NSchema as n,
} from '@nostrify/nostrify';
interface NBunkerOpts {
/** Options passed to `NBunker`. */
export interface NBunkerOpts {
/** Relay to subscribe to for NIP-46 requests. */
relay: NRelay;
signer: NostrSigner;
authorizedPubkey: string | undefined;
onAuthorize(pubkey: string): void;
onSubscribed(): void;
getSecret(): string;
/** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */
userSigner: NostrSigner;
/** Signer to sign, encrypt, and decrypt the kind 24133 transport events events. */
bunkerSigner: NostrSigner;
/**
* Callback when a `connect` request has been received.
* This is a good place to call `bunker.authorize()` with the remote client's pubkey.
* It's up to the caller to verify the request parameters and secret, and then return a response object.
* All other methods are handled by the bunker automatically.
*
* ```ts
* const bunker = new Bunker({
* ...opts,
* onConnect(request, event) {
* const [, secret] = request.params;
*
* if (secret === authorization.secret) {
* bunker.authorize(event.pubkey); // Authorize the pubkey for signer actions.
* return { id: request.id, result: 'ack' }; // Return a success response.
* } else {
* return { id: request.id, result: '', error: 'Invalid secret' };
* }
* },
* });
* ```
*/
onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise<NostrConnectResponse> | NostrConnectResponse;
/**
* Callback when an error occurs while parsing a request event.
* Client errors are not captured here, only errors that occur before arequest's `id` can be known,
* eg when decrypting the event content or parsing the request object.
*/
onError?(error: unknown, event: NostrEvent): void;
}
/**
* Modular NIP-46 remote signer bunker class.
*
* Runs a remote signer against a given relay, using `bunkerSigner` to sign transport events,
* and `userSigner` to complete NIP-46 requests.
*/
export class NBunker {
private relay: NRelay;
private signer: NostrSigner;
private authorizedPubkey: string | undefined;
private onAuthorize: (pubkey: string) => void;
private onSubscribed: () => void;
private getSecret: () => string;
private controller = new AbortController();
private authorizedPubkeys = new Set<string>();
constructor(opts: NBunkerOpts) {
this.relay = opts.relay;
this.signer = opts.signer;
this.authorizedPubkey = opts.authorizedPubkey;
this.onAuthorize = opts.onAuthorize;
this.onSubscribed = opts.onSubscribed;
this.getSecret = opts.getSecret;
/** Wait for the bunker to be ready before sending requests. */
public waitReady: Promise<void>;
private setReady!: () => void;
constructor(private opts: NBunkerOpts) {
this.waitReady = new Promise((resolve) => {
this.setReady = resolve;
});
this.open();
}
async open() {
const pubkey = await this.signer.getPublicKey();
const signal = this.controller.signal;
/** Open the signer subscription to the relay. */
private async open() {
const { relay, bunkerSigner, onError } = this.opts;
const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal });
this.onSubscribed();
const signal = this.controller.signal;
const bunkerPubkey = await bunkerSigner.getPublicKey();
const filters: NostrFilter[] = [
{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
];
const sub = relay.req(filters, { signal });
this.setReady();
for await (const msg of sub) {
if (msg[0] === 'EVENT') {
const event = msg[2];
this.handleEvent(event);
const [,, event] = msg;
try {
const decrypted = await this.decrypt(event.pubkey, event.content);
const request = n.json().pipe(n.connectRequest()).parse(decrypted);
await this.handleRequest(request, event);
} catch (error) {
onError?.(error, event);
}
}
}
}
private async handleEvent(event: NostrEvent): Promise<void> {
const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content);
const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
/**
* Handle NIP-46 requests.
*
* The `connect` method must be handled passing an `onConnect` option into the class
* and then calling `bunker.authorize()` within that callback to authorize the pubkey.
*
* All other methods are handled automatically, as long as the key is authorized,
* by invoking the appropriate method on the `userSigner`.
*/
private async handleRequest(request: NostrConnectRequest, event: NostrEvent): Promise<void> {
const { userSigner, onConnect } = this.opts;
const { pubkey } = event;
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<void> {
// 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' });
if (onConnect) {
const response = await onConnect(request, event);
return this.sendResponse(pubkey, response);
}
return;
}
// Prevent unauthorized access.
if (pubkey !== this.authorizedPubkey) {
return;
if (!this.authorizedPubkeys.has(pubkey)) {
return this.sendResponse(pubkey, {
id: request.id,
result: '',
error: 'Unauthorized',
});
}
// Authorized methods.
@ -75,7 +133,7 @@ export class NBunker {
case 'sign_event':
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))),
result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))),
});
case 'ping':
return this.sendResponse(pubkey, {
@ -85,32 +143,32 @@ export class NBunker {
case 'get_relays':
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await this.signer.getRelays?.() ?? []),
result: JSON.stringify(await userSigner.getRelays?.() ?? []),
});
case 'get_public_key':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.getPublicKey(),
result: await userSigner.getPublicKey(),
});
case 'nip04_encrypt':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]),
result: await userSigner.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]),
result: await userSigner.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]),
result: await userSigner.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]),
result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]),
});
default:
return this.sendResponse(pubkey, {
@ -121,33 +179,49 @@ export class NBunker {
}
}
private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) {
const [remotePubkey, secret] = request.params;
/** Encrypt the response with the bunker key, then publish it to the relay. */
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
const { bunkerSigner, relay } = this.opts;
if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
this.authorizedPubkey = pubkey;
this.onAuthorize(pubkey);
const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
await this.sendResponse(pubkey, {
id: request.id,
result: 'ack',
});
}
}
private async sendResponse(pubkey: string, response: NostrConnectResponse) {
const event = await this.signer.signEvent({
const event = await bunkerSigner.signEvent({
kind: 24133,
content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
content,
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
await this.relay.event(event);
await relay.event(event);
}
close() {
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
private async decrypt(pubkey: string, ciphertext: string): Promise<string> {
const { bunkerSigner } = this.opts;
try {
return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext);
} catch {
return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext);
}
}
/** Authorize the pubkey to perform signer actions (ie any other actions besides `connect`). */
authorize(pubkey: string): void {
this.authorizedPubkeys.add(pubkey);
}
/** Revoke authorization for the pubkey. */
revoke(pubkey: string): void {
this.authorizedPubkeys.delete(pubkey);
}
/** Stop the bunker and unsubscribe relay subscriptions. */
close(): void {
this.controller.abort();
}
[Symbol.dispose](): void {
this.close();
}
}

View File

@ -8,7 +8,7 @@ import { z } from 'zod';
* When instantiated, it will lock the storage key to prevent tampering.
* Changes to the object will sync to storage.
*/
export class NKeyStorage implements ReadonlyMap<string, NostrSigner> {
export class NKeyring implements ReadonlyMap<string, NostrSigner> {
#keypairs = new Map<string, Uint8Array>();
#storage: Storage;

View File

@ -5,7 +5,13 @@ import { useEffect, useRef, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useForceUpdate } from 'soapbox/hooks/useForceUpdate';
/** Streams events from the relay for the given filters. */
/**
* Streams events from the relay for the given filters.
*
* @deprecated Add a custom HTTP endpoint to Ditto instead.
* Integrating Nostr directly has too many problems.
* Soapbox should only connect to the Nostr relay to sign events, because it's required for Nostr to work.
*/
export function useNostrReq(filters: NostrFilter[]): { events: NostrEvent[]; eose: boolean; closed: boolean } {
const { relay } = useNostr();

View File

@ -0,0 +1,6 @@
import { NKeyring } from './NKeyring';
export const keyring = new NKeyring(
localStorage,
'soapbox:nostr:keys',
);

View File

@ -1,6 +0,0 @@
import { NKeyStorage } from './NKeyStorage';
export const NKeys = new NKeyStorage(
localStorage,
'soapbox:nostr:keys',
);

View File

@ -5,14 +5,18 @@ import { closeModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import Stack from 'soapbox/components/ui/stack/stack';
import Text from 'soapbox/components/ui/text/text';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch } from 'soapbox/hooks';
const NostrExtensionIndicator: React.FC = () => {
const dispatch = useAppDispatch();
const { relay } = useNostr();
const onClick = () => {
dispatch(nostrExtensionLogIn());
if (relay) {
dispatch(nostrExtensionLogIn(relay));
dispatch(closeModal());
}
};
function renderBody(): React.ReactNode {

View File

@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks';
interface IExtensionStep {
@ -18,6 +19,7 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
const dispatch = useAppDispatch();
const { instance } = useInstance();
const { logo } = useSoapboxConfig();
const { relay } = useNostr();
const handleClose = () => {
onClose();
@ -25,8 +27,10 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
};
const onClick = () => {
dispatch(nostrExtensionLogIn());
if (relay) {
dispatch(nostrExtensionLogIn(relay));
onClose();
}
};
return (

View File

@ -5,7 +5,8 @@ import { FormattedMessage } from 'react-intl';
import { logInNostr } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Input, FormGroup, Form, Divider } from 'soapbox/components/ui';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { keyring } from 'soapbox/features/nostr/keyring';
import { useAppDispatch } from 'soapbox/hooks';
import NostrExtensionIndicator from '../components/nostr-extension-indicator';
@ -19,6 +20,7 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
const [error, setError] = useState<string | undefined>();
const dispatch = useAppDispatch();
const { relay } = useNostr();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsec(e.target.value);
@ -26,13 +28,13 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
};
const handleSubmit = async () => {
if (!relay) return;
try {
const result = nip19.decode(nsec);
if (result.type === 'nsec') {
const seckey = result.data;
const signer = NKeys.add(seckey);
const pubkey = await signer.getPublicKey();
dispatch(logInNostr(pubkey));
const signer = keyring.add(seckey);
dispatch(logInNostr(signer, relay));
onClose();
return;
}

View File

@ -8,7 +8,7 @@ import { closeSidebar } from 'soapbox/actions/sidebar';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, FormGroup, Text, Tooltip, HStack, Input } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NKeys } from 'soapbox/features/nostr/keys';
import { keyring } from 'soapbox/features/nostr/keyring';
import { useAppDispatch, useInstance } from 'soapbox/hooks';
import { useIsMobile } from 'soapbox/hooks/useIsMobile';
import { download } from 'soapbox/utils/download';
@ -43,8 +43,9 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
};
const handleNext = async () => {
const signer = NKeys.add(secretKey);
const pubkey = await signer.getPublicKey();
if (!relay) return;
const signer = keyring.add(secretKey);
const now = Math.floor(Date.now() / 1000);
const [kind0, ...events] = await Promise.all([
@ -57,12 +58,12 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }),
]);
await relay?.event(kind0);
await Promise.all(events.map((event) => relay?.event(event)));
await relay.event(kind0);
await Promise.all(events.map((event) => relay.event(event)));
onClose();
await dispatch(logInNostr(pubkey));
await dispatch(logInNostr(signer, relay));
if (isMobile) {
dispatch(closeSidebar());

View File

@ -0,0 +1,31 @@
import { useEffect } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NBunker } from 'soapbox/features/nostr/NBunker';
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
function useBunker() {
const { relay } = useNostr();
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
useEffect(() => {
if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
const bunker = new NBunker({
relay,
userSigner,
bunkerSigner,
onError(error, event) {
console.warn('Bunker error:', error, event);
},
});
bunker.authorize(authorizedPubkey);
return () => {
bunker.close();
};
}, [relay, userSigner, bunkerSigner, authorizedPubkey]);
}
export { useBunker };

View File

@ -0,0 +1,84 @@
import { NSchema as n } from '@nostrify/nostrify';
import { produce } from 'immer';
import { z } from 'zod';
import { create } from 'zustand';
// eslint-disable-next-line import/extensions
import { persist } from 'zustand/middleware';
import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
/**
* A bunker connection maps an OAuth token from Mastodon API to a user pubkey and bunker keypair.
* The user pubkey is used to determine whether to use keys from localStorage or a browser extension,
* and the bunker keypair is used to sign and encrypt NIP-46 messages.
*/
interface BunkerConnection {
/** User pubkey. Events will be signed by this pubkey. */
pubkey: string;
/** Mastodon API access token associated with this connection. */
accessToken: string;
/** Pubkey of the app authorized to sign events with this connection. */
authorizedPubkey: string;
/** Pubkey for this connection. Secret key is stored in the keyring. NIP-46 responses will be signed by this key. */
bunkerPubkey: string;
}
const connectionSchema: z.ZodType<BunkerConnection> = z.object({
pubkey: n.id(),
accessToken: z.string(),
authorizedPubkey: n.id(),
bunkerPubkey: n.id(),
});
interface BunkerState {
connections: BunkerConnection[];
connect(connection: BunkerConnection): void;
revoke(accessToken: string): void;
}
export const useBunkerStore = create<BunkerState>()(
persist(
(setState) => ({
connections: [],
/** Connect to a bunker using the authorization secret. */
connect(connection: BunkerConnection): void {
setState((state) => {
return produce(state, (draft) => {
draft.connections.push(connection);
});
});
},
/** Revoke any connections associated with the access token. */
revoke(accessToken: string): void {
setState((state) => {
return produce(state, (draft) => {
draft.connections = draft.connections.filter((conn) => conn.accessToken !== accessToken);
});
});
},
}),
{
name: 'soapbox:bunker',
storage: {
getItem(name) {
const value = localStorage.getItem(name);
const connections = jsonSchema()
.pipe(filteredArray(connectionSchema))
.catch([])
.parse(value);
return { state: { connections } };
},
setItem(name, { state }) {
localStorage.setItem(name, JSON.stringify(state.connections));
},
removeItem(name) {
localStorage.removeItem(name);
},
},
},
),
);

View File

@ -0,0 +1,42 @@
import { useQuery } from '@tanstack/react-query';
import { keyring } from 'soapbox/features/nostr/keyring';
import { useAppSelector } from 'soapbox/hooks';
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
export function useSigner() {
const { connections } = useBunkerStore();
const connection = useAppSelector(({ auth }) => {
const accessToken = auth.me ? auth.users[auth.me]?.access_token : undefined;
if (accessToken) {
return connections.find((conn) => conn.accessToken === accessToken);
}
});
const { pubkey, bunkerPubkey, authorizedPubkey } = connection ?? {};
const { data: signer, ...rest } = useQuery({
queryKey: ['nostr', 'signer', pubkey ?? ''],
queryFn: async () => {
if (!pubkey) return null;
const signer = keyring.get(pubkey);
if (signer) return signer;
if (window.nostr && await window.nostr.getPublicKey() === pubkey) {
return window.nostr;
}
return null;
},
enabled: !!pubkey,
});
return {
signer: signer ?? undefined,
bunkerSigner: bunkerPubkey ? keyring.get(bunkerPubkey) : undefined,
authorizedPubkey,
...rest,
};
}

View File

@ -3,7 +3,6 @@ import { IntlProvider } from 'react-intl';
import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream';
import LoadingScreen from 'soapbox/components/loading-screen';
import { useNostr } from 'soapbox/contexts/nostr-context';
import {
@ -13,6 +12,8 @@ import {
useLocale,
useInstance,
} from 'soapbox/hooks';
import { useBunker } from 'soapbox/hooks/nostr/useBunker';
import { useSigner } from 'soapbox/hooks/nostr/useSigner';
import MESSAGES from 'soapbox/messages';
/** Load initial data from the backend */
@ -44,10 +45,12 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
const [localeLoading, setLocaleLoading] = useState(true);
const [isLoaded, setIsLoaded] = useState(false);
const { hasNostr, isRelayOpen, signer } = useNostr();
const { isSubscribed } = useSignerStream();
const nostr = useNostr();
const signer = useSigner();
const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed));
const nostrLoading = Boolean(nostr.isRelayLoading || signer.isLoading);
useBunker();
/** Whether to display a loading indicator. */
const showLoading = [

View File

@ -1,3 +1,4 @@
import { enableMapSet } from 'immer';
import React from 'react';
import { createRoot } from 'react-dom/client';
@ -14,7 +15,7 @@ import '@fontsource/inter/700.css';
import '@fontsource/inter/900.css';
import '@fontsource/roboto-mono/400.css';
import 'line-awesome/dist/font-awesome-line-awesome/css/all.css';
import 'soapbox/features/nostr/keys';
import 'soapbox/features/nostr/keyring';
import './iframe';
import './styles/i18n/arabic.css';
@ -25,6 +26,8 @@ import './styles/tailwind.css';
import ready from './ready';
import { registerSW, lockSW } from './utils/sw';
enableMapSet();
if (BuildConfig.NODE_ENV === 'production') {
printConsoleWarning();
registerSW('/sw.js');

View File

@ -2,6 +2,8 @@ import { AxiosError } from 'axios';
import { produce } from 'immer';
import { z } from 'zod';
import { keyring } from 'soapbox/features/nostr/keyring';
import { useBunkerStore } from 'soapbox/hooks/nostr/useBunkerStore';
import { Account, accountSchema } from 'soapbox/schemas';
import { Application, applicationSchema } from 'soapbox/schemas/application';
import { AuthUser, SoapboxAuth, soapboxAuthSchema } from 'soapbox/schemas/soapbox/soapbox-auth';
@ -24,6 +26,17 @@ import type { UnknownAction } from 'redux';
const STORAGE_KEY = 'soapbox:auth';
const SESSION_KEY = 'soapbox:auth:me';
// Log out legacy Nostr/Ditto users.
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key && /^soapbox:nostr:auth:[0-9a-f]{64}$/.test(key)) {
localStorage.clear();
sessionStorage.clear();
location.reload();
}
}
/** Get current user's URL from session storage. */
function getSessionUser(): string | undefined {
const value = sessionStorage.getItem(SESSION_KEY);
@ -37,7 +50,7 @@ function getSessionUser(): string | undefined {
/** Retrieve state from browser storage. */
function getLocalState(): SoapboxAuth | undefined {
const data = localStorage.getItem(STORAGE_KEY);
const result = jsonSchema.pipe(soapboxAuthSchema).safeParse(data);
const result = jsonSchema().pipe(soapboxAuthSchema).safeParse(data);
if (!result.success) {
return undefined;
@ -105,7 +118,26 @@ function importCredentials(auth: SoapboxAuth, accessToken: string, account: Acco
});
}
/** Delete Nostr credentials when an access token is revoked. */
// TODO: Rework auth so this can all be conrolled from one place.
function revokeNostr(accessToken: string): void {
const { connections, revoke } = useBunkerStore.getState();
for (const conn of connections) {
if (conn.accessToken === accessToken) {
// Revoke the Bunker connection.
revoke(accessToken);
// Revoke the user's private key.
keyring.delete(conn.pubkey);
// Revoke the bunker's private key.
keyring.delete(conn.bunkerPubkey);
}
}
}
function deleteToken(auth: SoapboxAuth, accessToken: string): SoapboxAuth {
revokeNostr(accessToken);
return produce(auth, draft => {
delete draft.tokens[accessToken];

View File

@ -56,7 +56,7 @@ import trending_statuses from './trending-statuses';
import trends from './trends';
import user_lists from './user-lists';
const reducers = {
export default combineReducers({
accounts_meta,
admin,
aliases,
@ -111,6 +111,4 @@ const reducers = {
trending_statuses,
trends,
user_lists,
};
export default combineReducers(reducers);
});

View File

@ -30,14 +30,16 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
}, {});
}
const jsonSchema = z.string().transform((value, ctx) => {
function jsonSchema(reviver?: (this: any, key: string, value: any) => any) {
return z.string().transform((value, ctx) => {
try {
return JSON.parse(value) as unknown;
return JSON.parse(value, reviver) as unknown;
} catch (_e) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
return z.NEVER;
}
});
});
}
/** MIME schema, eg `image/png`. */
const mimeSchema = z.string().regex(/^\w+\/[-+.\w]+$/);

View File

@ -9292,3 +9292,8 @@ zod@^3.23.4, zod@^3.23.5:
version "3.23.5"
resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.5.tgz#c7b7617d017d4a2f21852f533258d26a9a5ae09f"
integrity sha512-fkwiq0VIQTksNNA131rDOsVJcns0pfVUjHzLrNBiF/O/Xxb5lQyEXkhZWcJ7npWsYlvs+h0jFWXXy4X46Em1JA==
zustand@^5.0.0:
version "5.0.0"
resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.0.tgz#71f8aaecf185592a3ba2743d7516607361899da9"
integrity sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==