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 { 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}`;
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);
}
});
useEffect(() => {
let isCancelled = false;
if (signer && hasNostr) {
signer.getPublicKey().then((newPubkey) => {
if (!isCancelled) {
setPubkey(newPubkey);
}
}).catch(console.warn);
}
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,

View File

@ -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;
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);
try {
const request = await this.decryptRequest(event, signers);
if (!request.success) {
console.warn(decrypted);
console.warn(request.error);
return;
}
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' });
this.handleConnect(event, request, authorization);
}
} catch (error) {
console.warn(error);
}
}
}
}
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
const { authorizedPubkey, signers } = connection;
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);
}
}
}
}
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);
}
}

View File

@ -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({

View File

@ -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,
});