From 8f34c2de73aaf4df0ac0149d79c496c9b2db02ed Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 19 Sep 2025 16:09:05 -0400 Subject: [PATCH] Seem to have most everything working well. Got persistant state after page refresh, and implmented logout call --- lite/build.js | 1012 ++++++++++++++++++++++++++++++++++++++----- lite/nostr-lite.js | 1014 +++++++++++++++++++++++++++++++++++++++----- 2 files changed, 1797 insertions(+), 229 deletions(-) diff --git a/lite/build.js b/lite/build.js index ce1a31a..5a9bdea 100644 --- a/lite/build.js +++ b/lite/build.js @@ -270,24 +270,112 @@ class FloatingTab { // Listen for authentication events window.addEventListener('nlMethodSelected', (e) => { - console.log('FloatingTab: Authentication method selected:', e.detail); + console.log('🔍 FloatingTab: Authentication method selected event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + this._handleAuth(e.detail); + }); + + window.addEventListener('nlAuthRestored', (e) => { + console.log('🔍 FloatingTab: ✅ Authentication restored event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + console.log('🔍 FloatingTab: Calling _handleAuth with restored data...'); this._handleAuth(e.detail); }); window.addEventListener('nlLogout', () => { - console.log('FloatingTab: Logout detected'); + console.log('🔍 FloatingTab: Logout event received'); this._handleLogout(); }); } - _handleClick() { + async _handleClick() { console.log('FloatingTab: Clicked'); if (this.isAuthenticated && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { - // Open login modal + // Check if extension is available for direct login + if (window.nostr && this._isRealExtension(window.nostr)) { + console.log('FloatingTab: Extension available, attempting direct extension login'); + await this._tryExtensionLogin(window.nostr); + } else { + // Open login modal + if (this.modal) { + this.modal.open({ startScreen: 'login' }); + } + } + } + } + + // Check if object is a real extension (same logic as NostrLite._isRealExtension) + _isRealExtension(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { + return false; + } + + // Exclude our own library classes + const constructorName = obj.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Exclude NostrTools library object + if (obj === window.NostrTools) { + return false; + } + + // Conservative check: Look for common extension characteristics + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; + + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; + + return hasIndicators || hasExtensionConstructor; + } + + // Try to login with extension and trigger proper persistence + async _tryExtensionLogin(extension) { + try { + console.log('FloatingTab: Attempting extension login'); + + // Get pubkey from extension + const pubkey = await extension.getPublicKey(); + console.log('FloatingTab: Extension provided pubkey:', pubkey); + + // Create extension auth data + const extensionAuth = { + method: 'extension', + pubkey: pubkey, + extension: extension + }; + + // **CRITICAL FIX**: Dispatch nlMethodSelected event to trigger persistence + console.log('FloatingTab: Dispatching nlMethodSelected for persistence'); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlMethodSelected', { + detail: extensionAuth + })); + } + + // Also call our local _handleAuth for UI updates + await this._handleAuth(extensionAuth); + + } catch (error) { + console.error('FloatingTab: Extension login failed:', error); + // Fall back to opening modal if (this.modal) { this.modal.open({ startScreen: 'login' }); } @@ -295,28 +383,42 @@ class FloatingTab { } async _handleAuth(authData) { - console.log('FloatingTab: Handling authentication:', authData); + console.log('🔍 FloatingTab: === _handleAuth START ==='); + console.log('🔍 FloatingTab: authData received:', authData); + console.log('🔍 FloatingTab: Current isAuthenticated before:', this.isAuthenticated); + this.isAuthenticated = true; this.userInfo = authData; + console.log('🔍 FloatingTab: Set isAuthenticated to true'); + console.log('🔍 FloatingTab: Set userInfo to:', this.userInfo); + // Fetch user profile if enabled and we have a pubkey if (this.options.getUserInfo && authData.pubkey) { - console.log('FloatingTab: Fetching user profile for:', authData.pubkey); + console.log('🔍 FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); try { const profile = await this._fetchUserProfile(authData.pubkey); this.userProfile = profile; - console.log('FloatingTab: User profile fetched:', profile); + console.log('🔍 FloatingTab: User profile fetched:', profile); } catch (error) { - console.warn('FloatingTab: Failed to fetch user profile:', error); + console.warn('🔍 FloatingTab: Failed to fetch user profile:', error); this.userProfile = null; } + } else { + console.log('🔍 FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); } + console.log('🔍 FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); + if (this.options.behavior.hideWhenAuthenticated) { + console.log('🔍 FloatingTab: Hiding tab (hideWhenAuthenticated=true)'); this.hide(); } else { + console.log('🔍 FloatingTab: Updating appearance (hideWhenAuthenticated=false)'); this._updateAppearance(); } + + console.log('🔍 FloatingTab: === _handleAuth END ==='); } _handleLogout() { @@ -663,6 +765,7 @@ class NostrLite { this.options = { theme: 'default', + persistence: true, // Enable persistent authentication by default methods: { extension: true, local: true, @@ -699,7 +802,9 @@ class NostrLite { this.switchTheme(this.options.theme); // Always set up window.nostr facade to handle multiple extensions properly - this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Setting up facade before other initialization...'); + await this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Facade setup complete, continuing initialization...'); // Create modal during init (matching original git architecture) this.modal = new Modal(this.options); @@ -711,101 +816,74 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Floating tab initialized'); } + // Attempt to restore authentication state if persistence is enabled (AFTER facade is ready) + if (this.options.persistence) { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence enabled, attempting auth restoration...'); + await this._attemptAuthRestore(); + } else { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence disabled in options'); + } + this.initialized = true; console.log('NOSTR_LOGIN_LITE: Initialization complete'); return this; } - _setupWindowNostrFacade() { + async _setupWindowNostrFacade() { if (typeof window !== 'undefined') { - console.log('NOSTR_LOGIN_LITE: === TRUE SINGLE-EXTENSION ARCHITECTURE ==='); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr:', window.nostr); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr constructor:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === EXTENSION-FIRST FACADE SETUP ==='); + console.log('🔍 NOSTR_LOGIN_LITE: Current window.nostr:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: Constructor:', window.nostr?.constructor?.name); - // Store existing window.nostr if it exists (from extensions) - const existingNostr = window.nostr; - - // TRUE SINGLE-EXTENSION ARCHITECTURE: Don't install facade when extensions detected - if (this._isRealExtension(existingNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓ REAL EXTENSION DETECTED IMMEDIATELY - PRESERVING WITHOUT FACADE'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', existingNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(existingNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = existingNostr; - this.facadeInstalled = false; - // DON'T install facade - leave window.nostr as the extension - return; + // EXTENSION-FIRST ARCHITECTURE: Never interfere with real extensions + if (this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ REAL EXTENSION DETECTED - WILL NOT INSTALL FACADE'); + console.log('🔍 NOSTR_LOGIN_LITE: Extension constructor:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: Extensions will handle window.nostr directly'); + + // Store reference for persistence verification + this.detectedExtension = window.nostr; + this.hasExtension = true; + this.facadeInstalled = false; // We deliberately don't install facade for extensions + + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - no facade interference'); + return; // Don't install facade at all for extensions } - // DEFERRED EXTENSION DETECTION: Extensions like nos2x may load after us - console.log('NOSTR_LOGIN_LITE: No real extension detected initially, starting deferred detection...'); - this.facadeInstalled = false; + // NO EXTENSION: Install facade for local/NIP-46/readonly methods + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No real extension detected'); + console.log('🔍 NOSTR_LOGIN_LITE: Installing facade for non-extension authentication'); - let checkCount = 0; - const maxChecks = 10; // Check for up to 2 seconds - const checkInterval = setInterval(() => { - checkCount++; - const currentNostr = window.nostr; - - console.log('NOSTR_LOGIN_LITE: === DEFERRED CHECK ' + checkCount + '/' + maxChecks + ' ==='); - console.log('NOSTR_LOGIN_LITE: Current window.nostr:', currentNostr); - console.log('NOSTR_LOGIN_LITE: Constructor:', currentNostr?.constructor?.name); - - // Skip if it's our facade - if (currentNostr?.constructor?.name === 'WindowNostr') { - console.log('NOSTR_LOGIN_LITE: Skipping - this is our facade'); - return; - } - - if (this._isRealExtension(currentNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓✓✓ LATE EXTENSION DETECTED - PRESERVING WITHOUT FACADE ✓✓✓'); - console.log('NOSTR_LOGIN_LITE: Extension detected after ' + (checkCount * 200) + 'ms!'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', currentNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(currentNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = currentNostr; - this.facadeInstalled = false; - clearInterval(checkInterval); - // DON'T install facade - leave window.nostr as the extension - return; - } - - // Stop checking after max attempts - no extension found - if (checkCount >= maxChecks) { - console.log('NOSTR_LOGIN_LITE: ⚠️ MAX CHECKS REACHED - NO EXTENSION FOUND'); - clearInterval(checkInterval); - console.log('NOSTR_LOGIN_LITE: Installing facade for local/NIP-46/readonly methods'); - this._installFacade(); - } - }, 200); // Check every 200ms + this.hasExtension = false; + this._installFacade(window.nostr); // Install facade with any existing nostr object - console.log('NOSTR_LOGIN_LITE: Waiting for deferred detection to complete...'); + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade installed for local/NIP-46/readonly methods'); } } _installFacade(existingNostr = null) { if (typeof window !== 'undefined' && !this.facadeInstalled) { - console.log('NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); - console.log('NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); - console.log('NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); - console.log('NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); const facade = new WindowNostr(this, existingNostr); window.nostr = facade; this.facadeInstalled = true; - console.log('NOSTR_LOGIN_LITE: === FACADE INSTALLED WITH EXTENSION ==='); - console.log('NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: === FACADE INSTALLED FOR PERSISTENCE ==='); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); } } - // Helper method to identify real browser extensions + // Conservative method to identify real browser extensions _isRealExtension(obj) { - console.log('NOSTR_LOGIN_LITE: === _isRealExtension DEBUG ==='); + console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ==='); console.log('NOSTR_LOGIN_LITE: obj:', obj); console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj); @@ -814,13 +892,9 @@ class NostrLite { return false; } - console.log('NOSTR_LOGIN_LITE: Object keys:', Object.keys(obj)); - console.log('NOSTR_LOGIN_LITE: getPublicKey type:', typeof obj.getPublicKey); - console.log('NOSTR_LOGIN_LITE: signEvent type:', typeof obj.signEvent); - // Must have required Nostr methods if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { - console.log('NOSTR_LOGIN_LITE: ✗ Missing required methods'); + console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods'); return false; } @@ -829,37 +903,37 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName); if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { - console.log('NOSTR_LOGIN_LITE: ✗ Is our library class'); + console.log('NOSTR_LOGIN_LITE: ✗ Is our library class - NOT an extension'); return false; } // Exclude NostrTools library object if (obj === window.NostrTools) { - console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object'); + console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object - NOT an extension'); return false; } - // Real extensions typically have internal properties or specific characteristics - console.log('NOSTR_LOGIN_LITE: Extension property check:'); - console.log(' _isEnabled:', !!obj._isEnabled); - console.log(' enabled:', !!obj.enabled); - console.log(' kind:', !!obj.kind); - console.log(' _eventEmitter:', !!obj._eventEmitter); - console.log(' _scope:', !!obj._scope); - console.log(' _requests:', !!obj._requests); - console.log(' _pubkey:', !!obj._pubkey); - console.log(' name:', !!obj.name); - console.log(' version:', !!obj.version); - console.log(' description:', !!obj.description); + // Conservative check: Look for common extension characteristics + // Real extensions usually have some of these internal properties + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; - const hasExtensionProps = !!( - obj._isEnabled || obj.enabled || obj.kind || - obj._eventEmitter || obj._scope || obj._requests || obj._pubkey || - obj.name || obj.version || obj.description - ); + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; - console.log('NOSTR_LOGIN_LITE: Extension detection result for', constructorName, ':', hasExtensionProps); - return hasExtensionProps; + const isExtension = hasIndicators || hasExtensionConstructor; + + console.log('NOSTR_LOGIN_LITE: Extension indicators found:', hasIndicators); + console.log('NOSTR_LOGIN_LITE: Has extension constructor:', hasExtensionConstructor); + console.log('NOSTR_LOGIN_LITE: Final result for', constructorName, ':', isExtension); + + return isExtension; } launch(startScreen = 'login') { @@ -872,15 +946,149 @@ class NostrLite { } } + // Attempt to restore authentication state + async _attemptAuthRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptAuthRestore START ==='); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr:', window.nostr?.constructor?.name); + + if (this.hasExtension) { + // EXTENSION MODE: Use custom extension persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - using extension-specific restore'); + const restoredAuth = await this._attemptExtensionRestore(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth restored successfully!'); + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored'); + return null; + } + } else if (this.facadeInstalled && window.nostr?.restoreAuthState) { + // NON-EXTENSION MODE: Use facade persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Non-extension mode - using facade restore'); + const restoredAuth = await window.nostr.restoreAuthState(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade auth restored successfully!'); + console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method); + console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey); + + // Handle NIP-46 reconnection requirement + if (restoredAuth.requiresReconnection) { + console.log('🔍 NOSTR_LOGIN_LITE: NIP-46 connection requires user reconnection'); + this._showReconnectionPrompt(restoredAuth); + } + + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Facade auth could not be restored'); + return null; + } + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No restoration method available'); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr.restoreAuthState:', typeof window.nostr?.restoreAuthState); + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Auth restoration failed with error:', error); + console.error('🔍 NOSTR_LOGIN_LITE: Error stack:', error.stack); + return null; + } + } + + // Extension-specific authentication restoration + async _attemptExtensionRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ==='); + + // Use a simple AuthManager instance for extension persistence + const authManager = new AuthManager(); + const storedAuth = await authManager.restoreAuthState(); + + if (!storedAuth || storedAuth.method !== 'extension') { + console.log('🔍 NOSTR_LOGIN_LITE: No extension auth state stored'); + return null; + } + + // Verify the extension is still available and working + if (!window.nostr || !this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension no longer available'); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + try { + // Test that the extension still works with the same pubkey + const currentPubkey = await window.nostr.getPublicKey(); + if (currentPubkey !== storedAuth.pubkey) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension pubkey changed, clearing state'); + authManager.clearAuthState(); + return null; + } + + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth verification successful'); + + // Create extension auth data for UI restoration + const extensionAuth = { + method: 'extension', + pubkey: storedAuth.pubkey, + extension: window.nostr + }; + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 NOSTR_LOGIN_LITE: Dispatching nlAuthRestored event for extension'); + window.dispatchEvent(new CustomEvent('nlAuthRestored', { + detail: extensionAuth + })); + } + + return extensionAuth; + + } catch (error) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension verification failed:', error); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Extension restore failed:', error); + return null; + } + } + + // Show prompt for NIP-46 reconnection + _showReconnectionPrompt(authData) { + console.log('NOSTR_LOGIN_LITE: Showing reconnection prompt for NIP-46'); + + // Dispatch event that UI can listen to + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: authData.method, + pubkey: authData.pubkey, + connectionData: authData.connectionData, + message: 'Your NIP-46 session has expired. Please reconnect to continue.' + } + })); + } + } + logout() { console.log('NOSTR_LOGIN_LITE: Logout called'); - // Clear stored data + // Clear legacy stored data if (typeof localStorage !== 'undefined') { localStorage.removeItem('nl_current'); } - // Dispatch logout event + // Dispatch logout event (AuthManager will clear its own data via WindowNostr listener) if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('nlLogout', { detail: { timestamp: Date.now() } @@ -972,6 +1180,515 @@ class NostrLite { } } +// ====================================== +// Authentication Manager for Persistent Login +// ====================================== + +// Encryption utilities for secure local storage +class CryptoUtils { + static async generateKey() { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + return await window.crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async deriveKey(password, salt) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + + return await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async encrypt(data, key) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encoder.encode(data) + ); + + return { + encrypted: new Uint8Array(encrypted), + iv: iv + }; + } + + static async decrypt(encryptedData, key, iv) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + static arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + static base64ToArrayBuffer(base64) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } +} + +// Unified authentication state manager +class AuthManager { + constructor() { + this.storageKey = 'nostr_login_lite_auth'; + this.currentAuthState = null; + } + + // Save authentication state with method-specific security + async saveAuthState(authData) { + try { + const authState = { + method: authData.method, + timestamp: Date.now(), + pubkey: authData.pubkey + }; + + switch (authData.method) { + case 'extension': + // For extensions, only store verification data - no secrets + authState.extensionVerification = { + constructor: authData.extension?.constructor?.name, + hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function', + hasSignEvent: typeof authData.extension?.signEvent === 'function' + }; + break; + + case 'local': + // For local keys, encrypt the secret key + if (authData.secret) { + const password = this._generateSessionPassword(); + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const key = await CryptoUtils.deriveKey(password, salt); + const encrypted = await CryptoUtils.encrypt(authData.secret, key); + + authState.encrypted = { + data: CryptoUtils.arrayBufferToBase64(encrypted.encrypted), + iv: CryptoUtils.arrayBufferToBase64(encrypted.iv), + salt: CryptoUtils.arrayBufferToBase64(salt) + }; + + // Store session password in sessionStorage (cleared on tab close) + sessionStorage.setItem('nostr_session_key', password); + } + break; + + case 'nip46': + // For NIP-46, store connection parameters (no secrets) + if (authData.signer) { + authState.nip46 = { + remotePubkey: authData.signer.remotePubkey, + relays: authData.signer.relays, + // Don't store secret - user will need to reconnect + }; + } + break; + + case 'readonly': + // Read-only mode has no secrets to store + break; + + default: + throw new Error(\`Unknown auth method: \${authData.method}\`); + } + + localStorage.setItem(this.storageKey, JSON.stringify(authState)); + this.currentAuthState = authState; + console.log('AuthManager: Auth state saved for method:', authData.method); + + } catch (error) { + console.error('AuthManager: Failed to save auth state:', error); + throw error; + } + } + + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 AuthManager: === restoreAuthState START ==='); + console.log('🔍 AuthManager: storageKey:', this.storageKey); + + const stored = localStorage.getItem(this.storageKey); + console.log('🔍 AuthManager: localStorage raw value:', stored); + + if (!stored) { + console.log('🔍 AuthManager: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('🔍 AuthManager: ✅ Parsed stored auth state:', authState); + console.log('🔍 AuthManager: Method:', authState.method); + console.log('🔍 AuthManager: Timestamp:', authState.timestamp); + console.log('🔍 AuthManager: Age (ms):', Date.now() - authState.timestamp); + + // Check if stored state is too old (24 hours for most methods, 1 hour for extensions) + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + console.log('🔍 AuthManager: Max age for method:', maxAge, 'ms'); + + if (Date.now() - authState.timestamp > maxAge) { + console.log('🔍 AuthManager: ❌ Stored auth state expired, clearing'); + this.clearAuthState(); + return null; + } + + console.log('🔍 AuthManager: ✅ Auth state not expired, attempting restore for method:', authState.method); + + let result; + switch (authState.method) { + case 'extension': + console.log('🔍 AuthManager: Calling _restoreExtensionAuth...'); + result = await this._restoreExtensionAuth(authState); + break; + + case 'local': + console.log('🔍 AuthManager: Calling _restoreLocalAuth...'); + result = await this._restoreLocalAuth(authState); + break; + + case 'nip46': + console.log('🔍 AuthManager: Calling _restoreNip46Auth...'); + result = await this._restoreNip46Auth(authState); + break; + + case 'readonly': + console.log('🔍 AuthManager: Calling _restoreReadonlyAuth...'); + result = await this._restoreReadonlyAuth(authState); + break; + + default: + console.warn('🔍 AuthManager: ❌ Unknown auth method in stored state:', authState.method); + return null; + } + + console.log('🔍 AuthManager: Restore method result:', result); + console.log('🔍 AuthManager: === restoreAuthState END ==='); + return result; + + } catch (error) { + console.error('🔍 AuthManager: ❌ Failed to restore auth state:', error); + console.error('🔍 AuthManager: Error stack:', error.stack); + this.clearAuthState(); // Clear corrupted state + return null; + } + } + + async _restoreExtensionAuth(authState) { + console.log('🔍 AuthManager: === _restoreExtensionAuth START ==='); + console.log('🔍 AuthManager: authState:', authState); + console.log('🔍 AuthManager: window.nostr available:', !!window.nostr); + console.log('🔍 AuthManager: window.nostr constructor:', window.nostr?.constructor?.name); + + // SMART EXTENSION WAITING SYSTEM + // Extensions often load after our library, so we need to wait for them + const extension = await this._waitForExtension(authState, 3000); // Wait up to 3 seconds + + if (!extension) { + console.log('🔍 AuthManager: ❌ No extension found after waiting'); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension found:', extension.constructor?.name); + + try { + // Verify extension still works and has same pubkey + const currentPubkey = await extension.getPublicKey(); + if (currentPubkey !== authState.pubkey) { + console.log('🔍 AuthManager: ❌ Extension pubkey changed, not restoring'); + console.log('🔍 AuthManager: Expected:', authState.pubkey); + console.log('🔍 AuthManager: Got:', currentPubkey); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension auth restored successfully'); + return { + method: 'extension', + pubkey: authState.pubkey, + extension: extension + }; + + } catch (error) { + console.log('🔍 AuthManager: ❌ Extension verification failed:', error); + return null; + } + } + + // Smart extension waiting system - polls multiple locations for extensions + async _waitForExtension(authState, maxWaitMs = 3000) { + console.log('🔍 AuthManager: === _waitForExtension START ==='); + console.log('🔍 AuthManager: maxWaitMs:', maxWaitMs); + console.log('🔍 AuthManager: Looking for extension with constructor:', authState.extensionVerification?.constructor); + + const startTime = Date.now(); + const pollInterval = 100; // Check every 100ms + + // Extension locations to check (in priority order) + const extensionLocations = [ + { path: 'window.nostr', getter: () => window.nostr }, + { path: 'navigator.nostr', getter: () => navigator?.nostr }, + { path: 'window.navigator?.nostr', getter: () => window.navigator?.nostr }, + { path: 'window.alby?.nostr', getter: () => window.alby?.nostr }, + { path: 'window.webln?.nostr', getter: () => window.webln?.nostr }, + { path: 'window.nos2x', getter: () => window.nos2x }, + { path: 'window.flamingo?.nostr', getter: () => window.flamingo?.nostr }, + { path: 'window.mutiny?.nostr', getter: () => window.mutiny?.nostr } + ]; + + while (Date.now() - startTime < maxWaitMs) { + console.log('🔍 AuthManager: Polling for extensions... (elapsed:', Date.now() - startTime, 'ms)'); + + // If our facade is currently installed and blocking, temporarily remove it + let facadeRemoved = false; + let originalNostr = null; + if (window.nostr?.constructor?.name === 'WindowNostr') { + console.log('🔍 AuthManager: Temporarily removing our facade to check for real extensions'); + originalNostr = window.nostr; + window.nostr = window.nostr.existingNostr || undefined; + facadeRemoved = true; + } + + try { + // Check all extension locations + for (const location of extensionLocations) { + try { + const extension = location.getter(); + console.log('🔍 AuthManager: Checking', location.path, ':', !!extension, extension?.constructor?.name); + + if (this._isValidExtensionForRestore(extension, authState)) { + console.log('🔍 AuthManager: ✅ Found matching extension at', location.path); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + console.log('🔍 AuthManager: Restoring facade after finding extension'); + window.nostr = originalNostr; + } + + return extension; + } + } catch (error) { + console.log('🔍 AuthManager: Error checking', location.path, ':', error.message); + } + } + + // Restore facade if we removed it and haven't found an extension yet + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + facadeRemoved = false; + } + + } catch (error) { + console.error('🔍 AuthManager: Error during extension polling:', error); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + } + } + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + console.log('🔍 AuthManager: ❌ Extension waiting timeout after', maxWaitMs, 'ms'); + return null; + } + + // Check if an extension is valid for restoration + _isValidExtensionForRestore(extension, authState) { + if (!extension || typeof extension !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof extension.getPublicKey !== 'function' || + typeof extension.signEvent !== 'function') { + return false; + } + + // Must not be our own classes + const constructorName = extension.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Must not be NostrTools + if (extension === window.NostrTools) { + return false; + } + + // If we have stored verification data, check constructor match + const verification = authState.extensionVerification; + if (verification && verification.constructor) { + if (constructorName !== verification.constructor) { + console.log('🔍 AuthManager: Constructor mismatch -', + 'expected:', verification.constructor, + 'got:', constructorName); + return false; + } + } + + console.log('🔍 AuthManager: ✅ Extension validation passed for:', constructorName); + return true; + } + + async _restoreLocalAuth(authState) { + if (!authState.encrypted) { + console.log('AuthManager: No encrypted data found for local auth'); + return null; + } + + // Get session password + const sessionPassword = sessionStorage.getItem('nostr_session_key'); + if (!sessionPassword) { + console.log('AuthManager: Session password not found, cannot decrypt'); + return null; + } + + try { + // Decrypt the secret key + const salt = CryptoUtils.base64ToArrayBuffer(authState.encrypted.salt); + const key = await CryptoUtils.deriveKey(sessionPassword, new Uint8Array(salt)); + + const encryptedData = CryptoUtils.base64ToArrayBuffer(authState.encrypted.data); + const iv = CryptoUtils.base64ToArrayBuffer(authState.encrypted.iv); + + const secret = await CryptoUtils.decrypt(encryptedData, key, new Uint8Array(iv)); + + console.log('AuthManager: Local auth restored successfully'); + return { + method: 'local', + pubkey: authState.pubkey, + secret: secret + }; + + } catch (error) { + console.error('AuthManager: Failed to decrypt local key:', error); + return null; + } + } + + async _restoreNip46Auth(authState) { + if (!authState.nip46) { + console.log('AuthManager: No NIP-46 data found'); + 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 + console.log('AuthManager: NIP-46 connection data found, requires user reconnection'); + return { + method: 'nip46', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: authState.nip46 + }; + } + + async _restoreReadonlyAuth(authState) { + console.log('AuthManager: Read-only auth restored successfully'); + return { + method: 'readonly', + pubkey: authState.pubkey + }; + } + + // Clear stored authentication state + clearAuthState() { + localStorage.removeItem(this.storageKey); + sessionStorage.removeItem('nostr_session_key'); + this.currentAuthState = null; + console.log('AuthManager: Auth state cleared'); + } + + // Generate a session-specific password for local key encryption + _generateSessionPassword() { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + } + + // Check if we have valid stored auth + hasStoredAuth() { + const stored = localStorage.getItem(this.storageKey); + return !!stored; + } + + // Get current auth method without full restoration + getStoredAuthMethod() { + try { + const stored = localStorage.getItem(this.storageKey); + if (!stored) return null; + + const authState = JSON.parse(stored); + return authState.method; + } catch { + return null; + } + } +} + // NIP-07 compliant window.nostr provider class WindowNostr { constructor(nostrLite, existingNostr = null) { @@ -979,13 +1696,14 @@ class WindowNostr { this.authState = null; this.existingNostr = existingNostr; this.authenticatedExtension = null; + this.authManager = new AuthManager(); this._setupEventListeners(); } _setupEventListeners() { // Listen for authentication events to store auth state if (typeof window !== 'undefined') { - window.addEventListener('nlMethodSelected', (event) => { + window.addEventListener('nlMethodSelected', async (event) => { this.authState = event.detail; // If extension method, capture the specific extension the user chose @@ -994,11 +1712,21 @@ class WindowNostr { console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name); } - // CRITICAL FIX: Re-install our facade for ALL authentication methods - // Extensions may overwrite window.nostr after ANY authentication, not just extension auth - if (typeof window !== 'undefined') { + // Save authentication state for persistence + try { + await this.authManager.saveAuthState(event.detail); + console.log('WindowNostr: Auth state saved for persistence'); + } catch (error) { + console.error('WindowNostr: Failed to save auth state:', error); + } + + // EXTENSION-FIRST: Only reinstall facade for non-extension methods + // Extensions handle their own window.nostr - don't interfere! + if (event.detail.method !== 'extension' && typeof window !== 'undefined') { console.log('WindowNostr: Re-installing facade after', this.authState?.method, 'authentication'); window.nostr = this; + } else if (event.detail.method === 'extension') { + console.log('WindowNostr: Extension authentication - NOT reinstalling facade'); } console.log('WindowNostr: Auth state updated:', this.authState?.method); @@ -1007,17 +1735,73 @@ class WindowNostr { window.addEventListener('nlLogout', () => { this.authState = null; this.authenticatedExtension = null; - console.log('WindowNostr: Auth state cleared'); - // Re-install facade after logout to ensure we maintain control - if (typeof window !== 'undefined') { - console.log('WindowNostr: Re-installing facade after logout'); + // Clear persistent auth state + this.authManager.clearAuthState(); + console.log('WindowNostr: Auth state cleared and persistence removed'); + + // EXTENSION-FIRST: Only reinstall facade if we're not in extension mode + if (typeof window !== 'undefined' && !this.nostrLite?.hasExtension) { + console.log('WindowNostr: Re-installing facade after logout (non-extension mode)'); window.nostr = this; + } else { + console.log('WindowNostr: Logout in extension mode - NOT reinstalling facade'); } }); } } + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 WindowNostr: === restoreAuthState START ==='); + console.log('🔍 WindowNostr: authManager available:', !!this.authManager); + + const restoredAuth = await this.authManager.restoreAuthState(); + console.log('🔍 WindowNostr: authManager.restoreAuthState result:', restoredAuth); + + if (restoredAuth) { + console.log('🔍 WindowNostr: ✅ Setting authState to restored auth'); + this.authState = restoredAuth; + console.log('🔍 WindowNostr: this.authState now:', this.authState); + + // Handle method-specific restoration + if (restoredAuth.method === 'extension') { + console.log('🔍 WindowNostr: Extension method - setting authenticatedExtension'); + this.authenticatedExtension = restoredAuth.extension; + console.log('🔍 WindowNostr: authenticatedExtension set to:', this.authenticatedExtension); + } + + console.log('🔍 WindowNostr: ✅ Authentication state restored successfully!'); + console.log('🔍 WindowNostr: Method:', restoredAuth.method); + console.log('🔍 WindowNostr: Pubkey:', restoredAuth.pubkey); + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 WindowNostr: Dispatching nlAuthRestored event...'); + const event = new CustomEvent('nlAuthRestored', { + detail: restoredAuth + }); + console.log('🔍 WindowNostr: Event detail:', event.detail); + window.dispatchEvent(event); + console.log('🔍 WindowNostr: ✅ nlAuthRestored event dispatched'); + } + + console.log('🔍 WindowNostr: === restoreAuthState END (success) ==='); + return restoredAuth; + } else { + console.log('🔍 WindowNostr: ❌ No authentication state to restore (null from authManager)'); + console.log('🔍 WindowNostr: === restoreAuthState END (no restore) ==='); + return null; + } + } catch (error) { + console.error('🔍 WindowNostr: ❌ Failed to restore auth state:', error); + console.error('🔍 WindowNostr: Error stack:', error.stack); + console.log('🔍 WindowNostr: === restoreAuthState END (error) ==='); + return null; + } + } + async getPublicKey() { if (!this.authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()'); diff --git a/lite/nostr-lite.js b/lite/nostr-lite.js index 66c9204..3436be7 100644 --- a/lite/nostr-lite.js +++ b/lite/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-09-19T16:21:34.133Z + * Generated on: 2025-09-19T19:39:40.411Z */ // Verify dependencies are loaded @@ -2237,24 +2237,112 @@ class FloatingTab { // Listen for authentication events window.addEventListener('nlMethodSelected', (e) => { - console.log('FloatingTab: Authentication method selected:', e.detail); + console.log('🔍 FloatingTab: Authentication method selected event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + this._handleAuth(e.detail); + }); + + window.addEventListener('nlAuthRestored', (e) => { + console.log('🔍 FloatingTab: ✅ Authentication restored event received'); + console.log('🔍 FloatingTab: Event detail:', e.detail); + console.log('🔍 FloatingTab: Calling _handleAuth with restored data...'); this._handleAuth(e.detail); }); window.addEventListener('nlLogout', () => { - console.log('FloatingTab: Logout detected'); + console.log('🔍 FloatingTab: Logout event received'); this._handleLogout(); }); } - _handleClick() { + async _handleClick() { console.log('FloatingTab: Clicked'); if (this.isAuthenticated && this.options.behavior.showUserInfo) { // Show user menu or profile options this._showUserMenu(); } else { - // Open login modal + // Check if extension is available for direct login + if (window.nostr && this._isRealExtension(window.nostr)) { + console.log('FloatingTab: Extension available, attempting direct extension login'); + await this._tryExtensionLogin(window.nostr); + } else { + // Open login modal + if (this.modal) { + this.modal.open({ startScreen: 'login' }); + } + } + } + } + + // Check if object is a real extension (same logic as NostrLite._isRealExtension) + _isRealExtension(obj) { + if (!obj || typeof obj !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { + return false; + } + + // Exclude our own library classes + const constructorName = obj.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Exclude NostrTools library object + if (obj === window.NostrTools) { + return false; + } + + // Conservative check: Look for common extension characteristics + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; + + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; + + return hasIndicators || hasExtensionConstructor; + } + + // Try to login with extension and trigger proper persistence + async _tryExtensionLogin(extension) { + try { + console.log('FloatingTab: Attempting extension login'); + + // Get pubkey from extension + const pubkey = await extension.getPublicKey(); + console.log('FloatingTab: Extension provided pubkey:', pubkey); + + // Create extension auth data + const extensionAuth = { + method: 'extension', + pubkey: pubkey, + extension: extension + }; + + // **CRITICAL FIX**: Dispatch nlMethodSelected event to trigger persistence + console.log('FloatingTab: Dispatching nlMethodSelected for persistence'); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlMethodSelected', { + detail: extensionAuth + })); + } + + // Also call our local _handleAuth for UI updates + await this._handleAuth(extensionAuth); + + } catch (error) { + console.error('FloatingTab: Extension login failed:', error); + // Fall back to opening modal if (this.modal) { this.modal.open({ startScreen: 'login' }); } @@ -2262,28 +2350,42 @@ class FloatingTab { } async _handleAuth(authData) { - console.log('FloatingTab: Handling authentication:', authData); + console.log('🔍 FloatingTab: === _handleAuth START ==='); + console.log('🔍 FloatingTab: authData received:', authData); + console.log('🔍 FloatingTab: Current isAuthenticated before:', this.isAuthenticated); + this.isAuthenticated = true; this.userInfo = authData; + console.log('🔍 FloatingTab: Set isAuthenticated to true'); + console.log('🔍 FloatingTab: Set userInfo to:', this.userInfo); + // Fetch user profile if enabled and we have a pubkey if (this.options.getUserInfo && authData.pubkey) { - console.log('FloatingTab: Fetching user profile for:', authData.pubkey); + console.log('🔍 FloatingTab: getUserInfo enabled, fetching profile for:', authData.pubkey); try { const profile = await this._fetchUserProfile(authData.pubkey); this.userProfile = profile; - console.log('FloatingTab: User profile fetched:', profile); + console.log('🔍 FloatingTab: User profile fetched:', profile); } catch (error) { - console.warn('FloatingTab: Failed to fetch user profile:', error); + console.warn('🔍 FloatingTab: Failed to fetch user profile:', error); this.userProfile = null; } + } else { + console.log('🔍 FloatingTab: getUserInfo disabled or no pubkey, skipping profile fetch'); } + console.log('🔍 FloatingTab: hideWhenAuthenticated option:', this.options.behavior.hideWhenAuthenticated); + if (this.options.behavior.hideWhenAuthenticated) { + console.log('🔍 FloatingTab: Hiding tab (hideWhenAuthenticated=true)'); this.hide(); } else { + console.log('🔍 FloatingTab: Updating appearance (hideWhenAuthenticated=false)'); this._updateAppearance(); } + + console.log('🔍 FloatingTab: === _handleAuth END ==='); } _handleLogout() { @@ -2630,6 +2732,7 @@ class NostrLite { this.options = { theme: 'default', + persistence: true, // Enable persistent authentication by default methods: { extension: true, local: true, @@ -2666,7 +2769,9 @@ class NostrLite { this.switchTheme(this.options.theme); // Always set up window.nostr facade to handle multiple extensions properly - this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Setting up facade before other initialization...'); + await this._setupWindowNostrFacade(); + console.log('🔍 NOSTR_LOGIN_LITE: Facade setup complete, continuing initialization...'); // Create modal during init (matching original git architecture) this.modal = new Modal(this.options); @@ -2678,101 +2783,74 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Floating tab initialized'); } + // Attempt to restore authentication state if persistence is enabled (AFTER facade is ready) + if (this.options.persistence) { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence enabled, attempting auth restoration...'); + await this._attemptAuthRestore(); + } else { + console.log('🔍 NOSTR_LOGIN_LITE: Persistence disabled in options'); + } + this.initialized = true; console.log('NOSTR_LOGIN_LITE: Initialization complete'); return this; } - _setupWindowNostrFacade() { + async _setupWindowNostrFacade() { if (typeof window !== 'undefined') { - console.log('NOSTR_LOGIN_LITE: === TRUE SINGLE-EXTENSION ARCHITECTURE ==='); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr:', window.nostr); - console.log('NOSTR_LOGIN_LITE: Initial window.nostr constructor:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === EXTENSION-FIRST FACADE SETUP ==='); + console.log('🔍 NOSTR_LOGIN_LITE: Current window.nostr:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: Constructor:', window.nostr?.constructor?.name); - // Store existing window.nostr if it exists (from extensions) - const existingNostr = window.nostr; - - // TRUE SINGLE-EXTENSION ARCHITECTURE: Don't install facade when extensions detected - if (this._isRealExtension(existingNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓ REAL EXTENSION DETECTED IMMEDIATELY - PRESERVING WITHOUT FACADE'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', existingNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(existingNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = existingNostr; - this.facadeInstalled = false; - // DON'T install facade - leave window.nostr as the extension - return; + // EXTENSION-FIRST ARCHITECTURE: Never interfere with real extensions + if (this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ REAL EXTENSION DETECTED - WILL NOT INSTALL FACADE'); + console.log('🔍 NOSTR_LOGIN_LITE: Extension constructor:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: Extensions will handle window.nostr directly'); + + // Store reference for persistence verification + this.detectedExtension = window.nostr; + this.hasExtension = true; + this.facadeInstalled = false; // We deliberately don't install facade for extensions + + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - no facade interference'); + return; // Don't install facade at all for extensions } - // DEFERRED EXTENSION DETECTION: Extensions like nos2x may load after us - console.log('NOSTR_LOGIN_LITE: No real extension detected initially, starting deferred detection...'); - this.facadeInstalled = false; + // NO EXTENSION: Install facade for local/NIP-46/readonly methods + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No real extension detected'); + console.log('🔍 NOSTR_LOGIN_LITE: Installing facade for non-extension authentication'); - let checkCount = 0; - const maxChecks = 10; // Check for up to 2 seconds - const checkInterval = setInterval(() => { - checkCount++; - const currentNostr = window.nostr; - - console.log('NOSTR_LOGIN_LITE: === DEFERRED CHECK ' + checkCount + '/' + maxChecks + ' ==='); - console.log('NOSTR_LOGIN_LITE: Current window.nostr:', currentNostr); - console.log('NOSTR_LOGIN_LITE: Constructor:', currentNostr?.constructor?.name); - - // Skip if it's our facade - if (currentNostr?.constructor?.name === 'WindowNostr') { - console.log('NOSTR_LOGIN_LITE: Skipping - this is our facade'); - return; - } - - if (this._isRealExtension(currentNostr)) { - console.log('NOSTR_LOGIN_LITE: ✓✓✓ LATE EXTENSION DETECTED - PRESERVING WITHOUT FACADE ✓✓✓'); - console.log('NOSTR_LOGIN_LITE: Extension detected after ' + (checkCount * 200) + 'ms!'); - console.log('NOSTR_LOGIN_LITE: Extension constructor:', currentNostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: Extension keys:', Object.keys(currentNostr)); - console.log('NOSTR_LOGIN_LITE: Leaving window.nostr untouched for extension compatibility'); - this.preservedExtension = currentNostr; - this.facadeInstalled = false; - clearInterval(checkInterval); - // DON'T install facade - leave window.nostr as the extension - return; - } - - // Stop checking after max attempts - no extension found - if (checkCount >= maxChecks) { - console.log('NOSTR_LOGIN_LITE: ⚠️ MAX CHECKS REACHED - NO EXTENSION FOUND'); - clearInterval(checkInterval); - console.log('NOSTR_LOGIN_LITE: Installing facade for local/NIP-46/readonly methods'); - this._installFacade(); - } - }, 200); // Check every 200ms + this.hasExtension = false; + this._installFacade(window.nostr); // Install facade with any existing nostr object - console.log('NOSTR_LOGIN_LITE: Waiting for deferred detection to complete...'); + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade installed for local/NIP-46/readonly methods'); } } _installFacade(existingNostr = null) { if (typeof window !== 'undefined' && !this.facadeInstalled) { - console.log('NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); - console.log('NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); - console.log('NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); - console.log('NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: === _installFacade CALLED ==='); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr parameter:', existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: existingNostr constructor:', existingNostr?.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr before installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor before:', window.nostr?.constructor?.name); const facade = new WindowNostr(this, existingNostr); window.nostr = facade; this.facadeInstalled = true; - console.log('NOSTR_LOGIN_LITE: === FACADE INSTALLED WITH EXTENSION ==='); - console.log('NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); - console.log('NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); - console.log('NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); + console.log('🔍 NOSTR_LOGIN_LITE: === FACADE INSTALLED FOR PERSISTENCE ==='); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr after installation:', window.nostr); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr constructor after:', window.nostr.constructor?.name); + console.log('🔍 NOSTR_LOGIN_LITE: facade.existingNostr:', window.nostr.existingNostr); } } - // Helper method to identify real browser extensions + // Conservative method to identify real browser extensions _isRealExtension(obj) { - console.log('NOSTR_LOGIN_LITE: === _isRealExtension DEBUG ==='); + console.log('NOSTR_LOGIN_LITE: === _isRealExtension (Conservative) ==='); console.log('NOSTR_LOGIN_LITE: obj:', obj); console.log('NOSTR_LOGIN_LITE: typeof obj:', typeof obj); @@ -2781,13 +2859,9 @@ class NostrLite { return false; } - console.log('NOSTR_LOGIN_LITE: Object keys:', Object.keys(obj)); - console.log('NOSTR_LOGIN_LITE: getPublicKey type:', typeof obj.getPublicKey); - console.log('NOSTR_LOGIN_LITE: signEvent type:', typeof obj.signEvent); - // Must have required Nostr methods if (typeof obj.getPublicKey !== 'function' || typeof obj.signEvent !== 'function') { - console.log('NOSTR_LOGIN_LITE: ✗ Missing required methods'); + console.log('NOSTR_LOGIN_LITE: ✗ Missing required NIP-07 methods'); return false; } @@ -2796,37 +2870,37 @@ class NostrLite { console.log('NOSTR_LOGIN_LITE: Constructor name:', constructorName); if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { - console.log('NOSTR_LOGIN_LITE: ✗ Is our library class'); + console.log('NOSTR_LOGIN_LITE: ✗ Is our library class - NOT an extension'); return false; } // Exclude NostrTools library object if (obj === window.NostrTools) { - console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object'); + console.log('NOSTR_LOGIN_LITE: ✗ Is NostrTools object - NOT an extension'); return false; } - // Real extensions typically have internal properties or specific characteristics - console.log('NOSTR_LOGIN_LITE: Extension property check:'); - console.log(' _isEnabled:', !!obj._isEnabled); - console.log(' enabled:', !!obj.enabled); - console.log(' kind:', !!obj.kind); - console.log(' _eventEmitter:', !!obj._eventEmitter); - console.log(' _scope:', !!obj._scope); - console.log(' _requests:', !!obj._requests); - console.log(' _pubkey:', !!obj._pubkey); - console.log(' name:', !!obj.name); - console.log(' version:', !!obj.version); - console.log(' description:', !!obj.description); + // Conservative check: Look for common extension characteristics + // Real extensions usually have some of these internal properties + const extensionIndicators = [ + '_isEnabled', 'enabled', 'kind', '_eventEmitter', '_scope', + '_requests', '_pubkey', 'name', 'version', 'description' + ]; - const hasExtensionProps = !!( - obj._isEnabled || obj.enabled || obj.kind || - obj._eventEmitter || obj._scope || obj._requests || obj._pubkey || - obj.name || obj.version || obj.description - ); + const hasIndicators = extensionIndicators.some(prop => obj.hasOwnProperty(prop)); + + // Additional check: Extensions often have specific constructor patterns + const hasExtensionConstructor = constructorName && + constructorName !== 'Object' && + constructorName !== 'Function'; - console.log('NOSTR_LOGIN_LITE: Extension detection result for', constructorName, ':', hasExtensionProps); - return hasExtensionProps; + const isExtension = hasIndicators || hasExtensionConstructor; + + console.log('NOSTR_LOGIN_LITE: Extension indicators found:', hasIndicators); + console.log('NOSTR_LOGIN_LITE: Has extension constructor:', hasExtensionConstructor); + console.log('NOSTR_LOGIN_LITE: Final result for', constructorName, ':', isExtension); + + return isExtension; } launch(startScreen = 'login') { @@ -2839,15 +2913,149 @@ class NostrLite { } } + // Attempt to restore authentication state + async _attemptAuthRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptAuthRestore START ==='); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr:', window.nostr?.constructor?.name); + + if (this.hasExtension) { + // EXTENSION MODE: Use custom extension persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Extension mode - using extension-specific restore'); + const restoredAuth = await this._attemptExtensionRestore(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth restored successfully!'); + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Extension auth could not be restored'); + return null; + } + } else if (this.facadeInstalled && window.nostr?.restoreAuthState) { + // NON-EXTENSION MODE: Use facade persistence logic + console.log('🔍 NOSTR_LOGIN_LITE: Non-extension mode - using facade restore'); + const restoredAuth = await window.nostr.restoreAuthState(); + + if (restoredAuth) { + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Facade auth restored successfully!'); + console.log('🔍 NOSTR_LOGIN_LITE: Method:', restoredAuth.method); + console.log('🔍 NOSTR_LOGIN_LITE: Pubkey:', restoredAuth.pubkey); + + // Handle NIP-46 reconnection requirement + if (restoredAuth.requiresReconnection) { + console.log('🔍 NOSTR_LOGIN_LITE: NIP-46 connection requires user reconnection'); + this._showReconnectionPrompt(restoredAuth); + } + + return restoredAuth; + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ Facade auth could not be restored'); + return null; + } + } else { + console.log('🔍 NOSTR_LOGIN_LITE: ❌ No restoration method available'); + console.log('🔍 NOSTR_LOGIN_LITE: hasExtension:', this.hasExtension); + console.log('🔍 NOSTR_LOGIN_LITE: facadeInstalled:', this.facadeInstalled); + console.log('🔍 NOSTR_LOGIN_LITE: window.nostr.restoreAuthState:', typeof window.nostr?.restoreAuthState); + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Auth restoration failed with error:', error); + console.error('🔍 NOSTR_LOGIN_LITE: Error stack:', error.stack); + return null; + } + } + + // Extension-specific authentication restoration + async _attemptExtensionRestore() { + try { + console.log('🔍 NOSTR_LOGIN_LITE: === _attemptExtensionRestore START ==='); + + // Use a simple AuthManager instance for extension persistence + const authManager = new AuthManager(); + const storedAuth = await authManager.restoreAuthState(); + + if (!storedAuth || storedAuth.method !== 'extension') { + console.log('🔍 NOSTR_LOGIN_LITE: No extension auth state stored'); + return null; + } + + // Verify the extension is still available and working + if (!window.nostr || !this._isRealExtension(window.nostr)) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension no longer available'); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + try { + // Test that the extension still works with the same pubkey + const currentPubkey = await window.nostr.getPublicKey(); + if (currentPubkey !== storedAuth.pubkey) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension pubkey changed, clearing state'); + authManager.clearAuthState(); + return null; + } + + console.log('🔍 NOSTR_LOGIN_LITE: ✅ Extension auth verification successful'); + + // Create extension auth data for UI restoration + const extensionAuth = { + method: 'extension', + pubkey: storedAuth.pubkey, + extension: window.nostr + }; + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 NOSTR_LOGIN_LITE: Dispatching nlAuthRestored event for extension'); + window.dispatchEvent(new CustomEvent('nlAuthRestored', { + detail: extensionAuth + })); + } + + return extensionAuth; + + } catch (error) { + console.log('🔍 NOSTR_LOGIN_LITE: Extension verification failed:', error); + authManager.clearAuthState(); // Clear invalid state + return null; + } + + } catch (error) { + console.error('🔍 NOSTR_LOGIN_LITE: Extension restore failed:', error); + return null; + } + } + + // Show prompt for NIP-46 reconnection + _showReconnectionPrompt(authData) { + console.log('NOSTR_LOGIN_LITE: Showing reconnection prompt for NIP-46'); + + // Dispatch event that UI can listen to + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent('nlReconnectionRequired', { + detail: { + method: authData.method, + pubkey: authData.pubkey, + connectionData: authData.connectionData, + message: 'Your NIP-46 session has expired. Please reconnect to continue.' + } + })); + } + } + logout() { console.log('NOSTR_LOGIN_LITE: Logout called'); - // Clear stored data + // Clear legacy stored data if (typeof localStorage !== 'undefined') { localStorage.removeItem('nl_current'); } - // Dispatch logout event + // Dispatch logout event (AuthManager will clear its own data via WindowNostr listener) if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('nlLogout', { detail: { timestamp: Date.now() } @@ -2939,6 +3147,515 @@ class NostrLite { } } +// ====================================== +// Authentication Manager for Persistent Login +// ====================================== + +// Encryption utilities for secure local storage +class CryptoUtils { + static async generateKey() { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + return await window.crypto.subtle.generateKey( + { + name: 'AES-GCM', + length: 256, + }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async deriveKey(password, salt) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const keyMaterial = await window.crypto.subtle.importKey( + 'raw', + encoder.encode(password), + { name: 'PBKDF2' }, + false, + ['deriveBits', 'deriveKey'] + ); + + return await window.crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt: salt, + iterations: 100000, + hash: 'SHA-256', + }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + true, + ['encrypt', 'decrypt'] + ); + } + + static async encrypt(data, key) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const encoder = new TextEncoder(); + const iv = window.crypto.getRandomValues(new Uint8Array(12)); + + const encrypted = await window.crypto.subtle.encrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encoder.encode(data) + ); + + return { + encrypted: new Uint8Array(encrypted), + iv: iv + }; + } + + static async decrypt(encryptedData, key, iv) { + if (!window.crypto?.subtle) { + throw new Error('Web Crypto API not available'); + } + + const decrypted = await window.crypto.subtle.decrypt( + { + name: 'AES-GCM', + iv: iv, + }, + key, + encryptedData + ); + + const decoder = new TextDecoder(); + return decoder.decode(decrypted); + } + + static arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return window.btoa(binary); + } + + static base64ToArrayBuffer(base64) { + const binary = window.atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes.buffer; + } +} + +// Unified authentication state manager +class AuthManager { + constructor() { + this.storageKey = 'nostr_login_lite_auth'; + this.currentAuthState = null; + } + + // Save authentication state with method-specific security + async saveAuthState(authData) { + try { + const authState = { + method: authData.method, + timestamp: Date.now(), + pubkey: authData.pubkey + }; + + switch (authData.method) { + case 'extension': + // For extensions, only store verification data - no secrets + authState.extensionVerification = { + constructor: authData.extension?.constructor?.name, + hasGetPublicKey: typeof authData.extension?.getPublicKey === 'function', + hasSignEvent: typeof authData.extension?.signEvent === 'function' + }; + break; + + case 'local': + // For local keys, encrypt the secret key + if (authData.secret) { + const password = this._generateSessionPassword(); + const salt = window.crypto.getRandomValues(new Uint8Array(16)); + const key = await CryptoUtils.deriveKey(password, salt); + const encrypted = await CryptoUtils.encrypt(authData.secret, key); + + authState.encrypted = { + data: CryptoUtils.arrayBufferToBase64(encrypted.encrypted), + iv: CryptoUtils.arrayBufferToBase64(encrypted.iv), + salt: CryptoUtils.arrayBufferToBase64(salt) + }; + + // Store session password in sessionStorage (cleared on tab close) + sessionStorage.setItem('nostr_session_key', password); + } + break; + + case 'nip46': + // For NIP-46, store connection parameters (no secrets) + if (authData.signer) { + authState.nip46 = { + remotePubkey: authData.signer.remotePubkey, + relays: authData.signer.relays, + // Don't store secret - user will need to reconnect + }; + } + break; + + case 'readonly': + // Read-only mode has no secrets to store + break; + + default: + throw new Error(`Unknown auth method: ${authData.method}`); + } + + localStorage.setItem(this.storageKey, JSON.stringify(authState)); + this.currentAuthState = authState; + console.log('AuthManager: Auth state saved for method:', authData.method); + + } catch (error) { + console.error('AuthManager: Failed to save auth state:', error); + throw error; + } + } + + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 AuthManager: === restoreAuthState START ==='); + console.log('🔍 AuthManager: storageKey:', this.storageKey); + + const stored = localStorage.getItem(this.storageKey); + console.log('🔍 AuthManager: localStorage raw value:', stored); + + if (!stored) { + console.log('🔍 AuthManager: ❌ No stored auth state found'); + return null; + } + + const authState = JSON.parse(stored); + console.log('🔍 AuthManager: ✅ Parsed stored auth state:', authState); + console.log('🔍 AuthManager: Method:', authState.method); + console.log('🔍 AuthManager: Timestamp:', authState.timestamp); + console.log('🔍 AuthManager: Age (ms):', Date.now() - authState.timestamp); + + // Check if stored state is too old (24 hours for most methods, 1 hour for extensions) + const maxAge = authState.method === 'extension' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000; + console.log('🔍 AuthManager: Max age for method:', maxAge, 'ms'); + + if (Date.now() - authState.timestamp > maxAge) { + console.log('🔍 AuthManager: ❌ Stored auth state expired, clearing'); + this.clearAuthState(); + return null; + } + + console.log('🔍 AuthManager: ✅ Auth state not expired, attempting restore for method:', authState.method); + + let result; + switch (authState.method) { + case 'extension': + console.log('🔍 AuthManager: Calling _restoreExtensionAuth...'); + result = await this._restoreExtensionAuth(authState); + break; + + case 'local': + console.log('🔍 AuthManager: Calling _restoreLocalAuth...'); + result = await this._restoreLocalAuth(authState); + break; + + case 'nip46': + console.log('🔍 AuthManager: Calling _restoreNip46Auth...'); + result = await this._restoreNip46Auth(authState); + break; + + case 'readonly': + console.log('🔍 AuthManager: Calling _restoreReadonlyAuth...'); + result = await this._restoreReadonlyAuth(authState); + break; + + default: + console.warn('🔍 AuthManager: ❌ Unknown auth method in stored state:', authState.method); + return null; + } + + console.log('🔍 AuthManager: Restore method result:', result); + console.log('🔍 AuthManager: === restoreAuthState END ==='); + return result; + + } catch (error) { + console.error('🔍 AuthManager: ❌ Failed to restore auth state:', error); + console.error('🔍 AuthManager: Error stack:', error.stack); + this.clearAuthState(); // Clear corrupted state + return null; + } + } + + async _restoreExtensionAuth(authState) { + console.log('🔍 AuthManager: === _restoreExtensionAuth START ==='); + console.log('🔍 AuthManager: authState:', authState); + console.log('🔍 AuthManager: window.nostr available:', !!window.nostr); + console.log('🔍 AuthManager: window.nostr constructor:', window.nostr?.constructor?.name); + + // SMART EXTENSION WAITING SYSTEM + // Extensions often load after our library, so we need to wait for them + const extension = await this._waitForExtension(authState, 3000); // Wait up to 3 seconds + + if (!extension) { + console.log('🔍 AuthManager: ❌ No extension found after waiting'); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension found:', extension.constructor?.name); + + try { + // Verify extension still works and has same pubkey + const currentPubkey = await extension.getPublicKey(); + if (currentPubkey !== authState.pubkey) { + console.log('🔍 AuthManager: ❌ Extension pubkey changed, not restoring'); + console.log('🔍 AuthManager: Expected:', authState.pubkey); + console.log('🔍 AuthManager: Got:', currentPubkey); + return null; + } + + console.log('🔍 AuthManager: ✅ Extension auth restored successfully'); + return { + method: 'extension', + pubkey: authState.pubkey, + extension: extension + }; + + } catch (error) { + console.log('🔍 AuthManager: ❌ Extension verification failed:', error); + return null; + } + } + + // Smart extension waiting system - polls multiple locations for extensions + async _waitForExtension(authState, maxWaitMs = 3000) { + console.log('🔍 AuthManager: === _waitForExtension START ==='); + console.log('🔍 AuthManager: maxWaitMs:', maxWaitMs); + console.log('🔍 AuthManager: Looking for extension with constructor:', authState.extensionVerification?.constructor); + + const startTime = Date.now(); + const pollInterval = 100; // Check every 100ms + + // Extension locations to check (in priority order) + const extensionLocations = [ + { path: 'window.nostr', getter: () => window.nostr }, + { path: 'navigator.nostr', getter: () => navigator?.nostr }, + { path: 'window.navigator?.nostr', getter: () => window.navigator?.nostr }, + { path: 'window.alby?.nostr', getter: () => window.alby?.nostr }, + { path: 'window.webln?.nostr', getter: () => window.webln?.nostr }, + { path: 'window.nos2x', getter: () => window.nos2x }, + { path: 'window.flamingo?.nostr', getter: () => window.flamingo?.nostr }, + { path: 'window.mutiny?.nostr', getter: () => window.mutiny?.nostr } + ]; + + while (Date.now() - startTime < maxWaitMs) { + console.log('🔍 AuthManager: Polling for extensions... (elapsed:', Date.now() - startTime, 'ms)'); + + // If our facade is currently installed and blocking, temporarily remove it + let facadeRemoved = false; + let originalNostr = null; + if (window.nostr?.constructor?.name === 'WindowNostr') { + console.log('🔍 AuthManager: Temporarily removing our facade to check for real extensions'); + originalNostr = window.nostr; + window.nostr = window.nostr.existingNostr || undefined; + facadeRemoved = true; + } + + try { + // Check all extension locations + for (const location of extensionLocations) { + try { + const extension = location.getter(); + console.log('🔍 AuthManager: Checking', location.path, ':', !!extension, extension?.constructor?.name); + + if (this._isValidExtensionForRestore(extension, authState)) { + console.log('🔍 AuthManager: ✅ Found matching extension at', location.path); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + console.log('🔍 AuthManager: Restoring facade after finding extension'); + window.nostr = originalNostr; + } + + return extension; + } + } catch (error) { + console.log('🔍 AuthManager: Error checking', location.path, ':', error.message); + } + } + + // Restore facade if we removed it and haven't found an extension yet + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + facadeRemoved = false; + } + + } catch (error) { + console.error('🔍 AuthManager: Error during extension polling:', error); + + // Restore facade if we removed it + if (facadeRemoved && originalNostr) { + window.nostr = originalNostr; + } + } + + // Wait before next poll + await new Promise(resolve => setTimeout(resolve, pollInterval)); + } + + console.log('🔍 AuthManager: ❌ Extension waiting timeout after', maxWaitMs, 'ms'); + return null; + } + + // Check if an extension is valid for restoration + _isValidExtensionForRestore(extension, authState) { + if (!extension || typeof extension !== 'object') { + return false; + } + + // Must have required Nostr methods + if (typeof extension.getPublicKey !== 'function' || + typeof extension.signEvent !== 'function') { + return false; + } + + // Must not be our own classes + const constructorName = extension.constructor?.name; + if (constructorName === 'WindowNostr' || constructorName === 'NostrLite') { + return false; + } + + // Must not be NostrTools + if (extension === window.NostrTools) { + return false; + } + + // If we have stored verification data, check constructor match + const verification = authState.extensionVerification; + if (verification && verification.constructor) { + if (constructorName !== verification.constructor) { + console.log('🔍 AuthManager: Constructor mismatch -', + 'expected:', verification.constructor, + 'got:', constructorName); + return false; + } + } + + console.log('🔍 AuthManager: ✅ Extension validation passed for:', constructorName); + return true; + } + + async _restoreLocalAuth(authState) { + if (!authState.encrypted) { + console.log('AuthManager: No encrypted data found for local auth'); + return null; + } + + // Get session password + const sessionPassword = sessionStorage.getItem('nostr_session_key'); + if (!sessionPassword) { + console.log('AuthManager: Session password not found, cannot decrypt'); + return null; + } + + try { + // Decrypt the secret key + const salt = CryptoUtils.base64ToArrayBuffer(authState.encrypted.salt); + const key = await CryptoUtils.deriveKey(sessionPassword, new Uint8Array(salt)); + + const encryptedData = CryptoUtils.base64ToArrayBuffer(authState.encrypted.data); + const iv = CryptoUtils.base64ToArrayBuffer(authState.encrypted.iv); + + const secret = await CryptoUtils.decrypt(encryptedData, key, new Uint8Array(iv)); + + console.log('AuthManager: Local auth restored successfully'); + return { + method: 'local', + pubkey: authState.pubkey, + secret: secret + }; + + } catch (error) { + console.error('AuthManager: Failed to decrypt local key:', error); + return null; + } + } + + async _restoreNip46Auth(authState) { + if (!authState.nip46) { + console.log('AuthManager: No NIP-46 data found'); + 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 + console.log('AuthManager: NIP-46 connection data found, requires user reconnection'); + return { + method: 'nip46', + pubkey: authState.pubkey, + requiresReconnection: true, + connectionData: authState.nip46 + }; + } + + async _restoreReadonlyAuth(authState) { + console.log('AuthManager: Read-only auth restored successfully'); + return { + method: 'readonly', + pubkey: authState.pubkey + }; + } + + // Clear stored authentication state + clearAuthState() { + localStorage.removeItem(this.storageKey); + sessionStorage.removeItem('nostr_session_key'); + this.currentAuthState = null; + console.log('AuthManager: Auth state cleared'); + } + + // Generate a session-specific password for local key encryption + _generateSessionPassword() { + const array = new Uint8Array(32); + window.crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); + } + + // Check if we have valid stored auth + hasStoredAuth() { + const stored = localStorage.getItem(this.storageKey); + return !!stored; + } + + // Get current auth method without full restoration + getStoredAuthMethod() { + try { + const stored = localStorage.getItem(this.storageKey); + if (!stored) return null; + + const authState = JSON.parse(stored); + return authState.method; + } catch { + return null; + } + } +} + // NIP-07 compliant window.nostr provider class WindowNostr { constructor(nostrLite, existingNostr = null) { @@ -2946,13 +3663,14 @@ class WindowNostr { this.authState = null; this.existingNostr = existingNostr; this.authenticatedExtension = null; + this.authManager = new AuthManager(); this._setupEventListeners(); } _setupEventListeners() { // Listen for authentication events to store auth state if (typeof window !== 'undefined') { - window.addEventListener('nlMethodSelected', (event) => { + window.addEventListener('nlMethodSelected', async (event) => { this.authState = event.detail; // If extension method, capture the specific extension the user chose @@ -2961,11 +3679,21 @@ class WindowNostr { console.log('WindowNostr: Captured authenticated extension:', this.authenticatedExtension?.constructor?.name); } - // CRITICAL FIX: Re-install our facade for ALL authentication methods - // Extensions may overwrite window.nostr after ANY authentication, not just extension auth - if (typeof window !== 'undefined') { + // Save authentication state for persistence + try { + await this.authManager.saveAuthState(event.detail); + console.log('WindowNostr: Auth state saved for persistence'); + } catch (error) { + console.error('WindowNostr: Failed to save auth state:', error); + } + + // EXTENSION-FIRST: Only reinstall facade for non-extension methods + // Extensions handle their own window.nostr - don't interfere! + if (event.detail.method !== 'extension' && typeof window !== 'undefined') { console.log('WindowNostr: Re-installing facade after', this.authState?.method, 'authentication'); window.nostr = this; + } else if (event.detail.method === 'extension') { + console.log('WindowNostr: Extension authentication - NOT reinstalling facade'); } console.log('WindowNostr: Auth state updated:', this.authState?.method); @@ -2974,17 +3702,73 @@ class WindowNostr { window.addEventListener('nlLogout', () => { this.authState = null; this.authenticatedExtension = null; - console.log('WindowNostr: Auth state cleared'); - // Re-install facade after logout to ensure we maintain control - if (typeof window !== 'undefined') { - console.log('WindowNostr: Re-installing facade after logout'); + // Clear persistent auth state + this.authManager.clearAuthState(); + console.log('WindowNostr: Auth state cleared and persistence removed'); + + // EXTENSION-FIRST: Only reinstall facade if we're not in extension mode + if (typeof window !== 'undefined' && !this.nostrLite?.hasExtension) { + console.log('WindowNostr: Re-installing facade after logout (non-extension mode)'); window.nostr = this; + } else { + console.log('WindowNostr: Logout in extension mode - NOT reinstalling facade'); } }); } } + // Restore authentication state on page load + async restoreAuthState() { + try { + console.log('🔍 WindowNostr: === restoreAuthState START ==='); + console.log('🔍 WindowNostr: authManager available:', !!this.authManager); + + const restoredAuth = await this.authManager.restoreAuthState(); + console.log('🔍 WindowNostr: authManager.restoreAuthState result:', restoredAuth); + + if (restoredAuth) { + console.log('🔍 WindowNostr: ✅ Setting authState to restored auth'); + this.authState = restoredAuth; + console.log('🔍 WindowNostr: this.authState now:', this.authState); + + // Handle method-specific restoration + if (restoredAuth.method === 'extension') { + console.log('🔍 WindowNostr: Extension method - setting authenticatedExtension'); + this.authenticatedExtension = restoredAuth.extension; + console.log('🔍 WindowNostr: authenticatedExtension set to:', this.authenticatedExtension); + } + + console.log('🔍 WindowNostr: ✅ Authentication state restored successfully!'); + console.log('🔍 WindowNostr: Method:', restoredAuth.method); + console.log('🔍 WindowNostr: Pubkey:', restoredAuth.pubkey); + + // Dispatch restoration event so UI can update + if (typeof window !== 'undefined') { + console.log('🔍 WindowNostr: Dispatching nlAuthRestored event...'); + const event = new CustomEvent('nlAuthRestored', { + detail: restoredAuth + }); + console.log('🔍 WindowNostr: Event detail:', event.detail); + window.dispatchEvent(event); + console.log('🔍 WindowNostr: ✅ nlAuthRestored event dispatched'); + } + + console.log('🔍 WindowNostr: === restoreAuthState END (success) ==='); + return restoredAuth; + } else { + console.log('🔍 WindowNostr: ❌ No authentication state to restore (null from authManager)'); + console.log('🔍 WindowNostr: === restoreAuthState END (no restore) ==='); + return null; + } + } catch (error) { + console.error('🔍 WindowNostr: ❌ Failed to restore auth state:', error); + console.error('🔍 WindowNostr: Error stack:', error.stack); + console.log('🔍 WindowNostr: === restoreAuthState END (error) ==='); + return null; + } + } + async getPublicKey() { if (!this.authState) { throw new Error('Not authenticated - use NOSTR_LOGIN_LITE.launch()');