fix: stabilize manual WebRTC join flow
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

This commit is contained in:
lockbitchat
2026-05-18 19:49:57 -04:00
parent 01cb25f988
commit 1cc873223a
4 changed files with 505 additions and 43 deletions
+17 -16
View File
@@ -338,9 +338,11 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
markAnswerCreated, markAnswerCreated,
notificationIntegrationRef, notificationIntegrationRef,
isGeneratingKeys, isGeneratingKeys,
setIsGeneratingKeys,
handleCreateOffer, handleCreateOffer,
relayOnlyMode, relayOnlyMode,
setRelayOnlyMode setRelayOnlyMode,
webrtcManagerRef
}) => { }) => {
const [mode, setMode] = React.useState('select'); const [mode, setMode] = React.useState('select');
const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false); const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false);
@@ -784,6 +786,7 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
text: (() => { text: (() => {
try { try {
const min = typeof offerData === 'object' ? JSON.stringify(offerData) : (offerData || ''); const min = typeof offerData === 'object' ? JSON.stringify(offerData) : (offerData || '');
if (!min) return '';
if (typeof window.encodeBinaryToPrefixed === 'function') { if (typeof window.encodeBinaryToPrefixed === 'function') {
return window.encodeBinaryToPrefixed(min); return window.encodeBinaryToPrefixed(min);
} }
@@ -1111,6 +1114,7 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
text: (() => { text: (() => {
try { try {
const min = typeof answerData === 'object' ? JSON.stringify(answerData) : (answerData || ''); const min = typeof answerData === 'object' ? JSON.stringify(answerData) : (answerData || '');
if (!min) return '';
if (typeof window.encodeBinaryToPrefixed === 'function') { if (typeof window.encodeBinaryToPrefixed === 'function') {
return window.encodeBinaryToPrefixed(min); return window.encodeBinaryToPrefixed(min);
} }
@@ -1609,22 +1613,16 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
// Check if we should preserve answer data // Check if we should preserve answer data
const shouldPreserveAnswerData = () => { const shouldPreserveAnswerData = () => {
const now = Date.now(); const hasAnswerData = !!answerData ||
const answerAge = now - (connectionState.answerCreatedAt || 0); (answerInput && typeof answerInput === 'string' && answerInput.trim().length > 0);
const maxPreserveTime = 300000;
const hasAnswerQR = qrCodeUrl && typeof qrCodeUrl === 'string' && qrCodeUrl.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 shouldPreserve = (connectionState.hasActiveAnswer && const shouldPreserve = (connectionState.hasActiveAnswer &&
answerAge < maxPreserveTime &&
!connectionState.isUserInitiatedDisconnect) || !connectionState.isUserInitiatedDisconnect) ||
(hasAnswerData && answerAge < maxPreserveTime && (hasAnswerData &&
!connectionState.isUserInitiatedDisconnect) || !connectionState.isUserInitiatedDisconnect) ||
(hasAnswerQR && answerAge < maxPreserveTime && (hasAnswerQR &&
!connectionState.isUserInitiatedDisconnect); !connectionState.isUserInitiatedDisconnect);
@@ -3107,12 +3105,13 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
console.warn('Answer QR generation failed:', e); console.warn('Answer QR generation failed:', e);
} }
// Mark answer as created for state management // Mark generated answers as active immediately.
if (answerInput.trim().length > 0) { // `answerInput` is empty on the joiner path
// because the response was created locally,
// not pasted by the user.
if (typeof markAnswerCreated === 'function') { if (typeof markAnswerCreated === 'function') {
markAnswerCreated(); markAnswerCreated();
} }
}
const existingResponseMessages = messages.filter(m => const existingResponseMessages = messages.filter(m =>
@@ -3747,9 +3746,11 @@ import { installDebugWindowHooks } from './utils/debugWindowHooks.js';
markAnswerCreated: markAnswerCreated, markAnswerCreated: markAnswerCreated,
notificationIntegrationRef: notificationIntegrationRef, notificationIntegrationRef: notificationIntegrationRef,
isGeneratingKeys: isGeneratingKeys, isGeneratingKeys: isGeneratingKeys,
setIsGeneratingKeys: setIsGeneratingKeys,
handleCreateOffer: handleCreateOffer, handleCreateOffer: handleCreateOffer,
relayOnlyMode: relayOnlyMode, relayOnlyMode: relayOnlyMode,
setRelayOnlyMode: setRelayOnlyMode setRelayOnlyMode: setRelayOnlyMode,
webrtcManagerRef: webrtcManagerRef
}) })
), ),
+251 -26
View File
@@ -103,6 +103,9 @@ class EnhancedSecureWebRTCManager {
static PROTOCOL_VERSION = '4.1'; static PROTOCOL_VERSION = '4.1';
static MAX_SAS_ATTEMPTS = 3; static MAX_SAS_ATTEMPTS = 3;
static DEFAULT_ICE_SERVERS = Object.freeze([ 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:stun.l.google.com:19302' }),
Object.freeze({ urls: 'stun:stun1.l.google.com:19302' }), Object.freeze({ urls: 'stun:stun1.l.google.com:19302' }),
Object.freeze({ urls: 'stun:stun2.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); 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 * 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.lastSecurityLevelNotification = null;
this.verificationNotificationSent = false; this.verificationNotificationSent = false;
this.verificationInitiationSent = false; this.verificationInitiationSent = false;
this._verificationUiOpened = false;
this._sasLocalFingerprint = null;
this._sasRemoteFingerprint = null;
this.disconnectNotificationSent = false; this.disconnectNotificationSent = false;
this.reconnectionFailedNotificationSent = false; this.reconnectionFailedNotificationSent = false;
this.peerDisconnectNotificationSent = false; this.peerDisconnectNotificationSent = false;
@@ -7048,6 +7171,9 @@ async processMessage(data) {
this.verificationCode = null; this.verificationCode = null;
this.pendingSASCode = null; this.pendingSASCode = null;
this.sasValidationAttempts = 0; this.sasValidationAttempts = 0;
this._verificationUiOpened = false;
this._sasLocalFingerprint = null;
this._sasRemoteFingerprint = null;
// Clear key fingerprint and connection data // Clear key fingerprint and connection data
this.keyFingerprint = null; this.keyFingerprint = null;
@@ -7302,9 +7428,14 @@ async processMessage(data) {
this.peerConnection.onconnectionstatechange = () => { this.peerConnection.onconnectionstatechange = () => {
const state = this.peerConnection.connectionState; 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) { if (state === 'connected' && !this.isVerified) {
this.onStatusChange('verifying'); this._notifyVerificationReadyIfPossible();
} else if (state === 'connected' && this.isVerified) { } else if (state === 'connected' && this.isVerified) {
this.onStatusChange('connected'); this.onStatusChange('connected');
} else if (state === 'disconnected' || state === 'closed') { } else if (state === 'disconnected' || state === 'closed') {
@@ -7313,19 +7444,48 @@ async processMessage(data) {
this.onStatusChange('disconnected'); this.onStatusChange('disconnected');
setTimeout(() => this.disconnect(), 100); setTimeout(() => this.disconnect(), 100);
} else { } else {
this.onStatusChange('disconnected'); // Only emit disconnect if we were already verified (active session),
// Clear verification states on unexpected disconnect // otherwise keep the UI alive so the user can copy the answer.
this._clearVerificationStates(); 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') { } else if (state === 'failed') {
// Do not auto-reconnect to avoid closing the session on errors this._collectIceFailureDiagnostics().then((diagnostics) => {
this.onStatusChange('disconnected'); 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 { } else {
this.onStatusChange(state); 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) => { this.peerConnection.ondatachannel = (event) => {
// CRITICAL: Store the received data channel // CRITICAL: Store the received data channel
@@ -7395,7 +7555,7 @@ async processMessage(data) {
this.notifySecurityUpdate(); this.notifySecurityUpdate();
}, 500); }, 500);
} else { } else {
this.onStatusChange('verifying'); this._notifyVerificationReadyIfPossible();
this.initiateVerification(); this.initiateVerification();
} }
this.startHeartbeat(); this.startHeartbeat();
@@ -9503,8 +9663,34 @@ async processMessage(data) {
// Continue without fingerprint validation (fallback mode) // Continue without fingerprint validation (fallback mode)
} }
// Await ICE gathering // Await ICE gathering. Manual out-of-band exchange does not use
await this.waitForIceGathering(); // 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', { this._secureLog('debug', 'ICE gathering completed', {
operationId: operationId, operationId: operationId,
@@ -9663,7 +9849,7 @@ async processMessage(data) {
// Re-throw for upper-level handling // Re-throw for upper-level handling
throw error; 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', type: 'offer',
sdp: offerData.s || offerData.sdp 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', { this._secureLog('debug', 'Remote description set successfully', {
operationId: operationId, operationId: operationId,
@@ -10203,8 +10393,7 @@ async processMessage(data) {
const localFP = this.expectedDTLSFingerprint; const localFP = this.expectedDTLSFingerprint;
const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint); const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint);
this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP); this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP);
this.onStatusChange?.('verifying'); this._setSASMaterialReady(localFP, remoteFP);
this.onVerificationRequired(this.verificationCode);
} catch (sasError) { } catch (sasError) {
this._secureLog('error', 'SAS computation failed in createSecureAnswer (Answer side)', { this._secureLog('error', 'SAS computation failed in createSecureAnswer (Answer side)', {
errorType: sasError?.constructor?.name || 'Unknown' errorType: sasError?.constructor?.name || 'Unknown'
@@ -10213,8 +10402,34 @@ async processMessage(data) {
} }
// Await ICE gathering // Await ICE gathering. Manual out-of-band exchange does not use
await this.waitForIceGathering(); // 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', { this._secureLog('debug', 'ICE gathering completed for answer', {
operationId: operationId, operationId: operationId,
@@ -10426,7 +10641,7 @@ async processMessage(data) {
// Re-throw for upper-level handling // Re-throw for upper-level handling
throw error; 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'); throw new Error('Response data is too old possible replay attack');
} }
// Check protocol version compatibility // Check protocol version compatibility using the normalized field so
if (answerData.version !== '4.0') { // compact and legacy answer payloads are handled consistently.
if (answerVersion !== EnhancedSecureWebRTCManager.PROTOCOL_VERSION) {
window.EnhancedSecureCryptoUtils.secureLog.log('warn', 'Incompatible protocol version in answer', { window.EnhancedSecureCryptoUtils.secureLog.log('warn', 'Incompatible protocol version in answer', {
expectedVersion: '4.0', expectedVersion: EnhancedSecureWebRTCManager.PROTOCOL_VERSION,
receivedVersion: answerData.version receivedVersion: answerVersion
}); });
} }
@@ -10818,8 +11034,7 @@ async processMessage(data) {
const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint); const keyBytes = this._decodeKeyFingerprint(this.keyFingerprint);
this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP); this.verificationCode = await this._computeSAS(keyBytes, localFP, remoteFP);
this.onStatusChange?.('verifying'); this._setSASMaterialReady(localFP, remoteFP);
this.onVerificationRequired(this.verificationCode);
// CRITICAL: Store SAS code to send when data channel opens // CRITICAL: Store SAS code to send when data channel opens
this.pendingSASCode = this.verificationCode; this.pendingSASCode = this.verificationCode;
@@ -10870,6 +11085,13 @@ async processMessage(data) {
// Support both full and compact SDP field names // Support both full and compact SDP field names
const sdpData = answerData.sdp || answerData.s; 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', { this._secureLog('debug', 'Setting remote description from answer', {
sdpLength: sdpData?.length || 0, sdpLength: sdpData?.length || 0,
usingCompactSDP: !answerData.sdp && !!answerData.s usingCompactSDP: !answerData.sdp && !!answerData.s
@@ -10879,6 +11101,10 @@ async processMessage(data) {
type: 'answer', type: 'answer',
sdp: sdpData 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', { this._secureLog('debug', 'Remote description set successfully from answer', {
signalingState: this.peerConnection.signalingState signalingState: this.peerConnection.signalingState
@@ -11165,8 +11391,7 @@ async processMessage(data) {
} }
this.verificationCode = data.code; this.verificationCode = data.code;
this.onStatusChange?.('verifying'); this._notifyVerificationReadyIfPossible();
this.onVerificationRequired(this.verificationCode);
this._secureLog('info', 'SAS code received from Offer side', { this._secureLog('info', 'SAS code received from Offer side', {
sasCode: this.verificationCode, sasCode: this.verificationCode,
@@ -11565,14 +11790,14 @@ async processMessage(data) {
waitForIceGathering() { waitForIceGathering() {
return new Promise((resolve) => { return new Promise((resolve) => {
if (this.peerConnection.iceGatheringState === 'complete') { if (this.peerConnection.iceGatheringState === 'complete') {
resolve(); resolve(true);
return; return;
} }
const checkState = () => { const checkState = () => {
if (this.peerConnection && this.peerConnection.iceGatheringState === 'complete') { if (this.peerConnection && this.peerConnection.iceGatheringState === 'complete') {
this.peerConnection.removeEventListener('icegatheringstatechange', checkState); this.peerConnection.removeEventListener('icegatheringstatechange', checkState);
resolve(); resolve(true);
} }
}; };
@@ -11582,7 +11807,7 @@ async processMessage(data) {
if (this.peerConnection) { if (this.peerConnection) {
this.peerConnection.removeEventListener('icegatheringstatechange', checkState); this.peerConnection.removeEventListener('icegatheringstatechange', checkState);
} }
resolve(); resolve(this.peerConnection?.iceGatheringState === 'complete');
}, EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT); }, EnhancedSecureWebRTCManager.TIMEOUTS.ICE_GATHERING_TIMEOUT);
}); });
} }
+232
View File
@@ -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 // testSASNormalization
{ {
const manager = createFakeManager(); 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'); console.log('SAS verification tests passed');
+4
View File
@@ -102,6 +102,10 @@ function fake(config = {}) {
// ICE defaults are centralized and operator overrides remain untouched. // ICE defaults are centralized and operator overrides remain untouched.
{ {
assert.equal(Array.isArray(EnhancedSecureWebRTCManager.DEFAULT_ICE_SERVERS), true); 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 overrideServers = [{ urls: ['stun:operator.example.test:3478', 'turn:operator.example.test:3478'] }];
const manager = fake({ iceServers: overrideServers }); const manager = fake({ iceServers: overrideServers });
const config = EnhancedSecureWebRTCManager.prototype._buildPeerConnectionConfig.call(manager); const config = EnhancedSecureWebRTCManager.prototype._buildPeerConnectionConfig.call(manager);