Merge branch 'nip46' into 'main'

Implement real NIP-46 support

See merge request soapbox-pub/soapbox!3042
environments/review-main-yi2y9f/deployments/4656
Alex Gleason 2024-05-29 21:28:59 +00:00
commit 5a7569ad75
7 zmienionych plików z 219 dodań i 144 usunięć

Wyświetl plik

@ -1,14 +1,31 @@
import { nip19 } from 'nostr-tools';
import { RootState, type AppDispatch } from 'soapbox/store';
import { type AppDispatch } from 'soapbox/store';
import { authLoggedIn, verifyCredentials } from './auth';
import { obtainOAuthToken } from './oauth';
import { verifyCredentials } from './auth';
const NOSTR_PUBKEY_SET = 'NOSTR_PUBKEY_SET';
/** Log in with a Nostr pubkey. */
function logInNostr(pubkey: string) {
return (dispatch: AppDispatch) => {
const npub = nip19.npubEncode(pubkey);
return dispatch(verifyCredentials(npub));
return async (dispatch: AppDispatch, getState: () => RootState) => {
dispatch(setNostrPubkey(pubkey));
const secret = sessionStorage.getItem('soapbox:nip46:secret');
if (!secret) {
throw new Error('No secret found in session storage');
}
const relay = getState().instance.nostr?.relay;
const token = await dispatch(obtainOAuthToken({
grant_type: 'nostr_bunker',
pubkey,
relays: relay ? [relay] : undefined,
secret,
}));
const { access_token } = dispatch(authLoggedIn(token));
return await dispatch(verifyCredentials(access_token as string));
};
}
@ -23,4 +40,11 @@ function nostrExtensionLogIn() {
};
}
export { logInNostr, nostrExtensionLogIn };
function setNostrPubkey(pubkey: string) {
return {
type: NOSTR_PUBKEY_SET,
pubkey,
};
}
export { logInNostr, nostrExtensionLogIn, setNostrPubkey, NOSTR_PUBKEY_SET };

Wyświetl plik

@ -20,7 +20,7 @@ export const OAUTH_TOKEN_REVOKE_REQUEST = 'OAUTH_TOKEN_REVOKE_REQUEST';
export const OAUTH_TOKEN_REVOKE_SUCCESS = 'OAUTH_TOKEN_REVOKE_SUCCESS';
export const OAUTH_TOKEN_REVOKE_FAIL = 'OAUTH_TOKEN_REVOKE_FAIL';
export const obtainOAuthToken = (params: Record<string, string | undefined>, baseURL?: string) =>
export const obtainOAuthToken = (params: Record<string, unknown>, baseURL?: string) =>
(dispatch: AppDispatch) => {
dispatch({ type: OAUTH_TOKEN_CREATE_REQUEST, params });
return baseClient(null, baseURL).post('/oauth/token', params).then(({ data: token }) => {

Wyświetl plik

@ -1,137 +1,43 @@
import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useNostr } from 'soapbox/contexts/nostr-context';
import { nwcRequestSchema } from 'soapbox/schemas/nostr';
import { NConnect } from 'soapbox/features/nostr/NConnect';
const secretStorageKey = 'soapbox:nip46:secret';
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
function useSignerStream() {
const { relay, pubkey, signer } = useNostr();
const { relay, signer } = useNostr();
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
async function sendConnect(response: NostrConnectResponse) {
if (!relay || !pubkey || !signer) return;
const event = await signer.signEvent({
kind: 24133,
content: await signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
relay.event(event);
}
async function handleConnectEvent(event: NostrEvent) {
if (!relay || !pubkey || !signer) return;
const decrypted = await signer.nip04!.decrypt(pubkey, event.content);
const reqMsg = n.json().pipe(n.connectRequest()).safeParse(decrypted);
if (!reqMsg.success) {
console.warn(decrypted);
console.warn(reqMsg.error);
return;
}
const request = reqMsg.data;
switch (request.method) {
case 'connect':
return sendConnect({
id: request.id,
result: 'ack',
});
case 'sign_event':
return sendConnect({
id: request.id,
result: JSON.stringify(await signer.signEvent(JSON.parse(request.params[0]))),
});
case 'ping':
return sendConnect({
id: request.id,
result: 'pong',
});
case 'get_relays':
return sendConnect({
id: request.id,
result: JSON.stringify(await signer.getRelays?.() ?? []),
});
case 'get_public_key':
return sendConnect({
id: request.id,
result: await signer.getPublicKey(),
});
case 'nip04_encrypt':
return sendConnect({
id: request.id,
result: await signer.nip04!.encrypt(request.params[0], request.params[1]),
});
case 'nip04_decrypt':
return sendConnect({
id: request.id,
result: await signer.nip04!.decrypt(request.params[0], request.params[1]),
});
case 'nip44_encrypt':
return sendConnect({
id: request.id,
result: await signer.nip44!.encrypt(request.params[0], request.params[1]),
});
case 'nip44_decrypt':
return sendConnect({
id: request.id,
result: await signer.nip44!.decrypt(request.params[0], request.params[1]),
});
default:
return sendConnect({
id: request.id,
result: '',
error: `Unrecognized method: ${request.method}`,
});
}
}
async function handleWalletEvent(event: NostrEvent) {
if (!relay || !pubkey || !signer) return;
const decrypted = await signer.nip04!.decrypt(pubkey, event.content);
const reqMsg = n.json().pipe(nwcRequestSchema).safeParse(decrypted);
if (!reqMsg.success) {
console.warn(decrypted);
console.warn(reqMsg.error);
return;
}
await window.webln?.enable();
await window.webln?.sendPayment(reqMsg.data.params.invoice);
}
async function handleEvent(event: NostrEvent) {
switch (event.kind) {
case 24133:
await handleConnectEvent(event);
break;
case 23194:
await handleWalletEvent(event);
break;
}
}
const authStorageKey = `soapbox:nostr:auth:${pubkey}`;
useEffect(() => {
if (!relay || !pubkey) return;
if (signer) {
signer.getPublicKey().then(setPubkey).catch(console.warn);
}
}, [signer]);
const controller = new AbortController();
const signal = controller.signal;
useEffect(() => {
if (!relay || !signer || !pubkey) return;
(async() => {
for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }], { signal })) {
if (msg[0] === 'EVENT') handleEvent(msg[2]);
}
})();
const connect = new NConnect({
relay,
signer,
onAuthorize(authorizedPubkey) {
localStorage.setItem(authStorageKey, authorizedPubkey);
sessionStorage.setItem(secretStorageKey, crypto.randomUUID());
},
authorizedPubkey: localStorage.getItem(authStorageKey) ?? undefined,
getSecret: () => sessionStorage.getItem(secretStorageKey)!,
});
return () => {
controller.abort();
connect.close();
};
}, [relay, pubkey, signer]);
}, [relay, signer, pubkey]);
}
export { useSignerStream };

Wyświetl plik

@ -2,12 +2,11 @@ import { NRelay, NRelay1, NostrSigner } from '@nostrify/nostrify';
import React, { createContext, useContext, useState, useEffect, useMemo } from 'react';
import { NKeys } from 'soapbox/features/nostr/keys';
import { useOwnAccount } from 'soapbox/hooks';
import { useAppSelector, useOwnAccount } from 'soapbox/hooks';
import { useInstance } from 'soapbox/hooks/useInstance';
interface NostrContextType {
relay?: NRelay;
pubkey?: string;
signer?: NostrSigner;
}
@ -24,8 +23,7 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
const { account } = useOwnAccount();
const url = instance.nostr?.relay;
const pubkey = instance.nostr?.pubkey;
const accountPubkey = account?.nostr.pubkey;
const accountPubkey = useAppSelector((state) => account?.nostr.pubkey ?? state.meta.pubkey);
const signer = useMemo(
() => (accountPubkey ? NKeys.get(accountPubkey) : undefined) ?? window.nostr,
@ -42,7 +40,7 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
}, [url]);
return (
<NostrContext.Provider value={{ relay, pubkey, signer }}>
<NostrContext.Provider value={{ relay, signer }}>
{children}
</NostrContext.Provider>
);

Wyświetl plik

@ -0,0 +1,151 @@
import { NRelay, NostrConnectRequest, NostrConnectResponse, NostrEvent, NostrSigner, NSchema as n } from '@nostrify/nostrify';
interface NConnectOpts {
relay: NRelay;
signer: NostrSigner;
authorizedPubkey: string | undefined;
onAuthorize(pubkey: string): void;
getSecret(): string;
}
export class NConnect {
private relay: NRelay;
private signer: NostrSigner;
private authorizedPubkey: string | undefined;
private onAuthorize: (pubkey: string) => void;
private getSecret: () => string;
private controller = new AbortController();
constructor(opts: NConnectOpts) {
this.relay = opts.relay;
this.signer = opts.signer;
this.authorizedPubkey = opts.authorizedPubkey;
this.onAuthorize = opts.onAuthorize;
this.getSecret = opts.getSecret;
this.open();
}
async open() {
const pubkey = await this.signer.getPublicKey();
const signal = this.controller.signal;
for await (const msg of this.relay.req([{ kinds: [24133], '#p': [pubkey], limit: 0 }], { signal })) {
if (msg[0] === 'EVENT') {
const event = msg[2];
this.handleEvent(event);
}
}
}
private async handleEvent(event: NostrEvent): Promise<void> {
const decrypted = await this.signer.nip04!.decrypt(event.pubkey, event.content);
const request = n.json().pipe(n.connectRequest()).safeParse(decrypted);
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' });
}
// Prevent unauthorized access.
if (pubkey !== this.authorizedPubkey) {
return this.sendResponse(pubkey, {
id: request.id,
result: '',
error: 'Unauthorized',
});
}
// Authorized methods.
switch (request.method) {
case 'sign_event':
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await this.signer.signEvent(JSON.parse(request.params[0]))),
});
case 'ping':
return this.sendResponse(pubkey, {
id: request.id,
result: 'pong',
});
case 'get_relays':
return this.sendResponse(pubkey, {
id: request.id,
result: JSON.stringify(await this.signer.getRelays?.() ?? []),
});
case 'get_public_key':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.getPublicKey(),
});
case 'nip04_encrypt':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.nip04!.encrypt(request.params[0], request.params[1]),
});
case 'nip04_decrypt':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.nip04!.decrypt(request.params[0], request.params[1]),
});
case 'nip44_encrypt':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.nip44!.encrypt(request.params[0], request.params[1]),
});
case 'nip44_decrypt':
return this.sendResponse(pubkey, {
id: request.id,
result: await this.signer.nip44!.decrypt(request.params[0], request.params[1]),
});
default:
return this.sendResponse(pubkey, {
id: request.id,
result: '',
error: `Unrecognized method: ${request.method}`,
});
}
}
private async handleConnect(pubkey: string, request: NostrConnectRequest & { method: 'connect' }) {
const [remotePubkey, secret] = request.params;
if (secret === this.getSecret() && remotePubkey === await this.signer.getPublicKey()) {
this.authorizedPubkey = pubkey;
this.onAuthorize(pubkey);
await this.sendResponse(pubkey, {
id: request.id,
result: 'ack',
});
}
}
private async sendResponse(pubkey: string, response: NostrConnectResponse) {
const event = await this.signer.signEvent({
kind: 24133,
content: await this.signer.nip04!.encrypt(pubkey, JSON.stringify(response)),
tags: [['p', pubkey]],
created_at: Math.floor(Date.now() / 1000),
});
await this.relay.event(event);
}
close() {
this.controller.abort();
}
}

Wyświetl plik

@ -1,6 +1,7 @@
import { Record as ImmutableRecord } from 'immutable';
import { fetchInstance } from 'soapbox/actions/instance';
import { NOSTR_PUBKEY_SET } from 'soapbox/actions/nostr';
import { SW_UPDATING } from 'soapbox/actions/sw';
import type { AnyAction } from 'redux';
@ -10,6 +11,8 @@ const ReducerRecord = ImmutableRecord({
instance_fetch_failed: false,
/** Whether the ServiceWorker is currently updating (and we should display a loading screen). */
swUpdating: false,
/** User's nostr pubkey. */
pubkey: undefined as string | undefined,
});
export default function meta(state = ReducerRecord(), action: AnyAction) {
@ -21,6 +24,8 @@ export default function meta(state = ReducerRecord(), action: AnyAction) {
return state;
case SW_UPDATING:
return state.set('swUpdating', action.isUpdating);
case NOSTR_PUBKEY_SET:
return state.set('pubkey', action.pubkey);
default:
return state;
}

Wyświetl plik

@ -1,16 +1,7 @@
import { NSchema as n } from '@nostrify/nostrify';
import { verifyEvent } from 'nostr-tools';
import { z } from 'zod';
/** Nostr event schema that also verifies the event's signature. */
const signedEventSchema = n.event().refine(verifyEvent);
/** NIP-47 signer response. */
const nwcRequestSchema = z.object({
method: z.literal('pay_invoice'),
params: z.object({
invoice: z.string(),
}),
});
export { signedEventSchema, nwcRequestSchema };
export { signedEventSchema };