SHA256
Доработать вложенные файлы и архив проекта
This commit is contained in:
@@ -8,10 +8,28 @@ import {
|
||||
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_MAX_SOURCE_BYTES = 50 * 1024 * 1024;
|
||||
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')
|
||||
@@ -52,22 +70,63 @@ async function readErrorMessage(response) {
|
||||
}
|
||||
}
|
||||
|
||||
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)} МБ`;
|
||||
function objectUrl(serverBase, id) {
|
||||
return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`;
|
||||
}
|
||||
|
||||
export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) {
|
||||
if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран');
|
||||
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('Нет активной пользовательской сессии');
|
||||
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 для загрузки файла');
|
||||
@@ -75,98 +134,455 @@ export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } =
|
||||
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
|
||||
throw new Error('Сохранённый session key относится к другой сессии');
|
||||
}
|
||||
return {
|
||||
sessionId: cleanSessionId,
|
||||
serverBase: wsUrlToHttpBase(wsUrl),
|
||||
privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8),
|
||||
};
|
||||
}
|
||||
|
||||
const sourceBytes = new Uint8Array(await file.arrayBuffer());
|
||||
const fileKey = randomBytes(32);
|
||||
const iv = randomBytes(12);
|
||||
let encryptedBytes;
|
||||
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 {
|
||||
encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv);
|
||||
} finally {
|
||||
sourceBytes.fill(0);
|
||||
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 {
|
||||
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,
|
||||
}));
|
||||
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 };
|
||||
}
|
||||
|
||||
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'}`);
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await readErrorMessage(response);
|
||||
throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`);
|
||||
}
|
||||
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 {
|
||||
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,
|
||||
id: stored.id,
|
||||
count: descriptors.length,
|
||||
encryptedSize: stored.encryptedSize,
|
||||
};
|
||||
} finally {
|
||||
fileKey.fill(0);
|
||||
plain.fill(0);
|
||||
iv.fill(0);
|
||||
encryptedBytes?.fill(0);
|
||||
aad.fill(0);
|
||||
encrypted?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadAndDecryptDmFile(attachment = {}) {
|
||||
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('В сообщении не хватает данных для расшифровки файла');
|
||||
}
|
||||
|
||||
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 файла не совпал: зашифрованный файл повреждён или подменён');
|
||||
}
|
||||
|
||||
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) throw new Error('В сообщении не хватает данных для расшифровки файла');
|
||||
const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId);
|
||||
const keyBytes = base64UrlToBytes(keyB64Url);
|
||||
const ivBytes = base64UrlToBytes(ivB64Url);
|
||||
let plainBytes;
|
||||
@@ -179,26 +595,128 @@ export async function downloadAndDecryptDmFile(attachment = {}) {
|
||||
keyBytes.fill(0);
|
||||
ivBytes.fill(0);
|
||||
}
|
||||
|
||||
const expectedSize = Number(attachment?.size || 0);
|
||||
if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) {
|
||||
if (plainBytes.byteLength !== Number(attachment?.size || 0)) {
|
||||
plainBytes.fill(0);
|
||||
throw new Error('Размер расшифрованного файла не совпал с сообщением');
|
||||
}
|
||||
return plainBytes;
|
||||
}
|
||||
|
||||
const blob = new Blob([plainBytes], {
|
||||
type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream',
|
||||
});
|
||||
plainBytes.fill(0);
|
||||
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] : [] },
|
||||
}];
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
function saveBlob(blob, name) {
|
||||
const objectUrlValue = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = normalizeFileName(attachment?.name || 'file');
|
||||
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(objectUrl), 30_000);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user