Rewrite everything (???)

This commit is contained in:
Alex Gleason 2024-10-27 19:35:41 -05:00
parent aec9043c9b
commit 14793ef0a9
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
4 changed files with 171 additions and 90 deletions

View File

@ -1,59 +1,86 @@
import { NSecSigner } from '@nostrify/nostrify';
import { nip19 } from 'nostr-tools';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context'; 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'; const secretStorageKey = 'soapbox:nip46:secret';
sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
function useSignerStream() { function useSignerStream() {
const { relay } = useNostr();
const [isSubscribed, setIsSubscribed] = useState(false); const [isSubscribed, setIsSubscribed] = useState(false);
const [isSubscribing, setIsSubscribing] = useState(true); const [isSubscribing, setIsSubscribing] = useState(true);
const { relay, signer, hasNostr } = useNostr(); const authorizations = useAppSelector((state) => state.bunker.authorizations);
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
const authStorageKey = `soapbox:nostr:auth:${pubkey}`; const connection = useAppSelector((state) => {
const accessToken = state.auth.tokens[state.auth.me!]?.access_token;
useEffect(() => { if (accessToken) {
let isCancelled = false; return state.bunker.connections.find((conn) => conn.accessToken === accessToken);
if (signer && hasNostr) {
signer.getPublicKey().then((newPubkey) => {
if (!isCancelled) {
setPubkey(newPubkey);
}
}).catch(console.warn);
} }
});
return () => {
isCancelled = true;
};
}, [signer, hasNostr]);
useEffect(() => { useEffect(() => {
if (!relay || !signer || !pubkey) return; if (!relay || (!connection && !authorizations.length)) return;
const bunker = new NBunker({ const bunker = new NBunker({
relay, 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) { onAuthorize(authorizedPubkey) {
localStorage.setItem(authStorageKey, authorizedPubkey);
sessionStorage.setItem(secretStorageKey, crypto.randomUUID()); sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
}, },
onSubscribed() { onSubscribed() {
setIsSubscribed(true); setIsSubscribed(true);
setIsSubscribing(false); setIsSubscribing(false);
}, },
authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined,
getSecret: () => sessionStorage.getItem(secretStorageKey)!,
}); });
return () => { return () => {
bunker.close(); bunker.close();
}; };
}, [relay, signer, pubkey]); }, [relay, connection, authorizations]);
return { return {
isSubscribed, isSubscribed,

View File

@ -3,124 +3,173 @@ import {
NostrConnectRequest, NostrConnectRequest,
NostrConnectResponse, NostrConnectResponse,
NostrEvent, NostrEvent,
NostrFilter,
NostrSigner, NostrSigner,
NSchema as n, NSchema as n,
} from '@nostrify/nostrify'; } 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; relay: NRelay;
signer: NostrSigner; connection?: NBunkerConnection;
authorizedPubkey: string | undefined; authorizations: NBunkerAuthorization[];
onAuthorize(pubkey: string): void; onAuthorize(pubkey: string): void;
onSubscribed(): void; onSubscribed(): void;
getSecret(): string;
} }
export class NBunker { export class NBunker {
private relay: NRelay; private relay: NRelay;
private signer: NostrSigner; private connection?: NBunkerConnection;
private authorizedPubkey: string | undefined; private authorizations: NBunkerAuthorization[];
private onAuthorize: (pubkey: string) => void; private onAuthorize: (pubkey: string) => void;
private onSubscribed: () => void; private onSubscribed: () => void;
private getSecret: () => string;
private controller = new AbortController(); private controller = new AbortController();
constructor(opts: NBunkerOpts) { constructor(opts: NBunkerOpts) {
this.relay = opts.relay; this.relay = opts.relay;
this.signer = opts.signer; this.connection = opts.connection;
this.authorizedPubkey = opts.authorizedPubkey; this.authorizations = opts.authorizations;
this.onAuthorize = opts.onAuthorize; this.onAuthorize = opts.onAuthorize;
this.onSubscribed = opts.onSubscribed; this.onSubscribed = opts.onSubscribed;
this.getSecret = opts.getSecret;
this.open(); this.open();
} }
async 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 signal = this.controller.signal;
const sub = this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal }); const filters: NostrFilter[] = [
this.onSubscribed(); { 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') { if (msg[0] === 'EVENT') {
const event = msg[2]; const [,, event] = msg;
this.handleEvent(event);
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> { private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
const decrypted = await this.decrypt(event.pubkey, event.content); const { authorizedPubkey, signers } = connection;
const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
if (!request.success) { const bunkerPubkey = await signers.bunker.getPublicKey();
console.warn(decrypted); const signal = this.controller.signal;
console.warn(request.error);
return; 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> { private async decryptRequest(event: NostrEvent, signers: NBunkerSigners): Promise<NostrConnectRequest> {
// Connect is a special case. Any pubkey can try to request it. const decrypted = await this.decrypt(signers.bunker, event.pubkey, event.content);
if (request.method === 'connect') { return n.json().pipe(n.connectRequest()).parse(decrypted);
return this.handleConnect(pubkey, request as NostrConnectRequest & { method: 'connect' }); }
}
private async handleRequest(event: NostrEvent, request: NostrConnectRequest, connection: NBunkerConnection): Promise<void> {
const { signers, authorizedPubkey } = connection;
const { user } = signers;
// Prevent unauthorized access. // Prevent unauthorized access.
if (pubkey !== this.authorizedPubkey) { if (event.pubkey !== authorizedPubkey) {
return; return;
} }
// Authorized methods. // Authorized methods.
switch (request.method) { switch (request.method) {
case 'sign_event': case 'sign_event':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, 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': case 'ping':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, id: request.id,
result: 'pong', result: 'pong',
}); });
case 'get_relays': case 'get_relays':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, id: request.id,
result: JSON.stringify(await this.signer.getRelays?.() ?? []), result: JSON.stringify(await user.getRelays?.() ?? []),
}); });
case 'get_public_key': case 'get_public_key':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, id: request.id,
result: await this.signer.getPublicKey(), result: await user.getPublicKey(),
}); });
case 'nip04_encrypt': case 'nip04_encrypt':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, 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': case 'nip04_decrypt':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, 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': case 'nip44_encrypt':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, 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': case 'nip44_decrypt':
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, 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: default:
return this.sendResponse(pubkey, { return this.sendResponse(event.pubkey, {
id: request.id, id: request.id,
result: '', result: '',
error: `Unrecognized method: ${request.method}`, error: `Unrecognized method: ${request.method}`,
@ -128,24 +177,29 @@ export class NBunker {
} }
} }
private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) { private async handleConnect(event: NostrEvent, request: NostrConnectRequest, authorization: NBunkerAuthorization): Promise<void> {
const [remotePubkey, secret] = request.params; const [, secret] = request.params;
if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) { if (secret === authorization.secret) {
this.authorizedPubkey = pubkey; this.onAuthorize(event.pubkey);
this.onAuthorize(pubkey);
await this.sendResponse(pubkey, { await this.sendResponse(event.pubkey, {
id: request.id, id: request.id,
result: 'ack', result: 'ack',
}); });
} }
} }
private async sendResponse(pubkey: string, response: NostrConnectResponse) { private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
const event = await this.signer.signEvent({ const { user } = this.connection?.signers ?? {};
if (!user) {
return;
}
const event = await user.signEvent({
kind: 24133, kind: 24133,
content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)), content: await user.nip04!.encrypt(pubkey, JSON.stringify(response)),
tags: [['p', pubkey]], tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000), created_at: Math.floor(Date.now() / 1000),
}); });
@ -154,11 +208,11 @@ export class NBunker {
} }
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */ /** 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 { try {
return await this.signer.nip44!.decrypt(pubkey, ciphertext); return await signer.nip44!.decrypt(pubkey, ciphertext);
} catch { } catch {
return await this.signer.nip04!.decrypt(pubkey, ciphertext); return await signer.nip04!.decrypt(pubkey, ciphertext);
} }
} }

View File

@ -13,7 +13,7 @@ interface BunkerAuthorization {
/** User pubkey. Events will be signed by this pubkey. */ /** User pubkey. Events will be signed by this pubkey. */
pubkey: string; pubkey: string;
/** Secret key for this connection. NIP-46 responses will be signed by this key. */ /** 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. */ /** Pubkey of the app authorized to sign events with this connection. */
authorizedPubkey: string; authorizedPubkey: string;
/** Secret key for this connection. NIP-46 responses will be signed by this key. */ /** Secret key for this connection. NIP-46 responses will be signed by this key. */
bunkerSeckey: Uint8Array; bunkerSeckey: `nsec1${string}`;
} }
export default createSlice({ export default createSlice({

View File

@ -7,6 +7,7 @@ import admin from './admin';
import aliases from './aliases'; import aliases from './aliases';
import auth from './auth'; import auth from './auth';
import backups from './backups'; import backups from './backups';
import bunker from './bunker';
import chat_message_lists from './chat-message-lists'; import chat_message_lists from './chat-message-lists';
import chat_messages from './chat-messages'; import chat_messages from './chat-messages';
import chats from './chats'; import chats from './chats';
@ -56,7 +57,7 @@ import trending_statuses from './trending-statuses';
import trends from './trends'; import trends from './trends';
import user_lists from './user-lists'; import user_lists from './user-lists';
const reducers = { export default combineReducers({
accounts_meta, accounts_meta,
admin, admin,
aliases, aliases,
@ -111,6 +112,5 @@ const reducers = {
trending_statuses, trending_statuses,
trends, trends,
user_lists, user_lists,
}; bunker: bunker.reducer,
});
export default combineReducers(reducers);