release: prepare v4.8.5 security hardening release
This commit is contained in:
+112
-15
@@ -36,6 +36,31 @@
|
||||
|
||||
// Verification Component
|
||||
const VerificationStep = ({ verificationCode, onConfirm, onReject, localConfirmed, remoteConfirmed, bothConfirmed }) => {
|
||||
const [sasInput, setSasInput] = React.useState('');
|
||||
const [error, setError] = React.useState('');
|
||||
const normalizedExpectedLength = (verificationCode || '').replace(/[-\s]/g, '').length;
|
||||
const normalizedInputLength = sasInput.replace(/[-\s]/g, '').length;
|
||||
const canConfirm = !localConfirmed && normalizedExpectedLength > 0 && normalizedInputLength === normalizedExpectedLength;
|
||||
|
||||
React.useEffect(() => {
|
||||
setSasInput('');
|
||||
setError('');
|
||||
}, [verificationCode]);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
try {
|
||||
setError('');
|
||||
await onConfirm(sasInput);
|
||||
} catch (confirmationError) {
|
||||
setSasInput('');
|
||||
if (confirmationError?.message === 'SAS_MAX_ATTEMPTS') {
|
||||
setError('Too many incorrect attempts. Session reset for safety.');
|
||||
} else {
|
||||
setError('Incorrect code. Check it with your peer and try again.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return React.createElement('div', {
|
||||
className: "card-minimal rounded-xl p-6 border-purple-500/20"
|
||||
}, [
|
||||
@@ -63,7 +88,7 @@
|
||||
React.createElement('p', {
|
||||
key: 'description',
|
||||
className: "text-secondary text-sm"
|
||||
}, "Verify the security code with your contact via another communication channel (voice, SMS, etc.):"),
|
||||
}, "Compare this code with your peer out-of-band, then type the same code below to unlock the chat."),
|
||||
React.createElement('div', {
|
||||
key: 'code-display',
|
||||
className: "text-center"
|
||||
@@ -73,6 +98,36 @@
|
||||
className: "verification-code text-2xl py-4"
|
||||
}, verificationCode)
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'sas-input-wrap',
|
||||
className: "space-y-2"
|
||||
}, [
|
||||
React.createElement('label', {
|
||||
key: 'sas-label',
|
||||
className: "block text-sm text-secondary"
|
||||
}, "Enter the verified code"),
|
||||
React.createElement('input', {
|
||||
key: 'sas-input',
|
||||
type: 'text',
|
||||
value: sasInput,
|
||||
onChange: (event) => {
|
||||
setSasInput(event.target.value.toUpperCase());
|
||||
if (error) setError('');
|
||||
},
|
||||
autoFocus: true,
|
||||
autoComplete: 'off',
|
||||
spellCheck: false,
|
||||
inputMode: 'text',
|
||||
disabled: localConfirmed,
|
||||
placeholder: verificationCode ? 'Type code here' : 'Waiting for code…',
|
||||
className: "w-full rounded-lg border border-purple-500/30 bg-black/20 px-4 py-3 text-center text-xl tracking-[0.3em] text-primary uppercase focus:border-purple-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60",
|
||||
style: { fontFamily: 'monospace', textTransform: 'uppercase' }
|
||||
}),
|
||||
error && React.createElement('p', {
|
||||
key: 'sas-error',
|
||||
className: "text-sm text-red-400"
|
||||
}, error)
|
||||
]),
|
||||
// Verification status indicators
|
||||
React.createElement('div', {
|
||||
key: 'verification-status',
|
||||
@@ -142,14 +197,14 @@
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'confirm',
|
||||
onClick: onConfirm,
|
||||
disabled: localConfirmed,
|
||||
className: `flex-1 py-3 px-4 rounded-lg font-medium transition-all duration-200 ${localConfirmed ? 'bg-gray-500/20 text-gray-400 cursor-not-allowed' : 'btn-verify text-white'}`
|
||||
onClick: handleConfirm,
|
||||
disabled: !canConfirm,
|
||||
className: `flex-1 py-3 px-4 rounded-lg font-medium transition-all duration-200 ${!canConfirm ? 'bg-gray-500/20 text-gray-400 cursor-not-allowed' : 'btn-verify text-white'}`
|
||||
}, [
|
||||
React.createElement('i', {
|
||||
className: `fas ${localConfirmed ? 'fa-check-circle' : 'fa-check'} mr-2`
|
||||
}),
|
||||
localConfirmed ? 'Confirmed' : 'The codes match'
|
||||
localConfirmed ? 'Confirmed' : 'Confirm code'
|
||||
]),
|
||||
React.createElement('button', {
|
||||
key: 'reject',
|
||||
@@ -283,7 +338,9 @@
|
||||
markAnswerCreated,
|
||||
notificationIntegrationRef,
|
||||
isGeneratingKeys,
|
||||
handleCreateOffer
|
||||
handleCreateOffer,
|
||||
relayOnlyMode,
|
||||
setRelayOnlyMode
|
||||
}) => {
|
||||
const [mode, setMode] = React.useState('select');
|
||||
const [notificationPermissionRequested, setNotificationPermissionRequested] = React.useState(false);
|
||||
@@ -294,12 +351,12 @@
|
||||
onClearData();
|
||||
};
|
||||
|
||||
const handleVerificationConfirm = () => {
|
||||
onVerifyConnection(true);
|
||||
const handleVerificationConfirm = (userCode) => {
|
||||
return onVerifyConnection(userCode);
|
||||
};
|
||||
|
||||
const handleVerificationReject = () => {
|
||||
onVerifyConnection(false);
|
||||
onVerifyConnection(null, false);
|
||||
};
|
||||
|
||||
// Request notification permission on first user interaction
|
||||
@@ -449,6 +506,28 @@
|
||||
className: "text-secondary max-w-2xl mx-auto"
|
||||
}, "Choose a connection method for a secure channel with ECDH encryption and Perfect Forward Secrecy.")
|
||||
]),
|
||||
React.createElement('label', {
|
||||
key: 'privacy-mode',
|
||||
className: "mb-6 mx-auto flex max-w-2xl items-start gap-3 rounded-xl border border-purple-500/20 bg-purple-500/10 p-4 text-left"
|
||||
}, [
|
||||
React.createElement('input', {
|
||||
key: 'input',
|
||||
type: 'checkbox',
|
||||
checked: relayOnlyMode,
|
||||
onChange: (event) => setRelayOnlyMode(event.target.checked),
|
||||
className: "mt-1"
|
||||
}),
|
||||
React.createElement('span', { key: 'copy' }, [
|
||||
React.createElement('span', {
|
||||
key: 'title',
|
||||
className: "block text-sm font-medium text-primary"
|
||||
}, 'Privacy mode: relay-only WebRTC'),
|
||||
React.createElement('span', {
|
||||
key: 'desc',
|
||||
className: "block text-sm text-secondary"
|
||||
}, 'Uses TURN relay-only when configured. Without TURN, direct WebRTC may expose IP addresses and relay-only connections cannot start.')
|
||||
])
|
||||
]),
|
||||
|
||||
React.createElement('div', {
|
||||
key: 'options',
|
||||
@@ -1464,6 +1543,9 @@
|
||||
|
||||
const [messages, setMessages] = React.useState([]);
|
||||
const [connectionStatus, setConnectionStatus] = React.useState('disconnected');
|
||||
const [relayOnlyMode, setRelayOnlyMode] = React.useState(() => {
|
||||
try { return localStorage.getItem('securebit_relay_only_mode') === 'true'; } catch { return false; }
|
||||
});
|
||||
|
||||
// Moved scrollToBottom logic to be available globally
|
||||
const [messageInput, setMessageInput] = React.useState('');
|
||||
@@ -1680,6 +1762,13 @@
|
||||
|
||||
// Create scroll function using global helper
|
||||
const scrollToBottom = createScrollToBottomFunction(chatMessagesRef);
|
||||
|
||||
React.useEffect(() => {
|
||||
try { localStorage.setItem('securebit_relay_only_mode', String(relayOnlyMode)); } catch {}
|
||||
if (webrtcManagerRef.current?._config?.webrtc) {
|
||||
webrtcManagerRef.current._config.webrtc.relayOnly = relayOnlyMode;
|
||||
}
|
||||
}, [relayOnlyMode]);
|
||||
|
||||
// Auto-scroll when messages change
|
||||
React.useEffect(() => {
|
||||
@@ -1909,7 +1998,13 @@
|
||||
handleKeyExchange,
|
||||
handleVerificationRequired,
|
||||
handleAnswerError,
|
||||
handleVerificationStateChange
|
||||
handleVerificationStateChange,
|
||||
{
|
||||
webrtc: {
|
||||
relayOnly: relayOnlyMode,
|
||||
iceServers: Array.isArray(window.SECUREBIT_ICE_SERVERS) ? window.SECUREBIT_ICE_SERVERS : undefined
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Initialize notification integration if permission was already granted
|
||||
@@ -1926,7 +2021,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
handleMessage(' SecureBit.chat Enhanced Security Edition v4.7.56 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
|
||||
handleMessage(' SecureBit.chat Enhanced Security Edition v4.8.5 - ECDH + DTLS + SAS initialized. Ready to establish a secure connection with ECDH key exchange, DTLS fingerprint verification, and SAS authentication to prevent MITM attacks.', 'system');
|
||||
|
||||
const handleBeforeUnload = (event) => {
|
||||
if (event.type === 'beforeunload' && !isTabSwitching) {
|
||||
@@ -3226,9 +3321,9 @@
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyConnection = async (isValid) => {
|
||||
const handleVerifyConnection = async (userCode, isValid = true) => {
|
||||
if (isValid) {
|
||||
webrtcManagerRef.current.confirmVerification();
|
||||
webrtcManagerRef.current.confirmVerification(userCode);
|
||||
// Mark local verification as confirmed
|
||||
setLocalVerificationConfirmed(true);
|
||||
|
||||
@@ -3666,7 +3761,9 @@
|
||||
markAnswerCreated: markAnswerCreated,
|
||||
notificationIntegrationRef: notificationIntegrationRef,
|
||||
isGeneratingKeys: isGeneratingKeys,
|
||||
handleCreateOffer: handleCreateOffer
|
||||
handleCreateOffer: handleCreateOffer,
|
||||
relayOnlyMode: relayOnlyMode,
|
||||
setRelayOnlyMode: setRelayOnlyMode
|
||||
})
|
||||
),
|
||||
|
||||
@@ -3810,4 +3907,4 @@
|
||||
ReactDOM.render(AppWithUpdateChecker, document.getElementById('root'));
|
||||
} else {
|
||||
ReactDOM.render(React.createElement(EnhancedSecureP2PChat), document.getElementById('root'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
const [dragOver, setDragOver] = React.useState(false);
|
||||
const [transfers, setTransfers] = React.useState({ sending: [], receiving: [] });
|
||||
const [readyFiles, setReadyFiles] = React.useState([]); // файлы, готовые к скачиванию
|
||||
const [pendingIncomingFiles, setPendingIncomingFiles] = React.useState([]);
|
||||
const fileInputRef = React.useRef(null);
|
||||
|
||||
// Update transfers periodically
|
||||
@@ -18,6 +19,14 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
return () => clearInterval(interval);
|
||||
}, [isConnected, webrtcManager]);
|
||||
|
||||
// Clear session-local UI state when the connection ends so reconnect starts clean.
|
||||
React.useEffect(() => {
|
||||
if (isConnected) return;
|
||||
setReadyFiles([]);
|
||||
setPendingIncomingFiles([]);
|
||||
setTransfers({ sending: [], receiving: [] });
|
||||
}, [isConnected]);
|
||||
|
||||
// Setup file transfer callbacks - ИСПРАВЛЕНИЕ: НЕ отправляем промежуточные сообщения в чат
|
||||
React.useEffect(() => {
|
||||
if (!webrtcManager) return;
|
||||
@@ -61,8 +70,20 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
|
||||
// ИСПРАВЛЕНИЕ: НЕ дублируем сообщения об ошибках
|
||||
// Уведомления об ошибках уже отправляются в WebRTC менеджере
|
||||
},
|
||||
|
||||
// Incoming file request callback - user consent is mandatory
|
||||
(fileRequest) => {
|
||||
setPendingIncomingFiles(prev => {
|
||||
if (prev.some(file => file.fileId === fileRequest.fileId)) return prev;
|
||||
return [...prev, fileRequest];
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
webrtcManager.setFileTransferCallbacks(null, null, null, null);
|
||||
};
|
||||
}, [webrtcManager]);
|
||||
|
||||
const handleFileSelect = async (files) => {
|
||||
@@ -177,6 +198,19 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleIncomingDecision = async (fileId, accepted) => {
|
||||
try {
|
||||
if (accepted) {
|
||||
await webrtcManager.acceptIncomingFile(fileId);
|
||||
} else {
|
||||
await webrtcManager.rejectIncomingFile(fileId);
|
||||
}
|
||||
} finally {
|
||||
setPendingIncomingFiles(prev => prev.filter(file => file.fileId !== fileId));
|
||||
setTransfers(webrtcManager.getFileTransfers());
|
||||
}
|
||||
};
|
||||
|
||||
if (!isConnected) {
|
||||
return React.createElement('div', {
|
||||
className: "p-4 text-center text-muted"
|
||||
@@ -239,6 +273,45 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
onChange: handleFileInputChange
|
||||
}),
|
||||
|
||||
pendingIncomingFiles.length > 0 && React.createElement('div', {
|
||||
key: 'incoming-consent',
|
||||
className: "mt-4 space-y-2"
|
||||
}, pendingIncomingFiles.map(file => React.createElement('div', {
|
||||
key: file.fileId,
|
||||
className: "rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-3"
|
||||
}, [
|
||||
React.createElement('div', {
|
||||
key: 'info',
|
||||
className: "mb-3 flex items-center justify-between gap-3"
|
||||
}, [
|
||||
React.createElement('div', { key: 'text' }, [
|
||||
React.createElement('div', {
|
||||
key: 'title',
|
||||
className: "text-sm font-medium text-primary"
|
||||
}, 'Incoming file request'),
|
||||
React.createElement('div', {
|
||||
key: 'meta',
|
||||
className: "text-xs text-secondary"
|
||||
}, `${file.fileName} · ${formatFileSize(file.fileSize)} · ${file.mimeType}`)
|
||||
])
|
||||
]),
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
className: "flex gap-2"
|
||||
}, [
|
||||
React.createElement('button', {
|
||||
key: 'accept',
|
||||
onClick: () => handleIncomingDecision(file.fileId, true),
|
||||
className: "rounded-md bg-green-500/20 px-3 py-2 text-sm text-green-300 hover:bg-green-500/30"
|
||||
}, 'Accept'),
|
||||
React.createElement('button', {
|
||||
key: 'reject',
|
||||
onClick: () => handleIncomingDecision(file.fileId, false),
|
||||
className: "rounded-md bg-red-500/20 px-3 py-2 text-sm text-red-300 hover:bg-red-500/30"
|
||||
}, 'Reject')
|
||||
])
|
||||
]))),
|
||||
|
||||
// Active Transfers
|
||||
(transfers.sending.length > 0 || transfers.receiving.length > 0) && React.createElement('div', {
|
||||
key: 'transfers',
|
||||
@@ -366,7 +439,7 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
a.click();
|
||||
rf.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
alert('Failed to start download: ' + e.message);
|
||||
alert(e.message || 'This file is no longer available for download.');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
@@ -420,4 +493,4 @@ const FileTransferComponent = ({ webrtcManager, isConnected }) => {
|
||||
};
|
||||
|
||||
// Export
|
||||
window.FileTransferComponent = FileTransferComponent;
|
||||
window.FileTransferComponent = FileTransferComponent;
|
||||
|
||||
@@ -539,7 +539,7 @@ const EnhancedMinimalHeader = ({
|
||||
React.createElement('p', {
|
||||
key: 'subtitle',
|
||||
className: 'text-xs sm:text-sm text-muted hidden sm:block'
|
||||
}, 'End-to-end freedom v4.7.56')
|
||||
}, 'End-to-end freedom v4.8.5')
|
||||
])
|
||||
]),
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -256,12 +256,13 @@ class SecureMemoryManager {
|
||||
}
|
||||
|
||||
class EnhancedSecureFileTransfer {
|
||||
constructor(webrtcManager, onProgress, onComplete, onError, onFileReceived) {
|
||||
constructor(webrtcManager, onProgress, onComplete, onError, onFileReceived, onIncomingFileRequest) {
|
||||
this.webrtcManager = webrtcManager;
|
||||
this.onProgress = onProgress;
|
||||
this.onComplete = onComplete;
|
||||
this.onError = onError;
|
||||
this.onFileReceived = onFileReceived;
|
||||
this.onIncomingFileRequest = onIncomingFileRequest;
|
||||
|
||||
// Validate webrtcManager
|
||||
if (!webrtcManager) {
|
||||
@@ -284,87 +285,58 @@ class EnhancedSecureFileTransfer {
|
||||
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'
|
||||
pdf: {
|
||||
extensions: ['.pdf'],
|
||||
mimeTypes: ['application/pdf'],
|
||||
maxSize: 50 * 1024 * 1024,
|
||||
category: 'PDF',
|
||||
description: 'PDF'
|
||||
},
|
||||
|
||||
text: {
|
||||
extensions: ['.txt'],
|
||||
mimeTypes: ['text/plain'],
|
||||
maxSize: 10 * 1024 * 1024,
|
||||
category: 'Plain text',
|
||||
description: 'TXT'
|
||||
},
|
||||
|
||||
images: {
|
||||
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg', '.ico'],
|
||||
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.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'
|
||||
description: 'JPG, JPEG, PNG, GIF, WEBP, BMP, 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'
|
||||
],
|
||||
extensions: ['.zip'],
|
||||
mimeTypes: ['application/zip'],
|
||||
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'
|
||||
description: 'ZIP'
|
||||
}
|
||||
};
|
||||
this.BLOCKED_EXTENSIONS = new Set([
|
||||
'.exe', '.bat', '.cmd', '.sh', '.js', '.msi', '.dmg', '.app',
|
||||
'.jar', '.scr', '.ps1', '.vbs', '.html', '.svg'
|
||||
]);
|
||||
|
||||
// Active transfers tracking
|
||||
this.activeTransfers = new Map(); // fileId -> transfer state
|
||||
this.receivingTransfers = new Map(); // fileId -> receiving state
|
||||
this.pendingIncomingTransfers = new Map(); // fileId -> validated metadata awaiting consent
|
||||
this.transferQueue = []; // Queue for pending transfers
|
||||
this.pendingChunks = new Map();
|
||||
this.incomingOfferLimiter = new RateLimiter(5, 60000);
|
||||
this.MAX_PENDING_INCOMING_TRANSFERS = 3;
|
||||
|
||||
// Session key derivation
|
||||
this.sessionKeys = new Map(); // fileId -> derived session key
|
||||
@@ -373,6 +345,7 @@ class EnhancedSecureFileTransfer {
|
||||
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.MAX_RETAINED_RECEIVED_FILE_BUFFERS = 3;
|
||||
|
||||
this.setupFileMessageHandlers();
|
||||
|
||||
@@ -386,24 +359,15 @@ class EnhancedSecureFileTransfer {
|
||||
// ============================================
|
||||
|
||||
getFileType(file) {
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.'));
|
||||
const mimeType = file.type.toLowerCase();
|
||||
const fileName = String(file?.name || '').toLowerCase();
|
||||
const extensionIndex = fileName.lastIndexOf('.');
|
||||
const fileExtension = extensionIndex >= 0 ? fileName.substring(extensionIndex) : '';
|
||||
const mimeType = String(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
|
||||
};
|
||||
}
|
||||
|
||||
if (typeConfig.mimeTypes.includes(mimeType)) {
|
||||
const extensionAllowed = typeConfig.extensions.includes(fileExtension);
|
||||
const mimeAllowed = typeConfig.mimeTypes.includes(mimeType);
|
||||
if (extensionAllowed && mimeAllowed) {
|
||||
return {
|
||||
type: typeKey,
|
||||
category: typeConfig.category,
|
||||
@@ -414,26 +378,42 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
}
|
||||
|
||||
const generalConfig = this.FILE_TYPE_RESTRICTIONS.general;
|
||||
return {
|
||||
type: 'general',
|
||||
category: generalConfig.category,
|
||||
description: generalConfig.description,
|
||||
maxSize: generalConfig.maxSize,
|
||||
allowed: true
|
||||
type: 'blocked',
|
||||
category: 'Unsupported',
|
||||
description: 'Allowed: JPG, JPEG, PNG, GIF, WEBP, BMP, ICO, PDF, TXT, ZIP',
|
||||
maxSize: this.MAX_FILE_SIZE,
|
||||
allowed: false,
|
||||
extension: fileExtension,
|
||||
mimeType
|
||||
};
|
||||
}
|
||||
|
||||
validateFile(file) {
|
||||
const fileType = this.getFileType(file);
|
||||
const errors = [];
|
||||
const fileName = String(file?.name || '');
|
||||
const lowerName = fileName.toLowerCase();
|
||||
const extensionIndex = lowerName.lastIndexOf('.');
|
||||
const fileExtension = extensionIndex >= 0 ? lowerName.substring(extensionIndex) : '';
|
||||
const mimeType = String(file?.type || '').toLowerCase();
|
||||
|
||||
if (this.BLOCKED_EXTENSIONS.has(fileExtension)) {
|
||||
errors.push(`File rejected: ${fileExtension} files are not allowed for security reasons.`);
|
||||
}
|
||||
|
||||
if (!mimeType) {
|
||||
errors.push('File rejected: missing MIME type is unsafe.');
|
||||
}
|
||||
|
||||
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 (mimeType && !this.BLOCKED_EXTENSIONS.has(fileExtension)) {
|
||||
errors.push(`File rejected: extension and MIME type must match an allowed type. Supported types: ${fileType.description}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size > this.MAX_FILE_SIZE) {
|
||||
@@ -449,6 +429,48 @@ class EnhancedSecureFileTransfer {
|
||||
};
|
||||
}
|
||||
|
||||
normalizeDisplayFileName(fileName) {
|
||||
return String(fileName || '')
|
||||
.normalize('NFKC')
|
||||
.replace(/[\u0000-\u001F\u007F]/g, '')
|
||||
.replace(/[\\/]+/g, '_')
|
||||
.trim()
|
||||
.slice(0, 255);
|
||||
}
|
||||
|
||||
validateIncomingMetadata(metadata) {
|
||||
const errors = [];
|
||||
if (!metadata || typeof metadata !== 'object') errors.push('Invalid file transfer metadata');
|
||||
if (!metadata?.fileId || typeof metadata.fileId !== 'string') errors.push('Invalid file id');
|
||||
if (!Number.isSafeInteger(metadata?.fileSize) || metadata.fileSize <= 0) errors.push('Invalid file size');
|
||||
if (!Number.isSafeInteger(metadata?.totalChunks) || metadata.totalChunks <= 0) errors.push('Invalid chunk count');
|
||||
if (!Number.isSafeInteger(metadata?.chunkSize) || metadata.chunkSize <= 0 || metadata.chunkSize > this.CHUNK_SIZE) errors.push('Invalid chunk size');
|
||||
if (!Array.isArray(metadata?.salt) || metadata.salt.length !== 32) errors.push('Invalid salt');
|
||||
|
||||
const rawName = typeof metadata?.fileName === 'string' ? metadata.fileName : '';
|
||||
const displayName = this.normalizeDisplayFileName(rawName);
|
||||
const hasDangerousName =
|
||||
!rawName ||
|
||||
rawName !== rawName.trim() ||
|
||||
/[\u0000-\u001F\u007F]/.test(rawName) ||
|
||||
/[\\/]/.test(rawName) ||
|
||||
rawName === '.' ||
|
||||
rawName === '..' ||
|
||||
displayName.length === 0;
|
||||
if (hasDangerousName) errors.push('Dangerous file name');
|
||||
|
||||
if (errors.length === 0) {
|
||||
const validation = this.validateFile({
|
||||
name: displayName,
|
||||
size: metadata.fileSize,
|
||||
type: metadata.fileType || 'application/octet-stream'
|
||||
});
|
||||
if (!validation.isValid) errors.push(...validation.errors);
|
||||
}
|
||||
|
||||
return { isValid: errors.length === 0, errors, displayName };
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
@@ -461,8 +483,6 @@ class EnhancedSecureFileTransfer {
|
||||
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,
|
||||
@@ -878,10 +898,21 @@ class EnhancedSecureFileTransfer {
|
||||
this.activeTransfers.set(fileId, transferState);
|
||||
this.transferNonces.set(fileId, 0);
|
||||
|
||||
const consentPromise = new Promise((resolve, reject) => {
|
||||
transferState.resolveConsent = resolve;
|
||||
transferState.rejectConsent = reject;
|
||||
transferState.consentTimeout = setTimeout(() => {
|
||||
transferState.consentTimeout = null;
|
||||
reject(new Error('Transfer timeout'));
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
// Send file metadata first
|
||||
await this.sendFileMetadata(transferState);
|
||||
|
||||
// Start chunk transmission
|
||||
// Wait for explicit receiver consent before any chunks are sent.
|
||||
await consentPromise;
|
||||
|
||||
await this.startChunkTransmission(transferState);
|
||||
|
||||
return fileId;
|
||||
@@ -1106,11 +1137,14 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
async handleFileTransferStart(metadata) {
|
||||
try {
|
||||
// Validate metadata
|
||||
if (!metadata.fileId || !metadata.fileName || !metadata.fileSize) {
|
||||
throw new Error('Invalid file transfer metadata');
|
||||
const clientId = this.getClientIdentifier();
|
||||
if (!this.incomingOfferLimiter.isAllowed(clientId)) {
|
||||
throw new Error('Incoming file request rate limit exceeded');
|
||||
}
|
||||
|
||||
const validation = this.validateIncomingMetadata(metadata);
|
||||
if (!validation.isValid) throw new Error(validation.errors.join('. '));
|
||||
|
||||
if (metadata.signature && this.verificationKey) {
|
||||
try {
|
||||
const isValid = await FileMetadataSigner.verifyFileMetadata(
|
||||
@@ -1137,55 +1171,30 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
|
||||
// Check if we already have this transfer
|
||||
if (this.receivingTransfers.has(metadata.fileId)) {
|
||||
if (this.receivingTransfers.has(metadata.fileId) || this.pendingIncomingTransfers.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);
|
||||
if (this.pendingIncomingTransfers.size >= this.MAX_PENDING_INCOMING_TRANSFERS) {
|
||||
throw new Error('Too many pending incoming file requests');
|
||||
}
|
||||
|
||||
const pendingMetadata = {
|
||||
...metadata,
|
||||
fileName: validation.displayName,
|
||||
receivedAt: Date.now()
|
||||
};
|
||||
this.pendingIncomingTransfers.set(metadata.fileId, pendingMetadata);
|
||||
|
||||
if (typeof this.onIncomingFileRequest === 'function') {
|
||||
this.onIncomingFileRequest({
|
||||
fileId: pendingMetadata.fileId,
|
||||
fileName: pendingMetadata.fileName,
|
||||
fileSize: pendingMetadata.fileSize,
|
||||
mimeType: pendingMetadata.fileType || 'application/octet-stream'
|
||||
});
|
||||
} else {
|
||||
await this.rejectIncomingFile(metadata.fileId, 'User consent unavailable');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
@@ -1211,13 +1220,8 @@ class EnhancedSecureFileTransfer {
|
||||
try {
|
||||
let receivingState = this.receivingTransfers.get(chunkMessage.fileId);
|
||||
|
||||
// Buffer early chunks if transfer not yet initialized
|
||||
// Never buffer chunks before explicit consent.
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1352,7 +1356,7 @@ class EnhancedSecureFileTransfer {
|
||||
receivingState.endTime = Date.now();
|
||||
receivingState.status = 'completed';
|
||||
|
||||
this.receivedFileBuffers.set(receivingState.fileId, {
|
||||
this._storeReceivedFileBuffer(receivingState.fileId, {
|
||||
buffer: fileBuffer,
|
||||
type: receivingState.fileType,
|
||||
name: receivingState.fileName,
|
||||
@@ -1360,7 +1364,13 @@ class EnhancedSecureFileTransfer {
|
||||
});
|
||||
|
||||
if (this.onFileReceived) {
|
||||
const getBlob = async () => new Blob([this.receivedFileBuffers.get(receivingState.fileId).buffer], { type: receivingState.fileType });
|
||||
const getBlob = async () => {
|
||||
const blob = await this.getBlob(receivingState.fileId);
|
||||
if (!blob) {
|
||||
throw new Error('This file is no longer available for download.');
|
||||
}
|
||||
return blob;
|
||||
};
|
||||
const getObjectURL = async () => {
|
||||
const blob = await getBlob();
|
||||
return URL.createObjectURL(blob);
|
||||
@@ -1443,8 +1453,18 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
if (response.accepted) {
|
||||
transferState.status = 'accepted';
|
||||
if (transferState.consentTimeout) clearTimeout(transferState.consentTimeout);
|
||||
transferState.consentTimeout = null;
|
||||
transferState.resolveConsent?.();
|
||||
transferState.resolveConsent = null;
|
||||
transferState.rejectConsent = null;
|
||||
} else {
|
||||
transferState.status = 'rejected';
|
||||
if (transferState.consentTimeout) clearTimeout(transferState.consentTimeout);
|
||||
transferState.consentTimeout = null;
|
||||
transferState.rejectConsent?.(new Error(response.error || 'Transfer rejected'));
|
||||
transferState.rejectConsent = null;
|
||||
transferState.resolveConsent = null;
|
||||
|
||||
if (this.onError) {
|
||||
this.onError(`Transfer rejected: ${response.error || 'Unknown reason'}`);
|
||||
@@ -1555,6 +1575,48 @@ class EnhancedSecureFileTransfer {
|
||||
}));
|
||||
}
|
||||
|
||||
getPendingIncomingTransfers() {
|
||||
return Array.from(this.pendingIncomingTransfers.values()).map(transfer => ({
|
||||
fileId: transfer.fileId,
|
||||
fileName: transfer.fileName,
|
||||
fileSize: transfer.fileSize,
|
||||
mimeType: transfer.fileType || 'application/octet-stream',
|
||||
receivedAt: transfer.receivedAt
|
||||
}));
|
||||
}
|
||||
|
||||
async acceptIncomingFile(fileId) {
|
||||
const metadata = this.pendingIncomingTransfers.get(fileId);
|
||||
if (!metadata) return false;
|
||||
const sessionKey = await this.deriveFileSessionKeyFromSalt(fileId, metadata.salt);
|
||||
this.receivingTransfers.set(fileId, {
|
||||
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,
|
||||
salt: metadata.salt,
|
||||
receivedChunks: new Map(),
|
||||
receivedCount: 0,
|
||||
startTime: Date.now(),
|
||||
lastChunkTime: Date.now(),
|
||||
status: 'receiving'
|
||||
});
|
||||
this.pendingIncomingTransfers.delete(fileId);
|
||||
await this.sendSecureMessage({ type: 'file_transfer_response', fileId, accepted: true, timestamp: Date.now() });
|
||||
return true;
|
||||
}
|
||||
|
||||
async rejectIncomingFile(fileId, error = 'Rejected by user') {
|
||||
if (!this.pendingIncomingTransfers.has(fileId)) return false;
|
||||
this.pendingIncomingTransfers.delete(fileId);
|
||||
await this.sendSecureMessage({ type: 'file_transfer_response', fileId, accepted: false, error, timestamp: Date.now() });
|
||||
return true;
|
||||
}
|
||||
|
||||
cancelTransfer(fileId) {
|
||||
try {
|
||||
if (this.activeTransfers.has(fileId)) {
|
||||
@@ -1573,6 +1635,19 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
|
||||
cleanupTransfer(fileId) {
|
||||
const transferState = this.activeTransfers.get(fileId);
|
||||
if (transferState) {
|
||||
if (transferState.consentTimeout) {
|
||||
clearTimeout(transferState.consentTimeout);
|
||||
transferState.consentTimeout = null;
|
||||
}
|
||||
if (transferState.rejectConsent) {
|
||||
transferState.rejectConsent(new Error('Transfer cancelled during cleanup or disconnect'));
|
||||
transferState.rejectConsent = null;
|
||||
transferState.resolveConsent = null;
|
||||
}
|
||||
}
|
||||
|
||||
this.activeTransfers.delete(fileId);
|
||||
this.sessionKeys.delete(fileId);
|
||||
this.transferNonces.delete(fileId);
|
||||
@@ -1585,6 +1660,28 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
}
|
||||
|
||||
_storeReceivedFileBuffer(fileId, entry) {
|
||||
this.receivedFileBuffers.set(fileId, entry);
|
||||
while (this.receivedFileBuffers.size > this.MAX_RETAINED_RECEIVED_FILE_BUFFERS) {
|
||||
const oldestFileId = this.receivedFileBuffers.keys().next().value;
|
||||
this._discardReceivedFileBuffer(oldestFileId);
|
||||
}
|
||||
}
|
||||
|
||||
_discardReceivedFileBuffer(fileId) {
|
||||
const fileBuffer = this.receivedFileBuffers.get(fileId);
|
||||
if (!fileBuffer) return;
|
||||
try {
|
||||
if (fileBuffer.buffer) {
|
||||
SecureMemoryManager.secureWipe(fileBuffer.buffer);
|
||||
new Uint8Array(fileBuffer.buffer).fill(0);
|
||||
}
|
||||
} catch (_) {
|
||||
// Best-effort wipe; deletion must still proceed.
|
||||
}
|
||||
this.receivedFileBuffers.delete(fileId);
|
||||
}
|
||||
|
||||
// ✅ УЛУЧШЕННАЯ безопасная очистка памяти для предотвращения use-after-free
|
||||
cleanupReceivingTransfer(fileId) {
|
||||
try {
|
||||
@@ -1809,6 +1906,7 @@ class EnhancedSecureFileTransfer {
|
||||
|
||||
// Clear all state
|
||||
this.pendingChunks.clear();
|
||||
this.pendingIncomingTransfers.clear();
|
||||
this.activeTransfers.clear();
|
||||
this.receivingTransfers.clear();
|
||||
this.transferQueue.length = 0;
|
||||
@@ -1816,6 +1914,10 @@ class EnhancedSecureFileTransfer {
|
||||
this.transferNonces.clear();
|
||||
this.processedChunks.clear();
|
||||
|
||||
for (const fileId of Array.from(this.receivedFileBuffers.keys())) {
|
||||
this._discardReceivedFileBuffer(fileId);
|
||||
}
|
||||
|
||||
this.clearKeys();
|
||||
}
|
||||
|
||||
@@ -2026,4 +2128,4 @@ class EnhancedSecureFileTransfer {
|
||||
}
|
||||
}
|
||||
|
||||
export { EnhancedSecureFileTransfer };
|
||||
export { EnhancedSecureFileTransfer };
|
||||
|
||||
Reference in New Issue
Block a user