14 KiB
Личные сообщения (DM): как это устроено
Коротко
Личные сообщения в SHiNE теперь работают в двух слоях:
- контентные сообщения
type=1/2идут в новом бинарном форматеSHiNE_DM; - read-receipt
type=3/4пока остаются на legacy-форматеSHiNE_dm2, чтобы не ломать текущую механику подтверждения прочтения.
Одно логическое сообщение по-прежнему отправляется парой блоков:
type=1— входящее сообщение для получателя;type=2— исходящая копия для отправителя.
Ключ сообщения остаётся прежним:
baseKey = from|to|timeMs|noncemessageKey = baseKey|messageType
Теперь timeMs + nonce задаются один раз на всё логическое сообщение и не меняются при редактировании.
Для новой версии того же письма используется то же messageKey, но большее revisionTimeMs.
Сервер хранит только последнюю версию записи по этому messageKey через upsert.
1) Общая схема потока
- Клиент при необходимости сначала шифрует вложенные файлы локально.
- Клиент считает
SHA-256(ciphertext)и размер ciphertext. - Клиент проверяет наличие blob через
HEAD /f/<hashB64url>. - Если файла нет, клиент загружает ciphertext через
POST /upload?hash=<hashB64url>&size=<bytes>. - После загрузки всех файлов клиент строит пару signed DM-блоков
type=1/2. - Сервер валидирует подписи, формат, наличие blob-файлов и делает
upsertпары. - В одной транзакции сервер:
- удаляет старые файловые связи этого сообщения;
- корректирует
ref_count; - записывает новую версию
signed_messages_v2; - создаёт новые файловые связи;
- сбрасывает pending-доставку по сессиям.
- Сервер рассылает обновлённые блоки в активные сессии через
SignedMessageArrived. - Клиент обновляет существующий пузырь по
messageKey; если тело и вложения пустые — сообщение удаляется из UI.
2) Формат signed DM-блока для контентных сообщений (SHiNE_DM)
Префикс: SHiNE_DM (ASCII).
Далее поля, big-endian:
formatVersionMajor(u8) =1formatVersionMinor(u8) =0toLoginLen(u8) +toLogin(ASCII,1..60)fromLoginLen(u8) +fromLogin(ASCII,1..60)timeMs(u64)nonce(u32)messageType(u16) — только1или2revisionTimeMs(u64)attachmentsCount(u8) —0..12attachments[]:
encFileHashSHA256(32 bytes)encFileSize(u64)
encryptedBodyLen(u32) — сервер сейчас ограничивает до16384encryptedBody(bytes)signature(64 bytes, Ed25519)
Важные правила
messageTypeне входит в ID логического письма, он только различает сторону пары.- ID логического письма =
fromLogin + toLogin + timeMs + nonce. - У оригинала
revisionTimeMs = 0. - Для редактирования и удаления
timeMs/nonceне меняются, меняется толькоrevisionTimeMs. - Чем больше
revisionTimeMs, тем новее версия.
3) Legacy read-receipt (SHiNE_dm2)
Пока только блоки type=3/4 остаются в старом формате SHiNE_dm2:
toLoginLen(u8) +toLoginfromLoginLen(u8) +fromLogintimeMs(u64)nonce(u32)messageType(u16) —3или4payloadLen(u16)payloadBytessignature
Это временный совместимый слой. Контентные сообщения 1/2 в SHiNE_dm2 больше не считаются актуальным форматом.
4) Внешний контейнер вложений
Внешняя часть сообщения содержит только технические ссылки на blob-файлы:
attachmentsCount- список
encFileHashSHA256 + encFileSize
Во внешней части нет:
- имени файла;
- MIME;
- пароля/ключа;
- nonce/iv;
origHash.
Это позволяет серверу хранить и отдавать blob, не зная человеческих метаданных вложения.
5) Внутреннее содержимое encryptedBody
Сейчас encryptedBody содержит текстовый контейнер сообщения, который UI интерпретирует как текст + встроенные метки файлов.
Формат маркера:
<<file:file-format(1.0):type|fileName|origSize|origHashB64u|encHashB64u|encSize|keyB64u|nonceB64u>>
Где:
type=photo/video/audio/filefileName— настоящее имя файла, без символов|,:,>, переводов строкиorigSize— размер исходного файлаorigHashB64u—SHA-256исходного файла вbase64urlencHashB64u—SHA-256ciphertext-файла вbase64urlencSize— размер ciphertext-файлаkeyB64u— симметричный ключ расшифровки файлаnonceB64u— nonce/iv для расшифровки файла
UI:
- показывает обычный текст без маркеров;
- заменяет маркеры карточками скачивания;
- скачивает ciphertext по
/f/<encHashB64u>; - локально расшифровывает файл и отдаёт пользователю оригинал.
6) RPC и события
SendMessagePair / ReceiveOutcomingMessage
Запрос не меняется: сервер по-прежнему принимает пару incomingBlobB64 + outgoingBlobB64.
{
"op": "SendMessagePair",
"requestId": "req-1",
"payload": {
"incomingBlobB64": "<base64 signed block type 1 or 3>",
"outgoingBlobB64": "<base64 signed block type 2 or 4>"
}
}
Успешный ответ:
{
"op": "SendMessagePair",
"requestId": "req-1",
"status": 200,
"ok": true,
"payload": {
"baseKey": "from|to|time|nonce",
"incomingKey": "from|to|time|nonce|1",
"outgoingKey": "from|to|time|nonce|2",
"deliveredWsSessions": 2,
"deliveredWebPushSessions": 1
}
}
SignedMessageArrived
Событие в сессию всё ещё содержит:
messageKey,baseKeyfromLogin,toLogin,targetLoginmessageType,timeMs,nonceblobB64backlog
Новая версия того же письма приходит с тем же messageKey, но с более новым revisionTimeMs внутри бинарного блока.
AckSessionDelivery
Формат не меняется.
7) HTTP endpoints для файлов
HEAD /f/<hashB64url>
Проверяет наличие ciphertext-файла.
200— файл есть;404— файла нет.
GET /f/<hashB64url>
Отдаёт ciphertext-файл как application/octet-stream.
Сейчас доступ публичный, без проверки логина.
POST /upload?hash=<hashB64url>&size=<bytes>
Принимает raw bytes ciphertext-файла.
Сервер:
- пересчитывает
SHA-256; - проверяет размер;
- сохраняет blob в папку
f/под именем<hashB64url>; - если файл уже существует, повторно не пишет его на диск;
- регистрирует строку в
dm_files.
8) Хранение на сервере (SQLite)
signed_messages_v2
Основная таблица текущих DM:
message_key(PK)base_keytarget_loginfrom_login,to_logintime_ms,noncemessage_typerevision_time_msraw_blockcreated_at_mssource_api,origin_session_idreceipt_ref_base_key,receipt_ref_type
Для контентных сообщений сервер делает upsert по message_key, поэтому в таблице всегда лежит только последняя версия конкретной стороны пары.
signed_message_session_delivery
Хранит pending/ack по сессиям:
(message_key, session_id)— PKdelivereddelivered_at_mscreated_at_ms
При новой ревизии того же сообщения сервер сбрасывает доставку этого message_key обратно в delivered=0.
dm_files
file_hash_sha256(BLOB, PK)file_sizeref_count
dm_message_file_links
message_keyloginfile_hash_sha256
По этой таблице сервер понимает, какие файловые ссылки нужно снять при редактировании/удалении сообщения.
ref_count считается по числу логических message-side ссылок:
- у одного письма с вложением обычно две ссылки:
- получатель (
type=1) - отправитель (
type=2)
- получатель (
Файлы с ref_count = 0 на диске не удаляются автоматически.
9) Доставка, редактирование и удаление
Новое сообщение
revisionTimeMs = 0- создаётся пара
1/2 - сервер делает upsert, создаёт файловые связи и доставляет событие
Редактирование
- используется тот же
timeMs + nonce - отправляется новая пара
1/2 revisionTimeMsбольше- может измениться и
encryptedBody, и список вложений
Удаление
- тот же
timeMs + nonce - новая ревизия с большим
revisionTimeMs attachmentsCount = 0encryptedBodyLen = 0
UI такое сообщение полностью убирает из чата.
10) Логика UI-клиента
Хранилище сообщений
- in-memory:
state.chats[chatId] - IndexedDB:
shine-ui-messages-v1, storemessages, key =messageKey
Так как messageKey теперь стабилен для всех ревизий одного message-side, клиент делает не append, а update той же записи.
Поведение UI
- входящая новая ревизия с тем же
messageKeyобновляет существующий пузырь; - пустая ревизия (
attachments=0,encryptedBodyLen=0) удаляет пузырь из IndexedDB и из in-memory; - вложения показываются кнопками скачивания;
- ciphertext скачивается с HTTP, затем расшифровывается локально в браузере.
11) Межсерверная синхронизация
Межсерверный relay DM пока не реализован.
Когда он появится, серверы должны будут:
- синхронизировать только актуальную ревизию
message_key; - применять правило "больший
revisionTimeMsпобеждает"; - одинаково пересчитывать файловые связи и
ref_count.
12) Инварианты
- Пара
1/2должна применяться атомарно. baseKey/messageKeyформат не меняется.- Для одного
messageKeyвsigned_messages_v2хранится только последняя версия. - Все изменения DM и файловых связей применяются одной транзакцией.
- Для контентных сообщений обязательна предварительная загрузка blob-файлов.
- Для одного сообщения разрешено не больше
12вложений. - UI не показывает удалённые сообщения.
13) Ключевые файлы реализации
- UI:
shine-UI/js/services/auth-service.jsshine-UI/js/services/crypto-utils.jsshine-UI/js/state.jsshine-UI/js/app.jsshine-UI/js/pages/chat-view.js
- Server:
SHiNE-server/shine-server-net-protocol/.../messages/SignedMessageBlock.javaSHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesCore.javaSHiNE-server/shine-server-net-protocol/.../messages/Net_SendMessagePair_Handler.javaSHiNE-server/shine-server-net-protocol/.../messages/SignedMessagesRealtime.javaSHiNE-server/shine-server-net-protocol/.../messages/DmFileStorage.javaSHiNE-server/shine-server-db/.../dao/SignedMessagesV2DAO.javaSHiNE-server/src/main/java/server/files/DmFilesServlet.javaSHiNE-server/src/main/java/server/files/DmUploadServlet.java