UNPKG

peerpigeon

Version:

WebRTC-based peer-to-peer mesh networking library with intelligent routing and signaling server

744 lines (651 loc) 24.9 kB
import { EventEmitter } from './EventEmitter.js'; import DebugLogger from './DebugLogger.js'; // Dynamic import for unsea to handle both Node.js and browser environments let unsea = null; async function initializeUnsea() { if (unsea) return unsea; try { // Detect environment - prioritize Node.js detection for tests const isNode = typeof process !== 'undefined' && process.versions && process.versions.node; const isBrowser = !isNode && typeof window !== 'undefined' && typeof document !== 'undefined'; if (isNode) { // For Node.js environments (including tests), use npm package unsea = await import('unsea'); console.log('✅ Loaded unsea from npm package (Node.js)'); } else if (isBrowser) { // Check if we have the bundled version first (from browser bundle) if ((typeof globalThis !== 'undefined' && globalThis.__PEERPIGEON_UNSEA__) || (typeof window !== 'undefined' && window.__PEERPIGEON_UNSEA__)) { unsea = globalThis.__PEERPIGEON_UNSEA__ || window.__PEERPIGEON_UNSEA__; console.log('✅ Using bundled unsea (self-contained)'); } else { // Fallback to CDN sources for backwards compatibility try { unsea = await import('https://cdn.jsdelivr.net/npm/unsea@latest/+esm'); console.log('✅ Loaded unsea from jsDelivr CDN'); } catch (jsDelivrError) { console.warn('Failed to load from jsDelivr, trying unpkg:', jsDelivrError); try { unsea = await import('https://unpkg.com/unsea@latest/dist/unsea.esm.js'); console.log('✅ Loaded unsea from unpkg CDN'); } catch (unpkgError) { console.warn('Failed to load from unpkg, trying Skypack:', unpkgError); unsea = await import('https://cdn.skypack.dev/unsea'); console.log('✅ Loaded unsea from Skypack CDN'); } } } } else { throw new Error('Unknown environment - cannot load unsea'); } if (!unsea) { throw new Error('Unsea not found after loading'); } return unsea; } catch (error) { console.error('Failed to load unsea:', error); throw error; } } export class CryptoManager extends EventEmitter { constructor() { super(); this.debug = DebugLogger.create('CryptoManager'); this.unsea = null; this.keypair = null; this.peerKeys = new Map(); // Store peer public keys this.encryptionEnabled = false; this.initialized = false; this.groupKeys = new Map(); // Store group encryption keys this.messageNonces = new Set(); // Prevent replay attacks this.maxNonceAge = 300000; // 5 minutes this.nonceCleanupInterval = null; // Track the cleanup interval // Performance metrics this.stats = { messagesEncrypted: 0, messagesDecrypted: 0, encryptionTime: 0, decryptionTime: 0, keyExchanges: 0 }; } /** * Initialize the crypto manager * @param {Object} options - Configuration options * @param {string} options.alias - Optional user alias for persistent identity * @param {string} options.password - Optional password for user account * @param {boolean} options.generateKeypair - Whether to generate a new keypair if no credentials * @param {string} options.peerId - Peer ID to use for automatic key storage * @returns {Promise<Object>} The generated or loaded keypair */ async init(options = {}) { try { this.unsea = await initializeUnsea(); if (options.alias && options.password) { // Try to create or authenticate with persistent identity await this.createOrAuthenticateUser(options.alias, options.password); } else if (options.peerId) { // Use peer ID for automatic persistent key storage await this.initWithPeerId(options.peerId); } else if (options.generateKeypair !== false) { // Generate ephemeral keypair this.keypair = await this.unsea.generateRandomPair(); } if (this.keypair) { this.encryptionEnabled = true; this.initialized = true; this.emit('cryptoReady', { publicKey: this.getPublicKey() }); // Start nonce cleanup this.startNonceCleanup(); } return this.keypair; } catch (error) { this.debug.error('CryptoManager initialization failed:', error); this.emit('cryptoError', { error: error.message }); throw error; } } /** * Create or authenticate a persistent user account */ async createOrAuthenticateUser(alias, password) { try { // For unsea, we'll generate a deterministic keypair from credentials // Note: unsea doesn't have built-in user accounts like GUN // We can simulate this by generating deterministic keys from password+alias // Use unsea's key persistence if available (browser only) if (typeof window !== 'undefined') { try { // Try to load existing keys const existingKeys = await this.unsea.loadKeys(alias, password); if (existingKeys) { this.keypair = existingKeys; } else { // Generate new keys and save them this.keypair = await this.unsea.generateRandomPair(); // PERFORMANCE: Defer key saving to prevent blocking WebRTC connection establishment setTimeout(async () => { try { await this.unsea.saveKeys(alias, this.keypair, password); } catch (saveError) { this.debug.warn('Failed to save persistent keys:', saveError); } }, 0); } } catch (error) { // Fallback to generating ephemeral keys this.debug.warn('Failed to use persistent storage, generating ephemeral keys:', error); this.keypair = await this.unsea.generateRandomPair(); } } else { // For Node.js, just generate ephemeral keys this.keypair = await this.unsea.generateRandomPair(); } this.emit('userAuthenticated', { alias, publicKey: this.getPublicKey() }); } catch (error) { this.debug.error('User authentication failed:', error); throw error; } } /** * Initialize with automatic key persistence using peer ID * @param {string} peerId - The peer ID to use as storage alias */ async initWithPeerId(peerId) { try { // Initialize unsea if not already done if (!this.unsea) { this.unsea = await initializeUnsea(); } const keyAlias = `peerpigeon-${peerId}`; this.debug.log(`🔐 Initializing crypto with automatic key persistence for peer ${peerId.substring(0, 8)}...`); // Try to load existing keys first with timeout try { const loadKeysPromise = this.unsea.loadKeys(keyAlias); const timeoutPromise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error('LoadKeys timeout')), 5000); }); const existingKeys = await Promise.race([loadKeysPromise, timeoutPromise]); if (existingKeys && existingKeys.pub && existingKeys.priv) { this.keypair = existingKeys; this.debug.log(`🔐 Loaded existing keypair for peer ${peerId.substring(0, 8)}...`); // Mark as initialized and emit ready event this.initialized = true; this.encryptionEnabled = true; this.emit('cryptoReady', { hasKeypair: !!this.keypair, publicKey: this.getPublicKey() }); return; } } catch (error) { this.debug.log(`🔐 No existing keys found for peer ${peerId.substring(0, 8)}..., will generate new ones`); } // Generate new keys and save them with timeout const generateKeysPromise = this.unsea.generateRandomPair(); const generateTimeoutPromise = new Promise((resolve, reject) => { setTimeout(() => reject(new Error('GenerateKeys timeout')), 5000); }); this.keypair = await Promise.race([generateKeysPromise, generateTimeoutPromise]); // PERFORMANCE: Defer key saving to prevent blocking WebRTC connection establishment setTimeout(async () => { try { await this.unsea.saveKeys(keyAlias, this.keypair); this.debug.log(`🔐 Generated and saved new keypair for peer ${peerId.substring(0, 8)}...`); } catch (saveError) { this.debug.warn(`🔐 Failed to save keypair for peer ${peerId.substring(0, 8)}..., using ephemeral keys:`, saveError.message); // Continue with ephemeral keys if storage fails } }, 0); // Mark as initialized and emit ready event this.initialized = true; this.encryptionEnabled = true; this.emit('cryptoReady', { hasKeypair: !!this.keypair, publicKey: this.getPublicKey() }); } catch (error) { this.debug.error('Failed to initialize crypto with peer ID:', error); // Fallback to ephemeral keypair if (this.unsea) { this.keypair = await this.unsea.generateRandomPair(); this.debug.log('🔐 Using ephemeral keypair as fallback'); } else { throw error; } } // Mark as initialized and emit ready event this.initialized = true; this.encryptionEnabled = true; this.emit('cryptoReady', { hasKeypair: !!this.keypair, publicKey: this.getPublicKey() }); } /** * Get the public key for sharing * @returns {string} The public key */ getPublicKey() { // In unsea, the public key is likely in the 'pub' property return this.keypair?.pub || this.keypair?.publicKey; } /** * Get crypto status information * @returns {Object} Status information */ getStatus() { // Convert group keys Map to object for UI consumption const groups = {}; this.groupKeys.forEach((groupKey, groupId) => { groups[groupId] = { publicKey: groupKey.pub, created: groupKey.created || Date.now() // Use stored creation time or fallback to current time }; }); return { initialized: this.initialized, enabled: this.encryptionEnabled, hasKeypair: !!this.keypair, publicKey: this.getPublicKey(), peerCount: this.peerKeys.size, groupCount: this.groupKeys.size, groups, stats: { ...this.stats } }; } /** * Store a peer's public keys (both pub and epub) * @param {string} peerId - The peer ID * @param {string|Object} publicKey - The peer's public key(s) - can be string (pub) or object with {pub, epub} */ addPeerKey(peerId, publicKey) { if (!publicKey) return false; // Handle both string (just pub) and object (pub + epub) formats let keyData; if (typeof publicKey === 'string') { keyData = { pub: publicKey, epub: null }; } else if (typeof publicKey === 'object' && (publicKey.pub || publicKey.epub)) { keyData = publicKey; } else { return false; } // Check if we already have the same key for this peer to prevent duplicate key exchange events const existingKey = this.peerKeys.get(peerId); if (existingKey) { // Compare both pub and epub keys to determine if this is actually a new key const pubMatches = existingKey.pub === keyData.pub; const epubMatches = existingKey.epub === keyData.epub; if (pubMatches && epubMatches) { // This is a duplicate key exchange - don't emit event or increment stats return false; } } this.peerKeys.set(peerId, keyData); this.stats.keyExchanges++; this.emit('peerKeyAdded', { peerId, publicKey: keyData }); return true; } /** * Remove a peer's public key * @param {string} peerId - The peer ID */ removePeerKey(peerId) { const removed = this.peerKeys.delete(peerId); if (removed) { this.emit('peerKeyRemoved', { peerId }); } return removed; } /** * Encrypt a message for a specific peer * @param {any} message - The message to encrypt * @param {string} peerId - The target peer ID * @returns {Promise<Object>} Encrypted message object */ async encryptForPeer(message, peerId) { if (!this.encryptionEnabled) { return { encrypted: false, data: message }; } const peerKeyData = this.peerKeys.get(peerId); if (!peerKeyData) { throw new Error(`No public key found for peer ${peerId}`); } // Check if we have the encryption public key (epub) if (!peerKeyData.epub) { throw new Error(`No encryption public key (epub) found for peer ${peerId}. Only regular public key (pub) available.`); } const startTime = Date.now(); try { const nonce = await this.generateNonce(); const serialized = JSON.stringify(message); // Create a keypair object for unsea with both pub and epub const peerKeypair = { pub: peerKeyData.pub, epub: peerKeyData.epub }; const encrypted = await this.unsea.encryptMessageWithMeta(serialized, peerKeypair); const result = { encrypted: true, data: encrypted, from: this.getPublicKey(), nonce, timestamp: Date.now() }; this.stats.messagesEncrypted++; this.stats.encryptionTime += Date.now() - startTime; return result; } catch (error) { this.debug.error('Peer encryption failed:', error); throw error; } } /** * Decrypt a message from a peer * @param {Object} encryptedData - The encrypted message object * @returns {Promise<any>} The decrypted message */ async decryptFromPeer(encryptedData) { if (!this.encryptionEnabled || !encryptedData.encrypted) { return encryptedData.data || encryptedData; } // Check for replay attacks if (encryptedData.nonce && this.messageNonces.has(encryptedData.nonce)) { throw new Error('Replay attack detected: duplicate nonce'); } const startTime = Date.now(); try { // Use unsea's decryptMessageWithMeta - pass our ephemeral private key (epriv) const decrypted = await this.unsea.decryptMessageWithMeta(encryptedData.data, this.keypair.epriv); const parsed = JSON.parse(decrypted); // Store nonce to prevent replay if (encryptedData.nonce) { this.messageNonces.add(encryptedData.nonce); } this.stats.messagesDecrypted++; this.stats.decryptionTime += Date.now() - startTime; return parsed; } catch (error) { this.debug.error('Peer decryption failed:', error); throw error; } } /** * Sign data with our private key * @param {any} data - The data to sign * @returns {Promise<string>} The signature */ async sign(data) { if (!this.encryptionEnabled) return null; try { const serialized = typeof data === 'string' ? data : JSON.stringify(data); return await this.unsea.signMessage(serialized, this.keypair.priv); } catch (error) { this.debug.error('Signing failed:', error); throw error; } } /** * Verify a signature * @param {string} signature - The signature to verify * @param {any} data - The original data * @param {string} publicKey - The signer's public key * @returns {Promise<boolean>} Whether the signature is valid */ async verify(signature, data, publicKey) { if (!this.encryptionEnabled) return true; try { const serialized = typeof data === 'string' ? data : JSON.stringify(data); return await this.unsea.verifyMessage(serialized, signature, publicKey); } catch (error) { this.debug.error('Signature verification failed:', error); return false; } } /** * Generate a shared group key * @param {string} groupId - The group identifier * @returns {Promise<Object>} The group key pair */ async generateGroupKey(groupId) { try { const groupKey = await this.unsea.generateRandomPair(); // Add metadata to the group key const groupKeyWithMeta = { ...groupKey, created: Date.now(), groupId }; this.groupKeys.set(groupId, groupKeyWithMeta); this.emit('groupKeyGenerated', { groupId, publicKey: groupKey.pub }); return groupKeyWithMeta; } catch (error) { this.debug.error('Group key generation failed:', error); throw error; } } /** * Add an existing group key * @param {string} groupId - The group identifier * @param {Object} groupKey - The group key pair */ addGroupKey(groupId, groupKey) { this.groupKeys.set(groupId, groupKey); this.emit('groupKeyAdded', { groupId, publicKey: groupKey.pub }); } /** * Encrypt a message for a group * @param {any} message - The message to encrypt * @param {string} groupId - The group identifier * @returns {Promise<Object>} Encrypted message object */ async encryptForGroup(message, groupId) { if (!this.encryptionEnabled) { return { encrypted: false, data: message }; } const groupKey = this.groupKeys.get(groupId); if (!groupKey) { throw new Error(`No group key found for group ${groupId}`); } const startTime = Date.now(); try { const nonce = await this.generateNonce(); const serialized = JSON.stringify(message); const encrypted = await this.unsea.encryptMessageWithMeta(serialized, groupKey); const result = { encrypted: true, group: true, groupId, data: encrypted, from: this.getPublicKey(), nonce, timestamp: Date.now() }; this.stats.messagesEncrypted++; this.stats.encryptionTime += Date.now() - startTime; return result; } catch (error) { this.debug.error('Group encryption failed:', error); throw error; } } /** * Decrypt a group message * @param {Object} encryptedData - The encrypted message object * @returns {Promise<any>} The decrypted message */ async decryptFromGroup(encryptedData) { if (!this.encryptionEnabled || !encryptedData.encrypted || !encryptedData.group) { return encryptedData.data || encryptedData; } const groupKey = this.groupKeys.get(encryptedData.groupId); if (!groupKey) { throw new Error(`No group key found for group ${encryptedData.groupId}`); } // Check for replay attacks if (encryptedData.nonce && this.messageNonces.has(encryptedData.nonce)) { throw new Error('Replay attack detected: duplicate nonce'); } const startTime = Date.now(); try { const decrypted = await this.unsea.decryptMessageWithMeta(encryptedData.data, groupKey.epriv); const parsed = JSON.parse(decrypted); // Store nonce to prevent replay if (encryptedData.nonce) { this.messageNonces.add(encryptedData.nonce); } this.stats.messagesDecrypted++; this.stats.decryptionTime += Date.now() - startTime; return parsed; } catch (error) { this.debug.error('Group decryption failed:', error); throw error; } } /** * Generate a cryptographically secure nonce * @returns {Promise<string>} A unique nonce */ async generateNonce() { // Generate a simple nonce using timestamp and random const timestamp = Date.now().toString(); const random = Math.random().toString(36).substring(2); const combined = `${timestamp}-${random}-${Math.floor(Math.random() * 1000000)}`; // Use crypto.subtle to hash the combined string if available if (typeof crypto !== 'undefined' && crypto.subtle) { try { const encoder = new TextEncoder(); const data = encoder.encode(combined); const hashBuffer = await crypto.subtle.digest('SHA-256', data); const hashArray = Array.from(new Uint8Array(hashBuffer)); return hashArray.map(b => b.toString(16).padStart(2, '0')).join('').substring(0, 16); } catch (error) { this.debug.warn('Could not use crypto.subtle for nonce generation:', error); } } // Fallback to simple combined string return combined; } /** * Start periodic cleanup of old nonces */ startNonceCleanup() { // Store the interval ID for cleanup this.nonceCleanupInterval = setInterval(() => { try { // Remove old nonces (this is a simplified approach) // In a real implementation, you'd store nonces with timestamps if (this.messageNonces.size > 1000) { this.messageNonces.clear(); } } catch (error) { console.error('Error during nonce cleanup:', error); } }, 60000); // Clean up every minute } /** * Export public key for sharing * @returns {Object} Export data */ exportPublicKey() { if (!this.keypair) return null; return { pub: this.keypair.pub, epub: this.keypair.epub, algorithm: 'ECDSA', created: Date.now() }; } /** * Clear all stored keys and reset state */ reset() { // Clear the nonce cleanup interval if (this.nonceCleanupInterval) { clearInterval(this.nonceCleanupInterval); this.nonceCleanupInterval = null; } this.keypair = null; this.peerKeys.clear(); this.groupKeys.clear(); this.messageNonces.clear(); this.encryptionEnabled = false; this.initialized = false; // Reset stats this.stats = { messagesEncrypted: 0, messagesDecrypted: 0, encryptionTime: 0, decryptionTime: 0, keyExchanges: 0 }; this.emit('cryptoReset'); } /** * Test crypto functionality * @returns {Promise<Object>} Test results */ async runSelfTest() { const results = { keypairGeneration: false, encryption: false, decryption: false, signing: false, verification: false, groupEncryption: false, errors: [] }; // Check if crypto is properly initialized if (!this.unsea) { results.errors.push('Unsea library not loaded'); return results; } if (!this.initialized) { results.errors.push('CryptoManager not initialized'); return results; } this.debug.log('🔍 Debug: unsea object:', this.unsea); this.debug.log('🔍 Debug: available methods:', Object.keys(this.unsea)); try { // Test keypair generation this.debug.log('🧪 Testing keypair generation...'); const testKeypair = await this.unsea.generateRandomPair(); this.debug.log('🔍 Generated keypair:', testKeypair); results.keypairGeneration = !!(testKeypair && (testKeypair.pub || testKeypair.publicKey)); // Test encryption/decryption - create two keypairs to simulate peer-to-peer encryption this.debug.log('🧪 Testing encryption...'); const testMessage = 'Hello, crypto world!'; // Create a second keypair to simulate a peer const peerKeypair = await this.unsea.generateRandomPair(); // Encrypt from our keypair TO the peer keypair const encrypted = await this.unsea.encryptMessageWithMeta(testMessage, peerKeypair); this.debug.log('🔍 Encrypted result:', encrypted); results.encryption = !!encrypted; this.debug.log('🧪 Testing decryption...'); // Use the ephemeral private key (epriv) for decryption as shown in the example const decrypted = await this.unsea.decryptMessageWithMeta(encrypted, peerKeypair.epriv); this.debug.log('🔍 Decrypted result:', decrypted); results.decryption = decrypted === testMessage; // Test signing/verification this.debug.log('🧪 Testing signing...'); const signature = await this.unsea.signMessage(testMessage, this.keypair.priv); this.debug.log('🔍 Signature:', signature); results.signing = !!signature; this.debug.log('🧪 Testing verification...'); const verified = await this.unsea.verifyMessage(testMessage, signature, this.keypair.pub); this.debug.log('🔍 Verification result:', verified); results.verification = verified === true; // Test group encryption (same as regular encryption with different key) this.debug.log('🧪 Testing group encryption...'); const groupKey = await this.unsea.generateRandomPair(); const groupEncrypted = await this.unsea.encryptMessageWithMeta(testMessage, groupKey); const groupDecrypted = await this.unsea.decryptMessageWithMeta(groupEncrypted, groupKey.epriv); results.groupEncryption = groupDecrypted === testMessage; } catch (error) { this.debug.error('🔍 Self-test error:', error); results.errors.push(error.message); } this.debug.log('🔍 Final test results:', results); return results; } }