aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-01-28 23:52:58 -0800
committerAdam Soltys <[email protected]>2026-01-28 23:52:58 -0800
commit3aee33e5a0555f9dabe7a5eee32ca00987a423d5 (patch)
treefaaeed14230333337c48cc84afb31b3e32038814
parent838f74effb5b45e545bf68a4659e1e36c14da81f (diff)
downloadcoinos-ui-3aee33e5a0555f9dabe7a5eee32ca00987a423d5.tar.gz
coinos-ui-3aee33e5a0555f9dabe7a5eee32ca00987a423d5.zip
Fix NIP-17 DM signer integration and relay auth
- Add ensureSigner() to trigger signer modal when no valid key available - Add NIP-42 relay auth support for publishing DMs via publishWithAuth() - Validate signer sk length (64 hex chars) before use - Clear invalid signer state to ensure modal shows correctly - Include default relays when fetching messages for better coverage - Add try/catch around decrypt to handle failures gracefully - Fix variable declaration in for loop (const event) - Fall back to default DM relays when user has no 10050 event Co-Authored-By: Claude Opus 4.5 <[email protected]>
-rw-r--r--src/lib/nip17.ts160
1 files changed, 139 insertions, 21 deletions
diff --git a/src/lib/nip17.ts b/src/lib/nip17.ts
index b128dab9..6f29940d 100644
--- a/src/lib/nip17.ts
+++ b/src/lib/nip17.ts
@@ -1,13 +1,15 @@
-import { bytesToHex } from '@noble/hashes/utils';
+import { bytesToHex, hexToBytes } from '@noble/hashes/utils';
import { finalizeEvent, getEventHash, generateSecretKey, getPublicKey } from 'nostr-tools/pure';
import { SimplePool } from 'nostr-tools/pool';
+import { Relay } from 'nostr-tools/relay';
import { unwrapEvent } from 'nostr-tools/nip17';
-import { getPrivateKey } from '$lib/nostr';
import { relaysSupporting } from '$lib/nip11';
import { expired, expiration } from '$lib/nip40';
import { encrypt, u } from '$lib/nip44';
+import { eventToSign, signer } from '$lib/store';
+import { get } from 'svelte/store';
const TWO_DAYS = 2 * 24 * 60 * 60;
const randomTimestamp = () => Math.floor(
@@ -19,6 +21,65 @@ import { PUBLIC_DM_RELAYS } from '$env/static/public';
const DM_RELAYS_LIST = PUBLIC_DM_RELAYS.split(',');
const DM_FETCH_LIMIT = 256;
+// Ensures we have access to a secret key, triggering the signer modal if needed.
+// Returns the sk as Uint8Array, or null if using NIP-07 extension.
+const ensureSigner = async (userPubkey: string): Promise<Uint8Array | null> => {
+ // Check if NIP-07 extension is available and matches user
+ if (window.nostr) {
+ try {
+ const extPubkey = await window.nostr.getPublicKey();
+ if (extPubkey === userPubkey) {
+ return null; // Use NIP-07
+ }
+ } catch (e) {
+ // Extension didn't work, fall through to signer
+ }
+ }
+
+ // Check if signer already has a valid sk (64 hex chars = 32 bytes)
+ const currentSigner = get(signer);
+ if (currentSigner?.ready && currentSigner?.params?.sk && currentSigner.params.sk.length === 64) {
+ return hexToBytes(currentSigner.params.sk);
+ }
+
+ // Clear invalid signer state before triggering modal
+ if (currentSigner?.ready && (!currentSigner?.params?.sk || currentSigner.params.sk.length !== 64)) {
+ signer.set(undefined);
+ }
+
+ // Trigger the signer modal with a dummy event
+ const dummyEvent = { kind: 14, content: 'DM encryption', tags: [], created_at: Math.floor(Date.now() / 1000) };
+ eventToSign.set(dummyEvent);
+
+ // Wait for signer to be ready
+ const signerValue = await new Promise<any>((resolve, reject) => {
+ let unsubscribe: () => void;
+ unsubscribe = signer.subscribe((v) => {
+ if (v === 'cancel') {
+ setTimeout(() => unsubscribe?.());
+ eventToSign.set(null);
+ reject(new Error('cancelled'));
+ }
+ if (v?.ready) {
+ setTimeout(() => unsubscribe?.());
+ eventToSign.set(null);
+ resolve(v);
+ }
+ });
+ });
+
+ if (signerValue?.params?.sk) {
+ return hexToBytes(signerValue.params.sk);
+ }
+
+ // If extension method was selected
+ if (signerValue?.method === 'extension') {
+ return null; // Use NIP-07
+ }
+
+ throw new Error('No signing method available');
+}
+
// Create a NIP-17 message using a provided sender secret key.
// If wrapPK is specified and different than receiverPK,
// the message will be gift-wrapped to wrapPK
@@ -129,41 +190,48 @@ const decryptNIP17MessageNIP07 = async (wrapped: object): object => {
}
// Decrypts the provided message, addressed to the user.
-// If NIP-07 is available, it uses that, otherwise it uses getPrivateKey().
+// If NIP-07 is available, it uses that, otherwise it triggers the signer modal.
export const decrypt = async (wrapped: object, user: object): object => {
- if (await window.nostr.getPublicKey() === user.pubkey) {
- return decryptNIP17MessageNIP07(wrapped);
- } else {
- const sk = await getPrivateKey(user);
- return unwrapEvent(wrapped, sk);
- }
- }
+ const sk = await ensureSigner(user.pubkey);
+
+ if (sk === null) {
+ return decryptNIP17MessageNIP07(wrapped);
+ } else {
+ return unwrapEvent(wrapped, sk);
+ }
+}
// Returns the rumours of the messages the provided user has sent or received.
// If the events have an expiration timestamp,
// that timestamp is added to its rumour object as the property `expiration`.
export const getMessageRumours = async (user: object): object[] => {
- const relays = await getPreferredRelays(user.pubkey);
+ const preferredRelays = await getPreferredRelays(user.pubkey);
+ // Always include default relays to catch messages that might be there
+ const relays = [...new Set([...preferredRelays, ...DM_RELAYS_LIST])];
const wrapped = await pool.querySync(
relays, { kinds: [1059], "#p": [user.pubkey], limit: DM_FETCH_LIMIT }
);
let rumours = [];
// intentionally decrypting sequentially to avoid having a bunch of popups
- for (event of wrapped) {
+ for (const event of wrapped) {
if (expired(event)) {
console.log("relay(s) contain expired event", event.id);
continue;
}
- const rumour = await decrypt(event, user);
+ try {
+ const rumour = await decrypt(event, user);
- const expires = expiration(event);
- if (expires) {
- rumour.expiration = expires;
- }
+ const expires = expiration(event);
+ if (expires) {
+ rumour.expiration = expires;
+ }
- rumours.push(rumour);
+ rumours.push(rumour);
+ } catch (e) {
+ console.error('Failed to decrypt event:', event.id, e);
+ }
}
return rumours;
@@ -188,29 +256,79 @@ export const getPreferredRelays = async (pubkey: string): string[] => {
}
}
+ // Fall back to default DM relays if user hasn't published their preferred relays
+ if (relays.length === 0) {
+ relays = DM_RELAYS_LIST;
+ }
+
relayListCache.set(pubkey, relays);
return relays;
}
+// Publish to a single relay with NIP-42 auth support
+const publishWithAuth = async (relayUrl: string, event: object, sk: Uint8Array | null): Promise<string> => {
+ const relay = await Relay.connect(relayUrl);
+
+ const signAuthEvent = async (authEvent: object) => {
+ if (sk) {
+ return finalizeEvent(authEvent, sk);
+ } else if (window.nostr) {
+ return await window.nostr.signEvent(authEvent);
+ }
+ throw new Error('No signing method for auth');
+ };
+
+ try {
+ return await relay.publish(event);
+ } catch (e) {
+ // Check if it's an auth error
+ if (e?.message?.includes('auth-required')) {
+ // Authenticate and retry
+ await relay.auth(signAuthEvent);
+ return await relay.publish(event);
+ }
+ throw e;
+ }
+};
+
+// Store the current sk for use in publish
+let currentSk: Uint8Array | null = null;
+
const publishToPreferred = async (event: object, pubkey: string, expiryEnabled: boolean): Promise<string> => {
let preferredRelays = await getPreferredRelays(pubkey);
if (expiryEnabled) {
preferredRelays = await relaysSupporting(preferredRelays, [40]);
}
- return Promise.any(pool.publish(preferredRelays, event));
+ if (preferredRelays.length === 0) {
+ throw new Error('No relays available to publish to');
+ }
+
+ const promises = preferredRelays.map(url => publishWithAuth(url, event, currentSk));
+ try {
+ return await Promise.any(promises);
+ } catch (e) {
+ const results = await Promise.allSettled(promises);
+ console.error('All relay publishes failed:', results);
+ throw e;
+ }
}
// Sends a NIP-17 message from `user` to `recipient`.
// If expiryDays is set, messages will expire after that many days.
export const send = async (message: string, user: object, recipient: object, expiryDays?: number) => {
let event1, event2;
- if (await window.nostr.getPublicKey() === user.pubkey) {
+ const sk = await ensureSigner(user.pubkey);
+
+ // Store sk for auth handler
+ currentSk = sk;
+
+ if (sk === null) {
+ // Use NIP-07 extension
event1 = await createNIP17MessageNIP07(
message, user.pubkey, recipient.pubkey, recipient.pubkey, expiryDays);
event2 = await createNIP17MessageNIP07(
message, user.pubkey, recipient.pubkey, user.pubkey, expiryDays);
} else {
- const sk = await getPrivateKey(user);
event1 = createNIP17MessageSK(
message, sk, recipient.pubkey, recipient.pubkey, expiryDays);
event2 = createNIP17MessageSK(