SHA256
575 lines
27 KiB
Markdown
575 lines
27 KiB
Markdown
# Личные сообщения (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. Создание нового сообщения
|
||
|
||
Новое сообщение отправляется парой блоков через:
|
||
|
||
- `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. Несколько серверов у отправителя и получателя
|
||
|
||
Протокол должен поддерживать ситуацию, когда:
|
||
|
||
- у отправителя несколько `access_servers`;
|
||
- у получателя несколько `access_servers`;
|
||
- часть серверов у сторон совпадает;
|
||
- часть серверов уникальна.
|
||
|
||
Из этого следуют требования:
|
||
|
||
- все DM-операции должны быть идемпотентны;
|
||
- повторное получение уже известного события не должно ломать состояние;
|
||
- дубль tombstone должен быть безопасен;
|
||
- сервер не должен "оживлять" более старую версию сообщения после уже принятого tombstone.
|
||
|
||
## 8. Методы и их роли
|
||
|
||
### 8.1. Существующие методы, которые сохраняются
|
||
|
||
- `SendMessagePair`
|
||
- `ReceiveOutcomingMessage`
|
||
- `ReceiveIncomingMessage`
|
||
- `DeleteMessage`
|
||
- `DeleteConversation`
|
||
|
||
Их роли в 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:
|
||
|
||
```json
|
||
{
|
||
"op": "SendMessagePair",
|
||
"requestId": "req-123",
|
||
"payload": {
|
||
"incomingBlobB64": "...",
|
||
"outgoingBlobB64": "..."
|
||
}
|
||
}
|
||
```
|
||
|
||
Правила:
|
||
|
||
- клиенту достаточно отправить пару на один любой доступный сервер;
|
||
- сервер после принятия сам отвечает за дальнейшую межсерверную доставку.
|
||
|
||
### 10.2. `ReceiveIncomingMessage`
|
||
|
||
Назначение:
|
||
|
||
- приём одной входящей копии по схеме server-to-server;
|
||
- приём входящего редактирования;
|
||
- приём входящего read-receipt.
|
||
|
||
Request:
|
||
|
||
```json
|
||
{
|
||
"op": "ReceiveIncomingMessage",
|
||
"requestId": "req-456",
|
||
"payload": {
|
||
"incomingBlobB64": "..."
|
||
}
|
||
}
|
||
```
|
||
|
||
### 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`.
|
||
|
||
## 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` у контентных DM `type=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 в текущем коде.
|