release: prepare v4.8.5 security hardening release
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

This commit is contained in:
lockbitchat
2026-05-17 14:48:52 -04:00
parent 4b8c8829f1
commit 0a42aa13c3
35 changed files with 2975 additions and 11976 deletions
+112 -15
View File
@@ -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'));
}
}
+75 -2
View File
@@ -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;
+1 -1
View File
@@ -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
+242 -140
View File
@@ -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 };