import { base64UrlToBytes, bytesToBase58, bytesToBase64Url, decryptBytesAesGcm, encryptBytesAesGcm, importPkcs8Ed25519, randomBytes, sha256Bytes, signBase64, utf8Bytes, } from './crypto-utils.js'; import { loadSessionMaterial } from './key-vault.js'; import { TORRENT_V2_BLOCK_BYTES, TORRENT_V2_PIECE_BYTES, TorrentV2PieceAccumulator, buildTorrentV2MetainfoBytes, computeTorrentV2InfoHash, computeTorrentV2PieceRoot, } from './torrent-v2-service.js'; export const DM_FILE_CHUNK_BYTES = TORRENT_V2_PIECE_BYTES; export const DM_FILE_MANIFEST_PAGE_CHUNKS = 256; export const DM_MAX_ATTACHMENTS_PER_MESSAGE = 10; const MANIFEST_VERSION = 2; const MANIFEST_SCHEME = 'SHINE-DM-CHUNKED-AES-256-GCM'; const ROOT_IV_TOKEN = 0xffffffffffffffffn; const PAGE_IV_BASE = 0x8000000000000000n; const encoder = new TextEncoder(); const decoder = new TextDecoder(); 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 ''; } } function objectUrl(serverBase, id) { return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`; } function objectBaseFromUrl(url = '') { const parsed = new URL(String(url || ''), window.location.href); return parsed.origin; } function makeIv(prefix4, token) { if (!(prefix4 instanceof Uint8Array) || prefix4.byteLength !== 4) { throw new Error('Некорректный IV prefix файла'); } const iv = new Uint8Array(12); iv.set(prefix4, 0); new DataView(iv.buffer).setBigUint64(4, BigInt(token), false); return iv; } function chunkIv(prefix4, index) { return makeIv(prefix4, BigInt(index)); } function pageIv(prefix4, index) { return makeIv(prefix4, PAGE_IV_BASE + BigInt(index)); } function rootIv(prefix4) { return makeIv(prefix4, ROOT_IV_TOKEN); } function makeAad(prefix4, domain, index = 0) { return utf8Bytes(`SHINE-DM-FILE-V2:${bytesToBase64Url(prefix4)}:${domain}:${index}`); } function chunkAad(prefix4, index) { return makeAad(prefix4, 'chunk', index); } function pageAad(prefix4, index) { return makeAad(prefix4, 'page', index); } function rootAad(prefix4) { return makeAad(prefix4, 'root', 0); } function assertSafeIndex(index, label = 'index') { const n = Number(index); if (!Number.isSafeInteger(n) || n < 0) throw new Error(`Некорректный ${label}`); return n; } async function createUploadContext({ login, sessionId, wsUrl }) { const cleanLogin = String(login || '').trim(); const cleanSessionId = String(sessionId || '').trim(); if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии'); 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 относится к другой сессии'); } return { sessionId: cleanSessionId, serverBase: wsUrlToHttpBase(wsUrl), privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8), }; } async function uploadEncryptedObject(bytes, context) { const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || 0); const fileId = bytesToBase58(await sha256Bytes(payload)); const url = objectUrl(context.serverBase, fileId); // Быстрая дедупликация: если сервер уже подтверждает content-addressed объект, // не отправляем его байты повторно. HEAD на сервере сам перепроверяет SHA-256 файла. try { const existing = await fetch(url, { method: 'HEAD', cache: 'no-store' }); if (existing.ok) { const storedSize = Number(existing.headers.get('Content-Length') || -1); const etag = String(existing.headers.get('ETag') || '').replace(/^"|"$/g, ''); if (storedSize === payload.byteLength && (!etag || etag === fileId)) { return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists: true }; } } } catch { // Старый сервер или временная ошибка HEAD не должны ломать загрузку: PUT остаётся источником истины. } const timeMs = Date.now(); const signatureB64 = await signBase64(context.privateKey, uploadPreimage({ sessionId: context.sessionId, fileId, encryptedSize: payload.byteLength, timeMs, })); let response; try { response = await fetch(url, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream', 'X-Shine-Session-Id': context.sessionId, 'X-Shine-Time-Ms': String(timeMs), 'X-Shine-Content-Length': String(payload.byteLength), 'X-Shine-Signature': signatureB64, }, body: payload, 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})`); } let alreadyExists = false; try { const payloadJson = await response.clone().json(); alreadyExists = Boolean(payloadJson?.alreadyExists); } catch { // Ответ старого сервера может не содержать JSON-флаг; успешного HTTP достаточно. } return { id: fileId, url, encryptedSize: payload.byteLength, alreadyExists }; } async function fetchVerifiedObject(url, expectedId) { let response; try { response = await fetch(url, { 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 bytes = new Uint8Array(await response.arrayBuffer()); const actualId = bytesToBase58(await sha256Bytes(bytes)); if (actualId !== String(expectedId || '')) { bytes.fill(0); throw new Error('SHA-256 зашифрованной части не совпал: данные повреждены или подменены'); } return bytes; } function encodeJson(value) { return encoder.encode(JSON.stringify(value)); } function decodeJson(bytes) { return JSON.parse(decoder.decode(bytes)); } async function encryptAndUploadManifestPage({ descriptors, pageIndex, fileKey, ivPrefix, uploadContext }) { const plain = encodeJson({ v: MANIFEST_VERSION, page: pageIndex, chunks: descriptors }); const iv = pageIv(ivPrefix, pageIndex); const aad = pageAad(ivPrefix, pageIndex); let encrypted; try { encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); const stored = await uploadEncryptedObject(encrypted, uploadContext); return { id: stored.id, count: descriptors.length, encryptedSize: stored.encryptedSize, }; } finally { plain.fill(0); iv.fill(0); aad.fill(0); encrypted?.fill(0); } } 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)} КБ`; if (value < 1024 * 1024 * 1024) return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`; return `${(value / (1024 * 1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 * 1024 ? 1 : 2)} ГБ`; } /** * V2 uploader: no whole-file size limit. Only one 1 MiB plaintext piece is held in * memory at a time. Every piece is independently AES-256-GCM encrypted and stored * as Base58(SHA-256(ciphertext)). */ export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl, kind = 'file', durationMs = 0, onProgress = null, } = {}) { if (!file || typeof file.slice !== 'function') throw new Error('Файл не выбран'); const uploadContext = await createUploadContext({ login, sessionId, wsUrl }); const fileKey = randomBytes(32); const ivPrefix = randomBytes(4); const fileSize = Math.max(0, Number(file.size || 0)); const chunkCount = Math.ceil(fileSize / DM_FILE_CHUNK_BYTES); const pageRefs = []; let pageDescriptors = []; let pageIndex = 0; const pieceAccumulator = new TorrentV2PieceAccumulator(); let singlePieceRoot = null; let completedBytes = 0; try { for (let index = 0; index < chunkCount; index += 1) { const start = index * DM_FILE_CHUNK_BYTES; const end = Math.min(fileSize, start + DM_FILE_CHUNK_BYTES); const plain = new Uint8Array(await file.slice(start, end).arrayBuffer()); const iv = chunkIv(ivPrefix, index); const aad = chunkAad(ivPrefix, index); let encrypted = null; try { const pieceRoot = await computeTorrentV2PieceRoot(plain, { padToPieceLength: fileSize > TORRENT_V2_PIECE_BYTES, }); if (!pieceRoot) throw new Error('Не удалось вычислить torrent-v2 hash части'); if (chunkCount <= 1) singlePieceRoot = pieceRoot.slice(); else await pieceAccumulator.addPieceRoot(pieceRoot); encrypted = await encryptBytesAesGcm(plain, fileKey, iv, aad); const stored = await uploadEncryptedObject(encrypted, uploadContext); pageDescriptors.push({ i: index, id: stored.id, ps: plain.byteLength, es: encrypted.byteLength, ph: bytesToBase64Url(pieceRoot), }); completedBytes += plain.byteLength; onProgress?.({ phase: 'chunks', chunkIndex: index, chunkCount, processedBytes: completedBytes, totalBytes: fileSize, }); } finally { plain.fill(0); iv.fill(0); aad.fill(0); encrypted?.fill(0); } if (pageDescriptors.length >= DM_FILE_MANIFEST_PAGE_CHUNKS || index === chunkCount - 1) { const pageRef = await encryptAndUploadManifestPage({ descriptors: pageDescriptors, pageIndex, fileKey, ivPrefix, uploadContext, }); pageRefs.push(pageRef); pageDescriptors = []; pageIndex += 1; } } const piecesRoot = fileSize <= 0 ? null : (chunkCount <= 1 ? singlePieceRoot : await pieceAccumulator.finalize()); const torrentInfo = await computeTorrentV2InfoHash({ name: normalizeFileName(file.name), size: fileSize, piecesRoot, }); const rootManifest = { v: MANIFEST_VERSION, scheme: MANIFEST_SCHEME, chunkSize: DM_FILE_CHUNK_BYTES, fileSize, chunkCount, pages: pageRefs, torrent: { metaVersion: 2, blockLength: TORRENT_V2_BLOCK_BYTES, pieceLength: TORRENT_V2_PIECE_BYTES, piecesRoot: piecesRoot ? bytesToBase64Url(piecesRoot) : '', infoHash: torrentInfo.infoHashB64Url, }, }; const rootPlain = encodeJson(rootManifest); const iv = rootIv(ivPrefix); const aad = rootAad(ivPrefix); let rootEncrypted = null; try { rootEncrypted = await encryptBytesAesGcm(rootPlain, fileKey, iv, aad); const storedRoot = await uploadEncryptedObject(rootEncrypted, uploadContext); onProgress?.({ phase: 'manifest', processedBytes: fileSize, totalBytes: fileSize, chunkCount }); return { version: 2, id: storedRoot.id, url: storedRoot.url, keyB64Url: bytesToBase64Url(fileKey), ivPrefixB64Url: bytesToBase64Url(ivPrefix), name: normalizeFileName(file.name), mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream', size: fileSize, encryptedSize: pageRefs.reduce((sum, item) => sum + Number(item.encryptedSize || 0), 0) + storedRoot.encryptedSize, chunkSize: DM_FILE_CHUNK_BYTES, chunkCount, torrentV2InfoHashB64Url: torrentInfo.infoHashB64Url, torrentV2PiecesRootB64Url: piecesRoot ? bytesToBase64Url(piecesRoot) : '', kind: String(kind || 'file') === 'voice' ? 'voice' : 'file', durationMs: Math.max(0, Math.floor(Number(durationMs || 0))), }; } finally { rootPlain.fill(0); iv.fill(0); aad.fill(0); rootEncrypted?.fill(0); } } finally { fileKey.fill(0); ivPrefix.fill(0); singlePieceRoot?.fill(0); } } async function loadV2RootManifest(attachment) { const id = String(attachment?.id || '').trim(); const url = String(attachment?.url || '').trim(); const key = base64UrlToBytes(String(attachment?.keyB64Url || '')); const ivPrefix = base64UrlToBytes(String(attachment?.ivPrefixB64Url || '')); if (!id || !url || key.byteLength !== 32 || ivPrefix.byteLength !== 4) { key.fill(0); ivPrefix.fill(0); throw new Error('В сообщении не хватает данных для расшифровки chunked-файла'); } const encrypted = await fetchVerifiedObject(url, id); const iv = rootIv(ivPrefix); const aad = rootAad(ivPrefix); let plain; try { plain = await decryptBytesAesGcm(encrypted, key, iv, aad); const manifest = decodeJson(plain); if (Number(manifest?.v) !== MANIFEST_VERSION || manifest?.scheme !== MANIFEST_SCHEME) { throw new Error('Неизвестная версия манифеста файла'); } if (Number(manifest.chunkSize) !== DM_FILE_CHUNK_BYTES) throw new Error('Неожиданный размер chunk в манифесте'); if (Number(manifest.fileSize) !== Number(attachment?.size || 0)) throw new Error('Размер файла не совпал с манифестом'); if (Number(manifest.chunkCount) !== Math.ceil(Number(manifest.fileSize || 0) / DM_FILE_CHUNK_BYTES)) { throw new Error('Некорректное число частей в манифесте'); } const piecesRoot = manifest?.torrent?.piecesRoot ? base64UrlToBytes(manifest.torrent.piecesRoot) : null; const recomputedInfo = await computeTorrentV2InfoHash({ name: normalizeFileName(attachment?.name || 'file'), size: Number(manifest.fileSize || 0), piecesRoot, }); if (recomputedInfo.infoHashB64Url !== String(manifest?.torrent?.infoHash || '')) { piecesRoot?.fill(0); throw new Error('BitTorrent v2 infohash манифеста не совпал'); } piecesRoot?.fill(0); return { manifest, key, ivPrefix, serverBase: objectBaseFromUrl(url) }; } catch (error) { key.fill(0); ivPrefix.fill(0); throw error; } finally { encrypted.fill(0); iv.fill(0); aad.fill(0); plain?.fill(0); } } async function loadV2ManifestPage({ pageRef, pageIndex, key, ivPrefix, serverBase }) { const id = String(pageRef?.id || '').trim(); if (!id) throw new Error('В манифесте отсутствует идентификатор страницы'); const encrypted = await fetchVerifiedObject(objectUrl(serverBase, id), id); const iv = pageIv(ivPrefix, pageIndex); const aad = pageAad(ivPrefix, pageIndex); let plain; try { plain = await decryptBytesAesGcm(encrypted, key, iv, aad); const page = decodeJson(plain); if (Number(page?.v) !== MANIFEST_VERSION || Number(page?.page) !== pageIndex || !Array.isArray(page?.chunks)) { throw new Error('Повреждена страница манифеста'); } if (Number(pageRef?.count || 0) !== page.chunks.length) throw new Error('Размер страницы манифеста не совпал'); return page.chunks; } finally { encrypted.fill(0); iv.fill(0); aad.fill(0); plain?.fill(0); } } async function *iterateV2Chunks(context) { let expectedIndex = 0; for (let pageIndex = 0; pageIndex < context.manifest.pages.length; pageIndex += 1) { const descriptors = await loadV2ManifestPage({ pageRef: context.manifest.pages[pageIndex], pageIndex, key: context.key, ivPrefix: context.ivPrefix, serverBase: context.serverBase, }); for (const descriptor of descriptors) { if (assertSafeIndex(descriptor?.i, 'индекс chunk') !== expectedIndex) { throw new Error('Нарушен порядок частей файла'); } expectedIndex += 1; yield descriptor; } } if (expectedIndex !== Number(context.manifest.chunkCount || 0)) { throw new Error('Манифест содержит неполный список частей'); } } async function decryptV2Chunk(context, descriptor) { const index = assertSafeIndex(descriptor?.i, 'индекс chunk'); const id = String(descriptor?.id || '').trim(); if (!id) throw new Error('У части файла отсутствует hash'); const encrypted = await fetchVerifiedObject(objectUrl(context.serverBase, id), id); const iv = chunkIv(context.ivPrefix, index); const aad = chunkAad(context.ivPrefix, index); let plain; try { plain = await decryptBytesAesGcm(encrypted, context.key, iv, aad); } catch { throw new Error(`Не удалось расшифровать часть ${index + 1}`); } finally { encrypted.fill(0); iv.fill(0); aad.fill(0); } if (plain.byteLength !== Number(descriptor?.ps || 0)) { plain.fill(0); throw new Error(`Размер расшифрованной части ${index + 1} не совпал`); } const expectedPieceHash = String(descriptor?.ph || '').trim(); const pieceRoot = await computeTorrentV2PieceRoot(plain, { padToPieceLength: Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES, }); if (!pieceRoot || bytesToBase64Url(pieceRoot) !== expectedPieceHash) { plain.fill(0); pieceRoot?.fill(0); throw new Error(`BitTorrent v2 SHA-256 части ${index + 1} не совпал`); } return { plain, pieceRoot }; } async function verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot) { const expectedRoot = String(context.manifest?.torrent?.piecesRoot || ''); if (!expectedRoot && Number(context.manifest.fileSize || 0) === 0) return; const actualRoot = Number(context.manifest.chunkCount || 0) <= 1 ? singlePieceRoot : await accumulator.finalize(); if (!actualRoot || bytesToBase64Url(actualRoot) !== expectedRoot) { actualRoot?.fill(0); throw new Error('Итоговый BitTorrent v2 pieces root не совпал'); } actualRoot.fill(0); } async function decryptV2ToSink(attachment, sink, { onProgress = null } = {}) { const context = await loadV2RootManifest(attachment); const accumulator = new TorrentV2PieceAccumulator(); let singlePieceRoot = null; let processedBytes = 0; try { for await (const descriptor of iterateV2Chunks(context)) { const { plain, pieceRoot } = await decryptV2Chunk(context, descriptor); try { if (Number(context.manifest.chunkCount || 0) <= 1) singlePieceRoot = pieceRoot.slice(); else await accumulator.addPieceRoot(pieceRoot); await sink(plain, descriptor); processedBytes += plain.byteLength; onProgress?.({ processedBytes, totalBytes: Number(context.manifest.fileSize || 0), chunkIndex: Number(descriptor.i), chunkCount: Number(context.manifest.chunkCount || 0), }); } finally { plain.fill(0); pieceRoot.fill(0); } } if (processedBytes !== Number(context.manifest.fileSize || 0)) { throw new Error('Итоговый размер расшифрованного файла не совпал'); } await verifyCompletedTorrentRoot(context, accumulator, singlePieceRoot); return context.manifest; } finally { context.key.fill(0); context.ivPrefix.fill(0); singlePieceRoot?.fill(0); } } async function downloadV1(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('В сообщении не хватает данных для расшифровки файла'); const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId); 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); } if (plainBytes.byteLength !== Number(attachment?.size || 0)) { plainBytes.fill(0); throw new Error('Размер расшифрованного файла не совпал с сообщением'); } return plainBytes; } function suggestedPickerTypes(attachment) { const mime = String(attachment?.mime || '').trim(); if (!mime || mime === 'application/octet-stream') return undefined; const name = normalizeFileName(attachment?.name || 'file'); const dot = name.lastIndexOf('.'); const extension = dot >= 0 ? name.slice(dot) : ''; return [{ description: 'Файл SHiNE', accept: { [mime]: extension ? [extension] : [] }, }]; } function saveBlob(blob, name) { const objectUrlValue = URL.createObjectURL(blob); const anchor = document.createElement('a'); anchor.href = objectUrlValue; anchor.download = normalizeFileName(name || 'file'); anchor.rel = 'noopener'; anchor.style.display = 'none'; document.body.append(anchor); anchor.click(); anchor.remove(); window.setTimeout(() => URL.revokeObjectURL(objectUrlValue), 30_000); } export async function decryptDmFileToBlob(attachment = {}, options = {}) { if (Number(attachment?.version || 1) < 2) { const plain = await downloadV1(attachment); const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); plain.fill(0); return blob; } const parts = []; await decryptV2ToSink(attachment, async (plain) => { parts.push(plain.slice().buffer); }, options); return new Blob(parts, { type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream' }); } export async function downloadAndDecryptDmFile(attachment = {}, { onProgress = null } = {}) { if (Number(attachment?.version || 1) < 2) { const plain = await downloadV1(attachment); const blob = new Blob([plain], { type: String(attachment?.mime || 'application/octet-stream') }); plain.fill(0); saveBlob(blob, attachment?.name); return { streamed: false, size: blob.size }; } let writable = null; if (typeof window.showSaveFilePicker === 'function') { try { const handle = await window.showSaveFilePicker({ suggestedName: normalizeFileName(attachment?.name || 'file'), types: suggestedPickerTypes(attachment), }); writable = await handle.createWritable(); } catch (error) { if (error?.name === 'AbortError') return { cancelled: true }; // Some browsers expose the API but reject certain MIME/type descriptors. try { const handle = await window.showSaveFilePicker({ suggestedName: normalizeFileName(attachment?.name || 'file') }); writable = await handle.createWritable(); } catch (secondError) { if (secondError?.name === 'AbortError') return { cancelled: true }; } } } if (writable) { try { await decryptV2ToSink(attachment, async (plain) => { await writable.write(plain); }, { onProgress }); await writable.close(); writable = null; return { streamed: true, size: Number(attachment?.size || 0) }; } catch (error) { try { await writable?.abort?.(); } catch { /* ignore */ } throw error; } } // Cross-browser fallback: still chunk-download/decrypt, but the final Blob is held // in memory because Firefox/Safari do not yet expose a writable download stream. const blob = await decryptDmFileToBlob(attachment, { onProgress }); saveBlob(blob, attachment?.name); return { streamed: false, size: blob.size }; } export async function buildDmFileTorrentV2(attachment = {}) { if (Number(attachment?.version || 1) < 2) throw new Error('Torrent-v2 metadata есть только у файлов SHiNE v2'); const context = await loadV2RootManifest(attachment); const pieceLayer = []; try { for await (const descriptor of iterateV2Chunks(context)) { if (Number(context.manifest.fileSize || 0) > TORRENT_V2_PIECE_BYTES) { const hash = base64UrlToBytes(String(descriptor?.ph || '')); if (hash.byteLength !== 32) throw new Error('Повреждён torrent piece layer'); pieceLayer.push(hash); } } const piecesRoot = context.manifest?.torrent?.piecesRoot ? base64UrlToBytes(context.manifest.torrent.piecesRoot) : null; const bytes = buildTorrentV2MetainfoBytes({ name: normalizeFileName(attachment?.name || 'file'), size: Number(attachment?.size || 0), piecesRoot, pieceLayer, }); piecesRoot?.fill(0); pieceLayer.forEach((hash) => hash.fill(0)); return new Blob([bytes], { type: 'application/x-bittorrent' }); } finally { context.key.fill(0); context.ivPrefix.fill(0); } }