Move authorization out of bunkerStore, into loginNostr

This commit is contained in:
Alex Gleason 2024-10-28 23:09:45 -05:00
parent 1c1b56575c
commit 4d99a6d4f6
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
4 changed files with 39 additions and 160 deletions

View File

@ -1,4 +1,5 @@
import { NostrSigner, NRelay1 } from '@nostrify/nostrify';
import { NostrSigner, NRelay1, NSecSigner } from '@nostrify/nostrify';
import { generateSecretKey } from 'nostr-tools';
import { NostrRPC } from 'soapbox/features/nostr/NostrRPC';
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
@ -12,9 +13,9 @@ const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
/** Log in with a Nostr pubkey. */
function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
return async (dispatch: AppDispatch) => {
const authorization = generateBunkerAuth();
const pubkey = await signer.getPublicKey();
const bunker = useBunkerStore.getState();
const authorization = bunker.authorize(pubkey);
const bunkerPubkey = await authorization.signer.getPublicKey();
const rpc = new NostrRPC(relay, authorization.signer);
@ -51,16 +52,17 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
throw new Error('Authorization failed');
}
const { access_token } = dispatch(authLoggedIn(await tokenPromise));
const accessToken = dispatch(authLoggedIn(await tokenPromise)).access_token as string;
const bunkerState = useBunkerStore.getState();
useBunkerStore.getState().connect({
accessToken: access_token as string,
bunkerState.connect({
pubkey,
accessToken,
authorizedPubkey,
bunkerPubkey,
secret: authorization.secret,
bunkerSeckey: authorization.seckey,
});
await dispatch(verifyCredentials(access_token as string));
await dispatch(verifyCredentials(accessToken));
};
}
@ -74,6 +76,18 @@ function nostrExtensionLogIn(relay: NRelay1, signal: AbortSignal) {
};
}
/** Generate a bunker authorization object. */
function generateBunkerAuth() {
const secret = crypto.randomUUID();
const seckey = generateSecretKey();
return {
secret,
seckey,
signer: new NSecSigner(seckey),
};
}
function setNostrPubkey(pubkey: string | undefined) {
return {
type: NOSTR_PUBKEY_SET,

View File

@ -2,14 +2,14 @@ import { NSecSigner } from '@nostrify/nostrify';
import { useEffect, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NBunker, NBunkerOpts } 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() {
const { relay } = useNostr();
const { authorizations, connections } = useBunkerStore();
const { connections } = useBunkerStore();
const [isSubscribed, setIsSubscribed] = useState(false);
const [isSubscribing, setIsSubscribing] = useState(true);
@ -22,7 +22,7 @@ function useBunker() {
});
useEffect(() => {
if (!relay || (!connection && !authorizations.length)) return;
if (!relay || !connection) return;
const bunker = new NBunker({
relay,
@ -41,22 +41,6 @@ function useBunker() {
},
};
})(),
authorizations: authorizations.reduce((result, auth) => {
const { secret, pubkey, bunkerSeckey } = auth;
const user = NKeys.get(pubkey) ?? window.nostr;
if (!user) return result;
result.push({
secret,
signers: {
user,
bunker: new NSecSigner(bunkerSeckey),
},
});
return result;
}, [] as NBunkerOpts['authorizations']),
onSubscribed() {
setIsSubscribed(true);
setIsSubscribing(false);
@ -66,7 +50,7 @@ function useBunker() {
return () => {
bunker.close();
};
}, [relay, connection, authorizations]);
}, [relay, connection]);
return {
isSubscribed,

View File

@ -18,15 +18,9 @@ interface NBunkerConnection {
signers: NBunkerSigners;
}
interface NBunkerAuthorization {
secret: string;
signers: NBunkerSigners;
}
export interface NBunkerOpts {
relay: NRelay;
connection?: NBunkerConnection;
authorizations: NBunkerAuthorization[];
onSubscribed(): void;
}
@ -34,7 +28,6 @@ export class NBunker {
private relay: NRelay;
private connection?: NBunkerConnection;
private authorizations: NBunkerAuthorization[];
private onSubscribed: () => void;
private controller = new AbortController();
@ -42,7 +35,6 @@ export class NBunker {
constructor(opts: NBunkerOpts) {
this.relay = opts.relay;
this.connection = opts.connection;
this.authorizations = opts.authorizations;
this.onSubscribed = opts.onSubscribed;
this.open();
@ -52,27 +44,9 @@ export class NBunker {
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 filters: NostrFilter[] = [
{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 },
];
for await (const { event, request } of this.subscribe(filters, signers)) {
if (request.method === 'connect') {
this.handleConnect(event, request, authorization);
}
}
}
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
const { authorizedPubkey, signers } = connection;
const bunkerPubkey = await signers.bunker.getPublicKey();
@ -164,17 +138,6 @@ export class NBunker {
}
}
private async handleConnect(event: NostrEvent, request: NostrConnectRequest, authorization: NBunkerAuthorization): Promise<void> {
const [, secret] = request.params;
if (secret === authorization.secret) {
await this.sendResponse(event.pubkey, {
id: request.id,
result: 'ack',
});
}
}
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
const { user } = this.connection?.signers ?? {};

View File

@ -1,6 +1,6 @@
import { NSchema as n, NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { NSchema as n } from '@nostrify/nostrify';
import { produce } from 'immer';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { nip19 } from 'nostr-tools';
import { z } from 'zod';
import { create } from 'zustand';
// eslint-disable-next-line import/extensions
@ -8,22 +8,6 @@ import { persist } from 'zustand/middleware';
import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
/**
* Temporary authorization details to establish a bunker connection with an app.
* Will be upgraded to a `BunkerConnection` once the connection is established.
*/
interface BunkerAuthorization {
/**
* Authorization secret generated by the bunker.
* The app should return it to us in its `connect` call to establish a connection.
*/
secret: string;
/** 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;
}
/**
* A bunker connection maps an OAuth token from Mastodon API to a user pubkey and bunker keypair.
* The user pubkey is used to determine whether to use keys from localStorage or a browser extension,
@ -40,14 +24,6 @@ interface BunkerConnection {
bunkerSeckey: Uint8Array;
}
/** Options for connecting to the bunker. */
interface BunkerConnectRequest {
accessToken: string;
authorizedPubkey: string;
bunkerPubkey: string;
secret: string;
}
const connectionSchema = z.object({
pubkey: z.string(),
accessToken: z.string(),
@ -55,74 +31,21 @@ const connectionSchema = z.object({
bunkerSeckey: n.bech32('nsec'),
});
const authorizationSchema = z.object({
secret: z.string(),
pubkey: z.string(),
bunkerSeckey: n.bech32('nsec'),
});
const stateSchema = z.object({
connections: filteredArray(connectionSchema),
authorizations: filteredArray(authorizationSchema),
});
interface BunkerState {
connections: BunkerConnection[];
authorizations: BunkerAuthorization[];
authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string };
connect(request: BunkerConnectRequest): void;
connect(connection: BunkerConnection): void;
}
export const useBunkerStore = create<BunkerState>()(
persist(
(setState, getState) => ({
(setState) => ({
connections: [],
authorizations: [],
/** Generate a new authorization and persist it into the store. */
authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string } {
const authorization: BunkerAuthorization = {
pubkey,
secret: crypto.randomUUID(),
bunkerSeckey: generateSecretKey(),
};
setState((state) => {
return produce(state, (draft) => {
draft.authorizations.push(authorization);
});
});
return {
signer: new NSecSigner(authorization.bunkerSeckey),
secret: authorization.secret,
relays: [],
};
},
/** Connect to a bunker using the authorization secret. */
connect(request: BunkerConnectRequest): void {
const { authorizations } = getState();
const authorization = authorizations.find(
(existing) => existing.secret === request.secret && getPublicKey(existing.bunkerSeckey) === request.bunkerPubkey,
);
if (!authorization) {
throw new Error('Authorization not found');
}
const connection: BunkerConnection = {
pubkey: authorization.pubkey,
accessToken: request.accessToken,
authorizedPubkey: request.authorizedPubkey,
bunkerSeckey: authorization.bunkerSeckey,
};
connect(connection: BunkerConnection): void {
setState((state) => {
return produce(state, (draft) => {
draft.connections.push(connection);
draft.authorizations = draft.authorizations.filter((existing) => existing !== authorization);
});
});
},
@ -140,23 +63,18 @@ export const useBunkerStore = create<BunkerState>()(
name: 'soapbox:bunker',
storage: {
getItem(name) {
const connections = localStorage.getItem(`${name}:connections`);
const authorizations = sessionStorage.getItem(`${name}:authorizations`);
const connections = jsonSchema(nsecReviver)
.pipe(filteredArray(connectionSchema))
.catch([])
.parse(localStorage.getItem(name));
const state = stateSchema.parse({
connections: jsonSchema(nsecReviver).catch([]).parse(connections),
authorizations: jsonSchema(nsecReviver).catch([]).parse(authorizations),
});
return { state };
return { state: { connections } };
},
setItem(name, { state }) {
localStorage.setItem(`${name}:connections`, JSON.stringify(state.connections, nsecReplacer));
sessionStorage.setItem(`${name}:authorizations`, JSON.stringify(state.authorizations, nsecReplacer));
localStorage.setItem(name, JSON.stringify(state.connections, nsecReplacer));
},
removeItem(name) {
localStorage.removeItem(`${name}:connections`);
sessionStorage.removeItem(`${name}:authorizations`);
localStorage.removeItem(name);
},
},
},