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 });
|
await respond({ result: pubkey });
|
||||||
break;
|
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) {
|
if (!authorizedPubkey) {
|
||||||
|
|
|
@ -1,61 +1,31 @@
|
||||||
import { NSecSigner } from '@nostrify/nostrify';
|
import { useEffect } from 'react';
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
|
import { useSigner } from 'soapbox/api/hooks/nostr/useSigner';
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { NBunker } from 'soapbox/features/nostr/NBunker';
|
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() {
|
function useBunker() {
|
||||||
const { relay } = useNostr();
|
const { relay } = useNostr();
|
||||||
const { connections } = useBunkerStore();
|
const { signer: userSigner, bunkerSigner, authorizedPubkey } = useSigner();
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relay || !connection) return;
|
if (!relay || !userSigner || !bunkerSigner || !authorizedPubkey) return;
|
||||||
|
|
||||||
const bunker = new NBunker({
|
const bunker = new NBunker({
|
||||||
relay,
|
relay,
|
||||||
connection: (() => {
|
userSigner,
|
||||||
if (!connection) return;
|
bunkerSigner,
|
||||||
const { authorizedPubkey, bunkerSeckey, pubkey } = connection;
|
onError(error, event) {
|
||||||
|
console.warn('Bunker error:', error, event);
|
||||||
const user = NKeys.get(pubkey) ?? window.nostr;
|
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
return {
|
|
||||||
authorizedPubkey,
|
|
||||||
signers: {
|
|
||||||
user,
|
|
||||||
bunker: new NSecSigner(bunkerSeckey),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
})(),
|
|
||||||
onSubscribed() {
|
|
||||||
setIsSubscribed(true);
|
|
||||||
setIsSubscribing(false);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
bunker.authorize(authorizedPubkey);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
bunker.close();
|
bunker.close();
|
||||||
};
|
};
|
||||||
}, [relay, connection]);
|
}, [relay, userSigner, bunkerSigner, authorizedPubkey]);
|
||||||
|
|
||||||
return {
|
|
||||||
isSubscribed,
|
|
||||||
isSubscribing,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useBunker };
|
export { useBunker };
|
||||||
|
|
|
@ -7,8 +7,7 @@ import { useInstance } from 'soapbox/hooks/useInstance';
|
||||||
interface NostrContextType {
|
interface NostrContextType {
|
||||||
relay?: NRelay1;
|
relay?: NRelay1;
|
||||||
signer?: NostrSigner;
|
signer?: NostrSigner;
|
||||||
hasNostr: boolean;
|
isRelayLoading: boolean;
|
||||||
isRelayOpen: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const NostrContext = createContext<NostrContextType | undefined>(undefined);
|
const NostrContext = createContext<NostrContextType | undefined>(undefined);
|
||||||
|
@ -21,31 +20,31 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
||||||
const { instance } = useInstance();
|
const { instance } = useInstance();
|
||||||
const { signer } = useSigner();
|
const { signer } = useSigner();
|
||||||
|
|
||||||
const hasNostr = !!instance.nostr;
|
|
||||||
|
|
||||||
const [relay, setRelay] = useState<NRelay1>();
|
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 = () => {
|
const handleRelayOpen = () => {
|
||||||
setIsRelayOpen(true);
|
setIsRelayLoading(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (url) {
|
if (relayUrl) {
|
||||||
const relay = new NRelay1(url);
|
const relay = new NRelay1(relayUrl);
|
||||||
relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen);
|
relay.socket.underlyingWebsocket.addEventListener('open', handleRelayOpen);
|
||||||
setRelay(relay);
|
setRelay(relay);
|
||||||
|
} else {
|
||||||
|
setIsRelayLoading(false);
|
||||||
}
|
}
|
||||||
return () => {
|
return () => {
|
||||||
relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen);
|
relay?.socket.underlyingWebsocket.removeEventListener('open', handleRelayOpen);
|
||||||
relay?.close();
|
relay?.close();
|
||||||
};
|
};
|
||||||
}, [url]);
|
}, [relayUrl]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NostrContext.Provider value={{ relay, signer, isRelayOpen, hasNostr }}>
|
<NostrContext.Provider value={{ relay, signer, isRelayLoading }}>
|
||||||
{children}
|
{children}
|
||||||
</NostrContext.Provider>
|
</NostrContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -8,129 +8,141 @@ import {
|
||||||
NSchema as n,
|
NSchema as n,
|
||||||
} from '@nostrify/nostrify';
|
} from '@nostrify/nostrify';
|
||||||
|
|
||||||
interface NBunkerSigners {
|
/** Options passed to `NBunker`. */
|
||||||
user: NostrSigner;
|
|
||||||
bunker: NostrSigner;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NBunkerConnection {
|
|
||||||
authorizedPubkey: string;
|
|
||||||
signers: NBunkerSigners;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NBunkerOpts {
|
export interface NBunkerOpts {
|
||||||
|
/** Relay to subscribe to for NIP-46 requests. */
|
||||||
relay: NRelay;
|
relay: NRelay;
|
||||||
connection?: NBunkerConnection;
|
/** Signer to complete the actual NIP-46 requests, such as `get_public_key`, `sign_event`, `nip44_encrypt` etc. */
|
||||||
onSubscribed(): void;
|
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 {
|
export class NBunker {
|
||||||
|
|
||||||
private relay: NRelay;
|
|
||||||
private connection?: NBunkerConnection;
|
|
||||||
private onSubscribed: () => void;
|
|
||||||
|
|
||||||
private controller = new AbortController();
|
private controller = new AbortController();
|
||||||
|
private authorizedPubkeys = new Set<string>();
|
||||||
|
|
||||||
constructor(opts: NBunkerOpts) {
|
constructor(private opts: NBunkerOpts) {
|
||||||
this.relay = opts.relay;
|
|
||||||
this.connection = opts.connection;
|
|
||||||
this.onSubscribed = opts.onSubscribed;
|
|
||||||
|
|
||||||
this.open();
|
this.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
async open() {
|
/** Open the signer subscription to the relay. */
|
||||||
if (this.connection) {
|
private async open() {
|
||||||
this.subscribeConnection(this.connection);
|
const { relay, bunkerSigner, onError } = this.opts;
|
||||||
}
|
|
||||||
this.onSubscribed();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
|
const signal = this.controller.signal;
|
||||||
const { authorizedPubkey, signers } = connection;
|
const bunkerPubkey = await bunkerSigner.getPublicKey();
|
||||||
const bunkerPubkey = await signers.bunker.getPublicKey();
|
|
||||||
|
|
||||||
const filters: NostrFilter[] = [
|
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)) {
|
for await (const msg of relay.req(filters, { signal })) {
|
||||||
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 })) {
|
|
||||||
if (msg[0] === 'EVENT') {
|
if (msg[0] === 'EVENT') {
|
||||||
const [,, event] = msg;
|
const [,, event] = msg;
|
||||||
|
|
||||||
try {
|
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);
|
const request = n.json().pipe(n.connectRequest()).parse(decrypted);
|
||||||
yield { event, request };
|
await this.handleRequest(request, event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(error);
|
onError?.(error, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise<void> {
|
/**
|
||||||
const { signers, authorizedPubkey } = connection;
|
* Handle NIP-46 requests.
|
||||||
const { user } = signers;
|
*
|
||||||
|
* 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.
|
// Prevent unauthorized access.
|
||||||
if (event.pubkey !== authorizedPubkey) {
|
if (!this.authorizedPubkeys.has(pubkey)) {
|
||||||
return;
|
return this.sendResponse(pubkey, {
|
||||||
|
id: request.id,
|
||||||
|
result: '',
|
||||||
|
error: 'Unauthorized',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorized methods.
|
// Authorized methods.
|
||||||
switch (request.method) {
|
switch (request.method) {
|
||||||
case 'sign_event':
|
case 'sign_event':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
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':
|
case 'ping':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: 'pong',
|
result: 'pong',
|
||||||
});
|
});
|
||||||
case 'get_relays':
|
case 'get_relays':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: JSON.stringify(await user.getRelays?.() ?? []),
|
result: JSON.stringify(await userSigner.getRelays?.() ?? []),
|
||||||
});
|
});
|
||||||
case 'get_public_key':
|
case 'get_public_key':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: await user.getPublicKey(),
|
result: await userSigner.getPublicKey(),
|
||||||
});
|
});
|
||||||
case 'nip04_encrypt':
|
case 'nip04_encrypt':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
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':
|
case 'nip04_decrypt':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
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':
|
case 'nip44_encrypt':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
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':
|
case 'nip44_decrypt':
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
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:
|
default:
|
||||||
return this.sendResponse(event.pubkey, {
|
return this.sendResponse(pubkey, {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
result: '',
|
result: '',
|
||||||
error: `Unrecognized method: ${request.method}`,
|
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> {
|
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
|
||||||
const { user } = this.connection?.signers ?? {};
|
const { bunkerSigner, relay } = this.opts;
|
||||||
|
|
||||||
if (!user) {
|
const content = await bunkerSigner.nip04!.encrypt(pubkey, JSON.stringify(response));
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const event = await user.signEvent({
|
const event = await bunkerSigner.signEvent({
|
||||||
kind: 24133,
|
kind: 24133,
|
||||||
content: await user.nip04!.encrypt(pubkey, JSON.stringify(response)),
|
content,
|
||||||
tags: [['p', pubkey]],
|
tags: [['p', pubkey]],
|
||||||
created_at: Math.floor(Date.now() / 1000),
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.relay.event(event);
|
await relay.event(event);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
|
/** 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 {
|
try {
|
||||||
return await signer.nip44!.decrypt(pubkey, ciphertext);
|
return await bunkerSigner.nip44!.decrypt(pubkey, ciphertext);
|
||||||
} catch {
|
} 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();
|
this.controller.abort();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Symbol.asyncDispose](): void {
|
||||||
|
this.close();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -4,6 +4,7 @@ import { IntlProvider } from 'react-intl';
|
||||||
import { fetchMe } from 'soapbox/actions/me';
|
import { fetchMe } from 'soapbox/actions/me';
|
||||||
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
import { loadSoapboxConfig } from 'soapbox/actions/soapbox';
|
||||||
import { useBunker } from 'soapbox/api/hooks/nostr/useBunker';
|
import { useBunker } from 'soapbox/api/hooks/nostr/useBunker';
|
||||||
|
import { useSigner } from 'soapbox/api/hooks/nostr/useSigner';
|
||||||
import LoadingScreen from 'soapbox/components/loading-screen';
|
import LoadingScreen from 'soapbox/components/loading-screen';
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import {
|
import {
|
||||||
|
@ -44,10 +45,12 @@ const SoapboxLoad: React.FC<ISoapboxLoad> = ({ children }) => {
|
||||||
const [localeLoading, setLocaleLoading] = useState(true);
|
const [localeLoading, setLocaleLoading] = useState(true);
|
||||||
const [isLoaded, setIsLoaded] = useState(false);
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
|
||||||
const { hasNostr, isRelayOpen, signer } = useNostr();
|
const nostr = useNostr();
|
||||||
const { isSubscribed } = useBunker();
|
const signer = useSigner();
|
||||||
|
|
||||||
const nostrLoading = Boolean(hasNostr && signer && (!isRelayOpen || !isSubscribed));
|
const nostrLoading = Boolean(nostr.isRelayLoading || signer.isLoading);
|
||||||
|
|
||||||
|
useBunker();
|
||||||
|
|
||||||
/** Whether to display a loading indicator. */
|
/** Whether to display a loading indicator. */
|
||||||
const showLoading = [
|
const showLoading = [
|
||||||
|
|
Ładowanie…
Reference in New Issue