import { base64UrlToBytes, bytesToBase58, bytesToBase64Url, decryptBytesAesGcm, encryptBytesAesGcm, importPkcs8Ed25519, randomBytes, sha256Bytes, signBase64, } from './crypto-utils.js'; import { loadSessionMaterial } from './key-vault.js'; export const DM_FILE_MAX_SOURCE_BYTES = 50 * 1024 * 1024; function normalizeFileName(value = '') { const cleaned = String(value || 'file') .replace(/[\\/\u0000-\u001f\u007f]/g, '_') .trim(); return (cleaned || 'file').slice(0, 180); } function wsUrlToHttpBase(wsUrl = '') { const parsed = new URL(String(wsUrl || ''), window.location.href); if (parsed.protocol === 'wss:') parsed.protocol = 'https:'; else if (parsed.protocol === 'ws:') parsed.protocol = 'http:'; if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('Не удалось определить HTTP-адрес сервера SHiNE'); } parsed.pathname = '/'; parsed.search = ''; parsed.hash = ''; return parsed.origin; } function uploadPreimage({ sessionId, fileId, encryptedSize, timeMs }) { return `DM_FILE_UPLOAD_V1:${sessionId}:${fileId}:${Number(encryptedSize)}:${Number(timeMs)}`; } async function readErrorMessage(response) { try { const raw = String(await response.text()).trim(); if (!raw) return ''; try { const payload = JSON.parse(raw); return String(payload?.message || payload?.error || raw).trim(); } catch { return raw; } } catch { return ''; } } export function formatDmFileSize(bytes = 0) { const value = Math.max(0, Number(bytes || 0)); if (value < 1024) return `${value} Б`; if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`; return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; } export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) { if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран'); const cleanLogin = String(login || '').trim(); const cleanSessionId = String(sessionId || '').trim(); if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); if (Number(file.size || 0) > DM_FILE_MAX_SOURCE_BYTES) { throw new Error(`Файл слишком большой. Текущий лимит — ${formatDmFileSize(DM_FILE_MAX_SOURCE_BYTES)}.`); } const sessionMaterial = await loadSessionMaterial(cleanLogin); if (!sessionMaterial?.sessionPrivPkcs8) { throw new Error('На устройстве нет сохранённого session key для загрузки файла'); } if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) { throw new Error('Сохранённый session key относится к другой сессии'); } const sourceBytes = new Uint8Array(await file.arrayBuffer()); const fileKey = randomBytes(32); const iv = randomBytes(12); let encryptedBytes; try { encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv); } finally { sourceBytes.fill(0); } try { const fileHash = await sha256Bytes(encryptedBytes); const fileId = bytesToBase58(fileHash); const serverBase = wsUrlToHttpBase(wsUrl); const fileUrl = `${serverBase}/dm-files/${fileId}`; const timeMs = Date.now(); const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8); const signatureB64 = await signBase64(privateKey, uploadPreimage({ sessionId: cleanSessionId, fileId, encryptedSize: encryptedBytes.byteLength, timeMs, })); let response; try { response = await fetch(fileUrl, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'X-Shine-Session-Id': cleanSessionId, 'X-Shine-Time-Ms': String(timeMs), 'X-Shine-Content-Length': String(encryptedBytes.byteLength), 'X-Shine-Signature': signatureB64, }, body: encryptedBytes, cache: 'no-store', }); } catch (error) { throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`); } if (!response.ok) { const detail = await readErrorMessage(response); throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`); } return { version: 1, id: fileId, url: fileUrl, keyB64Url: bytesToBase64Url(fileKey), ivB64Url: bytesToBase64Url(iv), name: normalizeFileName(file.name), mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', size: Number(file.size || 0), encryptedSize: encryptedBytes.byteLength, }; } finally { fileKey.fill(0); iv.fill(0); encryptedBytes?.fill(0); } } export async function downloadAndDecryptDmFile(attachment = {}) { const fileId = String(attachment?.id || '').trim(); const fileUrl = String(attachment?.url || '').trim(); const keyB64Url = String(attachment?.keyB64Url || '').trim(); const ivB64Url = String(attachment?.ivB64Url || '').trim(); if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) { throw new Error('В сообщении не хватает данных для расшифровки файла'); } let response; try { response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' }); } catch (error) { throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`); } if (!response.ok) { throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`); } const encryptedBytes = new Uint8Array(await response.arrayBuffer()); const actualId = bytesToBase58(await sha256Bytes(encryptedBytes)); if (actualId !== fileId) { encryptedBytes.fill(0); throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён'); } const keyBytes = base64UrlToBytes(keyB64Url); const ivBytes = base64UrlToBytes(ivB64Url); let plainBytes; try { plainBytes = await decryptBytesAesGcm(encryptedBytes, keyBytes, ivBytes); } catch { throw new Error('Не удалось расшифровать файл: ключ или содержимое повреждены'); } finally { encryptedBytes.fill(0); keyBytes.fill(0); ivBytes.fill(0); } const expectedSize = Number(attachment?.size || 0); if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) { plainBytes.fill(0); throw new Error('Размер расшифрованного файла не совпал с сообщением'); } const blob = new Blob([plainBytes], { type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream', }); plainBytes.fill(0); const objectUrl = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrl; anchor.download = normalizeFileName(attachment?.name || 'file'); anchor.rel = 'noopener'; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000); }