38 KiB
Личные сообщения (DM) — спецификация v1
Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Важно:
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
Документ фиксирует:
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через
access_servers; - общее поведение сервера и БД.
Точный байтовый формат контейнера вынесен отдельно:
docs/Personal_Messages/Формат_DM_v1.md
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
docs/Personal_Messages/Технические_вставки_DM_v1.md
Изменяемое состояние доставки, retry-расписание и репликация между двумя access-серверами подробно описаны отдельно:
docs/Personal_Messages/Доставка_и_синхронизация_DM.md
Устаревшая предыдущая версия сохранена отдельно:
docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md
1. Основная модель
Личное сообщение в SHiNE хранится как две логические копии одного сообщения:
type=1— входящее сообщение для получателя;type=2— исходящая копия сообщения для отправителя.
Обе копии имеют общий логический идентификатор:
baseKey = fromLogin|toLogin|timeMs|nonce
Идентификатор конкретной копии:
messageKey = baseKey|messageType
Поля timeMs и nonce не меняются никогда.
nonce обязателен, потому что одного timeMs недостаточно для гарантированной уникальности.
2. Ключи и шифрование
2.1. Общий принцип
У пользователя в PDA публикуется один публичный clientKey в формате Ed25519.
Из того же ключевого материала стандартным способом выводится ключ X25519 для E2EE-шифрования DM.
Отдельный публичный dmEncKey в текущей архитектуре не хранится.
Подробности стандартного преобразования:
docs/Протоколы/Преобразование_ED25519_в_X25519.md
2.2. Правило шифрования копий
type=1шифруется на ключ получателя;type=2шифруется на ключ отправителя.
Следствия:
- входящую копию может прочитать только получатель;
- исходящую копию может прочитать только отправитель;
- сервер не должен расшифровывать DM;
- ciphertext у
type=1иtype=2обычно разный и не обязан совпадать побайтно.
2.3. Что именно знает сервер о body
Для контентных DM (type=1/2) сервер в v1 должен рассматривать body как opaque blob:
- сервер проверяет внешний контейнер
SHiNE_DM; - сервер проверяет подпись;
- сервер проверяет служебные поля envelope;
- сервер проверяет только то, что
bodyLen > 0и не превышает допустимый лимит; - сервер не обязан разбирать внутренний crypto-контейнер
body; - сервер не обязан знать конкретный алгоритм шифрования
body.
Это позволяет альтернативным клиентам использовать совместимый внешний DM-envelope при собственном формате зашифрованного payload внутри body.
Официальный UI SHiNE при приёме такого сообщения:
- пытается расшифровать знакомый формат
EncryptedBody_v1_0; - показывает текст сообщения при успешной расшифровке;
- если внутренний формат не поддерживается, показывает
Формат сообщения не поддерживается; - если формат понятен, но расшифровка не удалась, показывает
Не удалось расшифровать сообщение; - если
bodyповреждён или структурно битый, показываетСообщение повреждено.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки <S:...> в начале текста. Legacy-вставки <SHiNE:...> также продолжают поддерживаться при чтении.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
2.4. Источник истины по пользователю
Для DM-проверки сервер использует:
- локальную runtime-проекцию пользователей как кэш;
- Solana PDA как источник истины по
clientKeyиaccess_servers.
Если нужного пользователя нет локально, сервер обязан попытаться lazy-import из Solana PDA:
- в
GetUser; - при серверной верификации DM-подписи;
- перед межсерверной маршрутизацией через
access_servers.
3. Типы сообщений
В версии v1 используются следующие messageType:
1— входящее сообщение;2— исходящая копия сообщения;3— входящее подтверждение прочтения;4— исходящая копия подтверждения прочтения;5— сообщение удалено отправителем;6— сообщение удалено получателем;7— переписка удалена отправителем;8— переписка удалена получателем.
Типы 1/2 — это обычные контентные DM, в том числе их последующие ревизии.
Типы 3/4 — служебные подтверждения прочтения.
Типы 5/6/7/8 — служебные события удаления без зашифрованного тела.
4. Базовые операции
4.1. Создание нового сообщения
Новое сообщение отправляется парой блоков через:
SendMessagePairReceiveOutcomingMessageкак алиас
Пару создаёт автор сообщения.
Пара содержит:
- одну входящую копию
type=1; - одну исходящую копию
type=2; - одинаковые
fromLogin,toLogin,timeMs,nonce; - одинаковый логический plaintext;
- разные ciphertext для разных владельцев копий.
4.2. Редактирование сообщения
Редактирование общего текста делает автор сообщения.
При редактировании:
baseKeyостаётся тем же;messageTypeостаётся тем же;revisionTimeMsувеличивается;- автор заново шифрует обе копии и снова отправляет пару через
SendMessagePair.
Если revisionTimeMs = 0, это исходная версия.
Если revisionTimeMs > 0, это новая ревизия сообщения.
4.3. Перешифровка
Перешифровка нужна для будущего сценария полной или частичной перепаковки истории сообщений при сохранении тех же логических идентификаторов сообщений.
При перешифровке:
baseKeyостаётся прежним;messageKeyостаётся прежним;- plaintext может остаться тем же;
- ciphertext меняется;
reencryptedAtMsполучает ненулевое значение;revisionTimeMsможет остаться прежним, в том числе нулевым, если менялось только шифрование без редактирования текста;- сервер при выборе актуальной версии обязан учитывать обе метки времени.
Если сообщение уже удалено одним из типов 5/6, его перешифровывать больше нельзя.
Если переписка уже удалена типом 7/8, более старые сообщения этой пары тоже перешифровывать нельзя.
4.4. Приём входящей копии
Для server-to-server доставки одной входящей копии используется:
ReceiveIncomingMessage
Этот метод должен принимать:
- новые входящие сообщения;
- входящие обновления/редактирования;
- будущие входящие перешифрованные копии.
5. Удаление одного сообщения
5.1. Общая логика
Удаление одного сообщения в v1 всегда глобальное у обеих сторон.
Локального удаления только у себя в этой версии протокола не вводится.
5.2. Типы удаления
type=5— сообщение удалено отправителем;type=6— сообщение удалено получателем.
Удаляющее сообщение:
- не содержит зашифрованного тела;
- хранится в БД как tombstone;
- терминально закрывает это сообщение;
- не даёт больше принять никакую более позднюю содержательную версию этого же
messageKey.
5.3. Метод удаления
Для удаления одного сообщения нужен отдельный метод, условно:
DeleteMessage
Если сервер впервые получает валидное удаляющее сообщение:
- сохраняет tombstone в БД;
- удаляет или замещает прежнюю версию сообщения tombstone-записью;
- распространяет это же удаление на серверы доступа обеих сторон;
- не принимает в будущем попытки "оживить" это сообщение.
6. Удаление всей переписки
6.1. Отдельное служебное сообщение
Удаление всей переписки между двумя пользователями — это отдельное служебное сообщение без ciphertext.
Типы:
type=7— переписка удалена отправителем;type=8— переписка удалена получателем.
6.2. Граница удаления
Границей удаления считается:
timeMsсамого служебного сообщения удаления переписки
Отдельное deleteBeforeTimeMs в этой версии не вводится.
6.3. Правило применения
Если сервер получает такое сообщение впервые:
- сохраняет его в БД как tombstone переписки;
- удаляет из БД все сообщения этой пары пользователей с
timeMsменьше времени служебного сообщения; - больше не принимает новые или повторно доставленные сообщения с
timeMsраньше этой границы; - распространяет это же сообщение удаления переписки на серверы доступа обеих сторон.
6.4. Поведение при позднем старом сообщении
Если после удаления переписки приходит старое сообщение, у которого:
timeMs < deleteConversationMessage.timeMs
то сервер:
- не принимает это сообщение;
- распространяет уже известный tombstone удаления переписки на серверы доступа обеих сторон;
- ожидает, что вторая сторона обработает его как обычное уже известное удаление переписки.
6.5. Будущие новые сообщения
После удаления всей переписки новые сообщения между этими же пользователями разрешены, если:
- их
timeMsбольше времени служебного сообщения удаления переписки.
7. Серверы и маршрутизация
7.1. access_servers
Для обычного пользователя список серверов доставки и доступа задаётся через:
access_servers
Это:
- сервера доступа пользователя;
- сервера relay;
- сервера, через которые пользователь получает личные сообщения и другие пользовательские операции.
7.2. sync_servers
sync_servers относятся не к обычной пользовательской маршрутизации DM, а к server-to-server партнёрству серверного узла.
Они используются для:
- синхронизации серверных данных;
- синхронизации пользовательских блокчейнов SHiNE;
- общей межсерверной координации.
sync_servers не являются списком пользовательских серверов доставки DM.
7.3. Два сервера у отправителя и получателя
Актуальный runtime исходит из ограничения:
- у одного пользователя не более двух
access_servers; - следовательно, у локального сервера есть не более одного peer для репликации данных пользователя.
Протокол поддерживает ситуацию, когда:
- у отправителя один или два
access_servers; - у получателя один или два
access_servers; - часть серверов у сторон совпадает;
- часть серверов уникальна.
Из этого следуют требования:
- все DM-операции должны быть идемпотентны;
- повторное получение уже известного события не должно ломать состояние;
- дубль tombstone должен быть безопасен;
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
8. Методы и их роли
8.1. Существующие методы, которые сохраняются
SendMessagePairReceiveOutcomingMessageReceiveIncomingMessageDeleteMessageDeleteConversation
Их роли в v1:
SendMessagePair— новая пара сообщений и редактирование старой пары автором;ReceiveIncomingMessage— приём одной входящей копии, входящих редактирований и входящего read-receipt;ReceiveOutcomingMessage— алиасSendMessagePair.
8.2. Межсерверная синхронизация
ReceiveOutcomingMessage— прежняя полная пара второго access-сервера отправителя;ReceiveIncomingMessage— прежняя одиночная входящая копия;DmSyncBatch— pull событий сsynced=falseи ACK сохранённой предыдущей страницы;GetDmDeliveryStatus— единственная новая read-only проверка доставки по существующемуmessageKey.
Delivery-state между серверами не передаётся и не объединяется.
8.3. Серверный слой диалогов
Помимо хранения самих DM-сообщений сервер поддерживает материализованный слой состояния диалогов:
- отдельная запись на пару
owner_login+peer_login; relation_flagсо значениямиclose_friend,contact,none;last_message_blob_b64как последний контентный signed DM block в base64;last_message_time_ms;unread_count;last_read_receipt_time_msкак watermark последнего подтверждения прочтения.
Ключевые правила:
close_friendвсегда имеет приоритет надcontact;- если
read receiptприходит не по порядку, сервер хранит наибольший watermark и не откатывает состояние назад; unread_countпересчитывается сервером по сообщениям диалога с учётом watermark иread_at_ms;- старые исторические данные восстанавливаются из
signed_messagesпри инициализации/миграции; - UI не должен собирать inbox только из локального кеша, когда ему доступен серверный список диалогов.
9. Правила валидации и применения
9.1. Общее правило по ревизиям
Для одного и того же контентного сообщения сервер сравнивает пару:
revisionTimeMs;reencryptedAtMs.
Правило:
- если новый
revisionTimeMsбольше сохранённого, сообщение применяется; - если
revisionTimeMsравен, но новыйreencryptedAtMsбольше сохранённого, сообщение применяется; - если обе величины равны, сообщение не применяется;
- если новая пара (
revisionTimeMs,reencryptedAtMs) меньше или равна сохранённой, сообщение не применяется.
Содержимое body при этом сравнении не участвует.
То есть если сервер уже видел ту же пару (revisionTimeMs, reencryptedAtMs), он считает, что такая версия у него уже есть.
9.2. Повторный tombstone
Повторный tombstone означает ситуацию, когда сервер повторно получает то же самое событие удаления:
- того же сообщения;
- или той же переписки.
Это может произойти из-за:
- повторной межсерверной доставки;
- нескольких
access_servers; - сетевых retry;
- дублирующей пересылки с разных маршрутов.
Правило:
- повторный tombstone должен быть полностью безопасен;
- если соответствующее удаление уже сохранено, сервер ничего не меняет и просто игнорирует повтор.
9.3. Сообщения старше границы удалённой переписки
Если для пары пользователей уже есть сохранённая граница удаления переписки, и приходит:
- обычное сообщение;
- удаление одного сообщения;
- повторное удаление переписки;
- любое другое DM-событие;
у которого timeMs меньше этой границы, сервер:
- ничего не меняет в БД;
- не восстанавливает старую историю;
- не применяет это событие повторно.
Если это старое контентное сообщение или входящая копия, сервер дополнительно перерассылает уже известный tombstone удаления переписки на access_servers обеих сторон, чтобы отстающие серверы сами подчистили историю.
Такие сообщения считаются частью уже удалённой истории.
9.4. Приоритет удаления переписки
Если сначала пришло удаление переписки, а потом удаление одного старого сообщения из этой переписки, сервер должен:
- проигнорировать это удаление одного сообщения;
- не создавать новых изменений поверх уже удалённой истории.
То же правило действует и для обычных сообщений, и для редактирований старых сообщений.
10. JSON API v1
10.1. SendMessagePair
Назначение:
- клиент отправляет новый DM;
- клиент отправляет редактирование старого DM;
- клиент отправляет парную новую ревизию типов
1/2.
Request:
{
"op": "SendMessagePair",
"requestId": "req-123",
"payload": {
"incomingBlobB64": "...",
"outgoingBlobB64": "..."
}
}
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- успешный
status=200означает локальное сохранение; - первая сетевая попытка выполняется до ответа клиенту, параллельно для двух серверов получателя;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
Ответ сохраняет прежние baseKey, incomingKey, outgoingKey и счётчики доставки в клиентские сессии. Единственное новое поле — deliveryState: accepted, delivered или failed.
10.2. ReceiveIncomingMessage
Назначение:
- приём одной входящей копии по схеме server-to-server;
- приём входящего редактирования;
- приём входящего read-receipt.
Request:
{
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "...",
"sourceServerLogin": "server-a"
}
}
sourceServerLogin пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого SHiNE_DM. Для ReceiveOutcomingMessage и SendMessagePair пустой sourceServerLogin означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля messageKey, baseKey и счётчики realtime-доставки.
10.3. DeleteMessage
Назначение:
- удалить одно сообщение у обеих сторон.
Request:
{
"op": "DeleteMessage",
"requestId": "req-789",
"payload": {
"blobB64": "..."
}
}
Ожидается контейнер типа:
5, если удаление инициировал отправитель;6, если удаление инициировал получатель.
UI-следствие для клиента:
- пользователь может удалить как своё исходящее сообщение, так и входящее;
- для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (
type=6).
10.4. DeleteConversation
Назначение:
- удалить всю переписку до времени самого служебного сообщения.
Request:
{
"op": "DeleteConversation",
"requestId": "req-790",
"payload": {
"blobB64": "..."
}
}
Ожидается контейнер типа:
7, если удаление инициировал отправитель;8, если удаление инициировал получатель.
UI-следствие для клиента:
- действие
Очистить историюдолжно отправлять служебное сообщение удаления переписки; - для обычной клиентской кнопки очистки истории допускается вариант удаления, инициированный текущим получателем (
type=8); - после применения такого сообщения UI может оставлять в чате видимую служебную точку отсечения истории;
- отдельное UI-действие
Удалить чатможет дополнительно спросить, нужно ли вместе с удалением контакта также отправитьDeleteConversation.
10.5. GetDirectMessages
Назначение:
- постранично отдавать историю одного личного диалога;
- не вываливать весь старый backlog автоматически в момент логина;
- отдавать клиенту только полезные chat-сообщения, без технических receipt/tombstone блоков.
Правила:
- начиная с 22 июля 2026 года старая история DM больше не должна автоматически пушиться клиенту сразу после входа;
- клиент сам запрашивает первую страницу истории у нужного собеседника;
- последующие страницы клиент запрашивает по курсору
beforeTimeMs+beforeMessageKey; - realtime-новые сообщения по-прежнему приходят отдельно через
SignedMessageArrived.
Содержимое страницы:
- сервер возвращает только контентные DM типов
1/2; - read-receipt (
3/4) и tombstone удаления (5/6/7/8) в историю страницы не включаются; - сообщения идут страницами от новых к старым;
- лимит страницы считается по самим chat-сообщениям диалога.
Поле прочтения:
- для каждого контентного сообщения сервер может вернуть
readAtMs; readAtMsозначает точное время прочтения сообщения, если оно уже известно серверу;- если точное время для старого сообщения неизвестно, но по более новым данным видно, что сообщение уже точно прочитано, UI может показывать его как прочитанное без точного времени;
- read-receipt при этом остаётся отдельным DM-событием синхронизации, но в обычную историю страницы не подмешивается.
Для исходящих элементов сервер также возвращает единственное поле deliveryState.
11. Межсерверная доставка
11.1. Клиентская сторона
Клиенту достаточно отправить сообщение на:
- любой один доступный сервер.
11.2. Серверная сторона
После локального принятия пара получает изменяемое состояние accepted. Сервер сразу вызывает до двух текущих маршрутов получателя параллельно. Успех хотя бы одного маршрута переводит сообщение в терминальное delivered; ждать второй маршрут не требуется.
После первой попытки полная пара передаётся единственному второму access-серверу отправителя через прежний ReceiveOutcomingMessage. Peer самостоятельно пробует актуальные маршруты получателя; delivery-state в запрос не входит.
Идемпотентность обязательна. ACK означает запись в БД; повтор уже известной ревизии считается успешным ACK.
11.3. Догоняющая синхронизация истории
Для восстановления пропущенных DM-событий между access-серверами используется pull-операция:
DmSyncBatch
Второй сервер запрашивает outbox-события владельца с synced=false, сохраняет их и передаёт подтверждённые syncId в ackSyncIds следующего запроса. Источник ставит synced=true только после ACK. Каждый цикл начинается с начала списка; курсор нужен только для страниц текущего цикла.
Полная пара отправителя передаётся двумя blob. Входящая копия получателя и tombstone передаются одним blob.
Полная пара отправителя передаётся одним элементом с двумя blob в порядке incoming/outgoing. Входящая копия получателя и tombstone передаются одним blob.
При применении событий, полученных через DmSyncBatch, сервер:
- проверяет формат
SHiNE_DM; - проверяет подпись;
- применяет существующие правила ревизий, read-receipt и tombstone;
- не отправляет realtime/push-уведомления клиентам;
- не запускает повторный fan-out, чтобы не создавать циклы.
Синхронизация настроек и DM выполняется одним периодическим процессом и через
один логический последовательный сеанс поверх постоянного WSS-пула. Закрытие
этого логического сеанса не закрывает физическое соединение с peer. Выборка DM
использует частичный индекс по synced=false.
В текущей реализации межсерверная авторизация DM ещё не включена. Принимающий сервер проверяет, что сам является access-сервером ownerLogin, и всегда проверяет пользовательские подписи signed-блоков.
11.4. Ошибки доставки
Если ни один сервер получателя не подтвердил запись, попытки выполняются сразу, через 30 секунд, 5 минут, 25 минут и 1 час от первоначального принятия.
Перед попытками через 5 минут, 25 минут и час сервер read-only спрашивает peer отправителя через GetDmDeliveryStatus(messageKey). Если peer уже доставил хотя бы на один сервер, сообщение считается доставленным.
После неудачной последней попытки устанавливается failed; дальнейших автоматических попыток и кнопки ручного повтора нет.
12. Хранение в БД
Основная таблица остаётся:
signed_messages
В ней должны сохраняться:
- обычные контентные DM;
- read-receipt DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Для контентных сообщений в БД дополнительно должно поддерживаться серверное поле read_at_ms, если для этого сообщения уже было принято read-receipt событие.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
- не терять факт удаления;
- корректно синхронизировать событие между серверами.
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
Изменяемая сетевая часть хранится отдельно:
dm_delivery_state— состояние и расписание доставки исходящей пары;dm_sync_outbox— событие владельца и единственный флаг ACKsynced;- частичные индексы содержат только due/unsynced строки.
Legacy-таблица dm_sync_peer_state после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется.
13. Что обязательно должно измениться в коде относительно v0.5
- сервер не должен требовать одинаковый
encryptedBodyуtype=1иtype=2; - сервер не должен трактовать
encryptedBodyкак обычный UTF-8 текст; - сервер не должен валидировать внутреннюю crypto-структуру
bodyу контентных DMtype=1/2; - DM должны реально шифроваться end-to-end;
- удаление одного сообщения должно стать терминальным tombstone;
- удаление всей переписки должно стать отдельным служебным событием;
- межсерверная маршрутизация DM должна идти через
access_servers; - сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM;
- при выборе актуальной версии должен учитываться
reencryptedAtMs, еслиrevisionTimeMsсовпадает; - логика должна быть безопасна для одного или двух серверов у каждой стороны.
14. Что в v1 пока не входит
- вложения в DM;
- хранение отдельного
keyIdшифрования в DM; - ротация
clientKey; - финальная конкретная UI-реализация массовой перешифровки;
- межсерверная авторизация DM-синхронизации и доставки.