diff options
| author | Adam Soltys <[email protected]> | 2026-01-28 23:52:58 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-01-28 23:52:58 -0800 |
| commit | 3aee33e5a0555f9dabe7a5eee32ca00987a423d5 (patch) | |
| tree | faaeed14230333337c48cc84afb31b3e32038814 | |
| parent | 838f74effb5b45e545bf68a4659e1e36c14da81f (diff) | |
| download | coinos-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.ts | 160 |
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( |
