OKAY IT'S WORKING
This commit is contained in:
parent
333d3e76d6
commit
5c4b73f943
|
@ -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));
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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 };
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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!);
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
|
|
|
@ -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: [],
|
||||
};
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Reference in New Issue