27 KiB
Личные сообщения (DM) — спецификация v1
Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Важно:
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
Документ фиксирует:
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через
access_servers; - общее поведение сервера и БД.
Точный байтовый формат контейнера вынесен отдельно:
Dev_Docs/Personal_Messages/Формат_DM_v1.md
Устаревшая предыдущая версия сохранена отдельно:
Dev_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 в текущей архитектуре не хранится.
Подробности стандартного преобразования:
Dev_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повреждён или структурно битый, показываетСообщение повреждено.
2.4. Источник истины по пользователю
Для DM-проверки сервер использует:
- локальную
solana_usersкак кэш; - 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. Несколько серверов у отправителя и получателя
Протокол должен поддерживать ситуацию, когда:
- у отправителя несколько
access_servers; - у получателя несколько
access_servers; - часть серверов у сторон совпадает;
- часть серверов уникальна.
Из этого следуют требования:
- все DM-операции должны быть идемпотентны;
- повторное получение уже известного события не должно ломать состояние;
- дубль tombstone должен быть безопасен;
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
8. Методы и их роли
8.1. Существующие методы, которые сохраняются
SendMessagePairReceiveOutcomingMessageReceiveIncomingMessageDeleteMessageDeleteConversation
Их роли в v1:
SendMessagePair— новая пара сообщений и редактирование старой пары автором;ReceiveIncomingMessage— приём одной входящей копии, входящих редактирований и входящего read-receipt;ReceiveOutcomingMessage— алиасSendMessagePair.
8.2. Новые методы, которые нужны
Отдельный legacy-метод SendDirectMessage в DM v1 не используется и должен оставаться отключённым, чтобы не было параллельного старого стека доставки.
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": "..."
}
}
Правила:
- клиенту достаточно отправить пару на один любой доступный сервер;
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
10.2. ReceiveIncomingMessage
Назначение:
- приём одной входящей копии по схеме server-to-server;
- приём входящего редактирования;
- приём входящего read-receipt.
Request:
{
"op": "ReceiveIncomingMessage",
"requestId": "req-456",
"payload": {
"incomingBlobB64": "..."
}
}
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.
11. Межсерверная доставка
11.1. Клиентская сторона
Клиенту достаточно отправить сообщение на:
- любой один доступный сервер.
11.2. Серверная сторона
После принятия валидного события сервер должен отправлять его:
- на все серверы из
access_serversотправителя; - на все серверы из
access_serversполучателя.
Если часть серверов совпадает, это допустимо.
Если один и тот же сервер присутствует у обеих сторон, он не должен слать сообщение сам себе повторно, но обязан локально сохранить событие и доставить его в нужные пользовательские сессии.
Идемпотентность обязательна.
11.3. Ошибки доставки
Если часть серверов временно недоступна:
- это не должно отменять локальное принятие уже валидного сообщения;
- повторная доставка может делаться отдельным retry-механизмом;
- повторное получение того же события должно быть безопасным.
12. Хранение в БД
Основная таблица остаётся:
signed_messages_v2
В ней должны сохраняться:
- обычные контентные DM;
- tombstone одного сообщения;
- tombstone удаления переписки.
Сообщение об удалении одного сообщения хранится в БД и не удаляется физически, чтобы:
- защищать от повторного приёма старых версий;
- не терять факт удаления;
- корректно синхронизировать событие между серверами.
Сообщение об удалении переписки тоже хранится в БД, а старые сообщения до его времени из БД удаляются.
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совпадает; - legacy
SendDirectMessageдолжен быть отключён; - логика должна быть безопасна для нескольких серверов у каждой стороны.
14. Что в v1 пока не входит
- вложения в DM;
- хранение отдельного
keyIdшифрования в DM; - ротация
clientKey; - финальная конкретная UI-реализация массовой перешифровки;
- физическая полная реализация DM federation в текущем коде.