Files
securebit-chat/src/transfer/EnhancedSecureFileTransfer.js
T

1590 lines
63 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
class EnhancedSecureFileTransfer {
constructor(webrtcManager, onProgress, onComplete, onError, onFileReceived) {
this.webrtcManager = webrtcManager;
this.onProgress = onProgress;
this.onComplete = onComplete;
this.onError = onError;
this.onFileReceived = onFileReceived;
// Validate webrtcManager
if (!webrtcManager) {
throw new Error('webrtcManager is required for EnhancedSecureFileTransfer');
}
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Устанавливаем глобальный флаг
window.FILE_TRANSFER_ACTIVE = true;
window.fileTransferSystem = this;
// Transfer settings
// Размер чанка по умолчанию (баланс нагрузки и стабильности очереди)
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
this.RETRY_ATTEMPTS = 3;
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Система ограничений по типам файлов
this.FILE_TYPE_RESTRICTIONS = {
// Документы
documents: {
extensions: ['.pdf', '.doc', '.docx', '.txt', '.md', '.rtf', '.odt'],
mimeTypes: [
'application/pdf',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain',
'text/markdown',
'application/rtf',
'application/vnd.oasis.opendocument.text'
],
maxSize: 50 * 1024 * 1024, // 50 MB
category: 'Documents',
description: 'PDF, DOC, TXT, MD, RTF, ODT'
},
// Изображения
images: {
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'],
mimeTypes: [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/bmp',
'image/svg+xml',
'image/x-icon'
],
maxSize: 25 * 1024 * 1024, // 25 MB
category: 'Images',
description: 'JPG, PNG, GIF, WEBP, BMP, SVG, ICO'
},
// Архивы
archives: {
extensions: ['.zip', '.rar', '.7z', '.tar', '.gz', '.bz2', '.xz'],
mimeTypes: [
'application/zip',
'application/x-rar-compressed',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip',
'application/x-bzip2',
'application/x-xz'
],
maxSize: 100 * 1024 * 1024, // 100 MB
category: 'Archives',
description: 'ZIP, RAR, 7Z, TAR, GZ, BZ2, XZ'
},
// Медиа файлы
media: {
extensions: ['.mp3', '.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.ogg', '.wav'],
mimeTypes: [
'audio/mpeg',
'video/mp4',
'video/x-msvideo',
'video/x-matroska',
'video/quicktime',
'video/x-ms-wmv',
'video/x-flv',
'video/webm',
'audio/ogg',
'audio/wav'
],
maxSize: 100 * 1024 * 1024, // 100 MB
category: 'Media',
description: 'MP3, MP4, AVI, MKV, MOV, WMV, FLV, WEBM, OGG, WAV'
},
// Общие файлы (любые другие типы)
general: {
extensions: [], // Пустой массив означает "все остальные"
mimeTypes: [], // Пустой массив означает "все остальные"
maxSize: 50 * 1024 * 1024, // 50 MB
category: 'General',
description: 'Any file type up to size limits'
}
};
// Active transfers tracking
this.activeTransfers = new Map(); // fileId -> transfer state
this.receivingTransfers = new Map(); // fileId -> receiving state
this.transferQueue = []; // Queue for pending transfers
this.pendingChunks = new Map();
// Session key derivation
this.sessionKeys = new Map(); // fileId -> derived session key
// 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();
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Устанавливаем ссылку в WebRTC менеджере
if (this.webrtcManager) {
this.webrtcManager.fileTransferSystem = this;
}
}
// ============================================
// FILE TYPE VALIDATION SYSTEM
// ============================================
// Определяем тип файла по расширению и MIME типу
getFileType(file) {
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.'));
const mimeType = file.type.toLowerCase();
// Проверяем каждый тип файла
for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) {
if (typeKey === 'general') continue; // Пропускаем общий тип
// Проверяем расширение
if (typeConfig.extensions.includes(fileExtension)) {
return {
type: typeKey,
category: typeConfig.category,
description: typeConfig.description,
maxSize: typeConfig.maxSize,
allowed: true
};
}
// Проверяем MIME тип
if (typeConfig.mimeTypes.includes(mimeType)) {
return {
type: typeKey,
category: typeConfig.category,
description: typeConfig.description,
maxSize: typeConfig.maxSize,
allowed: true
};
}
}
// Если не найден в специфических типах, используем общий
const generalConfig = this.FILE_TYPE_RESTRICTIONS.general;
return {
type: 'general',
category: generalConfig.category,
description: generalConfig.description,
maxSize: generalConfig.maxSize,
allowed: true
};
}
// Проверяем, разрешен ли файл для передачи
validateFile(file) {
const fileType = this.getFileType(file);
const errors = [];
// Проверяем размер файла
if (file.size > fileType.maxSize) {
errors.push(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed for ${fileType.category} (${this.formatFileSize(fileType.maxSize)})`);
}
// Проверяем, разрешен ли тип файла
if (!fileType.allowed) {
errors.push(`File type not allowed. Supported types: ${fileType.description}`);
}
// Проверяем общий лимит размера
if (file.size > this.MAX_FILE_SIZE) {
errors.push(`File size (${this.formatFileSize(file.size)}) exceeds general limit (${this.formatFileSize(this.MAX_FILE_SIZE)})`);
}
return {
isValid: errors.length === 0,
errors: errors,
fileType: fileType,
fileSize: file.size,
formattedSize: this.formatFileSize(file.size)
};
}
// Форматируем размер файла для отображения
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// Получаем список поддерживаемых типов файлов
getSupportedFileTypes() {
const supportedTypes = {};
for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) {
if (typeKey === 'general') continue;
supportedTypes[typeKey] = {
category: typeConfig.category,
description: typeConfig.description,
extensions: typeConfig.extensions,
maxSize: this.formatFileSize(typeConfig.maxSize),
maxSizeBytes: typeConfig.maxSize
};
}
return supportedTypes;
}
// Получаем общую информацию о поддерживаемых типах
getFileTypeInfo() {
return {
supportedTypes: this.getSupportedFileTypes(),
generalMaxSize: this.formatFileSize(this.MAX_FILE_SIZE),
generalMaxSizeBytes: this.MAX_FILE_SIZE,
restrictions: this.FILE_TYPE_RESTRICTIONS
};
}
// ============================================
// 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 (_) {}
}
// ============================================
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ - ОБРАБОТКА СООБЩЕНИЙ
// ============================================
setupFileMessageHandlers() {
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Ждем готовности dataChannel
if (!this.webrtcManager.dataChannel) {
// Попытаемся настроить через небольшой интервал
const setupRetry = setInterval(() => {
if (this.webrtcManager.dataChannel) {
clearInterval(setupRetry);
this.setupMessageInterception();
}
}, 100);
// Timeout для предотвращения бесконечного ожидания
setTimeout(() => {
clearInterval(setupRetry);
}, 5000);
return;
}
// Если dataChannel уже готов, сразу настраиваем
this.setupMessageInterception();
}
// В методе setupMessageInterception(), замените весь метод на:
setupMessageInterception() {
try {
if (!this.webrtcManager.dataChannel) {
return;
}
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Глобальный флаг для блокировки файловых сообщений
window.FILE_TRANSFER_ACTIVE = true;
window.fileTransferSystem = this;
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Устанавливаем ссылку в WebRTC менеджере
if (this.webrtcManager) {
this.webrtcManager.fileTransferSystem = this;
}
// 1. ПЕРЕХВАТ НА УРОВНЕ dataChannel.onmessage
if (this.webrtcManager.dataChannel.onmessage) {
this.originalOnMessage = this.webrtcManager.dataChannel.onmessage;
}
this.webrtcManager.dataChannel.onmessage = async (event) => {
try {
// Проверяем файловые сообщения ПЕРВЫМИ
if (typeof event.data === 'string') {
try {
const parsed = JSON.parse(event.data);
if (this.isFileTransferMessage(parsed)) {
await this.handleFileMessage(parsed);
return; // КРИТИЧЕСКИ ВАЖНО: НЕ передаем дальше
}
} catch (parseError) {
// Не JSON - передаем оригинальному обработчику
}
}
// Передаем обычные сообщения оригинальному обработчику
if (this.originalOnMessage) {
return this.originalOnMessage.call(this.webrtcManager.dataChannel, event);
}
} catch (error) {
console.error('❌ Error in file system message interception:', error);
if (this.originalOnMessage) {
return this.originalOnMessage.call(this.webrtcManager.dataChannel, event);
}
}
};
} catch (error) {
console.error('❌ Failed to set up message interception:', error);
}
}
// Проверяем, является ли сообщение файловым
isFileTransferMessage(message) {
if (!message || typeof message !== 'object' || !message.type) {
return false;
}
const fileMessageTypes = [
'file_transfer_start',
'file_transfer_response',
'file_chunk',
'chunk_confirmation',
'file_transfer_complete',
'file_transfer_error'
];
return fileMessageTypes.includes(message.type);
}
// Обрабатываем файловые сообщения
async handleFileMessage(message) {
try {
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Проверяем готовность файловой системы
if (!this.webrtcManager.fileTransferSystem) {
try {
// Попытка инициализации файловой системы
if (typeof this.webrtcManager.initializeFileTransfer === 'function') {
this.webrtcManager.initializeFileTransfer();
// Ждем инициализации
let attempts = 0;
const maxAttempts = 50; // 5 секунд максимум
while (!this.webrtcManager.fileTransferSystem && attempts < maxAttempts) {
await new Promise(resolve => setTimeout(resolve, 100));
attempts++;
}
if (!this.webrtcManager.fileTransferSystem) {
throw new Error('File transfer system initialization timeout');
}
} else {
throw new Error('initializeFileTransfer method not available');
}
} catch (initError) {
console.error('❌ Failed to initialize file transfer system:', initError);
// Отправляем ошибку отправителю
if (message.fileId) {
const errorMessage = {
type: 'file_transfer_error',
fileId: message.fileId,
error: 'File transfer system not available',
timestamp: Date.now()
};
await this.sendSecureMessage(errorMessage);
}
return;
}
}
switch (message.type) {
case 'file_transfer_start':
await this.handleFileTransferStart(message);
break;
case 'file_transfer_response':
this.handleTransferResponse(message);
break;
case 'file_chunk':
await this.handleFileChunk(message);
break;
case 'chunk_confirmation':
this.handleChunkConfirmation(message);
break;
case 'file_transfer_complete':
this.handleTransferComplete(message);
break;
case 'file_transfer_error':
this.handleTransferError(message);
break;
default:
console.warn('⚠️ Unknown file message type:', message.type);
}
} catch (error) {
console.error('❌ Error handling file message:', error);
// Отправляем сообщение об ошибке
if (message.fileId) {
const errorMessage = {
type: 'file_transfer_error',
fileId: message.fileId,
error: error.message,
timestamp: Date.now()
};
await this.sendSecureMessage(errorMessage);
}
}
}
// ============================================
// SIMPLIFIED KEY DERIVATION - USE SHARED DATA
// ============================================
async deriveFileSessionKey(fileId) {
try {
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Используем keyFingerprint и sessionSalt
// которые уже согласованы между пирами
if (!this.webrtcManager.keyFingerprint || !this.webrtcManager.sessionSalt) {
throw new Error('WebRTC session data not available');
}
// Генерируем соль для этого конкретного файла
const fileSalt = crypto.getRandomValues(new Uint8Array(32));
// Создаем seed из согласованных данных
const encoder = new TextEncoder();
const fingerprintData = encoder.encode(this.webrtcManager.keyFingerprint);
const fileIdData = encoder.encode(fileId);
// Объединяем все компоненты для создания уникального seed
const sessionSaltArray = new Uint8Array(this.webrtcManager.sessionSalt);
const combinedSeed = new Uint8Array(
fingerprintData.length +
sessionSaltArray.length +
fileSalt.length +
fileIdData.length
);
let offset = 0;
combinedSeed.set(fingerprintData, offset);
offset += fingerprintData.length;
combinedSeed.set(sessionSaltArray, offset);
offset += sessionSaltArray.length;
combinedSeed.set(fileSalt, offset);
offset += fileSalt.length;
combinedSeed.set(fileIdData, offset);
// Хешируем для получения ключевого материала
const keyMaterial = await crypto.subtle.digest('SHA-256', combinedSeed);
// Импортируем как AES ключ напрямую
const fileSessionKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
// Сохраняем ключ и соль
this.sessionKeys.set(fileId, {
key: fileSessionKey,
salt: Array.from(fileSalt),
created: Date.now()
});
return { key: fileSessionKey, salt: Array.from(fileSalt) };
} catch (error) {
console.error('❌ Failed to derive file session key:', error);
throw error;
}
}
async deriveFileSessionKeyFromSalt(fileId, saltArray) {
try {
// Проверка соли
if (!saltArray || !Array.isArray(saltArray) || saltArray.length !== 32) {
throw new Error(`Invalid salt: ${saltArray?.length || 0} bytes`);
}
if (!this.webrtcManager.keyFingerprint || !this.webrtcManager.sessionSalt) {
throw new Error('WebRTC session data not available');
}
// Используем тот же процесс что и отправитель
const encoder = new TextEncoder();
const fingerprintData = encoder.encode(this.webrtcManager.keyFingerprint);
const fileIdData = encoder.encode(fileId);
// Используем полученную соль файла
const fileSalt = new Uint8Array(saltArray);
const sessionSaltArray = new Uint8Array(this.webrtcManager.sessionSalt);
// Объединяем компоненты в том же порядке
const combinedSeed = new Uint8Array(
fingerprintData.length +
sessionSaltArray.length +
fileSalt.length +
fileIdData.length
);
let offset = 0;
combinedSeed.set(fingerprintData, offset);
offset += fingerprintData.length;
combinedSeed.set(sessionSaltArray, offset);
offset += sessionSaltArray.length;
combinedSeed.set(fileSalt, offset);
offset += fileSalt.length;
combinedSeed.set(fileIdData, offset);
// Хешируем для получения того же ключевого материала
const keyMaterial = await crypto.subtle.digest('SHA-256', combinedSeed);
// Импортируем как AES ключ
const fileSessionKey = await crypto.subtle.importKey(
'raw',
keyMaterial,
{ name: 'AES-GCM' },
false,
['encrypt', 'decrypt']
);
this.sessionKeys.set(fileId, {
key: fileSessionKey,
salt: saltArray,
created: Date.now()
});
return fileSessionKey;
} catch (error) {
console.error('❌ Failed to derive session key from salt:', error);
throw error;
}
}
// ============================================
// FILE TRANSFER IMPLEMENTATION
// ============================================
async sendFile(file) {
try {
// Validate webrtcManager
if (!this.webrtcManager) {
throw new Error('WebRTC Manager not initialized');
}
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Валидация файла с новой системой типов
if (!file || !file.size) {
throw new Error('Invalid file object');
}
// Проверяем тип и размер файла
const validation = this.validateFile(file);
if (!validation.isValid) {
const errorMessage = validation.errors.join('. ');
throw new Error(errorMessage);
}
if (this.activeTransfers.size >= this.MAX_CONCURRENT_TRANSFERS) {
throw new Error('Maximum concurrent transfers reached');
}
// Generate unique file ID
const fileId = `file_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Calculate file hash for integrity verification
const fileHash = await this.calculateFileHash(file);
// Derive session key for this file
const keyResult = await this.deriveFileSessionKey(fileId);
const sessionKey = keyResult.key;
const salt = keyResult.salt;
// Create transfer state
const transferState = {
fileId: fileId,
file: file,
fileHash: fileHash,
sessionKey: sessionKey,
salt: salt, // Сохраняем соль для отправки
totalChunks: Math.ceil(file.size / this.CHUNK_SIZE),
sentChunks: 0,
confirmedChunks: 0,
startTime: Date.now(),
status: 'preparing',
retryCount: 0,
lastChunkTime: Date.now()
};
this.activeTransfers.set(fileId, transferState);
this.transferNonces.set(fileId, 0);
// Send file metadata first
await this.sendFileMetadata(transferState);
// Start chunk transmission
await this.startChunkTransmission(transferState);
return fileId;
} catch (error) {
console.error('❌ File sending failed:', error);
if (this.onError) this.onError(error.message);
throw error;
}
}
async sendFileMetadata(transferState) {
try {
const metadata = {
type: 'file_transfer_start',
fileId: transferState.fileId,
fileName: transferState.file.name,
fileSize: transferState.file.size,
fileType: transferState.file.type || 'application/octet-stream',
fileHash: transferState.fileHash,
totalChunks: transferState.totalChunks,
chunkSize: this.CHUNK_SIZE,
salt: transferState.salt, // Отправляем соль получателю
timestamp: Date.now(),
version: '2.0'
};
// Send metadata through secure channel
await this.sendSecureMessage(metadata);
transferState.status = 'metadata_sent';
} catch (error) {
console.error('❌ Failed to send file metadata:', error);
transferState.status = 'failed';
throw error;
}
}
async startChunkTransmission(transferState) {
try {
transferState.status = 'transmitting';
const file = transferState.file;
const totalChunks = transferState.totalChunks;
for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) {
const start = chunkIndex * this.CHUNK_SIZE;
const end = Math.min(start + this.CHUNK_SIZE, file.size);
// Read chunk from file
const chunkData = await this.readFileChunk(file, start, end);
// Send chunk (с учётом backpressure)
await this.sendFileChunk(transferState, chunkIndex, chunkData);
// Update progress
transferState.sentChunks++;
const progress = Math.round((transferState.sentChunks / totalChunks) * 95) + 5; // 5-100%
// Backpressure: ждём разгрузки очереди перед следующим чанком
await this.waitForBackpressure();
}
transferState.status = 'waiting_confirmation';
// Timeout for completion confirmation
setTimeout(() => {
if (this.activeTransfers.has(transferState.fileId)) {
const state = this.activeTransfers.get(transferState.fileId);
if (state.status === 'waiting_confirmation') {
this.cleanupTransfer(transferState.fileId);
}
}
}, 30000);
} catch (error) {
console.error('❌ Chunk transmission failed:', error);
transferState.status = 'failed';
throw error;
}
}
async readFileChunk(file, start, end) {
try {
const blob = file.slice(start, end);
return await blob.arrayBuffer();
} catch (error) {
console.error('❌ Failed to read file chunk:', error);
throw error;
}
}
async sendFileChunk(transferState, chunkIndex, chunkData) {
try {
const sessionKey = transferState.sessionKey;
const nonce = crypto.getRandomValues(new Uint8Array(12));
// Encrypt chunk data
const encryptedChunk = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: nonce
},
sessionKey,
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),
encryptedDataB64: encryptedB64,
chunkSize: chunkData.byteLength,
timestamp: Date.now()
};
// Перед отправкой проверяем backpressure (доп. защита)
await this.waitForBackpressure();
// Send chunk through secure channel
await this.sendSecureMessage(chunkMessage);
} catch (error) {
console.error('❌ Failed to send file chunk:', error);
throw error;
}
}
async sendSecureMessage(message) {
// ВАЖНО: отправляем напрямую в 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;
}
}
}
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
}
}
async calculateFileHash(file) {
try {
const arrayBuffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (error) {
console.error('❌ File hash calculation failed:', error);
throw error;
}
}
// ============================================
// MESSAGE HANDLERS
// ============================================
async handleFileTransferStart(metadata) {
try {
// Validate metadata
if (!metadata.fileId || !metadata.fileName || !metadata.fileSize) {
throw new Error('Invalid file transfer metadata');
}
// Check if we already have this transfer
if (this.receivingTransfers.has(metadata.fileId)) {
return;
}
// Derive session key from salt
const sessionKey = await this.deriveFileSessionKeyFromSalt(
metadata.fileId,
metadata.salt
);
// Create receiving transfer state
const receivingState = {
fileId: metadata.fileId,
fileName: metadata.fileName,
fileSize: metadata.fileSize,
fileType: metadata.fileType || 'application/octet-stream',
fileHash: metadata.fileHash,
totalChunks: metadata.totalChunks,
chunkSize: metadata.chunkSize || this.CHUNK_SIZE,
sessionKey: sessionKey,
salt: metadata.salt,
receivedChunks: new Map(),
receivedCount: 0,
startTime: Date.now(),
lastChunkTime: Date.now(),
status: 'receiving'
};
this.receivingTransfers.set(metadata.fileId, receivingState);
// Send acceptance response
const response = {
type: 'file_transfer_response',
fileId: metadata.fileId,
accepted: true,
timestamp: Date.now()
};
await this.sendSecureMessage(response);
// Process buffered chunks if any
if (this.pendingChunks.has(metadata.fileId)) {
const bufferedChunks = this.pendingChunks.get(metadata.fileId);
for (const [chunkIndex, chunkMessage] of bufferedChunks.entries()) {
await this.handleFileChunk(chunkMessage);
}
this.pendingChunks.delete(metadata.fileId);
}
} catch (error) {
console.error('❌ Failed to handle file transfer start:', error);
// Send error response
const errorResponse = {
type: 'file_transfer_response',
fileId: metadata.fileId,
accepted: false,
error: error.message,
timestamp: Date.now()
};
await this.sendSecureMessage(errorResponse);
}
}
async handleFileChunk(chunkMessage) {
try {
let receivingState = this.receivingTransfers.get(chunkMessage.fileId);
// Buffer early chunks if transfer not yet initialized
if (!receivingState) {
if (!this.pendingChunks.has(chunkMessage.fileId)) {
this.pendingChunks.set(chunkMessage.fileId, new Map());
}
this.pendingChunks.get(chunkMessage.fileId).set(chunkMessage.chunkIndex, chunkMessage);
return;
}
// Update last chunk time
receivingState.lastChunkTime = Date.now();
// Check if chunk already received
if (receivingState.receivedChunks.has(chunkMessage.chunkIndex)) {
return;
}
// Validate chunk
if (chunkMessage.chunkIndex < 0 || chunkMessage.chunkIndex >= receivingState.totalChunks) {
throw new Error(`Invalid chunk index: ${chunkMessage.chunkIndex}`);
}
// Decrypt chunk
const nonce = new Uint8Array(chunkMessage.nonce);
// 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');
}
const decryptedChunk = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: nonce
},
receivingState.sessionKey,
encryptedData
);
// Verify chunk size
if (decryptedChunk.byteLength !== chunkMessage.chunkSize) {
throw new Error(`Chunk size mismatch: expected ${chunkMessage.chunkSize}, got ${decryptedChunk.byteLength}`);
}
// Store chunk
receivingState.receivedChunks.set(chunkMessage.chunkIndex, decryptedChunk);
receivingState.receivedCount++;
// Send chunk confirmation
const confirmation = {
type: 'chunk_confirmation',
fileId: chunkMessage.fileId,
chunkIndex: chunkMessage.chunkIndex,
timestamp: Date.now()
};
await this.sendSecureMessage(confirmation);
// Check if all chunks received
if (receivingState.receivedCount === receivingState.totalChunks) {
await this.assembleFile(receivingState);
}
} catch (error) {
console.error('❌ Failed to handle file chunk:', error);
// Send error notification
const errorMessage = {
type: 'file_transfer_error',
fileId: chunkMessage.fileId,
error: error.message,
chunkIndex: chunkMessage.chunkIndex,
timestamp: Date.now()
};
await this.sendSecureMessage(errorMessage);
// Mark transfer as failed
const receivingState = this.receivingTransfers.get(chunkMessage.fileId);
if (receivingState) {
receivingState.status = 'failed';
}
if (this.onError) {
this.onError(`Chunk processing failed: ${error.message}`);
}
}
}
async assembleFile(receivingState) {
try {
receivingState.status = 'assembling';
// Verify we have all chunks
for (let i = 0; i < receivingState.totalChunks; i++) {
if (!receivingState.receivedChunks.has(i)) {
throw new Error(`Missing chunk ${i}`);
}
}
// Combine all chunks in order
const chunks = [];
for (let i = 0; i < receivingState.totalChunks; i++) {
const chunk = receivingState.receivedChunks.get(i);
chunks.push(new Uint8Array(chunk));
}
// Calculate total size
const totalSize = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
// Verify total size matches expected
if (totalSize !== receivingState.fileSize) {
throw new Error(`File size mismatch: expected ${receivingState.fileSize}, got ${totalSize}`);
}
// Combine into single array
const fileData = new Uint8Array(totalSize);
let offset = 0;
for (const chunk of chunks) {
fileData.set(chunk, offset);
offset += chunk.length;
}
// Verify file integrity
const receivedHash = await this.calculateFileHashFromData(fileData);
if (receivedHash !== receivingState.fileHash) {
throw new Error('File integrity check failed - hash mismatch');
}
// Lazy: храним буфер, но для совместимости формируем Blob для onFileReceived
const fileBuffer = fileData.buffer;
const fileBlob = new Blob([fileBuffer], { type: receivingState.fileType });
receivingState.endTime = Date.now();
receivingState.status = 'completed';
// Сохраняем в кэше до запроса скачивания
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,
mimeType: receivingState.fileType,
transferTime: receivingState.endTime - receivingState.startTime,
// backward-compatibility for existing UIs
fileBlob,
getBlob,
getObjectURL,
revokeObjectURL
});
}
// Send completion confirmation
const completionMessage = {
type: 'file_transfer_complete',
fileId: receivingState.fileId,
success: true,
timestamp: Date.now()
};
await this.sendSecureMessage(completionMessage);
// Cleanup
// Не удаляем буфер сразу, оставляем до загрузки пользователем
// Очистим метаданные чанков, оставив итоговый буфер
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);
} catch (error) {
console.error('❌ File assembly failed:', error);
receivingState.status = 'failed';
if (this.onError) {
this.onError(`File assembly failed: ${error.message}`);
}
// Send error notification
const errorMessage = {
type: 'file_transfer_complete',
fileId: receivingState.fileId,
success: false,
error: error.message,
timestamp: Date.now()
};
await this.sendSecureMessage(errorMessage);
// Cleanup failed transfer
this.cleanupReceivingTransfer(receivingState.fileId);
}
}
async calculateFileHashFromData(data) {
try {
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
} catch (error) {
console.error('❌ Hash calculation failed:', error);
throw error;
}
}
handleTransferResponse(response) {
try {
const transferState = this.activeTransfers.get(response.fileId);
if (!transferState) {
return;
}
if (response.accepted) {
transferState.status = 'accepted';
} else {
transferState.status = 'rejected';
if (this.onError) {
this.onError(`Transfer rejected: ${response.error || 'Unknown reason'}`);
}
this.cleanupTransfer(response.fileId);
}
} catch (error) {
console.error('❌ Failed to handle transfer response:', error);
}
}
handleChunkConfirmation(confirmation) {
try {
const transferState = this.activeTransfers.get(confirmation.fileId);
if (!transferState) {
return;
}
transferState.confirmedChunks++;
transferState.lastChunkTime = Date.now();
} catch (error) {
console.error('❌ Failed to handle chunk confirmation:', error);
}
}
handleTransferComplete(completion) {
try {
const transferState = this.activeTransfers.get(completion.fileId);
if (!transferState) {
return;
}
if (completion.success) {
transferState.status = 'completed';
transferState.endTime = Date.now();
if (this.onComplete) {
this.onComplete({
fileId: transferState.fileId,
fileName: transferState.file.name,
fileSize: transferState.file.size,
transferTime: transferState.endTime - transferState.startTime,
status: 'completed'
});
}
} else {
transferState.status = 'failed';
if (this.onError) {
this.onError(`Transfer failed: ${completion.error || 'Unknown error'}`);
}
}
this.cleanupTransfer(completion.fileId);
} catch (error) {
console.error('❌ Failed to handle transfer completion:', error);
}
}
handleTransferError(errorMessage) {
try {
const transferState = this.activeTransfers.get(errorMessage.fileId);
if (transferState) {
transferState.status = 'failed';
this.cleanupTransfer(errorMessage.fileId);
}
const receivingState = this.receivingTransfers.get(errorMessage.fileId);
if (receivingState) {
receivingState.status = 'failed';
this.cleanupReceivingTransfer(errorMessage.fileId);
}
if (this.onError) {
this.onError(`Transfer error: ${errorMessage.error || 'Unknown error'}`);
}
} catch (error) {
console.error('❌ Failed to handle transfer error:', error);
}
}
// ============================================
// UTILITY METHODS
// ============================================
getActiveTransfers() {
return Array.from(this.activeTransfers.values()).map(transfer => ({
fileId: transfer.fileId,
fileName: transfer.file?.name || 'Unknown',
fileSize: transfer.file?.size || 0,
progress: Math.round((transfer.sentChunks / transfer.totalChunks) * 100),
status: transfer.status,
startTime: transfer.startTime
}));
}
getReceivingTransfers() {
return Array.from(this.receivingTransfers.values()).map(transfer => ({
fileId: transfer.fileId,
fileName: transfer.fileName || 'Unknown',
fileSize: transfer.fileSize || 0,
progress: Math.round((transfer.receivedCount / transfer.totalChunks) * 100),
status: transfer.status,
startTime: transfer.startTime
}));
}
cancelTransfer(fileId) {
try {
if (this.activeTransfers.has(fileId)) {
this.cleanupTransfer(fileId);
return true;
}
if (this.receivingTransfers.has(fileId)) {
this.cleanupReceivingTransfer(fileId);
return true;
}
return false;
} catch (error) {
console.error('❌ Failed to cancel transfer:', error);
return false;
}
}
cleanupTransfer(fileId) {
this.activeTransfers.delete(fileId);
this.sessionKeys.delete(fileId);
this.transferNonces.delete(fileId);
// Remove processed chunk IDs for this transfer
for (const chunkId of this.processedChunks) {
if (chunkId.startsWith(fileId)) {
this.processedChunks.delete(chunkId);
}
}
}
cleanupReceivingTransfer(fileId) {
this.pendingChunks.delete(fileId);
const receivingState = this.receivingTransfers.get(fileId);
if (receivingState) {
// Clear chunk data from memory
receivingState.receivedChunks.clear();
}
this.receivingTransfers.delete(fileId);
this.sessionKeys.delete(fileId);
// Также очищаем финальный буфер, если он ещё хранится
this.receivedFileBuffers.delete(fileId);
// Remove processed chunk IDs
for (const chunkId of this.processedChunks) {
if (chunkId.startsWith(fileId)) {
this.processedChunks.delete(chunkId);
}
}
}
getTransferStatus(fileId) {
if (this.activeTransfers.has(fileId)) {
const transfer = this.activeTransfers.get(fileId);
return {
type: 'sending',
fileId: transfer.fileId,
fileName: transfer.file.name,
progress: Math.round((transfer.sentChunks / transfer.totalChunks) * 100),
status: transfer.status,
startTime: transfer.startTime
};
}
if (this.receivingTransfers.has(fileId)) {
const transfer = this.receivingTransfers.get(fileId);
return {
type: 'receiving',
fileId: transfer.fileId,
fileName: transfer.fileName,
progress: Math.round((transfer.receivedCount / transfer.totalChunks) * 100),
status: transfer.status,
startTime: transfer.startTime
};
}
return null;
}
getSystemStatus() {
return {
initialized: true,
activeTransfers: this.activeTransfers.size,
receivingTransfers: this.receivingTransfers.size,
totalTransfers: this.activeTransfers.size + this.receivingTransfers.size,
maxConcurrentTransfers: this.MAX_CONCURRENT_TRANSFERS,
maxFileSize: this.MAX_FILE_SIZE,
chunkSize: this.CHUNK_SIZE,
hasWebrtcManager: !!this.webrtcManager,
isConnected: this.webrtcManager?.isConnected?.() || false,
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Дополнительная диагностика
hasDataChannel: !!this.webrtcManager?.dataChannel,
dataChannelState: this.webrtcManager?.dataChannel?.readyState,
isVerified: this.webrtcManager?.isVerified,
hasEncryptionKey: !!this.webrtcManager?.encryptionKey,
hasMacKey: !!this.webrtcManager?.macKey,
linkedToWebRTCManager: this.webrtcManager?.fileTransferSystem === this,
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Информация о поддерживаемых типах файлов
supportedFileTypes: this.getSupportedFileTypes(),
fileTypeInfo: this.getFileTypeInfo()
};
}
cleanup() {
// ИСПРАВЛЕНИЕ: Очищаем глобальные флаги
window.FILE_TRANSFER_ACTIVE = false;
window.fileTransferSystem = null;
// ИСПРАВЛЕНИЕ: Восстанавливаем ВСЕ перехваченные методы
if (this.webrtcManager && this.webrtcManager.dataChannel && this.originalOnMessage) {
this.webrtcManager.dataChannel.onmessage = this.originalOnMessage;
this.originalOnMessage = null;
}
if (this.webrtcManager && this.originalProcessMessage) {
this.webrtcManager.processMessage = this.originalProcessMessage;
this.originalProcessMessage = null;
}
if (this.webrtcManager && this.originalRemoveSecurityLayers) {
this.webrtcManager.removeSecurityLayers = this.originalRemoveSecurityLayers;
this.originalRemoveSecurityLayers = null;
}
// Cleanup all active transfers
for (const fileId of this.activeTransfers.keys()) {
this.cleanupTransfer(fileId);
}
for (const fileId of this.receivingTransfers.keys()) {
this.cleanupReceivingTransfer(fileId);
}
// Clear all state
this.pendingChunks.clear();
this.activeTransfers.clear();
this.receivingTransfers.clear();
this.transferQueue.length = 0;
this.sessionKeys.clear();
this.transferNonces.clear();
this.processedChunks.clear();
}
// ============================================
// SESSION UPDATE HANDLER - FIXED
// ============================================
onSessionUpdate(sessionData) {
// Clear session keys cache for resync
this.sessionKeys.clear();
}
// ============================================
// DEBUGGING AND DIAGNOSTICS
// ============================================
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Метод для диагностики проблем с передачей файлов
diagnoseFileTransferIssue() {
const diagnosis = {
timestamp: new Date().toISOString(),
fileTransferSystem: {
initialized: !!this,
hasWebrtcManager: !!this.webrtcManager,
webrtcManagerType: this.webrtcManager?.constructor?.name,
linkedToWebRTCManager: this.webrtcManager?.fileTransferSystem === this
},
webrtcManager: {
hasDataChannel: !!this.webrtcManager?.dataChannel,
dataChannelState: this.webrtcManager?.dataChannel?.readyState,
isConnected: this.webrtcManager?.isConnected?.() || false,
isVerified: this.webrtcManager?.isVerified,
hasEncryptionKey: !!this.webrtcManager?.encryptionKey,
hasMacKey: !!this.webrtcManager?.macKey,
hasKeyFingerprint: !!this.webrtcManager?.keyFingerprint,
hasSessionSalt: !!this.webrtcManager?.sessionSalt
},
globalState: {
fileTransferActive: window.FILE_TRANSFER_ACTIVE,
hasGlobalFileTransferSystem: !!window.fileTransferSystem,
globalFileTransferSystemType: window.fileTransferSystem?.constructor?.name
},
transfers: {
activeTransfers: this.activeTransfers.size,
receivingTransfers: this.receivingTransfers.size,
pendingChunks: this.pendingChunks.size,
sessionKeys: this.sessionKeys.size
},
// КРИТИЧЕСКОЕ ИСПРАВЛЕНИЕ: Информация о поддерживаемых типах файлов
fileTypeSupport: {
supportedTypes: this.getSupportedFileTypes(),
generalMaxSize: this.formatFileSize(this.MAX_FILE_SIZE),
restrictions: Object.keys(this.FILE_TYPE_RESTRICTIONS)
}
};
return diagnosis;
}
async debugKeyDerivation(fileId) {
try {
if (!this.webrtcManager.keyFingerprint || !this.webrtcManager.sessionSalt) {
throw new Error('Session data not available');
}
// Test sender derivation
const senderResult = await this.deriveFileSessionKey(fileId);
// Test receiver derivation with same salt
const receiverKey = await this.deriveFileSessionKeyFromSalt(fileId, senderResult.salt);
// Test encryption/decryption
const testData = new TextEncoder().encode('test data');
const nonce = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: nonce },
senderResult.key,
testData
);
const decrypted = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: nonce },
receiverKey,
encrypted
);
const decryptedText = new TextDecoder().decode(decrypted);
if (decryptedText === 'test data') {
return { success: true, message: 'All tests passed' };
} else {
throw new Error('Decryption verification failed');
}
} catch (error) {
console.error('❌ Key derivation test failed:', error);
return { success: false, error: error.message };
}
}
// ============================================
// АЛЬТЕРНАТИВНЫЙ МЕТОД ИНИЦИАЛИЗАЦИИ ОБРАБОТЧИКОВ
// ============================================
// Если переопределение processMessage не работает,
// используйте этот метод для явной регистрации обработчика
registerWithWebRTCManager() {
if (!this.webrtcManager) {
throw new Error('WebRTC manager not available');
}
// Сохраняем ссылку на файловую систему в WebRTC менеджере
this.webrtcManager.fileTransferSystem = this;
// КРИТИЧЕСКИ ВАЖНО: Устанавливаем обработчик файловых сообщений
this.webrtcManager.setFileMessageHandler = (handler) => {
this.webrtcManager._fileMessageHandler = handler;
};
// Регистрируем наш обработчик
this.webrtcManager.setFileMessageHandler((message) => {
return this.handleFileMessage(message);
});
}
// Метод для прямого вызова из WebRTC менеджера
static createFileMessageFilter(fileTransferSystem) {
return async (event) => {
try {
if (typeof event.data === 'string') {
const parsed = JSON.parse(event.data);
if (fileTransferSystem.isFileTransferMessage(parsed)) {
await fileTransferSystem.handleFileMessage(parsed);
return true; // Сообщение обработано
}
}
} catch (error) {
// Не файловое сообщение или ошибка парсинга
}
return false; // Сообщение не обработано
};
}
}
export { EnhancedSecureFileTransfer };