kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Rewrite NBunker to have a more flexible and modular interface
rodzic
47fa485f97
commit
d6579eecf7
|
@ -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) {
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
}
|
|
@ -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 = [
|
||||
|
|
Ładowanie…
Reference in New Issue