Rewrite everything (???)
This commit is contained in:
parent
aec9043c9b
commit
14793ef0a9
|
@ -1,59 +1,86 @@
|
|||
import { NSecSigner } from '@nostrify/nostrify';
|
||||
import { nip19 } from 'nostr-tools';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||
import { NBunker } from 'soapbox/features/nostr/NBunker';
|
||||
import { NBunker, NBunkerOpts } from 'soapbox/features/nostr/NBunker';
|
||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
||||
import { useAppSelector } from 'soapbox/hooks';
|
||||
|
||||
const secretStorageKey = 'soapbox:nip46:secret';
|
||||
|
||||
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
|
||||
|
||||
function useSignerStream() {
|
||||
const { relay } = useNostr();
|
||||
|
||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||
const [isSubscribing, setIsSubscribing] = useState(true);
|
||||
|
||||
const { relay, signer, hasNostr } = useNostr();
|
||||
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
|
||||
const authorizations = useAppSelector((state) => state.bunker.authorizations);
|
||||
|
||||
const authStorageKey = `soapbox:nostr:auth:${pubkey}`;
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
if (signer && hasNostr) {
|
||||
signer.getPublicKey().then((newPubkey) => {
|
||||
if (!isCancelled) {
|
||||
setPubkey(newPubkey);
|
||||
}
|
||||
}).catch(console.warn);
|
||||
const connection = useAppSelector((state) => {
|
||||
const accessToken = state.auth.tokens[state.auth.me!]?.access_token;
|
||||
if (accessToken) {
|
||||
return state.bunker.connections.find((conn) => conn.accessToken === accessToken);
|
||||
}
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
};
|
||||
}, [signer, hasNostr]);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!relay || !signer || !pubkey) return;
|
||||
if (!relay || (!connection && !authorizations.length)) return;
|
||||
|
||||
const bunker = new NBunker({
|
||||
relay,
|
||||
signer,
|
||||
connection: (() => {
|
||||
if (!connection) return;
|
||||
const { authorizedPubkey, bunkerSeckey, pubkey } = connection;
|
||||
|
||||
const user = NKeys.get(pubkey) ?? window.nostr;
|
||||
if (!user) return;
|
||||
|
||||
const decoded = nip19.decode(bunkerSeckey);
|
||||
if (decoded.type !== 'nsec') return;
|
||||
|
||||
return {
|
||||
authorizedPubkey,
|
||||
signers: {
|
||||
user,
|
||||
bunker: new NSecSigner(decoded.data),
|
||||
},
|
||||
};
|
||||
})(),
|
||||
authorizations: authorizations.reduce((result, auth) => {
|
||||
const { secret, pubkey, bunkerSeckey } = auth;
|
||||
|
||||
const user = NKeys.get(pubkey) ?? window.nostr;
|
||||
if (!user) return result;
|
||||
|
||||
const decoded = nip19.decode(bunkerSeckey);
|
||||
if (decoded.type !== 'nsec') return result;
|
||||
|
||||
result.push({
|
||||
secret,
|
||||
signers: {
|
||||
user,
|
||||
bunker: new NSecSigner(decoded.data),
|
||||
},
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [] as NBunkerOpts['authorizations']),
|
||||
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]);
|
||||
}, [relay, connection, authorizations]);
|
||||
|
||||
return {
|
||||
isSubscribed,
|
||||
|
|
|
@ -3,124 +3,173 @@ import {
|
|||
NostrConnectRequest,
|
||||
NostrConnectResponse,
|
||||
NostrEvent,
|
||||
NostrFilter,
|
||||
NostrSigner,
|
||||
NSchema as n,
|
||||
} from '@nostrify/nostrify';
|
||||
|
||||
interface NBunkerOpts {
|
||||
interface NBunkerSigners {
|
||||
user: NostrSigner;
|
||||
bunker: NostrSigner;
|
||||
}
|
||||
|
||||
interface NBunkerConnection {
|
||||
authorizedPubkey: string;
|
||||
signers: NBunkerSigners;
|
||||
}
|
||||
|
||||
interface NBunkerAuthorization {
|
||||
secret: string;
|
||||
signers: NBunkerSigners;
|
||||
}
|
||||
|
||||
export interface NBunkerOpts {
|
||||
relay: NRelay;
|
||||
signer: NostrSigner;
|
||||
authorizedPubkey: string | undefined;
|
||||
connection?: NBunkerConnection;
|
||||
authorizations: NBunkerAuthorization[];
|
||||
onAuthorize(pubkey: string): void;
|
||||
onSubscribed(): void;
|
||||
getSecret(): string;
|
||||
}
|
||||
|
||||
export class NBunker {
|
||||
|
||||
private relay: NRelay;
|
||||
private signer: NostrSigner;
|
||||
private authorizedPubkey: string | undefined;
|
||||
private connection?: NBunkerConnection;
|
||||
private authorizations: NBunkerAuthorization[];
|
||||
private onAuthorize: (pubkey: string) => void;
|
||||
private onSubscribed: () => void;
|
||||
private getSecret: () => string;
|
||||
|
||||
private controller = new AbortController();
|
||||
|
||||
constructor(opts: NBunkerOpts) {
|
||||
this.relay = opts.relay;
|
||||
this.signer = opts.signer;
|
||||
this.authorizedPubkey = opts.authorizedPubkey;
|
||||
this.connection = opts.connection;
|
||||
this.authorizations = opts.authorizations;
|
||||
this.onAuthorize = opts.onAuthorize;
|
||||
this.onSubscribed = opts.onSubscribed;
|
||||
this.getSecret = opts.getSecret;
|
||||
|
||||
this.open();
|
||||
}
|
||||
|
||||
async open() {
|
||||
const pubkey = await this.signer.getPublicKey();
|
||||
if (this.connection) {
|
||||
this.subscribeConnection(this.connection);
|
||||
}
|
||||
for (const authorization of this.authorizations) {
|
||||
this.subscribeAuthorization(authorization);
|
||||
}
|
||||
this.onSubscribed();
|
||||
}
|
||||
|
||||
private async subscribeAuthorization(authorization: NBunkerAuthorization): Promise<void> {
|
||||
const { signers } = authorization;
|
||||
|
||||
const bunkerPubkey = await signers.bunker.getPublicKey();
|
||||
const signal = this.controller.signal;
|
||||
|
||||
const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal });
|
||||
this.onSubscribed();
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
|
||||
];
|
||||
|
||||
for await (const msg of sub) {
|
||||
for await (const msg of this.relay.req(filters, { signal })) {
|
||||
if (msg[0] === 'EVENT') {
|
||||
const event = msg[2];
|
||||
this.handleEvent(event);
|
||||
const [,, event] = msg;
|
||||
|
||||
try {
|
||||
const request = await this.decryptRequest(event, signers);
|
||||
|
||||
if (request.method === 'connect') {
|
||||
this.handleConnect(event, request, authorization);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleEvent(event: NostrEvent): Promise<void> {
|
||||
const decrypted = await this.decrypt(event.pubkey, event.content);
|
||||
const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
|
||||
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
|
||||
const { authorizedPubkey, signers } = connection;
|
||||
|
||||
if (!request.success) {
|
||||
console.warn(decrypted);
|
||||
console.warn(request.error);
|
||||
return;
|
||||
const bunkerPubkey = await signers.bunker.getPublicKey();
|
||||
const signal = this.controller.signal;
|
||||
|
||||
const filters: NostrFilter[] = [
|
||||
{ kinds: [24133], authors: [authorizedPubkey], '#p': [bunkerPubkey], limit: 0 },
|
||||
];
|
||||
|
||||
for await (const msg of this.relay.req(filters, { signal })) {
|
||||
if (msg[0] === 'EVENT') {
|
||||
const [,, event] = msg;
|
||||
|
||||
try {
|
||||
const request = await this.decryptRequest(event, signers);
|
||||
this.handleRequest(event, request, connection);
|
||||
} catch (error) {
|
||||
console.warn(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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' });
|
||||
}
|
||||
private async decryptRequest(event: NostrEvent, signers: NBunkerSigners): Promise<NostrConnectRequest> {
|
||||
const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content);
|
||||
return n.json().pipe(n.connectRequest()).parse(decrypted);
|
||||
}
|
||||
|
||||
private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise<void> {
|
||||
const { signers, authorizedPubkey } = connection;
|
||||
const { user } = signers;
|
||||
|
||||
// Prevent unauthorized access.
|
||||
if (pubkey !== this.authorizedPubkey) {
|
||||
if (event.pubkey !== authorizedPubkey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Authorized methods.
|
||||
switch (request.method) {
|
||||
case 'sign_event':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))),
|
||||
result: JSON.stringify(await user.signEvent(JSON.parse(request.params[0]))),
|
||||
});
|
||||
case 'ping':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: 'pong',
|
||||
});
|
||||
case 'get_relays':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: JSON.stringify(await this.signer.getRelays?.() ?? []),
|
||||
result: JSON.stringify(await user.getRelays?.() ?? []),
|
||||
});
|
||||
case 'get_public_key':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: await this.signer.getPublicKey(),
|
||||
result: await user.getPublicKey(),
|
||||
});
|
||||
case 'nip04_encrypt':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]),
|
||||
result: await user.nip04!.encrypt(request.params[0], request.params[1]),
|
||||
});
|
||||
case 'nip04_decrypt':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]),
|
||||
result: await user.nip04!.decrypt(request.params[0], request.params[1]),
|
||||
});
|
||||
case 'nip44_encrypt':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]),
|
||||
result: await user.nip44!.encrypt(request.params[0], request.params[1]),
|
||||
});
|
||||
case 'nip44_decrypt':
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]),
|
||||
result: await user.nip44!.decrypt(request.params[0], request.params[1]),
|
||||
});
|
||||
default:
|
||||
return this.sendResponse(pubkey, {
|
||||
return this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: '',
|
||||
error: `Unrecognized method: ${request.method}`,
|
||||
|
@ -128,24 +177,29 @@ export class NBunker {
|
|||
}
|
||||
}
|
||||
|
||||
private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) {
|
||||
const [remotePubkey, secret] = request.params;
|
||||
private async handleConnect(event: NostrEvent, request: NostrConnectRequest, authorization: NBunkerAuthorization): Promise<void> {
|
||||
const [, secret] = request.params;
|
||||
|
||||
if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
|
||||
this.authorizedPubkey = pubkey;
|
||||
this.onAuthorize(pubkey);
|
||||
if (secret === authorization.secret) {
|
||||
this.onAuthorize(event.pubkey);
|
||||
|
||||
await this.sendResponse(pubkey, {
|
||||
await this.sendResponse(event.pubkey, {
|
||||
id: request.id,
|
||||
result: 'ack',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async sendResponse(pubkey: string, response: NostrConnectResponse) {
|
||||
const event = await this.signer.signEvent({
|
||||
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
|
||||
const { user } = this.connection?.signers ?? {};
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
const event = await user.signEvent({
|
||||
kind: 24133,
|
||||
content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
|
||||
content: await user.nip04!.encrypt(pubkey, JSON.stringify(response)),
|
||||
tags: [['p', pubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
@ -154,11 +208,11 @@ export class NBunker {
|
|||
}
|
||||
|
||||
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
|
||||
private async decrypt(pubkey: string, ciphertext: string): Promise<string> {
|
||||
private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise<string> {
|
||||
try {
|
||||
return await this.signer.nip44!.decrypt(pubkey, ciphertext);
|
||||
return await signer.nip44!.decrypt(pubkey, ciphertext);
|
||||
} catch {
|
||||
return await this.signer.nip04!.decrypt(pubkey, ciphertext);
|
||||
return await signer.nip04!.decrypt(pubkey, ciphertext);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -13,7 +13,7 @@ interface BunkerAuthorization {
|
|||
/** User pubkey. Events will be signed by this pubkey. */
|
||||
pubkey: string;
|
||||
/** Secret key for this connection. NIP-46 responses will be signed by this key. */
|
||||
bunkerSeckey: Uint8Array;
|
||||
bunkerSeckey: `nsec1${string}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -29,7 +29,7 @@ interface BunkerConnection {
|
|||
/** Pubkey of the app authorized to sign events with this connection. */
|
||||
authorizedPubkey: string;
|
||||
/** Secret key for this connection. NIP-46 responses will be signed by this key. */
|
||||
bunkerSeckey: Uint8Array;
|
||||
bunkerSeckey: `nsec1${string}`;
|
||||
}
|
||||
|
||||
export default createSlice({
|
||||
|
|
|
@ -7,6 +7,7 @@ import admin from './admin';
|
|||
import aliases from './aliases';
|
||||
import auth from './auth';
|
||||
import backups from './backups';
|
||||
import bunker from './bunker';
|
||||
import chat_message_lists from './chat-message-lists';
|
||||
import chat_messages from './chat-messages';
|
||||
import chats from './chats';
|
||||
|
@ -56,7 +57,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 +112,5 @@ const reducers = {
|
|||
trending_statuses,
|
||||
trends,
|
||||
user_lists,
|
||||
};
|
||||
|
||||
export default combineReducers(reducers);
|
||||
bunker: bunker.reducer,
|
||||
});
|
Loading…
Reference in New Issue