fix: stabilize manual WebRTC join flow
This commit is contained in:
+17
-16
@@ -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
|
||||
})
|
||||
),
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user