Перенести документацию в docs и добавить социальный граф

This commit is contained in:
AidarKC
2026-07-13 12:58:41 +04:00
parent c9cfb394d7
commit af12d7b954
91 changed files with 653 additions and 2 deletions
+18
View File
@@ -0,0 +1,18 @@
# AGENTS
## Документация DM в этой папке
- Основной актуальный документ по личным сообщениям:
- `README.md`
- Его считать единственным источником истины по текущей реализованной логике DM.
## Черновик будущих вложений
- Файл `Черновик_будущих_DM_вложений.md` не является актуальной спецификацией.
- В нём описан только ранний черновик того, как когда-то планировались:
- формат вложений в DM;
- внешние и внутренние поля вложения;
- предполагаемая механика загрузки файлов.
- Эта схема не была реализована в таком виде и может существенно измениться в будущем.
- Любые решения по текущему коду, протоколу и UI нельзя принимать по этому черновику.
- Если есть расхождение между `README.md` и черновиком вложений, верным всегда считается `README.md`.
+19
View File
@@ -0,0 +1,19 @@
# Личные сообщения SHiNE
Эта папка содержит актуальную документацию по личным сообщениям SHiNE.
Точка входа:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md` — логика протокола, роли API, серверное поведение, routing по `access_servers`
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md` — точный бинарный формат контейнера `SHiNE_DM`
- `Dev_Docs/Personal_Messages/Технические_вставки_DM_v1.md` — формат специальных `<SHiNE:...>` вставок внутри plaintext DM после расшифровки
Исторический устаревший документ сохранён отдельно:
- `Dev_Docs/Personal_Messages/Спецификация_DM_v0.5_устаревшая.md`
Правило сопровождения:
- код DM и оба документа `Протокол_DM_v1.md` + `Формат_DM_v1.md` всегда должны обновляться синхронно;
- если меняется поведение DM в коде, в том же наборе изменений обновляется и эта документация.
- локальная таблица пользователей для DM считается кэшем, а источником истины остаётся Solana PDA; если серверная логика DM меняет правила lazy-import пользователей из PDA, это тоже обязательно фиксируется в документации.
@@ -0,0 +1,581 @@
# Личные сообщения (DM) — спецификация v1
## Статус документа
Этот файл — актуальная логическая спецификация DM-протокола SHiNE v1.
Важно:
- это актуальная реализованная спецификация;
- код и документы по DM должны изменяться синхронно;
- при любых будущих изменениях DM сначала обновляется эта спецификация и соседний документ формата.
Документ фиксирует:
- модель DM и смысл типов сообщений;
- правила редактирования, перешифровки и удаления;
- роли существующих API-методов;
- правила межсерверной маршрутизации через `access_servers`;
- общее поведение сервера и БД.
Точный байтовый формат контейнера вынесен отдельно:
- `Dev_Docs/Personal_Messages/Формат_DM_v1.md`
Формат клиентских технических вставок внутри plaintext вынесен отдельно:
- `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` повреждён или структурно битый, показывает `Сообщение повреждено`.
После успешной расшифровки plaintext может дополнительно содержать специальные клиентские вставки `<SHiNE:...>` в начале текста.
Эти вставки относятся уже к уровню UI/plaintext, а не к уровню серверного DM-envelope.
### 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 в текущем коде.
@@ -0,0 +1,230 @@
# Личные сообщения (DM) — v0.5 устаревшая спецификация
## Статус документа
Этот документ устарел и сохранён только как историческое описание ранее реализованной схемы DM.
Aктуальная целевая спецификация:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
Что в этом документе считать устаревшим:
- трактовку `encryptedBody` как фактически одинакового содержимого пары;
- отсутствие нормального E2EE-шифрования DM;
- старую модель удаления через пустую ревизию без терминального tombstone;
- старую трактовку обновления только как общей пары без отдельной будущей модели перешифровки;
- все упоминания legacy-формата read-receipt как части целевой архитектуры следующего этапа.
## Текущее состояние
Сейчас в проекте реализованы:
- новый формат контентных личных сообщений `SHiNE_DM`;
- ревизии сообщений через `revisionTimeMs`;
- редактирование сообщения через повторную отправку той же логической пары;
- удаление сообщения через пустую ревизию;
- `upsert` последней версии сообщения на сервере.
Сейчас в проекте **не реализованы**:
- вложения в DM;
- upload/download файлов для DM;
- UI-кнопка прикрепления файла;
- серверное хранение файловых связей для DM.
Черновик будущих вложений вынесен отдельно:
- `Dev_Docs/Personal_Messages/Черновик_будущих_DM_вложений.md`
## Общая схема
Личное сообщение по-прежнему отправляется парой signed-блоков:
- `type=1` — входящий блок для получателя;
- `type=2` — исходящая копия для отправителя.
Read-receipt пока остаются в legacy-формате:
- `type=3` — входящее подтверждение прочтения;
- `type=4` — исходящая копия подтверждения.
Ключи сообщения:
- `baseKey = fromLogin|toLogin|timeMs|nonce`
- `messageKey = baseKey|messageType`
Логический идентификатор письма задаётся парой:
- `timeMs`
- `nonce`
Эти поля не меняются при редактировании или удалении. Меняется только:
- `revisionTimeMs`
- содержимое `encryptedBody`
Сервер хранит только последнюю версию записи для каждого `messageKey`.
## Формат контентного DM: `SHiNE_DM`
Префикс бинарного блока:
- `SHiNE_DM`
Поля идут в big-endian порядке:
1. `formatVersionMajor` (`u8`) = `1`
2. `formatVersionMinor` (`u8`) = `0`
3. `toLoginLen` (`u8`) + `toLogin` (ASCII, `1..60`)
4. `fromLoginLen` (`u8`) + `fromLogin` (ASCII, `1..60`)
5. `timeMs` (`u64`)
6. `nonce` (`u32`)
7. `messageType` (`u16`) — только `1` или `2`
8. `revisionTimeMs` (`u64`)
9. `attachmentsCount` (`u8`)
10. `encryptedBodyLen` (`u32`)
11. `encryptedBody` (`bytes`)
12. `signature` (`64 bytes`, Ed25519)
### Ограничения
- `attachmentsCount` сейчас всегда должен быть `0`
- `encryptedBodyLen` сейчас ограничен сервером до `16384` байт
- `revisionTimeMs` не может быть отрицательным
Если приходит `attachmentsCount != 0`, сервер отклоняет такой DM как:
- `ATTACHMENTS_DISABLED`
## Legacy read-receipt: `SHiNE_dm2`
Подтверждения прочтения `type=3/4` пока используют старый контейнер `SHiNE_dm2`:
1. `toLoginLen` (`u8`) + `toLogin`
2. `fromLoginLen` (`u8`) + `fromLogin`
3. `timeMs` (`u64`)
4. `nonce` (`u32`)
5. `messageType` (`u16`) — `3` или `4`
6. `payloadLen` (`u16`)
7. `payloadBytes`
8. `signature`
## Редактирование
Редактирование делается новой отправкой той же логической пары сообщения:
- `timeMs` и `nonce` остаются теми же;
- `messageType` остаётся `1/2`;
- `revisionTimeMs` становится больше;
- `encryptedBody` содержит новую версию текста.
Если на сервер приходит более старая ревизия, она игнорируется.
Если приходит та же ревизия и тот же бинарный блок, сервер тоже её не применяет повторно.
## Удаление
Удаление личного сообщения делается как новая ревизия того же сообщения:
- `timeMs` и `nonce` остаются прежними;
- `revisionTimeMs` увеличивается;
- `attachmentsCount = 0`;
- `encryptedBodyLen = 0`;
- `encryptedBody` пустой.
В UI такое сообщение не показывается.
На сервере это не отдельный тип сообщения, а просто последняя пустая ревизия того же `messageKey`.
## Поведение сервера
Для контентных DM сервер:
1. принимает пару signed-блоков `type=1/2`;
2. валидирует формат, подпись и совпадение ключевых полей пары;
3. проверяет, что для обеих сторон пары совпадают:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
- `encryptedBody`
4. делает `upsert` последней версии в `signed_messages_v2`;
5. сбрасывает pending-доставку по сессиям для новой ревизии;
6. рассылает актуальную версию адресатам через `SignedMessageArrived`.
История старых ревизий сейчас не хранится отдельно: в таблице остаётся только последняя версия по каждому `messageKey`.
## Хранение в БД
Основная таблица:
- `signed_messages_v2`
Для контентных DM в ней используются:
- `message_key`
- `base_key`
- `target_login`
- `from_login`
- `to_login`
- `time_ms`
- `nonce`
- `message_type`
- `revision_time_ms`
- `raw_block`
- `created_at_ms`
Отдельных таблиц файлов для DM сейчас нет.
## События и доставка
Запрос на отправку по WebSocket остаётся прежним:
- `SendMessagePair`
- `ReceiveOutcomingMessage` как алиас
Клиент отправляет:
- `incomingBlobB64`
- `outgoingBlobB64`
Событие в активные сессии:
- `SignedMessageArrived`
Если пришла новая ревизия того же сообщения, `messageKey` остаётся прежним, а внутри `blobB64` будет более новый `revisionTimeMs`.
Подтверждение доставки в сессию:
- `AckSessionDelivery`
WebPush и локальные уведомления сейчас работают так:
- для активной онлайн-сессии приоритет у доставки по WebSocket через `SignedMessageArrived`;
- если целевая сессия не онлайн по WebSocket, сервер может отправить WebPush с `kind=new_message`;
- если вкладка/приложение живы, но страница скрыта (`document.visibilityState !== visible`), UI дополнительно пытается показать системное уведомление через `service worker`;
- для активной видимой страницы UI проигрывает короткий локальный сигнал на каждое новое входящее DM, если браузер ранее разрешил аудио-контекст после пользовательского жеста;
- для скрытой, но живой страницы UI также делает `best effort` сигнал через `vibrate()` и более длинный локальный звук;
- эти локальные сигналы не гарантируются браузером: на мобильных устройствах они зависят от политики Chrome/Android/iOS.
## Правила UI
UI сейчас работает так:
- показывает только текст `encryptedBody`;
- умеет обновлять уже существующее сообщение по тому же `messageKey`;
- не показывает удалённые сообщения;
- позволяет владельцу сообщения вызвать меню `Скопировать как текст / Прочесть / Изменить / Удалить`;
- при редактировании показывает над полем ввода полоску `Редактируем сообщение: ...` с кнопкой отмены;
- после редактирования показывает под временем отдельную строку `изменено: <дата время>`;
- на видимом экране чата/приложения проигрывает короткий локальный звук на новое входящее DM;
- при входящем DM для скрытой, но ещё живой страницы пытается поднять системное уведомление через `service worker`;
- не показывает и не принимает вложения.
## Что обязательно помнить
- вложения в DM сейчас отключены на уровне протокола и UI;
- любые старые описания `/f/...`, `/upload` и файловых таблиц для DM больше не актуальны;
- если позже вложения вернутся, их формат и серверная логика могут быть другими.
@@ -0,0 +1,147 @@
# Технические вставки внутри plaintext DM v1
## Статус документа
Этот файл описывает внутренний формат технических вставок, которые находятся внутри уже расшифрованного plaintext DM.
Важно:
- это не отдельный серверный DM-envelope;
- это часть plaintext контентного DM после E2EE-расшифровки;
- сервер не обязан знать этот формат;
- клиент может использовать эти вставки для специального UI-рендера.
## 1. Общий принцип
Обычное сообщение по-прежнему остаётся обычным текстом.
Если в начале plaintext стоит один или несколько специальных блоков формата:
```text
<SHiNE:...>
```
то клиент трактует их как технические вставки.
Техническими считаются только блоки, которые:
- стоят строго в начале plaintext;
- начинаются с точного префикса `<SHiNE:`;
- заканчиваются первым символом `>`.
Если текст не начинается с `<SHiNE:`, никакие технические вставки не ищутся.
## 2. Правило скрытия
Все корректно распознанные блоки `<SHiNE:...>` в начале plaintext:
- не показываются пользователю как сырой текст;
- используются клиентом для UI-логики;
- неизвестные будущие типы тоже скрываются, если они распознаны как корректный SHiNE-блок.
Если блок битый и не закрыт символом `>`, он не считается техническим и сообщение показывается как обычный текст целиком.
## 3. Защита от случайного пользовательского ввода
Перед отправкой обычного текстового сообщения клиент обязан проверить:
- если пользовательский текст начинается с `<SHiNE:`
то клиент автоматически превращает начало в:
```text
< SHiNE:
```
Такой текст уже не считается техническим блоком и должен отображаться как обычное сообщение.
## 4. Формат блока
Общий вид:
```text
<SHiNE:kind;key=value;key=value;...>
```
Правила:
- `kind` — ASCII-идентификатор типа вставки;
- параметры отделяются `;`;
- ключ и значение отделяются `=`;
- значения не экранируются в v1;
- формат чувствителен к точному префиксу `<SHiNE:`.
## 5. Тип `reply`
Формат:
```text
<SHiNE:reply;v=1;id=user1|user2|1720612345678|77>Текст ответа
```
Где поле `id` — это логический идентификатор сообщения:
```text
fromLogin|toLogin|timeMs|nonce
```
Правила:
- этот блок должен стоять в начале plaintext;
- после него может идти обычный текст ответа;
- официальный UI формирует такой блок при отправке ответа через пункт `Ответить` в меню сообщения;
- если клиент не находит сообщение, на которое ссылается `reply`, он просто не показывает reply-preview;
- в таком случае само сообщение отображается как обычный текст без блока `<SHiNE:reply...>`.
## 6. Тип `call`
### Успешный звонок
```text
<SHiNE:call;v=1;status=completed;duration=367>
```
Где:
- `duration` — длительность разговора в секундах.
### Неуспешный звонок
```text
<SHiNE:call;v=1;status=failed;reason=offline>
```
Допустимые причины в v1:
- `offline`
- `no_answer`
- `connect_failed`
- `busy`
- `declined`
Правила:
- call-блок в v1 должен содержать только техническую запись звонка;
- пользовательский текст после такого блока в штатной логике не предполагается;
- UI может рисовать такие сообщения отдельным специальным стилем.
- официальный UI не отправляет call-summary, если от старта исходящего звонка до его завершения прошло меньше `5` секунд.
## 7. Поведение официального UI
Официальный UI SHiNE в v1:
- скрывает все корректные `<SHiNE:...>` блоки в начале plaintext;
- для `call` строит специальный человекочитаемый текст:
- `Звонок: M:SS`
- `Звонок: H:MM:SS`
- `Звонил, но недозвонился: ...`
- для `reply` скрывает сам блок и показывает только текст ответа;
- если исходное reply-сообщение не найдено, reply-preview не показывается.
## 8. Совместимость
Так как это часть plaintext, а не часть серверного envelope:
- сервер не обязан понимать этот формат;
- будущие клиенты могут добавлять новые `kind`;
- клиенты, которые распознают SHiNE-вставки, должны скрывать неизвестные блоки целиком, если они стоят в начале и корректно закрыты.
@@ -0,0 +1,329 @@
# Формат DM v1
## Статус документа
Этот файл фиксирует байтовый формат DM-контейнеров SHiNE v1.
Важно:
- это актуальный формат, реализованный в текущем коде;
- контейнер `SHiNE_DM` используется для всех типов `1..8`;
- при любых будущих изменениях DM этот документ обновляется одновременно с кодом.
Логика протокола, API и поведение сервера описаны отдельно:
- `Dev_Docs/Personal_Messages/Протокол_DM_v1.md`
## 1. Общие правила
- Числа кодируются в `big-endian`.
- `toLogin` и `fromLogin` — ASCII строки длиной `1..60`.
- Подпись — `Ed25519`, 64 байта.
- `nonce``u32`.
- `messageType``u8`.
- `timeMs`, `revisionTimeMs`, `reencryptedAtMs``u64`.
## 2. Типы сообщений
- `1` — входящее сообщение
- `2` — исходящая копия сообщения
- `3` — входящее подтверждение прочтения
- `4` — исходящая копия подтверждения прочтения
- `5` — сообщение удалено отправителем
- `6` — сообщение удалено получателем
- `7` — переписка удалена отправителем
- `8` — переписка удалена получателем
## 3. Контейнер `SHiNE_DM`
```text
SHiNE_DM
- prefix = "SHiNE_DM"
- formatVersionMajor: u8
- formatVersionMinor: u8
- toLoginLen: u8
- toLogin
- fromLoginLen: u8
- fromLogin
- timeMs: u64
- nonce: u32
- messageType: u8
- revisionTimeMs: u64
- reencryptedAtMs: u64
- bodyLen: u32
- body
- signature: [64]
```
## 4. Смысл полей `SHiNE_DM`
### `timeMs`
Это время исходного создания сообщения.
Для типов `7/8` это время самого служебного сообщения удаления переписки и одновременно граница, раньше которой сообщения этой пары считаются недействительными.
### `nonce`
Дополнительное поле уникальности сообщения.
Используется вместе с `timeMs` для построения стабильного логического ID.
### `messageType`
Определяет смысл контейнера:
- обычное сообщение;
- исходящая копия;
- read-receipt;
- удаление одного сообщения;
- удаление всей переписки.
### `revisionTimeMs`
- `0` для исходной версии контентного сообщения;
- ненулевое значение для новой текстовой ревизии сообщения;
- для типов `5/6` это время tombstone удаления одного сообщения;
- для типов `7/8` всегда `0`.
То есть `revisionTimeMs` отвечает именно за изменение смыслового содержимого сообщения, а не за сам факт перешифровки.
### `reencryptedAtMs`
- `0`, если перешифровки не было;
- ненулевое значение, если копия сообщения была перешифрована.
Для типов `5/6/7/8` должно быть `0`.
`reencryptedAtMs` не требует, чтобы `revisionTimeMs` был ненулевым. Допустим сценарий:
- `revisionTimeMs = 0`
- `reencryptedAtMs > 0`
если исходное сообщение не редактировалось, но было перешифровано позже.
### `bodyLen`
- `> 0` для типов `1/2/3/4`;
- `0` для типов `5/6/7/8`;
- для всех типов используется один и тот же контейнер `SHiNE_DM`.
## 5. Поле `body` для типов `1/2`
Для обычного контентного DM поле `body` содержит не голый ciphertext, а контейнер шифрования:
```text
EncryptedBody_v1_0
- cryptoMethod: u8
- cryptoVersion: u8
- ephemeralPubKeyLen: u8
- ephemeralPubKey: bytes[ephemeralPubKeyLen]
- ivLen: u8
- iv: bytes[ivLen]
- cipherTextLen: u32
- cipherText: bytes[cipherTextLen]
```
### Смысл полей `EncryptedBody_v1_0`
#### `cryptoMethod`
Идентификатор метода шифрования.
В версии v1 резервируется:
- `1``X25519 + HKDF-SHA256 + AES-256-GCM`
#### `cryptoVersion`
Версия конкретного метода шифрования.
Для текущей версии:
- `0`
#### `ephemeralPubKey`
Публичный ephemeral `X25519` ключ отправителя конкретной ревизии.
Практически ожидается длина:
- `32`
#### `iv`
Nonce/IV для `AES-GCM`.
Практически ожидается длина:
- `12`
#### `cipherText`
Зашифрованное содержимое сообщения.
Сервер не должен трактовать это поле как UTF-8 текст и не должен пытаться расшифровывать его в обычной DM-логике.
Важно:
- официальный UI SHiNE сейчас использует именно `EncryptedBody_v1_0`;
- сервер для контентных DM `type=1/2` не должен требовать, чтобы `body` обязательно был распознан как `EncryptedBody_v1_0`;
- сервер должен хранить и пересылать `body` как opaque bytes, если внешний контейнер `SHiNE_DM` валиден.
### Точная схема для `cryptoMethod = 1`, `cryptoVersion = 0`
- публичный ключ получателя для E2EE получается как стандартное преобразование `Ed25519 -> X25519`;
- отправитель генерирует ephemeral `X25519` приватный ключ и кладёт соответствующий `ephemeralPubKey` в контейнер;
- общий секрет вычисляется как `X25519(ephemeralPrivKey, recipientX25519PubKey)`;
- `HKDF-SHA256` использует:
- `salt = ephemeralPubKey || recipientX25519PubKey`
- `info = "SHiNE_DM|1|0|X25519+HKDF-SHA256+AES-256-GCM"` в ASCII
- `outputLen = 32`
- полученные `32` байта используются как ключ `AES-256-GCM`;
- поле `cipherText` хранит стандартный результат библиотеки `AES-GCM` в виде:
- `ciphertext || tag`
## 6. Поле `body` для типов `3/4`
Для read-receipt типов `3/4` поле `body` содержит открытый контейнер ссылки на исходное сообщение:
```text
ReadReceiptBody_v1_0
- refToLoginLen: u8
- refToLogin
- refFromLoginLen: u8
- refFromLogin
- refTimeMs: u64
- refNonce: u32
```
Смысл:
- `refToLogin`
- `refFromLogin`
- `refTimeMs`
- `refNonce`
однозначно указывают на логическое сообщение по его `baseKey`.
Отдельный `refType` в v1 не нужен, потому что подтверждение прочтения относится к самому логическому сообщению, а не к выбору между копиями `type=1` и `type=2`.
## 7. Контент типов `1/2`
Для типов `1/2`:
- `bodyLen > 0`
- `body` обязан быть контейнером `EncryptedBody_v1_0`
- ciphertext у `type=1` и `type=2` может быть разным
### Новое сообщение
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
### Редактирование
- `revisionTimeMs > 0`
- `reencryptedAtMs = 0` или больше нуля, если одновременно произошла перешифровка
### Перешифровка
- `revisionTimeMs = 0` или `> 0`
- `reencryptedAtMs > 0`
## 8. Контент типов `3/4`
Типы:
- `3` — входящее подтверждение прочтения
- `4` — исходящая копия подтверждения прочтения
Правила:
- `bodyLen > 0`
- `body` обязан быть контейнером `ReadReceiptBody_v1_0`
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
Тип `3` и тип `4` по стилю полностью подчиняются общему контейнеру `SHiNE_DM`:
- тот же `prefix = "SHiNE_DM"`
- те же `formatVersionMajor = 1`
- те же `formatVersionMinor = 0`
Различается только `messageType` и формат `body`.
## 9. Контент типов `5/6`
Типы:
- `5` — сообщение удалено отправителем
- `6` — сообщение удалено получателем
Правила:
- `bodyLen = 0`
- `body` отсутствует
- `revisionTimeMs > 0`
- `reencryptedAtMs = 0`
Это terminal tombstone конкретного сообщения.
После принятия такого контейнера содержательная версия этого же `messageKey` больше не должна приниматься.
## 10. Контент типов `7/8`
Типы:
- `7` — переписка удалена отправителем
- `8` — переписка удалена получателем
Правила:
- `bodyLen = 0`
- `body` отсутствует
- `revisionTimeMs = 0`
- `reencryptedAtMs = 0`
Здесь роль границы удаления играет:
- `timeMs` самого контейнера
После принятия такого контейнера:
- более старые сообщения этой пары должны быть удалены;
- более старые новые поступления этой пары не должны приниматься.
## 11. Подпись
Поле `signature` всегда подписывает весь контейнер от `prefix` до конца `body` включительно.
Правило подписи по `messageType`:
- `1`, `2`, `5`, `7` подписывает отправитель сообщения;
- `6`, `8` подписывает получатель сообщения.
Практическое UI-следствие:
- клиентский UI может инициировать удаление входящего сообщения через `type=6`;
- клиентский UI может инициировать очистку истории переписки через `type=8`;
- сами контейнеры и байтовый формат при этом не отличаются от уже описанных типов `5/6/7/8`.
## 12. Какие поля должны совпадать у пары `1/2`
При обычной парной отправке через `SendMessagePair` у двух копий должны совпадать:
- `fromLogin`
- `toLogin`
- `timeMs`
- `nonce`
- `revisionTimeMs`
Дополнительно:
- логический plaintext должен быть одинаковым;
- ciphertext может различаться;
- `reencryptedAtMs` при одной парной ревизии тоже должен совпадать.
## 13. Примечание о поддержке
В версии DM v1 все типы `1..8` используют единый контейнер `SHiNE_DM`.