Rewrite NBunker to have a more flexible and modular interface

environments/review-update-vid-g70vyz/deployments/5013
Alex Gleason 2024-10-29 15:05:03 -05:00
rodzic 47fa485f97
commit d6579eecf7
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 7211D1F99744FBB7
5 zmienionych plików z 133 dodań i 130 usunięć

Wyświetl plik

@ -46,6 +46,10 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
await respond({ result: pubkey });
break;
}
// FIXME: this needs to actually be a full bunker that handles all methods...
// maybe NBunker can be modular? It should be okay to make multiple instances of it at once.
// Then it could be used for knox too.
}
if (!authorizedPubkey) {

Wyświetl plik

@ -1,61 +1,31 @@
import { NSecSigner } from '@nostrify/nostrify';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useSigner } from 'soapbox/api/hooks/nostr/useSigner';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NBunker } from 'soapbox/features/nostr/NBunker';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppSelector } from 'soapbox/hooks';
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
function useBunker() {
const { relay } = useNostr();
const { connections } = useBunkerStore();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSubscribing, setIsSubscribing] = useState(true);
const connection = useAppSelector((state) => {
const accessToken = state.auth.tokens[state.auth.me!]?.access_token;
if (accessToken) {
return connections.find((conn) => conn.accessToken === accessToken);
}
});
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
useEffect(() => {
if (!relay || !connection) return;
if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
const bunker = new NBunker({
relay,
connection: (() => {
if (!connection) return;
const { authorizedPubkey, bunkerSeckey, pubkey } = connection;
const user = NKeys.get(pubkey) ?? window.nostr;
if (!user) return;
return {
authorizedPubkey,
signers: {
user,
bunker: new NSecSigner(bunkerSeckey),
},
};
})(),
onSubscribed() {
setIsSubscribed(true);
setIsSubscribing(false);
userSigner,
bunkerSigner,
onError(error, event) {
console.warn('Bunker error:', error, event);
},
});
bunker.authorize(authorizedPubkey);
return () => {
bunker.close();
};
}, [relay, connection]);
return {
isSubscribed,
isSubscribing,
};
}, [relay, userSigner, bunkerSigner, authorizedPubkey]);
}
export { useBunker };

Wyświetl plik

@ -7,8 +7,7 @@ import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay1;
signer?: NostrSigner;
hasNostr: boolean;
isRelayOpen: boolean;
isRelayLoading: boolean;
}
const NostrContext = createContext<NostrContextType | undefined>(undefined);
@ -21,31 +20,31 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const { instance } = useInstance();
const { signer } = useSigner();
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 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, signer, isRelayLoading }}>
{children}
</NostrContext.Provider>
);

Wyświetl plik

@ -8,129 +8,141 @@ import {
NSchema as n,
} from '@nostrify/nostrify';
interface NBunkerSigners {
user: NostrSigner;
bunker: NostrSigner;
}
interface NBunkerConnection {
authorizedPubkey: string;
signers: NBunkerSigners;
}
/** Options passed to `NBunker`. */
export interface NBunkerOpts {
/** Relay to subscribe to for NIP-46 requests. */
relay: NRelay;
connection?: NBunkerConnection;
onSubscribed(): void;
/** 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.
* All other methods are handled by the bunker automatically.
*/
onConnect?(request: NostrConnectRequest, event: NostrEvent): Promise<void> | void;
/**
* 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 connection?: NBunkerConnection;
private onSubscribed: () => void;
private controller = new AbortController();
private authorizedPubkeys = new Set<string>();
constructor(opts: NBunkerOpts) {
this.relay = opts.relay;
this.connection = opts.connection;
this.onSubscribed = opts.onSubscribed;
constructor(private opts: NBunkerOpts) {
this.open();
}
async open() {
if (this.connection) {
this.subscribeConnection(this.connection);
}
this.onSubscribed();
}
/** Open the signer subscription to the relay. */
private async open() {
const { relay, bunkerSigner, onError } = this.opts;
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
const { authorizedPubkey, signers } = connection;
const bunkerPubkey = await signers.bunker.getPublicKey();
const signal = this.controller.signal;
const bunkerPubkey = await bunkerSigner.getPublicKey();
const filters: NostrFilter[] = [
{ kinds: [24133], authors: [authorizedPubkey], '#p': [bunkerPubkey], limit: 0 },
{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
];
for await (const { event, request } of this.subscribe(filters, signers)) {
this.handleRequest(event, request, connection);
}
}
private async *subscribe(filters: NostrFilter[], signers: NBunkerSigners): AsyncIterable<{ event: NostrEvent; request: NostrConnectRequest }> {
const signal = this.controller.signal;
for await (const msg of this.relay.req(filters, { signal })) {
for await (const msg of relay.req(filters, { signal })) {
if (msg[0] === 'EVENT') {
const [,, event] = msg;
try {
const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content);
const decrypted = await this.decrypt(event.pubkey, event.content);
const request = n.json().pipe(n.connectRequest()).parse(decrypted);
yield { event, request };
await this.handleRequest(request, event);
} catch (error) {
console.warn(error);
onError?.(error, event);
}
}
}
}
private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise<void> {
const { signers, authorizedPubkey } = connection;
const { user } = signers;
/**
* 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.method === 'connect') {
onConnect?.(request, event);
return;
}
// Prevent unauthorized access.
if (event.pubkey !== authorizedPubkey) {
return;
if (!this.authorizedPubkeys.has(pubkey)) {
return this.sendResponse(pubkey, {
id: request.id,
result: '',
error: 'Unauthorized',
});
}
// Authorized methods.
switch (request.method) {
case 'sign_event':
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await user.signEvent(JSON.parse(request.params[0]))),
result: JSON.stringify(await userSigner.signEvent(JSON.parse(request.params[0]))),
});
case 'ping':
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: 'pong',
});
case 'get_relays':
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await user.getRelays?.() ?? []),
result: JSON.stringify(await userSigner.getRelays?.() ?? []),
});
case 'get_public_key':
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: await user.getPublicKey(),
result: await userSigner.getPublicKey(),
});
case 'nip04_encrypt':
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: await user.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(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: await user.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(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: await user.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(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: await user.nip44!.decrypt(request.params[0], request.params[1]),
result: await userSigner.nip44!.decrypt(request.params[0], request.params[1]),
});
default:
return this.sendResponse(event.pubkey, {
return this.sendResponse(pubkey, {
id: request.id,
result: '',
error: `Unrecognized method: ${request.method}`,
@ -138,34 +150,49 @@ export class NBunker {
}
}
/** Encrypt the response with the bunker key, then publish it to the relay. */
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
const { user } = this.connection?.signers ?? {};
const { bunkerSigner, relay } = this.opts;
if (!user) {
return;
}
const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
const event = await user.signEvent({
const event = await bunkerSigner.signEvent({
kind: 24133,
content: await user.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);
}
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise<string> {
private async decrypt(pubkey: string, ciphertext: string): Promise<string> {
const { bunkerSigner } = this.opts;
try {
return await signer.nip44!.decrypt(pubkey, ciphertext);
return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext);
} catch {
return await signer.nip04!.decrypt(pubkey, ciphertext);
return await bunkerSigner.nip04!.decrypt(pubkey, ciphertext);
}
}
close() {
/** 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.asyncDispose](): void {
this.close();
}
}

Wyświetl plik

@ -4,6 +4,7 @@ import { IntlProvider } from 'react-intl';
import { fetchMe } from 'soapbox/actions/me';
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
import { useBunker } from 'soapbox/api/hooks/nostr/useBunker';
import { useSigner } from 'soapbox/api/hooks/nostr/useSigner';
import LoadingScreen from 'soapbox/components/loading-screen';
import { useNostr } from 'soapbox/contexts/nostr-context';
import {
@ -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 } = useBunker();
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 = [