Compare commits

...

15 Commits

Author SHA1 Message Date
Sameh Abouel-saad
1d3d0a4fa4 remove ts extension 2025-06-17 16:31:57 +03:00
zaelgohary
4f3f98a954 feat: Implement SigSocket request queuing and approval system, Enhance Settings UI 2025-06-17 03:18:25 +03:00
Sameh Abouel-saad
c641d0ae2e feat: Update build scripts for crypto_vault_extension 2025-06-11 14:22:05 +03:00
Sameh Abouel-saad
6f42e5ab8d v2 2025-06-06 05:31:03 +03:00
Sameh Abouel-saad
203cde1cba Reset crypto_vault_extension to clean state before SigSocket implementation 2025-06-05 20:08:10 +03:00
Sameh Abouel-saad
6b037537bf feat: Enhance request management in SigSocket client with new methods and structures 2025-06-05 20:02:34 +03:00
Sameh Abouel-saad
580fd72dce feat: Implement Sign Request Manager component for handling sign requests in the popup (WIP)
- Added SignRequestManager.js to manage sign requests, including UI states for keyspace lock, mismatch, and approval.
- Implemented methods for loading state, rendering UI, and handling user interactions (approve/reject requests).
- Integrated background message listeners for keyspace unlock events and request updates.
2025-06-04 16:55:15 +03:00
Sameh Abouel-saad
a0622629ae feat: Add function to retrieve the current keyspace name in WebAssembly bindings 2025-06-04 16:43:54 +03:00
Sameh Abouel-saad
4e1e707f85 feat: Add SigSocket integration with WASM client and JavaScript bridge for sign requests 2025-06-04 16:11:53 +03:00
Sameh Abouel-saad
9f143ded9d Implement native and WASM WebSocket client for sigsocket communication
- Added `NativeClient` for non-WASM environments with automatic reconnection and message handling.
- Introduced `WasmClient` for WASM environments, supporting WebSocket communication and reconnection logic.
- Created protocol definitions for `SignRequest` and `SignResponse` with serialization and deserialization.
- Developed integration tests for the client functionality and sign request handling.
- Implemented WASM-specific tests to ensure compatibility and functionality in browser environments.
2025-06-04 13:03:15 +03:00
Sameh Abouel-saad
b0d0aaa53d refactor: replace Ed25519 with Secp256k1 for default keypair generation 2025-06-02 15:59:17 +03:00
Sameh Abouel-saad
e00c140396 Fix tests 2025-05-29 14:40:55 +03:00
Sameh Abouel-saad
4ba1e43f4e clean files 2025-05-29 13:42:13 +03:00
Sameh Abouel-saad
b82d457873 Fixed unused imports and variables 2025-05-29 13:41:10 +03:00
zaelgohary
b0b6359be1 Enhance themes 2025-05-29 09:03:18 +03:00
96 changed files with 7532 additions and 9317 deletions

View File

@@ -5,5 +5,5 @@ members = [
"vault", "vault",
"evm_client", "evm_client",
"wasm_app", "wasm_app",
"sigsocket_client",
] ]

View File

@@ -26,6 +26,7 @@ build-wasm-app:
cd wasm_app && wasm-pack build --target web cd wasm_app && wasm-pack build --target web
# Build Hero Vault extension: wasm, copy, then extension # Build Hero Vault extension: wasm, copy, then extension
build-hero-vault-extension: build-crypto-vault-extension: build-wasm-app
cd wasm_app && wasm-pack build --target web cp wasm_app/pkg/wasm_app* crypto_vault_extension/wasm/
cd hero_vault_extension && npm run build cp wasm_app/pkg/*.d.ts crypto_vault_extension/wasm/
cp wasm_app/pkg/*.js crypto_vault_extension/wasm/

View File

@@ -134,13 +134,13 @@ For questions, contributions, or more details, see the architecture docs or open
### Native ### Native
```sh ```sh
cargo check --workspace --features kvstore/native cargo check --workspace
cargo test --workspace
``` ```
### WASM (kvstore only) ### WASM
```sh ```sh
cd kvstore make test-browser-all
wasm-pack test --headless --firefox --features web
``` ```
# Rhai Scripting System # Rhai Scripting System

View File

@@ -17,32 +17,22 @@ cd "$(dirname "$0")/wasm_app" || exit 1
wasm-pack build --target web wasm-pack build --target web
echo -e "${GREEN}✓ WASM build successful!${RESET}" echo -e "${GREEN}✓ WASM build successful!${RESET}"
# Step 2: Build the frontend extension # Step 2: Prepare the frontend extension
echo -e "${BLUE}Building frontend extension...${RESET}" echo -e "${BLUE}Preparing frontend extension...${RESET}"
cd ../hero_vault_extension || exit 1 cd ../crypto_vault_extension || exit 1
# Copy WASM files to the extension's public directory # Copy WASM files to the extension's public directory
echo "Copying WASM files..." echo "Copying WASM files..."
mkdir -p public/wasm cp ../wasm_app/pkg/wasm_app* wasm/
cp ../wasm_app/pkg/wasm_app* public/wasm/ cp ../wasm_app/pkg/*.d.ts wasm/
cp ../wasm_app/pkg/*.d.ts public/wasm/ cp ../wasm_app/pkg/*.js wasm/
cp ../wasm_app/pkg/package.json public/wasm/
# Build the extension without TypeScript checking
echo "Building extension..."
export NO_TYPECHECK=true
npm run build
# Ensure the background script is properly built
echo "Building background script..."
node scripts/build-background.js
echo -e "${GREEN}✓ Frontend build successful!${RESET}"
echo -e "${GREEN}=== Build Complete ===${RESET}" echo -e "${GREEN}=== Build Complete ===${RESET}"
echo "Extension is ready in: $(pwd)/dist" echo "Extension is ready in: $(pwd)"
echo "" echo ""
echo -e "${BLUE}To load the extension in Chrome:${RESET}" echo -e "${BLUE}To load the extension in Chrome:${RESET}"
echo "1. Go to chrome://extensions/" echo "1. Go to chrome://extensions/"
echo "2. Enable Developer mode (toggle in top-right)" echo "2. Enable Developer mode (toggle in top-right)"
echo "3. Click 'Load unpacked'" echo "3. Click 'Load unpacked'"
echo "4. Select the 'dist' directory: $(pwd)/dist" echo "4. Select the $(pwd) directory"

View File

@@ -6,6 +6,9 @@ let sessionTimeoutDuration = 15; // Default 15 seconds
let sessionTimeoutId = null; // Background timer let sessionTimeoutId = null; // Background timer
let popupPort = null; // Track popup connection let popupPort = null; // Track popup connection
// SigSocket service instance
let sigSocketService = null;
// Utility function to convert Uint8Array to hex // Utility function to convert Uint8Array to hex
function toHex(uint8Array) { function toHex(uint8Array) {
return Array.from(uint8Array) return Array.from(uint8Array)
@@ -26,7 +29,21 @@ function startSessionTimeout() {
if (vault && currentSession) { if (vault && currentSession) {
// Lock the session // Lock the session
vault.lock_session(); vault.lock_session();
// Keep the session info for SigSocket connection but mark it as timed out
const keyspace = currentSession.keyspace;
await sessionManager.clear(); await sessionManager.clear();
// Maintain SigSocket connection for the locked keyspace to receive pending requests
if (sigSocketService && keyspace) {
try {
// Keep SigSocket connected to receive requests even when locked
console.log(`🔒 Session timed out but maintaining SigSocket connection for: ${keyspace}`);
} catch (error) {
console.warn('Failed to maintain SigSocket connection after timeout:', error);
}
}
// Notify popup if it's open // Notify popup if it's open
if (popupPort) { if (popupPort) {
popupPort.postMessage({ popupPort.postMessage({
@@ -127,16 +144,53 @@ async function restoreSession() {
if (isUnlocked) { if (isUnlocked) {
// Restart keep-alive for restored session // Restart keep-alive for restored session
startKeepAlive(); startKeepAlive();
// Connect to SigSocket for the restored session
if (sigSocketService) {
try {
const connected = await sigSocketService.connectToServer(session.keyspace);
if (connected) {
console.log(`🔗 SigSocket reconnected for restored workspace: ${session.keyspace}`);
}
} catch (error) {
// Don't show as warning if it's just "no workspace" - this is expected on fresh start
if (error.message && error.message.includes('Workspace not found')) {
console.log(` SigSocket connection skipped for restored session: No workspace available yet`);
} else {
console.warn('Failed to reconnect SigSocket for restored session:', error);
}
}
}
return session; return session;
} else { } else {
await sessionManager.clear(); // Session exists but is locked - still try to connect SigSocket to receive pending requests
if (sigSocketService && session.keyspace) {
try {
const connected = await sigSocketService.connectToServer(session.keyspace);
if (connected) {
console.log(`🔗 SigSocket connected for locked workspace: ${session.keyspace} (will queue requests)`);
}
} catch (error) {
// Don't show as warning if it's just "no workspace" - this is expected on fresh start
if (error.message && error.message.includes('Workspace not found')) {
console.log(` SigSocket connection skipped for locked session: No workspace available yet`);
} else {
console.warn('Failed to connect SigSocket for locked session:', error);
} }
} }
return null; }
// Don't clear the session - keep it for SigSocket connection
// await sessionManager.clear();
}
}
return session; // Return session even if locked, so we know which keyspace to use
} }
// Import WASM module functions // Import WASM module functions and SigSocket service
import init, * as wasmFunctions from './wasm/wasm_app.js'; import init, * as wasmFunctions from './wasm/wasm_app.js';
import SigSocketService from './background/sigsocket.js';
// Initialize WASM module // Initialize WASM module
async function initVault() { async function initVault() {
@@ -151,6 +205,13 @@ async function initVault() {
vault = wasmFunctions; vault = wasmFunctions;
isInitialized = true; isInitialized = true;
// Initialize SigSocket service
if (!sigSocketService) {
sigSocketService = new SigSocketService();
await sigSocketService.initialize(vault);
console.log('🔌 SigSocket service initialized');
}
// Try to restore previous session // Try to restore previous session
await restoreSession(); await restoreSession();
@@ -172,6 +233,37 @@ const messageHandlers = {
initSession: async (request) => { initSession: async (request) => {
await vault.init_session(request.keyspace, request.password); await vault.init_session(request.keyspace, request.password);
await sessionManager.save(request.keyspace); await sessionManager.save(request.keyspace);
// Smart auto-connect to SigSocket when session is initialized
if (sigSocketService) {
try {
console.log(`🔗 Initializing SigSocket connection for workspace: ${request.keyspace}`);
// This will reuse existing connection if same workspace, or switch if different
const connected = await sigSocketService.connectToServer(request.keyspace);
if (connected) {
console.log(`✅ SigSocket ready for workspace: ${request.keyspace}`);
} else {
console.warn(`⚠️ SigSocket connection failed for workspace: ${request.keyspace}`);
}
} catch (error) {
console.warn('Failed to auto-connect to SigSocket:', error);
// If connection fails, try once more after a short delay
setTimeout(async () => {
try {
console.log(`🔄 Retrying SigSocket connection for workspace: ${request.keyspace}`);
await sigSocketService.connectToServer(request.keyspace);
} catch (retryError) {
console.warn('SigSocket retry connection also failed:', retryError);
}
}, 2000);
}
// Notify SigSocket service that keyspace is now unlocked
await sigSocketService.onKeypaceUnlocked();
}
return { success: true }; return { success: true };
}, },
@@ -261,6 +353,84 @@ const messageHandlers = {
await chrome.storage.local.set({ sessionTimeout: request.timeout }); await chrome.storage.local.set({ sessionTimeout: request.timeout });
resetSessionTimeout(); // Restart with new duration resetSessionTimeout(); // Restart with new duration
return { success: true }; return { success: true };
},
updateSigSocketUrl: async (request) => {
if (sigSocketService) {
// Update the server URL in the SigSocket service
sigSocketService.defaultServerUrl = request.serverUrl;
// Save to storage (already done in popup, but ensure consistency)
await chrome.storage.local.set({ sigSocketUrl: request.serverUrl });
console.log(`🔗 SigSocket server URL updated to: ${request.serverUrl}`);
}
return { success: true };
},
// SigSocket handlers
connectSigSocket: async (request) => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
const connected = await sigSocketService.connectToServer(request.workspace);
return { success: connected };
},
disconnectSigSocket: async () => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
await sigSocketService.disconnect();
return { success: true };
},
getSigSocketStatus: async () => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
const status = await sigSocketService.getStatus();
return { success: true, status };
},
getSigSocketStatusWithTest: async () => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
// Use the enhanced connection testing method
const status = await sigSocketService.getStatusWithConnectionTest();
return { success: true, status };
},
getPendingSignRequests: async () => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
try {
// Use WASM filtered requests which handles workspace filtering
const requests = await sigSocketService.getFilteredRequests();
return { success: true, requests };
} catch (error) {
console.error('Failed to get pending requests:', error);
return { success: false, error: error.message };
}
},
approveSignRequest: async (request) => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
const approved = await sigSocketService.approveSignRequest(request.requestId);
return { success: approved };
},
rejectSignRequest: async (request) => {
if (!sigSocketService) {
return { success: false, error: 'SigSocket service not initialized' };
}
const rejected = await sigSocketService.rejectSignRequest(request.requestId, request.reason);
return { success: rejected };
} }
}; };
@@ -302,15 +472,199 @@ chrome.runtime.onConnect.addListener((port) => {
// Track popup connection // Track popup connection
popupPort = port; popupPort = port;
// Connect SigSocket service to popup
if (sigSocketService) {
sigSocketService.setPopupPort(port);
}
// If we have an active session, ensure keep-alive is running // If we have an active session, ensure keep-alive is running
if (currentSession) { if (currentSession) {
startKeepAlive(); startKeepAlive();
} }
// Handle messages from popup
port.onMessage.addListener(async (message) => {
if (message.type === 'REQUEST_IMMEDIATE_STATUS') {
// Immediately send current SigSocket status to popup
if (sigSocketService) {
try {
const status = await sigSocketService.getStatus();
port.postMessage({
type: 'CONNECTION_STATUS_CHANGED',
status: status
});
console.log('📡 Sent immediate status to popup:', status.isConnected ? 'Connected' : 'Disconnected');
} catch (error) {
console.warn('Failed to send immediate status:', error);
}
}
}
});
port.onDisconnect.addListener(() => { port.onDisconnect.addListener(() => {
// Popup closed, clear reference and stop keep-alive // Popup closed, clear reference and stop keep-alive
popupPort = null; popupPort = null;
stopKeepAlive(); stopKeepAlive();
// Disconnect SigSocket service from popup
if (sigSocketService) {
sigSocketService.setPopupPort(null);
}
}); });
} }
}); });
// Handle notification clicks to open extension (notifications are now clickable without buttons)
chrome.notifications.onClicked.addListener(async (notificationId) => {
console.log(`🔔 Notification clicked: ${notificationId}`);
// Check if this is a SigSocket notification
if (notificationId.startsWith('sigsocket-request-')) {
console.log('🔔 SigSocket notification clicked, opening extension...');
try {
await openExtensionPopup();
// Clear the notification after successfully opening
chrome.notifications.clear(notificationId);
console.log('✅ Notification cleared after opening extension');
} catch (error) {
console.error('❌ Failed to handle notification click:', error);
}
} else {
console.log('🔔 Non-SigSocket notification clicked, ignoring');
}
});
// Note: Notification button handler removed - notifications are now clickable without buttons
// Function to open extension popup with best UX
async function openExtensionPopup() {
try {
console.log('🔔 Opening extension popup from notification...');
// First, check if there's already a popup window open
const windows = await chrome.windows.getAll({ populate: true });
const existingPopup = windows.find(window =>
window.type === 'popup' &&
window.tabs?.some(tab => tab.url?.includes('popup.html'))
);
if (existingPopup) {
// Focus existing popup and send focus message
await chrome.windows.update(existingPopup.id, { focused: true });
console.log('✅ Focused existing popup window');
// Send message to focus on SigSocket section
if (popupPort) {
popupPort.postMessage({
type: 'FOCUS_SIGSOCKET',
fromNotification: true
});
}
return;
}
// Best UX: Try to use the normal popup experience
// The action API gives the same popup as clicking the extension icon
try {
if (chrome.action && chrome.action.openPopup) {
await chrome.action.openPopup();
console.log('✅ Extension popup opened via action API (best UX - normal popup)');
// Send focus message after popup opens
setTimeout(() => {
if (popupPort) {
popupPort.postMessage({
type: 'FOCUS_SIGSOCKET',
fromNotification: true
});
}
}, 200);
return;
}
} catch (actionError) {
// The action API fails when there's no active browser window
// This is common when all browser windows are closed but extension is still running
console.log('⚠️ Action API failed (likely no active window):', actionError.message);
// Check if we have any normal browser windows
const allWindows = await chrome.windows.getAll();
const normalWindows = allWindows.filter(w => w.type === 'normal');
if (normalWindows.length > 0) {
// We have browser windows, try to focus one and retry action API
try {
const targetWindow = normalWindows.find(w => w.focused) || normalWindows[0];
await chrome.windows.update(targetWindow.id, { focused: true });
// Small delay and retry
await new Promise(resolve => setTimeout(resolve, 100));
await chrome.action.openPopup();
console.log('✅ Extension popup opened via action API after focusing window');
setTimeout(() => {
if (popupPort) {
popupPort.postMessage({
type: 'FOCUS_SIGSOCKET',
fromNotification: true
});
}
}, 200);
return;
} catch (retryError) {
console.log('⚠️ Action API retry also failed:', retryError.message);
}
}
}
// If action API fails completely, we need to create a window
// But let's make it as close to the normal popup experience as possible
console.log('⚠️ Creating popup window as fallback (action API unavailable)');
const popupUrl = chrome.runtime.getURL('popup.html?from=notification');
// Position the popup where the extension icon would normally show its popup
// Try to position it in the top-right area like a normal extension popup
let left = screen.width - 420; // 400px width + 20px margin
let top = 80; // Below browser toolbar area
try {
// If we have a browser window, position relative to it
const allWindows = await chrome.windows.getAll();
const normalWindows = allWindows.filter(w => w.type === 'normal');
if (normalWindows.length > 0) {
const referenceWindow = normalWindows[0];
left = (referenceWindow.left || 0) + (referenceWindow.width || 800) - 420;
top = (referenceWindow.top || 0) + 80;
}
} catch (positionError) {
console.log('⚠️ Could not get window position, using screen-based positioning');
}
const newWindow = await chrome.windows.create({
url: popupUrl,
type: 'popup',
width: 400,
height: 600,
left: Math.max(0, left),
top: Math.max(0, top),
focused: true
});
console.log(`✅ Extension popup window created: ${newWindow.id}`);
} catch (error) {
console.error('❌ Failed to open extension popup:', error);
// Final fallback: open in new tab (least ideal but still functional)
try {
const popupUrl = chrome.runtime.getURL('popup.html?from=notification');
await chrome.tabs.create({ url: popupUrl, active: true });
console.log('✅ Opened extension in new tab as final fallback');
} catch (tabError) {
console.error('❌ All popup opening methods failed:', tabError);
}
}
}

View File

@@ -0,0 +1,876 @@
/**
* 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;

View File

@@ -0,0 +1,75 @@
# Mock SigSocket Server Demo
This directory contains a mock SigSocket server for testing the browser extension functionality.
## Setup
1. Install dependencies:
```bash
npm install
```
2. Start the mock server:
```bash
npm start
```
The server will listen on `ws://localhost:8080/ws`
## Usage
### Interactive Commands
Once the server is running, you can use these commands:
- `test` - Send a test sign request to all connected clients
- `status` - Show server status and connected clients
- `quit` - Shutdown the server
### Testing Flow
1. Start the mock server
2. Load the browser extension in Chrome
3. Create a keyspace and keypair in the extension
4. The extension should automatically connect to the server
5. The server will send a test sign request after 3 seconds
6. Use the extension popup to approve or reject the request
7. The server will log the response and send another request after 10 seconds
### Expected Output
When a client connects:
```
New WebSocket connection from: ::1
Received message: 04a8b2c3d4e5f6...
Client registered: client_1234567890_abc123 with public key: 04a8b2c3d4e5f6...
📝 Sending sign request to client_1234567890_abc123: req_1_1234567890
Message: "Test message 1 - 2024-01-01T12:00:00.000Z"
```
When a sign response is received:
```
Received sign response from client_1234567890_abc123: {
id: 'req_1_1234567890',
message: 'VGVzdCBtZXNzYWdlIDEgLSAyMDI0LTAxLTAxVDEyOjAwOjAwLjAwMFo=',
signature: '3045022100...'
}
✅ Sign request req_1_1234567890 completed successfully
Signature: 3045022100...
```
## Protocol
The mock server implements a simplified version of the SigSocket protocol:
1. **Client Introduction**: Client sends hex-encoded public key
2. **Welcome Message**: Server responds with welcome JSON
3. **Sign Requests**: Server sends JSON with `id` and `message` (base64)
4. **Sign Responses**: Client sends JSON with `id`, `message`, and `signature`
## Troubleshooting
- **Connection refused**: Make sure the server is running on port 8080
- **No sign requests**: Check that the extension is properly connected
- **Extension errors**: Check the browser console for JavaScript errors
- **WASM errors**: Ensure the WASM files are properly built and loaded

View File

@@ -0,0 +1,232 @@
#!/usr/bin/env node
/**
* Mock SigSocket Server for Testing Browser Extension
*
* This is a simple WebSocket server that simulates the SigSocket protocol
* for testing the browser extension functionality.
*
* Usage:
* node mock_sigsocket_server.js
*
* The server will listen on ws://localhost:8080/ws
*/
const WebSocket = require('ws');
const http = require('http');
class MockSigSocketServer {
constructor(port = 8080) {
this.port = port;
this.clients = new Map(); // clientId -> { ws, publicKey }
this.requestCounter = 0;
this.setupServer();
}
setupServer() {
// Create HTTP server
this.httpServer = http.createServer();
// Create WebSocket server
this.wss = new WebSocket.Server({
server: this.httpServer,
path: '/ws'
});
this.wss.on('connection', (ws, req) => {
console.log('New WebSocket connection from:', req.socket.remoteAddress);
this.handleConnection(ws);
});
this.httpServer.listen(this.port, () => {
console.log(`Mock SigSocket Server listening on ws://localhost:${this.port}/ws`);
console.log('Waiting for browser extension connections...');
});
}
handleConnection(ws) {
let clientId = null;
let publicKey = null;
ws.on('message', (data) => {
try {
const message = data.toString();
console.log('Received message:', message);
// Check if this is a client introduction (hex-encoded public key)
if (!clientId && this.isHexString(message)) {
publicKey = message;
clientId = this.generateClientId();
this.clients.set(clientId, { ws, publicKey });
console.log(`Client registered: ${clientId} with public key: ${publicKey.substring(0, 16)}...`);
// Send welcome message
ws.send(JSON.stringify({
type: 'welcome',
clientId: clientId,
message: 'Connected to Mock SigSocket Server'
}));
// Schedule a test sign request after 3 seconds
setTimeout(() => {
this.sendTestSignRequest(clientId);
}, 3000);
return;
}
// Try to parse as JSON (sign response)
try {
const jsonMessage = JSON.parse(message);
this.handleSignResponse(clientId, jsonMessage);
} catch (e) {
console.log('Received non-JSON message:', message);
}
} catch (error) {
console.error('Error handling message:', error);
}
});
ws.on('close', () => {
if (clientId) {
this.clients.delete(clientId);
console.log(`Client disconnected: ${clientId}`);
}
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
}
handleSignResponse(clientId, response) {
console.log(`Received sign response from ${clientId}:`, response);
if (response.id && response.signature) {
console.log(`✅ Sign request ${response.id} completed successfully`);
console.log(` Signature: ${response.signature.substring(0, 32)}...`);
// Send another test request after 10 seconds
setTimeout(() => {
this.sendTestSignRequest(clientId);
}, 10000);
} else {
console.log('❌ Invalid sign response format');
}
}
sendTestSignRequest(clientId) {
const client = this.clients.get(clientId);
if (!client) {
console.log(`Client ${clientId} not found`);
return;
}
this.requestCounter++;
const requestId = `req_${this.requestCounter}_${Date.now()}`;
const testMessage = `Test message ${this.requestCounter} - ${new Date().toISOString()}`;
const messageBase64 = Buffer.from(testMessage).toString('base64');
const signRequest = {
id: requestId,
message: messageBase64
};
console.log(`📝 Sending sign request to ${clientId}:`, requestId);
console.log(` Message: "${testMessage}"`);
try {
client.ws.send(JSON.stringify(signRequest));
} catch (error) {
console.error(`Failed to send sign request to ${clientId}:`, error);
}
}
isHexString(str) {
return /^[0-9a-fA-F]+$/.test(str) && str.length >= 32; // At least 16 bytes
}
generateClientId() {
return `client_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
}
// Send a test request to all connected clients
broadcastTestRequest() {
console.log('\n📢 Broadcasting test sign request to all clients...');
for (const [clientId] of this.clients) {
this.sendTestSignRequest(clientId);
}
}
// Get server status
getStatus() {
return {
port: this.port,
connectedClients: this.clients.size,
clients: Array.from(this.clients.keys())
};
}
}
// Create and start the server
const server = new MockSigSocketServer();
// Handle graceful shutdown
process.on('SIGINT', () => {
console.log('\n🛑 Shutting down Mock SigSocket Server...');
server.httpServer.close(() => {
console.log('Server closed');
process.exit(0);
});
});
// Add some interactive commands
process.stdin.setEncoding('utf8');
console.log('\n📋 Available commands:');
console.log(' "test" - Send test sign request to all clients');
console.log(' "status" - Show server status');
console.log(' "quit" - Shutdown server');
console.log(' Type a command and press Enter\n');
process.stdin.on('readable', () => {
const chunk = process.stdin.read();
if (chunk !== null) {
const command = chunk.trim().toLowerCase();
switch (command) {
case 'test':
server.broadcastTestRequest();
break;
case 'status':
const status = server.getStatus();
console.log('\n📊 Server Status:');
console.log(` Port: ${status.port}`);
console.log(` Connected clients: ${status.connectedClients}`);
if (status.clients.length > 0) {
console.log(` Client IDs: ${status.clients.join(', ')}`);
}
console.log('');
break;
case 'quit':
case 'exit':
process.emit('SIGINT');
break;
case '':
break;
default:
console.log(`Unknown command: ${command}`);
break;
}
}
});
// Export for testing
module.exports = MockSigSocketServer;

View File

@@ -0,0 +1,21 @@
{
"name": "mock-sigsocket-server",
"version": "1.0.0",
"description": "Mock SigSocket server for testing browser extension",
"main": "mock_sigsocket_server.js",
"scripts": {
"start": "node mock_sigsocket_server.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {
"ws": "^8.14.0"
},
"keywords": [
"websocket",
"sigsocket",
"testing",
"mock"
],
"author": "",
"license": "MIT"
}

View File

@@ -6,7 +6,8 @@
"permissions": [ "permissions": [
"storage", "storage",
"activeTab" "activeTab",
"notifications"
], ],
"icons": { "icons": {

View File

@@ -7,28 +7,17 @@
<body> <body>
<div class="container"> <div class="container">
<header class="header"> <header class="header">
<div class="logo"> <div class="logo clickable-header" id="headerTitle">
<div class="logo-icon">🔐</div> <div class="logo-icon">🔐</div>
<h1>CryptoVault</h1> <h1>CryptoVault</h1>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<div class="settings-container"> <button id="settingsBtn" class="btn-icon-only" title="Settings">
<button id="settingsToggle" class="btn-icon-only" title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle> <circle cx="12" cy="12" r="3"></circle>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path> <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
</svg> </svg>
</button> </button>
<div class="settings-dropdown hidden" id="settingsDropdown">
<div class="settings-item">
<label for="timeoutInput">Session Timeout</label>
<div class="timeout-input-group">
<input type="number" id="timeoutInput" min="3" max="300" value="15">
<span>seconds</span>
</div>
</div>
</div>
</div>
<button id="themeToggle" class="btn-icon-only" title="Switch to dark mode"> <button id="themeToggle" class="btn-icon-only" title="Switch to dark mode">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
@@ -73,6 +62,50 @@
</div> </div>
</div> </div>
<!-- SigSocket Requests Section -->
<div class="card sigsocket-section" id="sigSocketSection">
<div class="section-header">
<h3>🔌 SigSocket Requests</h3>
<div class="connection-status" id="connectionStatus">
<span class="status-dot" id="connectionDot"></span>
<span id="connectionText">Disconnected</span>
</div>
</div>
<div class="requests-container" id="requestsContainer">
<div class="loading-requests hidden" id="loadingRequestsMessage">
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Loading requests...</p>
<small>Fetching pending signature requests</small>
</div>
</div>
<div class="no-requests" id="noRequestsMessage">
<div class="empty-state">
<div class="empty-icon">📝</div>
<p>No pending sign requests</p>
<small>Requests will appear here when received from SigSocket server</small>
</div>
</div>
<div class="requests-list hidden" id="requestsList">
<!-- Requests will be populated here -->
</div>
</div>
<div class="sigsocket-actions">
<button id="refreshRequestsBtn" class="btn btn-ghost btn-small">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"></polyline>
<polyline points="1 20 1 14 7 14"></polyline>
<path d="M20.49 9A9 9 0 0 0 5.64 5.64L1 10m22 4l-4.64 4.36A9 9 0 0 1 3.51 15"></path>
</svg>
Refresh
</button>
</div>
</div>
<div class="vault-header"> <div class="vault-header">
<h2>Your Keypairs</h2> <h2>Your Keypairs</h2>
<button id="toggleAddKeypairBtn" class="btn btn-primary"> <button id="toggleAddKeypairBtn" class="btn btn-primary">
@@ -191,8 +224,41 @@
</div> </div>
</section> </section>
<!-- Settings Section -->
<section class="section hidden" id="settingsSection">
<div class="settings-header">
<h2>Settings</h2>
</div>
<!-- Session Settings -->
<div class="card">
<h3>Session Settings</h3>
<div class="settings-item">
<label for="timeoutInput">Session Timeout</label>
<div class="timeout-input-group">
<input type="number" id="timeoutInput" min="3" max="300" value="15">
<span>seconds</span>
</div>
<small class="settings-help">Automatically lock session after inactivity</small>
</div>
</div>
<!-- SigSocket Settings -->
<div class="card">
<h3>SigSocket Settings</h3>
<div class="settings-item">
<label for="serverUrlInput">Server URL</label>
<div class="server-input-group">
<input type="text" id="serverUrlInput" placeholder="ws://localhost:8080/ws" value="ws://localhost:8080/ws">
<button id="saveServerUrlBtn" class="btn btn-small btn-primary">Save</button>
</div>
<small class="settings-help">WebSocket URL for SigSocket server (ws:// or wss://)</small>
</div>
</div>
</section>
</div> </div>

View File

@@ -32,6 +32,11 @@ function showToast(message, type = 'info') {
// Enhanced loading states for buttons // Enhanced loading states for buttons
function setButtonLoading(button, loading = true) { function setButtonLoading(button, loading = true) {
// Handle null/undefined button gracefully
if (!button) {
return;
}
if (loading) { if (loading) {
button.dataset.originalText = button.textContent; button.dataset.originalText = button.textContent;
button.classList.add('loading'); button.classList.add('loading');
@@ -126,9 +131,18 @@ const elements = {
// Header elements // Header elements
lockBtn: document.getElementById('lockBtn'), lockBtn: document.getElementById('lockBtn'),
themeToggle: document.getElementById('themeToggle'), themeToggle: document.getElementById('themeToggle'),
settingsToggle: document.getElementById('settingsToggle'), settingsBtn: document.getElementById('settingsBtn'),
settingsDropdown: document.getElementById('settingsDropdown'), headerTitle: document.getElementById('headerTitle'),
// Section elements
authSection: document.getElementById('authSection'),
vaultSection: document.getElementById('vaultSection'),
settingsSection: document.getElementById('settingsSection'),
// Settings page elements
timeoutInput: document.getElementById('timeoutInput'), timeoutInput: document.getElementById('timeoutInput'),
serverUrlInput: document.getElementById('serverUrlInput'),
saveServerUrlBtn: document.getElementById('saveServerUrlBtn'),
// Keypair management elements // Keypair management elements
toggleAddKeypairBtn: document.getElementById('toggleAddKeypairBtn'), toggleAddKeypairBtn: document.getElementById('toggleAddKeypairBtn'),
@@ -219,6 +233,53 @@ async function saveTimeoutSetting(timeout) {
await sendMessage('updateTimeout', { timeout }); await sendMessage('updateTimeout', { timeout });
} }
// Server URL settings
async function loadServerUrlSetting() {
try {
const result = await chrome.storage.local.get(['sigSocketUrl']);
const serverUrl = result.sigSocketUrl || 'ws://localhost:8080/ws';
if (elements.serverUrlInput) {
elements.serverUrlInput.value = serverUrl;
}
} catch (error) {
console.warn('Failed to load server URL setting:', error);
}
}
async function saveServerUrlSetting() {
try {
const serverUrl = elements.serverUrlInput?.value?.trim();
if (!serverUrl) {
showToast('Please enter a valid server URL', 'error');
return;
}
// Basic URL validation
if (!serverUrl.startsWith('ws://') && !serverUrl.startsWith('wss://')) {
showToast('Server URL must start with ws:// or wss://', 'error');
return;
}
// Save to storage
await chrome.storage.local.set({ sigSocketUrl: serverUrl });
// Notify background script to update server URL
const response = await sendMessage('updateSigSocketUrl', { serverUrl });
if (response?.success) {
showToast('Server URL saved successfully', 'success');
// Refresh connection status
await loadSigSocketState();
} else {
showToast('Failed to update server URL', 'error');
}
} catch (error) {
console.error('Failed to save server URL:', error);
showToast('Failed to save server URL', 'error');
}
}
async function resetSessionTimeout() { async function resetSessionTimeout() {
if (currentKeyspace) { if (currentKeyspace) {
await sendMessage('resetTimeout'); await sendMessage('resetTimeout');
@@ -241,11 +302,63 @@ function toggleTheme() {
updateThemeIcon(newTheme); updateThemeIcon(newTheme);
} }
// Settings dropdown management // Settings page navigation
function toggleSettingsDropdown() { async function showSettingsPage() {
const dropdown = elements.settingsDropdown; // Hide all sections
if (dropdown) { document.querySelectorAll('.section').forEach(section => {
dropdown.classList.toggle('hidden'); section.classList.add('hidden');
});
// Show settings section
elements.settingsSection?.classList.remove('hidden');
// Ensure we have current status before updating settings display
await loadSigSocketState();
}
async function hideSettingsPage() {
// Hide settings section
elements.settingsSection?.classList.add('hidden');
// Check current session state to determine what to show
try {
const response = await sendMessage('getStatus');
if (response && response.success && response.status && response.session) {
// Active session exists - show vault section
currentKeyspace = response.session.keyspace;
if (elements.keyspaceInput) {
elements.keyspaceInput.value = currentKeyspace;
}
setStatus(currentKeyspace, true);
elements.vaultSection?.classList.remove('hidden');
updateSettingsVisibility(); // Update settings visibility
// Load vault content
await loadKeypairs();
// Use retry mechanism for existing sessions that might have stale connections
await loadSigSocketStateWithRetry();
} else {
// No active session - show auth section
currentKeyspace = null;
setStatus('', false);
elements.authSection?.classList.remove('hidden');
updateSettingsVisibility(); // Update settings visibility
// For no session, use regular loading
await loadSigSocketState();
}
} catch (error) {
console.warn('Failed to check session state:', error);
// Fallback to auth section on error
currentKeyspace = null;
setStatus('', false);
elements.authSection?.classList.remove('hidden');
updateSettingsVisibility(); // Update settings visibility
// Still try to load SigSocket state
await loadSigSocketState();
} }
} }
@@ -287,6 +400,19 @@ function updateThemeIcon(theme) {
} }
} }
// Update settings button visibility based on keyspace state
function updateSettingsVisibility() {
if (elements.settingsBtn) {
if (currentKeyspace) {
// Show settings when keyspace is unlocked
elements.settingsBtn.style.display = '';
} else {
// Hide settings when keyspace is locked
elements.settingsBtn.style.display = 'none';
}
}
}
// Establish connection to background script for keep-alive // Establish connection to background script for keep-alive
function connectToBackground() { function connectToBackground() {
backgroundPort = chrome.runtime.connect({ name: 'popup' }); backgroundPort = chrome.runtime.connect({ name: 'popup' });
@@ -299,6 +425,7 @@ function connectToBackground() {
selectedKeypairId = null; selectedKeypairId = null;
setStatus('', false); setStatus('', false);
showSection('authSection'); showSection('authSection');
updateSettingsVisibility(); // Update settings visibility
clearVaultState(); clearVaultState();
// Clear form inputs // Clear form inputs
@@ -313,6 +440,13 @@ function connectToBackground() {
backgroundPort.onDisconnect.addListener(() => { backgroundPort.onDisconnect.addListener(() => {
backgroundPort = null; backgroundPort = null;
}); });
// Immediately request status update when popup connects
setTimeout(() => {
if (backgroundPort) {
backgroundPort.postMessage({ type: 'REQUEST_IMMEDIATE_STATUS' });
}
}, 50); // Small delay to ensure connection is established
} }
// Initialize // Initialize
@@ -323,6 +457,9 @@ document.addEventListener('DOMContentLoaded', async function() {
// Load timeout setting // Load timeout setting
await loadTimeoutSetting(); await loadTimeoutSetting();
// Load server URL setting
await loadServerUrlSetting();
// Ensure lock button starts hidden // Ensure lock button starts hidden
const lockBtn = document.getElementById('lockBtn'); const lockBtn = document.getElementById('lockBtn');
if (lockBtn) { if (lockBtn) {
@@ -338,7 +475,9 @@ document.addEventListener('DOMContentLoaded', async function() {
loginBtn: login, loginBtn: login,
lockBtn: lockSession, lockBtn: lockSession,
themeToggle: toggleTheme, themeToggle: toggleTheme,
settingsToggle: toggleSettingsDropdown, settingsBtn: showSettingsPage,
headerTitle: hideSettingsPage,
saveServerUrlBtn: saveServerUrlSetting,
toggleAddKeypairBtn: toggleAddKeypairForm, toggleAddKeypairBtn: toggleAddKeypairForm,
addKeypairBtn: addKeypair, addKeypairBtn: addKeypair,
cancelAddKeypairBtn: hideAddKeypairForm, cancelAddKeypairBtn: hideAddKeypairForm,
@@ -349,7 +488,10 @@ document.addEventListener('DOMContentLoaded', async function() {
}; };
Object.entries(eventMap).forEach(([elementKey, handler]) => { Object.entries(eventMap).forEach(([elementKey, handler]) => {
elements[elementKey]?.addEventListener('click', handler); const element = elements[elementKey];
if (element) {
element.addEventListener('click', handler);
}
}); });
// Tab functionality // Tab functionality
@@ -400,10 +542,66 @@ document.addEventListener('DOMContentLoaded', async function() {
} }
}); });
// Initialize SigSocket UI elements after DOM is ready
sigSocketElements = {
connectionStatus: document.getElementById('connectionStatus'),
connectionDot: document.getElementById('connectionDot'),
connectionText: document.getElementById('connectionText'),
requestsContainer: document.getElementById('requestsContainer'),
loadingRequestsMessage: document.getElementById('loadingRequestsMessage'),
noRequestsMessage: document.getElementById('noRequestsMessage'),
requestsList: document.getElementById('requestsList'),
refreshRequestsBtn: document.getElementById('refreshRequestsBtn')
};
// Add SigSocket button listeners
sigSocketElements.refreshRequestsBtn?.addEventListener('click', refreshSigSocketRequests);
// Check if opened via notification (focus on SigSocket section)
const urlParams = new URLSearchParams(window.location.search);
const fromNotification = urlParams.get('from') === 'notification';
// Check for existing session // Check for existing session
await checkExistingSession(); await checkExistingSession();
// If opened from notification, focus on SigSocket section and show requests
if (fromNotification) {
console.log('🔔 Opened from notification, focusing on SigSocket section');
focusOnSigSocketSection();
}
// Try to load any cached SigSocket state immediately for better UX
await loadCachedSigSocketState();
}); });
// Focus on SigSocket section when opened from notification
function focusOnSigSocketSection() {
try {
// Switch to SigSocket tab if not already active
const sigSocketTab = document.querySelector('[data-tab="sigsocket"]');
if (sigSocketTab && !sigSocketTab.classList.contains('active')) {
sigSocketTab.click();
}
// Scroll to requests container
if (sigSocketElements.requestsContainer) {
sigSocketElements.requestsContainer.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
// Show a helpful toast
showToast('New signature request received! Review pending requests below.', 'info');
// Refresh requests to ensure latest state
setTimeout(() => refreshSigSocketRequests(), 500);
} catch (error) {
console.error('Failed to focus on SigSocket section:', error);
}
}
async function checkExistingSession() { async function checkExistingSession() {
try { try {
const response = await sendMessage('getStatus'); const response = await sendMessage('getStatus');
@@ -413,15 +611,31 @@ async function checkExistingSession() {
elements.keyspaceInput.value = currentKeyspace; elements.keyspaceInput.value = currentKeyspace;
setStatus(currentKeyspace, true); setStatus(currentKeyspace, true);
showSection('vaultSection'); showSection('vaultSection');
updateSettingsVisibility(); // Update settings visibility
await loadKeypairs(); await loadKeypairs();
// Use retry mechanism for existing sessions to handle stale connections
await loadSigSocketStateWithRetry();
} else { } else {
// No active session // No active session
currentKeyspace = null;
setStatus('', false); setStatus('', false);
showSection('authSection'); showSection('authSection');
updateSettingsVisibility(); // Update settings visibility
// For no session, use regular loading (no retry needed)
await loadSigSocketState();
} }
} catch (error) { } catch (error) {
setStatus('', false); setStatus('', false);
showSection('authSection'); showSection('authSection');
// Still try to load SigSocket state even on error
try {
await loadSigSocketState();
} catch (sigSocketError) {
console.warn('Failed to load SigSocket state:', sigSocketError);
}
} }
} }
@@ -641,9 +855,23 @@ async function login() {
currentKeyspace = auth.keyspace; currentKeyspace = auth.keyspace;
setStatus(auth.keyspace, true); setStatus(auth.keyspace, true);
showSection('vaultSection'); showSection('vaultSection');
updateSettingsVisibility(); // Update settings visibility
clearVaultState(); clearVaultState();
await loadKeypairs(); await loadKeypairs();
// Clean flow: Login -> Connect -> Restore -> Display
console.log('🔓 Login successful, applying clean flow...');
// 1. Wait for SigSocket to connect and restore requests
await loadSigSocketStateWithRetry();
// 2. Show loading state while fetching
showRequestsLoading();
// 3. Refresh requests to get the clean, restored state
await refreshSigSocketRequests();
console.log('✅ Login clean flow completed');
return response; return response;
} else { } else {
throw new Error(getResponseError(response, 'login')); throw new Error(getResponseError(response, 'login'));
@@ -667,6 +895,7 @@ async function lockSession() {
selectedKeypairId = null; selectedKeypairId = null;
setStatus('', false); setStatus('', false);
showSection('authSection'); showSection('authSection');
updateSettingsVisibility(); // Update settings visibility
// Clear all form inputs // Clear all form inputs
elements.keyspaceInput.value = ''; elements.keyspaceInput.value = '';
@@ -932,3 +1161,491 @@ const verifySignature = () => performCryptoOperation({
</div>`; </div>`;
} }
}); });
// SigSocket functionality
let sigSocketRequests = [];
let sigSocketStatus = { isConnected: false, workspace: null };
let sigSocketElements = {}; // Will be initialized in DOMContentLoaded
let isInitialLoad = true; // Track if this is the first load
// Listen for messages from background script about SigSocket events
if (backgroundPort) {
backgroundPort.onMessage.addListener((message) => {
if (message.type === 'NEW_SIGN_REQUEST') {
handleNewSignRequest(message);
} else if (message.type === 'REQUESTS_UPDATED') {
updateRequestsList(message.pendingRequests);
} else if (message.type === 'KEYSPACE_UNLOCKED') {
handleKeypaceUnlocked(message);
} else if (message.type === 'CONNECTION_STATUS_CHANGED') {
handleConnectionStatusChanged(message);
} else if (message.type === 'FOCUS_SIGSOCKET') {
// Handle focus request from notification click
console.log('🔔 Received focus request from notification');
focusOnSigSocketSection();
}
});
}
// Load SigSocket state when popup opens
async function loadSigSocketState() {
try {
console.log('🔄 Loading SigSocket state...');
// Show loading state for requests
showRequestsLoading();
// Show loading state for connection status on initial load
if (isInitialLoad) {
showConnectionLoading();
}
// Force a fresh connection status check with enhanced testing
const statusResponse = await sendMessage('getSigSocketStatusWithTest');
if (statusResponse?.success) {
console.log('✅ Got SigSocket status:', statusResponse.status);
updateConnectionStatus(statusResponse.status);
} else {
console.warn('Enhanced status check failed, trying fallback...');
// Fallback to regular status check
const fallbackResponse = await sendMessage('getSigSocketStatus');
if (fallbackResponse?.success) {
console.log('✅ Got fallback SigSocket status:', fallbackResponse.status);
updateConnectionStatus(fallbackResponse.status);
} else {
// If both fail, show disconnected but don't show error on initial load
updateConnectionStatus({
isConnected: false,
workspace: null,
publicKey: null,
pendingRequestCount: 0,
serverUrl: 'ws://localhost:8080/ws'
});
}
}
// Get pending requests - this now works even when keyspace is locked
console.log('📋 Fetching pending requests...');
const requestsResponse = await sendMessage('getPendingSignRequests');
if (requestsResponse?.success) {
console.log(`📋 Retrieved ${requestsResponse.requests?.length || 0} pending requests:`, requestsResponse.requests);
updateRequestsList(requestsResponse.requests);
} else {
console.warn('Failed to get pending requests:', requestsResponse);
updateRequestsList([]);
}
// Mark initial load as complete
isInitialLoad = false;
} catch (error) {
console.warn('Failed to load SigSocket state:', error);
// Hide loading state and show error state
hideRequestsLoading();
// Set disconnected state on error (but don't show error toast on initial load)
updateConnectionStatus({
isConnected: false,
workspace: null,
publicKey: null,
pendingRequestCount: 0,
serverUrl: 'ws://localhost:8080/ws'
});
// Still try to show any cached requests
updateRequestsList([]);
// Mark initial load as complete
isInitialLoad = false;
}
}
// Load cached SigSocket state for immediate display
async function loadCachedSigSocketState() {
try {
// Try to get any cached requests from storage for immediate display
const cachedData = await chrome.storage.local.get(['sigSocketPendingRequests']);
if (cachedData.sigSocketPendingRequests && Array.isArray(cachedData.sigSocketPendingRequests)) {
console.log('📋 Loading cached requests for immediate display');
updateRequestsList(cachedData.sigSocketPendingRequests);
}
} catch (error) {
console.warn('Failed to load cached SigSocket state:', error);
}
}
// Load SigSocket state with simple retry for session initialization timing
async function loadSigSocketStateWithRetry() {
// First try immediately (might already be connected)
await loadSigSocketState();
// If still showing disconnected after initial load, try again after a short delay
if (!sigSocketStatus.isConnected) {
console.log('🔄 Initial load showed disconnected, retrying after delay...');
await new Promise(resolve => setTimeout(resolve, 500));
await loadSigSocketState();
}
}
// Show loading state for connection status
function showConnectionLoading() {
if (sigSocketElements.connectionDot && sigSocketElements.connectionText) {
sigSocketElements.connectionDot.classList.remove('connected');
sigSocketElements.connectionDot.classList.add('loading');
sigSocketElements.connectionText.textContent = 'Checking...';
}
}
// Hide loading state for connection status
function hideConnectionLoading() {
if (sigSocketElements.connectionDot) {
sigSocketElements.connectionDot.classList.remove('loading');
}
}
// Update connection status display
function updateConnectionStatus(status) {
sigSocketStatus = status;
// Hide loading state
hideConnectionLoading();
if (sigSocketElements.connectionDot && sigSocketElements.connectionText) {
if (status.isConnected) {
sigSocketElements.connectionDot.classList.add('connected');
sigSocketElements.connectionText.textContent = 'Connected';
} else {
sigSocketElements.connectionDot.classList.remove('connected');
sigSocketElements.connectionText.textContent = 'Disconnected';
}
}
// Log connection details for debugging
console.log('🔗 Connection status updated:', {
connected: status.isConnected,
workspace: status.workspace,
publicKey: status.publicKey?.substring(0, 16) + '...',
serverUrl: status.serverUrl
});
}
// Show loading state for requests
function showRequestsLoading() {
if (!sigSocketElements.requestsContainer) return;
sigSocketElements.loadingRequestsMessage?.classList.remove('hidden');
sigSocketElements.noRequestsMessage?.classList.add('hidden');
sigSocketElements.requestsList?.classList.add('hidden');
}
// Hide loading state for requests
function hideRequestsLoading() {
if (!sigSocketElements.requestsContainer) return;
sigSocketElements.loadingRequestsMessage?.classList.add('hidden');
}
// Update requests list display
function updateRequestsList(requests) {
sigSocketRequests = requests || [];
if (!sigSocketElements.requestsContainer) return;
// Hide loading state
hideRequestsLoading();
if (sigSocketRequests.length === 0) {
sigSocketElements.noRequestsMessage?.classList.remove('hidden');
sigSocketElements.requestsList?.classList.add('hidden');
} else {
sigSocketElements.noRequestsMessage?.classList.add('hidden');
sigSocketElements.requestsList?.classList.remove('hidden');
if (sigSocketElements.requestsList) {
sigSocketElements.requestsList.innerHTML = sigSocketRequests.map(request =>
createRequestItem(request)
).join('');
// Add event listeners to approve/reject buttons
addRequestEventListeners();
}
}
}
// Create HTML for a single request item
function createRequestItem(request) {
const requestTime = new Date(request.timestamp || Date.now()).toLocaleTimeString();
const shortId = request.id.substring(0, 8) + '...';
const decodedMessage = request.message ? atob(request.message) : 'No message';
// Check if keyspace is currently unlocked
const isKeypaceUnlocked = currentKeyspace !== null;
// Create different UI based on keyspace lock status
let actionsHtml;
let statusIndicator = '';
if (isKeypaceUnlocked) {
// Normal approve/reject buttons when unlocked
actionsHtml = `
<div class="request-actions">
<button class="btn-approve" data-request-id="${request.id}">
✓ Approve
</button>
<button class="btn-reject" data-request-id="${request.id}">
✗ Reject
</button>
</div>
`;
} else {
// Show pending status and unlock message when locked
statusIndicator = '<div class="request-status pending">⏳ Pending - Unlock keyspace to approve/reject</div>';
actionsHtml = `
<div class="request-actions locked">
<button class="btn-approve" data-request-id="${request.id}" disabled title="Unlock keyspace to approve">
✓ Approve
</button>
<button class="btn-reject" data-request-id="${request.id}" disabled title="Unlock keyspace to reject">
✗ Reject
</button>
</div>
`;
}
return `
<div class="request-item ${isKeypaceUnlocked ? '' : 'locked'}" data-request-id="${request.id}">
<div class="request-header">
<div class="request-id" title="${request.id}">${shortId}</div>
<div class="request-time">${requestTime}</div>
</div>
<div class="request-message" title="${decodedMessage}">
${decodedMessage.length > 100 ? decodedMessage.substring(0, 100) + '...' : decodedMessage}
</div>
${statusIndicator}
${actionsHtml}
</div>
`;
}
// Add event listeners to request action buttons
function addRequestEventListeners() {
// Approve buttons
document.querySelectorAll('.btn-approve').forEach(btn => {
btn.addEventListener('click', async (e) => {
const requestId = e.target.getAttribute('data-request-id');
await approveSignRequest(requestId);
});
});
// Reject buttons
document.querySelectorAll('.btn-reject').forEach(btn => {
btn.addEventListener('click', async (e) => {
const requestId = e.target.getAttribute('data-request-id');
await rejectSignRequest(requestId);
});
});
}
// Handle new sign request notification
function handleNewSignRequest(message) {
// Update requests list
if (message.pendingRequests) {
updateRequestsList(message.pendingRequests);
}
// Show notification if workspace doesn't match
if (!message.canApprove) {
showWorkspaceMismatchWarning();
}
}
// Handle keyspace unlocked event - Clean flow implementation
function handleKeypaceUnlocked(message) {
console.log('🔓 Keyspace unlocked - applying clean flow for request display');
// Clean flow: Unlock -> Show loading -> Display requests -> Update UI
try {
// 1. Show loading state immediately
showRequestsLoading();
// 2. Update requests list with restored + current requests
if (message.pendingRequests && Array.isArray(message.pendingRequests)) {
console.log(`📋 Displaying ${message.pendingRequests.length} restored requests`);
updateRequestsList(message.pendingRequests);
// 3. Update button states (should be enabled now)
updateRequestButtonStates(message.canApprove !== false);
// 4. Show appropriate notification
const count = message.pendingRequests.length;
if (count > 0) {
showToast(`Keyspace unlocked! ${count} pending request${count > 1 ? 's' : ''} ready for review.`, 'info');
} else {
showToast('Keyspace unlocked! No pending requests.', 'success');
}
} else {
// 5. If no requests in message, fetch fresh from server
console.log('📋 No requests in unlock message, fetching from server...');
setTimeout(() => refreshSigSocketRequests(), 100);
}
console.log('✅ Keyspace unlock flow completed');
} catch (error) {
console.error('❌ Error in keyspace unlock flow:', error);
hideRequestsLoading();
showToast('Error loading requests after unlock', 'error');
}
}
// Handle connection status change event
function handleConnectionStatusChanged(message) {
console.log('🔄 Connection status changed:', message.status);
// Store previous state for comparison
const previousState = sigSocketStatus ? sigSocketStatus.isConnected : null;
// Update the connection status display
updateConnectionStatus(message.status);
// Only show toast for actual changes, not initial status, and not during initial load
if (!isInitialLoad && previousState !== null && previousState !== message.status.isConnected) {
const statusText = message.status.isConnected ? 'Connected' : 'Disconnected';
const toastType = message.status.isConnected ? 'success' : 'warning';
showToast(`SigSocket ${statusText}`, toastType);
}
}
// Show workspace mismatch warning
function showWorkspaceMismatchWarning() {
const existingWarning = document.querySelector('.workspace-mismatch');
if (existingWarning) return; // Don't show multiple warnings
const warning = document.createElement('div');
warning.className = 'workspace-mismatch';
warning.innerHTML = `
⚠️ Sign requests received for a different workspace.
Switch to the correct workspace to approve requests.
`;
if (sigSocketElements.requestsContainer) {
sigSocketElements.requestsContainer.insertBefore(warning, sigSocketElements.requestsContainer.firstChild);
}
// Auto-remove warning after 10 seconds
setTimeout(() => {
warning.remove();
}, 10000);
}
// Update request button states
function updateRequestButtonStates(canApprove) {
document.querySelectorAll('.btn-approve, .btn-reject').forEach(btn => {
btn.disabled = !canApprove;
});
}
// Approve a sign request
async function approveSignRequest(requestId) {
let button = null;
try {
button = document.querySelector(`[data-request-id="${requestId}"].btn-approve`);
setButtonLoading(button, true);
const response = await sendMessage('approveSignRequest', { requestId });
if (response?.success) {
showToast('Request approved and signed!', 'success');
showRequestsLoading();
await refreshSigSocketRequests();
} else {
const errorMsg = getResponseError(response, 'approve request');
// Check for specific connection errors
if (errorMsg.includes('Connection not found') || errorMsg.includes('public key')) {
showToast('Connection error: Please check SigSocket connection and try again', 'error');
// Trigger a connection status refresh
await loadSigSocketState();
} else if (errorMsg.includes('keyspace') || errorMsg.includes('locked')) {
showToast('Keyspace is locked. Please unlock to approve requests.', 'error');
} else {
throw new Error(errorMsg);
}
}
} catch (error) {
console.error('Error approving request:', error);
showToast(`Failed to approve request: ${error.message}`, 'error');
} finally {
// Re-query button in case DOM was updated during the operation
const finalButton = document.querySelector(`[data-request-id="${requestId}"].btn-approve`);
setButtonLoading(finalButton, false);
}
}
// Reject a sign request
async function rejectSignRequest(requestId) {
let button = null;
try {
button = document.querySelector(`[data-request-id="${requestId}"].btn-reject`);
setButtonLoading(button, true);
const response = await sendMessage('rejectSignRequest', {
requestId,
reason: 'User rejected via extension'
});
if (response?.success) {
showToast('Request rejected', 'info');
showRequestsLoading();
await refreshSigSocketRequests();
} else {
throw new Error(getResponseError(response, 'reject request'));
}
} catch (error) {
showToast(`Failed to reject request: ${error.message}`, 'error');
} finally {
// Re-query button in case DOM was updated during the operation
const finalButton = document.querySelector(`[data-request-id="${requestId}"].btn-reject`);
setButtonLoading(finalButton, false);
}
}
// Refresh SigSocket requests
async function refreshSigSocketRequests() {
try {
setButtonLoading(sigSocketElements.refreshRequestsBtn, true);
showRequestsLoading();
console.log('🔄 Refreshing SigSocket requests...');
const response = await sendMessage('getPendingSignRequests');
if (response?.success) {
console.log(`📋 Retrieved ${response.requests?.length || 0} pending requests`);
updateRequestsList(response.requests);
const count = response.requests?.length || 0;
if (count > 0) {
showToast(`${count} pending request${count > 1 ? 's' : ''} found`, 'success');
} else {
showToast('No pending requests', 'info');
}
} else {
console.error('Failed to get pending requests:', response);
hideRequestsLoading();
throw new Error(getResponseError(response, 'refresh requests'));
}
} catch (error) {
console.error('Error refreshing requests:', error);
hideRequestsLoading();
showToast(`Failed to refresh requests: ${error.message}`, 'error');
} finally {
setButtonLoading(sigSocketElements.refreshRequestsBtn, false);
}
}

View File

@@ -0,0 +1,443 @@
/**
* Sign Request Manager Component
*
* Handles the display and management of SigSocket sign requests in the popup.
* Manages different UI states:
* 1. Keyspace locked: Show unlock form
* 2. Wrong keyspace: Show mismatch message
* 3. Correct keyspace: Show approval UI
*/
class SignRequestManager {
constructor() {
this.pendingRequests = [];
this.isKeypaceUnlocked = false;
this.keypaceMatch = false;
this.connectionStatus = { isConnected: false };
this.container = null;
this.initialized = false;
}
/**
* Initialize the component
* @param {HTMLElement} container - Container element to render into
*/
async initialize(container) {
this.container = container;
this.initialized = true;
// Load initial state
await this.loadState();
// Render initial UI
this.render();
// Set up event listeners
this.setupEventListeners();
// Listen for background messages
this.setupBackgroundListener();
}
/**
* Load current state from background script
*/
async loadState() {
try {
// Check if keyspace is unlocked
const unlockedResponse = await this.sendMessage('isUnlocked');
this.isKeypaceUnlocked = unlockedResponse?.unlocked || false;
// Get pending requests
const requestsResponse = await this.sendMessage('getPendingRequests');
this.pendingRequests = requestsResponse?.requests || [];
// Get SigSocket status
const statusResponse = await this.sendMessage('getSigSocketStatus');
this.connectionStatus = statusResponse?.status || { isConnected: false };
// If keyspace is unlocked, notify background to check keyspace match
if (this.isKeypaceUnlocked) {
await this.sendMessage('keypaceUnlocked');
}
} catch (error) {
console.error('Failed to load sign request state:', error);
}
}
/**
* Render the component UI
*/
render() {
if (!this.container) return;
const hasRequests = this.pendingRequests.length > 0;
if (!hasRequests) {
this.renderNoRequests();
return;
}
if (!this.isKeypaceUnlocked) {
this.renderUnlockPrompt();
return;
}
if (!this.keypaceMatch) {
this.renderKeypaceMismatch();
return;
}
this.renderApprovalUI();
}
/**
* Render no requests state
*/
renderNoRequests() {
this.container.innerHTML = `
<div class="sign-request-manager">
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
<span class="status-indicator"></span>
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
</div>
<div class="no-requests">
<p>No pending sign requests</p>
</div>
</div>
`;
}
/**
* Render unlock prompt
*/
renderUnlockPrompt() {
const requestCount = this.pendingRequests.length;
this.container.innerHTML = `
<div class="sign-request-manager">
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
<span class="status-indicator"></span>
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
</div>
<div class="unlock-prompt">
<h3>🔒 Unlock Keyspace</h3>
<p>Unlock your keyspace to see ${requestCount} pending sign request${requestCount !== 1 ? 's' : ''}.</p>
<p class="hint">Use the login form above to unlock your keyspace.</p>
</div>
</div>
`;
}
/**
* Render keyspace mismatch message
*/
renderKeypaceMismatch() {
this.container.innerHTML = `
<div class="sign-request-manager">
<div class="connection-status ${this.connectionStatus.isConnected ? 'connected' : 'disconnected'}">
<span class="status-indicator"></span>
SigSocket: ${this.connectionStatus.isConnected ? 'Connected' : 'Disconnected'}
</div>
<div class="keyspace-mismatch">
<h3>⚠️ Wrong Keyspace</h3>
<p>The unlocked keyspace doesn't match the connected SigSocket session.</p>
<p class="hint">Please unlock the correct keyspace to approve sign requests.</p>
</div>
</div>
`;
}
/**
* Render approval UI with pending requests
*/
renderApprovalUI() {
const requestsHtml = this.pendingRequests.map(request => this.renderSignRequestCard(request)).join('');
this.container.innerHTML = `
<div class="sign-request-manager">
<div class="connection-status connected">
<span class="status-indicator"></span>
SigSocket: Connected
</div>
<div class="requests-header">
<h3>📝 Sign Requests (${this.pendingRequests.length})</h3>
</div>
<div class="requests-list">
${requestsHtml}
</div>
</div>
`;
}
/**
* Render individual sign request card
* @param {Object} request - Sign request data
* @returns {string} - HTML string for the request card
*/
renderSignRequestCard(request) {
const timestamp = new Date(request.timestamp).toLocaleTimeString();
const messagePreview = this.getMessagePreview(request.message);
return `
<div class="sign-request-card" data-request-id="${request.id}">
<div class="request-header">
<div class="request-id">Request: ${request.id.substring(0, 8)}...</div>
<div class="request-time">${timestamp}</div>
</div>
<div class="request-message">
<label>Message:</label>
<div class="message-content">
<div class="message-preview">${messagePreview}</div>
<button class="expand-message" data-request-id="${request.id}">
<span class="expand-text">Show Full</span>
</button>
</div>
</div>
<div class="request-actions">
<button class="btn-reject" data-request-id="${request.id}">
❌ Reject
</button>
<button class="btn-approve" data-request-id="${request.id}">
✅ Approve & Sign
</button>
</div>
</div>
`;
}
/**
* Get a preview of the message content
* @param {string} messageBase64 - Base64 encoded message
* @returns {string} - Preview text
*/
getMessagePreview(messageBase64) {
try {
const decoded = atob(messageBase64);
const preview = decoded.length > 50 ? decoded.substring(0, 50) + '...' : decoded;
return preview;
} catch (error) {
return `Base64: ${messageBase64.substring(0, 20)}...`;
}
}
/**
* Set up event listeners
*/
setupEventListeners() {
if (!this.container) return;
// Use event delegation for dynamic content
this.container.addEventListener('click', (e) => {
const target = e.target;
if (target.classList.contains('btn-approve')) {
const requestId = target.getAttribute('data-request-id');
this.approveRequest(requestId);
} else if (target.classList.contains('btn-reject')) {
const requestId = target.getAttribute('data-request-id');
this.rejectRequest(requestId);
} else if (target.classList.contains('expand-message')) {
const requestId = target.getAttribute('data-request-id');
this.toggleMessageExpansion(requestId);
}
});
}
/**
* Set up listener for background script messages
*/
setupBackgroundListener() {
// Listen for keyspace unlock events
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (message.type === 'KEYSPACE_UNLOCKED') {
this.isKeypaceUnlocked = true;
this.keypaceMatch = message.keypaceMatches;
this.pendingRequests = message.pendingRequests || [];
this.render();
}
});
}
/**
* Approve a sign request
* @param {string} requestId - Request ID to approve
*/
async approveRequest(requestId) {
try {
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-approve`);
if (button) {
button.disabled = true;
button.textContent = 'Signing...';
}
const response = await this.sendMessage('approveSignRequest', { requestId });
if (response?.success) {
// Remove the request from UI
this.pendingRequests = this.pendingRequests.filter(r => r.id !== requestId);
this.render();
// Show success message
this.showToast('Sign request approved successfully!', 'success');
} else {
throw new Error(response?.error || 'Failed to approve request');
}
} catch (error) {
console.error('Failed to approve request:', error);
this.showToast('Failed to approve request: ' + error.message, 'error');
// Re-enable button
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-approve`);
if (button) {
button.disabled = false;
button.textContent = '✅ Approve & Sign';
}
}
}
/**
* Reject a sign request
* @param {string} requestId - Request ID to reject
*/
async rejectRequest(requestId) {
try {
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-reject`);
if (button) {
button.disabled = true;
button.textContent = 'Rejecting...';
}
const response = await this.sendMessage('rejectSignRequest', {
requestId,
reason: 'User rejected'
});
if (response?.success) {
// Remove the request from UI
this.pendingRequests = this.pendingRequests.filter(r => r.id !== requestId);
this.render();
// Show success message
this.showToast('Sign request rejected', 'info');
} else {
throw new Error(response?.error || 'Failed to reject request');
}
} catch (error) {
console.error('Failed to reject request:', error);
this.showToast('Failed to reject request: ' + error.message, 'error');
// Re-enable button
const button = this.container.querySelector(`[data-request-id="${requestId}"].btn-reject`);
if (button) {
button.disabled = false;
button.textContent = '❌ Reject';
}
}
}
/**
* Toggle message expansion
* @param {string} requestId - Request ID
*/
toggleMessageExpansion(requestId) {
const request = this.pendingRequests.find(r => r.id === requestId);
if (!request) return;
const card = this.container.querySelector(`[data-request-id="${requestId}"]`);
const messageContent = card.querySelector('.message-content');
const expandButton = card.querySelector('.expand-message');
const isExpanded = messageContent.classList.contains('expanded');
if (isExpanded) {
messageContent.classList.remove('expanded');
messageContent.querySelector('.message-preview').textContent = this.getMessagePreview(request.message);
expandButton.querySelector('.expand-text').textContent = 'Show Full';
} else {
messageContent.classList.add('expanded');
try {
const fullMessage = atob(request.message);
messageContent.querySelector('.message-preview').textContent = fullMessage;
} catch (error) {
messageContent.querySelector('.message-preview').textContent = `Base64: ${request.message}`;
}
expandButton.querySelector('.expand-text').textContent = 'Show Less';
}
}
/**
* Send message to background script
* @param {string} action - Action to perform
* @param {Object} data - Additional data
* @returns {Promise<Object>} - Response from background script
*/
async sendMessage(action, data = {}) {
return new Promise((resolve) => {
chrome.runtime.sendMessage({ action, ...data }, resolve);
});
}
/**
* Show toast notification
* @param {string} message - Message to show
* @param {string} type - Toast type (success, error, info)
*/
showToast(message, type = 'info') {
// Use the existing toast system from popup.js
if (typeof showToast === 'function') {
showToast(message, type);
} else {
console.log(`[${type.toUpperCase()}] ${message}`);
}
}
/**
* Update component state
* @param {Object} newState - New state data
*/
updateState(newState) {
console.log('SignRequestManager.updateState called with:', newState);
console.log('Current state before update:', {
isKeypaceUnlocked: this.isKeypaceUnlocked,
keypaceMatch: this.keypaceMatch,
pendingRequests: this.pendingRequests.length
});
Object.assign(this, newState);
// Fix the property name mismatch
if (newState.keypaceMatches !== undefined) {
this.keypaceMatch = newState.keypaceMatches;
}
console.log('State after update:', {
isKeypaceUnlocked: this.isKeypaceUnlocked,
keypaceMatch: this.keypaceMatch,
pendingRequests: this.pendingRequests.length
});
if (this.initialized) {
console.log('Rendering SignRequestManager with new state');
this.render();
}
}
/**
* Refresh component data
*/
async refresh() {
await this.loadState();
this.render();
}
}
// Export for use in popup
if (typeof module !== 'undefined' && module.exports) {
module.exports = SignRequestManager;
} else {
window.SignRequestManager = SignRequestManager;
}

View File

@@ -1,4 +1,4 @@
/* Enhanced CSS Variables for harmonious theming */ /* Balanced CSS Variables for harmonious theming */
:root { :root {
/* Core color foundation - mathematically harmonious */ /* Core color foundation - mathematically harmonious */
--primary-hue: 235; --primary-hue: 235;
@@ -6,27 +6,27 @@
--secondary-hue: 200; --secondary-hue: 200;
--accent-hue: 160; --accent-hue: 160;
/* Light theme - clean and professional */ /* Light theme - softer, less stark */
--bg-primary: linear-gradient(135deg, hsl(var(--primary-hue), 15%, 96%) 0%, hsl(var(--secondary-hue), 20%, 94%) 100%); --bg-primary: linear-gradient(135deg, hsl(var(--primary-hue), 12%, 92%) 0%, hsl(var(--secondary-hue), 15%, 90%) 100%);
--bg-secondary: rgba(255, 255, 255, 0.98); --bg-secondary: rgba(255, 255, 255, 0.92);
--bg-card: rgba(255, 255, 255, 0.95); --bg-card: rgba(255, 255, 255, 0.88);
--bg-input: hsl(var(--primary-hue), 20%, 98%); --bg-input: hsl(var(--primary-hue), 15%, 94%);
--bg-button-primary: hsl(var(--primary-hue), var(--primary-saturation), 55%); --bg-button-primary: hsl(var(--primary-hue), var(--primary-saturation), 55%);
--bg-button-secondary: hsl(var(--primary-hue), 15%, 92%); --bg-button-secondary: hsl(var(--primary-hue), 12%, 88%);
--bg-button-ghost: transparent; --bg-button-ghost: transparent;
--text-primary: hsl(var(--primary-hue), 25%, 15%); --text-primary: hsl(var(--primary-hue), 20%, 20%);
--text-secondary: hsl(var(--primary-hue), 15%, 35%); --text-secondary: hsl(var(--primary-hue), 12%, 40%);
--text-muted: hsl(var(--primary-hue), 10%, 55%); --text-muted: hsl(var(--primary-hue), 8%, 58%);
--text-inverse: white; --text-inverse: white;
--text-button-primary: white; --text-button-primary: white;
--text-button-secondary: hsl(var(--primary-hue), 25%, 25%); --text-button-secondary: hsl(var(--primary-hue), 20%, 30%);
--border-color: hsl(var(--primary-hue), 20%, 88%); --border-color: hsl(var(--primary-hue), 15%, 82%);
--border-input: hsl(var(--primary-hue), 15%, 82%); --border-input: hsl(var(--primary-hue), 12%, 78%);
--border-focus: hsl(var(--primary-hue), var(--primary-saturation), 55%); --border-focus: hsl(var(--primary-hue), var(--primary-saturation), 55%);
--shadow-card: 0 4px 20px hsla(var(--primary-hue), 30%, 20%, 0.08); --shadow-card: 0 4px 20px hsla(var(--primary-hue), 25%, 25%, 0.12);
--shadow-button: 0 2px 8px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.25); --shadow-button: 0 2px 8px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.25);
--shadow-button-hover: 0 4px 16px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.35); --shadow-button-hover: 0 4px 16px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.35);
@@ -46,36 +46,36 @@
--spacing-3xl: 32px; --spacing-3xl: 32px;
} }
/* Dark theme - harmonious complement to light theme */ /* Dark theme - balanced complement to light theme */
[data-theme="dark"] { [data-theme="dark"] {
--bg-primary: linear-gradient(135deg, hsl(var(--primary-hue), 20%, 8%) 0%, hsl(var(--secondary-hue), 15%, 12%) 100%); --bg-primary: linear-gradient(135deg, hsl(var(--primary-hue), 15%, 18%) 0%, hsl(var(--secondary-hue), 12%, 22%) 100%);
--bg-secondary: hsla(var(--primary-hue), 25%, 10%, 0.98); --bg-secondary: hsla(var(--primary-hue), 18%, 20%, 0.92);
--bg-card: hsla(var(--primary-hue), 20%, 14%, 0.95); --bg-card: hsla(var(--primary-hue), 15%, 24%, 0.88);
--bg-input: hsl(var(--primary-hue), 15%, 18%); --bg-input: hsl(var(--primary-hue), 12%, 28%);
--bg-button-primary: hsl(var(--primary-hue), var(--primary-saturation), 60%); --bg-button-primary: hsl(var(--primary-hue), var(--primary-saturation), 58%);
--bg-button-secondary: hsl(var(--primary-hue), 15%, 22%); --bg-button-secondary: hsl(var(--primary-hue), 12%, 32%);
--bg-button-ghost: transparent; --bg-button-ghost: transparent;
--text-primary: hsl(var(--primary-hue), 15%, 92%); --text-primary: hsl(var(--primary-hue), 12%, 85%);
--text-secondary: hsl(var(--primary-hue), 10%, 75%); --text-secondary: hsl(var(--primary-hue), 8%, 68%);
--text-muted: hsl(var(--primary-hue), 8%, 55%); --text-muted: hsl(var(--primary-hue), 6%, 52%);
--text-inverse: hsl(var(--primary-hue), 20%, 8%); --text-inverse: hsl(var(--primary-hue), 15%, 18%);
--text-button-primary: white; --text-button-primary: white;
--text-button-secondary: hsl(var(--primary-hue), 15%, 85%); --text-button-secondary: hsl(var(--primary-hue), 12%, 78%);
--border-color: hsl(var(--primary-hue), 15%, 25%); --border-color: hsl(var(--primary-hue), 12%, 38%);
--border-input: hsl(var(--primary-hue), 12%, 30%); --border-input: hsl(var(--primary-hue), 10%, 42%);
--border-focus: hsl(var(--primary-hue), var(--primary-saturation), 60%); --border-focus: hsl(var(--primary-hue), var(--primary-saturation), 58%);
--shadow-card: 0 4px 20px hsla(var(--primary-hue), 30%, 5%, 0.4); --shadow-card: 0 4px 20px hsla(var(--primary-hue), 20%, 10%, 0.25);
--shadow-button: 0 2px 8px hsla(var(--primary-hue), var(--primary-saturation), 60%, 0.3); --shadow-button: 0 2px 8px hsla(var(--primary-hue), var(--primary-saturation), 58%, 0.3);
--shadow-button-hover: 0 4px 16px hsla(var(--primary-hue), var(--primary-saturation), 60%, 0.4); --shadow-button-hover: 0 4px 16px hsla(var(--primary-hue), var(--primary-saturation), 58%, 0.4);
/* Enhanced accent colors for dark theme */ /* Balanced accent colors for dark theme */
--accent-success: hsl(var(--accent-hue), 60%, 55%); --accent-success: hsl(var(--accent-hue), 55%, 52%);
--accent-error: hsl(0, 65%, 60%); --accent-error: hsl(0, 60%, 58%);
--accent-warning: hsl(35, 80%, 60%); --accent-warning: hsl(35, 75%, 58%);
--accent-info: hsl(var(--primary-hue), 30%, 70%); --accent-info: hsl(var(--primary-hue), 28%, 68%);
} }
/* Consolidated button styling system */ /* Consolidated button styling system */
@@ -188,6 +188,15 @@ body {
margin: 0; margin: 0;
} }
.clickable-header {
cursor: pointer;
transition: opacity 0.2s ease;
}
.clickable-header:hover {
opacity: 0.8;
}
.header-actions { .header-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -261,6 +270,75 @@ body {
color: var(--text-muted); color: var(--text-muted);
} }
.server-input-group {
display: flex;
align-items: center;
gap: var(--spacing-sm);
}
.server-input-group input {
flex: 1;
padding: var(--spacing-xs) var(--spacing-sm);
font-size: 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-input);
color: var(--text-primary);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
.server-input-group input:focus {
outline: none;
border-color: var(--border-focus);
box-shadow: 0 0 0 2px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.15);
}
.settings-help {
display: block;
font-size: 12px;
color: var(--text-muted);
margin-top: var(--spacing-xs);
font-style: italic;
}
/* Settings page styles */
.settings-header {
display: flex;
align-items: center;
gap: var(--spacing-md);
margin-bottom: var(--spacing-lg);
}
.settings-header h2 {
margin: 0;
font-size: 20px;
font-weight: 600;
color: var(--text-primary);
}
.about-info {
text-align: left;
}
.about-info p {
margin: 0 0 var(--spacing-xs) 0;
font-size: 14px;
color: var(--text-primary);
}
.about-info strong {
font-weight: 600;
}
.version-info {
font-size: 12px;
color: var(--text-muted);
font-style: italic;
}
.btn-icon-only { .btn-icon-only {
background: var(--bg-button-ghost); background: var(--bg-button-ghost);
border: none; border: none;
@@ -421,14 +499,14 @@ input:focus, select:focus, textarea:focus {
/* Placeholder styling */ /* Placeholder styling */
input::placeholder, textarea::placeholder { input::placeholder, textarea::placeholder {
color: var(--text-muted); color: var(--text-muted);
opacity: 1; opacity: 0.9;
} }
/* Dark theme placeholder styling */ /* Dark theme placeholder styling */
[data-theme="dark"] input::placeholder, [data-theme="dark"] input::placeholder,
[data-theme="dark"] textarea::placeholder { [data-theme="dark"] textarea::placeholder {
color: #e2e8f0; color: var(--text-muted);
opacity: 0.8; opacity: 0.85;
} }
.select { .select {
@@ -456,6 +534,17 @@ input::placeholder, textarea::placeholder {
font-size: 12px; font-size: 12px;
} }
/* Button icon spacing */
.btn svg {
margin-right: var(--spacing-xs);
flex-shrink: 0;
}
.btn svg:last-child {
margin-right: 0;
margin-left: var(--spacing-xs);
}
.btn:disabled { .btn:disabled {
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
@@ -1070,3 +1159,261 @@ input::placeholder, textarea::placeholder {
width: 20px; width: 20px;
height: 20px; height: 20px;
} }
/* SigSocket Requests Styles */
.sigsocket-section {
margin-bottom: var(--spacing-lg);
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--spacing-lg);
}
.section-header h3 {
margin: 0;
color: var(--text-primary);
font-size: 16px;
font-weight: 600;
}
.connection-status {
display: flex;
align-items: center;
gap: var(--spacing-xs);
font-size: 12px;
color: var(--text-secondary);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-error);
transition: background-color 0.3s ease;
}
.status-dot.connected {
background: var(--accent-success);
}
.status-dot.loading {
background: var(--accent-warning);
animation: pulse-dot 1.5s ease-in-out infinite;
}
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.6;
transform: scale(1.2);
}
}
.requests-container {
min-height: 80px;
}
.empty-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.loading-state {
text-align: center;
padding: var(--spacing-xl);
color: var(--text-secondary);
}
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid var(--border-color);
border-top: 2px solid var(--primary-color);
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto var(--spacing-sm) auto;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-state p {
margin: var(--spacing-sm) 0;
font-weight: 500;
}
.loading-state small {
opacity: 0.8;
}
.empty-icon {
font-size: 24px;
margin-bottom: 8px;
}
.empty-state p {
margin: 0 0 4px 0;
font-weight: 500;
}
.empty-state small {
font-size: 11px;
opacity: 0.8;
}
.request-item {
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
background: var(--bg-secondary);
transition: all 0.2s ease;
}
.request-item:hover {
border-color: var(--border-focus);
box-shadow: 0 2px 8px hsla(var(--primary-hue), var(--primary-saturation), 55%, 0.1);
}
.request-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 8px;
}
.request-id {
font-family: 'Courier New', monospace;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-input);
padding: 2px 6px;
border-radius: 4px;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
.request-time {
font-size: 11px;
color: var(--text-secondary);
}
.request-message {
margin: 8px 0;
padding: 8px;
background: var(--bg-input);
border-radius: 4px;
font-family: 'Courier New', monospace;
font-size: 11px;
word-break: break-all;
max-height: 60px;
overflow-y: auto;
}
.request-actions {
display: flex;
gap: 8px;
margin-top: 12px;
}
.btn-approve {
background: var(--accent-success);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-approve:hover {
background: hsl(var(--accent-hue), 65%, 40%);
}
.btn-reject {
background: var(--accent-error);
color: white;
border: none;
padding: 6px 12px;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
transition: background-color 0.2s ease;
}
.btn-reject:hover {
background: hsl(0, 70%, 50%);
}
.btn-approve:disabled,
.btn-reject:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.sigsocket-actions {
display: flex;
gap: var(--spacing-sm);
margin-top: var(--spacing-md);
}
/* Ensure refresh button follows design system */
#refreshRequestsBtn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--spacing-xs);
}
/* Request item locked state styles */
.request-item.locked {
opacity: 0.8;
border-left: 3px solid var(--warning-color, #ffa500);
}
.request-status.pending {
background: var(--warning-bg, #fff3cd);
color: var(--warning-text, #856404);
padding: var(--spacing-xs) var(--spacing-sm);
border-radius: 4px;
font-size: 12px;
margin: var(--spacing-xs) 0;
border: 1px solid var(--warning-border, #ffeaa7);
}
.request-actions.locked button {
opacity: 0.6;
cursor: not-allowed;
}
.request-actions.locked button:hover {
background: var(--button-bg) !important;
transform: none !important;
}
.workspace-mismatch {
background: hsla(35, 85%, 85%, 0.8);
border: 1px solid hsla(35, 85%, 70%, 0.5);
color: hsl(35, 70%, 30%);
padding: 8px;
border-radius: 4px;
font-size: 12px;
margin-bottom: 8px;
}
[data-theme="dark"] .workspace-mismatch {
background: hsla(35, 60%, 15%, 0.8);
border-color: hsla(35, 60%, 30%, 0.5);
color: hsl(35, 70%, 70%);
}

View File

@@ -0,0 +1,114 @@
# Testing the SigSocket Browser Extension
## Prerequisites
1. **SigSocket Server**: You need a running SigSocket server at `ws://localhost:8080/ws`
2. **Browser**: Chrome or Chromium-based browser with developer mode enabled
## Test Steps
### 1. Load the Extension
1. Open Chrome and go to `chrome://extensions/`
2. Enable "Developer mode" in the top right
3. Click "Load unpacked" and select the `crypto_vault_extension` directory
4. The CryptoVault extension should appear in your extensions list
### 2. Basic Functionality Test
1. Click the CryptoVault extension icon in the toolbar
2. Create a new keyspace:
- Enter a keyspace name (e.g., "test-workspace")
- Enter a password
- Click "Create New"
3. The extension should automatically connect to the SigSocket server
4. Add a keypair:
- Click "Add Keypair"
- Enter a name for the keypair
- Click "Create Keypair"
### 3. SigSocket Integration Test
1. **Check Connection Status**:
- Look for the SigSocket connection status at the bottom of the popup
- It should show "SigSocket: Connected" with a green indicator
2. **Test Sign Request Flow**:
- Send a sign request to the SigSocket server (you'll need to implement this on the server side)
- The extension should show a notification
- The extension badge should show the number of pending requests
- Open the extension popup to see the sign request
3. **Test Approval Flow**:
- If keyspace is locked, you should see "Unlock keyspace to see X pending requests"
- Unlock the keyspace using the login form
- You should see the sign request details
- Click "Approve & Sign" to approve the request
- The request should be signed and sent back to the server
### 4. Settings Test
1. Click the settings gear icon in the extension popup
2. Change the SigSocket server URL if needed
3. Adjust the session timeout if desired
## Expected Behavior
- ✅ Extension loads without errors
- ✅ Can create keyspaces and keypairs
- ✅ SigSocket connection is established automatically
- ✅ Sign requests are received and displayed
- ✅ Approval flow works correctly
- ✅ Settings can be configured
## Troubleshooting
### Common Issues
1. **Extension won't load**: Check the console for JavaScript errors
2. **SigSocket won't connect**: Verify the server is running and the URL is correct
3. **WASM errors**: Check that the WASM files are properly built and copied
4. **Sign requests not appearing**: Check the browser console for callback errors
### Debug Steps
1. Open Chrome DevTools
2. Go to the Extensions tab
3. Find CryptoVault and click "Inspect views: background page"
4. Check the console for any errors
5. Also inspect the popup by right-clicking the extension icon and selecting "Inspect popup"
## Server-Side Testing
To fully test the extension, you'll need a SigSocket server that can:
1. Accept WebSocket connections at `/ws`
2. Handle client introduction messages (hex-encoded public keys)
3. Send sign requests in the format:
```json
{
"id": "unique-request-id",
"message": "base64-encoded-message"
}
```
4. Receive sign responses in the format:
```json
{
"id": "request-id",
"message": "base64-encoded-message",
"signature": "base64-encoded-signature"
}
```
## Next Steps
If basic functionality works:
1. Test with multiple concurrent sign requests
2. Test connection recovery after network issues
3. Test with different keyspace configurations
4. Test the rejection flow
5. Test session timeout behavior

View File

@@ -277,6 +277,42 @@ export function is_unlocked() {
return ret !== 0; return ret !== 0;
} }
/**
* Get the default public key for a workspace (keyspace)
* This returns the public key of the first keypair in the keyspace
* @param {string} workspace_id
* @returns {Promise<any>}
*/
export function get_workspace_default_public_key(workspace_id) {
const ptr0 = passStringToWasm0(workspace_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.get_workspace_default_public_key(ptr0, len0);
return ret;
}
/**
* Get the current unlocked public key as hex string
* @returns {string}
*/
export function get_current_unlocked_public_key() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.get_current_unlocked_public_key();
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/** /**
* Get all keypairs from the current session * Get all keypairs from the current session
* Returns an array of keypair objects with id, type, and metadata * Returns an array of keypair objects with id, type, and metadata
@@ -323,7 +359,7 @@ function passArray8ToWasm0(arg, malloc) {
return ptr; return ptr;
} }
/** /**
* Sign message with current session * Sign message with current session (requires selected keypair)
* @param {Uint8Array} message * @param {Uint8Array} message
* @returns {Promise<any>} * @returns {Promise<any>}
*/ */
@@ -334,6 +370,41 @@ export function sign(message) {
return ret; return ret;
} }
/**
* Get the current keyspace name
* @returns {string}
*/
export function get_current_keyspace_name() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.get_current_keyspace_name();
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Sign message with default keypair (first keypair in keyspace) without changing session state
* @param {Uint8Array} message
* @returns {Promise<any>}
*/
export function sign_with_default_keypair(message) {
const ptr0 = passArray8ToWasm0(message, wasm.__wbindgen_malloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.sign_with_default_keypair(ptr0, len0);
return ret;
}
/** /**
* Verify a signature with the current session's selected keypair * Verify a signature with the current session's selected keypair
* @param {Uint8Array} message * @param {Uint8Array} message
@@ -395,24 +466,391 @@ export function run_rhai(script) {
return takeFromExternrefTable0(ret[0]); return takeFromExternrefTable0(ret[0]);
} }
function __wbg_adapter_32(arg0, arg1, arg2) { function __wbg_adapter_34(arg0, arg1, arg2) {
wasm.closure121_externref_shim(arg0, arg1, arg2); wasm.closure203_externref_shim(arg0, arg1, arg2);
} }
function __wbg_adapter_35(arg0, arg1, arg2) { function __wbg_adapter_39(arg0, arg1) {
wasm.closure150_externref_shim(arg0, arg1, arg2); wasm._dyn_core__ops__function__FnMut_____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hd79bf9f6d48e92f7(arg0, arg1);
} }
function __wbg_adapter_38(arg0, arg1, arg2) { function __wbg_adapter_44(arg0, arg1, arg2) {
wasm.closure227_externref_shim(arg0, arg1, arg2); wasm.closure239_externref_shim(arg0, arg1, arg2);
} }
function __wbg_adapter_138(arg0, arg1, arg2, arg3) { function __wbg_adapter_49(arg0, arg1) {
wasm.closure1879_externref_shim(arg0, arg1, arg2, arg3); wasm._dyn_core__ops__function__FnMut_____Output___R_as_wasm_bindgen__closure__WasmClosure___describe__invoke__hf103de07b8856532(arg0, arg1);
} }
function __wbg_adapter_52(arg0, arg1, arg2) {
wasm.closure319_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_55(arg0, arg1, arg2) {
wasm.closure395_externref_shim(arg0, arg1, arg2);
}
function __wbg_adapter_207(arg0, arg1, arg2, arg3) {
wasm.closure2042_externref_shim(arg0, arg1, arg2, arg3);
}
const __wbindgen_enum_BinaryType = ["blob", "arraybuffer"];
const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"]; const __wbindgen_enum_IdbTransactionMode = ["readonly", "readwrite", "versionchange", "readwriteflush", "cleanup"];
const SigSocketConnectionFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_sigsocketconnection_free(ptr >>> 0, 1));
/**
* WASM-bindgen wrapper for SigSocket client
*
* This provides a clean JavaScript API for the browser extension to:
* - Connect to SigSocket servers
* - Send responses to sign requests
* - Manage connection state
*/
export class SigSocketConnection {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
SigSocketConnectionFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_sigsocketconnection_free(ptr, 0);
}
/**
* Create a new SigSocket connection
*/
constructor() {
const ret = wasm.sigsocketconnection_new();
this.__wbg_ptr = ret >>> 0;
SigSocketConnectionFinalization.register(this, this.__wbg_ptr, this);
return this;
}
/**
* Connect to a SigSocket server
*
* # Arguments
* * `server_url` - WebSocket server URL (e.g., "ws://localhost:8080/ws")
* * `public_key_hex` - Client's public key as hex string
*
* # Returns
* * `Ok(())` - Successfully connected
* * `Err(error)` - Connection failed
* @param {string} server_url
* @param {string} public_key_hex
* @returns {Promise<void>}
*/
connect(server_url, public_key_hex) {
const ptr0 = passStringToWasm0(server_url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(public_key_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketconnection_connect(this.__wbg_ptr, ptr0, len0, ptr1, len1);
return ret;
}
/**
* Send a response to a sign request
*
* This should be called by the extension after the user has approved
* a sign request and the message has been signed.
*
* # Arguments
* * `request_id` - ID of the original request
* * `message_base64` - Original message (base64-encoded)
* * `signature_hex` - Signature as hex string
*
* # Returns
* * `Ok(())` - Response sent successfully
* * `Err(error)` - Failed to send response
* @param {string} request_id
* @param {string} message_base64
* @param {string} signature_hex
* @returns {Promise<void>}
*/
send_response(request_id, message_base64, signature_hex) {
const ptr0 = passStringToWasm0(request_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(message_base64, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ptr2 = passStringToWasm0(signature_hex, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len2 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketconnection_send_response(this.__wbg_ptr, ptr0, len0, ptr1, len1, ptr2, len2);
return ret;
}
/**
* Send a rejection for a sign request
*
* This should be called when the user rejects a sign request.
*
* # Arguments
* * `request_id` - ID of the request to reject
* * `reason` - Reason for rejection (optional)
*
* # Returns
* * `Ok(())` - Rejection sent successfully
* * `Err(error)` - Failed to send rejection
* @param {string} request_id
* @param {string} reason
* @returns {Promise<void>}
*/
send_rejection(request_id, reason) {
const ptr0 = passStringToWasm0(request_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(reason, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketconnection_send_rejection(this.__wbg_ptr, ptr0, len0, ptr1, len1);
return ret;
}
/**
* Disconnect from the SigSocket server
*/
disconnect() {
wasm.sigsocketconnection_disconnect(this.__wbg_ptr);
}
/**
* Check if connected to the server
* @returns {boolean}
*/
is_connected() {
const ret = wasm.sigsocketconnection_is_connected(this.__wbg_ptr);
return ret !== 0;
}
}
const SigSocketManagerFinalization = (typeof FinalizationRegistry === 'undefined')
? { register: () => {}, unregister: () => {} }
: new FinalizationRegistry(ptr => wasm.__wbg_sigsocketmanager_free(ptr >>> 0, 1));
/**
* SigSocket manager for high-level operations
*/
export class SigSocketManager {
__destroy_into_raw() {
const ptr = this.__wbg_ptr;
this.__wbg_ptr = 0;
SigSocketManagerFinalization.unregister(this);
return ptr;
}
free() {
const ptr = this.__destroy_into_raw();
wasm.__wbg_sigsocketmanager_free(ptr, 0);
}
/**
* Connect to SigSocket server with smart connection management
*
* This handles all connection logic:
* - Reuses existing connection if same workspace
* - Switches connection if different workspace
* - Creates new connection if none exists
*
* # Arguments
* * `workspace` - The workspace name to connect with
* * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
* * `event_callback` - JavaScript function to call when events occur
*
* # Returns
* * `Ok(connection_info)` - JSON string with connection details
* * `Err(error)` - If connection failed or workspace is invalid
* @param {string} workspace
* @param {string} server_url
* @param {Function} event_callback
* @returns {Promise<string>}
*/
static connect_workspace_with_events(workspace, server_url, event_callback) {
const ptr0 = passStringToWasm0(workspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(server_url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_connect_workspace_with_events(ptr0, len0, ptr1, len1, event_callback);
return ret;
}
/**
* Connect to SigSocket server with a specific workspace (backward compatibility)
*
* This is a simpler version that doesn't set up event callbacks.
* Use connect_workspace_with_events for full functionality.
*
* # Arguments
* * `workspace` - The workspace name to connect with
* * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
*
* # Returns
* * `Ok(connection_info)` - JSON string with connection details
* * `Err(error)` - If connection failed or workspace is invalid
* @param {string} workspace
* @param {string} server_url
* @returns {Promise<string>}
*/
static connect_workspace(workspace, server_url) {
const ptr0 = passStringToWasm0(workspace, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(server_url, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_connect_workspace(ptr0, len0, ptr1, len1);
return ret;
}
/**
* Disconnect from SigSocket server
*
* # Returns
* * `Ok(())` - Successfully disconnected
* * `Err(error)` - If disconnect failed
* @returns {Promise<void>}
*/
static disconnect() {
const ret = wasm.sigsocketmanager_disconnect();
return ret;
}
/**
* Check if we can approve a specific sign request
*
* This validates that:
* 1. The request exists
* 2. The vault session is unlocked
* 3. The current workspace matches the request's target
*
* # Arguments
* * `request_id` - The ID of the request to validate
*
* # Returns
* * `Ok(true)` - Request can be approved
* * `Ok(false)` - Request cannot be approved
* * `Err(error)` - Validation error
* @param {string} request_id
* @returns {Promise<boolean>}
*/
static can_approve_request(request_id) {
const ptr0 = passStringToWasm0(request_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_can_approve_request(ptr0, len0);
return ret;
}
/**
* Approve a sign request and send the signature to the server
*
* This performs the complete approval flow:
* 1. Validates the request can be approved
* 2. Signs the message using the vault
* 3. Sends the signature to the SigSocket server
* 4. Removes the request from pending list
*
* # Arguments
* * `request_id` - The ID of the request to approve
*
* # Returns
* * `Ok(signature)` - Base64-encoded signature that was sent
* * `Err(error)` - If approval failed
* @param {string} request_id
* @returns {Promise<string>}
*/
static approve_request(request_id) {
const ptr0 = passStringToWasm0(request_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_approve_request(ptr0, len0);
return ret;
}
/**
* Reject a sign request
*
* # Arguments
* * `request_id` - The ID of the request to reject
* * `reason` - The reason for rejection
*
* # Returns
* * `Ok(())` - Request rejected successfully
* * `Err(error)` - If rejection failed
* @param {string} request_id
* @param {string} reason
* @returns {Promise<void>}
*/
static reject_request(request_id, reason) {
const ptr0 = passStringToWasm0(request_id, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ptr1 = passStringToWasm0(reason, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len1 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_reject_request(ptr0, len0, ptr1, len1);
return ret;
}
/**
* Get pending requests filtered by current workspace
*
* This returns only the requests that the current vault session can handle,
* based on the unlocked workspace and its public key.
*
* # Returns
* * `Ok(requests_json)` - JSON array of filtered requests
* * `Err(error)` - If filtering failed
* @returns {Promise<string>}
*/
static get_filtered_requests() {
const ret = wasm.sigsocketmanager_get_filtered_requests();
return ret;
}
/**
* Add a pending sign request (called when request arrives from server)
*
* # Arguments
* * `request_json` - JSON string containing the sign request
*
* # Returns
* * `Ok(())` - Request added successfully
* * `Err(error)` - If adding failed
* @param {string} request_json
*/
static add_pending_request(request_json) {
const ptr0 = passStringToWasm0(request_json, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
const len0 = WASM_VECTOR_LEN;
const ret = wasm.sigsocketmanager_add_pending_request(ptr0, len0);
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
/**
* Get connection status
*
* # Returns
* * `Ok(status_json)` - JSON object with connection status
* * `Err(error)` - If getting status failed
* @returns {string}
*/
static get_connection_status() {
let deferred2_0;
let deferred2_1;
try {
const ret = wasm.sigsocketmanager_get_connection_status();
var ptr1 = ret[0];
var len1 = ret[1];
if (ret[3]) {
ptr1 = 0; len1 = 0;
throw takeFromExternrefTable0(ret[2]);
}
deferred2_0 = ptr1;
deferred2_1 = len1;
return getStringFromWasm0(ptr1, len1);
} finally {
wasm.__wbindgen_free(deferred2_0, deferred2_1, 1);
}
}
/**
* Clear all pending requests
*
* # Returns
* * `Ok(())` - Requests cleared successfully
*/
static clear_pending_requests() {
const ret = wasm.sigsocketmanager_clear_pending_requests();
if (ret[1]) {
throw takeFromExternrefTable0(ret[0]);
}
}
}
async function __wbg_load(module, imports) { async function __wbg_load(module, imports) {
if (typeof Response === 'function' && module instanceof Response) { if (typeof Response === 'function' && module instanceof Response) {
if (typeof WebAssembly.instantiateStreaming === 'function') { if (typeof WebAssembly.instantiateStreaming === 'function') {
@@ -459,6 +897,9 @@ function __wbg_get_imports() {
const ret = arg0.call(arg1, arg2); const ret = arg0.call(arg1, arg2);
return ret; return ret;
}, arguments) }; }, arguments) };
imports.wbg.__wbg_close_2893b7d056a0627d = function() { return handleError(function (arg0) {
arg0.close();
}, arguments) };
imports.wbg.__wbg_createObjectStore_d2f9e1016f4d81b9 = function() { return handleError(function (arg0, arg1, arg2, arg3) { imports.wbg.__wbg_createObjectStore_d2f9e1016f4d81b9 = function() { return handleError(function (arg0, arg1, arg2, arg3) {
const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3); const ret = arg0.createObjectStore(getStringFromWasm0(arg1, arg2), arg3);
return ret; return ret;
@@ -467,6 +908,10 @@ function __wbg_get_imports() {
const ret = arg0.crypto; const ret = arg0.crypto;
return ret; return ret;
}; };
imports.wbg.__wbg_data_432d9c3df2630942 = function(arg0) {
const ret = arg0.data;
return ret;
};
imports.wbg.__wbg_error_524f506f44df1645 = function(arg0) { imports.wbg.__wbg_error_524f506f44df1645 = function(arg0) {
console.error(arg0); console.error(arg0);
}; };
@@ -539,10 +984,23 @@ function __wbg_get_imports() {
const ret = result; const ret = result;
return ret; return ret;
}; };
imports.wbg.__wbg_instanceof_Window_def73ea0955fc569 = function(arg0) {
let result;
try {
result = arg0 instanceof Window;
} catch (_) {
result = false;
}
const ret = result;
return ret;
};
imports.wbg.__wbg_length_52b6c4580c5ec934 = function(arg0) { imports.wbg.__wbg_length_52b6c4580c5ec934 = function(arg0) {
const ret = arg0.length; const ret = arg0.length;
return ret; return ret;
}; };
imports.wbg.__wbg_log_c222819a41e063d3 = function(arg0) {
console.log(arg0);
};
imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) { imports.wbg.__wbg_msCrypto_a61aeb35a24c1329 = function(arg0) {
const ret = arg0.msCrypto; const ret = arg0.msCrypto;
return ret; return ret;
@@ -558,7 +1016,7 @@ function __wbg_get_imports() {
const a = state0.a; const a = state0.a;
state0.a = 0; state0.a = 0;
try { try {
return __wbg_adapter_138(a, state0.b, arg0, arg1); return __wbg_adapter_207(a, state0.b, arg0, arg1);
} finally { } finally {
state0.a = a; state0.a = a;
} }
@@ -577,6 +1035,10 @@ function __wbg_get_imports() {
const ret = new Array(); const ret = new Array();
return ret; return ret;
}; };
imports.wbg.__wbg_new_92c54fc74574ef55 = function() { return handleError(function (arg0, arg1) {
const ret = new WebSocket(getStringFromWasm0(arg0, arg1));
return ret;
}, arguments) };
imports.wbg.__wbg_new_a12002a7f91c75be = function(arg0) { imports.wbg.__wbg_new_a12002a7f91c75be = function(arg0) {
const ret = new Uint8Array(arg0); const ret = new Uint8Array(arg0);
return ret; return ret;
@@ -609,6 +1071,12 @@ function __wbg_get_imports() {
const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2)); const ret = arg0.objectStore(getStringFromWasm0(arg1, arg2));
return ret; return ret;
}, arguments) }; }, arguments) };
imports.wbg.__wbg_onConnectionStateChanged_b0dc098522afadba = function(arg0) {
onConnectionStateChanged(arg0 !== 0);
};
imports.wbg.__wbg_onSignRequestReceived_93232ba7a0919705 = function(arg0, arg1, arg2, arg3) {
onSignRequestReceived(getStringFromWasm0(arg0, arg1), getStringFromWasm0(arg2, arg3));
};
imports.wbg.__wbg_open_88b1390d99a7c691 = function() { return handleError(function (arg0, arg1, arg2) { imports.wbg.__wbg_open_88b1390d99a7c691 = function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.open(getStringFromWasm0(arg1, arg2)); const ret = arg0.open(getStringFromWasm0(arg1, arg2));
return ret; return ret;
@@ -643,6 +1111,10 @@ function __wbg_get_imports() {
imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) { imports.wbg.__wbg_randomFillSync_ac0988aba3254290 = function() { return handleError(function (arg0, arg1) {
arg0.randomFillSync(arg1); arg0.randomFillSync(arg1);
}, arguments) }; }, arguments) };
imports.wbg.__wbg_readyState_7ef6e63c349899ed = function(arg0) {
const ret = arg0.readyState;
return ret;
};
imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () { imports.wbg.__wbg_require_60cc747a6bc5215a = function() { return handleError(function () {
const ret = module.require; const ret = module.require;
return ret; return ret;
@@ -655,12 +1127,38 @@ function __wbg_get_imports() {
const ret = arg0.result; const ret = arg0.result;
return ret; return ret;
}, arguments) }; }, arguments) };
imports.wbg.__wbg_send_0293179ba074ffb4 = function() { return handleError(function (arg0, arg1, arg2) {
arg0.send(getStringFromWasm0(arg1, arg2));
}, arguments) };
imports.wbg.__wbg_setTimeout_f2fe5af8e3debeb3 = function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.setTimeout(arg1, arg2);
return ret;
}, arguments) };
imports.wbg.__wbg_set_65595bdd868b3009 = function(arg0, arg1, arg2) { imports.wbg.__wbg_set_65595bdd868b3009 = function(arg0, arg1, arg2) {
arg0.set(arg1, arg2 >>> 0); arg0.set(arg1, arg2 >>> 0);
}; };
imports.wbg.__wbg_set_bb8cecf6a62b9f46 = function() { return handleError(function (arg0, arg1, arg2) {
const ret = Reflect.set(arg0, arg1, arg2);
return ret;
}, arguments) };
imports.wbg.__wbg_setbinaryType_92fa1ffd873b327c = function(arg0, arg1) {
arg0.binaryType = __wbindgen_enum_BinaryType[arg1];
};
imports.wbg.__wbg_setonclose_14fc475a49d488fc = function(arg0, arg1) {
arg0.onclose = arg1;
};
imports.wbg.__wbg_setonerror_8639efe354b947cd = function(arg0, arg1) {
arg0.onerror = arg1;
};
imports.wbg.__wbg_setonerror_d7e3056cc6e56085 = function(arg0, arg1) { imports.wbg.__wbg_setonerror_d7e3056cc6e56085 = function(arg0, arg1) {
arg0.onerror = arg1; arg0.onerror = arg1;
}; };
imports.wbg.__wbg_setonmessage_6eccab530a8fb4c7 = function(arg0, arg1) {
arg0.onmessage = arg1;
};
imports.wbg.__wbg_setonopen_2da654e1f39745d5 = function(arg0, arg1) {
arg0.onopen = arg1;
};
imports.wbg.__wbg_setonsuccess_afa464ee777a396d = function(arg0, arg1) { imports.wbg.__wbg_setonsuccess_afa464ee777a396d = function(arg0, arg1) {
arg0.onsuccess = arg1; arg0.onsuccess = arg1;
}; };
@@ -695,6 +1193,10 @@ function __wbg_get_imports() {
const ret = arg0.then(arg1); const ret = arg0.then(arg1);
return ret; return ret;
}; };
imports.wbg.__wbg_then_48b406749878a531 = function(arg0, arg1, arg2) {
const ret = arg0.then(arg1, arg2);
return ret;
};
imports.wbg.__wbg_transaction_d6d07c3c9963c49e = function() { return handleError(function (arg0, arg1, arg2) { imports.wbg.__wbg_transaction_d6d07c3c9963c49e = function() { return handleError(function (arg0, arg1, arg2) {
const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]); const ret = arg0.transaction(arg1, __wbindgen_enum_IdbTransactionMode[arg2]);
return ret; return ret;
@@ -703,6 +1205,9 @@ function __wbg_get_imports() {
const ret = arg0.versions; const ret = arg0.versions;
return ret; return ret;
}; };
imports.wbg.__wbg_warn_4ca3906c248c47c4 = function(arg0) {
console.warn(arg0);
};
imports.wbg.__wbindgen_cb_drop = function(arg0) { imports.wbg.__wbindgen_cb_drop = function(arg0) {
const obj = arg0.original; const obj = arg0.original;
if (obj.cnt-- == 1) { if (obj.cnt-- == 1) {
@@ -712,16 +1217,40 @@ function __wbg_get_imports() {
const ret = false; const ret = false;
return ret; return ret;
}; };
imports.wbg.__wbindgen_closure_wrapper378 = function(arg0, arg1, arg2) { imports.wbg.__wbindgen_closure_wrapper1036 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 122, __wbg_adapter_32); const ret = makeMutClosure(arg0, arg1, 320, __wbg_adapter_52);
return ret; return ret;
}; };
imports.wbg.__wbindgen_closure_wrapper549 = function(arg0, arg1, arg2) { imports.wbg.__wbindgen_closure_wrapper1329 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 151, __wbg_adapter_35); const ret = makeMutClosure(arg0, arg1, 396, __wbg_adapter_55);
return ret; return ret;
}; };
imports.wbg.__wbindgen_closure_wrapper857 = function(arg0, arg1, arg2) { imports.wbg.__wbindgen_closure_wrapper624 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 228, __wbg_adapter_38); const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_34);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper625 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_34);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper626 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_39);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper630 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 201, __wbg_adapter_34);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper765 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 240, __wbg_adapter_44);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper766 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 240, __wbg_adapter_44);
return ret;
};
imports.wbg.__wbindgen_closure_wrapper768 = function(arg0, arg1, arg2) {
const ret = makeMutClosure(arg0, arg1, 240, __wbg_adapter_49);
return ret; return ret;
}; };
imports.wbg.__wbindgen_debug_string = function(arg0, arg1) { imports.wbg.__wbindgen_debug_string = function(arg0, arg1) {
@@ -778,6 +1307,14 @@ function __wbg_get_imports() {
const ret = wasm.memory; const ret = wasm.memory;
return ret; return ret;
}; };
imports.wbg.__wbindgen_string_get = function(arg0, arg1) {
const obj = arg1;
const ret = typeof(obj) === 'string' ? obj : undefined;
var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
var len1 = WASM_VECTOR_LEN;
getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
};
imports.wbg.__wbindgen_string_new = function(arg0, arg1) { imports.wbg.__wbindgen_string_new = function(arg0, arg1) {
const ret = getStringFromWasm0(arg0, arg1); const ret = getStringFromWasm0(arg0, arg1);
return ret; return ret;

View File

@@ -68,7 +68,7 @@ impl EvmClient {
mut tx: provider::Transaction, mut tx: provider::Transaction,
signer: &dyn crate::signer::Signer, signer: &dyn crate::signer::Signer,
) -> Result<ethers_core::types::H256, EvmError> { ) -> Result<ethers_core::types::H256, EvmError> {
use ethers_core::types::{U256, H256, Bytes, Address}; use ethers_core::types::{U256, H256};
use std::str::FromStr; use std::str::FromStr;
use serde_json::json; use serde_json::json;
use crate::provider::{send_rpc, parse_signature_rs_v}; use crate::provider::{send_rpc, parse_signature_rs_v};
@@ -131,7 +131,7 @@ impl EvmClient {
// 3. Sign the RLP-encoded unsigned transaction // 3. Sign the RLP-encoded unsigned transaction
let sig = signer.sign(&rlp_unsigned).await?; let sig = signer.sign(&rlp_unsigned).await?;
let (r, s, v) = parse_signature_rs_v(&sig, tx.chain_id.unwrap()).ok_or_else(|| EvmError::Signing("Invalid signature format".to_string()))?; let (r, s, _v) = parse_signature_rs_v(&sig, tx.chain_id.unwrap()).ok_or_else(|| EvmError::Signing("Invalid signature format".to_string()))?;
// 4. RLP encode signed transaction (EIP-155) // 4. RLP encode signed transaction (EIP-155)
use rlp::RlpStream; use rlp::RlpStream;

View File

@@ -1,7 +1,7 @@
//! Rhai bindings for EVM Client module //! Rhai bindings for EVM Client module
//! Provides a single source of truth for scripting integration for EVM actions. //! Provides a single source of truth for scripting integration for EVM actions.
use rhai::{Engine, Map}; use rhai::Engine;
pub use crate::EvmClient; // Ensure EvmClient is public and defined in lib.rs pub use crate::EvmClient; // Ensure EvmClient is public and defined in lib.rs
/// Register EVM Client APIs with the Rhai scripting engine. /// Register EVM Client APIs with the Rhai scripting engine.
@@ -25,7 +25,7 @@ pub fn register_rhai_api(engine: &mut Engine, evm_client: std::sync::Arc<EvmClie
engine.register_type::<RhaiEvmClient>(); engine.register_type::<RhaiEvmClient>();
engine.register_fn("get_balance", RhaiEvmClient::get_balance); engine.register_fn("get_balance", RhaiEvmClient::get_balance);
// Register instance for scripts // Register instance for scripts
let rhai_ec = RhaiEvmClient { inner: evm_client.clone() }; let _rhai_ec = RhaiEvmClient { inner: evm_client.clone() };
// Rhai does not support register_global_constant; pass the client as a parameter or use module scope. // Rhai does not support register_global_constant; pass the client as a parameter or use module scope.
} }

View File

@@ -2,7 +2,6 @@
//! These use block_on for native, and should be adapted for WASM as needed. //! These use block_on for native, and should be adapted for WASM as needed.
use crate::EvmClient; use crate::EvmClient;
use rhai::Map;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use tokio::runtime::Handle; use tokio::runtime::Handle;

View File

@@ -37,7 +37,6 @@
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[tokio::test] #[tokio::test]
async fn test_get_balance_real_address() { async fn test_get_balance_real_address() {
use ethers_core::types::{Address, U256};
use evm_client::provider::get_balance; use evm_client::provider::get_balance;
// Vitalik's address // Vitalik's address

View File

@@ -1,88 +0,0 @@
# SAL Modular Cryptographic Browser Extension
A modern, secure browser extension for interacting with the SAL modular Rust cryptographic stack, enabling key management, cryptographic operations, and secure Rhai script execution.
## Features
### Session & Key Management
- Create and unlock encrypted keyspaces with password protection
- Create, select, and manage multiple keypairs (Ed25519, Secp256k1)
- Clear session state visualization and management
### Cryptographic Operations
- Sign and verify messages using selected keypair
- Encrypt and decrypt messages using asymmetric cryptography
- Support for symmetric encryption using password-derived keys
### Scripting (Rhai)
- Execute Rhai scripts securely within the extension
- Explicit user approval for all script executions
- Script history and audit trail
### WebSocket Integration
- Connect to WebSocket servers using keypair's public key
- Receive, review, and approve/reject incoming scripts
- Support for both local and remote script execution
### Security
- Dark mode UI with modern, responsive design
- Session auto-lock after configurable inactivity period
- Explicit user approval for all sensitive operations
- No persistent storage of passwords or private keys in plaintext
## Architecture
The extension is built with a modern tech stack:
- **Frontend**: React with TypeScript, Material-UI
- **State Management**: Zustand
- **Backend**: WebAssembly (WASM) modules compiled from Rust
- **Storage**: Chrome extension storage API with encryption
- **Networking**: WebSocket for server communication
## Development Setup
1. Install dependencies:
```
cd sal_extension
npm install
```
2. Build the extension:
```
npm run build
```
3. Load the extension in Chrome/Edge:
- Navigate to `chrome://extensions/`
- Enable "Developer mode"
- Click "Load unpacked" and select the `dist` directory
4. For development with hot-reload:
```
npm run watch
```
## Integration with WASM
The extension uses WebAssembly modules compiled from Rust to perform cryptographic operations securely. The WASM modules are loaded in the extension's background script and provide a secure API for the frontend.
Key WASM functions exposed:
- `init_session` - Unlock a keyspace with password
- `create_keyspace` - Create a new keyspace
- `add_keypair` - Create a new keypair
- `select_keypair` - Select a keypair for use
- `sign` - Sign a message with the selected keypair
- `run_rhai` - Execute a Rhai script securely
## Security Considerations
- The extension follows the principle of least privilege
- All sensitive operations require explicit user approval
- Passwords are never stored persistently, only kept in memory during an active session
- Session state is automatically cleared when the extension is locked
- WebSocket connections are authenticated using the user's public key
## License
[MIT License](LICENSE)

View File

@@ -1 +0,0 @@
:root{font-family:Roboto,system-ui,sans-serif;line-height:1.5;font-weight:400;color-scheme:dark}body{margin:0;min-width:360px;min-height:520px;overflow-x:hidden}#root{width:100%;height:100%}::-webkit-scrollbar{width:6px;height:6px}::-webkit-scrollbar-track{background:rgba(255,255,255,.05);border-radius:3px}::-webkit-scrollbar-thumb{background:rgba(255,255,255,.2);border-radius:3px}::-webkit-scrollbar-thumb:hover{background:rgba(255,255,255,.3)}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
console.log("Background script initialized");let i=!1,e=null;chrome.runtime.onMessage.addListener((o,l,r)=>{if(console.log("Background received message:",o.type),o.type==="SESSION_STATUS")return r({active:i}),!0;if(o.type==="SESSION_UNLOCK")return i=!0,r({success:!0}),!0;if(o.type==="SESSION_LOCK")return i=!1,e&&(e.close(),e=null),r({success:!0}),!0;if(o.type==="CONNECT_WEBSOCKET"&&o.serverUrl&&o.publicKey){try{e&&e.close(),e=new WebSocket(o.serverUrl),e.onopen=()=>{console.log("WebSocket connection established"),e&&e.send(JSON.stringify({type:"IDENTIFY",publicKey:o.publicKey}))},e.onmessage=c=>{try{const t=JSON.parse(c.data);console.log("WebSocket message received:",t),chrome.runtime.sendMessage({type:"WEBSOCKET_MESSAGE",data:t}).catch(n=>{console.error("Failed to forward WebSocket message:",n)})}catch(t){console.error("Failed to parse WebSocket message:",t)}},e.onerror=c=>{console.error("WebSocket error:",c)},e.onclose=()=>{console.log("WebSocket connection closed"),e=null},r({success:!0})}catch(c){console.error("Failed to connect to WebSocket:",c),r({success:!1,error:c.message})}return!0}return o.type==="DISCONNECT_WEBSOCKET"?(e?(e.close(),e=null,r({success:!0})):r({success:!1,error:"No active WebSocket connection"}),!0):!1});chrome.notifications&&chrome.notifications.onClicked&&chrome.notifications.onClicked.addListener(o=>{chrome.action.openPopup()});

File diff suppressed because one or more lines are too long

View File

@@ -1,61 +0,0 @@
// Background Service Worker for SAL Modular Cryptographic Extension
// This is a simplified version that only handles messaging
console.log('Background script initialized');
// Store active WebSocket connection
let activeWebSocket = null;
let sessionActive = false;
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Background received message:', message.type);
if (message.type === 'SESSION_STATUS') {
sendResponse({ active: sessionActive });
return true;
}
if (message.type === 'SESSION_UNLOCK') {
sessionActive = true;
sendResponse({ success: true });
return true;
}
if (message.type === 'SESSION_LOCK') {
sessionActive = false;
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
sendResponse({ success: true });
return true;
}
if (message.type === 'CONNECT_WEBSOCKET') {
// Simplified WebSocket handling
sendResponse({ success: true });
return true;
}
if (message.type === 'DISCONNECT_WEBSOCKET') {
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'No active WebSocket connection' });
}
return true;
}
return false;
});
// Initialize notification setup
chrome.notifications.onClicked.addListener((notificationId) => {
// Open the extension popup when a notification is clicked
chrome.action.openPopup();
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,14 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hero Vault</title>
<script type="module" crossorigin src="/assets/index-b58c7e43.js"></script>
<link rel="stylesheet" href="/assets/index-11057528.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,26 +0,0 @@
{
"manifest_version": 3,
"name": "Hero Vault",
"version": "1.0.0",
"description": "A secure browser extension for cryptographic operations and Rhai script execution",
"action": {
"default_popup": "index.html",
"default_title": "Hero Vault"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": [
"storage",
"unlimitedStorage"
],
"background": {
"service_worker": "service-worker-loader.js",
"type": "module"
},
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}

View File

@@ -1 +0,0 @@
import './assets/simple-background.ts-e63275e1.js';

View File

@@ -1,12 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hero Vault</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,42 +0,0 @@
{
"name": "hero-vault-extension",
"version": "1.0.0",
"description": "Hero Vault - A secure browser extension for cryptographic operations",
"scripts": {
"dev": "node scripts/copy-wasm.js && vite",
"build": "node scripts/copy-wasm.js && ([ \"$NO_TYPECHECK\" = \"true\" ] || tsc) && vite build",
"watch": "node scripts/copy-wasm.js && tsc && vite build --watch",
"preview": "vite preview",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"format": "prettier --write \"src/**/*.{ts,tsx,css,scss}\"",
"copy-wasm": "node scripts/copy-wasm.js"
},
"dependencies": {
"@emotion/react": "^11.11.1",
"@emotion/styled": "^11.11.0",
"@mui/icons-material": "^5.14.3",
"@mui/material": "^5.14.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.14.2",
"zustand": "^4.4.0"
},
"devDependencies": {
"@crxjs/vite-plugin": "^2.0.0-beta.18",
"@types/chrome": "^0.0.243",
"@types/node": "^20.4.5",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0",
"@vitejs/plugin-react": "^4.0.3",
"esbuild": "^0.25.4",
"eslint": "^8.45.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.3",
"prettier": "^3.0.0",
"sass": "^1.64.1",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 454 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 712 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,26 +0,0 @@
{
"manifest_version": 3,
"name": "Hero Vault",
"version": "1.0.0",
"description": "A secure browser extension for cryptographic operations and Rhai script execution",
"action": {
"default_popup": "index.html",
"default_title": "Hero Vault"
},
"icons": {
"16": "icons/icon-16.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": [
"storage",
"unlimitedStorage"
],
"background": {
"service_worker": "src/background/simple-background.ts",
"type": "module"
},
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'"
}
}

View File

@@ -1,85 +0,0 @@
/**
* Script to build the background script for the extension
*/
const { build } = require('esbuild');
const { resolve } = require('path');
const fs = require('fs');
async function buildBackground() {
try {
console.log('Building background script...');
// First, create a simplified background script that doesn't import WASM
const backgroundContent = `
// Background Service Worker for SAL Modular Cryptographic Extension
// This is a simplified version that only handles messaging
console.log('Background script initialized');
// Store active WebSocket connection
let activeWebSocket = null;
let sessionActive = false;
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Background received message:', message.type);
if (message.type === 'SESSION_STATUS') {
sendResponse({ active: sessionActive });
return true;
}
if (message.type === 'SESSION_UNLOCK') {
sessionActive = true;
sendResponse({ success: true });
return true;
}
if (message.type === 'SESSION_LOCK') {
sessionActive = false;
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
sendResponse({ success: true });
return true;
}
if (message.type === 'CONNECT_WEBSOCKET') {
// Simplified WebSocket handling
sendResponse({ success: true });
return true;
}
if (message.type === 'DISCONNECT_WEBSOCKET') {
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'No active WebSocket connection' });
}
return true;
}
return false;
});
// Initialize notification setup
chrome.notifications.onClicked.addListener((notificationId) => {
// Open the extension popup when a notification is clicked
chrome.action.openPopup();
});
`;
// Write the simplified background script to a temporary file
fs.writeFileSync(resolve(__dirname, '../dist/background.js'), backgroundContent);
console.log('Background script built successfully!');
} catch (error) {
console.error('Error building background script:', error);
process.exit(1);
}
}
buildBackground();

View File

@@ -1,33 +0,0 @@
/**
* Script to copy WASM files from wasm_app/pkg to the extension build directory
*/
const fs = require('fs');
const path = require('path');
// Source and destination paths
const sourceDir = path.resolve(__dirname, '../../wasm_app/pkg');
const destDir = path.resolve(__dirname, '../public/wasm');
// Create destination directory if it doesn't exist
if (!fs.existsSync(destDir)) {
fs.mkdirSync(destDir, { recursive: true });
console.log(`Created directory: ${destDir}`);
}
// Copy all files from source to destination
try {
const files = fs.readdirSync(sourceDir);
files.forEach(file => {
const sourcePath = path.join(sourceDir, file);
const destPath = path.join(destDir, file);
fs.copyFileSync(sourcePath, destPath);
console.log(`Copied: ${file}`);
});
console.log('WASM files copied successfully!');
} catch (error) {
console.error('Error copying WASM files:', error);
process.exit(1);
}

View File

@@ -1,127 +0,0 @@
import { useState, useEffect } from 'react';
import { Box, Container, Paper } from '@mui/material';
import { Routes, Route, HashRouter } from 'react-router-dom';
// Import pages
import HomePage from './pages/HomePage';
import SessionPage from './pages/SessionPage';
import KeypairPage from './pages/KeypairPage';
import ScriptPage from './pages/ScriptPage';
import SettingsPage from './pages/SettingsPage';
import WebSocketPage from './pages/WebSocketPage';
import CryptoPage from './pages/CryptoPage';
// Import components
import Header from './components/Header';
import Navigation from './components/Navigation';
// Import session state management
import { useSessionStore } from './store/sessionStore';
function App() {
const { checkSessionStatus, initWasm } = useSessionStore();
const [isLoading, setIsLoading] = useState(true);
const [wasmError, setWasmError] = useState<string | null>(null);
// Initialize WASM and check session status on mount
useEffect(() => {
const initializeApp = async () => {
try {
// First initialize WASM module
const wasmInitialized = await initWasm();
if (!wasmInitialized) {
throw new Error('Failed to initialize WASM module');
}
// Then check session status
await checkSessionStatus();
} catch (error) {
console.error('Initialization error:', error);
setWasmError((error as Error).message || 'Failed to initialize the extension');
} finally {
setIsLoading(false);
}
};
initializeApp();
}, [checkSessionStatus, initWasm]);
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
}}
>
Loading...
</Box>
);
}
if (wasmError) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'center',
height: '100vh',
p: 3,
textAlign: 'center',
}}
>
<Paper sx={{ p: 3, maxWidth: 400 }}>
<h6 style={{ color: 'red', marginBottom: '8px' }}>
WASM Module Failed to Initialize
</h6>
<p style={{ marginBottom: '16px' }}>
The WASM module could not be loaded. Please try reloading the extension.
</p>
<p style={{ fontSize: '0.875rem', color: 'gray' }}>
Error: {wasmError} Please contact support if the problem persists.
</p>
</Paper>
</Box>
);
}
return (
<HashRouter>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100vh' }}>
<Header />
<Container component="main" sx={{ flexGrow: 1, overflow: 'auto', py: 2 }}>
<Paper
elevation={3}
sx={{
p: 2,
height: '100%',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden'
}}
>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/session" element={<SessionPage />} />
<Route path="/keypair" element={<KeypairPage />} />
<Route path="/crypto" element={<CryptoPage />} />
<Route path="/script" element={<ScriptPage />} />
<Route path="/websocket" element={<WebSocketPage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Paper>
</Container>
<Navigation />
</Box>
</HashRouter>
);
}
export default App;

View File

@@ -1,145 +0,0 @@
/**
* Background Service Worker for Hero Vault Extension
*
* Responsibilities:
* - Maintain WebSocket connections
* - Handle incoming script requests
* - Manage session state when popup is closed
* - Provide messaging interface for popup/content scripts
* - Initialize WASM module when extension starts
*/
// Import WASM helper functions
import { initWasm } from '../wasm/wasmHelper';
// Initialize WASM module when service worker starts
initWasm().catch(error => {
console.error('Failed to initialize WASM module:', error);
});
// Store active WebSocket connection
let activeWebSocket: WebSocket | null = null;
let sessionActive = false;
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'SESSION_STATUS') {
sendResponse({ active: sessionActive });
return true;
}
if (message.type === 'SESSION_UNLOCK') {
sessionActive = true;
sendResponse({ success: true });
return true;
}
if (message.type === 'SESSION_LOCK') {
sessionActive = false;
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
sendResponse({ success: true });
return true;
}
if (message.type === 'CONNECT_WEBSOCKET' && message.serverUrl && message.publicKey) {
connectToWebSocket(message.serverUrl, message.publicKey)
.then(success => sendResponse({ success }))
.catch(error => sendResponse({ success: false, error: error.message }));
return true; // Indicates we'll respond asynchronously
}
if (message.type === 'DISCONNECT_WEBSOCKET') {
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'No active WebSocket connection' });
}
return true;
}
});
/**
* Connect to a WebSocket server with the user's public key
*/
async function connectToWebSocket(serverUrl: string, publicKey: string): Promise<boolean> {
if (activeWebSocket) {
activeWebSocket.close();
}
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(serverUrl);
ws.onopen = () => {
// Send authentication message with public key
ws.send(JSON.stringify({
type: 'AUTH',
publicKey
}));
activeWebSocket = ws;
resolve(true);
};
ws.onerror = (error) => {
console.error('WebSocket error:', error);
reject(new Error('Failed to connect to WebSocket server'));
};
ws.onclose = () => {
activeWebSocket = null;
console.log('WebSocket connection closed');
};
ws.onmessage = async (event) => {
try {
const data = JSON.parse(event.data);
// Handle incoming script requests
if (data.type === 'SCRIPT_REQUEST') {
// Notify the user of the script request
chrome.notifications.create({
type: 'basic',
iconUrl: 'icons/icon128.png',
title: 'Script Request',
message: `Received script request: ${data.title || 'Untitled Script'}`,
priority: 2
});
// Store the script request for the popup to handle
await chrome.storage.local.set({
pendingScripts: [
...(await chrome.storage.local.get('pendingScripts')).pendingScripts || [],
{
id: data.id,
title: data.title || 'Untitled Script',
description: data.description || '',
script: data.script,
tags: data.tags || [],
timestamp: Date.now()
}
]
});
}
} catch (error) {
console.error('Error processing WebSocket message:', error);
}
};
} catch (error) {
reject(error);
}
});
}
// Initialize notification setup
chrome.notifications.onClicked.addListener((_notificationId) => {
// Open the extension popup when a notification is clicked
chrome.action.openPopup();
});
console.log('Hero Vault Extension background service worker initialized');

View File

@@ -1,115 +0,0 @@
/**
* Simplified Background Service Worker for Hero Vault Extension
*
* This is a version that doesn't use WASM to avoid service worker limitations
* with dynamic imports. It only handles basic messaging between components.
*/
console.log('Background script initialized');
// Store session state
let sessionActive = false;
let activeWebSocket: WebSocket | null = null;
// Listen for messages from popup or content scripts
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
console.log('Background received message:', message.type);
if (message.type === 'SESSION_STATUS') {
sendResponse({ active: sessionActive });
return true;
}
if (message.type === 'SESSION_UNLOCK') {
sessionActive = true;
sendResponse({ success: true });
return true;
}
if (message.type === 'SESSION_LOCK') {
sessionActive = false;
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
}
sendResponse({ success: true });
return true;
}
if (message.type === 'CONNECT_WEBSOCKET' && message.serverUrl && message.publicKey) {
// Simplified WebSocket handling
try {
if (activeWebSocket) {
activeWebSocket.close();
}
activeWebSocket = new WebSocket(message.serverUrl);
activeWebSocket.onopen = () => {
console.log('WebSocket connection established');
// Send public key to identify this client
if (activeWebSocket) {
activeWebSocket.send(JSON.stringify({
type: 'IDENTIFY',
publicKey: message.publicKey
}));
}
};
activeWebSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WebSocket message received:', data);
// Forward message to popup
chrome.runtime.sendMessage({
type: 'WEBSOCKET_MESSAGE',
data
}).catch(error => {
console.error('Failed to forward WebSocket message:', error);
});
} catch (error) {
console.error('Failed to parse WebSocket message:', error);
}
};
activeWebSocket.onerror = (error) => {
console.error('WebSocket error:', error);
};
activeWebSocket.onclose = () => {
console.log('WebSocket connection closed');
activeWebSocket = null;
};
sendResponse({ success: true });
} catch (error) {
console.error('Failed to connect to WebSocket:', error);
sendResponse({ success: false, error: error.message });
}
return true;
}
if (message.type === 'DISCONNECT_WEBSOCKET') {
if (activeWebSocket) {
activeWebSocket.close();
activeWebSocket = null;
sendResponse({ success: true });
} else {
sendResponse({ success: false, error: 'No active WebSocket connection' });
}
return true;
}
// If we don't handle the message, return false
return false;
});
// Handle notifications if available
if (chrome.notifications && chrome.notifications.onClicked) {
chrome.notifications.onClicked.addListener((notificationId) => {
// Open the extension popup when a notification is clicked
chrome.action.openPopup();
});
}

View File

@@ -1,97 +0,0 @@
import { AppBar, Toolbar, Typography, IconButton, Box, Chip } from '@mui/material';
import LockIcon from '@mui/icons-material/Lock';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import SignalWifiStatusbar4BarIcon from '@mui/icons-material/SignalWifiStatusbar4Bar';
import SignalWifiOffIcon from '@mui/icons-material/SignalWifiOff';
import { useSessionStore } from '../store/sessionStore';
const Header = () => {
const {
isSessionUnlocked,
currentKeyspace,
currentKeypair,
isWebSocketConnected,
lockSession
} = useSessionStore();
const handleLockClick = async () => {
if (isSessionUnlocked) {
await lockSession();
}
};
return (
<AppBar position="static" color="primary" elevation={0}>
<Toolbar>
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Hero Vault
</Typography>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
{/* WebSocket connection status */}
{isWebSocketConnected ? (
<Chip
icon={<SignalWifiStatusbar4BarIcon fontSize="small" />}
label="Connected"
size="small"
color="success"
variant="outlined"
/>
) : (
<Chip
icon={<SignalWifiOffIcon fontSize="small" />}
label="Offline"
size="small"
color="default"
variant="outlined"
/>
)}
{/* Session status */}
{isSessionUnlocked ? (
<Chip
icon={<LockOpenIcon fontSize="small" />}
label={currentKeyspace || 'Unlocked'}
size="small"
color="primary"
variant="outlined"
/>
) : (
<Chip
icon={<LockIcon fontSize="small" />}
label="Locked"
size="small"
color="error"
variant="outlined"
/>
)}
{/* Current keypair */}
{isSessionUnlocked && currentKeypair && (
<Chip
label={currentKeypair.name || currentKeypair.id}
size="small"
color="secondary"
variant="outlined"
/>
)}
{/* Lock button */}
{isSessionUnlocked && (
<IconButton
edge="end"
color="inherit"
onClick={handleLockClick}
size="small"
aria-label="lock session"
>
<LockIcon />
</IconButton>
)}
</Box>
</Toolbar>
</AppBar>
);
};
export default Header;

View File

@@ -1,130 +0,0 @@
import React, { useState } from 'react';
import { BottomNavigation, BottomNavigationAction, Paper, Box, IconButton, Menu, MenuItem, ListItemIcon, ListItemText } from '@mui/material';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import { useNavigate, useLocation } from 'react-router-dom';
import HomeIcon from '@mui/icons-material/Home';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import CodeIcon from '@mui/icons-material/Code';
import SettingsIcon from '@mui/icons-material/Settings';
import WifiIcon from '@mui/icons-material/Wifi';
import LockIcon from '@mui/icons-material/Lock';
import { useSessionStore } from '../store/sessionStore';
const Navigation = () => {
const navigate = useNavigate();
const location = useLocation();
const { isSessionUnlocked } = useSessionStore();
// Get current path without leading slash
const currentPath = location.pathname.substring(1) || 'home';
// State for the more menu
const [moreAnchorEl, setMoreAnchorEl] = useState<null | HTMLElement>(null);
const isMoreMenuOpen = Boolean(moreAnchorEl);
const handleMoreClick = (event: React.MouseEvent<HTMLElement, MouseEvent>) => {
setMoreAnchorEl(event.currentTarget);
};
const handleMoreClose = () => {
setMoreAnchorEl(null);
};
const handleNavigation = (path: string) => {
navigate(`/${path === 'home' ? '' : path}`);
handleMoreClose();
};
return (
<Paper
sx={{ position: 'static', bottom: 0, left: 0, right: 0 }}
elevation={3}
>
<Box sx={{ display: 'flex', width: '100%' }}>
<BottomNavigation
showLabels
value={currentPath}
onChange={(_, newValue) => {
navigate(`/${newValue === 'home' ? '' : newValue}`);
}}
sx={{ flexGrow: 1 }}
>
<BottomNavigationAction
label="Home"
value="home"
icon={<HomeIcon />}
/>
<BottomNavigationAction
label="Keys"
value="keypair"
icon={<VpnKeyIcon />}
disabled={!isSessionUnlocked}
/>
<BottomNavigationAction
label="Crypto"
value="crypto"
icon={<LockIcon />}
disabled={!isSessionUnlocked}
/>
<BottomNavigationAction
label="More"
value="more"
icon={<MoreVertIcon />}
onClick={handleMoreClick}
/>
</BottomNavigation>
<Menu
anchorEl={moreAnchorEl}
open={isMoreMenuOpen}
onClose={handleMoreClose}
anchorOrigin={{
vertical: 'top',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
>
<MenuItem
onClick={() => handleNavigation('script')}
disabled={!isSessionUnlocked}
selected={currentPath === 'script'}
>
<ListItemIcon>
<CodeIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Scripts</ListItemText>
</MenuItem>
<MenuItem
onClick={() => handleNavigation('websocket')}
disabled={!isSessionUnlocked}
selected={currentPath === 'websocket'}
>
<ListItemIcon>
<WifiIcon fontSize="small" />
</ListItemIcon>
<ListItemText>WebSocket</ListItemText>
</MenuItem>
<MenuItem
onClick={() => handleNavigation('settings')}
selected={currentPath === 'settings'}
>
<ListItemIcon>
<SettingsIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Settings</ListItemText>
</MenuItem>
</Menu>
</Box>
</Paper>
);
};
export default Navigation;

View File

@@ -1,38 +0,0 @@
:root {
font-family: 'Roboto', system-ui, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: dark;
}
body {
margin: 0;
min-width: 360px;
min-height: 520px;
overflow-x: hidden;
}
#root {
width: 100%;
height: 100%;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
border-radius: 3px;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.2);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.3);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import App from './App';
import './index.css';
// Create a dark theme for the extension
const darkTheme = createTheme({
palette: {
mode: 'dark',
primary: {
main: '#6200ee',
},
secondary: {
main: '#03dac6',
},
background: {
default: '#121212',
paper: '#1e1e1e',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontSize: '1.5rem',
fontWeight: 600,
},
h2: {
fontSize: '1.25rem',
fontWeight: 600,
},
h3: {
fontSize: '1.125rem',
fontWeight: 600,
},
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 8,
textTransform: 'none',
},
},
},
MuiPaper: {
styleOverrides: {
root: {
borderRadius: 8,
},
},
},
},
});
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<ThemeProvider theme={darkTheme}>
<CssBaseline />
<App />
</ThemeProvider>
</React.StrictMode>
);

View File

@@ -1,392 +0,0 @@
/**
* Cryptographic Operations Page
*
* This page provides a UI for:
* - Encrypting/decrypting data using the keyspace's symmetric cipher
* - Signing/verifying messages using the selected keypair
*/
import { useState, useEffect } from 'react';
import type { SyntheticEvent } from '../types';
import {
Box,
Typography,
TextField,
Button,
Paper,
Tabs,
Tab,
CircularProgress,
Alert,
Divider,
IconButton,
Tooltip,
} from '@mui/material';
import ContentCopyIcon from '@mui/icons-material/ContentCopy';
import { useSessionStore } from '../store/sessionStore';
import { useCryptoStore } from '../store/cryptoStore';
import { useNavigate } from 'react-router-dom';
const CryptoPage = () => {
const navigate = useNavigate();
const { isSessionUnlocked, currentKeypair } = useSessionStore();
const {
encryptData,
decryptData,
signMessage,
verifySignature,
isEncrypting,
isDecrypting,
isSigning,
isVerifying,
error,
clearError
} = useCryptoStore();
const [activeTab, setActiveTab] = useState(0);
const [copySuccess, setCopySuccess] = useState<string | null>(null);
// Encryption state
const [plaintext, setPlaintext] = useState('');
const [encryptedData, setEncryptedData] = useState('');
// Decryption state
const [ciphertext, setCiphertext] = useState('');
const [decryptedData, setDecryptedData] = useState('');
// Signing state
const [messageToSign, setMessageToSign] = useState('');
const [signature, setSignature] = useState('');
// Verification state
const [messageToVerify, setMessageToVerify] = useState('');
const [signatureToVerify, setSignatureToVerify] = useState('');
const [isVerified, setIsVerified] = useState<boolean | null>(null);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
const handleTabChange = (_event: React.SyntheticEvent<Element, Event>, newValue: number) => {
setActiveTab(newValue);
clearError();
setCopySuccess(null);
};
const handleEncrypt = async () => {
try {
const result = await encryptData(plaintext);
setEncryptedData(result);
} catch (err) {
// Error is already handled in the store
}
};
const handleDecrypt = async () => {
try {
const result = await decryptData(ciphertext);
setDecryptedData(result);
} catch (err) {
// Error is already handled in the store
}
};
const handleSign = async () => {
try {
const result = await signMessage(messageToSign);
setSignature(result);
} catch (err) {
// Error is already handled in the store
}
};
const handleVerify = async () => {
try {
const result = await verifySignature(messageToVerify, signatureToVerify);
setIsVerified(result);
} catch (err) {
setIsVerified(false);
// Error is already handled in the store
}
};
const copyToClipboard = (text: string, label: string) => {
navigator.clipboard.writeText(text).then(
() => {
setCopySuccess(`${label} copied to clipboard!`);
setTimeout(() => setCopySuccess(null), 2000);
},
() => {
setCopySuccess('Failed to copy!');
}
);
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" sx={{ mb: 2 }}>Cryptographic Operations</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{copySuccess && (
<Alert severity="success" sx={{ mb: 2 }}>
{copySuccess}
</Alert>
)}
<Paper sx={{ flexGrow: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
{/* Tabs with smaller width and scrollable */}
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={activeTab}
onChange={handleTabChange}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ minHeight: '48px' }}
>
<Tab label="Encrypt" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
<Tab label="Decrypt" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
<Tab label="Sign" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
<Tab label="Verify" sx={{ minWidth: '80px', minHeight: '48px', py: 0 }} />
</Tabs>
</Box>
{/* Content area with proper scrolling */}
<Box sx={{ p: 2, flexGrow: 1, overflow: 'auto', height: 'calc(100% - 48px)' }}>
{/* Encryption Tab */}
{activeTab === 0 && (
<Box>
<Typography variant="subtitle1" gutterBottom>Encrypt Data</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Data will be encrypted using ChaCha20-Poly1305 with a key derived from your keyspace password.
</Typography>
<TextField
label="Data to Encrypt"
multiline
rows={4}
fullWidth
value={plaintext}
onChange={(e) => setPlaintext(e.target.value)}
margin="normal"
/>
<Button
variant="contained"
onClick={handleEncrypt}
disabled={!plaintext || isEncrypting}
sx={{ mt: 2 }}
>
{isEncrypting ? <CircularProgress size={24} /> : 'Encrypt'}
</Button>
{encryptedData && (
<Box sx={{ mt: 3 }}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1">Encrypted Result</Typography>
<Box sx={{ position: 'relative' }}>
<TextField
label="Encrypted Data (Base64)"
multiline
rows={4}
fullWidth
value={encryptedData}
InputProps={{ readOnly: true }}
margin="normal"
/>
<Tooltip title="Copy to clipboard">
<IconButton
sx={{ position: 'absolute', top: 8, right: 8 }}
onClick={() => copyToClipboard(encryptedData, 'Encrypted data')}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
)}
</Box>
)}
{/* Decryption Tab */}
{activeTab === 1 && (
<Box>
<Typography variant="subtitle1" gutterBottom>Decrypt Data</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Paste encrypted data (in Base64 format) to decrypt it using your keyspace password.
</Typography>
<TextField
label="Encrypted Data (Base64)"
multiline
rows={4}
fullWidth
value={ciphertext}
onChange={(e) => setCiphertext(e.target.value)}
margin="normal"
/>
<Button
variant="contained"
onClick={handleDecrypt}
disabled={!ciphertext || isDecrypting}
sx={{ mt: 2 }}
>
{isDecrypting ? <CircularProgress size={24} /> : 'Decrypt'}
</Button>
{decryptedData && (
<Box sx={{ mt: 3 }}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1">Decrypted Result</Typography>
<Box sx={{ position: 'relative' }}>
<TextField
label="Decrypted Data"
multiline
rows={4}
fullWidth
value={decryptedData}
InputProps={{ readOnly: true }}
margin="normal"
/>
<Tooltip title="Copy to clipboard">
<IconButton
sx={{ position: 'absolute', top: 8, right: 8 }}
onClick={() => copyToClipboard(decryptedData, 'Decrypted data')}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
)}
</Box>
)}
{/* Signing Tab */}
{activeTab === 2 && (
<Box>
<Typography variant="subtitle1" gutterBottom>Sign Message</Typography>
{!currentKeypair ? (
<Alert severity="warning" sx={{ mb: 2 }}>
Please select a keypair from the Keypair page before signing messages.
</Alert>
) : (
<Alert severity="info" sx={{ mb: 2 }}>
Signing with keypair: {currentKeypair.name || currentKeypair.id.substring(0, 8)}...
</Alert>
)}
<TextField
label="Message to Sign"
multiline
rows={4}
fullWidth
value={messageToSign}
onChange={(e) => setMessageToSign(e.target.value)}
margin="normal"
disabled={!currentKeypair}
/>
<Button
variant="contained"
onClick={handleSign}
disabled={!messageToSign || !currentKeypair || isSigning}
sx={{ mt: 2 }}
>
{isSigning ? <CircularProgress size={24} /> : 'Sign Message'}
</Button>
{signature && (
<Box sx={{ mt: 3 }}>
<Divider sx={{ my: 2 }} />
<Typography variant="subtitle1">Signature</Typography>
<Box sx={{ position: 'relative' }}>
<TextField
label="Signature (Hex)"
multiline
rows={4}
fullWidth
value={signature}
InputProps={{ readOnly: true }}
margin="normal"
/>
<Tooltip title="Copy to clipboard">
<IconButton
sx={{ position: 'absolute', top: 8, right: 8 }}
onClick={() => copyToClipboard(signature, 'Signature')}
>
<ContentCopyIcon fontSize="small" />
</IconButton>
</Tooltip>
</Box>
</Box>
)}
</Box>
)}
{/* Verification Tab */}
{activeTab === 3 && (
<Box>
<Typography variant="subtitle1" gutterBottom>Verify Signature</Typography>
<Typography variant="body2" color="text.secondary" paragraph>
Verify that a message was signed by the currently selected keypair.
</Typography>
<TextField
label="Message"
multiline
rows={4}
fullWidth
value={messageToVerify}
onChange={(e) => setMessageToVerify(e.target.value)}
margin="normal"
/>
<TextField
label="Signature (Hex)"
multiline
rows={2}
fullWidth
value={signatureToVerify}
onChange={(e) => setSignatureToVerify(e.target.value)}
margin="normal"
/>
<Button
variant="contained"
onClick={handleVerify}
disabled={!messageToVerify || !signatureToVerify || isVerifying}
sx={{ mt: 2 }}
>
{isVerifying ? <CircularProgress size={24} /> : 'Verify Signature'}
</Button>
{isVerified !== null && (
<Box sx={{ mt: 3 }}>
<Alert severity={isVerified ? "success" : "error"}>
{isVerified
? "Signature is valid! The message was signed by the expected keypair."
: "Invalid signature. The message may have been tampered with or signed by a different keypair."}
</Alert>
</Box>
)}
</Box>
)}
</Box>
</Paper>
</Box>
);
};
export default CryptoPage;

View File

@@ -1,155 +0,0 @@
import { useState } from 'react';
import {
Box,
Typography,
Button,
TextField,
Card,
CardContent,
Stack,
Alert,
CircularProgress
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useSessionStore } from '../store/sessionStore';
const HomePage = () => {
const navigate = useNavigate();
const { isSessionUnlocked, unlockSession, createKeyspace } = useSessionStore();
const [keyspace, setKeyspace] = useState<string>('');
const [password, setPassword] = useState<string>('');
const [isLoading, setIsLoading] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [mode, setMode] = useState<'unlock' | 'create'>('unlock');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
setIsLoading(true);
try {
let success = false;
if (mode === 'unlock') {
success = await unlockSession(keyspace, password);
} else {
success = await createKeyspace(keyspace, password);
}
if (success) {
// Navigate to keypair page on success
navigate('/keypair');
} else {
setError(mode === 'unlock'
? 'Failed to unlock keyspace. Check your password and try again.'
: 'Failed to create keyspace. Please try again.');
}
} catch (err) {
setError((err as Error).message || 'An unexpected error occurred');
} finally {
setIsLoading(false);
}
};
if (isSessionUnlocked) {
return (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h5" gutterBottom>
Welcome to Hero Vault
</Typography>
<Typography variant="body1" color="text.secondary" paragraph>
Your session is unlocked. You can now use the extension features.
</Typography>
<Stack direction="row" spacing={2} justifyContent="center" mt={3}>
<Button
variant="contained"
color="primary"
onClick={() => navigate('/keypair')}
>
Manage Keys
</Button>
<Button
variant="outlined"
color="secondary"
onClick={() => navigate('/script')}
>
Run Scripts
</Button>
</Stack>
</Box>
);
}
return (
<Box sx={{ maxWidth: 400, mx: 'auto', py: 2 }}>
<Typography variant="h5" align="center" gutterBottom>
Hero Vault
</Typography>
<Card variant="outlined" sx={{ mt: 3 }}>
<CardContent>
<Typography variant="h6" gutterBottom>
{mode === 'unlock' ? 'Unlock Keyspace' : 'Create New Keyspace'}
</Typography>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<form onSubmit={handleSubmit}>
<TextField
label="Keyspace Name"
value={keyspace}
onChange={(e) => setKeyspace(e.target.value)}
fullWidth
margin="normal"
required
disabled={isLoading}
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
fullWidth
margin="normal"
required
disabled={isLoading}
/>
<Box sx={{ mt: 3, display: 'flex', justifyContent: 'space-between' }}>
<Button
variant="text"
onClick={() => setMode(mode === 'unlock' ? 'create' : 'unlock')}
disabled={isLoading}
>
{mode === 'unlock' ? 'Create New Keyspace' : 'Unlock Existing'}
</Button>
<Button
type="submit"
variant="contained"
color="primary"
disabled={isLoading || !keyspace || !password}
>
{isLoading ? (
<CircularProgress size={24} color="inherit" />
) : mode === 'unlock' ? (
'Unlock'
) : (
'Create'
)}
</Button>
</Box>
</form>
</CardContent>
</Card>
</Box>
);
};
export default HomePage;

View File

@@ -1,242 +0,0 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Divider,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
CircularProgress,
Paper,
Alert,
Chip
} from '@mui/material';
import AddIcon from '@mui/icons-material/Add';
import CheckIcon from '@mui/icons-material/Check';
import { useSessionStore } from '../store/sessionStore';
import { useNavigate } from 'react-router-dom';
const KeypairPage = () => {
const navigate = useNavigate();
const {
isSessionUnlocked,
availableKeypairs,
currentKeypair,
listKeypairs,
selectKeypair,
createKeypair
} = useSessionStore();
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [newKeypairName, setNewKeypairName] = useState('');
const [newKeypairType, setNewKeypairType] = useState('Secp256k1');
const [newKeypairDescription, setNewKeypairDescription] = useState('');
const [isCreating, setIsCreating] = useState(false);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
// Load keypairs on mount
useEffect(() => {
const loadKeypairs = async () => {
try {
setIsLoading(true);
await listKeypairs();
} catch (err) {
setError((err as Error).message || 'Failed to load keypairs');
} finally {
setIsLoading(false);
}
};
if (isSessionUnlocked) {
loadKeypairs();
}
}, [isSessionUnlocked, listKeypairs]);
const handleSelectKeypair = async (keypairId: string) => {
try {
setIsLoading(true);
await selectKeypair(keypairId);
} catch (err) {
setError((err as Error).message || 'Failed to select keypair');
} finally {
setIsLoading(false);
}
};
const handleCreateKeypair = async () => {
try {
setIsCreating(true);
setError(null);
await createKeypair(newKeypairType, {
name: newKeypairName,
description: newKeypairDescription
});
setCreateDialogOpen(false);
setNewKeypairName('');
setNewKeypairDescription('');
// Refresh the list
await listKeypairs();
} catch (err) {
setError((err as Error).message || 'Failed to create keypair');
} finally {
setIsCreating(false);
}
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Keypair Management</Typography>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={() => setCreateDialogOpen(true)}
disabled={isLoading}
>
Create New
</Button>
</Box>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : availableKeypairs.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No keypairs found. Create your first keypair to get started.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{availableKeypairs.map((keypair: any, index: number) => (
<Box key={keypair.id}>
{index > 0 && <Divider />}
<ListItem
button
selected={currentKeypair?.id === keypair.id}
onClick={() => handleSelectKeypair(keypair.id)}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{keypair.name || keypair.id}
<Chip
label={keypair.type}
size="small"
color="primary"
variant="outlined"
/>
</Box>
}
secondary={
<Typography variant="body2" color="text.secondary">
{keypair.description || 'No description'}
<br />
Created: {new Date(keypair.createdAt).toLocaleString()}
</Typography>
}
/>
<ListItemSecondaryAction>
{currentKeypair?.id === keypair.id && (
<IconButton edge="end" disabled>
<CheckIcon color="success" />
</IconButton>
)}
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
{/* Create Keypair Dialog */}
<Dialog open={createDialogOpen} onClose={() => setCreateDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Create New Keypair</DialogTitle>
<DialogContent>
<TextField
label="Name"
value={newKeypairName}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewKeypairName(e.target.value)}
fullWidth
margin="normal"
disabled={isCreating}
/>
<FormControl fullWidth margin="normal">
<InputLabel>Type</InputLabel>
<Select
value={newKeypairType}
onChange={(e) => setNewKeypairType(e.target.value)}
disabled={isCreating}
>
<MenuItem value="Ed25519">Ed25519</MenuItem>
<MenuItem value="Secp256k1">Secp256k1 (Ethereum)</MenuItem>
</Select>
</FormControl>
<TextField
label="Description"
value={newKeypairDescription}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNewKeypairDescription(e.target.value)}
fullWidth
margin="normal"
multiline
rows={2}
disabled={isCreating}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateDialogOpen(false)} disabled={isCreating}>
Cancel
</Button>
<Button
onClick={handleCreateKeypair}
color="primary"
variant="contained"
disabled={isCreating || !newKeypairName}
>
{isCreating ? <CircularProgress size={24} /> : 'Create'}
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default KeypairPage;

View File

@@ -1,557 +0,0 @@
import { useState, useEffect } from 'react';
import { getChromeApi } from '../utils/chromeApi';
import {
Box,
Typography,
Button,
TextField,
Paper,
Alert,
CircularProgress,
Divider,
Tabs,
Tab,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Chip
} from '@mui/material';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
import VisibilityIcon from '@mui/icons-material/Visibility';
// DeleteIcon removed as it's not used
import { useNavigate } from 'react-router-dom';
import { useSessionStore } from '../store/sessionStore';
interface ScriptResult {
id: string;
timestamp: number;
script: string;
result: string;
success: boolean;
}
interface PendingScript {
id: string;
title: string;
description: string;
script: string;
tags: string[];
timestamp: number;
}
const ScriptPage = () => {
const navigate = useNavigate();
const { isSessionUnlocked, currentKeypair } = useSessionStore();
const [tabValue, setTabValue] = useState<number>(0);
const [scriptInput, setScriptInput] = useState<string>('');
const [isExecuting, setIsExecuting] = useState<boolean>(false);
const [executionResult, setExecutionResult] = useState<string | null>(null);
const [executionSuccess, setExecutionSuccess] = useState<boolean | null>(null);
const [scriptResults, setScriptResults] = useState<ScriptResult[]>([]);
const [pendingScripts, setPendingScripts] = useState<PendingScript[]>([]);
const [selectedPendingScript, setSelectedPendingScript] = useState<PendingScript | null>(null);
const [scriptDialogOpen, setScriptDialogOpen] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
// Load pending scripts from storage
useEffect(() => {
const loadPendingScripts = async () => {
try {
const chromeApi = getChromeApi();
const data = await chromeApi.storage.local.get('pendingScripts');
if (data.pendingScripts) {
setPendingScripts(data.pendingScripts);
}
} catch (err) {
console.error('Failed to load pending scripts:', err);
}
};
if (isSessionUnlocked) {
loadPendingScripts();
}
}, [isSessionUnlocked]);
// Load script history from storage
useEffect(() => {
const loadScriptResults = async () => {
try {
const chromeApi = getChromeApi();
const data = await chromeApi.storage.local.get('scriptResults');
if (data.scriptResults) {
setScriptResults(data.scriptResults);
}
} catch (err) {
console.error('Failed to load script results:', err);
}
};
if (isSessionUnlocked) {
loadScriptResults();
}
}, [isSessionUnlocked]);
const handleTabChange = (_: React.SyntheticEvent, newValue: number) => {
setTabValue(newValue);
};
const handleExecuteScript = async () => {
if (!scriptInput.trim()) return;
setIsExecuting(true);
setError(null);
setExecutionResult(null);
setExecutionSuccess(null);
try {
// Call the WASM run_rhai function via our store
const result = await useSessionStore.getState().executeScript(scriptInput);
setExecutionResult(result);
setExecutionSuccess(true);
// Save to history
const newResult: ScriptResult = {
id: `script-${Date.now()}`,
timestamp: Date.now(),
script: scriptInput,
result,
success: true
};
const updatedResults = [newResult, ...scriptResults].slice(0, 20); // Keep last 20
setScriptResults(updatedResults);
// Save to storage
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ scriptResults: updatedResults });
} catch (err) {
setError((err as Error).message || 'Failed to execute script');
setExecutionSuccess(false);
setExecutionResult('Execution failed');
} finally {
setIsExecuting(false);
}
};
const handleViewPendingScript = (script: PendingScript) => {
setSelectedPendingScript(script);
setScriptDialogOpen(true);
};
const handleApprovePendingScript = async () => {
if (!selectedPendingScript) return;
setScriptDialogOpen(false);
setScriptInput(selectedPendingScript.script);
setTabValue(0); // Switch to execute tab
// Remove from pending list
const updatedPendingScripts = pendingScripts.filter(
script => script.id !== selectedPendingScript.id
);
setPendingScripts(updatedPendingScripts);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
setSelectedPendingScript(null);
};
const handleRejectPendingScript = async () => {
if (!selectedPendingScript) return;
// Remove from pending list
const updatedPendingScripts = pendingScripts.filter(
script => script.id !== selectedPendingScript.id
);
setPendingScripts(updatedPendingScripts);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ pendingScripts: updatedPendingScripts });
setScriptDialogOpen(false);
setSelectedPendingScript(null);
};
const handleClearHistory = async () => {
setScriptResults([]);
const chromeApi = getChromeApi();
await chromeApi.storage.local.set({ scriptResults: [] });
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs
value={tabValue}
onChange={handleTabChange}
aria-label="script tabs"
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ minHeight: '48px' }}
>
<Tab label="Execute" sx={{ minHeight: '48px', py: 0 }} />
<Tab
label={
<Box sx={{ display: 'flex', alignItems: 'center' }}>
Pending
{pendingScripts.length > 0 && (
<Chip
label={pendingScripts.length}
size="small"
color="primary"
sx={{ ml: 1 }}
/>
)}
</Box>
}
sx={{ minHeight: '48px', py: 0 }}
/>
<Tab label="History" sx={{ minHeight: '48px', py: 0 }} />
</Tabs>
</Box>
{/* Execute Tab */}
{tabValue === 0 && (
<Box sx={{
p: 2,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: 'calc(100% - 48px)' // Subtract tab height
}}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
height: '100%',
pb: 2 // Add padding at bottom for scrolling
}}>
{!currentKeypair && (
<Alert severity="warning" sx={{ mb: 2 }}>
No keypair selected. Select a keypair to enable script execution with signing capabilities.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<TextField
label="Rhai Script"
multiline
rows={6} // Reduced from 8 to leave more space for results
value={scriptInput}
onChange={(e) => setScriptInput(e.target.value)}
fullWidth
variant="outlined"
placeholder="Enter your Rhai script here..."
sx={{ mb: 2 }}
disabled={isExecuting}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button
variant="contained"
color="primary"
startIcon={<PlayArrowIcon />}
onClick={handleExecuteScript}
disabled={isExecuting || !scriptInput.trim()}
>
{isExecuting ? <CircularProgress size={24} /> : 'Execute'}
</Button>
</Box>
{executionResult && (
<Paper
variant="outlined"
sx={{
p: 2,
bgcolor: executionSuccess ? 'success.dark' : 'error.dark',
color: 'white',
overflowY: 'auto',
mb: 2, // Add margin at bottom
minHeight: '100px', // Ensure minimum height for visibility
maxHeight: '200px' // Limit maximum height
}}
>
<Typography variant="subtitle2" gutterBottom>
Execution Result:
</Typography>
<Typography
variant="body2"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace'
}}
>
{executionResult}
</Typography>
</Paper>
)}
</Box>
</Box>
)}
{/* Pending Scripts Tab */}
{tabValue === 1 && (
<Box sx={{ p: 2, flexGrow: 1, display: 'flex', flexDirection: 'column' }}>
{pendingScripts.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No pending scripts. Incoming scripts from connected WebSocket servers will appear here.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{pendingScripts.map((script, index) => (
<Box key={script.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={script.title}
secondary={
<>
<Typography variant="body2" color="text.secondary">
{script.description || 'No description'}
</Typography>
<Box sx={{ mt: 0.5 }}>
{script.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
color={tag === 'remote' ? 'secondary' : 'primary'}
variant="outlined"
sx={{ mr: 0.5 }}
/>
))}
</Box>
</>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => handleViewPendingScript(script)}
aria-label="view script"
>
<VisibilityIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
</Box>
)}
{/* History Tab */}
{tabValue === 2 && (
<Box sx={{
p: 2,
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
height: 'calc(100% - 48px)' // Subtract tab height
}}>
<Box sx={{
display: 'flex',
flexDirection: 'column',
overflow: 'auto',
height: '100%',
pb: 2 // Add padding at bottom for scrolling
}}>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mb: 2 }}>
<Button
variant="outlined"
color="error"
size="small"
onClick={handleClearHistory}
disabled={scriptResults.length === 0}
>
Clear History
</Button>
</Box>
{scriptResults.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No script execution history yet.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{scriptResults.map((result, index) => (
<Box key={result.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
{new Date(result.timestamp).toLocaleString()}
</Typography>
<Chip
label={result.success ? 'Success' : 'Failed'}
size="small"
color={result.success ? 'success' : 'error'}
variant="outlined"
/>
</Box>
}
secondary={
<Typography
variant="body2"
color="text.secondary"
sx={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '280px'
}}
>
{result.script}
</Typography>
}
/>
<ListItemSecondaryAction>
<IconButton
edge="end"
onClick={() => {
setScriptInput(result.script);
setTabValue(0);
}}
aria-label="reuse script"
>
<PlayArrowIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
</Box>
</Box>
)}
{/* Pending Script Dialog */}
<Dialog
open={scriptDialogOpen}
onClose={() => setScriptDialogOpen(false)}
maxWidth="md"
fullWidth
>
<DialogTitle>
{selectedPendingScript?.title || 'Script Details'}
</DialogTitle>
<DialogContent>
{selectedPendingScript && (
<>
<Typography variant="subtitle2" gutterBottom>
Description:
</Typography>
<Typography variant="body2" paragraph>
{selectedPendingScript.description || 'No description provided'}
</Typography>
<Box sx={{ mb: 2 }}>
{selectedPendingScript.tags.map(tag => (
<Chip
key={tag}
label={tag}
size="small"
color={tag === 'remote' ? 'secondary' : 'primary'}
sx={{ mr: 0.5 }}
/>
))}
</Box>
<Typography variant="subtitle2" gutterBottom>
Script Content:
</Typography>
<Paper
variant="outlined"
sx={{
p: 2,
bgcolor: 'background.paper',
maxHeight: '300px',
overflow: 'auto'
}}
>
<Typography
variant="body2"
component="pre"
sx={{
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
fontFamily: 'monospace'
}}
>
{selectedPendingScript.script}
</Typography>
</Paper>
<Alert severity="warning" sx={{ mt: 2 }}>
<Typography variant="body2">
{selectedPendingScript.tags.includes('remote')
? 'This is a remote script. If approved, your signature will be sent to the server and the script may execute remotely.'
: 'This script will execute locally in your browser extension if approved.'}
</Typography>
</Alert>
</>
)}
</DialogContent>
<DialogActions>
<Button
onClick={handleRejectPendingScript}
color="error"
variant="outlined"
>
Reject
</Button>
<Button
onClick={handleApprovePendingScript}
color="primary"
variant="contained"
>
Approve
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ScriptPage;

View File

@@ -1,191 +0,0 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
Paper,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
Divider,
Card,
CardContent,
Grid
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useSessionStore } from '../store/sessionStore';
import LockIcon from '@mui/icons-material/Lock';
import SecurityIcon from '@mui/icons-material/Security';
// HistoryIcon removed as it's not used
interface SessionActivity {
id: string;
action: string;
timestamp: number;
details?: string;
}
const SessionPage = () => {
const navigate = useNavigate();
const {
isSessionUnlocked,
currentKeyspace,
currentKeypair,
lockSession
} = useSessionStore();
const [sessionActivities, setSessionActivities] = useState<SessionActivity[]>([]);
const [isLoading, setIsLoading] = useState(false);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
// Load session activities from storage
useEffect(() => {
const loadSessionActivities = async () => {
try {
setIsLoading(true);
const data = await chrome.storage.local.get('sessionActivities');
if (data.sessionActivities) {
setSessionActivities(data.sessionActivities);
}
} catch (err) {
console.error('Failed to load session activities:', err);
} finally {
setIsLoading(false);
}
};
if (isSessionUnlocked) {
loadSessionActivities();
}
}, [isSessionUnlocked]);
const handleLockSession = async () => {
try {
await lockSession();
navigate('/');
} catch (err) {
console.error('Failed to lock session:', err);
}
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom>
Session Management
</Typography>
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid item xs={12} sm={6}>
<Card variant="outlined">
<CardContent>
<Typography color="text.secondary" gutterBottom>
Current Keyspace
</Typography>
<Typography variant="h5" component="div">
{currentKeyspace || 'None'}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6}>
<Card variant="outlined">
<CardContent>
<Typography color="text.secondary" gutterBottom>
Selected Keypair
</Typography>
<Typography variant="h5" component="div">
{currentKeypair?.name || currentKeypair?.id || 'None'}
</Typography>
{currentKeypair && (
<Typography variant="body2" color="text.secondary">
Type: {currentKeypair.type}
</Typography>
)}
</CardContent>
</Card>
</Grid>
</Grid>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="subtitle1">
Session Activity
</Typography>
<Button
variant="outlined"
color="error"
startIcon={<LockIcon />}
onClick={handleLockSession}
>
Lock Session
</Button>
</Box>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : sessionActivities.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No session activity recorded yet.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{sessionActivities.map((activity, index) => (
<Box key={activity.id}>
{index > 0 && <Divider />}
<ListItem>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
{activity.action}
</Typography>
</Box>
}
secondary={
<>
<Typography variant="body2" color="text.secondary">
{new Date(activity.timestamp).toLocaleString()}
</Typography>
{activity.details && (
<Typography variant="body2" color="text.secondary">
{activity.details}
</Typography>
)}
</>
}
/>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
<Box sx={{ mt: 3 }}>
<Alert severity="info" icon={<SecurityIcon />}>
Your session is active. All cryptographic operations and script executions require explicit approval.
</Alert>
</Box>
</Box>
);
};
export default SessionPage;

View File

@@ -1,246 +0,0 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Switch,
// FormControlLabel removed as it's not used
Divider,
Paper,
List,
ListItem,
ListItemText,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
Alert,
Snackbar
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import InfoIcon from '@mui/icons-material/Info';
interface Settings {
darkMode: boolean;
autoLockTimeout: number; // minutes
confirmCryptoOperations: boolean;
showScriptNotifications: boolean;
}
const SettingsPage = () => {
const [settings, setSettings] = useState<Settings>({
darkMode: true,
autoLockTimeout: 15,
confirmCryptoOperations: true,
showScriptNotifications: true
});
const [clearDataDialogOpen, setClearDataDialogOpen] = useState(false);
const [confirmText, setConfirmText] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
// Load settings from storage
useEffect(() => {
const loadSettings = async () => {
try {
const data = await chrome.storage.local.get('settings');
if (data.settings) {
setSettings(data.settings);
}
} catch (err) {
console.error('Failed to load settings:', err);
}
};
loadSettings();
}, []);
// Save settings when changed
const handleSettingChange = (key: keyof Settings, value: boolean | number) => {
const updatedSettings = { ...settings, [key]: value };
setSettings(updatedSettings);
// Save to storage
chrome.storage.local.set({ settings: updatedSettings })
.then(() => {
setSnackbarMessage('Settings saved');
setSnackbarOpen(true);
})
.catch(err => {
console.error('Failed to save settings:', err);
setSnackbarMessage('Failed to save settings');
setSnackbarOpen(true);
});
};
const handleClearAllData = () => {
if (confirmText !== 'CLEAR ALL DATA') {
setSnackbarMessage('Please type the confirmation text exactly');
setSnackbarOpen(true);
return;
}
// Clear all extension data
chrome.storage.local.clear()
.then(() => {
setSnackbarMessage('All data cleared successfully');
setSnackbarOpen(true);
setClearDataDialogOpen(false);
setConfirmText('');
// Reset settings to defaults
setSettings({
darkMode: true,
autoLockTimeout: 15,
confirmCryptoOperations: true,
showScriptNotifications: true
});
})
.catch(err => {
console.error('Failed to clear data:', err);
setSnackbarMessage('Failed to clear data');
setSnackbarOpen(true);
});
};
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom>
Settings
</Typography>
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
<ListItem>
<ListItemText
primary="Dark Mode"
secondary="Use dark theme for the extension"
/>
<Switch
edge="end"
checked={settings.darkMode}
onChange={(e) => handleSettingChange('darkMode', e.target.checked)}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Auto-Lock Timeout"
secondary={`Automatically lock session after ${settings.autoLockTimeout} minutes of inactivity`}
/>
<Box sx={{ width: 120 }}>
<TextField
type="number"
size="small"
value={settings.autoLockTimeout}
onChange={(e) => {
const value = parseInt(e.target.value);
if (!isNaN(value) && value >= 1) {
handleSettingChange('autoLockTimeout', value);
}
}}
InputProps={{ inputProps: { min: 1, max: 60 } }}
/>
</Box>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Confirm Cryptographic Operations"
secondary="Always ask for confirmation before signing or encrypting"
/>
<Switch
edge="end"
checked={settings.confirmCryptoOperations}
onChange={(e) => handleSettingChange('confirmCryptoOperations', e.target.checked)}
/>
</ListItem>
<Divider />
<ListItem>
<ListItemText
primary="Script Notifications"
secondary="Show notifications when new scripts are received"
/>
<Switch
edge="end"
checked={settings.showScriptNotifications}
onChange={(e) => handleSettingChange('showScriptNotifications', e.target.checked)}
/>
</ListItem>
</List>
</Paper>
<Box sx={{ mt: 3 }}>
<Alert
severity="info"
icon={<InfoIcon />}
sx={{ mb: 2 }}
>
<Typography variant="body2">
The extension stores all cryptographic keys in encrypted form. Your password is never stored and is only kept in memory while the session is unlocked.
</Typography>
</Alert>
<Button
variant="outlined"
color="error"
startIcon={<DeleteIcon />}
onClick={() => setClearDataDialogOpen(true)}
fullWidth
>
Clear All Data
</Button>
</Box>
{/* Clear Data Confirmation Dialog */}
<Dialog open={clearDataDialogOpen} onClose={() => setClearDataDialogOpen(false)}>
<DialogTitle>Clear All Extension Data</DialogTitle>
<DialogContent>
<Typography variant="body1" paragraph>
This will permanently delete all your keyspaces, keypairs, and settings. This action cannot be undone.
</Typography>
<Typography variant="body2" color="error" paragraph>
Type "CLEAR ALL DATA" to confirm:
</Typography>
<TextField
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
fullWidth
variant="outlined"
placeholder="CLEAR ALL DATA"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setClearDataDialogOpen(false)}>
Cancel
</Button>
<Button
onClick={handleClearAllData}
color="error"
disabled={confirmText !== 'CLEAR ALL DATA'}
>
Clear All Data
</Button>
</DialogActions>
</Dialog>
{/* Snackbar for notifications */}
<Snackbar
open={snackbarOpen}
autoHideDuration={3000}
onClose={() => setSnackbarOpen(false)}
message={snackbarMessage}
/>
</Box>
);
};
export default SettingsPage;

View File

@@ -1,248 +0,0 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
Button,
TextField,
Paper,
Alert,
CircularProgress,
List,
ListItem,
ListItemText,
Divider,
Chip
} from '@mui/material';
import { useNavigate } from 'react-router-dom';
import { useSessionStore } from '../store/sessionStore';
interface ConnectionHistory {
id: string;
url: string;
timestamp: number;
status: 'connected' | 'disconnected';
}
const WebSocketPage = () => {
const navigate = useNavigate();
const {
isSessionUnlocked,
currentKeypair,
isWebSocketConnected,
webSocketUrl,
connectWebSocket,
disconnectWebSocket
} = useSessionStore();
const [serverUrl, setServerUrl] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionHistory, setConnectionHistory] = useState<ConnectionHistory[]>([]);
// Redirect if not unlocked
useEffect(() => {
if (!isSessionUnlocked) {
navigate('/');
}
}, [isSessionUnlocked, navigate]);
// Load connection history from storage
useEffect(() => {
const loadConnectionHistory = async () => {
try {
const data = await chrome.storage.local.get('connectionHistory');
if (data.connectionHistory) {
setConnectionHistory(data.connectionHistory);
}
} catch (err) {
console.error('Failed to load connection history:', err);
}
};
if (isSessionUnlocked) {
loadConnectionHistory();
}
}, [isSessionUnlocked]);
const handleConnect = async () => {
if (!serverUrl.trim() || !currentKeypair) return;
setIsConnecting(true);
setError(null);
try {
const success = await connectWebSocket(serverUrl);
if (success) {
// Add to connection history
const newConnection: ConnectionHistory = {
id: `conn-${Date.now()}`,
url: serverUrl,
timestamp: Date.now(),
status: 'connected'
};
const updatedHistory = [newConnection, ...connectionHistory].slice(0, 10); // Keep last 10
setConnectionHistory(updatedHistory);
// Save to storage
await chrome.storage.local.set({ connectionHistory: updatedHistory });
} else {
throw new Error('Failed to connect to WebSocket server');
}
} catch (err) {
setError((err as Error).message || 'Failed to connect to WebSocket server');
} finally {
setIsConnecting(false);
}
};
const handleDisconnect = async () => {
try {
const success = await disconnectWebSocket();
if (success && webSocketUrl) {
// Update connection history
const updatedHistory = connectionHistory.map(conn =>
conn.url === webSocketUrl && conn.status === 'connected'
? { ...conn, status: 'disconnected' }
: conn
);
setConnectionHistory(updatedHistory);
// Save to storage
await chrome.storage.local.set({ connectionHistory: updatedHistory });
}
} catch (err) {
setError((err as Error).message || 'Failed to disconnect from WebSocket server');
}
};
const handleQuickConnect = (url: string) => {
setServerUrl(url);
// Don't auto-connect to avoid unexpected connections
};
if (!isSessionUnlocked) {
return null; // Will redirect via useEffect
}
return (
<Box sx={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Typography variant="h6" gutterBottom>
WebSocket Connection
</Typography>
{!currentKeypair && (
<Alert severity="warning" sx={{ mb: 2 }}>
No keypair selected. Select a keypair before connecting to a WebSocket server.
</Alert>
)}
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
<Paper variant="outlined" sx={{ p: 2, mb: 2 }}>
<Box sx={{ mb: 2 }}>
<Typography variant="subtitle2" gutterBottom>
Connection Status:
</Typography>
<Chip
label={isWebSocketConnected ? 'Connected' : 'Disconnected'}
color={isWebSocketConnected ? 'success' : 'default'}
variant="outlined"
/>
{isWebSocketConnected && webSocketUrl && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Connected to: {webSocketUrl}
</Typography>
)}
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<TextField
label="WebSocket Server URL"
placeholder="wss://example.com/ws"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
fullWidth
disabled={isConnecting || isWebSocketConnected || !currentKeypair}
/>
{isWebSocketConnected ? (
<Button
variant="outlined"
color="error"
onClick={handleDisconnect}
>
Disconnect
</Button>
) : (
<Button
variant="contained"
color="primary"
onClick={handleConnect}
disabled={isConnecting || !serverUrl.trim() || !currentKeypair}
>
{isConnecting ? <CircularProgress size={24} /> : 'Connect'}
</Button>
)}
</Box>
</Paper>
<Typography variant="subtitle1" gutterBottom>
Connection History
</Typography>
{connectionHistory.length === 0 ? (
<Paper sx={{ p: 3, textAlign: 'center' }}>
<Typography variant="body1" color="text.secondary">
No connection history yet.
</Typography>
</Paper>
) : (
<Paper variant="outlined" sx={{ flexGrow: 1, overflow: 'auto' }}>
<List disablePadding>
{connectionHistory.map((conn, index) => (
<Box key={conn.id}>
{index > 0 && <Divider />}
<ListItem
button
onClick={() => handleQuickConnect(conn.url)}
disabled={isWebSocketConnected}
>
<ListItemText
primary={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">
{conn.url}
</Typography>
<Chip
label={conn.status}
size="small"
color={conn.status === 'connected' ? 'success' : 'default'}
variant="outlined"
/>
</Box>
}
secondary={
<Typography variant="body2" color="text.secondary">
{new Date(conn.timestamp).toLocaleString()}
</Typography>
}
/>
</ListItem>
</Box>
))}
</List>
</Paper>
)}
</Box>
);
};
export default WebSocketPage;

View File

@@ -1,144 +0,0 @@
/**
* Crypto Store for Hero Vault Extension
*
* This store manages cryptographic operations such as:
* - Encryption/decryption using the keyspace's symmetric cipher
* - Signing/verification using the selected keypair
*/
import { create } from 'zustand';
import { getWasmModule, stringToUint8Array, uint8ArrayToString } from '../wasm/wasmHelper';
// Helper functions for Unicode-safe base64 encoding/decoding
function base64Encode(data: Uint8Array): string {
// Convert binary data to a string that only uses the low 8 bits of each character
const binaryString = Array.from(data)
.map(byte => String.fromCharCode(byte))
.join('');
// Use btoa on the binary string
return btoa(binaryString);
}
function base64Decode(base64: string): Uint8Array {
// Decode base64 to binary string
const binaryString = atob(base64);
// Convert binary string to Uint8Array
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes;
}
interface CryptoState {
// State
isEncrypting: boolean;
isDecrypting: boolean;
isSigning: boolean;
isVerifying: boolean;
error: string | null;
// Actions
encryptData: (data: string) => Promise<string>;
decryptData: (encrypted: string) => Promise<string>;
signMessage: (message: string) => Promise<string>;
verifySignature: (message: string, signature: string) => Promise<boolean>;
clearError: () => void;
}
export const useCryptoStore = create<CryptoState>()((set, get) => ({
isEncrypting: false,
isDecrypting: false,
isSigning: false,
isVerifying: false,
error: null,
encryptData: async (data: string) => {
try {
set({ isEncrypting: true, error: null });
const wasmModule = await getWasmModule();
// Convert input to Uint8Array
const dataBytes = stringToUint8Array(data);
// Encrypt the data
const encrypted = await wasmModule.encrypt_data(dataBytes);
// Convert result to base64 for storage/display using our Unicode-safe function
const encryptedBase64 = base64Encode(encrypted);
return encryptedBase64;
} catch (error) {
set({ error: (error as Error).message || 'Failed to encrypt data' });
throw error;
} finally {
set({ isEncrypting: false });
}
},
decryptData: async (encrypted: string) => {
try {
set({ isDecrypting: true, error: null });
const wasmModule = await getWasmModule();
// Convert input from base64 using our Unicode-safe function
const encryptedBytes = base64Decode(encrypted);
// Decrypt the data
const decrypted = await wasmModule.decrypt_data(encryptedBytes);
// Convert result to string
return uint8ArrayToString(decrypted);
} catch (error) {
set({ error: (error as Error).message || 'Failed to decrypt data' });
throw error;
} finally {
set({ isDecrypting: false });
}
},
signMessage: async (message: string) => {
try {
set({ isSigning: true, error: null });
const wasmModule = await getWasmModule();
// Convert message to Uint8Array
const messageBytes = stringToUint8Array(message);
// Sign the message
const signature = await wasmModule.sign(messageBytes);
return signature;
} catch (error) {
set({ error: (error as Error).message || 'Failed to sign message' });
throw error;
} finally {
set({ isSigning: false });
}
},
verifySignature: async (message: string, signature: string) => {
try {
set({ isVerifying: true, error: null });
const wasmModule = await getWasmModule();
// Convert inputs
const messageBytes = stringToUint8Array(message);
// Verify the signature
const isValid = await wasmModule.verify(messageBytes, signature);
return isValid;
} catch (error) {
set({ error: (error as Error).message || 'Failed to verify signature' });
throw error;
} finally {
set({ isVerifying: false });
}
},
clearError: () => set({ error: null })
}));

View File

@@ -1,416 +0,0 @@
import { create } from 'zustand';
import { getWasmModule, stringToUint8Array } from '../wasm/wasmHelper';
import { getChromeApi } from '../utils/chromeApi';
// Import Chrome types
/// <reference types="chrome" />
interface KeypairMetadata {
id: string;
type: string;
name?: string;
description?: string;
createdAt: number;
}
interface SessionState {
isSessionUnlocked: boolean;
currentKeyspace: string | null;
currentKeypair: KeypairMetadata | null;
availableKeypairs: KeypairMetadata[];
isWebSocketConnected: boolean;
webSocketUrl: string | null;
isWasmLoaded: boolean;
// Actions
initWasm: () => Promise<boolean>;
checkSessionStatus: () => Promise<boolean>;
unlockSession: (keyspace: string, password: string) => Promise<boolean>;
lockSession: () => Promise<boolean>;
createKeyspace: (keyspace: string, password: string) => Promise<boolean>;
listKeypairs: () => Promise<KeypairMetadata[]>;
selectKeypair: (keypairId: string) => Promise<boolean>;
createKeypair: (type: string, metadata?: Record<string, any>) => Promise<string>;
connectWebSocket: (url: string) => Promise<boolean>;
disconnectWebSocket: () => Promise<boolean>;
executeScript: (script: string) => Promise<string>;
signMessage: (message: string) => Promise<string>;
}
// Create the store
export const useSessionStore = create<SessionState>((set: any, get: any) => ({
isSessionUnlocked: false,
currentKeyspace: null,
currentKeypair: null,
availableKeypairs: [],
isWebSocketConnected: false,
webSocketUrl: null,
isWasmLoaded: false,
// Initialize WASM module
initWasm: async () => {
try {
set({ isWasmLoaded: true });
return true;
} catch (error) {
console.error('Failed to initialize WASM module:', error);
return false;
}
},
// Check if a session is currently active
checkSessionStatus: async () => {
try {
// First check with the background service worker
const chromeApi = getChromeApi();
const response = await chromeApi.runtime.sendMessage({ type: 'SESSION_STATUS' });
if (response && response.active) {
// If session is active in the background, check with WASM
try {
const wasmModule = await getWasmModule();
const isUnlocked = wasmModule.is_unlocked();
if (isUnlocked) {
// Get current keypair metadata if available
try {
const keypairMetadata = await wasmModule.current_keypair_metadata();
const parsedMetadata = JSON.parse(keypairMetadata);
set({
isSessionUnlocked: true,
currentKeypair: parsedMetadata
});
// Load keypairs
await get().listKeypairs();
} catch (e) {
// No keypair selected, but session is unlocked
set({ isSessionUnlocked: true });
}
return true;
}
} catch (wasmError) {
console.error('WASM error checking session status:', wasmError);
}
}
set({ isSessionUnlocked: false });
return false;
} catch (error) {
console.error('Failed to check session status:', error);
set({ isSessionUnlocked: false });
return false;
}
},
// Unlock a session with keyspace and password
unlockSession: async (keyspace: string, password: string) => {
try {
const wasmModule = await getWasmModule();
// Call the WASM init_session function
await wasmModule.init_session(keyspace, password);
// Initialize Rhai environment
wasmModule.init_rhai_env();
// Notify background service worker
const chromeApi = getChromeApi();
await chromeApi.runtime.sendMessage({ type: 'SESSION_UNLOCK' });
set({
isSessionUnlocked: true,
currentKeyspace: keyspace,
currentKeypair: null
});
// Load keypairs after unlocking
const keypairs = await get().listKeypairs();
set({ availableKeypairs: keypairs });
return true;
} catch (error) {
console.error('Failed to unlock session:', error);
return false;
}
},
// Lock the current session
lockSession: async () => {
try {
const wasmModule = await getWasmModule();
// Call the WASM lock_session function
wasmModule.lock_session();
// Notify background service worker
const chromeApi = getChromeApi();
await chromeApi.runtime.sendMessage({ type: 'SESSION_LOCK' });
set({
isSessionUnlocked: false,
currentKeyspace: null,
currentKeypair: null,
availableKeypairs: [],
isWebSocketConnected: false,
webSocketUrl: null
});
return true;
} catch (error) {
console.error('Failed to lock session:', error);
return false;
}
},
// Create a new keyspace
createKeyspace: async (keyspace: string, password: string) => {
try {
const wasmModule = await getWasmModule();
// Call the WASM create_keyspace function
await wasmModule.create_keyspace(keyspace, password);
// Initialize Rhai environment
wasmModule.init_rhai_env();
// Notify background service worker
const chromeApi = getChromeApi();
await chromeApi.runtime.sendMessage({ type: 'SESSION_UNLOCK' });
set({
isSessionUnlocked: true,
currentKeyspace: keyspace,
currentKeypair: null,
availableKeypairs: []
});
return true;
} catch (error) {
console.error('Failed to create keyspace:', error);
return false;
}
},
// List all keypairs in the current keyspace
listKeypairs: async () => {
try {
console.log('Listing keypairs from WASM module');
const wasmModule = await getWasmModule();
console.log('WASM module loaded, calling list_keypairs');
// Call the WASM list_keypairs function
let keypairsJson;
try {
keypairsJson = await wasmModule.list_keypairs();
console.log('Raw keypairs JSON from WASM:', keypairsJson);
} catch (listError) {
console.error('Error calling list_keypairs:', listError);
throw new Error(`Failed to list keypairs: ${listError.message || listError}`);
}
let keypairs;
try {
keypairs = JSON.parse(keypairsJson);
console.log('Parsed keypairs object:', keypairs);
} catch (parseError) {
console.error('Error parsing keypairs JSON:', parseError);
throw new Error(`Failed to parse keypairs JSON: ${parseError.message}`);
}
// Transform the keypairs to our expected format
const formattedKeypairs: KeypairMetadata[] = keypairs.map((keypair: any, index: number) => {
console.log(`Processing keypair at index ${index}:`, keypair);
return {
id: keypair.id, // Use the actual keypair ID from the WASM module
type: keypair.key_type || 'Unknown',
name: keypair.metadata?.name,
description: keypair.metadata?.description,
createdAt: keypair.metadata?.created_at || Date.now()
};
});
console.log('Formatted keypairs for UI:', formattedKeypairs);
set({ availableKeypairs: formattedKeypairs });
return formattedKeypairs;
} catch (error) {
console.error('Failed to list keypairs:', error);
return [];
}
},
// Select a keypair for use
selectKeypair: async (keypairId: string) => {
try {
console.log('Selecting keypair with ID:', keypairId);
// First, let's log the available keypairs to see what we have
const { availableKeypairs } = get();
console.log('Available keypairs:', JSON.stringify(availableKeypairs));
const wasmModule = await getWasmModule();
console.log('WASM module loaded, attempting to select keypair');
try {
// Call the WASM select_keypair function
await wasmModule.select_keypair(keypairId);
console.log('Successfully selected keypair in WASM');
} catch (selectError) {
console.error('Error in WASM select_keypair:', selectError);
throw new Error(`select_keypair error: ${selectError.message || selectError}`);
}
// Find the keypair in our availableKeypairs list
const selectedKeypair = availableKeypairs.find((kp: KeypairMetadata) => kp.id === keypairId);
if (selectedKeypair) {
console.log('Found keypair in available list, setting as current');
set({ currentKeypair: selectedKeypair });
} else {
console.log('Keypair not found in available list, creating new entry from available data');
// If not found in our list (rare case), create a new entry with what we know
// Since we can't get metadata from WASM, use what we have from the keypair list
const matchingKeypair = availableKeypairs.find(k => k.id === keypairId);
if (matchingKeypair) {
set({ currentKeypair: matchingKeypair });
} else {
// Last resort: create a minimal keypair entry
const newKeypair: KeypairMetadata = {
id: keypairId,
type: 'Unknown',
name: `Keypair ${keypairId.substring(0, 8)}...`,
createdAt: Date.now()
};
set({ currentKeypair: newKeypair });
}
}
return true;
} catch (error) {
console.error('Failed to select keypair:', error);
throw error; // Re-throw to show error in UI
}
},
// Create a new keypair
createKeypair: async (type: string, metadata?: Record<string, any>) => {
try {
const wasmModule = await getWasmModule();
// Format metadata for WASM
const metadataJson = metadata ? JSON.stringify({
name: metadata.name,
description: metadata.description,
created_at: Date.now()
}) : undefined;
// Call the WASM add_keypair function
const keypairId = await wasmModule.add_keypair(type, metadataJson);
// Refresh the keypair list
await get().listKeypairs();
return keypairId;
} catch (error) {
console.error('Failed to create keypair:', error);
throw error;
}
},
// Connect to a WebSocket server
connectWebSocket: async (url: string) => {
try {
const wasmModule = await getWasmModule();
const { currentKeypair } = get();
if (!currentKeypair) {
throw new Error('No keypair selected');
}
// Get the public key from WASM
const publicKeyArray = await wasmModule.current_keypair_public_key();
const publicKeyHex = Array.from(publicKeyArray)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
// Connect to WebSocket via background service worker
const chromeApi = getChromeApi();
const response = await chromeApi.runtime.sendMessage({
type: 'CONNECT_WEBSOCKET',
serverUrl: url,
publicKey: publicKeyHex
});
if (response && response.success) {
set({
isWebSocketConnected: true,
webSocketUrl: url
});
return true;
} else {
throw new Error(response?.error || 'Failed to connect to WebSocket server');
}
} catch (error) {
console.error('Failed to connect to WebSocket:', error);
return false;
}
},
// Disconnect from WebSocket server
disconnectWebSocket: async () => {
try {
// Disconnect via background service worker
const chromeApi = getChromeApi();
const response = await chromeApi.runtime.sendMessage({
type: 'DISCONNECT_WEBSOCKET'
});
if (response && response.success) {
set({
isWebSocketConnected: false,
webSocketUrl: null
});
return true;
} else {
throw new Error(response?.error || 'Failed to disconnect from WebSocket server');
}
} catch (error) {
console.error('Failed to disconnect from WebSocket:', error);
return false;
}
},
// Execute a Rhai script
executeScript: async (script: string) => {
try {
const wasmModule = await getWasmModule();
// Call the WASM run_rhai function
const result = await wasmModule.run_rhai(script);
return result;
} catch (error) {
console.error('Failed to execute script:', error);
throw error;
}
},
// Sign a message with the current keypair
signMessage: async (message: string) => {
try {
const wasmModule = await getWasmModule();
// Convert message to Uint8Array
const messageBytes = stringToUint8Array(message);
// Call the WASM sign function
const signature = await wasmModule.sign(messageBytes);
return signature;
} catch (error) {
console.error('Failed to sign message:', error);
throw error;
}
}
}));

View File

@@ -1,45 +0,0 @@
/**
* Common TypeScript types for the Hero Vault Extension
*/
// React types
export type SyntheticEvent<T = Element, E = Event> = React.BaseSyntheticEvent<E, EventTarget & T, EventTarget>;
// Session types
export interface SessionActivity {
timestamp: number;
action: string;
details?: string;
}
// Script types
export interface ScriptResult {
id: string;
script: string;
result: string;
timestamp: number;
success: boolean;
}
export interface PendingScript {
id: string;
name: string;
script: string;
}
// WebSocket types
export interface ConnectionHistory {
id: string;
url: string;
timestamp: number;
status: 'connected' | 'disconnected' | 'error';
message?: string;
}
// Settings types
export interface Settings {
darkMode: boolean;
autoLockTimeout: number;
defaultKeyType: string;
showScriptNotifications: boolean;
}

View File

@@ -1,5 +0,0 @@
/// <reference types="chrome" />
// This file provides type declarations for Chrome extension APIs
// It's needed because we're using the Chrome extension API in a TypeScript project
// The actual implementation is provided by the browser at runtime

View File

@@ -1,14 +0,0 @@
// Type declarations for modules without type definitions
// React and Material UI
declare module 'react';
declare module 'react-dom';
declare module 'react-router-dom';
declare module '@mui/material';
declare module '@mui/material/*';
declare module '@mui/icons-material/*';
// Project modules
declare module './pages/*';
declare module './components/*';
declare module './store/*';

View File

@@ -1,16 +0,0 @@
declare module '*/wasm_app.js' {
export default function init(): Promise<void>;
export function init_session(keyspace: string, password: string): Promise<void>;
export function create_keyspace(keyspace: string, password: string): Promise<void>;
export function lock_session(): void;
export function is_unlocked(): boolean;
export function add_keypair(key_type: string | undefined, metadata: string | undefined): Promise<string>;
export function list_keypairs(): Promise<string>;
export function select_keypair(key_id: string): Promise<void>;
export function current_keypair_metadata(): Promise<any>;
export function current_keypair_public_key(): Promise<Uint8Array>;
export function sign(message: Uint8Array): Promise<string>;
export function verify(signature: string, message: Uint8Array): Promise<boolean>;
export function init_rhai_env(): void;
export function run_rhai(script: string): Promise<string>;
}

View File

@@ -1,103 +0,0 @@
/**
* Chrome API utilities for Hero Vault Extension
*
* This module provides Chrome API detection and mocks for development mode
*/
// Check if we're running in a Chrome extension environment
export const isExtensionEnvironment = (): boolean => {
return typeof chrome !== 'undefined' && !!chrome.runtime && !!chrome.runtime.id;
};
// Mock storage for development mode
const mockStorage: Record<string, any> = {
// Initialize with some default values for script storage
pendingScripts: [],
scriptResults: []
};
// Mock Chrome API for development mode
export const getChromeApi = () => {
// If we're in a Chrome extension environment, return the real Chrome API
if (isExtensionEnvironment()) {
return chrome;
}
// Otherwise, return a mock implementation
return {
runtime: {
sendMessage: (message: any): Promise<any> => {
console.log('Mock sendMessage called with:', message);
// Mock responses based on message type
if (message.type === 'SESSION_STATUS') {
return Promise.resolve({ active: false });
}
if (message.type === 'CREATE_KEYSPACE') {
mockStorage['currentKeyspace'] = message.keyspace;
return Promise.resolve({ success: true });
}
if (message.type === 'UNLOCK_SESSION') {
mockStorage['currentKeyspace'] = message.keyspace;
return Promise.resolve({ success: true });
}
if (message.type === 'LOCK_SESSION') {
delete mockStorage['currentKeyspace'];
return Promise.resolve({ success: true });
}
return Promise.resolve({ success: false });
},
getURL: (path: string): string => {
return path;
}
},
storage: {
local: {
get: (keys: string | string[] | object): Promise<Record<string, any>> => {
console.log('Mock storage.local.get called with:', keys);
if (typeof keys === 'string') {
// Handle specific script storage keys
if (keys === 'pendingScripts' && !mockStorage[keys]) {
mockStorage[keys] = [];
}
if (keys === 'scriptResults' && !mockStorage[keys]) {
mockStorage[keys] = [];
}
return Promise.resolve({ [keys]: mockStorage[keys] });
}
if (Array.isArray(keys)) {
const result: Record<string, any> = {};
keys.forEach(key => {
// Handle specific script storage keys
if (key === 'pendingScripts' && !mockStorage[key]) {
mockStorage[key] = [];
}
if (key === 'scriptResults' && !mockStorage[key]) {
mockStorage[key] = [];
}
result[key] = mockStorage[key];
});
return Promise.resolve(result);
}
return Promise.resolve(mockStorage);
},
set: (items: Record<string, any>): Promise<void> => {
console.log('Mock storage.local.set called with:', items);
Object.keys(items).forEach(key => {
mockStorage[key] = items[key];
});
return Promise.resolve();
}
}
}
} as typeof chrome;
};

View File

@@ -1,139 +0,0 @@
/**
* WASM Helper for Hero Vault Extension
*
* This module handles loading and initializing the WASM module,
* and provides a typed interface to the WASM functions.
*/
// Import types for TypeScript
interface WasmModule {
// Session management
init_session: (keyspace: string, password: string) => Promise<void>;
create_keyspace: (keyspace: string, password: string) => Promise<void>;
lock_session: () => void;
is_unlocked: () => boolean;
// Keypair management
add_keypair: (key_type: string | undefined, metadata: string | undefined) => Promise<string>;
list_keypairs: () => Promise<string>;
select_keypair: (key_id: string) => Promise<void>;
current_keypair_metadata: () => Promise<any>;
current_keypair_public_key: () => Promise<Uint8Array>;
// Cryptographic operations
sign: (message: Uint8Array) => Promise<string>;
verify: (message: Uint8Array, signature: string) => Promise<boolean>;
encrypt_data: (data: Uint8Array) => Promise<Uint8Array>;
decrypt_data: (encrypted: Uint8Array) => Promise<Uint8Array>;
// Rhai scripting
init_rhai_env: () => void;
run_rhai: (script: string) => Promise<string>;
}
// Global reference to the WASM module
let wasmModule: WasmModule | null = null;
let isInitializing = false;
let initPromise: Promise<void> | null = null;
/**
* Initialize the WASM module
* This should be called before any other WASM functions
*/
export const initWasm = async (): Promise<void> => {
if (wasmModule) {
return Promise.resolve(); // Already initialized
}
if (isInitializing && initPromise) {
return initPromise; // Already initializing
}
isInitializing = true;
initPromise = new Promise<void>(async (resolve, reject) => {
try {
try {
// Import the WASM module
// Use a relative path that will be resolved by Vite during build
const wasmImport = await import('../../public/wasm/wasm_app.js');
// Initialize the WASM module
await wasmImport.default();
// Store the WASM module globally
wasmModule = wasmImport as unknown as WasmModule;
console.log('WASM module initialized successfully');
resolve();
} catch (error) {
console.error('Failed to initialize WASM module:', error);
reject(error);
}
} finally {
isInitializing = false;
}
});
return initPromise;
};
/**
* Get the WASM module
* This will initialize the module if it hasn't been initialized yet
*/
export const getWasmModule = async (): Promise<WasmModule> => {
if (!wasmModule) {
await initWasm();
}
if (!wasmModule) {
throw new Error('WASM module failed to initialize');
}
return wasmModule;
};
/**
* Check if the WASM module is initialized
*/
export const isWasmInitialized = (): boolean => {
return wasmModule !== null;
};
/**
* Helper to convert string to Uint8Array
*/
export const stringToUint8Array = (str: string): Uint8Array => {
const encoder = new TextEncoder();
return encoder.encode(str);
};
/**
* Helper to convert Uint8Array to string
*/
export const uint8ArrayToString = (array: Uint8Array): string => {
const decoder = new TextDecoder();
return decoder.decode(array);
};
/**
* Helper to convert hex string to Uint8Array
*/
export const hexToUint8Array = (hex: string): Uint8Array => {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
}
return bytes;
};
/**
* Helper to convert Uint8Array to hex string
*/
export const uint8ArrayToHex = (array: Uint8Array): string => {
return Array.from(array)
.map(b => b.toString(16).padStart(2, '0'))
.join('');
};

View File

@@ -1,30 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": false,
"noImplicitAny": false,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"typeRoots": ["./node_modules/@types", "./src/types"],
"jsxImportSource": "react"
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -1,10 +0,0 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -1,33 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { crx } from '@crxjs/vite-plugin';
import { resolve } from 'path';
import { readFileSync } from 'fs';
import fs from 'fs';
const manifest = JSON.parse(
readFileSync('public/manifest.json', 'utf-8')
);
export default defineConfig({
plugins: [
react(),
crx({ manifest }),
],
resolve: {
alias: {
'@': resolve(__dirname, 'src'),
},
},
build: {
outDir: 'dist',
emptyOutDir: true,
rollupOptions: {
input: {
index: resolve(__dirname, 'index.html'),
},
},
},
// Copy WASM files to the dist directory
publicDir: 'public',
});

View File

@@ -130,6 +130,7 @@ store.put(&js_value, Some(&JsValue::from_str(key)))?.await
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub struct WasmStore; pub struct WasmStore;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
#[async_trait] #[async_trait]
impl KVStore for WasmStore { impl KVStore for WasmStore {
@@ -139,10 +140,16 @@ impl KVStore for WasmStore {
async fn set(&self, _key: &str, _value: &[u8]) -> Result<()> { async fn set(&self, _key: &str, _value: &[u8]) -> Result<()> {
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string())) Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
} }
async fn delete(&self, _key: &str) -> Result<()> { async fn remove(&self, _key: &str) -> Result<()> {
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string())) Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
} }
async fn exists(&self, _key: &str) -> Result<bool> { async fn contains_key(&self, _key: &str) -> Result<bool> {
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
}
async fn keys(&self) -> Result<Vec<String>> {
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
}
async fn clear(&self) -> Result<()> {
Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string())) Err(KVError::Other("WasmStore is only available on wasm32 targets".to_string()))
} }
} }

View File

@@ -0,0 +1,53 @@
[package]
name = "sigsocket_client"
version = "0.1.0"
edition = "2021"
description = "WebSocket client for sigsocket server with WASM-first support"
license = "MIT OR Apache-2.0"
repository = "https://git.ourworld.tf/samehabouelsaad/sal-modular"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
# Core dependencies (both native and WASM)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"
hex = "0.4"
base64 = "0.21"
url = "2.5"
async-trait = "0.1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# Native-only dependencies
tokio = { version = "1.0", features = ["full"] }
tokio-tungstenite = "0.21"
futures-util = "0.3"
thiserror = "1.0"
[target.'cfg(target_arch = "wasm32")'.dependencies]
# WASM-only dependencies
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
web-sys = { version = "0.3", features = [
"console",
"WebSocket",
"MessageEvent",
"Event",
"BinaryType",
"CloseEvent",
"ErrorEvent",
"Window",
] }
js-sys = "0.3"
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
tokio = { version = "1.0", features = ["full"] }
env_logger = "0.10"
[target.'cfg(target_arch = "wasm32")'.dev-dependencies]
wasm-bindgen-test = "0.3"
console_error_panic_hook = "0.1"

View File

@@ -0,0 +1,214 @@
# SigSocket Client Implementation
## Overview
This document describes the implementation of the `sigsocket_client` crate, a WebSocket client library designed for connecting to sigsocket servers with **WASM-first support**.
## Architecture
### Core Design Principles
1. **WASM-First**: Designed primarily for browser environments with native support as a secondary target
2. **No Signing Logic**: The client delegates all signing operations to the application
3. **User Approval Flow**: Applications are notified about incoming requests and handle user approval
4. **Protocol Compatibility**: Fully compatible with the sigsocket server protocol
5. **Async/Await**: Modern async Rust API throughout
### Module Structure
```
sigsocket_client/
├── src/
│ ├── lib.rs # Main library entry point
│ ├── error.rs # Error types (native + WASM versions)
│ ├── protocol.rs # Protocol message definitions
│ ├── client.rs # Main client interface
│ ├── native.rs # Native (tokio) implementation
│ └── wasm.rs # WASM (web-sys) implementation
├── examples/
│ ├── basic_usage.rs # Native usage example
│ └── wasm_usage.rs # WASM usage example
├── tests/
│ └── integration_test.rs
└── README.md
```
## Protocol Implementation
The sigsocket protocol is simple and consists of three message types:
### 1. Introduction Message
When connecting, the client sends its public key as a hex-encoded string:
```
02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9
```
### 2. Sign Request (Server → Client)
```json
{
"id": "req_123",
"message": "dGVzdCBtZXNzYWdl" // base64-encoded message
}
```
### 3. Sign Response (Client → Server)
```json
{
"id": "req_123",
"message": "dGVzdCBtZXNzYWdl", // original message
"signature": "c2lnbmF0dXJl" // base64-encoded signature
}
```
## Key Features Implemented
### ✅ Dual Platform Support
- **Native**: Uses `tokio` and `tokio-tungstenite` for async WebSocket communication
- **WASM**: Uses `web-sys` and `wasm-bindgen` for browser WebSocket API
### ✅ Type-Safe Protocol
- `SignRequest` and `SignResponse` structs with serde serialization
- Helper methods for base64 encoding/decoding
- Comprehensive error handling
### ✅ Flexible Sign Handler Interface
```rust
trait SignRequestHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
}
```
### ✅ Connection Management
- Automatic connection state tracking
- Clean disconnect handling
- Connection status queries
### ✅ Error Handling
- Comprehensive error types for different failure modes
- Platform-specific error conversions
- WASM-compatible error handling (no `std::error::Error` dependency)
## Platform-Specific Implementations
### Native Implementation (`native.rs`)
- Uses `tokio-tungstenite` for WebSocket communication
- Spawns separate tasks for reading and writing
- Thread-safe with `Arc<RwLock<T>>` for shared state
- Supports `Send + Sync` trait bounds
### WASM Implementation (`wasm.rs`)
- Uses `web-sys::WebSocket` for browser WebSocket API
- Event-driven with JavaScript closures
- Single-threaded (no `Send + Sync` requirements)
- Browser console logging for debugging
## Usage Patterns
### Native Usage
```rust
#[tokio::main]
async fn main() -> Result<()> {
let public_key = hex::decode("02f9308a...")?;
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
client.set_sign_handler(MySignHandler);
client.connect().await?;
// Client handles requests automatically
Ok(())
}
```
### WASM Usage
```rust
#[wasm_bindgen]
pub async fn connect_to_sigsocket() -> Result<(), JsValue> {
let public_key = get_user_public_key()?;
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
client.set_sign_handler(WasmSignHandler);
client.connect().await?;
Ok(())
}
```
## Testing
### Unit Tests
- Protocol message serialization/deserialization
- Error handling and conversion
- Client creation and configuration
### Integration Tests
- End-to-end usage patterns
- Sign request/response cycles
- Error scenarios
### Documentation Tests
- Example code in documentation is verified to compile
## Dependencies
### Core Dependencies (Both Platforms)
- `serde` + `serde_json` - JSON serialization
- `hex` - Hex encoding/decoding
- `base64` - Base64 encoding/decoding
- `url` - URL parsing and validation
### Native-Only Dependencies
- `tokio` - Async runtime
- `tokio-tungstenite` - WebSocket client
- `futures-util` - Stream utilities
- `thiserror` - Error derive macros
### WASM-Only Dependencies
- `wasm-bindgen` - Rust/JavaScript interop
- `web-sys` - Browser API bindings
- `js-sys` - JavaScript type bindings
- `wasm-bindgen-futures` - Async support
## Build Targets
### Native Build
```bash
cargo build --features native
cargo test --features native
cargo run --example basic_usage --features native
```
### WASM Build
```bash
cargo check --target wasm32-unknown-unknown --features wasm
wasm-pack build --target web --features wasm
```
## Future Enhancements
### Potential Improvements
1. **Reconnection Logic**: Automatic reconnection with exponential backoff
2. **Request Queuing**: Queue multiple concurrent sign requests
3. **Timeout Handling**: Configurable timeouts for requests
4. **Metrics**: Connection and request metrics
5. **Logging**: Structured logging with configurable levels
### WASM Enhancements
1. **Better Callback System**: More ergonomic callback handling in WASM
2. **Browser Wallet Integration**: Direct integration with MetaMask, etc.
3. **Service Worker Support**: Background request handling
## Security Considerations
1. **No Private Key Storage**: The client never handles private keys
2. **User Approval Required**: All signing requires explicit user approval
3. **Message Validation**: All incoming messages are validated
4. **Secure Transport**: Requires WebSocket Secure (WSS) in production
## Compatibility
- **Rust Version**: 1.70+
- **WASM Target**: `wasm32-unknown-unknown`
- **Browser Support**: Modern browsers with WebSocket support
- **Server Compatibility**: Compatible with sigsocket server protocol
This implementation provides a solid foundation for applications that need to connect to sigsocket servers while maintaining security and user control over signing operations.

218
sigsocket_client/README.md Normal file
View File

@@ -0,0 +1,218 @@
# SigSocket Client
A WebSocket client library for connecting to sigsocket servers with **WASM-first support**.
## Features
- 🌐 **WASM-first design**: Optimized for browser environments
- 🖥️ **Native support**: Works in native Rust applications
- 🔐 **No signing logic**: Delegates signing to the application
- 👤 **User approval flow**: Notifies applications about incoming requests
- 🔌 **sigsocket compatible**: Fully compatible with sigsocket server protocol
- 🚀 **Async/await**: Modern async Rust API
- 🔄 **Automatic reconnection**: Both platforms support reconnection with exponential backoff
- ⏱️ **Connection timeouts**: Proper timeout handling and connection management
- 🛡️ **Production ready**: Comprehensive error handling and reliability features
## Quick Start
### Native Usage
```rust
use sigsocket_client::{SigSocketClient, SignRequestHandler, SignRequest, Result};
struct MySignHandler;
impl SignRequestHandler for MySignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
// 1. Present request to user
println!("Sign request: {}", request.message);
// 2. Get user approval
// ... your UI logic here ...
// 3. Sign the message (using your signing logic)
let signature = your_signing_function(&request.message_bytes()?)?;
Ok(signature)
}
}
#[tokio::main]
async fn main() -> Result<()> {
// Your public key bytes
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388")?;
// Create and configure client
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
client.set_sign_handler(MySignHandler);
// Connect and handle requests
client.connect().await?;
// Client will automatically handle incoming signature requests
// Keep the connection alive...
Ok(())
}
```
### WASM Usage
```rust
use sigsocket_client::{SigSocketClient, SignRequestHandler, SignRequest, Result};
use wasm_bindgen::prelude::*;
struct WasmSignHandler;
impl SignRequestHandler for WasmSignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
// Show request to user in browser
web_sys::window()
.unwrap()
.alert_with_message(&format!("Sign request: {}", request.id))
.unwrap();
// Your signing logic here...
let signature = sign_with_browser_wallet(&request.message_bytes()?)?;
Ok(signature)
}
}
#[wasm_bindgen]
pub async fn connect_to_sigsocket() -> Result<(), JsValue> {
let public_key = get_user_public_key()?;
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
client.set_sign_handler(WasmSignHandler);
client.connect().await
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(())
}
```
## Protocol
The sigsocket client implements a simple WebSocket protocol:
### 1. Introduction
Upon connection, the client sends its public key as a hex-encoded string:
```
02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388
```
### 2. Sign Requests
The server sends signature requests as JSON:
```json
{
"id": "req_123",
"message": "dGVzdCBtZXNzYWdl" // base64-encoded message
}
```
### 3. Sign Responses
The client responds with signatures as JSON:
```json
{
"id": "req_123",
"message": "dGVzdCBtZXNzYWdl", // original message
"signature": "c2lnbmF0dXJl" // base64-encoded signature
}
```
## API Reference
### `SigSocketClient`
Main client for connecting to sigsocket servers.
#### Methods
- `new(url, public_key)` - Create a new client
- `set_sign_handler(handler)` - Set the signature request handler
- `connect()` - Connect to the server with automatic reconnection
- `disconnect()` - Disconnect from the server
- `send_sign_response(response)` - Manually send a signature response
- `state()` - Get current connection state
- `is_connected()` - Check if connected
#### Reconnection Configuration (WASM only)
- `set_auto_reconnect(enabled)` - Enable/disable automatic reconnection
- `set_reconnect_config(max_attempts, initial_delay_ms)` - Configure reconnection parameters
**Default settings:**
- Max attempts: 5
- Initial delay: 1000ms (with exponential backoff: 1s, 2s, 4s, 8s, 16s)
- Auto-reconnect: enabled
### `SignRequestHandler` Trait
Implement this trait to handle incoming signature requests.
```rust
trait SignRequestHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
}
```
### `SignRequest`
Represents a signature request from the server.
#### Fields
- `id: String` - Unique request identifier
- `message: String` - Base64-encoded message to sign
#### Methods
- `message_bytes()` - Decode message to bytes
- `message_hex()` - Get message as hex string
### `SignResponse`
Represents a signature response to send to the server.
#### Methods
- `new(id, message, signature)` - Create a new response
- `from_request_and_signature(request, signature)` - Create from request and signature bytes
## Examples
Run the basic example:
```bash
cargo run --example basic_usage
```
## Building
### Native Build
```bash
cargo build
cargo test
cargo run --example basic_usage
```
### WASM Build
```bash
wasm-pack build --target web
wasm-pack test --headless --firefox # Run WASM tests
```
## Requirements
### Native
- Rust 1.70+
- tokio runtime
### WASM
- wasm-pack
- Modern browser with WebSocket support
## License
MIT OR Apache-2.0

View File

@@ -0,0 +1,133 @@
//! Basic usage example for sigsocket_client
//!
//! This example demonstrates how to:
//! 1. Create a sigsocket client
//! 2. Set up a sign request handler
//! 3. Connect to a sigsocket server
//! 4. Handle incoming signature requests
//!
//! This example only runs on native (non-WASM) targets.
#[cfg(not(target_arch = "wasm32"))]
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
#[cfg(not(target_arch = "wasm32"))]
/// Example sign request handler
///
/// In a real application, this would:
/// - Present the request to the user
/// - Get user approval
/// - Use a secure signing method (hardware wallet, etc.)
/// - Return the signature
struct ExampleSignHandler;
#[cfg(not(target_arch = "wasm32"))]
impl SignRequestHandler for ExampleSignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
println!("📝 Received sign request:");
println!(" ID: {}", request.id);
println!(" Message (base64): {}", request.message);
// Decode the message to show what we're signing
match request.message_bytes() {
Ok(message_bytes) => {
println!(" Message (hex): {}", hex::encode(&message_bytes));
println!(" Message (text): {}", String::from_utf8_lossy(&message_bytes));
}
Err(e) => {
println!(" ⚠️ Failed to decode message: {}", e);
return Err(SigSocketError::Base64(e.to_string()));
}
}
// In a real implementation, you would:
// 1. Show this to the user
// 2. Get user approval
// 3. Sign the message using a secure method
println!("🤔 Would you like to sign this message? (This is a simulation)");
println!("✅ Auto-approving for demo purposes...");
// Simulate signing - in reality, this would be a real signature
let fake_signature = format!("fake_signature_for_{}", request.id);
Ok(fake_signature.into_bytes())
}
}
#[cfg(not(target_arch = "wasm32"))]
#[tokio::main]
async fn main() -> Result<()> {
// Initialize logging
env_logger::init();
println!("🚀 SigSocket Client Example");
println!("============================");
// Example public key (in a real app, this would be your actual public key)
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9388")
.expect("Invalid public key hex");
println!("🔑 Public key: {}", hex::encode(&public_key));
// Create the client
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
println!("📡 Created client for: {}", client.url());
// Set up the sign request handler
client.set_sign_handler(ExampleSignHandler);
println!("✅ Sign request handler configured");
// Connect to the server
println!("🔌 Connecting to sigsocket server...");
match client.connect().await {
Ok(()) => {
println!("✅ Connected successfully!");
println!("📊 Connection state: {:?}", client.state());
}
Err(e) => {
println!("❌ Failed to connect: {}", e);
println!("💡 Make sure the sigsocket server is running on localhost:8080");
return Err(e);
}
}
// Keep the connection alive and handle requests
println!("👂 Listening for signature requests...");
println!(" (Press Ctrl+C to exit)");
// In a real application, you might want to:
// - Handle reconnection
// - Provide a UI for user interaction
// - Manage multiple concurrent requests
// - Store and manage signatures
// For this example, we'll just wait
tokio::signal::ctrl_c().await.expect("Failed to listen for ctrl-c");
println!("\n🛑 Shutting down...");
client.disconnect().await?;
println!("✅ Disconnected cleanly");
Ok(())
}
// Example of how you might manually send a response (if needed)
#[cfg(not(target_arch = "wasm32"))]
#[allow(dead_code)]
async fn send_manual_response(client: &SigSocketClient) -> Result<()> {
let response = SignResponse::new(
"example-request-id",
"dGVzdCBtZXNzYWdl", // "test message" in base64
"ZmFrZV9zaWduYXR1cmU=", // "fake_signature" in base64
);
client.send_sign_response(&response).await?;
println!("📤 Sent manual response: {}", response.id);
Ok(())
}
// WASM main function (does nothing since this example is native-only)
#[cfg(target_arch = "wasm32")]
fn main() {
// This example is designed for native use only
}

View File

@@ -0,0 +1,384 @@
//! Main client interface for sigsocket communication
#[cfg(target_arch = "wasm32")]
use alloc::{string::String, vec::Vec, boxed::Box, string::ToString};
#[cfg(not(target_arch = "wasm32"))]
use std::collections::HashMap;
#[cfg(target_arch = "wasm32")]
use alloc::collections::BTreeMap as HashMap;
use crate::{SignRequest, SignResponse, Result, SigSocketError};
use crate::protocol::ManagedSignRequest;
/// Connection state of the sigsocket client
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ConnectionState {
/// Client is disconnected
Disconnected,
/// Client is connecting
Connecting,
/// Client is connected and ready
Connected,
/// Client connection failed
Failed,
}
/// Trait for handling sign requests from the sigsocket server
///
/// Applications should implement this trait to handle incoming signature requests.
/// The implementation should:
/// 1. Present the request to the user
/// 2. Get user approval
/// 3. Sign the message (using external signing logic)
/// 4. Return the signature
#[cfg(not(target_arch = "wasm32"))]
pub trait SignRequestHandler: Send + Sync {
/// Handle a sign request from the server
///
/// This method is called when the server sends a signature request.
/// The implementation should:
/// - Decode and validate the message
/// - Present it to the user for approval
/// - If approved, sign the message and return the signature
/// - If rejected, return an error
///
/// # Arguments
/// * `request` - The sign request from the server
///
/// # Returns
/// * `Ok(signature_bytes)` - The signature as raw bytes
/// * `Err(error)` - If the request was rejected or signing failed
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
}
/// WASM version of SignRequestHandler (no Send + Sync requirements)
#[cfg(target_arch = "wasm32")]
pub trait SignRequestHandler {
/// Handle a sign request from the server
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>>;
}
/// Main sigsocket client
///
/// This is the primary interface for connecting to sigsocket servers.
/// It handles the WebSocket connection, protocol communication, and
/// delegates signing requests to the application.
pub struct SigSocketClient {
/// WebSocket server URL
url: String,
/// Client's public key (hex-encoded)
public_key: Vec<u8>,
/// Current connection state
state: ConnectionState,
/// Sign request handler
sign_handler: Option<Box<dyn SignRequestHandler>>,
/// Pending sign requests managed by the client
pending_requests: HashMap<String, ManagedSignRequest>,
/// Connected public key (hex-encoded) - set when connection is established
connected_public_key: Option<String>,
/// Platform-specific implementation
#[cfg(not(target_arch = "wasm32"))]
inner: Option<crate::native::NativeClient>,
#[cfg(target_arch = "wasm32")]
inner: Option<crate::wasm::WasmClient>,
}
impl SigSocketClient {
/// Create a new sigsocket client
///
/// # Arguments
/// * `url` - WebSocket server URL (e.g., "ws://localhost:8080/ws")
/// * `public_key` - Client's public key as bytes
///
/// # Returns
/// * `Ok(client)` - New client instance
/// * `Err(error)` - If the URL is invalid or public key is invalid
pub fn new(url: impl Into<String>, public_key: Vec<u8>) -> Result<Self> {
let url = url.into();
// Validate URL
let _ = url::Url::parse(&url)?;
// Validate public key (should be 33 bytes for compressed secp256k1)
if public_key.is_empty() {
return Err(SigSocketError::InvalidPublicKey("Public key cannot be empty".into()));
}
Ok(Self {
url,
public_key,
state: ConnectionState::Disconnected,
sign_handler: None,
pending_requests: HashMap::new(),
connected_public_key: None,
inner: None,
})
}
/// Set the sign request handler
///
/// This handler will be called whenever the server sends a signature request.
///
/// # Arguments
/// * `handler` - Implementation of SignRequestHandler trait
pub fn set_sign_handler<H>(&mut self, handler: H)
where
H: SignRequestHandler + 'static,
{
self.sign_handler = Some(Box::new(handler));
}
/// Get the current connection state
pub fn state(&self) -> ConnectionState {
self.state
}
/// Check if the client is connected
pub fn is_connected(&self) -> bool {
self.state == ConnectionState::Connected
}
/// Get the client's public key as hex string
pub fn public_key_hex(&self) -> String {
hex::encode(&self.public_key)
}
/// Get the WebSocket server URL
pub fn url(&self) -> &str {
&self.url
}
/// Get the connected public key (if connected)
pub fn connected_public_key(&self) -> Option<&str> {
self.connected_public_key.as_deref()
}
// === Request Management Methods ===
/// Add a pending sign request
///
/// This is typically called when a sign request is received from the server.
/// The request will be stored and can be retrieved later for processing.
///
/// # Arguments
/// * `request` - The sign request to add
/// * `target_public_key` - The public key this request is intended for
pub fn add_pending_request(&mut self, request: SignRequest, target_public_key: String) {
let managed_request = ManagedSignRequest::new(request, target_public_key);
self.pending_requests.insert(managed_request.id().to_string(), managed_request);
}
/// Remove a pending request by ID
///
/// # Arguments
/// * `request_id` - The ID of the request to remove
///
/// # Returns
/// * `Some(request)` - The removed request if it existed
/// * `None` - If no request with that ID was found
pub fn remove_pending_request(&mut self, request_id: &str) -> Option<ManagedSignRequest> {
self.pending_requests.remove(request_id)
}
/// Get a pending request by ID
///
/// # Arguments
/// * `request_id` - The ID of the request to retrieve
///
/// # Returns
/// * `Some(request)` - The request if it exists
/// * `None` - If no request with that ID was found
pub fn get_pending_request(&self, request_id: &str) -> Option<&ManagedSignRequest> {
self.pending_requests.get(request_id)
}
/// Get all pending requests
///
/// # Returns
/// * A reference to the HashMap containing all pending requests
pub fn get_pending_requests(&self) -> &HashMap<String, ManagedSignRequest> {
&self.pending_requests
}
/// Get pending requests filtered by public key
///
/// # Arguments
/// * `public_key` - The public key to filter by (hex-encoded)
///
/// # Returns
/// * A vector of references to requests for the specified public key
pub fn get_requests_for_public_key(&self, public_key: &str) -> Vec<&ManagedSignRequest> {
self.pending_requests
.values()
.filter(|req| req.is_for_public_key(public_key))
.collect()
}
/// Check if a request can be handled for the given public key
///
/// This performs protocol-level validation without cryptographic operations.
///
/// # Arguments
/// * `request` - The sign request to validate
/// * `public_key` - The public key to check against (hex-encoded)
///
/// # Returns
/// * `true` - If the request can be handled for this public key
/// * `false` - If the request cannot be handled
pub fn can_handle_request_for_key(&self, request: &SignRequest, public_key: &str) -> bool {
// Basic protocol validation
if request.id.is_empty() || request.message.is_empty() {
return false;
}
// Check if we can decode the message
if request.message_bytes().is_err() {
return false;
}
// For now, we assume any valid request can be handled for any public key
// More sophisticated validation can be added here
!public_key.is_empty()
}
/// Clear all pending requests
pub fn clear_pending_requests(&mut self) {
self.pending_requests.clear();
}
/// Get the count of pending requests
pub fn pending_request_count(&self) -> usize {
self.pending_requests.len()
}
}
// Platform-specific implementations will be added in separate modules
impl SigSocketClient {
/// Connect to the sigsocket server
///
/// This establishes a WebSocket connection and sends the introduction message
/// with the client's public key.
///
/// # Returns
/// * `Ok(())` - Successfully connected
/// * `Err(error)` - Connection failed
pub async fn connect(&mut self) -> Result<()> {
if self.state == ConnectionState::Connected {
return Err(SigSocketError::AlreadyConnected);
}
self.state = ConnectionState::Connecting;
#[cfg(not(target_arch = "wasm32"))]
{
let mut client = crate::native::NativeClient::new(&self.url, &self.public_key)?;
if let Some(handler) = self.sign_handler.take() {
client.set_sign_handler_boxed(handler);
}
client.connect().await?;
self.inner = Some(client);
}
#[cfg(target_arch = "wasm32")]
{
let mut client = crate::wasm::WasmClient::new(&self.url, &self.public_key)?;
if let Some(handler) = self.sign_handler.take() {
client.set_sign_handler_boxed(handler);
}
client.connect().await?;
self.inner = Some(client);
}
self.state = ConnectionState::Connected;
self.connected_public_key = Some(self.public_key_hex());
Ok(())
}
/// Disconnect from the sigsocket server
///
/// # Returns
/// * `Ok(())` - Successfully disconnected
/// * `Err(error)` - Disconnect failed
pub async fn disconnect(&mut self) -> Result<()> {
if let Some(inner) = &mut self.inner {
inner.disconnect().await?;
}
self.inner = None;
self.state = ConnectionState::Disconnected;
self.connected_public_key = None;
self.clear_pending_requests();
Ok(())
}
/// Send a sign response to the server
///
/// This is typically called after the user has approved a signature request
/// and the application has generated the signature.
///
/// # Arguments
/// * `response` - The sign response containing the signature
///
/// # Returns
/// * `Ok(())` - Response sent successfully
/// * `Err(error)` - Failed to send response
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
if !self.is_connected() {
return Err(SigSocketError::NotConnected);
}
if let Some(inner) = &self.inner {
inner.send_sign_response(response).await
} else {
Err(SigSocketError::NotConnected)
}
}
/// Send a response for a specific request ID with signature
///
/// This is a convenience method that creates a SignResponse and sends it.
///
/// # Arguments
/// * `request_id` - The ID of the request being responded to
/// * `message` - The original message (base64-encoded)
/// * `signature` - The signature (base64-encoded)
///
/// # Returns
/// * `Ok(())` - Response sent successfully
/// * `Err(error)` - Failed to send response
pub async fn send_response(&self, request_id: &str, message: &str, signature: &str) -> Result<()> {
let response = SignResponse::new(request_id, message, signature);
self.send_sign_response(&response).await
}
/// Send a rejection for a specific request ID
///
/// This sends an error response to indicate the request was rejected.
///
/// # Arguments
/// * `request_id` - The ID of the request being rejected
/// * `reason` - The reason for rejection
///
/// # Returns
/// * `Ok(())` - Rejection sent successfully
/// * `Err(error)` - Failed to send rejection
pub async fn send_rejection(&self, request_id: &str, _reason: &str) -> Result<()> {
// For now, we'll send an empty signature to indicate rejection
// This can be improved with a proper rejection protocol
let response = SignResponse::new(request_id, "", "");
self.send_sign_response(&response).await
}
}
impl Drop for SigSocketClient {
fn drop(&mut self) {
// Cleanup will be handled by the platform-specific implementations
}
}

View File

@@ -0,0 +1,168 @@
//! Error types for the sigsocket client
#[cfg(target_arch = "wasm32")]
use alloc::{string::{String, ToString}, format};
#[cfg(not(target_arch = "wasm32"))]
use thiserror::Error;
#[cfg(target_arch = "wasm32")]
use core::fmt;
/// Result type alias for sigsocket client operations
pub type Result<T> = core::result::Result<T, SigSocketError>;
/// Error types that can occur when using the sigsocket client
#[cfg(not(target_arch = "wasm32"))]
#[derive(Error, Debug)]
pub enum SigSocketError {
/// WebSocket connection error
#[error("Connection error: {0}")]
Connection(String),
/// WebSocket protocol error
#[error("Protocol error: {0}")]
Protocol(String),
/// Message serialization/deserialization error
#[error("Serialization error: {0}")]
Serialization(String),
/// Invalid public key format
#[error("Invalid public key: {0}")]
InvalidPublicKey(String),
/// Invalid URL format
#[error("Invalid URL: {0}")]
InvalidUrl(String),
/// Client is not connected
#[error("Client is not connected")]
NotConnected,
/// Client is already connected
#[error("Client is already connected")]
AlreadyConnected,
/// Timeout error
#[error("Operation timed out")]
Timeout,
/// Send error
#[error("Failed to send message: {0}")]
Send(String),
/// Receive error
#[error("Failed to receive message: {0}")]
Receive(String),
/// Base64 encoding/decoding error
#[error("Base64 error: {0}")]
Base64(String),
/// Hex encoding/decoding error
#[error("Hex error: {0}")]
Hex(String),
/// Generic error
#[error("Error: {0}")]
Other(String),
}
/// WASM version of error types (no thiserror dependency)
#[cfg(target_arch = "wasm32")]
#[derive(Debug)]
pub enum SigSocketError {
/// WebSocket connection error
Connection(String),
/// WebSocket protocol error
Protocol(String),
/// Message serialization/deserialization error
Serialization(String),
/// Invalid public key format
InvalidPublicKey(String),
/// Invalid URL format
InvalidUrl(String),
/// Client is not connected
NotConnected,
/// Client is already connected
AlreadyConnected,
/// Timeout error
Timeout,
/// Send error
Send(String),
/// Receive error
Receive(String),
/// Base64 encoding/decoding error
Base64(String),
/// Hex encoding/decoding error
Hex(String),
/// Generic error
Other(String),
}
#[cfg(target_arch = "wasm32")]
impl fmt::Display for SigSocketError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
SigSocketError::Connection(msg) => write!(f, "Connection error: {}", msg),
SigSocketError::Protocol(msg) => write!(f, "Protocol error: {}", msg),
SigSocketError::Serialization(msg) => write!(f, "Serialization error: {}", msg),
SigSocketError::InvalidPublicKey(msg) => write!(f, "Invalid public key: {}", msg),
SigSocketError::InvalidUrl(msg) => write!(f, "Invalid URL: {}", msg),
SigSocketError::NotConnected => write!(f, "Client is not connected"),
SigSocketError::AlreadyConnected => write!(f, "Client is already connected"),
SigSocketError::Timeout => write!(f, "Operation timed out"),
SigSocketError::Send(msg) => write!(f, "Failed to send message: {}", msg),
SigSocketError::Receive(msg) => write!(f, "Failed to receive message: {}", msg),
SigSocketError::Base64(msg) => write!(f, "Base64 error: {}", msg),
SigSocketError::Hex(msg) => write!(f, "Hex error: {}", msg),
SigSocketError::Other(msg) => write!(f, "Error: {}", msg),
}
}
}
// Implement From traits for common error types
impl From<serde_json::Error> for SigSocketError {
fn from(err: serde_json::Error) -> Self {
SigSocketError::Serialization(err.to_string())
}
}
impl From<base64::DecodeError> for SigSocketError {
fn from(err: base64::DecodeError) -> Self {
SigSocketError::Base64(err.to_string())
}
}
impl From<hex::FromHexError> for SigSocketError {
fn from(err: hex::FromHexError) -> Self {
SigSocketError::Hex(err.to_string())
}
}
impl From<url::ParseError> for SigSocketError {
fn from(err: url::ParseError) -> Self {
SigSocketError::InvalidUrl(err.to_string())
}
}
// Native-specific error conversions
#[cfg(not(target_arch = "wasm32"))]
mod native_errors {
use super::SigSocketError;
impl From<tokio_tungstenite::tungstenite::Error> for SigSocketError {
fn from(err: tokio_tungstenite::tungstenite::Error) -> Self {
SigSocketError::Connection(err.to_string())
}
}
}
// WASM-specific error conversions
#[cfg(target_arch = "wasm32")]
impl From<wasm_bindgen::JsValue> for SigSocketError {
fn from(err: wasm_bindgen::JsValue) -> Self {
SigSocketError::Other(format!("{:?}", err))
}
}

View File

@@ -0,0 +1,72 @@
//! # SigSocket Client
//!
//! A WebSocket client library for connecting to sigsocket servers with WASM-first support.
//!
//! This library provides a unified interface for both native and WASM environments,
//! allowing applications to connect to sigsocket servers using a public key and handle
//! incoming signature requests.
//!
//! ## Features
//!
//! - **WASM-first design**: Optimized for browser environments
//! - **Native support**: Works in native Rust applications
//! - **No signing logic**: Delegates signing to the application
//! - **User approval flow**: Notifies applications about incoming requests
//! - **sigsocket compatible**: Fully compatible with sigsocket server protocol
//!
//! ## Example
//!
//! ```rust,no_run
//! use sigsocket_client::{SigSocketClient, SignRequest, SignRequestHandler, Result};
//!
//! struct MyHandler;
//! impl SignRequestHandler for MyHandler {
//! fn handle_sign_request(&self, _request: &SignRequest) -> Result<Vec<u8>> {
//! Ok(b"fake_signature".to_vec())
//! }
//! }
//!
//! #[tokio::main]
//! async fn main() -> Result<()> {
//! // Create client with public key
//! let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
//! let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key)?;
//!
//! // Set up request handler
//! client.set_sign_handler(MyHandler);
//!
//! // Connect to server
//! client.connect().await?;
//! Ok(())
//! }
//! ```
#![cfg_attr(target_arch = "wasm32", no_std)]
#[cfg(target_arch = "wasm32")]
extern crate alloc;
#[cfg(target_arch = "wasm32")]
use alloc::{string::String, vec::Vec};
mod error;
mod protocol;
mod client;
#[cfg(not(target_arch = "wasm32"))]
mod native;
#[cfg(target_arch = "wasm32")]
mod wasm;
pub use error::{SigSocketError, Result};
pub use protocol::{SignRequest, SignResponse, ManagedSignRequest, RequestStatus};
pub use client::{SigSocketClient, SignRequestHandler, ConnectionState};
// Re-export for convenience
pub mod prelude {
pub use crate::{
SigSocketClient, SignRequest, SignResponse, ManagedSignRequest, RequestStatus,
SignRequestHandler, ConnectionState, SigSocketError, Result
};
}

View File

@@ -0,0 +1,232 @@
//! Native (non-WASM) implementation of the sigsocket client
use std::sync::Arc;
use tokio::sync::{mpsc, RwLock};
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{SinkExt, StreamExt};
use url::Url;
use crate::{SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
/// Native WebSocket client implementation
pub struct NativeClient {
url: String,
public_key: Vec<u8>,
sign_handler: Option<Arc<dyn SignRequestHandler>>,
sender: Option<mpsc::UnboundedSender<Message>>,
connected: Arc<RwLock<bool>>,
reconnect_attempts: u32,
max_reconnect_attempts: u32,
reconnect_delay_ms: u64,
}
impl NativeClient {
/// Create a new native client
pub fn new(url: &str, public_key: &[u8]) -> Result<Self> {
Ok(Self {
url: url.to_string(),
public_key: public_key.to_vec(),
sign_handler: None,
sender: None,
connected: Arc::new(RwLock::new(false)),
reconnect_attempts: 0,
max_reconnect_attempts: 5,
reconnect_delay_ms: 1000, // Start with 1 second
})
}
/// Set the sign request handler
pub fn set_sign_handler<H>(&mut self, handler: H)
where
H: SignRequestHandler + 'static,
{
self.sign_handler = Some(Arc::new(handler));
}
/// Set the sign request handler from a boxed trait object
pub fn set_sign_handler_boxed(&mut self, handler: Box<dyn SignRequestHandler>) {
self.sign_handler = Some(Arc::from(handler));
}
/// Connect to the WebSocket server with automatic reconnection
pub async fn connect(&mut self) -> Result<()> {
self.reconnect_attempts = 0;
self.connect_with_retry().await
}
/// Connect with retry logic
async fn connect_with_retry(&mut self) -> Result<()> {
loop {
match self.try_connect().await {
Ok(()) => {
self.reconnect_attempts = 0; // Reset on successful connection
return Ok(());
}
Err(e) => {
self.reconnect_attempts += 1;
if self.reconnect_attempts > self.max_reconnect_attempts {
log::error!("Max reconnection attempts ({}) exceeded", self.max_reconnect_attempts);
return Err(e);
}
let delay = self.reconnect_delay_ms * (2_u64.pow(self.reconnect_attempts - 1)); // Exponential backoff
log::warn!("Connection failed (attempt {}/{}), retrying in {}ms: {}",
self.reconnect_attempts, self.max_reconnect_attempts, delay, e);
tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
}
}
}
}
/// Single connection attempt
async fn try_connect(&mut self) -> Result<()> {
let url = Url::parse(&self.url)?;
// Connect to WebSocket
let (ws_stream, _) = connect_async(url).await
.map_err(|e| SigSocketError::Connection(e.to_string()))?;
let (mut write, mut read) = ws_stream.split();
// Send introduction message (hex-encoded public key)
let intro_message = hex::encode(&self.public_key);
write.send(Message::Text(intro_message)).await
.map_err(|e| SigSocketError::Send(e.to_string()))?;
// Set up message sender channel
let (tx, mut rx) = mpsc::unbounded_channel();
self.sender = Some(tx);
// Set connected state
*self.connected.write().await = true;
// Spawn write task
let write_task = tokio::spawn(async move {
while let Some(message) = rx.recv().await {
if let Err(e) = write.send(message).await {
log::error!("Failed to send message: {}", e);
break;
}
}
});
// Spawn read task
let connected = self.connected.clone();
let sign_handler = self.sign_handler.clone();
let sender = self.sender.as_ref().unwrap().clone();
let read_task = tokio::spawn(async move {
while let Some(message) = read.next().await {
match message {
Ok(Message::Text(text)) => {
if let Err(e) = Self::handle_text_message(&text, &sign_handler, &sender).await {
log::error!("Failed to handle message: {}", e);
}
}
Ok(Message::Close(_)) => {
log::info!("WebSocket connection closed");
break;
}
Err(e) => {
log::error!("WebSocket error: {}", e);
break;
}
_ => {
// Ignore other message types
}
}
}
// Mark as disconnected
*connected.write().await = false;
});
// Store tasks (in a real implementation, you'd want to manage these properly)
tokio::spawn(async move {
let _ = tokio::try_join!(write_task, read_task);
});
Ok(())
}
/// Handle incoming text messages
async fn handle_text_message(
text: &str,
sign_handler: &Option<Arc<dyn SignRequestHandler>>,
sender: &mpsc::UnboundedSender<Message>,
) -> Result<()> {
log::debug!("Received message: {}", text);
// Handle simple acknowledgment messages
if text == "Connected" {
log::info!("Server acknowledged connection");
return Ok(());
}
// Try to parse as sign request
if let Ok(sign_request) = serde_json::from_str::<SignRequest>(text) {
if let Some(handler) = sign_handler {
// Handle the sign request
match handler.handle_sign_request(&sign_request) {
Ok(signature) => {
// Create and send response
let response = SignResponse::from_request_and_signature(&sign_request, &signature);
let response_json = serde_json::to_string(&response)?;
sender.send(Message::Text(response_json))
.map_err(|e| SigSocketError::Send(e.to_string()))?;
log::info!("Sent signature response for request {}", response.id);
}
Err(e) => {
log::warn!("Sign request rejected: {}", e);
// Optionally send an error response to the server
}
}
} else {
log::warn!("No sign request handler registered, ignoring request");
}
return Ok(());
}
log::warn!("Failed to parse message: {}", text);
Ok(())
}
/// Disconnect from the WebSocket server
pub async fn disconnect(&mut self) -> Result<()> {
*self.connected.write().await = false;
if let Some(sender) = &self.sender {
// Send close message
let _ = sender.send(Message::Close(None));
}
self.sender = None;
Ok(())
}
/// Send a sign response to the server
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
if let Some(sender) = &self.sender {
let response_json = serde_json::to_string(response)?;
sender.send(Message::Text(response_json))
.map_err(|e| SigSocketError::Send(e.to_string()))?;
Ok(())
} else {
Err(SigSocketError::NotConnected)
}
}
/// Check if connected
pub async fn is_connected(&self) -> bool {
*self.connected.read().await
}
}
impl Drop for NativeClient {
fn drop(&mut self) {
// Cleanup will be handled by the async tasks
}
}

View File

@@ -0,0 +1,256 @@
//! Protocol definitions for sigsocket communication
#[cfg(target_arch = "wasm32")]
use alloc::{string::String, vec::Vec};
use serde::{Deserialize, Serialize};
/// Sign request from the sigsocket server
///
/// This represents a request from the server for the client to sign a message.
/// The client should present this to the user for approval before signing.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SignRequest {
/// Unique identifier for this request
pub id: String,
/// Message to be signed (base64-encoded)
pub message: String,
}
/// Sign response to send back to the sigsocket server
///
/// This represents the client's response after the user has approved and signed the message.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SignResponse {
/// Request identifier (must match the original request)
pub id: String,
/// Original message that was signed (base64-encoded)
pub message: String,
/// Signature of the message (base64-encoded)
pub signature: String,
}
impl SignRequest {
/// Create a new sign request
pub fn new(id: impl Into<String>, message: impl Into<String>) -> Self {
Self {
id: id.into(),
message: message.into(),
}
}
/// Get the message as bytes (decoded from base64)
pub fn message_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &self.message)
}
/// Get the message as a hex string (for display purposes)
pub fn message_hex(&self) -> Result<String, base64::DecodeError> {
self.message_bytes().map(|bytes| hex::encode(bytes))
}
}
impl SignResponse {
/// Create a new sign response
pub fn new(
id: impl Into<String>,
message: impl Into<String>,
signature: impl Into<String>,
) -> Self {
Self {
id: id.into(),
message: message.into(),
signature: signature.into(),
}
}
/// Create a sign response from a request and signature bytes
pub fn from_request_and_signature(
request: &SignRequest,
signature: &[u8],
) -> Self {
Self {
id: request.id.clone(),
message: request.message.clone(),
signature: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, signature),
}
}
/// Get the signature as bytes (decoded from base64)
pub fn signature_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, &self.signature)
}
}
/// Enhanced sign request with additional metadata for request management
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ManagedSignRequest {
/// The original sign request
#[serde(flatten)]
pub request: SignRequest,
/// Timestamp when the request was received (Unix timestamp in milliseconds)
pub timestamp: u64,
/// Target public key for this request (hex-encoded)
pub target_public_key: String,
/// Current status of the request
pub status: RequestStatus,
}
/// Status of a sign request
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum RequestStatus {
/// Request is pending user approval
Pending,
/// Request has been approved and signed
Approved,
/// Request has been rejected by user
Rejected,
/// Request has expired or been cancelled
Cancelled,
}
impl ManagedSignRequest {
/// Create a new managed sign request
pub fn new(request: SignRequest, target_public_key: String) -> Self {
Self {
request,
timestamp: current_timestamp_ms(),
target_public_key,
status: RequestStatus::Pending,
}
}
/// Get the request ID
pub fn id(&self) -> &str {
&self.request.id
}
/// Get the message as bytes (decoded from base64)
pub fn message_bytes(&self) -> Result<Vec<u8>, base64::DecodeError> {
self.request.message_bytes()
}
/// Check if this request is for the given public key
pub fn is_for_public_key(&self, public_key: &str) -> bool {
self.target_public_key == public_key
}
/// Mark the request as approved
pub fn mark_approved(&mut self) {
self.status = RequestStatus::Approved;
}
/// Mark the request as rejected
pub fn mark_rejected(&mut self) {
self.status = RequestStatus::Rejected;
}
/// Check if the request is still pending
pub fn is_pending(&self) -> bool {
matches!(self.status, RequestStatus::Pending)
}
}
/// Get current timestamp in milliseconds
#[cfg(not(target_arch = "wasm32"))]
fn current_timestamp_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
/// Get current timestamp in milliseconds (WASM version)
#[cfg(target_arch = "wasm32")]
fn current_timestamp_ms() -> u64 {
// In WASM, we'll use a simple counter or Date.now() via JS
// For now, return 0 - this can be improved later
0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sign_request_creation() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
assert_eq!(request.id, "test-id");
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
}
#[test]
fn test_sign_request_message_bytes() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
let bytes = request.message_bytes().unwrap();
assert_eq!(bytes, b"test message");
}
#[test]
fn test_sign_request_message_hex() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl"); // "test message" in base64
let hex = request.message_hex().unwrap();
assert_eq!(hex, hex::encode(b"test message"));
}
#[test]
fn test_sign_response_creation() {
let response = SignResponse::new("test-id", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl"); // "signature" in base64
assert_eq!(response.id, "test-id");
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
assert_eq!(response.signature, "c2lnbmF0dXJl");
}
#[test]
fn test_sign_response_from_request() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
let signature = b"signature";
let response = SignResponse::from_request_and_signature(&request, signature);
assert_eq!(response.id, request.id);
assert_eq!(response.message, request.message);
assert_eq!(response.signature_bytes().unwrap(), signature);
}
#[test]
fn test_serialization() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
let json = serde_json::to_string(&request).unwrap();
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
let response = SignResponse::new("test-id", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
let json = serde_json::to_string(&response).unwrap();
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
#[test]
fn test_managed_sign_request() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
let managed = ManagedSignRequest::new(request.clone(), "test-public-key".to_string());
assert_eq!(managed.id(), "test-id");
assert_eq!(managed.request, request);
assert_eq!(managed.target_public_key, "test-public-key");
assert!(managed.is_pending());
assert!(managed.is_for_public_key("test-public-key"));
assert!(!managed.is_for_public_key("other-key"));
}
#[test]
fn test_managed_request_status_changes() {
let request = SignRequest::new("test-id", "dGVzdCBtZXNzYWdl");
let mut managed = ManagedSignRequest::new(request, "test-public-key".to_string());
assert!(managed.is_pending());
managed.mark_approved();
assert_eq!(managed.status, RequestStatus::Approved);
assert!(!managed.is_pending());
managed.mark_rejected();
assert_eq!(managed.status, RequestStatus::Rejected);
assert!(!managed.is_pending());
}
}

View File

@@ -0,0 +1,549 @@
//! WASM implementation of the sigsocket client
use alloc::{string::{String, ToString}, vec::Vec, boxed::Box, rc::Rc, format};
use core::cell::RefCell;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use web_sys::{WebSocket, MessageEvent, Event, BinaryType};
use crate::{SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
/// WASM WebSocket client implementation
pub struct WasmClient {
url: String,
public_key: Vec<u8>,
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
websocket: Option<WebSocket>,
connected: Rc<RefCell<bool>>,
reconnect_attempts: Rc<RefCell<u32>>,
max_reconnect_attempts: u32,
reconnect_delay_ms: u64,
auto_reconnect: bool,
}
impl WasmClient {
/// Create a new WASM client
pub fn new(url: &str, public_key: &[u8]) -> Result<Self> {
Ok(Self {
url: url.to_string(),
public_key: public_key.to_vec(),
sign_handler: None,
websocket: None,
connected: Rc::new(RefCell::new(false)),
reconnect_attempts: Rc::new(RefCell::new(0)),
max_reconnect_attempts: 5,
reconnect_delay_ms: 1000, // Start with 1 second
auto_reconnect: false, // Disable auto-reconnect to avoid multiple connections
})
}
/// Set the sign request handler from a boxed trait object
pub fn set_sign_handler_boxed(&mut self, handler: Box<dyn SignRequestHandler>) {
self.sign_handler = Some(Rc::new(RefCell::new(handler)));
}
/// Enable or disable automatic reconnection
pub fn set_auto_reconnect(&mut self, enabled: bool) {
self.auto_reconnect = enabled;
}
/// Set reconnection parameters
pub fn set_reconnect_config(&mut self, max_attempts: u32, initial_delay_ms: u64) {
self.max_reconnect_attempts = max_attempts;
self.reconnect_delay_ms = initial_delay_ms;
}
/// Connect to the WebSocket server with automatic reconnection
pub async fn connect(&mut self) -> Result<()> {
*self.reconnect_attempts.borrow_mut() = 0;
self.connect_with_retry().await
}
/// Connect with retry logic
async fn connect_with_retry(&mut self) -> Result<()> {
loop {
match self.try_connect().await {
Ok(()) => {
*self.reconnect_attempts.borrow_mut() = 0; // Reset on successful connection
return Ok(());
}
Err(e) => {
let mut attempts = self.reconnect_attempts.borrow_mut();
*attempts += 1;
if *attempts > self.max_reconnect_attempts {
web_sys::console::error_1(&format!("Max reconnection attempts ({}) exceeded", self.max_reconnect_attempts).into());
return Err(e);
}
let delay = self.reconnect_delay_ms * (2_u64.pow(*attempts - 1)); // Exponential backoff
web_sys::console::warn_1(&format!("Connection failed (attempt {}/{}), retrying in {}ms: {}",
*attempts, self.max_reconnect_attempts, delay, e).into());
// Drop the borrow before the async sleep
drop(attempts);
// Wait before retrying
self.sleep_ms(delay).await;
}
}
}
}
/// Sleep for the specified number of milliseconds (WASM-compatible)
async fn sleep_ms(&self, ms: u64) -> () {
use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;
let promise = Promise::new(&mut |resolve, _reject| {
let timeout_callback = Closure::wrap(Box::new(move || {
resolve.call0(&wasm_bindgen::JsValue::UNDEFINED).unwrap();
}) as Box<dyn FnMut()>);
web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_callback.as_ref().unchecked_ref(),
ms as i32,
)
.unwrap();
timeout_callback.forget();
});
let _ = JsFuture::from(promise).await;
}
/// Single connection attempt
async fn try_connect(&mut self) -> Result<()> {
use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;
web_sys::console::log_1(&format!("try_connect: Creating WebSocket to {}", self.url).into());
// Create WebSocket
let ws = WebSocket::new(&self.url)
.map_err(|e| {
web_sys::console::error_1(&format!("Failed to create WebSocket: {:?}", e).into());
SigSocketError::Connection(format!("{:?}", e))
})?;
web_sys::console::log_1(&"try_connect: WebSocket created successfully".into());
// Set binary type
ws.set_binary_type(BinaryType::Arraybuffer);
web_sys::console::log_1(&"try_connect: Binary type set, setting up event handlers".into());
let connected = self.connected.clone();
let public_key = self.public_key.clone();
// Set up onopen handler
{
let ws_clone = ws.clone();
let public_key_clone = public_key.clone();
let onopen_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
web_sys::console::log_1(&"MAIN CONNECTION: WebSocket opened, sending public key introduction".into());
// Send introduction message (hex-encoded public key)
let intro_message = hex::encode(&public_key_clone);
web_sys::console::log_1(&format!("MAIN CONNECTION: Sending public key: {}", &intro_message[..16]).into());
if let Err(e) = ws_clone.send_with_str(&intro_message) {
web_sys::console::error_1(&format!("MAIN CONNECTION: Failed to send introduction: {:?}", e).into());
} else {
web_sys::console::log_1(&"MAIN CONNECTION: Public key sent successfully".into());
}
});
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
onopen_callback.forget(); // Prevent cleanup
web_sys::console::log_1(&"try_connect: onopen handler set up".into());
}
// Set up onmessage handler
{
let ws_clone = ws.clone();
let handler_clone = self.sign_handler.clone();
let connected_clone = connected.clone();
let onmessage_callback = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
let message = text.as_string().unwrap_or_default();
web_sys::console::log_1(&format!("MAIN CONNECTION: Received message: {}", message).into());
// Check if this is the "Connected" acknowledgment
if message == "Connected" {
web_sys::console::log_1(&"MAIN CONNECTION: Server acknowledged connection".into());
*connected_clone.borrow_mut() = true;
}
// Handle the message with proper sign request support
Self::handle_message(&message, &ws_clone, &handler_clone, &connected_clone);
}
});
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
onmessage_callback.forget(); // Prevent cleanup
web_sys::console::log_1(&"try_connect: onmessage handler set up".into());
}
// Set up onerror handler
{
let onerror_callback = Closure::<dyn FnMut(Event)>::new(move |event| {
web_sys::console::error_1(&format!("MAIN CONNECTION: WebSocket error: {:?}", event).into());
});
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
onerror_callback.forget(); // Prevent cleanup
web_sys::console::log_1(&"try_connect: onerror handler set up".into());
}
// Set up onclose handler with auto-reconnection support
{
let connected = connected.clone();
let auto_reconnect = self.auto_reconnect;
let reconnect_attempts = self.reconnect_attempts.clone();
let max_attempts = self.max_reconnect_attempts;
let url = self.url.clone();
let public_key = self.public_key.clone();
let sign_handler = self.sign_handler.clone();
let delay_ms = self.reconnect_delay_ms;
let onclose_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
*connected.borrow_mut() = false;
web_sys::console::log_1(&"WebSocket connection closed".into());
// Trigger auto-reconnection if enabled
if auto_reconnect {
let attempts = reconnect_attempts.clone();
let current_attempts = *attempts.borrow();
if current_attempts < max_attempts {
web_sys::console::log_1(&"Attempting automatic reconnection...".into());
// Schedule reconnection attempt
Self::schedule_reconnection(
url.clone(),
public_key.clone(),
sign_handler.clone(),
attempts.clone(),
max_attempts,
delay_ms,
connected.clone(),
);
} else {
web_sys::console::error_1(&format!("Max reconnection attempts ({}) reached, giving up", max_attempts).into());
}
}
});
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
onclose_callback.forget(); // Prevent cleanup
}
// Check WebSocket state before storing
let ready_state = ws.ready_state();
web_sys::console::log_1(&format!("try_connect: WebSocket ready state: {}", ready_state).into());
self.websocket = Some(ws);
web_sys::console::log_1(&"try_connect: WebSocket stored, waiting for connection to be established".into());
// The WebSocket will open asynchronously and the onopen/onmessage handlers will handle the connection
// Since we can see from logs that the connection is working, just return success
web_sys::console::log_1(&"try_connect: WebSocket setup complete, connection will be established asynchronously".into());
Ok(())
}
/// Wait for WebSocket connection to be established
async fn wait_for_connection(&self) -> Result<()> {
use wasm_bindgen_futures::JsFuture;
use js_sys::Promise;
web_sys::console::log_1(&"wait_for_connection: Starting to wait for connection".into());
// Simple approach: just wait a bit and check if we're connected
// The onopen handler should have fired by now if the connection is working
let connected = self.connected.clone();
// Wait up to 30 seconds, checking every 500ms
for attempt in 1..=60 {
// Check if we're connected
if *connected.borrow() {
web_sys::console::log_1(&format!("wait_for_connection: Connected after {} attempts ({}ms)", attempt, attempt * 500).into());
return Ok(());
}
// Wait 500ms before next check
let promise = Promise::new(&mut |resolve, _reject| {
let timeout_callback = Closure::wrap(Box::new(move || {
resolve.call0(&wasm_bindgen::JsValue::UNDEFINED).unwrap();
}) as Box<dyn FnMut()>);
web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_callback.as_ref().unchecked_ref(),
500,
)
.unwrap();
timeout_callback.forget();
});
let _ = JsFuture::from(promise).await;
if attempt % 10 == 0 {
web_sys::console::log_1(&format!("wait_for_connection: Still waiting... attempt {}/60", attempt).into());
}
}
web_sys::console::error_1(&"wait_for_connection: Timeout after 30 seconds".into());
Err(SigSocketError::Connection("Connection timeout".to_string()))
}
/// Schedule a reconnection attempt (called from onclose handler)
fn schedule_reconnection(
url: String,
public_key: Vec<u8>,
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
reconnect_attempts: Rc<RefCell<u32>>,
_max_attempts: u32,
delay_ms: u64,
connected: Rc<RefCell<bool>>,
) {
let mut attempts = reconnect_attempts.borrow_mut();
*attempts += 1;
let current_attempt = *attempts;
drop(attempts); // Release the borrow
let delay = delay_ms * (2_u64.pow(current_attempt - 1)); // Exponential backoff
web_sys::console::log_1(&format!("Scheduling reconnection attempt {} in {}ms", current_attempt, delay).into());
// Schedule the reconnection attempt
let timeout_callback = Closure::wrap(Box::new(move || {
// Create a new client instance for reconnection
match Self::attempt_reconnection(url.clone(), public_key.clone(), sign_handler.clone(), connected.clone()) {
Ok(_) => {
web_sys::console::log_1(&"Reconnection attempt initiated".into());
}
Err(e) => {
web_sys::console::error_1(&format!("Failed to initiate reconnection: {:?}", e).into());
}
}
}) as Box<dyn FnMut()>);
web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(
timeout_callback.as_ref().unchecked_ref(),
delay as i32,
)
.unwrap();
timeout_callback.forget();
}
/// Attempt to reconnect (helper method)
fn attempt_reconnection(
url: String,
public_key: Vec<u8>,
sign_handler: Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
connected: Rc<RefCell<bool>>,
) -> Result<()> {
// Create WebSocket
let ws = WebSocket::new(&url)
.map_err(|e| SigSocketError::Connection(format!("{:?}", e)))?;
ws.set_binary_type(BinaryType::Arraybuffer);
// Send public key on open
{
let public_key_clone = public_key.clone();
let connected_clone = connected.clone();
let ws_clone = ws.clone();
let onopen_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
web_sys::console::log_1(&"Reconnection WebSocket opened, sending public key introduction".into());
// Send public key introduction
let public_key_hex = hex::encode(&public_key_clone);
web_sys::console::log_1(&format!("Reconnection sending public key: {}", &public_key_hex[..16]).into());
if let Err(e) = ws_clone.send_with_str(&public_key_hex) {
web_sys::console::error_1(&format!("Failed to send public key on reconnection: {:?}", e).into());
} else {
web_sys::console::log_1(&"Reconnection public key sent successfully, waiting for server acknowledgment".into());
// Don't set connected=true here, wait for "Connected" message
}
});
ws.set_onopen(Some(onopen_callback.as_ref().unchecked_ref()));
onopen_callback.forget();
}
// Set up message handler for reconnected socket
{
let ws_clone = ws.clone();
let handler_clone = sign_handler.clone();
let connected_clone = connected.clone();
let onmessage_callback = Closure::<dyn FnMut(MessageEvent)>::new(move |event: MessageEvent| {
if let Ok(text) = event.data().dyn_into::<js_sys::JsString>() {
let message = text.as_string().unwrap_or_default();
Self::handle_message(&message, &ws_clone, &handler_clone, &connected_clone);
}
});
ws.set_onmessage(Some(onmessage_callback.as_ref().unchecked_ref()));
onmessage_callback.forget();
}
// Set up error handler
{
let onerror_callback = Closure::<dyn FnMut(Event)>::new(move |event| {
web_sys::console::error_1(&format!("Reconnection WebSocket error: {:?}", event).into());
});
ws.set_onerror(Some(onerror_callback.as_ref().unchecked_ref()));
onerror_callback.forget();
}
// Set up close handler (for potential future reconnections)
{
let connected_clone = connected.clone();
let onclose_callback = Closure::<dyn FnMut(Event)>::new(move |_event| {
*connected_clone.borrow_mut() = false;
web_sys::console::log_1(&"Reconnected WebSocket closed".into());
});
ws.set_onclose(Some(onclose_callback.as_ref().unchecked_ref()));
onclose_callback.forget();
}
Ok(())
}
/// Handle incoming messages with full sign request support
fn handle_message(
text: &str,
ws: &WebSocket,
sign_handler: &Option<Rc<RefCell<Box<dyn SignRequestHandler>>>>,
connected: &Rc<RefCell<bool>>
) {
web_sys::console::log_1(&format!("Received message: {}", text).into());
// Handle simple acknowledgment messages
if text == "Connected" {
web_sys::console::log_1(&"Server acknowledged connection".into());
*connected.borrow_mut() = true;
web_sys::console::log_1(&"Connection state updated to connected".into());
return;
}
// Try to parse as sign request
if let Ok(sign_request) = serde_json::from_str::<SignRequest>(text) {
web_sys::console::log_1(&format!("Received sign request: {}", sign_request.id).into());
// Handle the sign request if we have a handler
if let Some(handler_rc) = sign_handler {
match handler_rc.try_borrow() {
Ok(handler) => {
match handler.handle_sign_request(&sign_request) {
Ok(signature) => {
// Create and send response
let response = SignResponse::from_request_and_signature(&sign_request, &signature);
match serde_json::to_string(&response) {
Ok(response_json) => {
if let Err(e) = ws.send_with_str(&response_json) {
web_sys::console::error_1(&format!("Failed to send response: {:?}", e).into());
} else {
web_sys::console::log_1(&format!("Sent signature response for request {}", response.id).into());
}
}
Err(e) => {
web_sys::console::error_1(&format!("Failed to serialize response: {}", e).into());
}
}
}
Err(e) => {
web_sys::console::warn_1(&format!("Sign request rejected: {}", e).into());
// Optionally send an error response to the server
}
}
}
Err(_) => {
web_sys::console::error_1(&"Failed to borrow sign handler".into());
}
}
} else {
web_sys::console::warn_1(&"No sign request handler registered, ignoring request".into());
}
return;
}
web_sys::console::warn_1(&format!("Failed to parse message: {}", text).into());
}
/// Disconnect from the WebSocket server
pub async fn disconnect(&mut self) -> Result<()> {
if let Some(ws) = &self.websocket {
ws.close()
.map_err(|e| SigSocketError::Connection(format!("{:?}", e)))?;
}
*self.connected.borrow_mut() = false;
self.websocket = None;
Ok(())
}
/// Send a sign response to the server
pub async fn send_sign_response(&self, response: &SignResponse) -> Result<()> {
if let Some(ws) = &self.websocket {
let response_json = serde_json::to_string(response)?;
ws.send_with_str(&response_json)
.map_err(|e| SigSocketError::Send(format!("{:?}", e)))?;
Ok(())
} else {
Err(SigSocketError::NotConnected)
}
}
/// Check if connected
pub fn is_connected(&self) -> bool {
*self.connected.borrow()
}
}
impl Drop for WasmClient {
fn drop(&mut self) {
// Close WebSocket connection if it exists
if let Some(ws) = self.websocket.take() {
ws.close().unwrap_or_else(|e| {
web_sys::console::warn_1(&format!("Failed to close WebSocket: {:?}", e).into());
});
web_sys::console::log_1(&"🔌 WebSocket connection closed on drop".into());
}
}
}
// WASM-specific utilities
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
// Helper macro for logging in WASM
#[allow(unused_macros)]
macro_rules! console_log {
($($t:tt)*) => (log(&format_args!($($t)*).to_string()))
}

View File

@@ -0,0 +1,162 @@
//! Integration tests for sigsocket_client
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
/// Test sign request handler
struct TestSignHandler {
should_approve: bool,
}
impl TestSignHandler {
fn new(should_approve: bool) -> Self {
Self { should_approve }
}
}
impl SignRequestHandler for TestSignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
if self.should_approve {
// Create a test signature
let signature = format!("test_signature_for_{}", request.id);
Ok(signature.into_bytes())
} else {
Err(SigSocketError::Other("User rejected request".to_string()))
}
}
}
#[test]
fn test_sign_request_creation() {
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl");
assert_eq!(request.id, "test-123");
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
}
#[test]
fn test_sign_request_message_decoding() {
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl"); // "test message" in base64
let bytes = request.message_bytes().unwrap();
assert_eq!(bytes, b"test message");
let hex = request.message_hex().unwrap();
assert_eq!(hex, hex::encode(b"test message"));
}
#[test]
fn test_sign_response_creation() {
let response = SignResponse::new("test-123", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
assert_eq!(response.id, "test-123");
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
assert_eq!(response.signature, "c2lnbmF0dXJl");
}
#[test]
fn test_sign_response_from_request() {
let request = SignRequest::new("test-123", "dGVzdCBtZXNzYWdl");
let signature = b"test_signature";
let response = SignResponse::from_request_and_signature(&request, signature);
assert_eq!(response.id, request.id);
assert_eq!(response.message, request.message);
assert_eq!(response.signature_bytes().unwrap(), signature);
}
#[test]
fn test_protocol_serialization() {
// Test SignRequest serialization
let request = SignRequest::new("req-456", "SGVsbG8gV29ybGQ="); // "Hello World" in base64
let json = serde_json::to_string(&request).unwrap();
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
// Test SignResponse serialization
let response = SignResponse::new("req-456", "SGVsbG8gV29ybGQ=", "c2lnbmF0dXJlXzEyMw==");
let json = serde_json::to_string(&response).unwrap();
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
#[test]
fn test_client_creation() {
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
.unwrap();
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key.clone()).unwrap();
assert_eq!(client.url(), "ws://localhost:8080/ws");
assert_eq!(client.public_key_hex(), hex::encode(&public_key));
assert!(!client.is_connected());
}
#[test]
fn test_client_invalid_url() {
let public_key = vec![1, 2, 3];
let result = SigSocketClient::new("invalid-url", public_key);
assert!(result.is_err());
}
#[test]
fn test_client_empty_public_key() {
let result = SigSocketClient::new("ws://localhost:8080/ws", vec![]);
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, SigSocketError::InvalidPublicKey(_)));
}
}
#[test]
fn test_sign_handler_approval() {
let handler = TestSignHandler::new(true);
let request = SignRequest::new("test-789", "dGVzdA==");
let result = handler.handle_sign_request(&request);
assert!(result.is_ok());
let signature = result.unwrap();
assert_eq!(signature, b"test_signature_for_test-789");
}
#[test]
fn test_sign_handler_rejection() {
let handler = TestSignHandler::new(false);
let request = SignRequest::new("test-789", "dGVzdA==");
let result = handler.handle_sign_request(&request);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SigSocketError::Other(_)));
}
#[test]
fn test_error_display() {
let error = SigSocketError::NotConnected;
assert_eq!(error.to_string(), "Client is not connected");
let error = SigSocketError::Connection("test error".to_string());
assert_eq!(error.to_string(), "Connection error: test error");
}
// Test that demonstrates the expected usage pattern
#[test]
fn test_usage_pattern() {
// 1. Create client
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
.unwrap();
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
// 2. Set handler
client.set_sign_handler(TestSignHandler::new(true));
// 3. Verify state
assert!(!client.is_connected());
// 4. Create a test request/response cycle
let request = SignRequest::new("test-request", "dGVzdCBtZXNzYWdl");
let handler = TestSignHandler::new(true);
let signature = handler.handle_sign_request(&request).unwrap();
let response = SignResponse::from_request_and_signature(&request, &signature);
// 5. Verify the response
assert_eq!(response.id, request.id);
assert_eq!(response.message, request.message);
assert_eq!(response.signature_bytes().unwrap(), signature);
}

View File

@@ -0,0 +1,92 @@
//! Tests for the enhanced request management functionality
use sigsocket_client::prelude::*;
#[test]
fn test_client_request_management() {
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
// Initially no requests
assert_eq!(client.pending_request_count(), 0);
assert!(client.get_pending_requests().is_empty());
// Add a request
let request = SignRequest::new("test-1", "dGVzdCBtZXNzYWdl");
let public_key_hex = "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
client.add_pending_request(request.clone(), public_key_hex.to_string());
// Check request was added
assert_eq!(client.pending_request_count(), 1);
assert!(client.get_pending_request("test-1").is_some());
// Check filtering by public key
let filtered = client.get_requests_for_public_key(public_key_hex);
assert_eq!(filtered.len(), 1);
assert_eq!(filtered[0].id(), "test-1");
// Add another request for different public key
let request2 = SignRequest::new("test-2", "dGVzdCBtZXNzYWdlMg==");
let other_public_key = "03f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9";
client.add_pending_request(request2, other_public_key.to_string());
// Check total count
assert_eq!(client.pending_request_count(), 2);
// Check filtering still works
let filtered = client.get_requests_for_public_key(public_key_hex);
assert_eq!(filtered.len(), 1);
let filtered_other = client.get_requests_for_public_key(other_public_key);
assert_eq!(filtered_other.len(), 1);
// Remove a request
let removed = client.remove_pending_request("test-1");
assert!(removed.is_some());
assert_eq!(removed.unwrap().id(), "test-1");
assert_eq!(client.pending_request_count(), 1);
// Clear all requests
client.clear_pending_requests();
assert_eq!(client.pending_request_count(), 0);
}
#[test]
fn test_client_request_validation() {
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
// Valid request
let valid_request = SignRequest::new("test-1", "dGVzdCBtZXNzYWdl");
assert!(client.can_handle_request_for_key(&valid_request, "some-public-key"));
// Invalid request - empty ID
let invalid_request = SignRequest::new("", "dGVzdCBtZXNzYWdl");
assert!(!client.can_handle_request_for_key(&invalid_request, "some-public-key"));
// Invalid request - empty message
let invalid_request2 = SignRequest::new("test-1", "");
assert!(!client.can_handle_request_for_key(&invalid_request2, "some-public-key"));
// Invalid request - invalid base64
let invalid_request3 = SignRequest::new("test-1", "invalid-base64!");
assert!(!client.can_handle_request_for_key(&invalid_request3, "some-public-key"));
// Invalid public key
assert!(!client.can_handle_request_for_key(&valid_request, ""));
}
#[test]
fn test_client_connection_state() {
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9").unwrap();
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
// Initially disconnected
assert_eq!(client.state(), ConnectionState::Disconnected);
assert!(!client.is_connected());
assert!(client.connected_public_key().is_none());
// Public key should be available
assert_eq!(client.public_key_hex(), "02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9");
assert_eq!(client.url(), "ws://localhost:8080/ws");
}

View File

@@ -0,0 +1,181 @@
#![cfg(target_arch = "wasm32")]
//! WASM/browser tests for sigsocket_client using wasm-bindgen-test
use wasm_bindgen_test::*;
use sigsocket_client::{SigSocketClient, SignRequest, SignResponse, SignRequestHandler, Result, SigSocketError};
wasm_bindgen_test_configure!(run_in_browser);
/// Test sign request handler for WASM tests
struct TestWasmSignHandler {
should_approve: bool,
}
impl TestWasmSignHandler {
fn new(should_approve: bool) -> Self {
Self { should_approve }
}
}
impl SignRequestHandler for TestWasmSignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
if self.should_approve {
// Create a test signature
let signature = format!("wasm_test_signature_for_{}", request.id);
Ok(signature.into_bytes())
} else {
Err(SigSocketError::Other("User rejected request in WASM test".to_string()))
}
}
}
#[wasm_bindgen_test]
fn test_sign_request_creation_wasm() {
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl");
assert_eq!(request.id, "wasm-test-123");
assert_eq!(request.message, "dGVzdCBtZXNzYWdl");
}
#[wasm_bindgen_test]
fn test_sign_request_message_decoding_wasm() {
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl"); // "test message" in base64
let bytes = request.message_bytes().unwrap();
assert_eq!(bytes, b"test message");
let hex = request.message_hex().unwrap();
assert_eq!(hex, hex::encode(b"test message"));
}
#[wasm_bindgen_test]
fn test_sign_response_creation_wasm() {
let response = SignResponse::new("wasm-test-123", "dGVzdCBtZXNzYWdl", "c2lnbmF0dXJl");
assert_eq!(response.id, "wasm-test-123");
assert_eq!(response.message, "dGVzdCBtZXNzYWdl");
assert_eq!(response.signature, "c2lnbmF0dXJl");
}
#[wasm_bindgen_test]
fn test_sign_response_from_request_wasm() {
let request = SignRequest::new("wasm-test-123", "dGVzdCBtZXNzYWdl");
let signature = b"wasm_test_signature";
let response = SignResponse::from_request_and_signature(&request, signature);
assert_eq!(response.id, request.id);
assert_eq!(response.message, request.message);
assert_eq!(response.signature_bytes().unwrap(), signature);
}
#[wasm_bindgen_test]
fn test_protocol_serialization_wasm() {
// Test SignRequest serialization
let request = SignRequest::new("wasm-req-456", "SGVsbG8gV29ybGQ="); // "Hello World" in base64
let json = serde_json::to_string(&request).unwrap();
let deserialized: SignRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request, deserialized);
// Test SignResponse serialization
let response = SignResponse::new("wasm-req-456", "SGVsbG8gV29ybGQ=", "c2lnbmF0dXJlXzEyMw==");
let json = serde_json::to_string(&response).unwrap();
let deserialized: SignResponse = serde_json::from_str(&json).unwrap();
assert_eq!(response, deserialized);
}
#[wasm_bindgen_test]
fn test_client_creation_wasm() {
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
.unwrap();
let client = SigSocketClient::new("ws://localhost:8080/ws", public_key.clone()).unwrap();
assert_eq!(client.url(), "ws://localhost:8080/ws");
assert_eq!(client.public_key_hex(), hex::encode(&public_key));
assert!(!client.is_connected());
}
#[wasm_bindgen_test]
fn test_client_invalid_url_wasm() {
let public_key = vec![1, 2, 3];
let result = SigSocketClient::new("invalid-url", public_key);
assert!(result.is_err());
}
#[wasm_bindgen_test]
fn test_client_empty_public_key_wasm() {
let result = SigSocketClient::new("ws://localhost:8080/ws", vec![]);
assert!(result.is_err());
if let Err(error) = result {
assert!(matches!(error, SigSocketError::InvalidPublicKey(_)));
}
}
#[wasm_bindgen_test]
fn test_sign_handler_approval_wasm() {
let handler = TestWasmSignHandler::new(true);
let request = SignRequest::new("wasm-test-789", "dGVzdA==");
let result = handler.handle_sign_request(&request);
assert!(result.is_ok());
let signature = result.unwrap();
assert_eq!(signature, b"wasm_test_signature_for_wasm-test-789");
}
#[wasm_bindgen_test]
fn test_sign_handler_rejection_wasm() {
let handler = TestWasmSignHandler::new(false);
let request = SignRequest::new("wasm-test-789", "dGVzdA==");
let result = handler.handle_sign_request(&request);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), SigSocketError::Other(_)));
}
#[wasm_bindgen_test]
fn test_error_display_wasm() {
let error = SigSocketError::NotConnected;
assert_eq!(error.to_string(), "Client is not connected");
let error = SigSocketError::Connection("wasm test error".to_string());
assert_eq!(error.to_string(), "Connection error: wasm test error");
}
// Test that demonstrates the expected WASM usage pattern
#[wasm_bindgen_test]
fn test_wasm_usage_pattern() {
// 1. Create client
let public_key = hex::decode("02f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9")
.unwrap();
let mut client = SigSocketClient::new("ws://localhost:8080/ws", public_key).unwrap();
// 2. Set handler
client.set_sign_handler(TestWasmSignHandler::new(true));
// 3. Verify state
assert!(!client.is_connected());
// 4. Create a test request/response cycle
let request = SignRequest::new("wasm-test-request", "dGVzdCBtZXNzYWdl");
let handler = TestWasmSignHandler::new(true);
let signature = handler.handle_sign_request(&request).unwrap();
let response = SignResponse::from_request_and_signature(&request, &signature);
// 5. Verify the response
assert_eq!(response.id, request.id);
assert_eq!(response.message, request.message);
assert_eq!(response.signature_bytes().unwrap(), signature);
}
// Test WASM-specific console logging (if needed)
#[wasm_bindgen_test]
fn test_wasm_console_logging() {
// This test verifies that WASM console logging works
web_sys::console::log_1(&"SigSocket WASM test logging works!".into());
// Test that we can create and log protocol messages
let request = SignRequest::new("log-test", "dGVzdA==");
let json = serde_json::to_string(&request).unwrap();
web_sys::console::log_1(&format!("Sign request JSON: {}", json).into());
// This test always passes - it's just for verification that logging works
assert!(true);
}

View File

@@ -24,7 +24,6 @@ use error::VaultError;
pub use kvstore::traits::KVStore; pub use kvstore::traits::KVStore;
use crate::crypto::cipher::{decrypt_chacha20, encrypt_chacha20}; use crate::crypto::cipher::{decrypt_chacha20, encrypt_chacha20};
use signature::SignatureEncoding;
// TEMP: File-based debug logger for crypto troubleshooting // TEMP: File-based debug logger for crypto troubleshooting
use log::debug; use log::debug;
@@ -217,7 +216,7 @@ impl<S: KVStore> Vault<S> {
// --- Keypair Management APIs --- // --- Keypair Management APIs ---
/// Create a default Ed25519 keypair for client identity /// Create a default Secp256k1 keypair for client identity
/// This keypair is deterministically generated from the password and salt /// This keypair is deterministically generated from the password and salt
/// and will always be the first keypair in the keyspace /// and will always be the first keypair in the keyspace
async fn create_default_keypair( async fn create_default_keypair(
@@ -229,26 +228,32 @@ impl<S: KVStore> Vault<S> {
// 1. Derive a deterministic seed using standard PBKDF2 // 1. Derive a deterministic seed using standard PBKDF2
let seed = kdf::keyspace_key(password, salt); let seed = kdf::keyspace_key(password, salt);
// 2. Generate Ed25519 keypair from the seed // 2. Generate Secp256k1 keypair from the seed
use ed25519_dalek::{SigningKey, VerifyingKey}; use k256::ecdsa::{SigningKey, VerifyingKey};
// Use the seed to create a deterministic keypair // Use the seed as the private key directly (32 bytes)
let signing = SigningKey::from_bytes(seed.as_slice().try_into().unwrap()); let mut secret_key_bytes = [0u8; 32];
let verifying: VerifyingKey = (&signing).into(); secret_key_bytes.copy_from_slice(&seed[..32]);
let priv_bytes = signing.to_bytes().to_vec(); // Create signing key
let pub_bytes = verifying.to_bytes().to_vec(); let signing_key = SigningKey::from_bytes(&secret_key_bytes.into())
.map_err(|e| VaultError::Crypto(format!("Failed to create signing key: {}", e)))?;
// Create an ID for the default keypair // Get verifying key
let verifying_key = VerifyingKey::from(&signing_key);
// Convert keys to bytes
let priv_bytes = signing_key.to_bytes().to_vec();
let pub_bytes = verifying_key.to_encoded_point(false).as_bytes().to_vec();
let id = hex::encode(&pub_bytes); let id = hex::encode(&pub_bytes);
// 3. Unlock the keyspace to get its data // 3. Unlock keyspace to add the keypair
let mut data = self.unlock_keyspace(keyspace, password).await?; let mut data = self.unlock_keyspace(keyspace, password).await?;
// 4. Add to keypairs (as the first entry) // 4. Create key entry
let entry = KeyEntry { let entry = KeyEntry {
id: id.clone(), id: id.clone(),
key_type: KeyType::Ed25519, key_type: KeyType::Secp256k1,
private_key: priv_bytes, private_key: priv_bytes,
public_key: pub_bytes, public_key: pub_bytes,
metadata: Some(KeyMetadata { metadata: Some(KeyMetadata {
@@ -460,14 +465,15 @@ impl<S: KVStore> Vault<S> {
Ok(sig.to_bytes().to_vec()) Ok(sig.to_bytes().to_vec())
} }
KeyType::Secp256k1 => { KeyType::Secp256k1 => {
use k256::ecdsa::{signature::Signer, SigningKey}; use k256::ecdsa::{signature::Signer, SigningKey, Signature};
let arr: &[u8; 32] = key.private_key.as_slice().try_into().map_err(|_| { let arr: &[u8; 32] = key.private_key.as_slice().try_into().map_err(|_| {
VaultError::Crypto("Invalid secp256k1 private key length".to_string()) VaultError::Crypto("Invalid secp256k1 private key length".to_string())
})?; })?;
let sk = SigningKey::from_bytes(arr.into()) let sk = SigningKey::from_bytes(arr.into())
.map_err(|e| VaultError::Crypto(e.to_string()))?; .map_err(|e| VaultError::Crypto(e.to_string()))?;
let sig: k256::ecdsa::DerSignature = sk.sign(message); let sig: Signature = sk.sign(message);
Ok(sig.to_vec()) // Return compact signature (64 bytes) instead of DER format
Ok(sig.to_bytes().to_vec())
} }
} }
} }
@@ -511,7 +517,11 @@ impl<S: KVStore> Vault<S> {
use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; use k256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
let pk = VerifyingKey::from_sec1_bytes(&key.public_key) let pk = VerifyingKey::from_sec1_bytes(&key.public_key)
.map_err(|e| VaultError::Crypto(e.to_string()))?; .map_err(|e| VaultError::Crypto(e.to_string()))?;
let sig = Signature::from_der(signature) // Use compact format (64 bytes) instead of DER
let sig_array: &[u8; 64] = signature.try_into().map_err(|_| {
VaultError::Crypto("Invalid secp256k1 signature length".to_string())
})?;
let sig = Signature::from_bytes(sig_array.into())
.map_err(|e| VaultError::Crypto(e.to_string()))?; .map_err(|e| VaultError::Crypto(e.to_string()))?;
Ok(pk.verify(message, &sig).is_ok()) Ok(pk.verify(message, &sig).is_ok())
} }

View File

@@ -9,7 +9,7 @@ use crate::session::SessionManager;
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub fn register_rhai_api<S: kvstore::traits::KVStore + Send + Sync + Clone + 'static>( pub fn register_rhai_api<S: kvstore::traits::KVStore + Send + Sync + Clone + 'static>(
engine: &mut Engine, engine: &mut Engine,
session_manager: std::sync::Arc<std::sync::Mutex<SessionManager<S>>>, _session_manager: std::sync::Arc<std::sync::Mutex<SessionManager<S>>>,
) { ) {
engine.register_type::<RhaiSessionManager<S>>(); engine.register_type::<RhaiSessionManager<S>>();
engine.register_fn("select_keypair", RhaiSessionManager::<S>::select_keypair); engine.register_fn("select_keypair", RhaiSessionManager::<S>::select_keypair);

View File

@@ -3,7 +3,6 @@
//! All state is local to the SessionManager instance. No global state. //! All state is local to the SessionManager instance. No global state.
use crate::{KVStore, KeyEntry, KeyspaceData, Vault, VaultError}; use crate::{KVStore, KeyEntry, KeyspaceData, Vault, VaultError};
use std::collections::HashMap;
use zeroize::Zeroize; use zeroize::Zeroize;
/// SessionManager: Ergonomic, stateful wrapper over the Vault stateless API. /// SessionManager: Ergonomic, stateful wrapper over the Vault stateless API.

View File

@@ -26,6 +26,8 @@ async fn test_keypair_management_and_crypto() {
vault.create_keyspace(keyspace, password, None).await.unwrap(); vault.create_keyspace(keyspace, password, None).await.unwrap();
debug!("after create_keyspace: keyspace={} password={}", keyspace, hex::encode(password)); debug!("after create_keyspace: keyspace={} password={}", keyspace, hex::encode(password));
let keys = vault.list_keypairs(keyspace, password).await.unwrap();
assert_eq!(keys.len(), 1); // should be 1 because we added a default keypair on create_keyspace
debug!("before add Ed25519 keypair"); debug!("before add Ed25519 keypair");
let key_id = vault.add_keypair(keyspace, password, Some(KeyType::Ed25519), Some(KeyMetadata { name: Some("edkey".into()), created_at: None, tags: None })).await; let key_id = vault.add_keypair(keyspace, password, Some(KeyType::Ed25519), Some(KeyMetadata { name: Some("edkey".into()), created_at: None, tags: None })).await;
match &key_id { match &key_id {
@@ -38,7 +40,7 @@ async fn test_keypair_management_and_crypto() {
debug!("before list_keypairs"); debug!("before list_keypairs");
let keys = vault.list_keypairs(keyspace, password).await.unwrap(); let keys = vault.list_keypairs(keyspace, password).await.unwrap();
assert_eq!(keys.len(), 2); assert_eq!(keys.len(), 3);
debug!("before export Ed25519 keypair"); debug!("before export Ed25519 keypair");
let (priv_bytes, pub_bytes) = vault.export_keypair(keyspace, password, &key_id).await.unwrap(); let (priv_bytes, pub_bytes) = vault.export_keypair(keyspace, password, &key_id).await.unwrap();
@@ -65,5 +67,5 @@ async fn test_keypair_management_and_crypto() {
// Remove a keypair // Remove a keypair
vault.remove_keypair(keyspace, password, &key_id).await.unwrap(); vault.remove_keypair(keyspace, password, &key_id).await.unwrap();
let keys = vault.list_keypairs(keyspace, password).await.unwrap(); let keys = vault.list_keypairs(keyspace, password).await.unwrap();
assert_eq!(keys.len(), 1); assert_eq!(keys.len(), 2);
} }

View File

@@ -11,7 +11,7 @@ async fn session_manager_end_to_end() {
use tempfile::TempDir; use tempfile::TempDir;
let tmp_dir = TempDir::new().expect("create temp dir"); let tmp_dir = TempDir::new().expect("create temp dir");
let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore"); let store = NativeStore::open(tmp_dir.path().to_str().unwrap()).expect("open NativeStore");
let mut vault = Vault::new(store); let vault = Vault::new(store);
let keyspace = "personal"; let keyspace = "personal";
let password = b"testpass"; let password = b"testpass";

View File

@@ -32,8 +32,7 @@ async fn test_session_manager_lock_unlock_keypairs_persistence() {
// 3. List, store keys and names // 3. List, store keys and names
let keypairs_before = session.list_keypairs().expect("list_keypairs before").iter().map(|k| (k.id.clone(), k.public_key.clone(), k.private_key.clone(), k.metadata.clone())).collect::<Vec<_>>(); let keypairs_before = session.list_keypairs().expect("list_keypairs before").iter().map(|k| (k.id.clone(), k.public_key.clone(), k.private_key.clone(), k.metadata.clone())).collect::<Vec<_>>();
let keypairs_before = session.list_keypairs().expect("list_keypairs before").iter().map(|k| (k.id.clone(), k.public_key.clone(), k.private_key.clone(), k.metadata.clone())).collect::<Vec<_>>(); assert_eq!(keypairs_before.len(), 3);
assert_eq!(keypairs_before.len(), 2);
assert!(keypairs_before.iter().any(|k| k.0 == id1 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-one"))); assert!(keypairs_before.iter().any(|k| k.0 == id1 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-one")));
assert!(keypairs_before.iter().any(|k| k.0 == id2 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-two"))); assert!(keypairs_before.iter().any(|k| k.0 == id2 && k.3.as_ref().unwrap().name.as_deref() == Some("keypair-two")));

View File

@@ -12,6 +12,7 @@ web-sys = { version = "0.3", features = ["console"] }
js-sys = "0.3" js-sys = "0.3"
kvstore = { path = "../kvstore" } kvstore = { path = "../kvstore" }
hex = "0.4" hex = "0.4"
base64 = "0.22"
wasm-bindgen = { version = "0.2", features = ["serde-serialize"] } wasm-bindgen = { version = "0.2", features = ["serde-serialize"] }
gloo-utils = "0.1" gloo-utils = "0.1"
@@ -23,6 +24,7 @@ wasm-bindgen-futures = "0.4"
once_cell = "1.21" once_cell = "1.21"
vault = { path = "../vault" } vault = { path = "../vault" }
evm_client = { path = "../evm_client" } evm_client = { path = "../evm_client" }
sigsocket_client = { path = "../sigsocket_client" }
[dev-dependencies] [dev-dependencies]
wasm-bindgen-test = "0.3" wasm-bindgen-test = "0.3"

View File

@@ -24,8 +24,13 @@ pub use vault::session_singleton::SESSION_MANAGER;
// Include the keypair bindings module // Include the keypair bindings module
mod vault_bindings; mod vault_bindings;
mod sigsocket_bindings;
pub use vault_bindings::*; pub use vault_bindings::*;
// Include the sigsocket module
mod sigsocket;
pub use sigsocket::*;
/// Initialize the scripting environment (must be called before run_rhai) /// Initialize the scripting environment (must be called before run_rhai)
#[wasm_bindgen] #[wasm_bindgen]
pub fn init_rhai_env() { pub fn init_rhai_env() {

View File

@@ -0,0 +1,168 @@
//! SigSocket connection wrapper for WASM
//!
//! This module provides a WASM-bindgen compatible wrapper around the
//! SigSocket client that can be used from JavaScript in the browser extension.
use wasm_bindgen::prelude::*;
use sigsocket_client::{SigSocketClient, SignResponse};
use crate::sigsocket::handler::JavaScriptSignHandler;
/// WASM-bindgen wrapper for SigSocket client
///
/// This provides a clean JavaScript API for the browser extension to:
/// - Connect to SigSocket servers
/// - Send responses to sign requests
/// - Manage connection state
#[wasm_bindgen]
pub struct SigSocketConnection {
client: Option<SigSocketClient>,
connected: bool,
}
#[wasm_bindgen]
impl SigSocketConnection {
/// Create a new SigSocket connection
#[wasm_bindgen(constructor)]
pub fn new() -> Self {
Self {
client: None,
connected: false,
}
}
/// Connect to a SigSocket server
///
/// # Arguments
/// * `server_url` - WebSocket server URL (e.g., "ws://localhost:8080/ws")
/// * `public_key_hex` - Client's public key as hex string
///
/// # Returns
/// * `Ok(())` - Successfully connected
/// * `Err(error)` - Connection failed
#[wasm_bindgen]
pub async fn connect(&mut self, server_url: &str, public_key_hex: &str) -> Result<(), JsValue> {
web_sys::console::log_1(&format!("SigSocketConnection::connect called with URL: {}", server_url).into());
web_sys::console::log_1(&format!("Public key (first 16 chars): {}", &public_key_hex[..16]).into());
// Decode public key from hex
let public_key = hex::decode(public_key_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid public key hex: {}", e)))?;
web_sys::console::log_1(&"Creating SigSocketClient...".into());
// Create client
let mut client = SigSocketClient::new(server_url, public_key)
.map_err(|e| JsValue::from_str(&format!("Failed to create client: {}", e)))?;
web_sys::console::log_1(&"SigSocketClient created, attempting connection...".into());
// Set up JavaScript handler
client.set_sign_handler(JavaScriptSignHandler);
// Connect to server
web_sys::console::log_1(&"Calling client.connect()...".into());
client.connect().await
.map_err(|e| {
web_sys::console::error_1(&format!("Client connection failed: {}", e).into());
JsValue::from_str(&format!("Failed to connect: {}", e))
})?;
web_sys::console::log_1(&"Client connection successful!".into());
self.client = Some(client);
self.connected = true;
web_sys::console::log_1(&"SigSocketConnection state updated to connected".into());
// Notify JavaScript of connection state change
super::handler::on_connection_state_changed(true);
Ok(())
}
/// Send a response to a sign request
///
/// This should be called by the extension after the user has approved
/// a sign request and the message has been signed.
///
/// # Arguments
/// * `request_id` - ID of the original request
/// * `message_base64` - Original message (base64-encoded)
/// * `signature_hex` - Signature as hex string
///
/// # Returns
/// * `Ok(())` - Response sent successfully
/// * `Err(error)` - Failed to send response
#[wasm_bindgen]
pub async fn send_response(&self, request_id: &str, message_base64: &str, signature_hex: &str) -> Result<(), JsValue> {
let client = self.client.as_ref()
.ok_or_else(|| JsValue::from_str("Not connected"))?;
// Decode signature from hex
let signature = hex::decode(signature_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid signature hex: {}", e)))?;
// Create response
let response = SignResponse::new(request_id, message_base64,
base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &signature));
// Send response
client.send_sign_response(&response).await
.map_err(|e| JsValue::from_str(&format!("Failed to send response: {}", e)))?;
Ok(())
}
/// Send a rejection for a sign request
///
/// This should be called when the user rejects a sign request.
///
/// # Arguments
/// * `request_id` - ID of the request to reject
/// * `reason` - Reason for rejection (optional)
///
/// # Returns
/// * `Ok(())` - Rejection sent successfully
/// * `Err(error)` - Failed to send rejection
#[wasm_bindgen]
pub async fn send_rejection(&self, request_id: &str, reason: &str) -> Result<(), JsValue> {
// For now, we'll just log the rejection
// In a full implementation, the server might support rejection messages
web_sys::console::log_1(&format!("Sign request {} rejected: {}", request_id, reason).into());
// TODO: If the server supports rejection messages, send them here
// For now, we just ignore the request (timeout on server side)
Ok(())
}
/// Disconnect from the SigSocket server
#[wasm_bindgen]
pub fn disconnect(&mut self) {
if let Some(_client) = self.client.take() {
// Note: We can't await in a non-async function, so we'll just drop the client
// The Drop implementation should handle cleanup
self.connected = false;
// Notify JavaScript of connection state change
super::handler::on_connection_state_changed(false);
}
}
/// Check if connected to the server
#[wasm_bindgen]
pub fn is_connected(&self) -> bool {
// Check if we have a client and if it reports as connected
if let Some(ref client) = self.client {
client.is_connected()
} else {
false
}
}
}
impl Default for SigSocketConnection {
fn default() -> Self {
Self::new()
}
}

View File

@@ -0,0 +1,51 @@
//! JavaScript bridge handler for SigSocket sign requests
//!
//! This module provides a sign request handler that delegates to JavaScript
//! callbacks, allowing the browser extension to handle the actual signing
//! and user approval flow.
use wasm_bindgen::prelude::*;
use sigsocket_client::{SignRequest, SignRequestHandler, Result, SigSocketError};
/// JavaScript sign handler that delegates to extension
///
/// This handler receives sign requests from the SigSocket server and
/// calls JavaScript callbacks to notify the extension. The extension
/// handles the user approval flow and signing, then responds via
/// the SigSocketConnection.send_response() method.
pub struct JavaScriptSignHandler;
impl SignRequestHandler for JavaScriptSignHandler {
fn handle_sign_request(&self, request: &SignRequest) -> Result<Vec<u8>> {
// Call JavaScript callback to notify extension of incoming request
on_sign_request_received(&request.id, &request.message);
// Return error - JavaScript handles response via send_response()
// This is intentional as the signing happens asynchronously in the extension
Err(SigSocketError::Other("Handled by JavaScript extension".to_string()))
}
}
/// External JavaScript functions that the extension must implement
#[wasm_bindgen]
extern "C" {
/// Called when a sign request is received from the server
///
/// The extension should:
/// 1. Store the request details
/// 2. Show notification/badge to user
/// 3. Handle user approval flow when popup is opened
///
/// # Arguments
/// * `request_id` - Unique identifier for the request
/// * `message_base64` - Message to be signed (base64-encoded)
#[wasm_bindgen(js_name = "onSignRequestReceived")]
pub fn on_sign_request_received(request_id: &str, message_base64: &str);
/// Called when connection state changes
///
/// # Arguments
/// * `connected` - True if connected, false if disconnected
#[wasm_bindgen(js_name = "onConnectionStateChanged")]
pub fn on_connection_state_changed(connected: bool);
}

View File

@@ -0,0 +1,11 @@
//! SigSocket integration module for WASM app
//!
//! This module provides a clean transport API for SigSocket communication
//! that can be used by the browser extension. It handles connection management
//! and delegates signing to the extension through JavaScript callbacks.
pub mod connection;
pub mod handler;
pub use connection::SigSocketConnection;
pub use handler::JavaScriptSignHandler;

View File

@@ -0,0 +1,528 @@
//! SigSocket bindings for WASM - integrates sigsocket_client with vault system
use std::cell::RefCell;
use wasm_bindgen::prelude::*;
use serde::{Deserialize, Serialize};
use sigsocket_client::{SigSocketClient, SignRequest, SignRequestHandler, Result as SigSocketResult, SigSocketError};
use web_sys::console;
use base64::prelude::*;
use crate::vault_bindings::{get_workspace_default_public_key, get_current_keyspace_name, is_unlocked, sign_with_default_keypair};
// Global SigSocket client instance
thread_local! {
static SIGSOCKET_CLIENT: RefCell<Option<SigSocketClient>> = RefCell::new(None);
}
// Helper macro for console logging
macro_rules! console_log {
($($t:tt)*) => (console::log_1(&format!($($t)*).into()))
}
/// Extension notification handler that forwards requests to JavaScript
pub struct ExtensionNotificationHandler {
callback: js_sys::Function,
}
impl ExtensionNotificationHandler {
pub fn new(callback: js_sys::Function) -> Self {
Self { callback }
}
}
impl SignRequestHandler for ExtensionNotificationHandler {
fn handle_sign_request(&self, request: &SignRequest) -> SigSocketResult<Vec<u8>> {
console_log!("📨 WASM: Handling sign request: {}", request.id);
// First, store the request in the WASM client
let store_result = SIGSOCKET_CLIENT.with(|c| {
let mut client_opt = c.borrow_mut();
if let Some(client) = client_opt.as_mut() {
// Get the connected public key as the target
if let Some(target_public_key) = client.connected_public_key() {
client.add_pending_request(request.clone(), target_public_key.to_string());
console_log!("✅ WASM: Stored sign request: {}", request.id);
Ok(())
} else {
Err(SigSocketError::Other("No connected public key".to_string()))
}
} else {
Err(SigSocketError::Other("No SigSocket client available".to_string()))
}
});
// If storage failed, return error
if let Err(e) = store_result {
console_log!("❌ WASM: Failed to store request: {:?}", e);
return Err(e);
}
// Create event object for JavaScript notification
let event = js_sys::Object::new();
js_sys::Reflect::set(&event, &"type".into(), &"sign_request".into())
.map_err(|_| SigSocketError::Other("Failed to set event type".to_string()))?;
js_sys::Reflect::set(&event, &"request_id".into(), &request.id.clone().into())
.map_err(|_| SigSocketError::Other("Failed to set request_id".to_string()))?;
js_sys::Reflect::set(&event, &"message".into(), &request.message.clone().into())
.map_err(|_| SigSocketError::Other("Failed to set message".to_string()))?;
// Notify the extension
match self.callback.call1(&wasm_bindgen::JsValue::NULL, &event) {
Ok(_) => {
console_log!("✅ WASM: Notified extension about sign request: {}", request.id);
// Return an error to indicate this request should not be auto-signed
// The extension will handle the approval flow
Err(SigSocketError::Other("Request forwarded to extension for approval".to_string()))
}
Err(e) => {
console_log!("❌ WASM: Failed to notify extension: {:?}", e);
Err(SigSocketError::Other("Extension notification failed".to_string()))
}
}
}
}
/// Connection information for SigSocket
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SigSocketConnectionInfo {
pub workspace: String,
pub public_key: String,
pub is_connected: bool,
pub server_url: String,
}
/// SigSocket manager for high-level operations
#[wasm_bindgen]
pub struct SigSocketManager;
#[wasm_bindgen]
impl SigSocketManager {
/// Connect to SigSocket server with smart connection management
///
/// This handles all connection logic:
/// - Reuses existing connection if same workspace
/// - Switches connection if different workspace
/// - Creates new connection if none exists
///
/// # Arguments
/// * `workspace` - The workspace name to connect with
/// * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
/// * `event_callback` - JavaScript function to call when events occur
///
/// # Returns
/// * `Ok(connection_info)` - JSON string with connection details
/// * `Err(error)` - If connection failed or workspace is invalid
#[wasm_bindgen]
pub async fn connect_workspace_with_events(workspace: &str, server_url: &str, event_callback: &js_sys::Function) -> Result<String, JsValue> {
// 1. Validate workspace exists and get default public key from vault
let public_key_js = get_workspace_default_public_key(workspace).await
.map_err(|e| JsValue::from_str(&format!("Failed to get workspace public key: {:?}", e)))?;
let public_key_hex = public_key_js.as_string()
.ok_or_else(|| JsValue::from_str("Public key is not a string"))?;
// 2. Decode public key
let public_key_bytes = hex::decode(&public_key_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid public key format: {}", e)))?;
// 3. Check if already connected to same workspace and handle disconnection
let should_connect = SIGSOCKET_CLIENT.with(|c| {
let mut client_opt = c.borrow_mut();
// Check if we already have a client for this workspace
if let Some(existing_client) = client_opt.as_ref() {
if let Some(existing_key) = existing_client.connected_public_key() {
if existing_key == hex::encode(&public_key_bytes) && existing_client.is_connected() {
console_log!("🔄 WASM: Already connected to workspace: {}", workspace);
return false; // Reuse existing connection
} else {
console_log!("🔄 WASM: Switching workspace from {} to {}",
existing_key, hex::encode(&public_key_bytes));
// Disconnect the old client
*client_opt = None; // This will drop the old client and close WebSocket
console_log!("🔌 WASM: Disconnected from old workspace");
return true; // Need new connection
}
}
}
true // Need new connection, no old one to disconnect
});
// 4. Create and connect if needed
if should_connect {
console_log!("🔗 WASM: Creating new connection for workspace: {}", workspace);
// Create new client
let mut client = SigSocketClient::new(server_url, public_key_bytes.clone())
.map_err(|e| JsValue::from_str(&format!("Failed to create client: {:?}", e)))?;
// Set up extension notification handler
let handler = ExtensionNotificationHandler::new(event_callback.clone());
client.set_sign_handler(handler);
// Connect to the WebSocket server
client.connect().await
.map_err(|e| JsValue::from_str(&format!("Connection failed: {:?}", e)))?;
console_log!("✅ WASM: Connected to SigSocket server for workspace: {}", workspace);
// Store the connected client
SIGSOCKET_CLIENT.with(|c| {
*c.borrow_mut() = Some(client);
});
}
// 6. Return connection info
let connection_info = SigSocketConnectionInfo {
workspace: workspace.to_string(),
public_key: public_key_hex.clone(),
is_connected: true,
server_url: server_url.to_string(),
};
// 7. Serialize and return connection info
serde_json::to_string(&connection_info)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
}
/// Connect to SigSocket server with a specific workspace (backward compatibility)
///
/// This is a simpler version that doesn't set up event callbacks.
/// Use connect_workspace_with_events for full functionality.
///
/// # Arguments
/// * `workspace` - The workspace name to connect with
/// * `server_url` - The SigSocket server URL (e.g., "ws://localhost:8080/ws")
///
/// # Returns
/// * `Ok(connection_info)` - JSON string with connection details
/// * `Err(error)` - If connection failed or workspace is invalid
#[wasm_bindgen]
pub async fn connect_workspace(workspace: &str, server_url: &str) -> Result<String, JsValue> {
// Create a dummy callback that just logs
let dummy_callback = js_sys::Function::new_no_args("console.log('SigSocket event:', arguments[0]);");
Self::connect_workspace_with_events(workspace, server_url, &dummy_callback).await
}
/// Disconnect from SigSocket server
///
/// # Returns
/// * `Ok(())` - Successfully disconnected
/// * `Err(error)` - If disconnect failed
#[wasm_bindgen]
pub async fn disconnect() -> Result<(), JsValue> {
SIGSOCKET_CLIENT.with(|c| {
let mut client_opt = c.borrow_mut();
if let Some(client) = client_opt.take() {
let workspace_info = client.connected_public_key()
.map(|key| key[..16].to_string())
.unwrap_or_else(|| "unknown".to_string());
// Dropping the client will close the WebSocket connection
drop(client);
console_log!("🔌 WASM: Disconnected SigSocket client (was: {}...)", workspace_info);
} else {
console_log!("🔌 WASM: No SigSocket client to disconnect");
}
Ok(())
})
}
/// Check if we can approve a specific sign request
///
/// This validates that:
/// 1. The request exists
/// 2. The vault session is unlocked
/// 3. The current workspace matches the request's target
///
/// # Arguments
/// * `request_id` - The ID of the request to validate
///
/// # Returns
/// * `Ok(true)` - Request can be approved
/// * `Ok(false)` - Request cannot be approved
/// * `Err(error)` - Validation error
#[wasm_bindgen]
pub async fn can_approve_request(request_id: &str) -> Result<bool, JsValue> {
// 1. Check if vault session is unlocked
if !is_unlocked() {
return Ok(false);
}
// 2. Get current workspace and its public key
let current_workspace = get_current_keyspace_name()
.map_err(|e| JsValue::from_str(&format!("Failed to get current workspace: {:?}", e)))?;
let current_public_key_js = get_workspace_default_public_key(&current_workspace).await
.map_err(|e| JsValue::from_str(&format!("Failed to get current public key: {:?}", e)))?;
let current_public_key = current_public_key_js.as_string()
.ok_or_else(|| JsValue::from_str("Current public key is not a string"))?;
// 3. Check the request
SIGSOCKET_CLIENT.with(|c| {
let client = c.borrow();
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
// Get the request
let request = client.get_pending_request(request_id)
.ok_or_else(|| JsValue::from_str("Request not found"))?;
// Check if request matches current session
let can_approve = request.target_public_key == current_public_key;
console_log!("Can approve request {}: {} (current: {}, target: {})",
request_id, can_approve, current_public_key, request.target_public_key);
Ok(can_approve)
})
}
/// Approve a sign request and send the signature to the server
///
/// This performs the complete approval flow:
/// 1. Validates the request can be approved
/// 2. Signs the message using the vault
/// 3. Sends the signature to the SigSocket server
/// 4. Removes the request from pending list
///
/// # Arguments
/// * `request_id` - The ID of the request to approve
///
/// # Returns
/// * `Ok(signature)` - Base64-encoded signature that was sent
/// * `Err(error)` - If approval failed
#[wasm_bindgen]
pub async fn approve_request(request_id: &str) -> Result<String, JsValue> {
// 1. Validate we can approve this request
if !Self::can_approve_request(request_id).await? {
return Err(JsValue::from_str("Cannot approve this request"));
}
// 2. Get request details and sign the message
let (message_bytes, original_request) = SIGSOCKET_CLIENT.with(|c| {
let client = c.borrow();
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected"))?;
let request = client.get_pending_request(request_id)
.ok_or_else(|| JsValue::from_str("Request not found"))?;
// Decode the message
let message_bytes = request.message_bytes()
.map_err(|e| JsValue::from_str(&format!("Invalid message format: {}", e)))?;
Ok::<(Vec<u8>, SignRequest), JsValue>((message_bytes, request.request.clone()))
})?;
// 3. Sign with vault
let signature_result = sign_with_default_keypair(&message_bytes).await?;
let signature_hex = signature_result.as_string()
.ok_or_else(|| JsValue::from_str("Signature result is not a string"))?;
// Convert hex signature to base64 for SigSocket protocol
let signature_bytes = hex::decode(&signature_hex)
.map_err(|e| JsValue::from_str(&format!("Invalid hex signature: {}", e)))?;
let signature_base64 = base64::prelude::BASE64_STANDARD.encode(&signature_bytes);
// 4. Get original message for response
let original_message = SIGSOCKET_CLIENT.with(|c| {
let client = c.borrow();
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected"))?;
let request = client.get_pending_request(request_id)
.ok_or_else(|| JsValue::from_str("Request not found"))?;
Ok::<String, JsValue>(request.request.message.clone())
})?;
// 5. Send response to server (create a new scope to avoid borrowing issues)
{
let client_ref = SIGSOCKET_CLIENT.with(|c| {
c.borrow().as_ref().map(|client| client as *const SigSocketClient)
}).ok_or_else(|| JsValue::from_str("Not connected"))?;
// SAFETY: We know the client exists and we're using it synchronously
let client = unsafe { &*client_ref };
client.send_response(request_id, &original_message, &signature_base64).await
.map_err(|e| JsValue::from_str(&format!("Failed to send response: {:?}", e)))?;
console_log!("✅ WASM: Sent signature response to server for request: {}", request_id);
}
// 6. Remove the request after successful send
SIGSOCKET_CLIENT.with(|c| {
let mut client = c.borrow_mut();
if let Some(client) = client.as_mut() {
client.remove_pending_request(request_id);
console_log!("✅ WASM: Removed request from pending list: {}", request_id);
}
});
console_log!("🎉 WASM: Successfully approved and sent signature for request: {}", request_id);
Ok(signature_base64)
}
/// Reject a sign request
///
/// # Arguments
/// * `request_id` - The ID of the request to reject
/// * `reason` - The reason for rejection
///
/// # Returns
/// * `Ok(())` - Request rejected successfully
/// * `Err(error)` - If rejection failed
#[wasm_bindgen]
pub async fn reject_request(request_id: &str, reason: &str) -> Result<(), JsValue> {
// Send rejection to server first
{
let client_ref = SIGSOCKET_CLIENT.with(|c| {
c.borrow().as_ref().map(|client| client as *const SigSocketClient)
}).ok_or_else(|| JsValue::from_str("Not connected"))?;
// SAFETY: We know the client exists and we're using it synchronously
let client = unsafe { &*client_ref };
client.send_rejection(request_id, reason).await
.map_err(|e| JsValue::from_str(&format!("Failed to send rejection: {:?}", e)))?;
console_log!("✅ WASM: Sent rejection to server for request: {}", request_id);
}
// Remove the request after successful send
SIGSOCKET_CLIENT.with(|c| {
let mut client = c.borrow_mut();
if let Some(client) = client.as_mut() {
client.remove_pending_request(request_id);
console_log!("✅ WASM: Removed rejected request from pending list: {}", request_id);
}
});
console_log!("🚫 WASM: Successfully rejected request: {} (reason: {})", request_id, reason);
Ok(())
}
/// Get pending requests filtered by current workspace
///
/// This returns only the requests that the current vault session can handle,
/// based on the unlocked workspace and its public key.
///
/// # Returns
/// * `Ok(requests_json)` - JSON array of filtered requests
/// * `Err(error)` - If filtering failed
#[wasm_bindgen]
pub async fn get_filtered_requests() -> Result<String, JsValue> {
// If vault is locked, return empty array
if !is_unlocked() {
return Ok("[]".to_string());
}
// Get current workspace public key
let current_workspace = get_current_keyspace_name()
.map_err(|e| JsValue::from_str(&format!("Failed to get current workspace: {:?}", e)))?;
let current_public_key_js = get_workspace_default_public_key(&current_workspace).await
.map_err(|e| JsValue::from_str(&format!("Failed to get current public key: {:?}", e)))?;
let current_public_key = current_public_key_js.as_string()
.ok_or_else(|| JsValue::from_str("Current public key is not a string"))?;
// Filter requests for current workspace
SIGSOCKET_CLIENT.with(|c| {
let client = c.borrow();
let client = client.as_ref().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
let filtered_requests: Vec<_> = client.get_requests_for_public_key(&current_public_key);
console_log!("Filtered requests: {} total, {} for current workspace",
client.pending_request_count(), filtered_requests.len());
// Serialize and return
serde_json::to_string(&filtered_requests)
.map_err(|e| JsValue::from_str(&format!("Serialization error: {}", e)))
})
}
/// Add a pending sign request (called when request arrives from server)
///
/// # Arguments
/// * `request_json` - JSON string containing the sign request
///
/// # Returns
/// * `Ok(())` - Request added successfully
/// * `Err(error)` - If adding failed
#[wasm_bindgen]
pub fn add_pending_request(request_json: &str) -> Result<(), JsValue> {
// Parse the request
let request: SignRequest = serde_json::from_str(request_json)
.map_err(|e| JsValue::from_str(&format!("Invalid request JSON: {}", e)))?;
SIGSOCKET_CLIENT.with(|c| {
let mut client = c.borrow_mut();
let client = client.as_mut().ok_or_else(|| JsValue::from_str("Not connected to SigSocket"))?;
// Get the connected public key as the target
let target_public_key = client.connected_public_key()
.ok_or_else(|| JsValue::from_str("No connected public key"))?
.to_string();
// Add the request
client.add_pending_request(request, target_public_key);
console_log!("Added pending request: {}", request_json);
Ok(())
})
}
/// Get connection status
///
/// # Returns
/// * `Ok(status_json)` - JSON object with connection status
/// * `Err(error)` - If getting status failed
#[wasm_bindgen]
pub fn get_connection_status() -> Result<String, JsValue> {
SIGSOCKET_CLIENT.with(|c| {
let client = c.borrow();
if let Some(client) = client.as_ref() {
let status = serde_json::json!({
"is_connected": client.is_connected(),
"connected_public_key": client.connected_public_key(),
"pending_request_count": client.pending_request_count(),
"server_url": client.url()
});
Ok(status.to_string())
} else {
let status = serde_json::json!({
"is_connected": false,
"connected_public_key": null,
"pending_request_count": 0,
"server_url": null
});
Ok(status.to_string())
}
})
}
/// Clear all pending requests
///
/// # Returns
/// * `Ok(())` - Requests cleared successfully
#[wasm_bindgen]
pub fn clear_pending_requests() -> Result<(), JsValue> {
SIGSOCKET_CLIENT.with(|c| {
let mut client = c.borrow_mut();
if let Some(client) = client.as_mut() {
client.clear_pending_requests();
console_log!("Cleared all pending requests");
}
Ok(())
})
}
}

View File

@@ -120,6 +120,41 @@ pub fn is_unlocked() -> bool {
}) })
} }
/// Get the default public key for a workspace (keyspace)
/// This returns the public key of the first keypair in the keyspace
#[wasm_bindgen]
pub async fn get_workspace_default_public_key(workspace_id: &str) -> Result<JsValue, JsValue> {
// For now, workspace_id is the same as keyspace name
// In a full implementation, you might have a mapping from workspace to keyspace
SESSION_MANAGER.with(|cell| {
if let Some(session) = cell.borrow().as_ref() {
if let Some(keyspace_name) = session.current_keyspace_name() {
if keyspace_name == workspace_id {
// Use the default_keypair method to get the first keypair
if let Some(default_keypair) = session.default_keypair() {
// Return the actual public key as hex
let public_key_hex = hex::encode(&default_keypair.public_key);
return Ok(JsValue::from_str(&public_key_hex));
}
}
}
}
Err(JsValue::from_str("Workspace not found or no keypairs available"))
})
}
/// Get the current unlocked public key as hex string
#[wasm_bindgen]
pub fn get_current_unlocked_public_key() -> Result<String, JsValue> {
SESSION_MANAGER.with(|cell| {
cell.borrow().as_ref()
.and_then(|session| session.current_keypair_public_key())
.map(|pk| hex::encode(pk.as_slice()))
.ok_or_else(|| JsValue::from_str("No keypair selected or no keyspace unlocked"))
})
}
/// Get all keypairs from the current session /// Get all keypairs from the current session
/// Returns an array of keypair objects with id, type, and metadata /// Returns an array of keypair objects with id, type, and metadata
// #[wasm_bindgen] // #[wasm_bindgen]
@@ -214,7 +249,7 @@ pub async fn add_keypair(
Ok(JsValue::from_str(&key_id)) Ok(JsValue::from_str(&key_id))
} }
/// Sign message with current session /// Sign message with current session (requires selected keypair)
#[wasm_bindgen] #[wasm_bindgen]
pub async fn sign(message: &[u8]) -> Result<JsValue, JsValue> { pub async fn sign(message: &[u8]) -> Result<JsValue, JsValue> {
{ {
@@ -235,6 +270,79 @@ pub async fn sign(message: &[u8]) -> Result<JsValue, JsValue> {
} }
} }
/// Get the current keyspace name
#[wasm_bindgen]
pub fn get_current_keyspace_name() -> Result<String, JsValue> {
SESSION_MANAGER.with(|cell| {
if let Some(session) = cell.borrow().as_ref() {
if let Some(keyspace_name) = session.current_keyspace_name() {
Ok(keyspace_name.to_string())
} else {
Err(JsValue::from_str("No keyspace unlocked"))
}
} else {
Err(JsValue::from_str("Session not initialized"))
}
})
}
/// Sign message with default keypair (first keypair in keyspace) without changing session state
#[wasm_bindgen]
pub async fn sign_with_default_keypair(message: &[u8]) -> Result<JsValue, JsValue> {
// Temporarily select the default keypair, sign, then restore the original selection
let original_keypair = SESSION_MANAGER.with(|cell| {
cell.borrow().as_ref()
.and_then(|session| session.current_keypair())
.map(|kp| kp.id.clone())
});
// Select default keypair
let select_result = SESSION_MANAGER.with(|cell| {
let mut session_opt = cell.borrow_mut().take();
if let Some(ref mut session) = session_opt {
let result = session.select_default_keypair();
*cell.borrow_mut() = Some(session_opt.take().unwrap());
result.map_err(|e| e.to_string())
} else {
Err("Session not initialized".to_string())
}
});
if let Err(e) = select_result {
return Err(JsValue::from_str(&format!("Failed to select default keypair: {e}")));
}
// Sign with the default keypair
let sign_result = {
let session_ptr = SESSION_MANAGER.with(|cell| cell.borrow().as_ref().map(|s| s as *const _));
let session: &vault::session::SessionManager<kvstore::wasm::WasmStore> = match session_ptr {
Some(ptr) => unsafe { &*ptr },
None => return Err(JsValue::from_str("Session not initialized")),
};
session.sign(message).await
};
// Restore original keypair selection if there was one
if let Some(original_id) = original_keypair {
SESSION_MANAGER.with(|cell| {
let mut session_opt = cell.borrow_mut().take();
if let Some(ref mut session) = session_opt {
let _ = session.select_keypair(&original_id); // Ignore errors here
*cell.borrow_mut() = Some(session_opt.take().unwrap());
}
});
}
// Return the signature result
match sign_result {
Ok(sig_bytes) => {
let hex_sig = hex::encode(&sig_bytes);
Ok(JsValue::from_str(&hex_sig))
}
Err(e) => Err(JsValue::from_str(&format!("Sign error: {e}"))),
}
}
/// Verify a signature with the current session's selected keypair /// Verify a signature with the current session's selected keypair
#[wasm_bindgen] #[wasm_bindgen]
pub async fn verify(message: &[u8], signature: &str) -> Result<JsValue, JsValue> { pub async fn verify(message: &[u8], signature: &str) -> Result<JsValue, JsValue> {