fix: stabilize manual WebRTC join flow
This commit is contained in:
+17
-16
@@ -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
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
// 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');
|
this.onStatusChange('disconnected');
|
||||||
// Clear verification states on unexpected disconnect
|
// Clear verification states on unexpected disconnect
|
||||||
this._clearVerificationStates();
|
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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user