/** * SigSocket Service for Browser Extension * * Handles SigSocket client functionality including: * - Auto-connecting to SigSocket server when workspace is created * - Managing pending sign requests * - Handling user approval/rejection flow * - Validating keyspace matches before showing approval UI */ class SigSocketService { constructor() { this.connection = null; this.pendingRequests = new Map(); // requestId -> SignRequestData this.connectedPublicKey = null; this.isConnected = false; this.defaultServerUrl = "ws://localhost:8080/ws"; // Initialize WASM module reference this.wasmModule = null; // Reference to popup port for communication this.popupPort = null; } /** * Initialize the service with WASM module * @param {Object} wasmModule - The loaded WASM module */ async initialize(wasmModule) { this.wasmModule = wasmModule; // Load server URL from storage try { const result = await chrome.storage.local.get(['sigSocketUrl']); if (result.sigSocketUrl) { this.defaultServerUrl = result.sigSocketUrl; } } catch (error) { console.warn('Failed to load SigSocket URL from storage:', error); } // Set up global callbacks for WASM globalThis.onSignRequestReceived = this.handleIncomingRequest.bind(this); globalThis.onConnectionStateChanged = this.handleConnectionStateChange.bind(this); } /** * Connect to SigSocket server for a workspace * @param {string} workspaceId - The workspace/keyspace identifier * @returns {Promise} - True if connected successfully */ async connectToServer(workspaceId) { try { if (!this.wasmModule) { throw new Error('WASM module not initialized'); } // Check if already connected to this workspace if (this.isConnected && this.connection) { console.log(`Already connected to SigSocket server for workspace: ${workspaceId}`); return true; } // Disconnect any existing connection first if (this.connection) { this.disconnect(); } // Get the workspace default public key const publicKeyHex = await this.wasmModule.get_workspace_default_public_key(workspaceId); if (!publicKeyHex) { throw new Error('No public key found for workspace'); } console.log(`Connecting to SigSocket server for workspace: ${workspaceId} with key: ${publicKeyHex.substring(0, 16)}...`); // Create new SigSocket connection console.log('Creating new SigSocketConnection instance'); this.connection = new this.wasmModule.SigSocketConnection(); console.log('SigSocketConnection instance created'); // Connect to server await this.connection.connect(this.defaultServerUrl, publicKeyHex); this.connectedPublicKey = publicKeyHex; // Clear pending requests if switching to a different workspace if (this.currentWorkspace && this.currentWorkspace !== workspaceId) { console.log(`Switching workspace from ${this.currentWorkspace} to ${workspaceId}, clearing pending requests`); this.pendingRequests.clear(); this.updateBadge(); } this.currentWorkspace = workspaceId; this.isConnected = true; console.log(`Successfully connected to SigSocket server for workspace: ${workspaceId}`); return true; } catch (error) { console.error('Failed to connect to SigSocket server:', error); this.isConnected = false; this.connectedPublicKey = null; this.currentWorkspace = null; if (this.connection) { this.connection.disconnect(); this.connection = null; } return false; } } /** * Handle incoming sign request from server * @param {string} requestId - Unique request identifier * @param {string} messageBase64 - Message to be signed (base64-encoded) */ handleIncomingRequest(requestId, messageBase64) { console.log(`Received sign request: ${requestId}`); // Security check: Only accept requests if we have an active connection if (!this.isConnected || !this.connectedPublicKey || !this.currentWorkspace) { console.warn(`Rejecting sign request ${requestId}: No active workspace connection`); return; } // Store the request with workspace info const requestData = { id: requestId, message: messageBase64, timestamp: Date.now(), status: 'pending', workspace: this.currentWorkspace, connectedPublicKey: this.connectedPublicKey }; this.pendingRequests.set(requestId, requestData); console.log(`Stored sign request for workspace: ${this.currentWorkspace}`); // Show notification to user this.showSignRequestNotification(); // Update extension badge this.updateBadge(); // Notify popup about new request if it's open and keyspace is unlocked this.notifyPopupOfNewRequest(); } /** * Handle connection state changes * @param {boolean} connected - True if connected, false if disconnected */ handleConnectionStateChange(connected) { this.isConnected = connected; console.log(`SigSocket connection state changed: ${connected ? 'connected' : 'disconnected'}`); if (!connected) { this.connectedPublicKey = null; this.currentWorkspace = null; // Optionally attempt reconnection here } } /** * Called when keyspace is unlocked - validate and show/hide approval UI */ async onKeypaceUnlocked() { try { if (!this.wasmModule) { return; } // Only check keyspace match if we have a connection if (!this.isConnected || !this.connectedPublicKey) { console.log('No SigSocket connection to validate against'); return; } // Get the currently unlocked workspace name const unlockedWorkspaceName = this.wasmModule.get_current_keyspace_name(); // Get workspace default public key for the UNLOCKED workspace (not connected workspace) const unlockedWorkspacePublicKey = await this.wasmModule.get_workspace_default_public_key(unlockedWorkspaceName); // Check if the unlocked workspace matches the connected workspace const workspaceMatches = unlockedWorkspaceName === this.currentWorkspace; const publicKeyMatches = unlockedWorkspacePublicKey === this.connectedPublicKey; const keypaceMatches = workspaceMatches && publicKeyMatches; console.log(`Keyspace unlock validation:`); console.log(` Connected workspace: ${this.currentWorkspace}`); console.log(` Unlocked workspace: ${unlockedWorkspaceName}`); console.log(` Connected public key: ${this.connectedPublicKey}`); console.log(` Unlocked public key: ${unlockedWorkspacePublicKey}`); console.log(` Workspace matches: ${workspaceMatches}`); console.log(` Public key matches: ${publicKeyMatches}`); console.log(` Overall match: ${keypaceMatches}`); // Always get current pending requests (filtered by connected workspace) const currentPendingRequests = this.getPendingRequests(); // Notify popup about keyspace state console.log(`Sending KEYSPACE_UNLOCKED message to popup: keypaceMatches=${keypaceMatches}, pendingRequests=${currentPendingRequests.length}`); if (this.popupPort) { this.popupPort.postMessage({ type: 'KEYSPACE_UNLOCKED', keypaceMatches, pendingRequests: currentPendingRequests }); console.log('KEYSPACE_UNLOCKED message sent to popup'); } else { console.log('No popup port available to send KEYSPACE_UNLOCKED message'); } } catch (error) { if (error.message && error.message.includes('Workspace not found')) { console.log(`Keyspace unlock: Different workspace unlocked (connected to: ${this.currentWorkspace})`); // Send message with no match and empty requests if (this.popupPort) { this.popupPort.postMessage({ type: 'KEYSPACE_UNLOCKED', keypaceMatches: false, pendingRequests: [] }); } } else { console.error('Error handling keyspace unlock:', error); } } } /** * Approve a sign request * @param {string} requestId - Request to approve * @returns {Promise} - True if approved successfully */ async approveSignRequest(requestId) { try { const request = this.pendingRequests.get(requestId); if (!request) { throw new Error('Request not found'); } // Validate request is for current workspace if (request.workspace !== this.currentWorkspace) { throw new Error(`Request is for workspace '${request.workspace}', but current workspace is '${this.currentWorkspace}'`); } if (request.connectedPublicKey !== this.connectedPublicKey) { throw new Error('Request public key does not match current connection'); } // Validate keyspace is still unlocked and matches if (!this.wasmModule.is_unlocked()) { throw new Error('Keyspace is locked'); } const currentPublicKey = await this.wasmModule.get_workspace_default_public_key(this.currentWorkspace); if (currentPublicKey !== this.connectedPublicKey) { throw new Error('Keyspace mismatch'); } // Decode message from base64 const messageBytes = atob(request.message).split('').map(c => c.charCodeAt(0)); // Sign the message with default keypair (doesn't require selected keypair) const signatureHex = await this.wasmModule.sign_with_default_keypair(new Uint8Array(messageBytes)); // Send response to server await this.connection.send_response(requestId, request.message, signatureHex); // Update request status request.status = 'approved'; request.signature = signatureHex; // Remove from pending requests this.pendingRequests.delete(requestId); // Update badge this.updateBadge(); console.log(`Approved sign request: ${requestId}`); return true; } catch (error) { console.error('Failed to approve sign request:', error); return false; } } /** * Reject a sign request * @param {string} requestId - Request to reject * @param {string} reason - Reason for rejection (optional) * @returns {Promise} - True if rejected successfully */ async rejectSignRequest(requestId, reason = 'User rejected') { try { const request = this.pendingRequests.get(requestId); if (!request) { throw new Error('Request not found'); } // Send rejection to server await this.connection.send_rejection(requestId, reason); // Update request status request.status = 'rejected'; request.reason = reason; // Remove from pending requests this.pendingRequests.delete(requestId); // Update badge this.updateBadge(); console.log(`Rejected sign request: ${requestId}, reason: ${reason}`); return true; } catch (error) { console.error('Failed to reject sign request:', error); return false; } } /** * Get all pending requests for the current workspace * @returns {Array} - Array of pending request data for current workspace */ getPendingRequests() { const allRequests = Array.from(this.pendingRequests.values()); // Filter requests to only include those for the current workspace const filteredRequests = allRequests.filter(request => { const isCurrentWorkspace = request.workspace === this.currentWorkspace; const isCurrentPublicKey = request.connectedPublicKey === this.connectedPublicKey; if (!isCurrentWorkspace || !isCurrentPublicKey) { console.log(`Filtering out request ${request.id}: workspace=${request.workspace} (current=${this.currentWorkspace}), publicKey match=${isCurrentPublicKey}`); } return isCurrentWorkspace && isCurrentPublicKey; }); console.log(`getPendingRequests: ${allRequests.length} total, ${filteredRequests.length} for current workspace`); return filteredRequests; } /** * Show notification for new sign request */ showSignRequestNotification() { // Create notification chrome.notifications.create({ type: 'basic', iconUrl: 'icons/icon48.png', title: 'Sign Request', message: 'New signature request received. Click to review.' }); } /** * Notify popup about new request if popup is open and keyspace is unlocked */ async notifyPopupOfNewRequest() { // Only notify if popup is connected if (!this.popupPort) { console.log('No popup port available, skipping new request notification'); return; } // Check if we have WASM module and can validate keyspace if (!this.wasmModule) { console.log('WASM module not available, skipping new request notification'); return; } try { // Check if keyspace is unlocked if (!this.wasmModule.is_unlocked()) { console.log('Keyspace is locked, skipping new request notification'); return; } // Get the currently unlocked workspace name const unlockedWorkspaceName = this.wasmModule.get_current_keyspace_name(); // Get workspace default public key for the UNLOCKED workspace const unlockedWorkspacePublicKey = await this.wasmModule.get_workspace_default_public_key(unlockedWorkspaceName); // Check if the unlocked workspace matches the connected workspace const workspaceMatches = unlockedWorkspaceName === this.currentWorkspace; const publicKeyMatches = unlockedWorkspacePublicKey === this.connectedPublicKey; const keypaceMatches = workspaceMatches && publicKeyMatches; console.log(`New request notification check: keypaceMatches=${keypaceMatches}, workspace=${unlockedWorkspaceName}, connected=${this.currentWorkspace}`); // Get current pending requests (filtered by connected workspace) const currentPendingRequests = this.getPendingRequests(); // SECURITY: Only send requests if workspace matches, otherwise send empty array const requestsToSend = keypaceMatches ? currentPendingRequests : []; // Send update to popup this.popupPort.postMessage({ type: 'NEW_SIGN_REQUEST', keypaceMatches, pendingRequests: requestsToSend }); console.log(`Sent NEW_SIGN_REQUEST message to popup: keypaceMatches=${keypaceMatches}, ${requestsToSend.length} requests (${currentPendingRequests.length} total for connected workspace)`); } catch (error) { console.log('Error in notifyPopupOfNewRequest:', error); } } /** * Update extension badge with pending request count for current workspace */ updateBadge() { // Only count requests for the current workspace const currentWorkspaceRequests = this.getPendingRequests(); const count = currentWorkspaceRequests.length; const badgeText = count > 0 ? count.toString() : ''; console.log(`Updating badge: ${this.pendingRequests.size} total requests, ${count} for current workspace, badge text: "${badgeText}"`); chrome.action.setBadgeText({ text: badgeText }); chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' }); } /** * Disconnect from SigSocket server */ disconnect() { if (this.connection) { this.connection.disconnect(); this.connection = null; } this.isConnected = false; this.connectedPublicKey = null; this.currentWorkspace = null; this.pendingRequests.clear(); this.updateBadge(); } /** * Get connection status * @returns {Object} - Connection status information */ getStatus() { return { isConnected: this.isConnected, connectedPublicKey: this.connectedPublicKey, pendingRequestCount: this.getPendingRequests().length, serverUrl: this.defaultServerUrl }; } /** * Set the popup port for communication * @param {chrome.runtime.Port} port - The popup port */ setPopupPort(port) { this.popupPort = port; } } // Export for use in background script export default SigSocketService;