/** * NOSTR NIP-46 Client Implementation * Minimal RPC over NostrTools.SimplePool for NOSTR_LOGIN_LITE */ class NIP46Client { constructor() { this.pool = null; this.localSk = null; this.localPk = null; this.remotePk = null; this.relays = []; this.sub = null; this.pendingRequests = {}; this.useNip44 = false; this.iframeOrigin = null; this.iframePort = null; } init(localSk, remotePk, relays, iframeOrigin) { // Create SimplePool this.pool = new window.NostrTools.SimplePool(); // Setup keys this.localSk = localSk; if (this.localSk) { this.localPk = window.NostrTools.getPublicKey(this.localSk); } this.remotePk = remotePk; this.relays = [...relays]; // Store iframe origin for future use this.iframeOrigin = iframeOrigin; console.log('NIP46Client initialized for', this.remotePk ? 'remote signer' : 'listening mode'); } setUseNip44(use) { this.useNip44 = use; } subscribeReplies() { if (!this.pool || !this.localPk) return; // Subscribe to replies to our pubkey on kind 24133 (NIP-46 methods) this.sub = this.pool.sub(this.relays, [{ kinds: [24133], '#p': [this.localPk] }]); this.sub.on('event', (event) => this.onEvent(event)); this.sub.on('eose', () => { console.log('NIP-46 subscription caught up'); }); console.log('Subscribed to NIP-46 replies on relays:', this.relays); } unsubscribe() { if (this.sub) { this.sub.unsub(); this.sub = null; } } async onEvent(event) { console.log('NIP-46 event received:', event); try { const parsed = await this.parseEvent(event); if (parsed) { if (parsed.id && this.pendingRequests[parsed.id]) { // Handle response const handler = this.pendingRequests[parsed.id]; delete this.pendingRequests[parsed.id]; if (parsed.result !== undefined) { handler.resolve(parsed.result); } else if (parsed.error) { handler.reject(new Error(parsed.error)); } else { handler.reject(new Error('Invalid response format')); } } else if (parsed.method === 'auth_url') { // Handle auth_url emissions (deduplication required) this.emitAuthUrlIfNeeded(parsed.params[0]); } } } catch (error) { console.error('Error processing NIP-46 event:', error); } } emitAuthUrlIfNeeded(url) { // Deduplicate auth_url emissions - only emit if not recently shown const lastUrl = sessionStorage.getItem('nl-last-auth-url'); if (lastUrl === url) { console.log('Auth URL already shown, skipping duplicate:', url); return; } sessionStorage.setItem('nl-last-auth-url', url); console.log('New auth URL:', url); // Emit event for UI window.dispatchEvent(new CustomEvent('nlAuthUrl', { detail: { url } })); } async parseEvent(event) { try { let content = event.content; // Determine encryption method based on content structure if (content.length > 44) { // Likely NIP-44 (encrypted) if (this.localSk && event.pubkey) { try { content = window.NostrTools.nip44?.decrypt(this.localSk, event.pubkey, content); } catch (e) { console.warn('NIP-44 decryption failed, trying NIP-04...'); content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content); } } } else { // Likely NIP-04 if (this.localSk && event.pubkey) { content = await window.NostrTools.nip04.decrypt(this.localSk, event.pubkey, content); } } const payload = JSON.parse(content); console.log('Decrypted NIP-46 payload:', payload); return { id: payload.id, method: payload.method, params: payload.params, result: payload.result, error: payload.error, event: event }; } catch (e) { console.error('Failed to parse event:', e); return null; } } async listen(nostrConnectSecret) { return new Promise((resolve, reject) => { if (!this.localPk) { reject(new Error('No local pubkey available for listening')); return; } // Subscribe to unsolicited events to our pubkey let foundSecretOrAck = false; const listenSub = this.pool.sub(this.relays, [{ kinds: [24133], '#p': [this.localPk] }]); listenSub.on('event', async (event) => { try { const parsed = await this.parseEvent(event); if (parsed && parsed.method === 'connect') { // Accept if it's an ack or matches our secret const [userPubkey, token] = parsed.params || []; if (token === '' && parsed.result === 'ack') { // Ack received foundSecretOrAck = true; listenSub.unsub(); resolve(event.pubkey); } else if (token === nostrConnectSecret) { // Secret match foundSecretOrAck = true; listenSub.unsub(); resolve(event.pubkey); } } } catch (error) { console.error('Error in listen mode:', error); } }); // Timeout after 5 minutes setTimeout(() => { if (!foundSecretOrAck) { listenSub.unsub(); reject(new Error('Listen timeout - no signer connected')); } }, 300000); }); } async connect(token, perms) { return new Promise(async (resolve, reject) => { try { const result = await this.sendRequest( this.remotePk, 'connect', [this.localPk, token || '', perms || ''], 24133, (response) => { if (response === 'ack') { resolve(true); } else { reject(new Error('Connection not acknowledged')); } } ); // Set 30 second timeout setTimeout(() => reject(new Error('Connection timeout')), 30000); } catch (error) { reject(error); } }); } async initUserPubkey(hint) { if (hint) { this.remotePk = hint; return hint; } if (!this.remotePk) { // Request get_public_key return new Promise(async (resolve, reject) => { try { const pubkey = await this.sendRequest( this.remotePk, 'get_public_key', [], 24133 ); this.remotePk = pubkey; resolve(pubkey); } catch (error) { reject(error); } }); } return this.remotePk; } async sendRequest(remotePubkey, method, params, kind = 24133, cb) { if (!this.pool || !this.localSk || !this.localPk) { throw new Error('NIP46Client not properly initialized'); } if (!remotePubkey) { throw new Error('No remote pubkey specified'); } const id = this._generateId(); // Create request event const event = await this.createRequestEvent(id, remotePubkey, method, params, kind); console.log('Sending NIP-46 request:', { id, method, params }); // Publish to relays const pubs = await this.pool.publish(this.relays, event); console.log('Published to relays, waiting for response...'); return new Promise((resolve, reject) => { // Set timeout const timeout = setTimeout(() => { console.error('NIP-46 request timeout for id:', id); delete this.pendingRequests[id]; reject(new Error(`Request timeout for ${method}`)); }, 60000); // 1 minute timeout // Store handler this.pendingRequests[id] = { resolve: (result) => { clearTimeout(timeout); resolve(result); }, reject: (error) => { clearTimeout(timeout); reject(error); }, timestamp: Date.now() }; // If callback provided, override resolve if (cb) { const originalResolve = this.pendingRequests[id].resolve; this.pendingRequests[id].resolve = (result) => { cb(result); originalResolve(result); }; } }); } async createRequestEvent(id, remotePubkey, method, params, kind = 24133) { let content = JSON.stringify({ id, method, params }); // Choose encryption method let encrypted = content; if (method !== 'create_account') { // Use NIP-44 for non-account creation methods if available if (this.useNip44 && window.NostrTools.nip44) { encrypted = window.NostrTools.nip44.encrypt(this.localSk, remotePubkey, content); } else { // Fallback to NIP-04 encrypted = await window.NostrTools.nip04.encrypt(this.localSk, remotePubkey, content); } } // Create event structure const event = { kind: kind, content: encrypted, tags: [ ['p', remotePubkey] ], created_at: Math.floor(Date.now() / 1000), pubkey: this.localPk, id: '', // Will be set by finalizeEvent sig: '' // Will be set by finalizeEvent }; // Sign the event const signedEvent = window.NostrTools.finalizeEvent(event, this.localSk); return signedEvent; } _generateId() { return 'nl-' + Date.now() + '-' + Math.random().toString(36).substring(2, 15); } setWorkerIframePort(port) { this.iframePort = port; // Set up postMessage routing if needed if (this.iframePort && this.iframeOrigin) { this.iframePort.onmessage = (event) => { if (event.origin !== this.iframeOrigin) { console.warn('Ignoring message from unknown origin:', event.origin); return; } console.log('Received iframe message:', event.data); // Handle iframe messages }; // Send keepalive setInterval(() => { if (this.iframePort) { try { this.iframePort.postMessage({ type: 'ping' }); } catch (e) { console.warn('Iframe port closed'); this.iframePort = null; } } }, 30000); // 30 seconds } } teardown() { this.unsubscribe(); if (this.iframePort) { try { this.iframePort.close(); } catch (e) { console.warn('Error closing iframe port:', e); } this.iframePort = null; } if (this.pool) { this.pool.close(this.relays); this.pool = null; } // Clear all pending requests for (const id in this.pendingRequests) { this.pendingRequests[id].reject(new Error('Client teardown')); } this.pendingRequests = {}; } }