From 7af85a45a7d077740c470dee2c042b17fb52c048 Mon Sep 17 00:00:00 2001 From: Laan Tungir Date: Sat, 4 Apr 2026 09:40:23 -0400 Subject: [PATCH] Fix NIP-46 signing across pages by auto-reconnecting BunkerSigner --- build/nostr-lite.js | 215 +++++++++++++++++++++++++++++++++++++------- src/VERSION | 2 +- src/build.js | 89 ++++++++++++++++-- src/ui/modal.js | 122 +++++++++++++++++++------ 4 files changed, 361 insertions(+), 67 deletions(-) diff --git a/build/nostr-lite.js b/build/nostr-lite.js index 8060866..07c5e07 100644 --- a/build/nostr-lite.js +++ b/build/nostr-lite.js @@ -8,7 +8,7 @@ * Two-file architecture: * 1. Load nostr.bundle.js (official nostr-tools bundle) * 2. Load nostr-lite.js (this file - NOSTR_LOGIN_LITE library with CSS-only themes) - * Generated on: 2025-11-14T18:40:05.334Z + * Generated on: 2026-04-04T13:40:23.265Z */ // Verify dependencies are loaded @@ -436,7 +436,7 @@ class Modal { modalContent.appendChild(modalHeader); // Add version element in bottom-right corner aligned with modal body const versionElement = document.createElement('div'); - versionElement.textContent = 'v0.1.12'; + versionElement.textContent = 'v0.1.13'; versionElement.style.cssText = ` position: absolute; bottom: 8px; @@ -492,7 +492,10 @@ class Modal { // No manual styling needed - the CSS variables handle everything } - open(opts = {}) { + open(opts = {}) { + // If a NIP-46 connect is in progress, don't overwrite the connecting UI + if (this._nip46Connecting) return; + this.currentScreen = opts.startScreen; this.isVisible = true; this.container.style.display = 'block'; @@ -1707,14 +1710,20 @@ class Modal { } } - _handleNip46Connect(bunkerPubkey) { + _handleNip46Connect(bunkerPubkey) { if (!bunkerPubkey || !bunkerPubkey.length) { this._showError('Bunker pubkey is required'); return; } this._showNip46Connecting(bunkerPubkey); - this._performNip46Connect(bunkerPubkey); + + // While connecting, prevent modal.open() from overwriting the "Connecting..." + // state (e.g. when init-ndk's auth timeout fires and calls launch() again). + this._nip46Connecting = true; + this._performNip46Connect(bunkerPubkey).finally(() => { + this._nip46Connecting = false; + }); } _showNip46Connecting(bunkerPubkey) { @@ -1753,51 +1762,109 @@ class Modal { this.modalBody.appendChild(connectingDiv); } - async _performNip46Connect(bunkerPubkey) { - try { - // console.log('Starting NIP-46 connection to bunker:', bunkerPubkey); + async _performNip46Connect(bunkerPubkey) { + // Cancel any previous in-flight connect attempt + if (this._nip46CancelToken) this._nip46CancelToken.cancelled = true; + const cancelToken = { cancelled: false }; + this._nip46CancelToken = cancelToken; + try { // Check if nostr-tools NIP-46 is available if (!window.NostrTools?.nip46) { throw new Error('nostr-tools NIP-46 module not available'); } // Use nostr-tools to parse bunker input - this handles all formats correctly - // console.log('Parsing bunker input with nostr-tools...'); const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey); if (!bunkerPointer) { throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier'); } - // console.log('Parsed bunker pointer:', bunkerPointer); - // Create local client keypair for this session const localSecretKey = window.NostrTools.generateSecretKey(); - // console.log('Generated local client keypair for NIP-46 session'); - // Use nostr-tools BunkerSigner factory method (not constructor - it's private) - // console.log('Creating nip46 BunkerSigner...'); + // Create a SimplePool with a patched subscribe that filters out stale cached + // NIP-46 response events. Relays like primal.net store and replay previous + // "unauthorized" responses before we've even sent our request. + // We use a shared object so the flag can be set synchronously from outside + // the closure at exactly the right moment. + const sessionStart = Math.floor(Date.now() / 1000); + const gate = { open: false }; + const signerRef = { current: null }; // forward ref so the closure can access signer after it's created + const pool = new window.NostrTools.SimplePool(); + const origSubscribe = pool.subscribe.bind(pool); + pool.subscribe = (relays, filter, subParams) => { + const origOnevent = subParams.onevent; + subParams.onevent = async (event) => { + if (!gate.open) return; // drop events arriving before we open the gate + // Also skip if we can decrypt and see it's a response to a request id + // that we haven't sent yet (i.e. its id doesn't match any pending listener) + try { + const s = signerRef.current; + if (!s) return; // signer not yet created, drop + const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey)); + if (decrypted.id && !s.listeners[decrypted.id]) { + // No pending listener for this id — it's stale, drop it + return; + } + } catch (_) { + // Can't decrypt — not for us, drop it + return; + } + return origOnevent(event); + }; + return origSubscribe(relays, { ...filter, since: sessionStart }, subParams); + }; + + // Use nostr-tools BunkerSigner factory method, passing our patched pool const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { + pool, onauth: (url) => { - // console.log('Received auth URL from bunker:', url); - // Open auth URL in popup or redirect window.open(url, '_blank', 'width=600,height=800'); } }); + signerRef.current = signer; // allow the onevent closure to access the signer - // console.log('NIP-46 BunkerSigner created successfully'); + // NIP-46 connect requires 3 params: [remotePubkey, secret, perms] + // The nostr-tools BunkerSigner.connect() only sends [pubkey, secret] (2 params), + // which causes strict bunkers to respond "unauthorized" for subsequent calls + // because no permissions were explicitly requested. + // We call sendRequest directly to include the required perms in the connect call. + const perms = bunkerPointer.perms && bunkerPointer.perms.length + ? bunkerPointer.perms + : ['get_public_key', 'sign_event', 'nip04_encrypt', 'nip04_decrypt', 'nip44_encrypt', 'nip44_decrypt']; - // Skip ping test - NIP-46 works through relays, not direct connection - // Try to connect directly (this may trigger auth flow) - // console.log('Attempting NIP-46 connect...'); - await signer.connect(); - // console.log('NIP-46 connect successful'); + // Some signers (e.g. Amber) require the user to approve the connection on their + // device before responding with "ack". They respond "unauthorized" immediately + // and send "ack" once the user taps approve. We retry the connect request on + // "unauthorized" until the user approves or the overall timeout is reached. + const CONNECT_TIMEOUT_MS = 120000; // 2 minutes for user to approve on device + const RETRY_DELAY_MS = 3000; + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + let connectError = null; + let connectDone = false; + while (!connectDone) { + try { + gate.open = true; + await signer.sendRequest('connect', [bunkerPointer.pubkey, bunkerPointer.secret || '', perms.join(',')]); + connectDone = true; + } catch (err) { + if (String(err) === 'unauthorized' && Date.now() < deadline && !cancelToken.cancelled) { + connectError = err; + await new Promise(r => setTimeout(r, RETRY_DELAY_MS)); + // Only cancel during the retry wait, not after a successful connect + if (cancelToken.cancelled) return; + } else { + throw err; + } + } + } // Get the user's public key from the bunker - // console.log('Getting public key from bunker...'); + console.log('[NIP-46] connect done, requesting getPublicKey...'); const userPubkey = await signer.getPublicKey(); - // console.log('NIP-46 user public key:', userPubkey); + console.log('[NIP-46] getPublicKey returned:', userPubkey); // Store the NIP-46 authentication info const nip46Info = { @@ -1807,19 +1874,20 @@ class Modal { remotePubkey: bunkerPointer.pubkey, bunkerSigner: signer, secret: bunkerPointer.secret, - relays: bunkerPointer.relays + relays: bunkerPointer.relays, + localSecretKey: window.NostrTools.nip19.nsecEncode(localSecretKey) } }; - // console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!'); - // Set as current auth method + console.log('[NIP-46] connect success, pubkey:', userPubkey, 'calling _setAuthMethod...'); this._setAuthMethod('nip46', nip46Info); + console.log('[NIP-46] _setAuthMethod returned, modal.isVisible:', this.isVisible); return; } catch (error) { - console.error('NIP-46 connection failed:', error); - this._showNip46Error(error.message); + console.error('[NIP-46] connection failed:', error); + this._showNip46Error(error?.message || String(error)); } } @@ -3438,12 +3506,13 @@ class AuthManager { break; case 'nip46': - // For NIP-46, store connection parameters (no secrets) + // For NIP-46, store connection parameters including secret to allow auto-reconnect if (authData.signer) { authState.nip46 = { remotePubkey: authData.signer.remotePubkey, relays: authData.signer.relays, - // Don't store secret - user will need to reconnect + secret: authData.signer.secret, + localSecretKey: authData.signer.localSecretKey // We need to store this too }; } @@ -3726,10 +3795,88 @@ class AuthManager { return null; } - // For NIP-46, we can't automatically restore the connection - // because it requires the user to re-authenticate with the remote signer - // Instead, we return the connection parameters so the UI can prompt for reconnection + // If we have the local secret key and remote pubkey, we can auto-reconnect + if (authState.nip46.localSecretKey && authState.nip46.remotePubkey) { + try { + if (!window.NostrTools?.nip46) { + throw new Error('nostr-tools NIP-46 module not available'); + } + const localSecretKey = window.NostrTools.nip19.decode(authState.nip46.localSecretKey).data; + + // Reconstruct bunker pointer + const bunkerPointer = { + pubkey: authState.nip46.remotePubkey, + relays: authState.nip46.relays || [], + secret: authState.nip46.secret + }; + + // Create a SimplePool with a patched subscribe that filters out stale cached + // NIP-46 response events. Relays like primal.net store and replay previous + // "unauthorized" responses before we've even sent our request. + const sessionStart = Math.floor(Date.now() / 1000); + const gate = { open: false }; + const signerRef = { current: null }; // forward ref so the closure can access signer after it's created + const pool = new window.NostrTools.SimplePool(); + const origSubscribe = pool.subscribe.bind(pool); + pool.subscribe = (relays, filter, subParams) => { + const origOnevent = subParams.onevent; + subParams.onevent = async (event) => { + if (!gate.open) return; // drop events arriving before we open the gate + // Also skip if we can decrypt and see it's a response to a request id + // that we haven't sent yet (i.e. its id doesn't match any pending listener) + try { + const s = signerRef.current; + if (!s) return; // signer not yet created, drop + const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey)); + if (decrypted.id && !s.listeners[decrypted.id]) { + // No pending listener for this id — it's stale, drop it + return; + } + } catch (_) { + // Can't decrypt — not for us, drop it + return; + } + return origOnevent(event); + }; + return origSubscribe(relays, { ...filter, since: sessionStart }, subParams); + }; + + // Use nostr-tools BunkerSigner factory method, passing our patched pool + const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { + pool, + onauth: (url) => { + window.open(url, '_blank', 'width=600,height=800'); + } + }); + signerRef.current = signer; // allow the onevent closure to access the signer + + // Open the gate to allow events through + gate.open = true; + + // We don't need to call connect() again if we already have the pubkey and secret, + // but we do need to ensure the signer is ready to sign. + // The BunkerSigner will automatically subscribe to the relays when we call signEvent. + + return { + method: 'nip46', + pubkey: authState.pubkey, + signer: { + method: 'nip46', + remotePubkey: authState.nip46.remotePubkey, + bunkerSigner: signer, + secret: authState.nip46.secret, + relays: authState.nip46.relays + } + }; + } catch (error) { + console.error('🔍 AuthManager: Failed to auto-reconnect NIP-46:', error); + // Fall back to requiring reconnection + } + } + + // For NIP-46, if we can't auto-reconnect, we return the connection parameters + // so the UI can prompt for reconnection return { method: 'nip46', pubkey: authState.pubkey, diff --git a/src/VERSION b/src/VERSION index 0e24a92..7ac4e5e 100644 --- a/src/VERSION +++ b/src/VERSION @@ -1 +1 @@ -0.1.12 +0.1.13 diff --git a/src/build.js b/src/build.js index 32466b8..4f3cbd5 100644 --- a/src/build.js +++ b/src/build.js @@ -1427,12 +1427,13 @@ class AuthManager { break; case 'nip46': - // For NIP-46, store connection parameters (no secrets) + // For NIP-46, store connection parameters including secret to allow auto-reconnect if (authData.signer) { authState.nip46 = { remotePubkey: authData.signer.remotePubkey, relays: authData.signer.relays, - // Don't store secret - user will need to reconnect + secret: authData.signer.secret, + localSecretKey: authData.signer.localSecretKey // We need to store this too }; } @@ -1715,10 +1716,88 @@ class AuthManager { return null; } - // For NIP-46, we can't automatically restore the connection - // because it requires the user to re-authenticate with the remote signer - // Instead, we return the connection parameters so the UI can prompt for reconnection + // If we have the local secret key and remote pubkey, we can auto-reconnect + if (authState.nip46.localSecretKey && authState.nip46.remotePubkey) { + try { + if (!window.NostrTools?.nip46) { + throw new Error('nostr-tools NIP-46 module not available'); + } + const localSecretKey = window.NostrTools.nip19.decode(authState.nip46.localSecretKey).data; + + // Reconstruct bunker pointer + const bunkerPointer = { + pubkey: authState.nip46.remotePubkey, + relays: authState.nip46.relays || [], + secret: authState.nip46.secret + }; + + // Create a SimplePool with a patched subscribe that filters out stale cached + // NIP-46 response events. Relays like primal.net store and replay previous + // "unauthorized" responses before we've even sent our request. + const sessionStart = Math.floor(Date.now() / 1000); + const gate = { open: false }; + const signerRef = { current: null }; // forward ref so the closure can access signer after it's created + const pool = new window.NostrTools.SimplePool(); + const origSubscribe = pool.subscribe.bind(pool); + pool.subscribe = (relays, filter, subParams) => { + const origOnevent = subParams.onevent; + subParams.onevent = async (event) => { + if (!gate.open) return; // drop events arriving before we open the gate + // Also skip if we can decrypt and see it's a response to a request id + // that we haven't sent yet (i.e. its id doesn't match any pending listener) + try { + const s = signerRef.current; + if (!s) return; // signer not yet created, drop + const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey)); + if (decrypted.id && !s.listeners[decrypted.id]) { + // No pending listener for this id — it's stale, drop it + return; + } + } catch (_) { + // Can't decrypt — not for us, drop it + return; + } + return origOnevent(event); + }; + return origSubscribe(relays, { ...filter, since: sessionStart }, subParams); + }; + + // Use nostr-tools BunkerSigner factory method, passing our patched pool + const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { + pool, + onauth: (url) => { + window.open(url, '_blank', 'width=600,height=800'); + } + }); + signerRef.current = signer; // allow the onevent closure to access the signer + + // Open the gate to allow events through + gate.open = true; + + // We don't need to call connect() again if we already have the pubkey and secret, + // but we do need to ensure the signer is ready to sign. + // The BunkerSigner will automatically subscribe to the relays when we call signEvent. + + return { + method: 'nip46', + pubkey: authState.pubkey, + signer: { + method: 'nip46', + remotePubkey: authState.nip46.remotePubkey, + bunkerSigner: signer, + secret: authState.nip46.secret, + relays: authState.nip46.relays + } + }; + } catch (error) { + console.error('🔍 AuthManager: Failed to auto-reconnect NIP-46:', error); + // Fall back to requiring reconnection + } + } + + // For NIP-46, if we can't auto-reconnect, we return the connection parameters + // so the UI can prompt for reconnection return { method: 'nip46', pubkey: authState.pubkey, diff --git a/src/ui/modal.js b/src/ui/modal.js index 09f0288..9e64ad8 100644 --- a/src/ui/modal.js +++ b/src/ui/modal.js @@ -180,7 +180,10 @@ class Modal { // No manual styling needed - the CSS variables handle everything } - open(opts = {}) { + open(opts = {}) { + // If a NIP-46 connect is in progress, don't overwrite the connecting UI + if (this._nip46Connecting) return; + this.currentScreen = opts.startScreen; this.isVisible = true; this.container.style.display = 'block'; @@ -1395,14 +1398,20 @@ class Modal { } } - _handleNip46Connect(bunkerPubkey) { + _handleNip46Connect(bunkerPubkey) { if (!bunkerPubkey || !bunkerPubkey.length) { this._showError('Bunker pubkey is required'); return; } this._showNip46Connecting(bunkerPubkey); - this._performNip46Connect(bunkerPubkey); + + // While connecting, prevent modal.open() from overwriting the "Connecting..." + // state (e.g. when init-ndk's auth timeout fires and calls launch() again). + this._nip46Connecting = true; + this._performNip46Connect(bunkerPubkey).finally(() => { + this._nip46Connecting = false; + }); } _showNip46Connecting(bunkerPubkey) { @@ -1441,51 +1450,109 @@ class Modal { this.modalBody.appendChild(connectingDiv); } - async _performNip46Connect(bunkerPubkey) { - try { - // console.log('Starting NIP-46 connection to bunker:', bunkerPubkey); + async _performNip46Connect(bunkerPubkey) { + // Cancel any previous in-flight connect attempt + if (this._nip46CancelToken) this._nip46CancelToken.cancelled = true; + const cancelToken = { cancelled: false }; + this._nip46CancelToken = cancelToken; + try { // Check if nostr-tools NIP-46 is available if (!window.NostrTools?.nip46) { throw new Error('nostr-tools NIP-46 module not available'); } // Use nostr-tools to parse bunker input - this handles all formats correctly - // console.log('Parsing bunker input with nostr-tools...'); const bunkerPointer = await window.NostrTools.nip46.parseBunkerInput(bunkerPubkey); if (!bunkerPointer) { throw new Error('Unable to parse bunker connection string or resolve NIP-05 identifier'); } - // console.log('Parsed bunker pointer:', bunkerPointer); - // Create local client keypair for this session const localSecretKey = window.NostrTools.generateSecretKey(); - // console.log('Generated local client keypair for NIP-46 session'); - // Use nostr-tools BunkerSigner factory method (not constructor - it's private) - // console.log('Creating nip46 BunkerSigner...'); + // Create a SimplePool with a patched subscribe that filters out stale cached + // NIP-46 response events. Relays like primal.net store and replay previous + // "unauthorized" responses before we've even sent our request. + // We use a shared object so the flag can be set synchronously from outside + // the closure at exactly the right moment. + const sessionStart = Math.floor(Date.now() / 1000); + const gate = { open: false }; + const signerRef = { current: null }; // forward ref so the closure can access signer after it's created + const pool = new window.NostrTools.SimplePool(); + const origSubscribe = pool.subscribe.bind(pool); + pool.subscribe = (relays, filter, subParams) => { + const origOnevent = subParams.onevent; + subParams.onevent = async (event) => { + if (!gate.open) return; // drop events arriving before we open the gate + // Also skip if we can decrypt and see it's a response to a request id + // that we haven't sent yet (i.e. its id doesn't match any pending listener) + try { + const s = signerRef.current; + if (!s) return; // signer not yet created, drop + const decrypted = JSON.parse(window.NostrTools.nip44.decrypt(event.content, s.conversationKey)); + if (decrypted.id && !s.listeners[decrypted.id]) { + // No pending listener for this id — it's stale, drop it + return; + } + } catch (_) { + // Can't decrypt — not for us, drop it + return; + } + return origOnevent(event); + }; + return origSubscribe(relays, { ...filter, since: sessionStart }, subParams); + }; + + // Use nostr-tools BunkerSigner factory method, passing our patched pool const signer = window.NostrTools.nip46.BunkerSigner.fromBunker(localSecretKey, bunkerPointer, { + pool, onauth: (url) => { - // console.log('Received auth URL from bunker:', url); - // Open auth URL in popup or redirect window.open(url, '_blank', 'width=600,height=800'); } }); + signerRef.current = signer; // allow the onevent closure to access the signer - // console.log('NIP-46 BunkerSigner created successfully'); + // NIP-46 connect requires 3 params: [remotePubkey, secret, perms] + // The nostr-tools BunkerSigner.connect() only sends [pubkey, secret] (2 params), + // which causes strict bunkers to respond "unauthorized" for subsequent calls + // because no permissions were explicitly requested. + // We call sendRequest directly to include the required perms in the connect call. + const perms = bunkerPointer.perms && bunkerPointer.perms.length + ? bunkerPointer.perms + : ['get_public_key', 'sign_event', 'nip04_encrypt', 'nip04_decrypt', 'nip44_encrypt', 'nip44_decrypt']; - // Skip ping test - NIP-46 works through relays, not direct connection - // Try to connect directly (this may trigger auth flow) - // console.log('Attempting NIP-46 connect...'); - await signer.connect(); - // console.log('NIP-46 connect successful'); + // Some signers (e.g. Amber) require the user to approve the connection on their + // device before responding with "ack". They respond "unauthorized" immediately + // and send "ack" once the user taps approve. We retry the connect request on + // "unauthorized" until the user approves or the overall timeout is reached. + const CONNECT_TIMEOUT_MS = 120000; // 2 minutes for user to approve on device + const RETRY_DELAY_MS = 3000; + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + let connectError = null; + let connectDone = false; + while (!connectDone) { + try { + gate.open = true; + await signer.sendRequest('connect', [bunkerPointer.pubkey, bunkerPointer.secret || '', perms.join(',')]); + connectDone = true; + } catch (err) { + if (String(err) === 'unauthorized' && Date.now() < deadline && !cancelToken.cancelled) { + connectError = err; + await new Promise(r => setTimeout(r, RETRY_DELAY_MS)); + // Only cancel during the retry wait, not after a successful connect + if (cancelToken.cancelled) return; + } else { + throw err; + } + } + } // Get the user's public key from the bunker - // console.log('Getting public key from bunker...'); + console.log('[NIP-46] connect done, requesting getPublicKey...'); const userPubkey = await signer.getPublicKey(); - // console.log('NIP-46 user public key:', userPubkey); + console.log('[NIP-46] getPublicKey returned:', userPubkey); // Store the NIP-46 authentication info const nip46Info = { @@ -1495,19 +1562,20 @@ class Modal { remotePubkey: bunkerPointer.pubkey, bunkerSigner: signer, secret: bunkerPointer.secret, - relays: bunkerPointer.relays + relays: bunkerPointer.relays, + localSecretKey: window.NostrTools.nip19.nsecEncode(localSecretKey) } }; - // console.log('NOSTR_LOGIN_LITE NIP-46 connection established successfully!'); - // Set as current auth method + console.log('[NIP-46] connect success, pubkey:', userPubkey, 'calling _setAuthMethod...'); this._setAuthMethod('nip46', nip46Info); + console.log('[NIP-46] _setAuthMethod returned, modal.isVisible:', this.isVisible); return; } catch (error) { - console.error('NIP-46 connection failed:', error); - this._showNip46Error(error.message); + console.error('[NIP-46] connection failed:', error); + this._showNip46Error(error?.message || String(error)); } }