sal-modular/crypto_vault_extension/background/sigsocket.js

876 lines
33 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SigSocket Service - Clean Implementation with New WASM APIs
*
* This service provides a clean interface for SigSocket functionality using
* the new WASM-based APIs that handle all WebSocket management, request storage,
* and security validation internally.
*
* Architecture:
* - WASM handles: WebSocket connection, message parsing, request storage, security
* - Extension handles: UI notifications, badge updates, user interactions
*/
class SigSocketService {
constructor() {
// Connection state
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
// Configuration
this.defaultServerUrl = "ws://localhost:8080/ws";
// WASM module reference
this.wasmModule = null;
// UI communication
this.popupPort = null;
// Status monitoring
this.statusMonitorInterval = null;
this.lastKnownConnectionState = false;
}
/**
* Initialize the service with WASM module
* @param {Object} wasmModule - The loaded WASM module with SigSocketManager
*/
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);
}
// Restore any persisted pending requests
await this.restorePendingRequests();
console.log('🔌 SigSocket service initialized with WASM APIs');
}
/**
* Restore pending requests from persistent storage
* Only restore requests that match the current workspace
*/
async restorePendingRequests() {
try {
const result = await chrome.storage.local.get(['sigSocketPendingRequests']);
if (result.sigSocketPendingRequests && Array.isArray(result.sigSocketPendingRequests)) {
console.log(`🔄 Found ${result.sigSocketPendingRequests.length} stored requests`);
// Filter requests for current workspace only
const currentWorkspaceRequests = result.sigSocketPendingRequests.filter(request =>
request.target_public_key === this.connectedPublicKey
);
console.log(`🔄 Restoring ${currentWorkspaceRequests.length} requests for current workspace`);
// Add each workspace-specific request back to WASM storage
for (const request of currentWorkspaceRequests) {
try {
await this.wasmModule.SigSocketManager.add_pending_request(JSON.stringify(request.request || request));
console.log(`✅ Restored request: ${request.id || request.request?.id}`);
} catch (error) {
console.warn(`Failed to restore request ${request.id || request.request?.id}:`, error);
}
}
// Update badge after restoration
this.updateBadge();
}
} catch (error) {
console.warn('Failed to restore pending requests:', error);
}
}
/**
* Persist pending requests to storage with workspace isolation
*/
async persistPendingRequests() {
try {
const requests = await this.getFilteredRequests();
// Get existing storage to merge with other workspaces
const result = await chrome.storage.local.get(['sigSocketPendingRequests']);
const existingRequests = result.sigSocketPendingRequests || [];
// Remove old requests for current workspace
const otherWorkspaceRequests = existingRequests.filter(request =>
request.target_public_key !== this.connectedPublicKey
);
// Combine with current workspace requests
const allRequests = [...otherWorkspaceRequests, ...requests];
await chrome.storage.local.set({ sigSocketPendingRequests: allRequests });
console.log(`💾 Persisted ${requests.length} requests for current workspace (${allRequests.length} total)`);
} catch (error) {
console.warn('Failed to persist pending requests:', error);
}
}
/**
* Connect to SigSocket server using WASM APIs
* WASM handles all connection logic (reuse, switching, etc.)
* @param {string} workspaceId - The workspace/keyspace identifier
* @param {number} retryCount - Number of retry attempts (default: 3)
* @returns {Promise<boolean>} - True if connected successfully
*/
async connectToServer(workspaceId, retryCount = 3) {
for (let attempt = 1; attempt <= retryCount; attempt++) {
try {
if (!this.wasmModule?.SigSocketManager) {
throw new Error('WASM SigSocketManager not available');
}
console.log(`🔗 Requesting SigSocket connection for workspace: ${workspaceId} (attempt ${attempt}/${retryCount})`);
// Clean workspace switching
if (this.currentWorkspace && this.currentWorkspace !== workspaceId) {
console.log(`🔄 Clean workspace switch: ${this.currentWorkspace} -> ${workspaceId}`);
await this.cleanWorkspaceSwitch(workspaceId);
// Small delay to ensure clean state transition
await new Promise(resolve => setTimeout(resolve, 300));
}
// Let WASM handle all connection logic (reuse, switching, etc.)
const connectionInfo = await this.wasmModule.SigSocketManager.connect_workspace_with_events(
workspaceId,
this.defaultServerUrl,
(event) => this.handleSigSocketEvent(event)
);
// Parse connection info
const info = JSON.parse(connectionInfo);
this.currentWorkspace = workspaceId; // Use the parameter we passed, not WASM response
this.connectedPublicKey = info.public_key;
this.isConnected = info.is_connected;
console.log(`✅ SigSocket connection result:`, {
workspace: this.currentWorkspace,
publicKey: this.connectedPublicKey?.substring(0, 16) + '...',
connected: this.isConnected,
serverUrl: this.defaultServerUrl
});
// Validate that we have a public key if connected
if (this.isConnected && !this.connectedPublicKey) {
console.warn('⚠️ Connected but no public key received - this may cause request issues');
}
// Update badge to show current state
this.updateBadge();
if (this.isConnected) {
// Clean flow: Connect -> Restore workspace requests -> Update UI
console.log(`🔗 Connected to workspace: ${workspaceId}, restoring pending requests...`);
// 1. Restore requests for this specific workspace
await this.restorePendingRequests();
// 2. Update badge with current count
this.updateBadge();
console.log(`✅ Workspace ${workspaceId} ready with restored requests`);
return true;
}
// If not connected but no error, try again
if (attempt < retryCount) {
console.log(`⏳ Connection not established, retrying in 1 second...`);
await new Promise(resolve => setTimeout(resolve, 1000));
}
} catch (error) {
// Check if this is an expected "no workspace" error during startup
const isExpectedStartupError = error.message &&
(error.message.includes('Workspace not found') ||
error.message.includes('no keypairs available'));
if (isExpectedStartupError && attempt === 1) {
console.log(`⏳ SigSocket connection attempt ${attempt}: No active workspace (expected after extension reload)`);
}
// Check if this is a public key related error
if (error.message && error.message.includes('public key')) {
console.error(`🔑 Public key error detected: ${error.message}`);
// For public key errors, don't retry immediately - might need workspace change
if (attempt === 1) {
console.log(`🔄 Public key error on first attempt, trying to disconnect and reconnect...`);
await this.disconnect();
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
if (attempt < retryCount) {
if (!isExpectedStartupError) {
console.log(`⏳ Retrying connection in 2 seconds...`);
}
await new Promise(resolve => setTimeout(resolve, 2000));
} else {
// Final attempt failed
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
if (isExpectedStartupError) {
console.log(` SigSocket connection failed: No active workspace. Will connect when user logs in.`);
}
}
}
}
return false;
}
/**
* Handle events from the WASM SigSocket client
* This is called automatically when requests arrive
* @param {Object} event - Event from WASM layer
*/
async handleSigSocketEvent(event) {
console.log('📨 Received SigSocket event:', event);
if (event.type === 'sign_request') {
console.log(`🔐 New sign request: ${event.request_id} for workspace: ${this.currentWorkspace}`);
// Clean flow: Request arrives -> Store -> Persist -> Update UI
try {
// 1. Request is automatically stored in WASM (already done by WASM layer)
// 2. Persist to storage with workspace isolation
await this.persistPendingRequests();
// 3. Update badge count
this.updateBadge();
// 4. Show notification
this.showSignRequestNotification();
// 5. Notify popup if connected
this.notifyPopupOfNewRequest();
console.log(`✅ Request ${event.request_id} processed and stored for workspace: ${this.currentWorkspace}`);
} catch (error) {
console.error(`❌ Failed to process request ${event.request_id}:`, error);
}
}
}
/**
* Approve a sign request using WASM APIs
* @param {string} requestId - Request to approve
* @returns {Promise<boolean>} - True if approved successfully
*/
async approveSignRequest(requestId) {
try {
if (!this.wasmModule?.SigSocketManager) {
throw new Error('WASM SigSocketManager not available');
}
// Check if we're connected before attempting approval
if (!this.isConnected) {
console.warn(`⚠️ Not connected to SigSocket server, cannot approve request: ${requestId}`);
throw new Error('Not connected to SigSocket server');
}
// Verify we can approve this request
const canApprove = await this.canApproveRequest(requestId);
if (!canApprove) {
console.warn(`⚠️ Cannot approve request ${requestId} - keyspace may be locked or request not found`);
throw new Error('Cannot approve request - keyspace may be locked or request not found');
}
console.log(`✅ Approving request: ${requestId}`);
// WASM handles all validation, signing, and server communication
await this.wasmModule.SigSocketManager.approve_request(requestId);
console.log(`🎉 Request approved successfully: ${requestId}`);
// Clean flow: Approve -> Remove from storage -> Update UI
// 1. Remove from persistent storage (WASM already removed it)
await this.persistPendingRequests();
// 2. Update badge count
this.updateBadge();
// 3. Notify popup of updated state
this.notifyPopupOfRequestUpdate();
console.log(`✅ Request ${requestId} approved and cleaned up`);
return true;
} catch (error) {
console.error(`❌ Failed to approve request ${requestId}:`, error);
// Check if this is a connection-related error
if (error.message && (error.message.includes('Connection not found') || error.message.includes('public key'))) {
console.error(`🔑 Connection/public key error during approval. Current state:`, {
connected: this.isConnected,
workspace: this.currentWorkspace,
publicKey: this.connectedPublicKey?.substring(0, 16) + '...'
});
// Try to reconnect for next time
if (this.currentWorkspace) {
console.log(`🔄 Attempting to reconnect to workspace: ${this.currentWorkspace}`);
setTimeout(() => this.connectToServer(this.currentWorkspace), 1000);
}
}
return false;
}
}
/**
* Reject a sign request using WASM APIs
* @param {string} requestId - Request to reject
* @param {string} reason - Reason for rejection
* @returns {Promise<boolean>} - True if rejected successfully
*/
async rejectSignRequest(requestId, reason = 'User rejected') {
try {
if (!this.wasmModule?.SigSocketManager) {
throw new Error('WASM SigSocketManager not available');
}
console.log(`❌ Rejecting request: ${requestId}, reason: ${reason}`);
// WASM handles rejection and server communication
await this.wasmModule.SigSocketManager.reject_request(requestId, reason);
console.log(`✅ Request rejected successfully: ${requestId}`);
// Clean flow: Reject -> Remove from storage -> Update UI
// 1. Remove from persistent storage (WASM already removed it)
await this.persistPendingRequests();
// 2. Update badge count
this.updateBadge();
// 3. Notify popup of updated state
this.notifyPopupOfRequestUpdate();
console.log(`✅ Request ${requestId} rejected and cleaned up`);
return true;
} catch (error) {
console.error(`❌ Failed to reject request ${requestId}:`, error);
return false;
}
}
/**
* Get pending requests from WASM (filtered by current workspace)
* @returns {Promise<Array>} - Array of pending requests for current workspace
*/
async getPendingRequests() {
return this.getFilteredRequests();
}
/**
* Get filtered requests from WASM (workspace-aware)
* @returns {Promise<Array>} - Array of filtered requests
*/
async getFilteredRequests() {
try {
if (!this.wasmModule?.SigSocketManager) {
return [];
}
const requestsJson = await this.wasmModule.SigSocketManager.get_filtered_requests();
const requests = JSON.parse(requestsJson);
console.log(`📋 Retrieved ${requests.length} filtered requests for current workspace`);
return requests;
} catch (error) {
console.error('Failed to get filtered requests:', error);
return [];
}
}
/**
* Check if a request can be approved (keyspace validation)
* @param {string} requestId - Request ID to check
* @returns {Promise<boolean>} - True if can be approved
*/
async canApproveRequest(requestId) {
try {
if (!this.wasmModule?.SigSocketManager) {
return false;
}
return await this.wasmModule.SigSocketManager.can_approve_request(requestId);
} catch (error) {
console.error('Failed to check request approval status:', error);
return false;
}
}
/**
* Show clickable notification for new sign request
* Call this AFTER the request has been stored and persisted
*/
async showSignRequestNotification() {
try {
if (chrome.notifications && chrome.notifications.create) {
// Small delay to ensure request is fully stored
await new Promise(resolve => setTimeout(resolve, 100));
console.log(`📢 Preparing notification for new signature request`);
// Check if keyspace is currently unlocked to customize message
let message = 'New signature request received. Click to review and approve.';
let title = 'SigSocket Sign Request';
// Try to determine if keyspace is locked
try {
const requests = await this.getPendingRequests();
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
if (!canApprove) {
message = 'New signature request received. Click to unlock keyspace and approve.';
title = 'SigSocket Request';
}
} catch (error) {
// If we can't check, use generic message
message = 'New signature request received. Click to open extension.';
}
// Create clickable notification with unique ID
const notificationId = `sigsocket-request-${Date.now()}`;
const notificationOptions = {
type: 'basic',
iconUrl: 'icons/icon48.png',
title: title,
message: message,
requireInteraction: true // Keep notification visible until user interacts
};
console.log(`📢 Creating notification: ${notificationId}`, notificationOptions);
chrome.notifications.create(notificationId, notificationOptions, (createdId) => {
if (chrome.runtime.lastError) {
console.error('❌ Failed to create notification:', chrome.runtime.lastError);
} else {
console.log(`✅ Notification created successfully: ${createdId}`);
}
});
} else {
console.log('📢 Notifications not available, skipping notification');
}
} catch (error) {
console.warn('Failed to show notification:', error);
}
}
/**
* Update extension badge with pending request count
*/
async updateBadge() {
try {
const requests = await this.getPendingRequests();
const count = requests.length;
const badgeText = count > 0 ? count.toString() : '';
console.log(`🔢 Updating badge: ${count} pending requests`);
chrome.action.setBadgeText({ text: badgeText });
chrome.action.setBadgeBackgroundColor({ color: '#ff6b6b' });
} catch (error) {
console.error('Failed to update badge:', error);
}
}
/**
* Notify popup about new request
*/
async notifyPopupOfNewRequest() {
if (!this.popupPort) {
console.log('No popup connected, skipping notification');
return;
}
try {
const requests = await this.getPendingRequests();
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : false;
this.popupPort.postMessage({
type: 'NEW_SIGN_REQUEST',
canApprove,
pendingRequests: requests
});
console.log(`📤 Notified popup: ${requests.length} requests, canApprove: ${canApprove}`);
} catch (error) {
console.error('Failed to notify popup:', error);
}
}
/**
* Notify popup about request updates
*/
async notifyPopupOfRequestUpdate() {
if (!this.popupPort) return;
try {
const requests = await this.getPendingRequests();
this.popupPort.postMessage({
type: 'REQUESTS_UPDATED',
pendingRequests: requests
});
} catch (error) {
console.error('Failed to notify popup of update:', error);
}
}
/**
* Disconnect from SigSocket server
* WASM handles all disconnection logic
*/
async disconnect() {
try {
if (this.wasmModule?.SigSocketManager) {
await this.wasmModule.SigSocketManager.disconnect();
}
// Clear local state
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
this.lastKnownConnectionState = false;
// Stop status monitoring
this.stopStatusMonitoring();
this.updateBadge();
console.log('🔌 SigSocket disconnection requested');
} catch (error) {
console.error('Failed to disconnect:', error);
}
}
/**
* Clear persisted pending requests from storage
*/
async clearPersistedRequests() {
try {
await chrome.storage.local.remove(['sigSocketPendingRequests']);
console.log('🗑️ Cleared persisted pending requests from storage');
} catch (error) {
console.warn('Failed to clear persisted requests:', error);
}
}
/**
* Clean workspace switch - clear current workspace requests only
*/
async cleanWorkspaceSwitch(newWorkspace) {
try {
console.log(`🔄 Clean workspace switch: ${this.currentWorkspace} -> ${newWorkspace}`);
// 1. Persist current workspace requests before switching
if (this.currentWorkspace && this.isConnected) {
await this.persistPendingRequests();
console.log(`💾 Saved requests for workspace: ${this.currentWorkspace}`);
}
// 2. Clear WASM state (will be restored for new workspace)
if (this.wasmModule?.SigSocketManager) {
await this.wasmModule.SigSocketManager.clear_pending_requests();
console.log('🧹 Cleared WASM request state');
}
// 3. Reset local state
this.currentWorkspace = null;
this.connectedPublicKey = null;
this.isConnected = false;
console.log('✅ Workspace switch cleanup completed');
} catch (error) {
console.error('❌ Failed to clean workspace switch:', error);
}
}
/**
* Get connection status with real connection verification
* @returns {Promise<Object>} - Connection status information
*/
async getStatus() {
try {
if (!this.wasmModule?.SigSocketManager) {
return {
isConnected: false,
workspace: null,
publicKey: null,
pendingRequestCount: 0,
serverUrl: this.defaultServerUrl
};
}
// Get WASM status first
const statusJson = await this.wasmModule.SigSocketManager.get_connection_status();
const status = JSON.parse(statusJson);
// Verify connection by trying to get requests (this will fail if not connected)
let actuallyConnected = false;
let requests = [];
try {
requests = await this.getPendingRequests();
// If we can get requests and WASM says connected, we're probably connected
actuallyConnected = status.is_connected && Array.isArray(requests);
} catch (error) {
// If getting requests fails, we're definitely not connected
console.warn('Connection verification failed:', error);
actuallyConnected = false;
}
// Update our internal state
this.isConnected = actuallyConnected;
if (status.connected_public_key && actuallyConnected) {
this.connectedPublicKey = status.connected_public_key;
} else {
this.connectedPublicKey = null;
}
// If we're disconnected, clear our workspace
if (!actuallyConnected) {
this.currentWorkspace = null;
}
const statusResult = {
isConnected: actuallyConnected,
workspace: this.currentWorkspace,
publicKey: status.connected_public_key,
pendingRequestCount: requests.length,
serverUrl: this.defaultServerUrl,
// Clean flow status indicators
cleanFlowReady: actuallyConnected && this.currentWorkspace && status.connected_public_key
};
console.log('📊 Clean flow status:', {
connected: statusResult.isConnected,
workspace: statusResult.workspace,
requestCount: statusResult.pendingRequestCount,
flowReady: statusResult.cleanFlowReady
});
return statusResult;
} catch (error) {
console.error('Failed to get status:', error);
// Clear state on error
this.isConnected = false;
this.currentWorkspace = null;
this.connectedPublicKey = null;
return {
isConnected: false,
workspace: null,
publicKey: null,
pendingRequestCount: 0,
serverUrl: this.defaultServerUrl
};
}
}
/**
* Set the popup port for communication
* @param {chrome.runtime.Port|null} port - The popup port or null to disconnect
*/
setPopupPort(port) {
this.popupPort = port;
if (port) {
console.log('📱 Popup connected to SigSocket service');
// Immediately check connection status when popup opens
this.checkConnectionStatusNow();
// Start monitoring connection status when popup connects
this.startStatusMonitoring();
} else {
console.log('📱 Popup disconnected from SigSocket service');
// Stop monitoring when popup disconnects
this.stopStatusMonitoring();
}
}
/**
* Immediately check and update connection status
*/
async checkConnectionStatusNow() {
try {
// Force a fresh connection check
const currentStatus = await this.getStatusWithConnectionTest();
this.lastKnownConnectionState = currentStatus.isConnected;
// Notify popup of current status
this.notifyPopupOfStatusChange(currentStatus);
console.log(`🔍 Immediate status check: ${currentStatus.isConnected ? 'Connected' : 'Disconnected'}`);
} catch (error) {
console.warn('Failed to check connection status immediately:', error);
}
}
/**
* Get status with additional connection testing
*/
async getStatusWithConnectionTest() {
const status = await this.getStatus();
// If WASM claims we're connected, do an additional verification
if (status.isConnected) {
try {
// Try to get connection status again - if this fails, we're not really connected
const verifyJson = await this.wasmModule.SigSocketManager.get_connection_status();
const verifyStatus = JSON.parse(verifyJson);
if (!verifyStatus.is_connected) {
console.log('🔍 Connection verification failed - marking as disconnected');
status.isConnected = false;
this.isConnected = false;
this.currentWorkspace = null;
}
} catch (error) {
console.log('🔍 Connection test failed - marking as disconnected:', error.message);
status.isConnected = false;
this.isConnected = false;
this.currentWorkspace = null;
}
}
return status;
}
/**
* Start periodic status monitoring to detect connection changes
*/
startStatusMonitoring() {
// Clear any existing monitoring
if (this.statusMonitorInterval) {
clearInterval(this.statusMonitorInterval);
}
// Check status every 2 seconds when popup is open (more responsive)
this.statusMonitorInterval = setInterval(async () => {
if (this.popupPort) {
try {
const currentStatus = await this.getStatusWithConnectionTest();
// Check if connection status changed
if (currentStatus.isConnected !== this.lastKnownConnectionState) {
console.log(`🔄 Connection state changed: ${this.lastKnownConnectionState} -> ${currentStatus.isConnected}`);
this.lastKnownConnectionState = currentStatus.isConnected;
// Notify popup of status change
this.notifyPopupOfStatusChange(currentStatus);
}
} catch (error) {
console.warn('Status monitoring error:', error);
// On error, assume disconnected
if (this.lastKnownConnectionState !== false) {
console.log('🔄 Status monitoring error - marking as disconnected');
this.lastKnownConnectionState = false;
this.notifyPopupOfStatusChange({
isConnected: false,
workspace: null,
publicKey: null,
pendingRequestCount: 0,
serverUrl: this.defaultServerUrl
});
}
}
} else {
// Stop monitoring when popup is closed
this.stopStatusMonitoring();
}
}, 2000); // 2 seconds for better responsiveness
}
/**
* Stop status monitoring
*/
stopStatusMonitoring() {
if (this.statusMonitorInterval) {
clearInterval(this.statusMonitorInterval);
this.statusMonitorInterval = null;
}
}
/**
* Notify popup of connection status change
* @param {Object} status - Current connection status
*/
notifyPopupOfStatusChange(status) {
if (this.popupPort) {
this.popupPort.postMessage({
type: 'CONNECTION_STATUS_CHANGED',
status: status
});
console.log(`📡 Notified popup of connection status change: ${status.isConnected ? 'Connected' : 'Disconnected'}`);
}
}
/**
* Called when keyspace is unlocked - clean approach to show pending requests
*/
async onKeypaceUnlocked() {
try {
console.log('🔓 Keyspace unlocked - preparing to show pending requests');
// 1. Restore any persisted requests for this workspace
await this.restorePendingRequests();
// 2. Get current requests (includes restored + any new ones)
const requests = await this.getPendingRequests();
// 3. Check if we can approve requests (keyspace should be unlocked now)
const canApprove = requests.length > 0 ? await this.canApproveRequest(requests[0].id) : true;
// 4. Update badge with current count
this.updateBadge();
// 5. Notify popup if connected
if (this.popupPort) {
this.popupPort.postMessage({
type: 'KEYSPACE_UNLOCKED',
canApprove,
pendingRequests: requests
});
}
console.log(`🔓 Keyspace unlocked: ${requests.length} requests ready, canApprove: ${canApprove}`);
return requests;
} catch (error) {
console.error('Failed to handle keyspace unlock:', error);
return [];
}
}
}
// Export for use in background script
export default SigSocketService;