feat(security): comprehensive connection security overhaul with mutex framework
Implemented robust security framework with custom withMutex system:
**Race condition protection:**
- Custom _withMutex('connectionOperation') implementation with 15s timeout
- Atomic key generation through _generateEncryptionKeys()
- Serialized connection operations to prevent conflicts
**Multi-stage validation pipeline:**
- Step-by-step validation (keys, fingerprints, SDP)
- Automatic rollback via _cleanupFailedOfferCreation() on failures
- Error phase detection for precise diagnostics
**Enhanced MITM protection:**
- Unique encryption key fingerprints
- Session ID anti-hijacking protection
- Mutual authentication challenge system
- Package integrity validation
**Advanced logging & monitoring:**
- Secure logging without sensitive data leaks
- Operation tracking via unique operationId
- Comprehensive error diagnostics and phase tracking
- Deadlock detection with emergency recovery
Breaking changes: Connection establishment now requires mutex coordination
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
const [dragOver, setDragOver] = React.useState(false);
|
||||
const [transfers, setTransfers] = React.useState({ sending: [], receiving: [] });
|
||||
const [readyFiles, setReadyFiles] = React.useState([]); // файлы, готовые к скачиванию
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
// Update transfers periodically
|
||||
@@ -33,24 +34,27 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
// НЕ отправляем сообщения в чат!
|
||||
},
|
||||
|
||||
// File received callback - показываем только финальное уведомление
|
||||
// File received callback - добавляем кнопку скачивания в UI
|
||||
(fileData) => {
|
||||
console.log(`📥 File received in UI: ${fileData.fileName}`);
|
||||
|
||||
// Auto-download received file
|
||||
const url = URL.createObjectURL(fileData.fileBlob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileData.fileName;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
// Update transfer list
|
||||
// Добавляем в список готовых к скачиванию
|
||||
setReadyFiles(prev => {
|
||||
// избегаем дублей по fileId
|
||||
if (prev.some(f => f.fileId === fileData.fileId)) return prev;
|
||||
return [...prev, {
|
||||
fileId: fileData.fileId,
|
||||
fileName: fileData.fileName,
|
||||
fileSize: fileData.fileSize,
|
||||
mimeType: fileData.mimeType,
|
||||
getBlob: fileData.getBlob,
|
||||
getObjectURL: fileData.getObjectURL,
|
||||
revokeObjectURL: fileData.revokeObjectURL
|
||||
}];
|
||||
});
|
||||
|
||||
// Обновляем список активных передач
|
||||
const currentTransfers = webrtcManager.getFileTransfers();
|
||||
setTransfers(currentTransfers);
|
||||
|
||||
// ИСПРАВЛЕНИЕ: НЕ дублируем системные сообщения
|
||||
// Финальное уведомление уже отправляется в WebRTC менеджере
|
||||
},
|
||||
|
||||
// Error callback
|
||||
@@ -342,14 +346,40 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
className: "text-muted text-xs ml-2"
|
||||
}, formatFileSize(transfer.fileSize))
|
||||
]),
|
||||
React.createElement('button', {
|
||||
key: 'cancel',
|
||||
onClick: () => webrtcManager.cancelFileTransfer(transfer.fileId),
|
||||
className: "text-red-400 hover:text-red-300 text-xs"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: 'fas fa-times'
|
||||
})
|
||||
React.createElement('div', { key: 'actions', className: 'flex items-center space-x-2' }, [
|
||||
// Кнопка скачать, если файл уже готов (есть в readyFiles)
|
||||
(() => {
|
||||
const rf = readyFiles.find(f => f.fileId === transfer.fileId);
|
||||
if (!rf || transfer.status !== 'completed') return null;
|
||||
return React.createElement('button', {
|
||||
key: 'download',
|
||||
className: 'text-green-400 hover:text-green-300 text-xs flex items-center',
|
||||
onClick: async () => {
|
||||
try {
|
||||
const url = await rf.getObjectURL();
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = rf.fileName || 'file';
|
||||
a.click();
|
||||
rf.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
alert('Не удалось начать скачивание: ' + e.message);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
React.createElement('i', { key: 'i', className: 'fas fa-download mr-1' }),
|
||||
'Скачать'
|
||||
]);
|
||||
})(),
|
||||
React.createElement('button', {
|
||||
key: 'cancel',
|
||||
onClick: () => webrtcManager.cancelFileTransfer(transfer.fileId),
|
||||
className: "text-red-400 hover:text-red-300 text-xs"
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: 'fas fa-times'
|
||||
})
|
||||
])
|
||||
])
|
||||
]),
|
||||
React.createElement('div', {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,8 @@ class EnhancedSecureFileTransfer {
|
||||
});
|
||||
|
||||
// Transfer settings
|
||||
this.CHUNK_SIZE = 65536; // 64 KB chunks
|
||||
// Размер чанка по умолчанию (баланс нагрузки и стабильности очереди)
|
||||
this.CHUNK_SIZE = 64 * 1024; // 64 KB
|
||||
this.MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB limit
|
||||
this.MAX_CONCURRENT_TRANSFERS = 3;
|
||||
this.CHUNK_TIMEOUT = 30000; // 30 seconds per chunk
|
||||
@@ -42,6 +43,7 @@ class EnhancedSecureFileTransfer {
|
||||
// Security
|
||||
this.processedChunks = new Set(); // Prevent replay attacks
|
||||
this.transferNonces = new Map(); // fileId -> current nonce counter
|
||||
this.receivedFileBuffers = new Map(); // fileId -> { buffer:ArrayBuffer, type:string, name:string, size:number }
|
||||
|
||||
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Регистрируем обработчик сообщений
|
||||
this.setupFileMessageHandlers();
|
||||
@@ -49,6 +51,54 @@ class EnhancedSecureFileTransfer {
|
||||
console.log('🔒 Enhanced Secure File Transfer initialized');
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// ENCODING HELPERS (Base64 for efficient transport)
|
||||
// ============================================
|
||||
arrayBufferToBase64(buffer) {
|
||||
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
const len = bytes.byteLength;
|
||||
for (let i = 0; i < len; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
base64ToUint8Array(base64) {
|
||||
const binaryString = atob(base64);
|
||||
const len = binaryString.length;
|
||||
const bytes = new Uint8Array(len);
|
||||
for (let i = 0; i < len; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PUBLIC ACCESSORS FOR RECEIVED FILES
|
||||
// ============================================
|
||||
getReceivedFileMeta(fileId) {
|
||||
const entry = this.receivedFileBuffers.get(fileId);
|
||||
if (!entry) return null;
|
||||
return { fileId, fileName: entry.name, fileSize: entry.size, mimeType: entry.type };
|
||||
}
|
||||
|
||||
async getBlob(fileId) {
|
||||
const entry = this.receivedFileBuffers.get(fileId);
|
||||
if (!entry) return null;
|
||||
return new Blob([entry.buffer], { type: entry.type });
|
||||
}
|
||||
|
||||
async getObjectURL(fileId) {
|
||||
const blob = await this.getBlob(fileId);
|
||||
if (!blob) return null;
|
||||
return URL.createObjectURL(blob);
|
||||
}
|
||||
|
||||
revokeObjectURL(url) {
|
||||
try { URL.revokeObjectURL(url); } catch (_) {}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ - ОБРАБОТКА СООБЩЕНИЙ
|
||||
// ============================================
|
||||
@@ -474,7 +524,7 @@ class EnhancedSecureFileTransfer {
|
||||
// Read chunk from file
|
||||
const chunkData = await this.readFileChunk(file, start, end);
|
||||
|
||||
// Send chunk
|
||||
// Send chunk (с учётом backpressure)
|
||||
await this.sendFileChunk(transferState, chunkIndex, chunkData);
|
||||
|
||||
// Update progress
|
||||
@@ -485,10 +535,8 @@ class EnhancedSecureFileTransfer {
|
||||
// Только логируем
|
||||
console.log(`📤 Chunk sent ${transferState.sentChunks}/${totalChunks} (${progress}%)`);
|
||||
|
||||
// Small delay between chunks to prevent overwhelming
|
||||
if (chunkIndex < totalChunks - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
// Backpressure: ждём разгрузки очереди перед следующим чанком
|
||||
await this.waitForBackpressure();
|
||||
}
|
||||
|
||||
transferState.status = 'waiting_confirmation';
|
||||
@@ -537,17 +585,21 @@ class EnhancedSecureFileTransfer {
|
||||
chunkData
|
||||
);
|
||||
|
||||
// Use Base64 to drastically reduce JSON overhead
|
||||
const encryptedB64 = this.arrayBufferToBase64(new Uint8Array(encryptedChunk));
|
||||
const chunkMessage = {
|
||||
type: 'file_chunk',
|
||||
fileId: transferState.fileId,
|
||||
chunkIndex: chunkIndex,
|
||||
totalChunks: transferState.totalChunks,
|
||||
nonce: Array.from(nonce),
|
||||
encryptedData: Array.from(new Uint8Array(encryptedChunk)),
|
||||
encryptedDataB64: encryptedB64,
|
||||
chunkSize: chunkData.byteLength,
|
||||
timestamp: Date.now()
|
||||
};
|
||||
|
||||
// Перед отправкой проверяем backpressure (доп. защита)
|
||||
await this.waitForBackpressure();
|
||||
// Send chunk through secure channel
|
||||
await this.sendSecureMessage(chunkMessage);
|
||||
|
||||
@@ -558,19 +610,64 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
|
||||
async sendSecureMessage(message) {
|
||||
try {
|
||||
// Send through existing WebRTC channel
|
||||
const messageString = JSON.stringify(message);
|
||||
|
||||
// Use the WebRTC manager's sendMessage method
|
||||
if (this.webrtcManager.sendMessage) {
|
||||
await this.webrtcManager.sendMessage(messageString);
|
||||
} else {
|
||||
throw new Error('WebRTC manager sendMessage method not available');
|
||||
// ВАЖНО: отправляем напрямую в DataChannel, чтобы file_* и chunk_confirmation
|
||||
// приходили верхнего уровня и перехватывались файловой системой, без обёртки type: 'message'
|
||||
const messageString = JSON.stringify(message);
|
||||
const dc = this.webrtcManager?.dataChannel;
|
||||
const maxRetries = 10;
|
||||
let attempt = 0;
|
||||
const wait = (ms) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
if (!dc || dc.readyState !== 'open') {
|
||||
throw new Error('Data channel not ready');
|
||||
}
|
||||
await this.waitForBackpressure();
|
||||
dc.send(messageString);
|
||||
return; // success
|
||||
} catch (error) {
|
||||
const msg = String(error?.message || '');
|
||||
const queueFull = msg.includes('send queue is full') || msg.includes('bufferedAmount');
|
||||
const opErr = error?.name === 'OperationError';
|
||||
if ((queueFull || opErr) && attempt < maxRetries) {
|
||||
attempt++;
|
||||
await this.waitForBackpressure();
|
||||
await wait(Math.min(50 * attempt, 500));
|
||||
continue;
|
||||
}
|
||||
console.error('❌ Failed to send secure message:', error);
|
||||
throw error;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to send secure message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForBackpressure() {
|
||||
try {
|
||||
const dc = this.webrtcManager?.dataChannel;
|
||||
if (!dc) return;
|
||||
|
||||
if (typeof dc.bufferedAmountLowThreshold === 'number') {
|
||||
// Если буфер превышает порог — ждём события снижения
|
||||
if (dc.bufferedAmount > dc.bufferedAmountLowThreshold) {
|
||||
await new Promise(resolve => {
|
||||
const handler = () => {
|
||||
dc.removeEventListener('bufferedamountlow', handler);
|
||||
resolve();
|
||||
};
|
||||
dc.addEventListener('bufferedamountlow', handler, { once: true });
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Фолбэк: опрашиваем bufferedAmount и ждём пока не упадёт ниже 4MB
|
||||
const softLimit = 4 * 1024 * 1024;
|
||||
while (dc.bufferedAmount > softLimit) {
|
||||
await new Promise(r => setTimeout(r, 20));
|
||||
}
|
||||
} catch (_) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +802,15 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
// Decrypt chunk
|
||||
const nonce = new Uint8Array(chunkMessage.nonce);
|
||||
const encryptedData = new Uint8Array(chunkMessage.encryptedData);
|
||||
// Backward compatible: prefer Base64, fallback to numeric array
|
||||
let encryptedData;
|
||||
if (chunkMessage.encryptedDataB64) {
|
||||
encryptedData = this.base64ToUint8Array(chunkMessage.encryptedDataB64);
|
||||
} else if (chunkMessage.encryptedData) {
|
||||
encryptedData = new Uint8Array(chunkMessage.encryptedData);
|
||||
} else {
|
||||
throw new Error('Missing encrypted data');
|
||||
}
|
||||
|
||||
console.log('🔓 Decrypting chunk:', chunkMessage.chunkIndex);
|
||||
|
||||
@@ -816,20 +921,43 @@ class EnhancedSecureFileTransfer {
|
||||
throw new Error('File integrity check failed - hash mismatch');
|
||||
}
|
||||
|
||||
// Create blob and notify
|
||||
const fileBlob = new Blob([fileData], { type: receivingState.fileType });
|
||||
// Lazy: храним буфер, но для совместимости формируем Blob для onFileReceived
|
||||
const fileBuffer = fileData.buffer;
|
||||
const fileBlob = new Blob([fileBuffer], { type: receivingState.fileType });
|
||||
|
||||
receivingState.endTime = Date.now();
|
||||
receivingState.status = 'completed';
|
||||
|
||||
// Notify file received
|
||||
// Сохраняем в кэше до запроса скачивания
|
||||
this.receivedFileBuffers.set(receivingState.fileId, {
|
||||
buffer: fileBuffer,
|
||||
type: receivingState.fileType,
|
||||
name: receivingState.fileName,
|
||||
size: receivingState.fileSize
|
||||
});
|
||||
|
||||
// Сообщаем UI о готовности файла и даём ленивые методы получения
|
||||
if (this.onFileReceived) {
|
||||
const getBlob = async () => new Blob([this.receivedFileBuffers.get(receivingState.fileId).buffer], { type: receivingState.fileType });
|
||||
const getObjectURL = async () => {
|
||||
const blob = await getBlob();
|
||||
return URL.createObjectURL(blob);
|
||||
};
|
||||
const revokeObjectURL = (url) => {
|
||||
try { URL.revokeObjectURL(url); } catch (_) {}
|
||||
};
|
||||
|
||||
this.onFileReceived({
|
||||
fileId: receivingState.fileId,
|
||||
fileName: receivingState.fileName,
|
||||
fileSize: receivingState.fileSize,
|
||||
fileBlob: fileBlob,
|
||||
transferTime: receivingState.endTime - receivingState.startTime
|
||||
mimeType: receivingState.fileType,
|
||||
transferTime: receivingState.endTime - receivingState.startTime,
|
||||
// backward-compatibility for existing UIs
|
||||
fileBlob,
|
||||
getBlob,
|
||||
getObjectURL,
|
||||
revokeObjectURL
|
||||
});
|
||||
}
|
||||
|
||||
@@ -843,7 +971,13 @@ class EnhancedSecureFileTransfer {
|
||||
await this.sendSecureMessage(completionMessage);
|
||||
|
||||
// Cleanup
|
||||
this.cleanupReceivingTransfer(receivingState.fileId);
|
||||
// Не удаляем буфер сразу, оставляем до загрузки пользователем
|
||||
// Очистим метаданные чанков, оставив итоговый буфер
|
||||
if (this.receivingTransfers.has(receivingState.fileId)) {
|
||||
const rs = this.receivingTransfers.get(receivingState.fileId);
|
||||
if (rs && rs.receivedChunks) rs.receivedChunks.clear();
|
||||
}
|
||||
this.receivingTransfers.delete(receivingState.fileId);
|
||||
|
||||
console.log('✅ File assembly completed:', receivingState.fileName);
|
||||
|
||||
@@ -1058,6 +1192,8 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
this.receivingTransfers.delete(fileId);
|
||||
this.sessionKeys.delete(fileId);
|
||||
// Также очищаем финальный буфер, если он ещё хранится
|
||||
this.receivedFileBuffers.delete(fileId);
|
||||
|
||||
// Remove processed chunk IDs
|
||||
for (const chunkId of this.processedChunks) {
|
||||
|
||||
Reference in New Issue
Block a user