kopia lustrzana https://gitlab.com/soapbox-pub/soapbox
Add initial version of NConnect class
rodzic
b7afb2fe15
commit
41f676fdfb
|
@ -1,137 +1,35 @@
|
||||||
import { NostrEvent, NostrConnectResponse, NSchema as n } from '@nostrify/nostrify';
|
import { useEffect, useState } from 'react';
|
||||||
import { useEffect } from 'react';
|
|
||||||
|
|
||||||
import { useNostr } from 'soapbox/contexts/nostr-context';
|
import { useNostr } from 'soapbox/contexts/nostr-context';
|
||||||
import { nwcRequestSchema } from 'soapbox/schemas/nostr';
|
import { NConnect } from 'soapbox/features/nostr/NConnect';
|
||||||
|
|
||||||
function useSignerStream() {
|
function useSignerStream() {
|
||||||
const { relay, pubkey, signer } = useNostr();
|
const { relay, signer } = useNostr();
|
||||||
|
const [pubkey, setPubkey] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
async function sendConnect(response: NostrConnectResponse) {
|
const storageKey = `soapbox:nostr:auth:${pubkey}`;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!relay || !pubkey) return;
|
if (signer) {
|
||||||
|
signer.getPublicKey().then(setPubkey).catch(console.warn);
|
||||||
|
}
|
||||||
|
}, [signer]);
|
||||||
|
|
||||||
const controller = new AbortController();
|
useEffect(() => {
|
||||||
const signal = controller.signal;
|
if (!relay || !signer || !pubkey) return;
|
||||||
|
|
||||||
(async() => {
|
const connect = new NConnect({
|
||||||
for await (const msg of relay.req([{ kinds: [24133, 23194], authors: [pubkey], limit: 0 }], { signal })) {
|
relay,
|
||||||
if (msg[0] === 'EVENT') handleEvent(msg[2]);
|
signer,
|
||||||
}
|
onAuthorize: (authorizedPubkey) => localStorage.setItem(storageKey, authorizedPubkey),
|
||||||
})();
|
authorizedPubkey: localStorage.getItem(storageKey) ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
controller.abort();
|
connect.close();
|
||||||
};
|
};
|
||||||
|
|
||||||
}, [relay, pubkey, signer]);
|
}, [relay, signer, pubkey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { useSignerStream };
|
export { useSignerStream };
|
||||||
|
|
|
@ -7,7 +7,6 @@ import { useInstance } from 'soapbox/hooks/useInstance';
|
||||||
|
|
||||||
interface NostrContextType {
|
interface NostrContextType {
|
||||||
relay?: NRelay;
|
relay?: NRelay;
|
||||||
pubkey?: string;
|
|
||||||
signer?: NostrSigner;
|
signer?: NostrSigner;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -24,7 +23,6 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
||||||
const { account } = useOwnAccount();
|
const { account } = useOwnAccount();
|
||||||
|
|
||||||
const url = instance.nostr?.relay;
|
const url = instance.nostr?.relay;
|
||||||
const pubkey = instance.nostr?.pubkey;
|
|
||||||
const accountPubkey = account?.nostr.pubkey;
|
const accountPubkey = account?.nostr.pubkey;
|
||||||
|
|
||||||
const signer = useMemo(
|
const signer = useMemo(
|
||||||
|
@ -42,7 +40,7 @@ export const NostrProvider: React.FC<NostrProviderProps> = ({ children }) => {
|
||||||
}, [url]);
|
}, [url]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NostrContext.Provider value={{ relay, pubkey, signer }}>
|
<NostrContext.Provider value={{ relay, signer }}>
|
||||||
{children}
|
{children}
|
||||||
</NostrContext.Provider>
|
</NostrContext.Provider>
|
||||||
);
|
);
|
||||||
|
|
|
@ -0,0 +1,150 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NConnect {
|
||||||
|
|
||||||
|
private relay: NRelay;
|
||||||
|
private signer: NostrSigner;
|
||||||
|
private authorizedPubkey: string | undefined;
|
||||||
|
private onAuthorize: (pubkey: string) => void;
|
||||||
|
|
||||||
|
public secret = crypto.randomUUID();
|
||||||
|
private controller = new AbortController();
|
||||||
|
|
||||||
|
constructor(opts: NConnectOpts) {
|
||||||
|
this.relay = opts.relay;
|
||||||
|
this.signer = opts.signer;
|
||||||
|
this.authorizedPubkey = opts.authorizedPubkey;
|
||||||
|
this.onAuthorize = opts.onAuthorize;
|
||||||
|
|
||||||
|
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.secret && remotePubkey === await this.signer.getPublicKey()) {
|
||||||
|
this.secret = crypto.randomUUID();
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -1,16 +1,7 @@
|
||||||
import { NSchema as n } from '@nostrify/nostrify';
|
import { NSchema as n } from '@nostrify/nostrify';
|
||||||
import { verifyEvent } from 'nostr-tools';
|
import { verifyEvent } from 'nostr-tools';
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
/** Nostr event schema that also verifies the event's signature. */
|
/** Nostr event schema that also verifies the event's signature. */
|
||||||
const signedEventSchema = n.event().refine(verifyEvent);
|
const signedEventSchema = n.event().refine(verifyEvent);
|
||||||
|
|
||||||
/** NIP-47 signer response. */
|
export { signedEventSchema };
|
||||||
const nwcRequestSchema = z.object({
|
|
||||||
method: z.literal('pay_invoice'),
|
|
||||||
params: z.object({
|
|
||||||
invoice: z.string(),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
export { signedEventSchema, nwcRequestSchema };
|
|
Ładowanie…
Reference in New Issue