SHA256
Доработать вложенные файлы и архив проекта
This commit is contained in:
+1
-1
@@ -147,7 +147,7 @@ public final class SignedMessagesRealtime {
|
|||||||
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
if (isBlank(session.getPushEndpoint()) || isBlank(session.getPushP256dhKey()) || isBlank(session.getPushAuthKey())) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
String text = "Вам пришло новое личное сообщение от " + message.getFromLogin() + ".";
|
String text = "Новое сообщение от " + message.getFromLogin();
|
||||||
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
String payload = "{\"kind\":\"new_message\",\"fromLogin\":\"" + jsonEscape(message.getFromLogin()) + "\",\"text\":\"" + jsonEscape(text) + "\"}";
|
||||||
return WebPushSender.sendBase64Payload(
|
return WebPushSender.sendBase64Payload(
|
||||||
session.getPushEndpoint(),
|
session.getPushEndpoint(),
|
||||||
|
|||||||
+2
-1
@@ -3,6 +3,7 @@ package server.logic.ws_protocol.JSON.push;
|
|||||||
import nl.martijndwars.webpush.Notification;
|
import nl.martijndwars.webpush.Notification;
|
||||||
import nl.martijndwars.webpush.PushService;
|
import nl.martijndwars.webpush.PushService;
|
||||||
import nl.martijndwars.webpush.Subscription;
|
import nl.martijndwars.webpush.Subscription;
|
||||||
|
import nl.martijndwars.webpush.Urgency;
|
||||||
import org.jose4j.lang.JoseException;
|
import org.jose4j.lang.JoseException;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -61,7 +62,7 @@ public final class WebPushSender {
|
|||||||
endpoint,
|
endpoint,
|
||||||
new Subscription.Keys(p256dhKey, authKey)
|
new Subscription.Keys(p256dhKey, authKey)
|
||||||
);
|
);
|
||||||
Notification notification = new Notification(subscription, payloadB64);
|
Notification notification = new Notification(subscription, payloadB64, Urgency.HIGH);
|
||||||
var response = service().send(notification);
|
var response = service().send(notification);
|
||||||
int code = response.getStatusLine().getStatusCode();
|
int code = response.getStatusLine().getStatusCode();
|
||||||
return code >= 200 && code < 300;
|
return code >= 200 && code < 300;
|
||||||
|
|||||||
@@ -102,15 +102,20 @@ public final class DmFileServlet extends HttpServlet {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content-addressed объект неизменяем. Повторная корректно подписанная загрузка того же
|
// Content-addressed объект неизменяем. Нельзя доверять одному только имени файла:
|
||||||
// ciphertext безопасна и просто возвращает существующий объект.
|
// перед ответом «уже есть» повторно проверяем реальный SHA-256 сохранённых байтов.
|
||||||
|
// Если файл на диске повреждён/подменён, удаляем его и принимаем корректную загрузку заново.
|
||||||
if (Files.isRegularFile(target)) {
|
if (Files.isRegularFile(target)) {
|
||||||
if (Files.size(target) != auth.declaredLength) {
|
StoredObjectCheck stored = verifyStoredObject(target, fileId);
|
||||||
writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером");
|
if (stored.valid()) {
|
||||||
|
if (stored.size() != auth.declaredLength) {
|
||||||
|
writeError(resp, HttpServletResponse.SC_CONFLICT, "FILE_ID_CONFLICT", "Файл с таким hash уже существует с другим размером");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
writeUploadOk(resp, fileId, auth.declaredLength, true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
writeUploadOk(resp, fileId, auth.declaredLength, true);
|
Files.deleteIfExists(target);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp");
|
Path temp = Files.createTempFile(storageDir, ".dm-upload-", ".tmp");
|
||||||
@@ -153,7 +158,19 @@ public final class DmFileServlet extends HttpServlet {
|
|||||||
}
|
}
|
||||||
keepTemp = true; // temp уже перемещён, удалять нечего
|
keepTemp = true; // temp уже перемещён, удалять нечего
|
||||||
} catch (FileAlreadyExistsException race) {
|
} catch (FileAlreadyExistsException race) {
|
||||||
alreadyExists = true;
|
StoredObjectCheck stored = verifyStoredObject(target, fileId);
|
||||||
|
if (stored.valid() && stored.size() == actualLength) {
|
||||||
|
alreadyExists = true;
|
||||||
|
} else {
|
||||||
|
// Даже в редкой гонке не подтверждаем объект, пока его реальные байты не прошли hash-проверку.
|
||||||
|
Files.deleteIfExists(target);
|
||||||
|
try {
|
||||||
|
Files.move(temp, target, StandardCopyOption.ATOMIC_MOVE);
|
||||||
|
} catch (AtomicMoveNotSupportedException ignored) {
|
||||||
|
Files.move(temp, target);
|
||||||
|
}
|
||||||
|
keepTemp = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
writeUploadOk(resp, fileId, actualLength, alreadyExists);
|
writeUploadOk(resp, fileId, actualLength, alreadyExists);
|
||||||
@@ -188,7 +205,15 @@ public final class DmFileServlet extends HttpServlet {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long size = Files.size(target);
|
StoredObjectCheck stored = verifyStoredObject(target, fileId);
|
||||||
|
if (!stored.valid()) {
|
||||||
|
// Повреждённый content-addressed объект нельзя подтверждать через HEAD и нельзя отдавать через GET.
|
||||||
|
Files.deleteIfExists(target);
|
||||||
|
writeError(resp, HttpServletResponse.SC_NOT_FOUND, "FILE_NOT_FOUND", "Файл не найден");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
long size = stored.size();
|
||||||
resp.setStatus(HttpServletResponse.SC_OK);
|
resp.setStatus(HttpServletResponse.SC_OK);
|
||||||
resp.setContentType("application/octet-stream");
|
resp.setContentType("application/octet-stream");
|
||||||
resp.setHeader("Content-Length", Long.toString(size));
|
resp.setHeader("Content-Length", Long.toString(size));
|
||||||
@@ -203,6 +228,25 @@ public final class DmFileServlet extends HttpServlet {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private static StoredObjectCheck verifyStoredObject(Path target, String expectedFileId) throws IOException {
|
||||||
|
MessageDigest digest = sha256Digest();
|
||||||
|
long size = 0L;
|
||||||
|
byte[] buffer = new byte[64 * 1024];
|
||||||
|
try (InputStream in = Files.newInputStream(target)) {
|
||||||
|
int read;
|
||||||
|
while ((read = in.read(buffer)) >= 0) {
|
||||||
|
if (read == 0) continue;
|
||||||
|
size += read;
|
||||||
|
digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
String actualFileId = toBase58(digest.digest());
|
||||||
|
return new StoredObjectCheck(Objects.equals(expectedFileId, actualFileId), size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record StoredObjectCheck(boolean valid, long size) {}
|
||||||
|
|
||||||
private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure {
|
private UploadAuth readAndVerifyUploadAuth(HttpServletRequest req, String fileId) throws AuthFailure {
|
||||||
String sessionId = requiredHeader(req, HEADER_SESSION_ID);
|
String sessionId = requiredHeader(req, HEADER_SESSION_ID);
|
||||||
String timeRaw = requiredHeader(req, HEADER_TIME_MS);
|
String timeRaw = requiredHeader(req, HEADER_TIME_MS);
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ server.ui.buildHash=
|
|||||||
# На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)).
|
# На диске лежит только AES-GCM ciphertext. Имя файла = Base58(SHA-256(ciphertext)).
|
||||||
dm.files.enabled=true
|
dm.files.enabled=true
|
||||||
dm.files.storageDir=data/dm-files
|
dm.files.storageDir=data/dm-files
|
||||||
# 50 MiB исходного файла + 16 bytes AES-GCM authentication tag.
|
# Лимит одного immutable ciphertext-объекта, НЕ всего файла.
|
||||||
|
# DM file v2 режет файл на 1 MiB части, поэтому общий размер файла этим параметром не ограничен.
|
||||||
|
# Значение 50 MiB сохранено для совместимости с DM file v1.
|
||||||
dm.files.maxBytes=52428816
|
dm.files.maxBytes=52428816
|
||||||
|
|
||||||
webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
|
webpush.vapid.public=BOdoWZndZRaNe9kyUFsJ5-xEfFABXNKennAKg15Z7ycAwUIQ7yDV_sIWWYJCwJriN4g9oU-CyJPrn1U6lfxuDbI
|
||||||
|
|||||||
+2
-2
@@ -1,2 +1,2 @@
|
|||||||
client.version=1.12.14
|
client.version=1.12.15
|
||||||
server.version=1.10.5
|
server.version=1.10.6
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ set -Eeuo pipefail
|
|||||||
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
ROOT="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
OUT="${1:-SHiNE-bundle-$(date +%Y%m%d-%H%M%S).zip}"
|
DEFAULT_OUT=0
|
||||||
|
if [[ $# -gt 0 ]]; then
|
||||||
|
OUT="$1"
|
||||||
|
else
|
||||||
|
DEFAULT_OUT=1
|
||||||
|
OUT="SHiNE-bundle-$(date +%Y.%m.%d-%H.%M.%S).zip"
|
||||||
|
fi
|
||||||
case "$OUT" in
|
case "$OUT" in
|
||||||
/*) ;;
|
/*) ;;
|
||||||
*) OUT="$ROOT/$OUT" ;;
|
*) OUT="$ROOT/$OUT" ;;
|
||||||
@@ -169,7 +175,11 @@ if [[ ! -s "$SAFE_LIST" ]]; then
|
|||||||
exit 3
|
exit 3
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f -- "$OUT"
|
if (( DEFAULT_OUT == 1 )); then
|
||||||
|
rm -f -- "$ROOT"/SHiNE-bundle-*.zip
|
||||||
|
else
|
||||||
|
rm -f -- "$OUT"
|
||||||
|
fi
|
||||||
|
|
||||||
(
|
(
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ dm.files.storageDir=data/dm-files
|
|||||||
dm.files.maxBytes=52428816
|
dm.files.maxBytes=52428816
|
||||||
```
|
```
|
||||||
|
|
||||||
`dm.files.maxBytes` ограничивает размер ciphertext. Текущий официальный UI ограничивает исходный файл 50 MiB; AES-GCM добавляет 16-byte authentication tag.
|
`dm.files.maxBytes` ограничивает размер одного ciphertext-объекта. Для legacy DM file v1 это фактически ограничивало целый файл. В DM file v2 файл состоит из 1-MiB ciphertext-объектов, поэтому общий размер логического файла этим параметром не ограничивается.
|
||||||
|
|
||||||
## 3. PUT `/dm-files/{fileId}`
|
## 3. PUT `/dm-files/{fileId}`
|
||||||
|
|
||||||
@@ -57,7 +57,7 @@ DM_FILE_UPLOAD_V1:{sessionId}:{fileId}:{encryptedSize}:{timeMs}
|
|||||||
|
|
||||||
Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`.
|
Сервер потоково пишет временный файл, одновременно считает SHA-256, проверяет фактический размер и только после успешной проверки атомарно перемещает объект под именем `{fileId}`.
|
||||||
|
|
||||||
Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна.
|
Повторная корректно подписанная загрузка уже существующего immutable объекта идемпотентна. Перед ответом `alreadyExists=true` сервер повторно вычисляет `SHA-256` уже сохранённого объекта и сверяет его с `fileId`; одного совпадения имени файла на диске недостаточно. Если объект повреждён или подменён, сервер удаляет некорректную копию и принимает корректный `PUT` заново.
|
||||||
|
|
||||||
Успешный ответ:
|
Успешный ответ:
|
||||||
|
|
||||||
@@ -84,16 +84,30 @@ GET не требует пользовательской сессии: `fileId`
|
|||||||
|
|
||||||
## 5. HEAD и OPTIONS
|
## 5. HEAD и OPTIONS
|
||||||
|
|
||||||
- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела;
|
- `HEAD /dm-files/{fileId}` возвращает метаданные ciphertext без тела только после повторной проверки `Base58(SHA-256(ciphertext)) == fileId`; официальный UI использует этот запрос перед `PUT`, чтобы не отправлять уже существующие байты повторно;
|
||||||
- `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT.
|
- `OPTIONS /dm-files/*` обслуживает CORS preflight для браузерного PUT.
|
||||||
|
|
||||||
## 6. Reverse proxy
|
## 6. Reverse proxy
|
||||||
|
|
||||||
Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback.
|
Caddy/Nginx должен проксировать `/dm-files/*` в тот же Jetty, что обслуживает `/ws`. Route должен находиться до SPA fallback.
|
||||||
|
|
||||||
## 7. Ограничения v1
|
## 7. Ограничения legacy v1
|
||||||
|
|
||||||
- файл перед загрузкой целиком читается в память браузера, поэтому UI ограничен 50 MiB;
|
- legacy-файл перед загрузкой целиком читается в память браузера и ограничен 50 MiB;
|
||||||
|
- новые отправки официального UI используют v2 и этого ограничения не имеют;
|
||||||
- удаления/TTL/garbage collection пока нет;
|
- удаления/TTL/garbage collection пока нет;
|
||||||
- если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом;
|
- если ciphertext уже загружен, а отправка E2EE DM затем не удалась, объект может остаться orphan-файлом;
|
||||||
- resumable/chunked upload не входит в v1.
|
- resumable/chunked upload не входит в v1.
|
||||||
|
|
||||||
|
## 8. DM file v2: большие файлы
|
||||||
|
|
||||||
|
Начиная с UI v2 один логический файл не загружается одним HTTP-объектом. Клиент режет plaintext на `1 MiB` части и каждый AES-GCM ciphertext-кусок загружает отдельным обычным `PUT /dm-files/{fileId}`.
|
||||||
|
|
||||||
|
Поэтому `dm.files.maxBytes` — это лимит **одного immutable HTTP-объекта**, а не всего пользовательского файла. При стандартном `chunkSize=1 MiB` общий размер файла этим параметром не ограничивается.
|
||||||
|
|
||||||
|
Манифест также хранится через тот же immutable API:
|
||||||
|
|
||||||
|
- encrypted manifest page — до 256 chunk descriptors;
|
||||||
|
- encrypted root manifest — ссылки на страницы + BitTorrent v2 root metadata.
|
||||||
|
|
||||||
|
Никаких новых доверенных серверных операций для v2 не требуется: сервер по-прежнему только проверяет подпись PUT, длину и `Base58(SHA-256(ciphertext))`.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на
|
- `docs/Personal_Messages/Доставка_и_синхронизация_DM.md` — доставка на
|
||||||
единственный access-сервер, retry-воркер и UI-статусы
|
единственный access-сервер, retry-воркер и UI-статусы
|
||||||
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
- `docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<S:...>` вставок внутри plaintext DM после расшифровки
|
||||||
|
- `docs/Personal_Messages/Файлы_DM_v2.md` — большие chunked-вложения, голосовые и BitTorrent v2 SHA-256/Merkle metadata
|
||||||
- `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя
|
- `docs/API/18_DM_File_Storage_API.md` — HTTP-хранилище зашифрованных файлов DM на access-сервере отправителя
|
||||||
|
|
||||||
Исторический устаревший документ сохранён отдельно:
|
Исторический устаревший документ сохранён отдельно:
|
||||||
|
|||||||
@@ -247,3 +247,19 @@ DeleteMessage принимает type=5/6. DeleteConversation принимает
|
|||||||
Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `<S:file...>` остаются скачиваемыми.
|
Отключение функции «Передача файлов» в локальных дополнительных настройках запрещает только отправку новых файлов; ранее полученные `<S:file...>` остаются скачиваемыми.
|
||||||
|
|
||||||
Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`.
|
Хранение ciphertext и HTTP-контракт описаны в `docs/API/18_DM_File_Storage_API.md`.
|
||||||
|
|
||||||
|
## 16. Дедупликация файлов и пересылка сообщений (2026-09-11)
|
||||||
|
|
||||||
|
Для HTTP-хранилища DM-файлов действует усиленное правило content-addressed хранения:
|
||||||
|
|
||||||
|
- перед загрузкой каждого ciphertext-объекта официальный UI делает `HEAD /dm-files/{fileId}`;
|
||||||
|
- если сервер подтверждает объект с тем же `fileId` и размером, `PUT` с байтами повторно не выполняется;
|
||||||
|
- сервер перед ответом на `HEAD`, `GET` и перед `alreadyExists=true` сам пересчитывает `SHA-256` сохранённого объекта и сверяет его с `fileId`;
|
||||||
|
- повреждённый или подменённый объект не считается существующим и не выдаётся клиенту; при следующем корректном `PUT` он может быть записан заново.
|
||||||
|
|
||||||
|
Пересылка личного сообщения не вводит новый тип DM и не добавляет признак «переслано». UI создаёт обычное новое контентное сообщение `type=1/2` выбранному собеседнику:
|
||||||
|
|
||||||
|
- для обычного текста переносится отображаемый текст сообщения;
|
||||||
|
- исходный `<S:reply...>` не переносится, поэтому новое сообщение не остаётся ответом на сообщение из старого чата;
|
||||||
|
- для сообщения с файлами повторно используются существующие `<S:file...>`-описатели, поэтому ciphertext не шифруется и не загружается повторно;
|
||||||
|
- сервер и получатель видят пересланное сообщение как обычное новое сообщение без служебной пометки об источнике.
|
||||||
|
|||||||
@@ -172,3 +172,26 @@ fromLogin|toLogin|timeMs|nonce
|
|||||||
- сервер не обязан понимать этот формат;
|
- сервер не обязан понимать этот формат;
|
||||||
- будущие клиенты могут добавлять новые `kind`;
|
- будущие клиенты могут добавлять новые `kind`;
|
||||||
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
|
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
|
||||||
|
|
||||||
|
## 10. Расширение `file` v2
|
||||||
|
|
||||||
|
Клиент обязан продолжать читать `v=1`. Новые chunked-вложения отправляются так:
|
||||||
|
|
||||||
|
```text
|
||||||
|
<S:file;v=2;id=ROOT_MANIFEST_ID;url=ENCODED_URL;key=BASE64URL_AES_KEY;ivp=BASE64URL_4BYTE_PREFIX;name=ENCODED_NAME;mime=ENCODED_MIME;size=123;encsize=456;chunk=1048576;chunks=7;th=TORRENT_V2_INFOHASH;pr=TORRENT_V2_PIECES_ROOT;kind=file;dur=0>
|
||||||
|
```
|
||||||
|
|
||||||
|
Дополнительные поля v2:
|
||||||
|
|
||||||
|
- `id` / `url` указывают не на весь файл, а на зашифрованный root manifest;
|
||||||
|
- `ivp` — случайный 4-byte IV prefix, из которого детерминированно строятся уникальные IV chunks/pages/root;
|
||||||
|
- `chunk` — plaintext chunk size, сейчас `1048576`;
|
||||||
|
- `chunks` — число частей;
|
||||||
|
- `th` — BitTorrent v2 SHA-256 infohash;
|
||||||
|
- `pr` — BitTorrent v2 pieces root;
|
||||||
|
- `kind=file|voice`;
|
||||||
|
- `dur` — длительность voice в миллисекундах, для обычного файла `0`.
|
||||||
|
|
||||||
|
В одном plaintext DM разрешено несколько последовательных `<S:file...>` блоков. Официальный UI собирает их в один список вложений и скрывает fallback-текст.
|
||||||
|
|
||||||
|
Подробный формат chunk encryption, manifest pages и BitTorrent v2 hashing: `Файлы_DM_v2.md`.
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
# Файлы и голосовые DM v2: chunked AES-GCM + BitTorrent v2 hashes
|
||||||
|
|
||||||
|
## Цели
|
||||||
|
|
||||||
|
DM file v2 убирает ограничение размера исходного файла, не требует держать файл целиком в RAM и сохраняет серверную модель «сервер видит только ciphertext».
|
||||||
|
|
||||||
|
Основные свойства:
|
||||||
|
|
||||||
|
- plaintext режется на куски по `1 MiB`;
|
||||||
|
- каждый кусок независимо шифруется `AES-256-GCM` одним случайным ключом файла, но с уникальным 96-bit IV;
|
||||||
|
- каждый ciphertext-кусок хранится как immutable объект `Base58(SHA-256(ciphertext))`;
|
||||||
|
- список кусков хранится в зашифрованных страницах манифеста по 256 записей;
|
||||||
|
- корневой манифест тоже зашифрован и content-addressed;
|
||||||
|
- E2EE DM содержит только ссылку на корневой манифест, AES-key, IV-prefix и пользовательские метаданные;
|
||||||
|
- один DM может содержать до 10 `<S:file;v=2...>` блоков;
|
||||||
|
- `kind=voice` использует тот же формат хранения и отдельный UI проигрывателя.
|
||||||
|
|
||||||
|
## IV и domain separation
|
||||||
|
|
||||||
|
На один файл создаётся случайный 4-byte `ivPrefix`.
|
||||||
|
|
||||||
|
12-byte IV строится как:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ivPrefix[4] || uint64_be(token)
|
||||||
|
```
|
||||||
|
|
||||||
|
Диапазоны `token` разделены:
|
||||||
|
|
||||||
|
- chunks: `0 .. 2^63-1`;
|
||||||
|
- manifest pages: `2^63 + pageIndex`;
|
||||||
|
- root manifest: `0xffffffffffffffff`.
|
||||||
|
|
||||||
|
AAD также содержит домен (`chunk`, `page`, `root`), prefix и индекс. Поэтому перестановка ciphertext-кусков не проходит AES-GCM authentication.
|
||||||
|
|
||||||
|
## Manifest pages
|
||||||
|
|
||||||
|
Каждая страница после расшифровки содержит до 256 записей:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 2,
|
||||||
|
"page": 0,
|
||||||
|
"chunks": [
|
||||||
|
{
|
||||||
|
"i": 0,
|
||||||
|
"id": "Base58(SHA-256(ciphertext))",
|
||||||
|
"ps": 1048576,
|
||||||
|
"es": 1048592,
|
||||||
|
"ph": "BitTorrent-v2-piece-hash-base64url"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Страницы сами AES-GCM зашифрованы и загружаются в `/dm-files/{id}`.
|
||||||
|
|
||||||
|
## Root manifest
|
||||||
|
|
||||||
|
После расшифровки:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"v": 2,
|
||||||
|
"scheme": "SHINE-DM-CHUNKED-AES-256-GCM",
|
||||||
|
"chunkSize": 1048576,
|
||||||
|
"fileSize": 123456789,
|
||||||
|
"chunkCount": 118,
|
||||||
|
"pages": [{"id":"...","count":118,"encryptedSize":12345}],
|
||||||
|
"torrent": {
|
||||||
|
"metaVersion": 2,
|
||||||
|
"blockLength": 16384,
|
||||||
|
"pieceLength": 1048576,
|
||||||
|
"piecesRoot": "...",
|
||||||
|
"infoHash": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Имя файла и MIME в manifest не пишутся. Они остаются внутри E2EE DM.
|
||||||
|
|
||||||
|
## BitTorrent v2 совместимость
|
||||||
|
|
||||||
|
Хеширование соответствует BEP 52:
|
||||||
|
|
||||||
|
- базовый hash block: `16 KiB`;
|
||||||
|
- SHA-256;
|
||||||
|
- piece length: `1 MiB`;
|
||||||
|
- Merkle padding leaf = 32 zero bytes;
|
||||||
|
- `pieces root` вычисляется по правилам BitTorrent v2;
|
||||||
|
- `infoHash` = `SHA-256(bencode(info dictionary))`;
|
||||||
|
- piece-layer hash каждого 1-MiB куска хранится в зашифрованной manifest page.
|
||||||
|
|
||||||
|
Из `name`, `size`, `piecesRoot` и списка `ph` можно построить tracker-less `.torrent` v2 без повторного хеширования исходного файла.
|
||||||
|
|
||||||
|
Это совместимость метаданных и проверки контента. HTTP `/dm-files` пока не является BitTorrent peer transport: для настоящего P2P потребуется отдельный seeding/peer слой.
|
||||||
|
|
||||||
|
## Скачивание
|
||||||
|
|
||||||
|
Получатель:
|
||||||
|
|
||||||
|
1. получает и проверяет ciphertext root manifest по Base58(SHA-256);
|
||||||
|
2. расшифровывает root manifest;
|
||||||
|
3. по очереди получает manifest pages;
|
||||||
|
4. получает каждый chunk с сервера отправителя;
|
||||||
|
5. проверяет content address ciphertext;
|
||||||
|
6. расшифровывает chunk локально;
|
||||||
|
7. проверяет BitTorrent-v2 piece hash;
|
||||||
|
8. пишет plaintext на диск;
|
||||||
|
9. в конце проверяет итоговый `pieces root`.
|
||||||
|
|
||||||
|
В браузерах с File System Access API plaintext пишется на диск по частям и целиком в RAM не собирается. В остальных браузерах остаётся Blob fallback без искусственного лимита размера, но фактический предел зависит от памяти браузера.
|
||||||
|
|
||||||
|
## Голосовые
|
||||||
|
|
||||||
|
`MediaRecorder` пишет `Opus/WebM`, `Opus/Ogg` или поддерживаемый браузером audio MIME. После завершения запись проходит тот же DM file v2 pipeline.
|
||||||
|
|
||||||
|
Технический блок отличается полями:
|
||||||
|
|
||||||
|
```text
|
||||||
|
kind=voice;dur=<milliseconds>
|
||||||
|
```
|
||||||
|
|
||||||
|
Получатель видит player с Play/Pause, прогрессом и длительностью. Аудио расшифровывается только на клиенте.
|
||||||
@@ -380,3 +380,16 @@ UI-примечание (байтовый формат не меняет): ра
|
|||||||
Для контентных `type=1/2` технический блок `<S:file...>` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается.
|
Для контентных `type=1/2` технический блок `<S:file...>` является частью обычного plaintext, который затем попадает в уже существующий зашифрованный `body`. Сам ciphertext внешнего файла в `SHiNE_DM` не включается.
|
||||||
|
|
||||||
Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними.
|
Поэтому подписи контейнера, `baseKey`, `revisionTimeMs`, `reencryptedAtMs`, алгоритм E2EE DM и правила парности входящей/исходящей копий остаются прежними.
|
||||||
|
|
||||||
|
## 16. Примечание о пересылке сообщений (2026-09-11)
|
||||||
|
|
||||||
|
Пересылка не меняет байтовый формат `SHiNE_DM` и не добавляет отдельный `messageType` или флаг forward/repost.
|
||||||
|
|
||||||
|
Клиент формирует новый обычный plaintext для `type=1/2`:
|
||||||
|
|
||||||
|
- текстовая часть копируется как новое сообщение;
|
||||||
|
- `<S:reply...>` исходного сообщения удаляется;
|
||||||
|
- валидные `<S:file...>` могут быть скопированы без изменения, чтобы новое сообщение ссылалось на тот же уже загруженный зашифрованный объект;
|
||||||
|
- информация о том, из какого чата или сообщения выполнена пересылка, в контейнер не добавляется.
|
||||||
|
|
||||||
|
Таким образом подпись, `baseKey`, шифрование и структура контейнера остаются полностью прежними: меняется только содержимое нового plaintext перед стандартной отправкой.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,7 @@ self.addEventListener('push', (event) => {
|
|||||||
|
|
||||||
const notifyPromise = shouldNotify
|
const notifyPromise = shouldNotify
|
||||||
? self.registration.showNotification(notificationTitle, {
|
? self.registration.showNotification(notificationTitle, {
|
||||||
body: body || (fromLogin ? `Вам пришло сообщение от ${fromLogin}` : 'Вам пришло сообщение'),
|
body: body || (fromLogin ? `Новое сообщение от ${fromLogin}` : 'Новое сообщение'),
|
||||||
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
|
tag: callId || (kind === 'test_push' ? 'shine-test-push' : 'shine-direct-message'),
|
||||||
renotify: true,
|
renotify: true,
|
||||||
requireInteraction: kind === 'incoming_call',
|
requireInteraction: kind === 'incoming_call',
|
||||||
|
|||||||
@@ -41,14 +41,14 @@ export function render({ navigate, chrome }) {
|
|||||||
const developerRow = createToggleRow({
|
const developerRow = createToggleRow({
|
||||||
id: 'advanced-developer-tools',
|
id: 'advanced-developer-tools',
|
||||||
title: 'Настройки разработчика',
|
title: 'Настройки разработчика',
|
||||||
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках.',
|
hint: 'Показывать блок «Версии» и кнопку настроек разработчика в обычных настройках. По умолчанию выключено.',
|
||||||
checked: isDeveloperToolsEnabled(),
|
checked: isDeveloperToolsEnabled(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const filesRow = createToggleRow({
|
const filesRow = createToggleRow({
|
||||||
id: 'advanced-dm-files',
|
id: 'advanced-dm-files',
|
||||||
title: 'Передача файлов в личных сообщениях',
|
title: 'Передача файлов в личных сообщениях',
|
||||||
hint: 'Разрешить отправку новых зашифрованных файлов через удержание кнопки эмодзи. Полученные ранее файлы останутся доступными.',
|
hint: 'Разрешить зашифрованные файлы любого размера, несколько файлов за сообщение и голосовые. Полученные ранее файлы останутся доступными.',
|
||||||
checked: isDmFileTransferEnabled(),
|
checked: isDmFileTransferEnabled(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+824
-85
File diff suppressed because it is too large
Load Diff
@@ -289,15 +289,19 @@ export async function importAesKeyRaw(keyBytes, usages = ['encrypt', 'decrypt'])
|
|||||||
return getSubtleApi().importKey('raw', keyBytes, { name: 'AES-GCM' }, false, usages);
|
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 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);
|
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 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);
|
return new Uint8Array(plain);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,10 +8,28 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
sha256Bytes,
|
sha256Bytes,
|
||||||
signBase64,
|
signBase64,
|
||||||
|
utf8Bytes,
|
||||||
} from './crypto-utils.js';
|
} from './crypto-utils.js';
|
||||||
import { loadSessionMaterial } from './key-vault.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 = '') {
|
function normalizeFileName(value = '') {
|
||||||
const cleaned = String(value || 'file')
|
const cleaned = String(value || 'file')
|
||||||
@@ -52,22 +70,63 @@ async function readErrorMessage(response) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function formatDmFileSize(bytes = 0) {
|
function objectUrl(serverBase, id) {
|
||||||
const value = Math.max(0, Number(bytes || 0));
|
return `${String(serverBase || '').replace(/\/$/, '')}/dm-files/${encodeURIComponent(String(id || ''))}`;
|
||||||
if (value < 1024) return `${value} Б`;
|
|
||||||
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} КБ`;
|
|
||||||
return `${(value / (1024 * 1024)).toFixed(value >= 10 * 1024 * 1024 ? 0 : 1)} МБ`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } = {}) {
|
function objectBaseFromUrl(url = '') {
|
||||||
if (!file || typeof file.arrayBuffer !== 'function') throw new Error('Файл не выбран');
|
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 cleanLogin = String(login || '').trim();
|
||||||
const cleanSessionId = String(sessionId || '').trim();
|
const cleanSessionId = String(sessionId || '').trim();
|
||||||
if (!cleanLogin || !cleanSessionId) throw new Error('Нет активной пользовательской сессии');
|
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);
|
const sessionMaterial = await loadSessionMaterial(cleanLogin);
|
||||||
if (!sessionMaterial?.sessionPrivPkcs8) {
|
if (!sessionMaterial?.sessionPrivPkcs8) {
|
||||||
throw new Error('На устройстве нет сохранённого session key для загрузки файла');
|
throw new Error('На устройстве нет сохранённого session key для загрузки файла');
|
||||||
@@ -75,98 +134,455 @@ export async function encryptAndUploadDmFile({ file, login, sessionId, wsUrl } =
|
|||||||
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
|
if (sessionMaterial.sessionId && String(sessionMaterial.sessionId) !== cleanSessionId) {
|
||||||
throw new Error('Сохранённый session key относится к другой сессии');
|
throw new Error('Сохранённый session key относится к другой сессии');
|
||||||
}
|
}
|
||||||
|
return {
|
||||||
|
sessionId: cleanSessionId,
|
||||||
|
serverBase: wsUrlToHttpBase(wsUrl),
|
||||||
|
privateKey: await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const sourceBytes = new Uint8Array(await file.arrayBuffer());
|
async function uploadEncryptedObject(bytes, context) {
|
||||||
const fileKey = randomBytes(32);
|
const payload = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes || 0);
|
||||||
const iv = randomBytes(12);
|
const fileId = bytesToBase58(await sha256Bytes(payload));
|
||||||
let encryptedBytes;
|
const url = objectUrl(context.serverBase, fileId);
|
||||||
|
|
||||||
|
// Быстрая дедупликация: если сервер уже подтверждает content-addressed объект,
|
||||||
|
// не отправляем его байты повторно. HEAD на сервере сам перепроверяет SHA-256 файла.
|
||||||
try {
|
try {
|
||||||
encryptedBytes = await encryptBytesAesGcm(sourceBytes, fileKey, iv);
|
const existing = await fetch(url, { method: 'HEAD', cache: 'no-store' });
|
||||||
} finally {
|
if (existing.ok) {
|
||||||
sourceBytes.fill(0);
|
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 {
|
try {
|
||||||
const fileHash = await sha256Bytes(encryptedBytes);
|
response = await fetch(url, {
|
||||||
const fileId = bytesToBase58(fileHash);
|
method: 'PUT',
|
||||||
const serverBase = wsUrlToHttpBase(wsUrl);
|
headers: {
|
||||||
const fileUrl = `${serverBase}/dm-files/${fileId}`;
|
'Content-Type': 'application/octet-stream',
|
||||||
const timeMs = Date.now();
|
'X-Shine-Session-Id': context.sessionId,
|
||||||
const privateKey = await importPkcs8Ed25519(sessionMaterial.sessionPrivPkcs8);
|
'X-Shine-Time-Ms': String(timeMs),
|
||||||
const signatureB64 = await signBase64(privateKey, uploadPreimage({
|
'X-Shine-Content-Length': String(payload.byteLength),
|
||||||
sessionId: cleanSessionId,
|
'X-Shine-Signature': signatureB64,
|
||||||
fileId,
|
},
|
||||||
encryptedSize: encryptedBytes.byteLength,
|
body: payload,
|
||||||
timeMs,
|
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;
|
async function fetchVerifiedObject(url, expectedId) {
|
||||||
try {
|
let response;
|
||||||
response = await fetch(fileUrl, {
|
try {
|
||||||
method: 'PUT',
|
response = await fetch(url, { method: 'GET', cache: 'no-store' });
|
||||||
headers: {
|
} catch (error) {
|
||||||
'Content-Type': 'application/octet-stream',
|
throw new Error(`Не удалось скачать зашифрованные данные: ${error?.message || 'network error'}`);
|
||||||
'X-Shine-Session-Id': cleanSessionId,
|
}
|
||||||
'X-Shine-Time-Ms': String(timeMs),
|
if (!response.ok) {
|
||||||
'X-Shine-Content-Length': String(encryptedBytes.byteLength),
|
throw new Error(response.status === 404 ? 'Часть файла больше не найдена на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`);
|
||||||
'X-Shine-Signature': signatureB64,
|
}
|
||||||
},
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
body: encryptedBytes,
|
const actualId = bytesToBase58(await sha256Bytes(bytes));
|
||||||
cache: 'no-store',
|
if (actualId !== String(expectedId || '')) {
|
||||||
});
|
bytes.fill(0);
|
||||||
} catch (error) {
|
throw new Error('SHA-256 зашифрованной части не совпал: данные повреждены или подменены');
|
||||||
throw new Error(`Не удалось загрузить файл на сервер отправителя: ${error?.message || 'network error'}`);
|
}
|
||||||
}
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
function encodeJson(value) {
|
||||||
const detail = await readErrorMessage(response);
|
return encoder.encode(JSON.stringify(value));
|
||||||
throw new Error(detail || `Сервер отклонил файл (HTTP ${response.status})`);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
version: 1,
|
id: stored.id,
|
||||||
id: fileId,
|
count: descriptors.length,
|
||||||
url: fileUrl,
|
encryptedSize: stored.encryptedSize,
|
||||||
keyB64Url: bytesToBase64Url(fileKey),
|
|
||||||
ivB64Url: bytesToBase64Url(iv),
|
|
||||||
name: normalizeFileName(file.name),
|
|
||||||
mime: String(file.type || 'application/octet-stream').trim().slice(0, 160) || 'application/octet-stream',
|
|
||||||
size: Number(file.size || 0),
|
|
||||||
encryptedSize: encryptedBytes.byteLength,
|
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
fileKey.fill(0);
|
plain.fill(0);
|
||||||
iv.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 fileId = String(attachment?.id || '').trim();
|
||||||
const fileUrl = String(attachment?.url || '').trim();
|
const fileUrl = String(attachment?.url || '').trim();
|
||||||
const keyB64Url = String(attachment?.keyB64Url || '').trim();
|
const keyB64Url = String(attachment?.keyB64Url || '').trim();
|
||||||
const ivB64Url = String(attachment?.ivB64Url || '').trim();
|
const ivB64Url = String(attachment?.ivB64Url || '').trim();
|
||||||
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) {
|
if (!fileId || !fileUrl || !keyB64Url || !ivB64Url) throw new Error('В сообщении не хватает данных для расшифровки файла');
|
||||||
throw new Error('В сообщении не хватает данных для расшифровки файла');
|
const encryptedBytes = await fetchVerifiedObject(fileUrl, fileId);
|
||||||
}
|
|
||||||
|
|
||||||
let response;
|
|
||||||
try {
|
|
||||||
response = await fetch(fileUrl, { method: 'GET', cache: 'no-store' });
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Не удалось скачать зашифрованный файл: ${error?.message || 'network error'}`);
|
|
||||||
}
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(response.status === 404 ? 'Файл больше не найден на сервере отправителя' : `Ошибка скачивания файла (HTTP ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const encryptedBytes = new Uint8Array(await response.arrayBuffer());
|
|
||||||
const actualId = bytesToBase58(await sha256Bytes(encryptedBytes));
|
|
||||||
if (actualId !== fileId) {
|
|
||||||
encryptedBytes.fill(0);
|
|
||||||
throw new Error('SHA-256 файла не совпал: зашифрованный файл повреждён или подменён');
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyBytes = base64UrlToBytes(keyB64Url);
|
const keyBytes = base64UrlToBytes(keyB64Url);
|
||||||
const ivBytes = base64UrlToBytes(ivB64Url);
|
const ivBytes = base64UrlToBytes(ivB64Url);
|
||||||
let plainBytes;
|
let plainBytes;
|
||||||
@@ -179,26 +595,128 @@ export async function downloadAndDecryptDmFile(attachment = {}) {
|
|||||||
keyBytes.fill(0);
|
keyBytes.fill(0);
|
||||||
ivBytes.fill(0);
|
ivBytes.fill(0);
|
||||||
}
|
}
|
||||||
|
if (plainBytes.byteLength !== Number(attachment?.size || 0)) {
|
||||||
const expectedSize = Number(attachment?.size || 0);
|
|
||||||
if (expectedSize >= 0 && plainBytes.byteLength !== expectedSize) {
|
|
||||||
plainBytes.fill(0);
|
plainBytes.fill(0);
|
||||||
throw new Error('Размер расшифрованного файла не совпал с сообщением');
|
throw new Error('Размер расшифрованного файла не совпал с сообщением');
|
||||||
}
|
}
|
||||||
|
return plainBytes;
|
||||||
|
}
|
||||||
|
|
||||||
const blob = new Blob([plainBytes], {
|
function suggestedPickerTypes(attachment) {
|
||||||
type: String(attachment?.mime || 'application/octet-stream') || 'application/octet-stream',
|
const mime = String(attachment?.mime || '').trim();
|
||||||
});
|
if (!mime || mime === 'application/octet-stream') return undefined;
|
||||||
plainBytes.fill(0);
|
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');
|
const anchor = document.createElement('a');
|
||||||
anchor.href = objectUrl;
|
anchor.href = objectUrlValue;
|
||||||
anchor.download = normalizeFileName(attachment?.name || 'file');
|
anchor.download = normalizeFileName(name || 'file');
|
||||||
anchor.rel = 'noopener';
|
anchor.rel = 'noopener';
|
||||||
anchor.style.display = 'none';
|
anchor.style.display = 'none';
|
||||||
document.body.append(anchor);
|
document.body.append(anchor);
|
||||||
anchor.click();
|
anchor.click();
|
||||||
anchor.remove();
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function defaultParsed(rawText = '') {
|
|||||||
replyRef: null,
|
replyRef: null,
|
||||||
callSummary: null,
|
callSummary: null,
|
||||||
fileAttachment: null,
|
fileAttachment: null,
|
||||||
|
fileAttachments: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,17 +98,46 @@ function decodeFileField(value = '') {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFileAttachment(fields = {}) {
|
function normalizeFileAttachment(fields = {}) {
|
||||||
|
const version = Number(fields.v || 0);
|
||||||
const id = String(fields.id || '').trim();
|
const id = String(fields.id || '').trim();
|
||||||
const url = decodeFileField(fields.url || '').trim();
|
const url = decodeFileField(fields.url || '').trim();
|
||||||
const keyB64Url = String(fields.key || '').trim();
|
const keyB64Url = String(fields.key || '').trim();
|
||||||
const ivB64Url = String(fields.iv || '').trim();
|
|
||||||
const name = decodeFileField(fields.name || '').trim() || 'file';
|
const name = decodeFileField(fields.name || '').trim() || 'file';
|
||||||
const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream';
|
const mime = decodeFileField(fields.mime || '').trim() || 'application/octet-stream';
|
||||||
const size = Number(fields.size || 0);
|
const size = Number(fields.size || 0);
|
||||||
const encryptedSize = Number(fields.encsize || 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 {
|
return {
|
||||||
version: Number(fields.v || 0),
|
version: version || 1,
|
||||||
id,
|
id,
|
||||||
url,
|
url,
|
||||||
keyB64Url,
|
keyB64Url,
|
||||||
@@ -116,20 +146,37 @@ function normalizeFileAttachment(fields = {}) {
|
|||||||
mime,
|
mime,
|
||||||
size,
|
size,
|
||||||
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
|
encryptedSize: Number.isFinite(encryptedSize) && encryptedSize > 0 ? encryptedSize : 0,
|
||||||
|
kind: 'file',
|
||||||
|
durationMs: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildDmFileTechBlock(attachment = {}) {
|
export function buildDmFileTechBlock(attachment = {}) {
|
||||||
|
const version = Math.max(1, Math.floor(Number(attachment?.version || 1)));
|
||||||
const id = String(attachment?.id || '').trim();
|
const id = String(attachment?.id || '').trim();
|
||||||
const url = String(attachment?.url || '').trim();
|
const url = String(attachment?.url || '').trim();
|
||||||
const keyB64Url = String(attachment?.keyB64Url || '').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 size = Math.max(0, Math.floor(Number(attachment?.size || 0)));
|
||||||
const encryptedSize = Math.max(0, Math.floor(Number(attachment?.encryptedSize || 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 name = encodeURIComponent(String(attachment?.name || 'file'));
|
||||||
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
|
const mime = encodeURIComponent(String(attachment?.mime || 'application/octet-stream'));
|
||||||
const encodedUrl = encodeURIComponent(url);
|
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}>`;
|
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 replyRef = null;
|
||||||
let callSummary = null;
|
let callSummary = null;
|
||||||
let fileAttachment = null;
|
let fileAttachment = null;
|
||||||
|
const fileAttachments = [];
|
||||||
|
|
||||||
while (hasTechPrefix(text, cursor)) {
|
while (hasTechPrefix(text, cursor)) {
|
||||||
const end = text.indexOf('>', cursor);
|
const end = text.indexOf('>', cursor);
|
||||||
@@ -189,8 +237,12 @@ export function parseDmTechBlocks(rawText = '') {
|
|||||||
reason: String(fields.reason || '').trim().toLowerCase(),
|
reason: String(fields.reason || '').trim().toLowerCase(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
} else if (kind === 'file' && !fileAttachment) {
|
} else if (kind === 'file') {
|
||||||
fileAttachment = normalizeFileAttachment(fields);
|
const attachment = normalizeFileAttachment(fields);
|
||||||
|
if (attachment) {
|
||||||
|
fileAttachments.push(attachment);
|
||||||
|
if (!fileAttachment) fileAttachment = attachment;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cursor = end + 1;
|
cursor = end + 1;
|
||||||
@@ -207,5 +259,6 @@ export function parseDmTechBlocks(rawText = '') {
|
|||||||
replyRef,
|
replyRef,
|
||||||
callSummary,
|
callSummary,
|
||||||
fileAttachment,
|
fileAttachment,
|
||||||
|
fileAttachments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ function writeBoolean(key, enabled) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isDeveloperToolsEnabled() {
|
export function isDeveloperToolsEnabled() {
|
||||||
return readBoolean(STORAGE_KEYS.developerTools, true);
|
return readBoolean(STORAGE_KEYS.developerTools, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setDeveloperToolsEnabled(enabled) {
|
export function setDeveloperToolsEnabled(enabled) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -1373,3 +1373,303 @@
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 104px;
|
bottom: 104px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== DM file transfer v2 + voice notes ===== */
|
||||||
|
.dm-attachment-stack {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-icon-btn svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
display: block;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-icon-btn.is-recording {
|
||||||
|
transform: scale(1.04);
|
||||||
|
box-shadow: 0 0 0 5px rgba(255, 104, 126, 0.08), 0 0 24px rgba(255, 104, 126, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-icon-btn.is-recording:not(.is-locked) {
|
||||||
|
color: rgba(255, 190, 202, 0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-icon-btn.is-locked {
|
||||||
|
color: rgba(255, 218, 226, 0.98);
|
||||||
|
background: rgba(164, 45, 70, 0.28);
|
||||||
|
border-color: rgba(255, 150, 171, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-send-busy-dot {
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
font-size: 20px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-status {
|
||||||
|
grid-column: 1;
|
||||||
|
min-height: 42px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-status[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(255, 104, 126, 0.96);
|
||||||
|
box-shadow: 0 0 12px rgba(255, 104, 126, 0.55);
|
||||||
|
animation: dmVoicePulse 1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dmVoicePulse {
|
||||||
|
0%, 100% { opacity: 0.55; transform: scale(0.82); }
|
||||||
|
50% { opacity: 1; transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-time {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 720;
|
||||||
|
color: rgba(255, 230, 235, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-hint {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(210, 222, 241, 0.66);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-chat-input--recording .dm-actions-col {
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card {
|
||||||
|
min-width: min(280px, 72vw);
|
||||||
|
max-width: min(330px, 76vw);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 42px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__play {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
background: rgba(92, 190, 255, 0.12);
|
||||||
|
border: 1px solid rgba(159, 211, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__play:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__body {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__track {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 18px;
|
||||||
|
min-height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__track::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 8px;
|
||||||
|
height: 2px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(220, 234, 255, 0.20);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__fill {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 7px;
|
||||||
|
width: 0;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: currentColor;
|
||||||
|
box-shadow: 0 0 8px rgba(92, 190, 255, 0.35);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-card__meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: rgba(205, 219, 242, 0.68);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 390px) {
|
||||||
|
.dm-voice-card {
|
||||||
|
min-width: min(252px, 76vw);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-voice-recording-hint {
|
||||||
|
max-width: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Пересылка DM: выбор чата и подтверждение остаются внутри одного feature-owned modal. */
|
||||||
|
.dm-forward-modal {
|
||||||
|
align-items: center;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-card {
|
||||||
|
width: min(92vw, 430px);
|
||||||
|
max-height: min(78vh, 680px);
|
||||||
|
overflow: hidden;
|
||||||
|
gap: 12px;
|
||||||
|
border: 1px solid rgba(104, 193, 255, 0.25);
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(155deg, rgba(20, 31, 49, 0.98), rgba(7, 13, 24, 0.98));
|
||||||
|
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.52), 0 0 34px rgba(56, 165, 255, 0.12);
|
||||||
|
backdrop-filter: blur(24px) saturate(130%);
|
||||||
|
-webkit-backdrop-filter: blur(24px) saturate(130%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-head,
|
||||||
|
.dm-forward-confirm-peer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-head {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-close {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border-radius: 50%;
|
||||||
|
font-size: 25px;
|
||||||
|
line-height: 1;
|
||||||
|
color: rgba(255, 255, 255, 0.78);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-preview {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: rgba(242, 247, 255, 0.76);
|
||||||
|
background: rgba(255, 255, 255, 0.035);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-preview--confirm {
|
||||||
|
white-space: normal;
|
||||||
|
max-height: 128px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 120px;
|
||||||
|
max-height: min(54vh, 460px);
|
||||||
|
overflow-y: auto;
|
||||||
|
padding-right: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-chat-row {
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 11px;
|
||||||
|
min-height: 62px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 15px;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-chat-row:hover,
|
||||||
|
.dm-forward-chat-row:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(67, 166, 255, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-chat-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-chat-copy strong,
|
||||||
|
.dm-forward-chat-copy span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-chat-arrow {
|
||||||
|
font-size: 28px;
|
||||||
|
color: rgba(130, 205, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-loading {
|
||||||
|
padding: 20px 8px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-confirm-peer {
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(67, 166, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dm-forward-confirm-actions {
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user