Nostr: sign events with NIP-46
This commit is contained in:
@@ -173,11 +173,6 @@ const connectTimelineStream = (
|
||||
case 'marker':
|
||||
dispatch({ type: MARKER_FETCH_SUCCESS, marker: JSON.parse(data.payload) });
|
||||
break;
|
||||
case 'nostr.sign':
|
||||
window.nostr?.signEvent(JSON.parse(data.payload))
|
||||
.then((data) => websocket.send(JSON.stringify({ type: 'nostr.sign', data })))
|
||||
.catch(() => console.warn('Failed to sign Nostr event.'));
|
||||
break;
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -53,4 +53,3 @@ export { useHashtagStream } from './streaming/useHashtagStream';
|
||||
export { useListStream } from './streaming/useListStream';
|
||||
export { useGroupStream } from './streaming/useGroupStream';
|
||||
export { useRemoteStream } from './streaming/useRemoteStream';
|
||||
export { useNostrStream } from './streaming/useNostrStream';
|
||||
57
app/soapbox/api/hooks/nostr/useSignerStream.ts
Normal file
57
app/soapbox/api/hooks/nostr/useSignerStream.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { relayInit, type Relay } from 'nostr-tools';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useInstance } from 'soapbox/hooks';
|
||||
import { connectRequestSchema } from 'soapbox/schemas/nostr';
|
||||
import { jsonSchema } from 'soapbox/schemas/utils';
|
||||
|
||||
function useSignerStream() {
|
||||
const { nostr } = useInstance();
|
||||
|
||||
const relayUrl = nostr.get('relay') as string | undefined;
|
||||
const pubkey = nostr.get('pubkey') as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
let relay: Relay | undefined;
|
||||
|
||||
if (relayUrl && pubkey && window.nostr?.nip04) {
|
||||
relay = relayInit(relayUrl);
|
||||
relay.connect();
|
||||
|
||||
relay
|
||||
.sub([{ kinds: [24133], authors: [pubkey], limit: 0 }])
|
||||
.on('event', async (event) => {
|
||||
if (!relay || !window.nostr?.nip04) return;
|
||||
|
||||
const decrypted = await window.nostr.nip04.decrypt(pubkey, event.content);
|
||||
const reqMsg = jsonSchema.pipe(connectRequestSchema).safeParse(decrypted);
|
||||
|
||||
if (!reqMsg.success) {
|
||||
console.warn(decrypted);
|
||||
console.warn(reqMsg.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const signed = await window.nostr.signEvent(reqMsg.data.params[0]);
|
||||
const respMsg = {
|
||||
id: reqMsg.data.id,
|
||||
result: signed,
|
||||
};
|
||||
|
||||
const respEvent = await window.nostr.signEvent({
|
||||
kind: 24133,
|
||||
content: await window.nostr.nip04.encrypt(pubkey, JSON.stringify(respMsg)),
|
||||
tags: [['p', pubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
relay.publish(respEvent);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
relay?.close();
|
||||
};
|
||||
}, [relayUrl, pubkey]);
|
||||
}
|
||||
|
||||
export { useSignerStream };
|
||||
@@ -1,20 +0,0 @@
|
||||
import { useFeatures, useLoggedIn } from 'soapbox/hooks';
|
||||
|
||||
import { useTimelineStream } from './useTimelineStream';
|
||||
|
||||
function useNostrStream() {
|
||||
const features = useFeatures();
|
||||
const { isLoggedIn } = useLoggedIn();
|
||||
|
||||
return useTimelineStream(
|
||||
'nostr',
|
||||
'nostr',
|
||||
null,
|
||||
null,
|
||||
{
|
||||
enabled: isLoggedIn && features.nostrSign && Boolean(window.nostr),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export { useNostrStream };
|
||||
@@ -66,6 +66,7 @@ export const baseClient = (accessToken?: string | null, baseURL: string = ''): A
|
||||
baseURL: isURL(BuildConfig.BACKEND_URL) ? BuildConfig.BACKEND_URL : baseURL,
|
||||
headers: Object.assign(accessToken ? {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'X-Nostr-Sign': 'true',
|
||||
} : {}),
|
||||
transformResponse: [maybeParseJSON],
|
||||
});
|
||||
|
||||
@@ -16,7 +16,8 @@ import { register as registerPushNotifications } from 'soapbox/actions/push-noti
|
||||
import { fetchScheduledStatuses } from 'soapbox/actions/scheduled-statuses';
|
||||
import { fetchSuggestionsForTimeline } from 'soapbox/actions/suggestions';
|
||||
import { expandHomeTimeline } from 'soapbox/actions/timelines';
|
||||
import { useNostrStream, useUserStream } from 'soapbox/api/hooks';
|
||||
import { useUserStream } from 'soapbox/api/hooks';
|
||||
import { useSignerStream } from 'soapbox/api/hooks/nostr/useSignerStream';
|
||||
import GroupLookupHoc from 'soapbox/components/hoc/group-lookup-hoc';
|
||||
import withHoc from 'soapbox/components/hoc/with-hoc';
|
||||
import SidebarNavigation from 'soapbox/components/sidebar-navigation';
|
||||
@@ -443,7 +444,7 @@ const UI: React.FC<IUI> = ({ children }) => {
|
||||
}, []);
|
||||
|
||||
useUserStream();
|
||||
useNostrStream();
|
||||
useSignerStream();
|
||||
|
||||
// The user has logged in
|
||||
useEffect(() => {
|
||||
|
||||
@@ -62,6 +62,10 @@ describe('normalizeInstance()', () => {
|
||||
uri: '',
|
||||
urls: {},
|
||||
version: '0.0.0',
|
||||
nostr: {
|
||||
pubkey: undefined,
|
||||
relay: undefined,
|
||||
},
|
||||
};
|
||||
|
||||
const result = normalizeInstance(ImmutableMap());
|
||||
|
||||
@@ -69,6 +69,10 @@ export const InstanceRecord = ImmutableRecord({
|
||||
status_count: 0,
|
||||
user_count: 0,
|
||||
}),
|
||||
nostr: ImmutableMap<string, any>({
|
||||
relay: undefined as string | undefined,
|
||||
pubkey: undefined as string | undefined,
|
||||
}),
|
||||
title: '',
|
||||
thumbnail: '',
|
||||
uri: '',
|
||||
|
||||
34
app/soapbox/schemas/nostr.ts
Normal file
34
app/soapbox/schemas/nostr.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { verifySignature } from 'nostr-tools';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** Schema to validate Nostr hex IDs such as event IDs and pubkeys. */
|
||||
const nostrIdSchema = z.string().regex(/^[0-9a-f]{64}$/);
|
||||
/** Nostr kinds are positive integers. */
|
||||
const kindSchema = z.number().int().positive();
|
||||
|
||||
/** Nostr event template schema. */
|
||||
const eventTemplateSchema = z.object({
|
||||
kind: kindSchema,
|
||||
tags: z.array(z.array(z.string())),
|
||||
content: z.string(),
|
||||
created_at: z.number(),
|
||||
});
|
||||
|
||||
/** Nostr event schema. */
|
||||
const eventSchema = eventTemplateSchema.extend({
|
||||
id: nostrIdSchema,
|
||||
pubkey: nostrIdSchema,
|
||||
sig: z.string(),
|
||||
});
|
||||
|
||||
/** Nostr event schema that also verifies the event's signature. */
|
||||
const signedEventSchema = eventSchema.refine(verifySignature);
|
||||
|
||||
/** NIP-46 signer request. */
|
||||
const connectRequestSchema = z.object({
|
||||
id: z.string(),
|
||||
method: z.literal('sign_event'),
|
||||
params: z.tuple([eventTemplateSchema]),
|
||||
});
|
||||
|
||||
export { nostrIdSchema, kindSchema, eventSchema, signedEventSchema, connectRequestSchema };
|
||||
@@ -30,4 +30,13 @@ function makeCustomEmojiMap(customEmojis: CustomEmoji[]) {
|
||||
}, {});
|
||||
}
|
||||
|
||||
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema };
|
||||
const jsonSchema = z.string().transform((value, ctx) => {
|
||||
try {
|
||||
return JSON.parse(value) as unknown;
|
||||
} catch (_e) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid JSON' });
|
||||
return z.NEVER;
|
||||
}
|
||||
});
|
||||
|
||||
export { filteredArray, makeCustomEmojiMap, emojiSchema, contentSchema, dateSchema, jsonSchema };
|
||||
@@ -3,6 +3,10 @@ import type { Event, EventTemplate } from 'nostr-tools';
|
||||
interface Nostr {
|
||||
getPublicKey(): Promise<string>
|
||||
signEvent(event: EventTemplate): Promise<Event>
|
||||
nip04?: {
|
||||
encrypt: (pubkey: string, plaintext: string) => Promise<string>
|
||||
decrypt: (pubkey: string, ciphertext: string) => Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
export default Nostr;
|
||||
Reference in New Issue
Block a user