Доработать вложенные файлы и архив проекта

This commit is contained in:
AidarKC
2026-09-11 14:33:21 +03:00
parent f9ae811702
commit ef068eca81
22 changed files with 3865 additions and 216 deletions
+2 -2
View File
@@ -41,14 +41,14 @@ export function render({ navigate, chrome }) {
const developerRow = createToggleRow({
id: 'advanced-developer-tools',
title: 'Настройки разработчика',
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.',
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках. По умолчанию выключено.',
checked: isDeveloperToolsEnabled(),
});
const filesRow = createToggleRow({
id: 'advanced-dm-files',
title: 'Передача файлов в личных сообщениях',
hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.',
hint: 'Разрешить зашифрованные файлы любого размера, несколько файлов за сообщение и голосовые. Полученные ранее файлы останутся доступными.',
checked: isDmFileTransferEnabled(),
});
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -289,15 +289,19 @@ export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt'])
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
}
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes) {
export async function encryptBytesAesGcm(plainBytes, keyBytes, ivBytes, additionalData = null) {
const key = await importAesKeyRaw(keyBytes, ['encrypt']);
const cipher = await getSubtleApi().encrypt({ name: 'AES-GCM', iv: ivBytes }, key, plainBytes);
const algorithm = { name: 'AES-GCM', iv: ivBytes };
if (additionalData) algorithm.additionalData = additionalData;
const cipher = await getSubtleApi().encrypt(algorithm, key, plainBytes);
return new Uint8Array(cipher);
}
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes) {
export async function decryptBytesAesGcm(cipherBytes, keyBytes, ivBytes, additionalData = null) {
const key = await importAesKeyRaw(keyBytes, ['decrypt']);
const plain = await getSubtleApi().decrypt({ name: 'AES-GCM', iv: ivBytes }, key, cipherBytes);
const algorithm = { name: 'AES-GCM', iv: ivBytes };
if (additionalData) algorithm.additionalData = additionalData;
const plain = await getSubtleApi().decrypt(algorithm, key, cipherBytes);
return new Uint8Array(plain);
}
+614 -96
View File
@@ -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);
}
}
+60 -7
View File
@@ -9,6 +9,7 @@ function defaultParsed(rawText = '') {
replyRef: null,
callSummary: null,
fileAttachment: null,
fileAttachments: [],
};
}
@@ -97,17 +98,46 @@ function decodeFileField(value = '') {
}
function normalizeFileAttachment(fields = {}) {
const version = Number(fields.v || 0);
const id = String(fields.id || '').trim();
const url = decodeFileField(fields.url || '').trim();
const keyB64Url = String(fields.key || '').trim();
const ivB64Url = String(fields.iv || '').trim();
const name = decodeFileField(fields.name || '').trim() || 'file';
const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream';
const size = Number(fields.size || 0);
const encryptedSize = Number(fields.encsize || 0);
if (!id || !url || !keyB64Url || !ivB64Url || !Number.isFinite(size) || size < 0) return null;
if (!id || !url || !keyB64Url || !Number.isFinite(size) || size < 0) return null;
if (version >= 2) {
const ivPrefixB64Url = String(fields.ivp || '').trim();
if (!ivPrefixB64Url) return null;
const chunkSize = Number(fields.chunk || 0);
const chunkCount = Number(fields.chunks || 0);
const kind = String(fields.kind || 'file').trim().toLowerCase() === 'voice' ? 'voice' : 'file';
const durationMs = Math.max(0, Math.floor(Number(fields.dur || 0)));
return {
version,
id,
url,
keyB64Url,
ivPrefixB64Url,
name,
mime,
size,
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
chunkSize: Number.isFinite(chunkSize) && chunkSize > 0 ? chunkSize : 0,
chunkCount: Number.isFinite(chunkCount) && chunkCount >= 0 ? Math.floor(chunkCount) : 0,
torrentV2InfoHashB64Url: String(fields.th || '').trim(),
torrentV2PiecesRootB64Url: String(fields.pr || '').trim(),
kind,
durationMs,
};
}
const ivB64Url = String(fields.iv || '').trim();
if (!ivB64Url) return null;
return {
version: Number(fields.v || 0),
version: version || 1,
id,
url,
keyB64Url,
@@ -116,20 +146,37 @@ function normalizeFileAttachment(fields = {}) {
mime,
size,
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
kind: 'file',
durationMs: 0,
};
}
export function buildDmFileTechBlock(attachment = {}) {
const version = Math.max(1, Math.floor(Number(attachment?.version || 1)));
const id = String(attachment?.id || '').trim();
const url = String(attachment?.url || '').trim();
const keyB64Url = String(attachment?.keyB64Url || '').trim();
const ivB64Url = String(attachment?.ivB64Url || '').trim();
const size = Math.max(0, Math.floor(Number(attachment?.size || 0)));
const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 0)));
if (!id || !url || !keyB64Url || !ivB64Url) throw new Error('Не хватает данных для технического блока файла');
if (!id || !url || !keyB64Url) throw new Error('Не хватает данных для технического блока файла');
const name = encodeURIComponent(String(attachment?.name || 'file'));
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
const encodedUrl = encodeURIComponent(url);
if (version >= 2) {
const ivPrefix = String(attachment?.ivPrefixB64Url || '').trim();
if (!ivPrefix) throw new Error('Не хватает IV prefix для chunked-файла');
const chunkSize = Math.max(0, Math.floor(Number(attachment?.chunkSize || 0)));
const chunkCount = Math.max(0, Math.floor(Number(attachment?.chunkCount || 0)));
const torrentHash = String(attachment?.torrentV2InfoHashB64Url || '').trim();
const piecesRoot = String(attachment?.torrentV2PiecesRootB64Url || '').trim();
const kind = String(attachment?.kind || 'file') === 'voice' ? 'voice' : 'file';
const durationMs = Math.max(0, Math.floor(Number(attachment?.durationMs || 0)));
return `<S:file;v=2;id=${id};url=${encodedUrl};key=${keyB64Url};ivp=${ivPrefix};name=${name};mime=${mime};size=${size};encsize=${encryptedSize};chunk=${chunkSize};chunks=${chunkCount};th=${torrentHash};pr=${piecesRoot};kind=${kind};dur=${durationMs}>`;
}
const ivB64Url = String(attachment?.ivB64Url || '').trim();
if (!ivB64Url) throw new Error('Не хватает IV для файла v1');
return `<S:file;v=1;id=${id};url=${encodedUrl};key=${keyB64Url};iv=${ivB64Url};name=${name};mime=${mime};size=${size};encsize=${encryptedSize}>`;
}
@@ -142,6 +189,7 @@ export function parseDmTechBlocks(rawText = '') {
let replyRef = null;
let callSummary = null;
let fileAttachment = null;
const fileAttachments = [];
while (hasTechPrefix(text, cursor)) {
const end = text.indexOf('>', cursor);
@@ -189,8 +237,12 @@ export function parseDmTechBlocks(rawText = '') {
reason: String(fields.reason || '').trim().toLowerCase(),
};
}
} else if (kind === 'file' && !fileAttachment) {
fileAttachment = normalizeFileAttachment(fields);
} else if (kind === 'file') {
const attachment = normalizeFileAttachment(fields);
if (attachment) {
fileAttachments.push(attachment);
if (!fileAttachment) fileAttachment = attachment;
}
}
cursor = end + 1;
@@ -207,5 +259,6 @@ export function parseDmTechBlocks(rawText = '') {
replyRef,
callSummary,
fileAttachment,
fileAttachments,
};
}
+1 -1
View File
@@ -27,7 +27,7 @@ function writeBoolean(key, enabled) {
}
export function isDeveloperToolsEnabled() {
return readBoolean(STORAGE_KEYS.developerTools, true);
return readBoolean(STORAGE_KEYS.developerTools, false);
}
export function setDeveloperToolsEnabled(enabled) {
+234
View File
@@ -0,0 +1,234 @@
import { bytesToBase64Url, sha256Bytes } from './crypto-utils.js';
export const TORRENT_V2_BLOCK_BYTES = 16 * 1024;
export const TORRENT_V2_PIECE_BYTES = 1024 * 1024;
const ZERO_HASH = new Uint8Array(32);
const encoder = new TextEncoder();
function concatBytes(parts = []) {
const arrays = parts.map((part) => part instanceof Uint8Array ? part : new Uint8Array(part || 0));
const total = arrays.reduce((sum, part) => sum + part.byteLength, 0);
const out = new Uint8Array(total);
let offset = 0;
for (const part of arrays) {
out.set(part, offset);
offset += part.byteLength;
}
return out;
}
async function hashPair(left, right) {
return sha256Bytes(concatBytes([left, right]));
}
function nextPowerOfTwo(value) {
let n = 1;
while (n < value) n *= 2;
return n;
}
async function reduceMerkleLevel(nodes) {
if (nodes.length <= 1) return nodes;
const next = [];
for (let i = 0; i < nodes.length; i += 2) {
next.push(hashPair(nodes[i], nodes[i + 1]));
}
return Promise.all(next);
}
/**
* BEP 52 pieces root / piece-layer hash for one piece.
* Leaves are SHA-256 hashes of 16 KiB blocks. Padding leaves are 32 zero bytes,
* exactly as required by BitTorrent v2.
*/
export async function computeTorrentV2PieceRoot(pieceBytes, { padToPieceLength = false } = {}) {
const bytes = pieceBytes instanceof Uint8Array ? pieceBytes : new Uint8Array(pieceBytes || 0);
if (!bytes.byteLength) return null;
const blockCount = Math.ceil(bytes.byteLength / TORRENT_V2_BLOCK_BYTES);
const leafPromises = [];
for (let offset = 0; offset < bytes.byteLength; offset += TORRENT_V2_BLOCK_BYTES) {
leafPromises.push(sha256Bytes(bytes.subarray(offset, Math.min(bytes.byteLength, offset + TORRENT_V2_BLOCK_BYTES))));
}
let level = await Promise.all(leafPromises);
const targetLeaves = padToPieceLength
? (TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES)
: nextPowerOfTwo(Math.max(1, blockCount));
while (level.length < targetLeaves) level.push(ZERO_HASH.slice());
while (level.length > 1) level = await reduceMerkleLevel(level);
return level[0];
}
let zeroPieceRootPromise = null;
export function getTorrentV2ZeroPieceRoot() {
if (!zeroPieceRootPromise) {
zeroPieceRootPromise = (async () => {
let level = Array.from(
{ length: TORRENT_V2_PIECE_BYTES / TORRENT_V2_BLOCK_BYTES },
() => ZERO_HASH.slice(),
);
while (level.length > 1) level = await reduceMerkleLevel(level);
return level[0];
})();
}
return zeroPieceRootPromise;
}
/**
* Streaming Merkle accumulator for the BEP 52 piece layer. It keeps only O(log n)
* hashes in memory and pads the right side with the standard zero-piece subtree.
*/
export class TorrentV2PieceAccumulator {
constructor() {
this.stack = [];
this.count = 0;
}
async #addSubtree(hash, level) {
let current = hash;
let currentLevel = level;
while (this.stack[currentLevel]) {
current = await hashPair(this.stack[currentLevel], current);
this.stack[currentLevel] = null;
currentLevel += 1;
}
this.stack[currentLevel] = current;
}
async addPieceRoot(hash) {
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) {
throw new Error('Некорректный torrent-v2 piece hash');
}
await this.#addSubtree(hash, 0);
this.count += 1;
}
async finalize() {
if (this.count <= 0) return null;
let target = 1;
while (target < this.count) target *= 2;
let remaining = target - this.count;
let count = this.count;
const zeroRoots = [await getTorrentV2ZeroPieceRoot()];
const ensureZeroLevel = async (level) => {
while (zeroRoots.length <= level) {
const previous = zeroRoots[zeroRoots.length - 1];
zeroRoots.push(await hashPair(previous, previous));
}
return zeroRoots[level];
};
while (remaining > 0) {
let maxByRemaining = Math.floor(Math.log2(remaining));
let alignmentLevel = 0;
let aligned = count;
while (aligned > 0 && aligned % 2 === 0) {
alignmentLevel += 1;
aligned /= 2;
}
const level = Math.min(maxByRemaining, alignmentLevel);
const blockLeaves = 2 ** level;
await this.#addSubtree(await ensureZeroLevel(level), level);
count += blockLeaves;
remaining -= blockLeaves;
}
const root = this.stack.findLast?.((value) => value) || [...this.stack].reverse().find((value) => value) || null;
return root ? root.slice() : null;
}
}
function encodeBString(bytes) {
const data = bytes instanceof Uint8Array ? bytes : encoder.encode(String(bytes ?? ''));
return concatBytes([encoder.encode(`${data.byteLength}:`), data]);
}
function compareByteArrays(left, right) {
const limit = Math.min(left.byteLength, right.byteLength);
for (let i = 0; i < limit; i += 1) {
if (left[i] !== right[i]) return left[i] - right[i];
}
return left.byteLength - right.byteLength;
}
function encodeBValue(value) {
if (value instanceof Uint8Array) return encodeBString(value);
if (typeof value === 'string') return encodeBString(encoder.encode(value));
if (typeof value === 'number' || typeof value === 'bigint') {
const integer = typeof value === 'bigint' ? value : BigInt(Math.trunc(value));
return encoder.encode(`i${integer.toString()}e`);
}
if (Array.isArray(value)) {
return concatBytes([encoder.encode('l'), ...value.map(encodeBValue), encoder.encode('e')]);
}
if (value && typeof value === 'object') {
const entries = Object.entries(value).map(([key, item]) => ({
keyBytes: encoder.encode(key),
value: item,
})).sort((a, b) => compareByteArrays(a.keyBytes, b.keyBytes));
const parts = [encoder.encode('d')];
for (const entry of entries) {
parts.push(encodeBString(entry.keyBytes), encodeBValue(entry.value));
}
parts.push(encoder.encode('e'));
return concatBytes(parts);
}
throw new Error('Неподдерживаемое значение bencode');
}
export function buildTorrentV2InfoBytes({ name, size, piecesRoot, pieceLength = TORRENT_V2_PIECE_BYTES } = {}) {
const cleanName = String(name || 'file');
const fileData = { length: Math.max(0, Math.trunc(Number(size || 0))) };
if (fileData.length > 0) {
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
throw new Error('Для непустого файла нужен 32-байтный pieces root');
}
fileData['pieces root'] = piecesRoot;
}
return encodeBValue({
'file tree': {
[cleanName]: {
'': fileData,
},
},
'meta version': 2,
name: cleanName,
'piece length': Math.trunc(pieceLength),
});
}
export async function computeTorrentV2InfoHash({ name, size, piecesRoot } = {}) {
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
const hash = await sha256Bytes(infoBytes);
return {
infoBytes,
infoHash: hash,
infoHashB64Url: bytesToBase64Url(hash),
};
}
/** Builds a tracker-less BitTorrent v2 metainfo file. */
export function buildTorrentV2MetainfoBytes({ name, size, piecesRoot, pieceLayer = [] } = {}) {
const infoBytes = buildTorrentV2InfoBytes({ name, size, piecesRoot });
const outer = [encoder.encode('d'), encodeBString(encoder.encode('info')), infoBytes];
if (Number(size || 0) > TORRENT_V2_PIECE_BYTES) {
if (!(piecesRoot instanceof Uint8Array) || piecesRoot.byteLength !== 32) {
throw new Error('Некорректный pieces root');
}
const hashes = (Array.isArray(pieceLayer) ? pieceLayer : []).map((hash) => {
if (!(hash instanceof Uint8Array) || hash.byteLength !== 32) throw new Error('Некорректный piece layer');
return hash;
});
const layerBytes = concatBytes(hashes);
outer.push(
encodeBString(encoder.encode('piece layers')),
encoder.encode('d'),
encodeBString(piecesRoot),
encodeBString(layerBytes),
encoder.encode('e'),
);
}
outer.push(encoder.encode('e'));
return concatBytes(outer);
}