# Личные сообщения (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 может дополнительно содержать специальные клиентские вставки `` в начале текста. Legacy-вставки `` также продолжают поддерживаться при чтении. Эти вставки относятся уже к уровню 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. Создание нового сообщения Новое сообщение отправляется парой блоков через: - `SendMessagePair` - `ReceiveOutcomingMessage` как алиас Пару создаёт автор сообщения. Пара содержит: - одну входящую копию `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. Существующие методы, которые сохраняются - `SendMessagePair` - `ReceiveOutcomingMessage` - `ReceiveIncomingMessage` - `DeleteMessage` - `DeleteConversation` Их роли в 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: ```json { "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: ```json { "op": "ReceiveIncomingMessage", "requestId": "req-456", "payload": { "incomingBlobB64": "...", "sourceServerLogin": "server-a" } } ``` `sourceServerLogin` пока доверяется без отдельной межсерверной подписи. Сервер всё равно проверяет пользовательскую подпись самого `SHiNE_DM`. Для `ReceiveOutcomingMessage` и `SendMessagePair` пустой `sourceServerLogin` означает клиентский вызов, непустой - peer-вызов. Успешный ответ сохраняет старые поля `messageKey`, `baseKey` и счётчики realtime-доставки. ### 10.3. `DeleteMessage` Назначение: - удалить одно сообщение у обеих сторон. Request: ```json { "op": "DeleteMessage", "requestId": "req-789", "payload": { "blobB64": "..." } } ``` Ожидается контейнер типа: - `5`, если удаление инициировал отправитель; - `6`, если удаление инициировал получатель. UI-следствие для клиента: - пользователь может удалить как своё исходящее сообщение, так и входящее; - для удаления входящего UI должен отправлять вариант, где удаление инициировал получатель (`type=6`). ### 10.4. `DeleteConversation` Назначение: - удалить всю переписку до времени самого служебного сообщения. Request: ```json { "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` — событие владельца и единственный флаг ACK `synced`; - частичные индексы содержат только due/unsynced строки. Legacy-таблица `dm_sync_peer_state` после миграции v12 физически остаётся для безопасной установки ZIP-накладки, но новым DM-кодом не используется. ## 13. Что обязательно должно измениться в коде относительно v0.5 - сервер не должен требовать одинаковый `encryptedBody` у `type=1` и `type=2`; - сервер не должен трактовать `encryptedBody` как обычный UTF-8 текст; - сервер не должен валидировать внутреннюю crypto-структуру `body` у контентных DM `type=1/2`; - DM должны реально шифроваться end-to-end; - удаление одного сообщения должно стать терминальным tombstone; - удаление всей переписки должно стать отдельным служебным событием; - межсерверная маршрутизация DM должна идти через `access_servers`; - сервер должен добирать отсутствующих пользователей из Solana PDA до проверки подписи DM; - при выборе актуальной версии должен учитываться `reencryptedAtMs`, если `revisionTimeMs` совпадает; - логика должна быть безопасна для одного или двух серверов у каждой стороны. ## 14. Что в v1 пока не входит - вложения в DM; - хранение отдельного `keyId` шифрования в DM; - ротация `clientKey`; - финальная конкретная UI-реализация массовой перешифровки; - межсерверная авторизация DM-синхронизации и доставки.