Fix NIP-46 signing across pages by auto-reconnecting BunkerSigner

This commit is contained in:
Laan Tungir
2026-04-04 09:40:23 -04:00
parent 71347ea1bf
commit 7af85a45a7
4 changed files with 361 additions and 67 deletions

View File

@@ -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,

View File

@@ -1 +1 @@
0.1.12
0.1.13

View File

@@ -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,

View File

@@ -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));
}
}