From 1cc873223ab73940e962b567dfd8df227d887a2c Mon Sep 17 00:00:00 2001 From: lockbitchat Date: Mon, 18 May 2026 19:49:57 -0400 Subject: [PATCH] fix: stabilize manual WebRTC join flow --- src/app.jsx | 33 +-- src/network/EnhancedSecureWebRTCManager.js | 279 +++++++++++++++++++-- tests/sas-verification.test.mjs | 232 +++++++++++++++++ tests/webrtc-privacy-mode.test.mjs | 4 + 4 files changed, 505 insertions(+), 43 deletions(-) diff --git a/src/app.jsx b/src/app.jsx index 1ad67a4..2af8681 100644 --- a/src/app.jsx +++ b/src/app.jsx @@ -338,9 +338,11 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; markAnswerCreated, notificationIntegrationRef, isGeneratingKeys, + setIsGeneratingKeys, handleCreateOffer, relayOnlyMode, - setRelayOnlyMode + setRelayOnlyMode, + webrtcManagerRef }) => { const [mode, setMode] = React.useState('select'); const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false); @@ -784,6 +786,7 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; text: (() => { try { const min = typeof offerData === 'object' ? JSON.stringify(offerData) : (offerData || ''); + if (!min) return ''; if (typeof window.encodeBinaryToPrefixed === 'function') { return window.encodeBinaryToPrefixed(min); } @@ -1111,6 +1114,7 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; text: (() => { try { const min = typeof answerData === 'object' ? JSON.stringify(answerData) : (answerData || ''); + if (!min) return ''; if (typeof window.encodeBinaryToPrefixed === 'function') { return window.encodeBinaryToPrefixed(min); } @@ -1609,22 +1613,16 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; // Check if we should preserve answer data const shouldPreserveAnswerData = () => { - const now = Date.now(); - const answerAge = now - (connectionState.answerCreatedAt || 0); - const maxPreserveTime = 300000; - + const hasAnswerData = !!answerData || + (answerInput && typeof answerInput === 'string' && answerInput.trim().length > 0); - const hasAnswerData = (answerData && typeof answerData === 'string' && answerData.trim().length > 0) || - (answerInput && answerInput.trim().length > 0); - - const hasAnswerQR = qrCodeUrl && qrCodeUrl.trim().length > 0; + const hasAnswerQR = qrCodeUrl && typeof qrCodeUrl === 'string' && qrCodeUrl.trim().length > 0; const shouldPreserve = (connectionState.hasActiveAnswer && - answerAge < maxPreserveTime && !connectionState.isUserInitiatedDisconnect) || - (hasAnswerData && answerAge < maxPreserveTime && + (hasAnswerData && !connectionState.isUserInitiatedDisconnect) || - (hasAnswerQR && answerAge < maxPreserveTime && + (hasAnswerQR && !connectionState.isUserInitiatedDisconnect); @@ -3107,12 +3105,13 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; console.warn('Answer QR generation failed:', e); } - // Mark answer as created for state management - if (answerInput.trim().length > 0) { + // Mark generated answers as active immediately. + // `answerInput` is empty on the joiner path + // because the response was created locally, + // not pasted by the user. if (typeof markAnswerCreated === 'function') { markAnswerCreated(); } - } const existingResponseMessages = messages.filter(m => @@ -3747,9 +3746,11 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js'; markAnswerCreated: markAnswerCreated, notificationIntegrationRef: notificationIntegrationRef, isGeneratingKeys: isGeneratingKeys, + setIsGeneratingKeys: setIsGeneratingKeys, handleCreateOffer: handleCreateOffer, relayOnlyMode: relayOnlyMode, - setRelayOnlyMode: setRelayOnlyMode + setRelayOnlyMode: setRelayOnlyMode, + webrtcManagerRef: webrtcManagerRef }) ), diff --git a/src/network/EnhancedSecureWebRTCManager.js b/src/network/EnhancedSecureWebRTCManager.js index 7b49fa7..a75d39b 100644 --- a/src/network/EnhancedSecureWebRTCManager.js +++ b/src/network/EnhancedSecureWebRTCManager.js @@ -103,6 +103,9 @@ class EnhancedSecureWebRTCManager { static PROTOCOL_VERSION = '4.1'; static MAX_SAS_ATTEMPTS = 3; static DEFAULT_ICE_SERVERS = Object.freeze([ + // Keep multiple independent public STUN defaults so one provider-side + // DNS/path failure does not strand standard-mode connectivity. + Object.freeze({ urls: 'stun:stun.cloudflare.com:3478' }), Object.freeze({ urls: 'stun:stun.l.google.com:19302' }), Object.freeze({ urls: 'stun:stun1.l.google.com:19302' }), Object.freeze({ urls: 'stun:stun2.l.google.com:19302' }), @@ -953,6 +956,123 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida if (timer && this._activeTimers) this._activeTimers.delete(timer); } + _setSASMaterialReady(localFingerprint, remoteFingerprint) { + this._sasLocalFingerprint = localFingerprint; + this._sasRemoteFingerprint = remoteFingerprint; + } + + _isVerificationReady() { + const hasDescriptions = !!( + this.peerConnection?.localDescription && + this.peerConnection?.remoteDescription + ); + const hasOpenDataChannel = this.dataChannel?.readyState === 'open'; + const hasVerificationCode = typeof this.verificationCode === 'string' && + this.verificationCode.trim().length > 0; + const hasFingerprintMaterial = typeof this._sasLocalFingerprint === 'string' && + this._sasLocalFingerprint.trim().length > 0 && + typeof this._sasRemoteFingerprint === 'string' && + this._sasRemoteFingerprint.trim().length > 0; + + return hasDescriptions && hasOpenDataChannel && hasVerificationCode && hasFingerprintMaterial; + } + + _notifyVerificationReadyIfPossible() { + if (!this._isVerificationReady()) { + return false; + } + + if (!this._verificationUiOpened) { + this._verificationUiOpened = true; + this.onStatusChange?.('verifying'); + this.onVerificationRequired?.(this.verificationCode); + } + + return true; + } + + _countIceCandidatesInSDP(sdp) { + if (typeof sdp !== 'string') return 0; + return (sdp.match(/^a=candidate:/gm) || []).length; + } + + _summarizeIceCandidatesInSDP(sdp) { + const summary = { + total: 0, + host: 0, + srflx: 0, + relay: 0, + prflx: 0, + unknown: 0 + }; + + if (typeof sdp !== 'string') return summary; + + for (const line of sdp.match(/^a=candidate:.*$/gm) || []) { + summary.total += 1; + const match = line.match(/\btyp\s+(host|srflx|relay|prflx)\b/i); + const type = match?.[1]?.toLowerCase(); + + if (type && Object.prototype.hasOwnProperty.call(summary, type)) { + summary[type] += 1; + } else { + summary.unknown += 1; + } + } + + return summary; + } + + async _collectIceFailureDiagnostics() { + if (!this.peerConnection?.getStats) return null; + + try { + const stats = await this.peerConnection.getStats(); + const candidates = new Map(); + const candidatePairs = []; + + stats.forEach((report) => { + if (report.type === 'local-candidate' || report.type === 'remote-candidate') { + candidates.set(report.id, { + type: report.type, + candidateType: report.candidateType, + protocol: report.protocol, + address: report.address || report.ip || null, + port: report.port || null, + networkType: report.networkType || null + }); + } + }); + + stats.forEach((report) => { + if (report.type !== 'candidate-pair') return; + candidatePairs.push({ + state: report.state, + nominated: !!report.nominated, + writable: !!report.writable, + bytesSent: report.bytesSent || 0, + bytesReceived: report.bytesReceived || 0, + currentRoundTripTime: report.currentRoundTripTime ?? null, + local: candidates.get(report.localCandidateId) || null, + remote: candidates.get(report.remoteCandidateId) || null + }); + }); + + return { + pairCount: candidatePairs.length, + states: candidatePairs.reduce((acc, pair) => { + acc[pair.state || 'unknown'] = (acc[pair.state || 'unknown'] || 0) + 1; + return acc; + }, {}), + pairs: candidatePairs + }; + } catch (error) { + return { + error: error?.message || 'Failed to collect ICE diagnostics' + }; + } + } + /** * Execute all maintenance tasks in a single cycle */ @@ -4310,6 +4430,9 @@ this._secureLog('info', '🔒 Enhanced Mutex system fully initialized and valida this.lastSecurityLevelNotification = null; this.verificationNotificationSent = false; this.verificationInitiationSent = false; + this._verificationUiOpened = false; + this._sasLocalFingerprint = null; + this._sasRemoteFingerprint = null; this.disconnectNotificationSent = false; this.reconnectionFailedNotificationSent = false; this.peerDisconnectNotificationSent = false; @@ -7048,6 +7171,9 @@ async processMessage(data) { this.verificationCode = null; this.pendingSASCode = null; this.sasValidationAttempts = 0; + this._verificationUiOpened = false; + this._sasLocalFingerprint = null; + this._sasRemoteFingerprint = null; // Clear key fingerprint and connection data this.keyFingerprint = null; @@ -7302,9 +7428,14 @@ async processMessage(data) { this.peerConnection.onconnectionstatechange = () => { const state = this.peerConnection.connectionState; + console.info('[SecureBit ICE] connection state changed', { + connectionState: state, + iceConnectionState: this.peerConnection.iceConnectionState, + iceGatheringState: this.peerConnection.iceGatheringState + }); if (state === 'connected' && !this.isVerified) { - this.onStatusChange('verifying'); + this._notifyVerificationReadyIfPossible(); } else if (state === 'connected' && this.isVerified) { this.onStatusChange('connected'); } else if (state === 'disconnected' || state === 'closed') { @@ -7313,19 +7444,48 @@ async processMessage(data) { this.onStatusChange('disconnected'); setTimeout(() => this.disconnect(), 100); } else { - this.onStatusChange('disconnected'); - // Clear verification states on unexpected disconnect - this._clearVerificationStates(); + // Only emit disconnect if we were already verified (active session), + // otherwise keep the UI alive so the user can copy the answer. + if (this.isVerified || state === 'closed') { + this.onStatusChange('disconnected'); + // Clear verification states on unexpected disconnect + this._clearVerificationStates(); + } else { + console.warn(`[SecureBit ICE] State is ${state} but not verified yet. Keeping session open for manual exchange.`); + } } } else if (state === 'failed') { - // Do not auto-reconnect to avoid closing the session on errors - this.onStatusChange('disconnected'); - + this._collectIceFailureDiagnostics().then((diagnostics) => { + console.warn('[SecureBit ICE] failure diagnostics', diagnostics); + }); + + // Do not auto-reconnect or close session during setup. + if (this.isVerified) { + this.onStatusChange('disconnected'); + } else { + console.warn('[SecureBit ICE] State is failed but not verified yet. Keeping session open for manual exchange.'); + } } else { this.onStatusChange(state); } }; + this.peerConnection.oniceconnectionstatechange = () => { + console.info('[SecureBit ICE] ICE connection state changed', { + connectionState: this.peerConnection.connectionState, + iceConnectionState: this.peerConnection.iceConnectionState, + iceGatheringState: this.peerConnection.iceGatheringState + }); + }; + + this.peerConnection.onicecandidateerror = (event) => { + console.warn('[SecureBit ICE] ICE candidate error', { + url: event.url, + errorCode: event.errorCode, + errorText: event.errorText + }); + }; + this.peerConnection.ondatachannel = (event) => { // CRITICAL: Store the received data channel @@ -7395,7 +7555,7 @@ async processMessage(data) { this.notifySecurityUpdate(); }, 500); } else { - this.onStatusChange('verifying'); + this._notifyVerificationReadyIfPossible(); this.initiateVerification(); } this.startHeartbeat(); @@ -9503,8 +9663,34 @@ async processMessage(data) { // Continue without fingerprint validation (fallback mode) } - // Await ICE gathering - await this.waitForIceGathering(); + // Await ICE gathering. Manual out-of-band exchange does not use + // trickle ICE, so exporting an SDP before completion can strand + // late candidates that the remote peer will never receive. + const offerIceGatheringStartedAt = Date.now(); + const offerIceGatheringCompleted = await this.waitForIceGathering(); + const offerCandidateSummary = this._summarizeIceCandidatesInSDP(this.peerConnection.localDescription?.sdp); + const offerCandidateCount = offerCandidateSummary.total; + if (!offerIceGatheringCompleted && offerCandidateCount === 0) { + throw new Error('ICE gathering did not produce candidates before invitation export'); + } + this._secureLog(offerCandidateCount > 0 ? 'info' : 'warn', 'ICE candidates captured for offer export', { + candidateSummary: offerCandidateSummary, + iceGatheringState: this.peerConnection.iceGatheringState, + iceGatheringDurationMs: Date.now() - offerIceGatheringStartedAt, + iceGatheringCompleted: offerIceGatheringCompleted + }); + console.info('[SecureBit ICE] offer export', { + candidateSummary: offerCandidateSummary, + iceGatheringState: this.peerConnection.iceGatheringState, + iceGatheringDurationMs: Date.now() - offerIceGatheringStartedAt, + iceGatheringCompleted: offerIceGatheringCompleted + }); + if (!offerIceGatheringCompleted) { + this.deliverMessageToUI('ICE gathering timed out before completion, but available candidates were included in the invitation. Connectivity may still fail on restrictive networks.', 'system'); + } + if (offerCandidateCount === 0) { + this.deliverMessageToUI('No ICE candidates were gathered for the invitation yet. The peer connection may fail unless network candidates become available.', 'system'); + } this._secureLog('debug', 'ICE gathering completed', { operationId: operationId, @@ -9663,7 +9849,7 @@ async processMessage(data) { // Re-throw for upper-level handling throw error; } - }, 15000); // 15 seconds timeout for the entire offer creation + }, 60000); // 60 seconds timeout for the entire offer creation } /** @@ -10136,6 +10322,10 @@ async processMessage(data) { type: 'offer', sdp: offerData.s || offerData.sdp })); + console.info('[SecureBit ICE] remote offer applied', { + candidateSummary: this._summarizeIceCandidatesInSDP(this.peerConnection.remoteDescription?.sdp), + signalingState: this.peerConnection.signalingState + }); this._secureLog('debug', 'Remote description set successfully', { operationId: operationId, @@ -10203,8 +10393,7 @@ async processMessage(data) { const localFP = this.expectedDTLSFingerprint; const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint); this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP); - this.onStatusChange?.('verifying'); - this.onVerificationRequired(this.verificationCode); + this._setSASMaterialReady(localFP, remoteFP); } catch (sasError) { this._secureLog('error', 'SAS computation failed in createSecureAnswer (Answer side)', { errorType: sasError?.constructor?.name || 'Unknown' @@ -10213,8 +10402,34 @@ async processMessage(data) { } - // Await ICE gathering - await this.waitForIceGathering(); + // Await ICE gathering. Manual out-of-band exchange does not use + // trickle ICE, so exporting an SDP before completion can strand + // late candidates that the remote peer will never receive. + const answerIceGatheringStartedAt = Date.now(); + const answerIceGatheringCompleted = await this.waitForIceGathering(); + const answerCandidateSummary = this._summarizeIceCandidatesInSDP(this.peerConnection.localDescription?.sdp); + const answerCandidateCount = answerCandidateSummary.total; + if (!answerIceGatheringCompleted && answerCandidateCount === 0) { + throw new Error('ICE gathering did not produce candidates before response export'); + } + this._secureLog(answerCandidateCount > 0 ? 'info' : 'warn', 'ICE candidates captured for answer export', { + candidateSummary: answerCandidateSummary, + iceGatheringState: this.peerConnection.iceGatheringState, + iceGatheringDurationMs: Date.now() - answerIceGatheringStartedAt, + iceGatheringCompleted: answerIceGatheringCompleted + }); + console.info('[SecureBit ICE] answer export', { + candidateSummary: answerCandidateSummary, + iceGatheringState: this.peerConnection.iceGatheringState, + iceGatheringDurationMs: Date.now() - answerIceGatheringStartedAt, + iceGatheringCompleted: answerIceGatheringCompleted + }); + if (!answerIceGatheringCompleted) { + this.deliverMessageToUI('ICE gathering timed out before completion, but available candidates were included in the response. Connectivity may still fail on restrictive networks.', 'system'); + } + if (answerCandidateCount === 0) { + this.deliverMessageToUI('No ICE candidates were gathered for the response yet. The peer connection may fail unless network candidates become available.', 'system'); + } this._secureLog('debug', 'ICE gathering completed for answer', { operationId: operationId, @@ -10426,7 +10641,7 @@ async processMessage(data) { // Re-throw for upper-level handling throw error; } - }, 20000); // 20 seconds timeout for the entire answer creation (longer than offer) + }, 60000); // 60 seconds timeout for the entire answer creation (longer than offer) } /** @@ -10686,11 +10901,12 @@ async processMessage(data) { throw new Error('Response data is too old – possible replay attack'); } - // Check protocol version compatibility - if (answerData.version !== '4.0') { + // Check protocol version compatibility using the normalized field so + // compact and legacy answer payloads are handled consistently. + if (answerVersion !== EnhancedSecureWebRTCManager.PROTOCOL_VERSION) { window.EnhancedSecureCryptoUtils.secureLog.log('warn', 'Incompatible protocol version in answer', { - expectedVersion: '4.0', - receivedVersion: answerData.version + expectedVersion: EnhancedSecureWebRTCManager.PROTOCOL_VERSION, + receivedVersion: answerVersion }); } @@ -10818,8 +11034,7 @@ async processMessage(data) { const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint); this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP); - this.onStatusChange?.('verifying'); - this.onVerificationRequired(this.verificationCode); + this._setSASMaterialReady(localFP, remoteFP); // CRITICAL: Store SAS code to send when data channel opens this.pendingSASCode = this.verificationCode; @@ -10869,6 +11084,13 @@ async processMessage(data) { // Support both full and compact SDP field names const sdpData = answerData.sdp || answerData.s; + + if (this.peerConnection?.signalingState !== 'have-local-offer') { + this._secureLog('warn', 'Ignoring answer outside have-local-offer state', { + signalingState: this.peerConnection?.signalingState || 'unknown' + }); + return; + } this._secureLog('debug', 'Setting remote description from answer', { sdpLength: sdpData?.length || 0, @@ -10879,6 +11101,10 @@ async processMessage(data) { type: 'answer', sdp: sdpData }); + console.info('[SecureBit ICE] remote answer applied', { + candidateSummary: this._summarizeIceCandidatesInSDP(this.peerConnection.remoteDescription?.sdp), + signalingState: this.peerConnection.signalingState + }); this._secureLog('debug', 'Remote description set successfully from answer', { signalingState: this.peerConnection.signalingState @@ -11165,8 +11391,7 @@ async processMessage(data) { } this.verificationCode = data.code; - this.onStatusChange?.('verifying'); - this.onVerificationRequired(this.verificationCode); + this._notifyVerificationReadyIfPossible(); this._secureLog('info', 'SAS code received from Offer side', { sasCode: this.verificationCode, @@ -11565,14 +11790,14 @@ async processMessage(data) { waitForIceGathering() { return new Promise((resolve) => { if (this.peerConnection.iceGatheringState === 'complete') { - resolve(); + resolve(true); return; } const checkState = () => { if (this.peerConnection && this.peerConnection.iceGatheringState === 'complete') { this.peerConnection.removeEventListener('icegatheringstatechange', checkState); - resolve(); + resolve(true); } }; @@ -11582,7 +11807,7 @@ async processMessage(data) { if (this.peerConnection) { this.peerConnection.removeEventListener('icegatheringstatechange', checkState); } - resolve(); + resolve(this.peerConnection?.iceGatheringState === 'complete'); }, EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT); }); } diff --git a/tests/sas-verification.test.mjs b/tests/sas-verification.test.mjs index aa812fc..e1bd696 100644 --- a/tests/sas-verification.test.mjs +++ b/tests/sas-verification.test.mjs @@ -45,6 +45,32 @@ function createSASManager() { }; } +function createVerificationReadinessManager({ + localDescription = { type: 'answer' }, + remoteDescription = { type: 'offer' }, + dataChannelState = 'connecting', + verificationCode = 'A1-B2-C3', + localFingerprint = 'AA:BB', + remoteFingerprint = 'CC:DD' +} = {}) { + const notifications = []; + return { + peerConnection: { localDescription, remoteDescription }, + dataChannel: { readyState: dataChannelState }, + verificationCode, + _sasLocalFingerprint: localFingerprint, + _sasRemoteFingerprint: remoteFingerprint, + notifications, + _isVerificationReady: EnhancedSecureWebRTCManager.prototype._isVerificationReady, + onStatusChange(status) { + notifications.push({ kind: 'status', value: status }); + }, + onVerificationRequired(code) { + notifications.push({ kind: 'verification', value: code }); + } + }; +} + // testSASNormalization { const manager = createFakeManager(); @@ -172,4 +198,210 @@ function createSASManager() { ); } +// ICE diagnostics classify candidate types so connectivity failures are visible. +{ + const manager = createSASManager(); + const sdp = [ + 'v=0', + 'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host', + 'a=candidate:2 1 UDP 1686052607 203.0.113.10 40000 typ srflx raddr 192.168.1.2 rport 54400', + 'a=candidate:3 1 UDP 41819902 198.51.100.20 50000 typ relay raddr 0.0.0.0 rport 0', + 'a=candidate:4 1 UDP 1518280447 198.51.100.30 60000 typ prflx', + 'a=candidate:5 1 UDP 1518280447 198.51.100.40 61000 generation 0' + ].join('\r\n'); + + assert.deepEqual( + EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call(manager, sdp), + { total: 5, host: 1, srflx: 1, relay: 1, prflx: 1, unknown: 1 } + ); +} + +// Manual exchange must not treat an ICE gathering timeout as completion. +{ + const listeners = new Map(); + const manager = { + peerConnection: { + iceGatheringState: 'gathering', + addEventListener(eventName, handler) { + listeners.set(eventName, handler); + }, + removeEventListener(eventName) { + listeners.delete(eventName); + } + } + }; + + const originalTimeout = EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT; + EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = 0; + try { + assert.equal( + await EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager), + false + ); + } finally { + EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT = originalTimeout; + } +} + +// A timed-out ICE gathering can still yield usable candidates for manual export. +{ + const summary = EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call( + createSASManager(), + 'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host\r\n' + ); + assert.equal(summary.total > 0, true); +} + +// ICE gathering resolves positively only after the peer reports completion. +{ + let listener = null; + const manager = { + peerConnection: { + iceGatheringState: 'gathering', + addEventListener(_eventName, handler) { + listener = handler; + }, + removeEventListener() {} + } + }; + + const gathering = EnhancedSecureWebRTCManager.prototype.waitForIceGathering.call(manager); + manager.peerConnection.iceGatheringState = 'complete'; + listener(); + assert.equal(await gathering, true); +} + +// ICE failure diagnostics summarize candidate-pair states without crashing. +{ + const reports = new Map([ + ['local-1', { id: 'local-1', type: 'local-candidate', candidateType: 'host', protocol: 'udp', address: '192.168.1.2', port: 5000 }], + ['remote-1', { id: 'remote-1', type: 'remote-candidate', candidateType: 'srflx', protocol: 'udp', address: '203.0.113.10', port: 6000 }], + ['pair-1', { id: 'pair-1', type: 'candidate-pair', state: 'failed', nominated: false, writable: false, bytesSent: 0, bytesReceived: 0, localCandidateId: 'local-1', remoteCandidateId: 'remote-1' }] + ]); + + const manager = { + peerConnection: { + async getStats() { + return reports; + } + } + }; + + assert.deepEqual( + await EnhancedSecureWebRTCManager.prototype._collectIceFailureDiagnostics.call(manager), + { + pairCount: 1, + states: { failed: 1 }, + pairs: [{ + state: 'failed', + nominated: false, + writable: false, + bytesSent: 0, + bytesReceived: 0, + currentRoundTripTime: null, + local: { + type: 'local-candidate', + candidateType: 'host', + protocol: 'udp', + address: '192.168.1.2', + port: 5000, + networkType: null + }, + remote: { + type: 'remote-candidate', + candidateType: 'srflx', + protocol: 'udp', + address: '203.0.113.10', + port: 6000, + networkType: null + } + }] + } + ); +} + +// Remote SDP candidate summaries use the same parser as local diagnostics. +{ + const sdp = [ + 'v=0', + 'a=candidate:1 1 UDP 2122252543 192.168.1.2 54400 typ host', + 'a=candidate:2 1 UDP 1686052607 203.0.113.10 40000 typ srflx' + ].join('\r\n'); + assert.deepEqual( + EnhancedSecureWebRTCManager.prototype._summarizeIceCandidatesInSDP.call(createSASManager(), sdp), + { total: 2, host: 1, srflx: 1, relay: 0, prflx: 0, unknown: 0 } + ); +} + +// Joining with an offer and generating an answer does not open verification +// before the answer has been applied by the creator and the channel opens. +{ + const joiner = createVerificationReadinessManager({ + dataChannelState: 'connecting' + }); + assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(joiner), false); + assert.equal( + EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(joiner), + false + ); + assert.deepEqual(joiner.notifications, []); +} + +// The creator has applied the answer only once both descriptions exist; even +// then verification waits for a real ready transport. +{ + const creatorBeforeAnswer = createVerificationReadinessManager({ + remoteDescription: null, + dataChannelState: 'open' + }); + assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(creatorBeforeAnswer), false); + + const creatorAfterAnswerBeforeOpen = createVerificationReadinessManager({ + dataChannelState: 'connecting' + }); + assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(creatorAfterAnswerBeforeOpen), false); +} + +// Verification opens only after negotiated descriptions, open data channel, and +// valid SAS fingerprint material are all present. +{ + const missingFingerprint = createVerificationReadinessManager({ + dataChannelState: 'open', + remoteFingerprint: '' + }); + assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(missingFingerprint), false); + + const ready = createVerificationReadinessManager({ + dataChannelState: 'open' + }); + assert.equal(EnhancedSecureWebRTCManager.prototype._isVerificationReady.call(ready), true); + assert.equal( + EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(ready), + true + ); + assert.deepEqual(ready.notifications, [ + { kind: 'status', value: 'verifying' }, + { kind: 'verification', value: 'A1-B2-C3' } + ]); + + // Existing happy path stays idempotent after the UI is opened once. + EnhancedSecureWebRTCManager.prototype._notifyVerificationReadyIfPossible.call(ready); + assert.equal(ready.notifications.length, 2); +} + +// SDP diagnostics distinguish candidate-less exports from usable manual payloads. +{ + assert.equal( + EnhancedSecureWebRTCManager.prototype._countIceCandidatesInSDP.call({}, 'v=0\r\na=mid:0'), + 0 + ); + assert.equal( + EnhancedSecureWebRTCManager.prototype._countIceCandidatesInSDP.call( + {}, + 'v=0\r\na=candidate:1 1 udp 1 192.0.2.1 1234 typ host\r\na=candidate:2 1 udp 1 198.51.100.1 2345 typ srflx' + ), + 2 + ); +} + console.log('SAS verification tests passed'); diff --git a/tests/webrtc-privacy-mode.test.mjs b/tests/webrtc-privacy-mode.test.mjs index d29b83f..a2ecc9c 100644 --- a/tests/webrtc-privacy-mode.test.mjs +++ b/tests/webrtc-privacy-mode.test.mjs @@ -102,6 +102,10 @@ function fake(config = {}) { // ICE defaults are centralized and operator overrides remain untouched. { assert.equal(Array.isArray(EnhancedSecureWebRTCManager.DEFAULT_ICE_SERVERS), true); + assert.equal( + EnhancedSecureWebRTCManager.DEFAULT_ICE_SERVERS.some(server => server.urls === 'stun:stun.cloudflare.com:3478'), + true + ); const overrideServers = [{ urls: ['stun:operator.example.test:3478', 'turn:operator.example.test:3478'] }]; const manager = fake({ iceServers: overrideServers }); const config = EnhancedSecureWebRTCManager.prototype._buildPeerConnectionConfig.call(manager);