OKAY IT'S WORKING

This commit is contained in:
Alex Gleason 2024-10-28 20:20:37 -05:00
parent 333d3e76d6
commit 5c4b73f943
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
12 changed files with 189 additions and 63 deletions

View File

@ -1,4 +1,8 @@
import { RootState, type AppDispatch } from 'soapbox/store';
import { NostrSigner, NRelay1 } from '@nostrify/nostrify';
import { NostrRPC } from 'soapbox/features/nostr/NostrRPC';
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
import { type AppDispatch } from 'soapbox/store';
import { authLoggedIn, verifyCredentials } from './auth';
import { obtainOAuthToken } from './oauth';
@ -6,42 +10,67 @@ import { obtainOAuthToken } from './oauth';
const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
/** Log in with a Nostr pubkey. */
function logInNostr(pubkey: string) {
return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(setNostrPubkey(pubkey));
function logInNostr(signer: NostrSigner, relay: NRelay1, signal: AbortSignal) {
return async (dispatch: AppDispatch) => {
const pubkey = await signer.getPublicKey();
const bunker = useBunkerStore.getState();
const authorization = bunker.authorize(pubkey);
const bunkerPubkey = await authorization.signer.getPublicKey();
const secret = sessionStorage.getItem('soapbox:nip46:secret');
if (!secret) {
throw new Error('No secret found in session storage');
}
const rpc = new NostrRPC(relay, authorization.signer);
const sub = rpc.req([{ kinds: [24133], '#p': [bunkerPubkey], limit: 0 }], { signal: AbortSignal.timeout(1_000) });
const relay = getState().instance.nostr?.relay;
// HACK: waits 1 second to ensure the relay subscription is open
await new Promise((resolve) => setTimeout(resolve, 1000));
const token = await dispatch(obtainOAuthToken({
const tokenPromise = dispatch(obtainOAuthToken({
grant_type: 'nostr_bunker',
pubkey,
relays: relay ? [relay] : undefined,
secret,
pubkey: bunkerPubkey,
relays: [relay.socket.url],
secret: authorization.secret,
}));
const { access_token } = dispatch(authLoggedIn(token));
await dispatch(verifyCredentials(access_token as string));
let authorizedPubkey: string | undefined;
dispatch(setNostrPubkey(undefined));
for await (const { request, respond, requestEvent } of sub) {
if (request.method === 'connect') {
const [, secret] = request.params;
if (secret === authorization.secret) {
authorizedPubkey = requestEvent.pubkey;
await respond({ result: 'ack' });
} else {
await respond({ result: '', error: 'Invalid secret' });
throw new Error('Invalid secret');
}
}
if (request.method === 'get_public_key') {
await respond({ result: pubkey });
break;
}
}
if (!authorizedPubkey) {
throw new Error('Authorization failed');
}
const { access_token } = dispatch(authLoggedIn(await tokenPromise));
useBunkerStore.getState().connect({
accessToken: access_token as string,
authorizedPubkey,
bunkerPubkey,
secret: authorization.secret,
});
await dispatch(verifyCredentials(access_token as string));
};
}
/** Log in with a Nostr extension. */
function nostrExtensionLogIn() {
function nostrExtensionLogIn(relay: NRelay1, signal: AbortSignal) {
return async (dispatch: AppDispatch) => {
if (!window.nostr) {
throw new Error('No Nostr signer available');
}
const pubkey = await window.nostr.getPublicKey();
return dispatch(logInNostr(pubkey));
return dispatch(logInNostr(window.nostr, relay, signal));
};
}

View File

@ -0,0 +1,34 @@
import { useQuery } from '@tanstack/react-query';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppSelector } from 'soapbox/hooks';
import { useBunkerStore } from 'soapbox/hooks/useBunkerStore';
export function useSigner() {
const { connections } = useBunkerStore();
const pubkey = useAppSelector(({ auth }) => {
const accessToken = auth.me ? auth.tokens[auth.me]?.access_token : undefined;
if (accessToken) {
return connections.find((conn) => conn.accessToken === accessToken)?.pubkey;
}
});
const { data: signer, ...rest } = useQuery({
queryKey: ['nostr', 'signer', pubkey],
queryFn: async () => {
if (!pubkey) return null;
const signer = NKeys.get(pubkey);
if (signer) return signer;
if (window.nostr && await window.nostr.getPublicKey() === pubkey) {
return window.nostr;
}
return null;
},
});
return { signer: signer ?? undefined, ...rest };
}

View File

@ -1,12 +1,11 @@
import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify';
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { NRelay1, NostrSigner } from '@nostrify/nostrify';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppSelector } from 'soapbox/hooks';
import { useSigner } from 'soapbox/api/hooks/nostr/useSigner';
import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay;
relay?: NRelay1;
signer?: NostrSigner;
hasNostr: boolean;
isRelayOpen: boolean;
@ -20,18 +19,14 @@ interface NostrProviderProps {
export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const { instance } = useInstance();
const { signer } = useSigner();
const hasNostr = !!instance.nostr;
const [relay, setRelay] = useState<NRelay1>();
const [isRelayOpen, setIsRelayOpen] = useState(false);
const url = instance.nostr?.relay;
const accountPubkey = useAppSelector(({ meta, auth }) => meta.pubkey ?? auth.users[auth.me!]?.id);
const signer = useMemo(
() => accountPubkey ? NKeys.get(accountPubkey) ?? window.nostr : undefined,
[accountPubkey, window.nostr],
);
const handleRelayOpen = () => {
setIsRelayOpen(true);

View File

@ -1,4 +1,4 @@
import { produce, enableMapSet } from 'immer';
import { produce } from 'immer';
import {
ENTITIES_IMPORT,
@ -17,8 +17,6 @@ import { createCache, createList, updateStore, updateList } from './utils';
import type { DeleteEntitiesOpts } from './actions';
import type { EntitiesTransaction, Entity, EntityCache, EntityListState, ImportPosition } from './types';
enableMapSet();
/** Entity reducer state. */
interface State {
[entityType: string]: EntityCache | undefined;

View File

@ -96,8 +96,6 @@ const SearchZapSplit = (props: ISearchZapSplit) => {
const getAccount = (accountId: string) => (dispatch: any, getState: () => RootState) => {
const account = selectAccount(getState(), accountId);
console.log(account);
props.onChange(account!);
};

View File

@ -0,0 +1,66 @@
import {
NRelay,
NostrConnectRequest,
NostrConnectResponse,
NostrEvent,
NostrFilter,
NostrSigner,
NSchema as n,
} from '@nostrify/nostrify';
export class NostrRPC {
constructor(
private relay: NRelay,
private signer: NostrSigner,
) {}
async *req(
filters: NostrFilter[],
opts: { signal?: AbortSignal },
): AsyncIterable<{
requestEvent: NostrEvent;
request: NostrConnectRequest;
respond: (response: Omit<NostrConnectResponse, 'id'>) => Promise<void>;
}> {
for await (const msg of this.relay.req(filters, opts)) {
if (msg[0] === 'EVENT') {
const [,, requestEvent] = msg;
const decrypted = await this.decrypt(this.signer, requestEvent.pubkey, requestEvent.content);
const request = n.json().pipe(n.connectRequest()).parse(decrypted);
const respond = async (response: Omit<NostrConnectResponse, 'id'>): Promise<void> => {
await this.respond(requestEvent, { ...response, id: request.id });
};
yield { requestEvent, request, respond };
}
if (msg[0] === 'CLOSED') {
break;
}
}
}
private async respond(requestEvent: NostrEvent, response: NostrConnectResponse): Promise<void> {
const responseEvent = await this.signer.signEvent({
kind: 24133,
content: await this.signer.nip04!.encrypt(requestEvent.pubkey, JSON.stringify(response)),
tags: [['p', requestEvent.pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
await this.relay.event(responseEvent);
}
/** Auto-decrypt NIP-44 or NIP-04 ciphertext. */
private async decrypt(signer: NostrSigner, pubkey: string, ciphertext: string): Promise<string> {
try {
return await signer.nip44!.decrypt(pubkey, ciphertext);
} catch {
return await signer.nip04!.decrypt(pubkey, ciphertext);
}
}
}

View File

@ -5,14 +5,18 @@ import { closeModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import Stack from 'soapbox/components/ui/stack/stack';
import Text from 'soapbox/components/ui/text/text';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch } from 'soapbox/hooks';
const NostrExtensionIndicator: React.FC = () => {
const dispatch = useAppDispatch();
const { relay } = useNostr();
const onClick = () => {
dispatch(nostrExtensionLogIn());
dispatch(closeModal());
if (relay) {
dispatch(nostrExtensionLogIn(relay, AbortSignal.timeout(30_000)));
dispatch(closeModal());
}
};
function renderBody(): React.ReactNode {

View File

@ -6,6 +6,7 @@ import { openModal } from 'soapbox/actions/modals';
import { nostrExtensionLogIn } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Text, Divider, HStack } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { useAppDispatch, useInstance, useSoapboxConfig } from 'soapbox/hooks';
interface IExtensionStep {
@ -18,6 +19,7 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
const dispatch = useAppDispatch();
const { instance } = useInstance();
const { logo } = useSoapboxConfig();
const { relay } = useNostr();
const handleClose = () => {
onClose();
@ -25,8 +27,10 @@ const ExtensionStep: React.FC<IExtensionStep> = ({ isLogin, onClickAlt, onClose
};
const onClick = () => {
dispatch(nostrExtensionLogIn());
onClose();
if (relay) {
dispatch(nostrExtensionLogIn(relay, AbortSignal.timeout(30_000)));
onClose();
}
};
return (

View File

@ -5,6 +5,7 @@ import { FormattedMessage } from 'react-intl';
import { logInNostr } from 'soapbox/actions/nostr';
import EmojiGraphic from 'soapbox/components/emoji-graphic';
import { Button, Stack, Modal, Input, FormGroup, Form, Divider } from 'soapbox/components/ui';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useAppDispatch } from 'soapbox/hooks';
@ -19,6 +20,7 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
const [error, setError] = useState<string | undefined>();
const dispatch = useAppDispatch();
const { relay } = useNostr();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setNsec(e.target.value);
@ -26,13 +28,13 @@ const KeyAddStep: React.FC<IKeyAddStep> = ({ onClose }) => {
};
const handleSubmit = async () => {
if (!relay) return;
try {
const result = nip19.decode(nsec);
if (result.type === 'nsec') {
const seckey = result.data;
const signer = NKeys.add(seckey);
const pubkey = await signer.getPublicKey();
dispatch(logInNostr(pubkey));
dispatch(logInNostr(signer, relay, AbortSignal.timeout(30_000)));
onClose();
return;
}

View File

@ -43,8 +43,9 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
};
const handleNext = async () => {
if (!relay) return;
const signer = NKeys.add(secretKey);
const pubkey = await signer.getPublicKey();
const now = Math.floor(Date.now() / 1000);
const [kind0, ...events] = await Promise.all([
@ -57,12 +58,12 @@ const KeygenStep: React.FC<IKeygenStep> = ({ onClose }) => {
signer.signEvent({ kind: 30078, content: '', tags: [['d', 'pub.ditto.pleroma_settings_store']], created_at: now }),
]);
await relay?.event(kind0);
await Promise.all(events.map((event) => relay?.event(event)));
await relay.event(kind0);
await Promise.all(events.map((event) => relay.event(event)));
onClose();
await dispatch(logInNostr(pubkey));
await dispatch(logInNostr(signer, relay, AbortSignal.timeout(30_000)));
if (isMobile) {
dispatch(closeSidebar());

View File

@ -1,3 +1,4 @@
import { NSchema as n, NostrSigner, NSecSigner } from '@nostrify/nostrify';
import { produce } from 'immer';
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools';
import { z } from 'zod';
@ -7,13 +8,6 @@ import { persist } from 'zustand/middleware';
import { filteredArray, jsonSchema } from 'soapbox/schemas/utils';
/** User-facing authorization string. */
interface BunkerURI {
pubkey: string;
relays: string[];
secret?: string;
}
/**
* Temporary authorization details to establish a bunker connection with an app.
* Will be upgraded to a `BunkerConnection` once the connection is established.
@ -54,19 +48,17 @@ interface BunkerConnectRequest {
secret: string;
}
const nsecSchema = z.custom<`nsec1${string}`>((v) => typeof v === 'string' && v.startsWith('nsec1'));
const connectionSchema = z.object({
pubkey: z.string(),
accessToken: z.string(),
authorizedPubkey: z.string(),
bunkerSeckey: nsecSchema,
bunkerSeckey: n.bech32('nsec'),
});
const authorizationSchema = z.object({
secret: z.string(),
pubkey: z.string(),
bunkerSeckey: nsecSchema,
bunkerSeckey: n.bech32('nsec'),
});
const stateSchema = z.object({
@ -77,7 +69,7 @@ const stateSchema = z.object({
interface BunkerState {
connections: BunkerConnection[];
authorizations: BunkerAuthorization[];
authorize(pubkey: string): BunkerURI;
authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string };
connect(request: BunkerConnectRequest): void;
}
@ -88,7 +80,7 @@ export const useBunkerStore = create<BunkerState>()(
authorizations: [],
/** Generate a new authorization and persist it into the store. */
authorize(pubkey: string): BunkerURI {
authorize(pubkey: string): { signer: NostrSigner; relays: string[]; secret: string } {
const authorization: BunkerAuthorization = {
pubkey,
secret: crypto.randomUUID(),
@ -102,7 +94,7 @@ export const useBunkerStore = create<BunkerState>()(
});
return {
pubkey: getPublicKey(authorization.bunkerSeckey),
signer: new NSecSigner(authorization.bunkerSeckey),
secret: authorization.secret,
relays: [],
};

View File

@ -1,3 +1,4 @@
import { enableMapSet } from 'immer';
import React from 'react';
import { createRoot } from 'react-dom/client';
@ -25,6 +26,8 @@ import './styles/tailwind.css';
import ready from './ready';
import { registerSW, lockSW } from './utils/sw';
enableMapSet();
if (BuildConfig.NODE_ENV === 'production') {
printConsoleWarning();
registerSW('/sw.js');