Move authorization out of bunkerStore, into loginNostr
This commit is contained in:
parent
1c1b56575c
commit
4d99a6d4f6
|
@ -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 { NostrRPC } from 'soapbox/features/nostr/NostrRPC';
|
||||||
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
|
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
|
||||||
|
@ -12,9 +13,9 @@ const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
|
||||||
/** Log in with a Nostr pubkey. */
|
/** Log in with a Nostr pubkey. */
|
||||||
function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
|
function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
|
||||||
return async (dispatch: AppDispatch) => {
|
return async (dispatch: AppDispatch) => {
|
||||||
|
const authorization = generateBunkerAuth();
|
||||||
|
|
||||||
const pubkey = await signer.getPublicKey();
|
const pubkey = await signer.getPublicKey();
|
||||||
const bunker = useBunkerStore.getState();
|
|
||||||
const authorization = bunker.authorize(pubkey);
|
|
||||||
const bunkerPubkey = await authorization.signer.getPublicKey();
|
const bunkerPubkey = await authorization.signer.getPublicKey();
|
||||||
|
|
||||||
const rpc = new NostrRPC(relay, authorization.signer);
|
const rpc = new NostrRPC(relay, authorization.signer);
|
||||||
|
@ -51,16 +52,17 @@ function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
|
||||||
throw new Error('Authorization failed');
|
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({
|
bunkerState.connect({
|
||||||
accessToken: access_token as string,
|
pubkey,
|
||||||
|
accessToken,
|
||||||
authorizedPubkey,
|
authorizedPubkey,
|
||||||
bunkerPubkey,
|
bunkerSeckey: authorization.seckey,
|
||||||
secret: authorization.secret,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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) {
|
function setNostrPubkey(pubkey: string | undefined) {
|
||||||
return {
|
return {
|
||||||
type: NOSTR_PUBKEY_SET,
|
type: NOSTR_PUBKEY_SET,
|
||||||
|
|
|
@ -2,14 +2,14 @@ import { NSecSigner } from '@nostrify/nostrify';
|
||||||
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, NBunkerOpts } from 'soapbox/features/nostr/NBunker';
|
import { NBunker } from 'soapbox/features/nostr/NBunker';
|
||||||
import { NKeys } from 'soapbox/features/nostr/keys';
|
import { NKeys } from 'soapbox/features/nostr/keys';
|
||||||
import { useAppSelector } from 'soapbox/hooks';
|
import { useAppSelector } from 'soapbox/hooks';
|
||||||
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
|
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
|
||||||
|
|
||||||
function useBunker() {
|
function useBunker() {
|
||||||
const { relay } = useNostr();
|
const { relay } = useNostr();
|
||||||
const { authorizations, connections } = useBunkerStore();
|
const { connections } = useBunkerStore();
|
||||||
|
|
||||||
const [isSubscribed, setIsSubscribed] = useState(false);
|
const [isSubscribed, setIsSubscribed] = useState(false);
|
||||||
const [isSubscribing, setIsSubscribing] = useState(true);
|
const [isSubscribing, setIsSubscribing] = useState(true);
|
||||||
|
@ -22,7 +22,7 @@ function useBunker() {
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relay || (!connection && !authorizations.length)) return;
|
if (!relay || !connection) return;
|
||||||
|
|
||||||
const bunker = new NBunker({
|
const bunker = new NBunker({
|
||||||
relay,
|
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() {
|
onSubscribed() {
|
||||||
setIsSubscribed(true);
|
setIsSubscribed(true);
|
||||||
setIsSubscribing(false);
|
setIsSubscribing(false);
|
||||||
|
@ -66,7 +50,7 @@ function useBunker() {
|
||||||
return () => {
|
return () => {
|
||||||
bunker.close();
|
bunker.close();
|
||||||
};
|
};
|
||||||
}, [relay, connection, authorizations]);
|
}, [relay, connection]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isSubscribed,
|
isSubscribed,
|
||||||
|
|
|
@ -18,15 +18,9 @@ interface NBunkerConnection {
|
||||||
signers: NBunkerSigners;
|
signers: NBunkerSigners;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NBunkerAuthorization {
|
|
||||||
secret: string;
|
|
||||||
signers: NBunkerSigners;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NBunkerOpts {
|
export interface NBunkerOpts {
|
||||||
relay: NRelay;
|
relay: NRelay;
|
||||||
connection?: NBunkerConnection;
|
connection?: NBunkerConnection;
|
||||||
authorizations: NBunkerAuthorization[];
|
|
||||||
onSubscribed(): void;
|
onSubscribed(): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -34,7 +28,6 @@ export class NBunker {
|
||||||
|
|
||||||
private relay: NRelay;
|
private relay: NRelay;
|
||||||
private connection?: NBunkerConnection;
|
private connection?: NBunkerConnection;
|
||||||
private authorizations: NBunkerAuthorization[];
|
|
||||||
private onSubscribed: () => void;
|
private onSubscribed: () => void;
|
||||||
|
|
||||||
private controller = new AbortController();
|
private controller = new AbortController();
|
||||||
|
@ -42,7 +35,6 @@ export class NBunker {
|
||||||
constructor(opts: NBunkerOpts) {
|
constructor(opts: NBunkerOpts) {
|
||||||
this.relay = opts.relay;
|
this.relay = opts.relay;
|
||||||
this.connection = opts.connection;
|
this.connection = opts.connection;
|
||||||
this.authorizations = opts.authorizations;
|
|
||||||
this.onSubscribed = opts.onSubscribed;
|
this.onSubscribed = opts.onSubscribed;
|
||||||
|
|
||||||
this.open();
|
this.open();
|
||||||
|
@ -52,27 +44,9 @@ export class NBunker {
|
||||||
if (this.connection) {
|
if (this.connection) {
|
||||||
this.subscribeConnection(this.connection);
|
this.subscribeConnection(this.connection);
|
||||||
}
|
}
|
||||||
for (const authorization of this.authorizations) {
|
|
||||||
this.subscribeAuthorization(authorization);
|
|
||||||
}
|
|
||||||
this.onSubscribed();
|
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> {
|
private async subscribeConnection(connection: NBunkerConnection): Promise<void> {
|
||||||
const { authorizedPubkey, signers } = connection;
|
const { authorizedPubkey, signers } = connection;
|
||||||
const bunkerPubkey = await signers.bunker.getPublicKey();
|
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> {
|
private async sendResponse(pubkey: string, response: NostrConnectResponse): Promise<void> {
|
||||||
const { user } = this.connection?.signers ?? {};
|
const { user } = this.connection?.signers ?? {};
|
||||||
|
|
||||||
|
|
|
@ -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 { produce } from 'immer';
|
||||||
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
|
import { nip19 } from 'nostr-tools';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
// eslint-disable-next-line import/extensions
|
// eslint-disable-next-line import/extensions
|
||||||
|
@ -8,22 +8,6 @@ import { persist } from 'zustand/middleware';
|
||||||
|
|
||||||
import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
|
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.
|
* 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,
|
* 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;
|
bunkerSeckey: Uint8Array;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Options for connecting to the bunker. */
|
|
||||||
interface BunkerConnectRequest {
|
|
||||||
accessToken: string;
|
|
||||||
authorizedPubkey: string;
|
|
||||||
bunkerPubkey: string;
|
|
||||||
secret: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const connectionSchema = z.object({
|
const connectionSchema = z.object({
|
||||||
pubkey: z.string(),
|
pubkey: z.string(),
|
||||||
accessToken: z.string(),
|
accessToken: z.string(),
|
||||||
|
@ -55,74 +31,21 @@ const connectionSchema = z.object({
|
||||||
bunkerSeckey: n.bech32('nsec'),
|
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 {
|
interface BunkerState {
|
||||||
connections: BunkerConnection[];
|
connections: BunkerConnection[];
|
||||||
authorizations: BunkerAuthorization[];
|
connect(connection: BunkerConnection): void;
|
||||||
authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string };
|
|
||||||
connect(request: BunkerConnectRequest): void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useBunkerStore = create<BunkerState>()(
|
export const useBunkerStore = create<BunkerState>()(
|
||||||
persist(
|
persist(
|
||||||
(setState, getState) => ({
|
(setState) => ({
|
||||||
connections: [],
|
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 to a bunker using the authorization secret. */
|
||||||
connect(request: BunkerConnectRequest): void {
|
connect(connection: BunkerConnection): 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
setState((state) => {
|
setState((state) => {
|
||||||
return produce(state, (draft) => {
|
return produce(state, (draft) => {
|
||||||
draft.connections.push(connection);
|
draft.connections.push(connection);
|
||||||
draft.authorizations = draft.authorizations.filter((existing) => existing !== authorization);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@ -140,23 +63,18 @@ export const useBunkerStore = create<BunkerState>()(
|
||||||
name: 'soapbox:bunker',
|
name: 'soapbox:bunker',
|
||||||
storage: {
|
storage: {
|
||||||
getItem(name) {
|
getItem(name) {
|
||||||
const connections = localStorage.getItem(`${name}:connections`);
|
const connections = jsonSchema(nsecReviver)
|
||||||
const authorizations = sessionStorage.getItem(`${name}:authorizations`);
|
.pipe(filteredArray(connectionSchema))
|
||||||
|
.catch([])
|
||||||
|
.parse(localStorage.getItem(name));
|
||||||
|
|
||||||
const state = stateSchema.parse({
|
return { state: { connections } };
|
||||||
connections: jsonSchema(nsecReviver).catch([]).parse(connections),
|
|
||||||
authorizations: jsonSchema(nsecReviver).catch([]).parse(authorizations),
|
|
||||||
});
|
|
||||||
|
|
||||||
return { state };
|
|
||||||
},
|
},
|
||||||
setItem(name, { state }) {
|
setItem(name, { state }) {
|
||||||
localStorage.setItem(`${name}:connections`, JSON.stringify(state.connections, nsecReplacer));
|
localStorage.setItem(name, JSON.stringify(state.connections, nsecReplacer));
|
||||||
sessionStorage.setItem(`${name}:authorizations`, JSON.stringify(state.authorizations, nsecReplacer));
|
|
||||||
},
|
},
|
||||||
removeItem(name) {
|
removeItem(name) {
|
||||||
localStorage.removeItem(`${name}:connections`);
|
localStorage.removeItem(name);
|
||||||
sessionStorage.removeItem(`${name}:authorizations`);
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in New Issue