docs: describe subgroup DM delivery, file keys and retention protocol
This commit is contained in:
1 parent
15406c8e8a
commit
b801ce533d
3 files changed
+121
-24
No files matched your search
+114
-19
@@ -1,13 +1,14 @@
|
||||
# DM: доставка сообщений и файлов
|
||||
|
||||
Личные беседы принадлежат `src/dm/`. Общие CHAT-группы используются для знакомства,
|
||||
проверки ролей и маршрутизации; группа не становится идентичностью беседы.
|
||||
проверки ролей и маршрутизации. Подгруппа личных устройств — отдельная идентичность,
|
||||
не CHAT-канал. Согласованные требования: [dm_devgroup_plan.txt](dm_devgroup_plan.txt).
|
||||
|
||||
## Реализовано
|
||||
|
||||
- История и постоянная исходящая очередь сообщений в общей SQLite ядра.
|
||||
- Доставка через живой маршрут router, включая BGP-транзит.
|
||||
- При отсутствии маршрута — подписанный PUT доступному суперузлу общей группы сторон.
|
||||
- При отсутствии маршрута или ACK в течение 5 секунд — PUT суперузлу общей группы сторон.
|
||||
- Приём после проверки подписи и расшифровки, подтверждение только после commit.
|
||||
- Доставка с суперузла после возвращения получателя, независимо от отправителя.
|
||||
- Точное удаление промежуточного сообщения и сохранение квитанции для поздних повторов.
|
||||
@@ -18,6 +19,17 @@
|
||||
|
||||
## Идентичность и шифрование
|
||||
|
||||
Два режима: DEVICE закрепляет конкретные два устройства, GROUPS закрепляет
|
||||
подгруппу каждой стороны независимо, с fallback на устройство при отсутствии
|
||||
подгруппы. Смена состава/профиля не переключает существующую беседу в другой режим.
|
||||
Действие на заголовке подгруппы открывает GROUPS; на устройстве — DEVICE. Если
|
||||
своя подгруппа есть, а у выбранного устройства её нет, UI предлагает оба варианта.
|
||||
DEVICE между собственными устройствами сохраняется.
|
||||
|
||||
Сторона кодируется как `kind:1,node:LE64,ed:32,x:32`. Подгруппа имеет node=0;
|
||||
её ключи и удостоверения устройств проверяются по подписанным devgroup-записям.
|
||||
GROUPS ID — хеш домена и отсортированных полных сторон; DEVICE сохраняет прежний ID:
|
||||
|
||||
```text
|
||||
conv_hash = SHA256("utun_dm_v" || min(node_a,node_b) || max(node_a,node_b))
|
||||
conv_id = native_u64(conv_hash[0:8]) & 0x7FFFFFFFFFFFFFFF
|
||||
@@ -30,7 +42,15 @@ media_nonce = SHA256("utun_dm_media" || author || media_id || part)[..13]
|
||||
байтов хоста; native_u64 читает первые 8 байт хеша в том же порядке.
|
||||
Нулевой conv_id заменяется на 1.
|
||||
|
||||
Сообщения и медиа используют один content_key target, без ключей отдельных файлов.
|
||||
Для текста ключ выводится из X25519 закреплённых сторон. Для каждого нового файла
|
||||
создаётся собственная пара X25519 F. HPKE export-only X25519/HKDF-SHA256 выводит
|
||||
файловый AES-ключ; F_private независимо упакован HPKE AES-256-GCM для каждой стороны.
|
||||
`DM_FILE_KEYS_SIZE=224`: F_public32, file_enc32, wrap_sender80, wrap_recipient80.
|
||||
Info привязывает ключи к полным сторонам, conv, автору, UUID и назначению упаковки.
|
||||
В metadata сообщения: `DMKEY001:8`, блок ключей224, затем attachment_info.
|
||||
Обе упаковки защищены подписью сообщения; серверы не получают секретов.
|
||||
Все источники передают один и тот же ciphertext. Старые сохранённые DEVICE-файлы
|
||||
без блока ключей читаются прежним content_key; новые создаются с отдельной парой F.
|
||||
AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная порция plaintext — 65535 байт;
|
||||
порция медиа — 32768 байт. UUID медиа новый для каждой подготовки. Повтор доставки
|
||||
использует неизменяемый ciphertext. Повторная подготовка после ошибки требует нового UUID.
|
||||
@@ -42,6 +62,16 @@ AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная пор
|
||||
```
|
||||
|
||||
Автор подписывает все поля до sig. conv связывает автора с получателем.
|
||||
В GROUPS старший бит enc_len установлен: после enc_len идёт контекст
|
||||
`version:1,party_sender:73,party_recipient:73,count:LE16,targets[count]:137`.
|
||||
Target: `node:LE64,side:1,x:32,ed:32,member_sig:64`; список сортируется по node.
|
||||
Низшие 15 бит enc_len задают длину ciphertext. Нулевой ct_len остаётся допустимым
|
||||
для сохранённого DEVICE-тела. Единственный parser находится в dm_protocol.
|
||||
|
||||
Каждое сообщение фиксирует все известные источнику проверенные устройства обеих
|
||||
сторон, включая офлайн и собственные. Максимум128; превышение отклоняет всю отправку.
|
||||
Новые найденные устройства не получают старую историю автоматически. Своя реплика
|
||||
показывается исходящей, но не создаёт новую очередь рассылки.
|
||||
Для `ct=file` перед подписью добавляется descriptor `[UUID:16][plaintext_size:8][SHA256(ciphertext):32]`.
|
||||
В зашифрованном теле находится `attachment_info`; пути хранения строятся только из UUID.
|
||||
Размер data в API отправки ограничен 1024 байтами; для file сюда входит открытый
|
||||
@@ -95,13 +125,16 @@ tags, точный размер и хеш; владелец публикует
|
||||
|
||||
```text
|
||||
dm_conversations(conv_id, peer_node_id, peer_x25519, peer_ed25519, peer_name,
|
||||
group_id, last_out_seq, last_in_seq, created_at, last_ts)
|
||||
dm_messages(conv_id, dir, seq, ts, author, ct, data, sig)
|
||||
group_id, last_out_seq, last_in_seq, created_at, last_ts,
|
||||
mode, local_party, peer_party, local_key, closed)
|
||||
dm_messages(conv_id, author, seq, dir, ts, ct, data, sig, body)
|
||||
dm_outbox(conv_id, seq, body, retry_at)
|
||||
dm_delivery(conv_id, author, seq, recipient, receipt, first_attempt, direct_until, retry_at, supernode)
|
||||
dm_sequence(node_id, value)
|
||||
dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at)
|
||||
dm_mail_receipts(recipient, sender, seq, receipt, group_id)
|
||||
dm_history(conv_id, generation, hidden)
|
||||
dm_deleted_messages(conv_id, dir, seq, body_hash, media_id, media_receipt)
|
||||
dm_deleted_messages(conv_id, author, seq, body_hash, media_id, media_receipt)
|
||||
dm_local_files(path, conv_id, generation, busy, retry_at)
|
||||
```
|
||||
|
||||
@@ -109,14 +142,14 @@ dm_local_files(path, conv_id, generation, busy, retry_at)
|
||||
одна транзакция. При ошибке откатывается всё, сообщение не выходит в сеть.
|
||||
Приём, создание/обновление беседы и история — также одна транзакция.
|
||||
|
||||
Dedup — точный ключ (conv,dir,seq) с проверкой сохранённой подписи. last_in_seq —
|
||||
Dedup — точный ключ (conv,author,seq) с проверкой полного подписанного тела. last_in_seq —
|
||||
статистика максимального номера, не курсор подтверждения: seq=99 после seq=100
|
||||
сохраняется. Тело с другой подписью для уже сохранённого seq отклоняется.
|
||||
|
||||
Локальное удаление (`dm_history_delete`) сохраняет запись беседы, ключи и счётчики,
|
||||
скрывает её (`dm_history.hidden`) и увеличивает постоянное `generation`.
|
||||
В одной транзакции удаляются история, outbox и локальные media-записи, а SHA256
|
||||
точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,dir,seq)`.
|
||||
точных удалённых тел сохраняется в `dm_deleted_messages` по `(conv_id,author,seq)`.
|
||||
Файловая квитанция сохраняется только если она уже была получена; удаление само её не создаёт.
|
||||
После commit отменяются незавершённые операции; физическая уборка файлов выполняется отдельно.
|
||||
Это локальная операция: история адресата и промежуточные копии ею не отзываются.
|
||||
@@ -127,6 +160,17 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
|
||||
явный `dm_start` также снимает hidden без смены поколения. Отложенные отправки
|
||||
проверяют generation, чтобы старый запрос не попал в повторно открытую беседу.
|
||||
|
||||
Счётчик автора общий для его бесед: mailbox `(recipient,author,seq)` остаётся
|
||||
однозначным, когда те же устройства одновременно используют DEVICE и GROUPS.
|
||||
Каждый адресат подтверждается отдельно; outbox удаляется только после всех ACK.
|
||||
|
||||
Выход из подгруппы атомарно закрывает старые GROUPS-беседы и удаляет исходящие
|
||||
очереди. Старый групповой секрет не архивируется. Уже сохранённая история читается
|
||||
ключом беседы, упакованным для собственного устройства; скачанные файлы остаются.
|
||||
Новые приём/отправка/скачивание закрыты, незавершённые workers не публикуют результат.
|
||||
Сам выход не ротирует ключи остальных устройств; это не криптографический отзыв
|
||||
секрета у скомпрометированного устройства.
|
||||
|
||||
Загрузка беседы различает `FOUND`, `NOT_FOUND` и `ERROR`: отсутствие означает
|
||||
только успешно завершённый SELECT без строки. Ошибка SQL, повреждённые ключи
|
||||
или отказ X25519 прекращают открытие/приём/отправку/приглашение без пересоздания
|
||||
@@ -135,7 +179,7 @@ Dedup — точный ключ (conv,dir,seq) с проверкой сохра
|
||||
|
||||
Таймеры работают раз в 1с: outbox A и текстовая очередь S обрабатывают до 32 задач;
|
||||
локальные файлы endpoint, custody M и media jobs S — до 8 задач каждый.
|
||||
Обычный retry_at — через 5с; исходящий файл с квитанцией проверяется через 3600с,
|
||||
Обычный retry_at — через 5с; файлы повторяют неподтверждённые факты независимо от текста,
|
||||
неудачная уборка локального пути повторяется через 30с. Это время допуска следующей
|
||||
попытки, не гарантия доставки за указанный срок. Перед отправкой проверяется backpressure router.
|
||||
Потеря пути сохраняет durable задачу; stop отменяет таймеры, но сохраняет БД.
|
||||
@@ -158,6 +202,7 @@ SQLite принадлежит ядру и остановкой DM не закр
|
||||
```
|
||||
|
||||
Квитанцию создаёт только получатель после commit. Подпись охватывает первые 72 байта.
|
||||
GROUPS использует домен `DMACKG01`; получатель обязан быть в подписанном snapshot.
|
||||
Отправитель удаляет outbox только после проверки подписи и точного хеша тела.
|
||||
История беседы сохраняется. Подтверждение транспорта router не заменяет квитанцию.
|
||||
|
||||
@@ -167,7 +212,8 @@ SQLite принадлежит ядру и остановкой DM не закр
|
||||
PUT=1 [recipient:8][message]
|
||||
DELIVER=2 [message]
|
||||
RECEIPT=3 [receipt:136]
|
||||
STORED=4 [conv:8][seq:8]
|
||||
STORED=4 [conv:8][seq:8][recipient:8]
|
||||
MEDIA_REQUEST=5 [recipient:8][file-message]
|
||||
```
|
||||
|
||||
Все пакеты идут с подписью router; unsigned control отклоняется.
|
||||
@@ -185,6 +231,7 @@ dm_mail_receipts содержит только служебные квитанц
|
||||
с промежуточного узла удаляется. Ранее использовавшийся dm_mail/PULL/HELLO и
|
||||
диапазонные ACK не поддерживаются: это devel, совместимость не сохраняется.
|
||||
TTL текстовых сообщений не введён; недельный TTL из pm.txt относится к медиа.
|
||||
MEDIA_REQUEST независимо регистрирует файловое задание; PUT занимается только текстом.
|
||||
|
||||
## Доставка медиа по pm.txt
|
||||
|
||||
@@ -195,10 +242,14 @@ TTL текстовых сообщений не введён; недельный
|
||||
2. Отдельный owner dm_media: подготовка ciphertext, manifest, отправка/скачивание,
|
||||
временные файлы, TTL и лимит хранения. Каталоги dm_media/ и dm_pending/ вне
|
||||
группового обхода очистки orphan-файлов; групповые объявления HAVE_BLOCK не используются.
|
||||
3. Суперузел сначала durable регистрирует сообщение и media job, затем выбирает M.
|
||||
3. Автор сначала предлагает файл напрямую всем адресатам. Если через 5с остаются
|
||||
неподтверждённые устройства, включая свои, отдельно заказывает хранение у S.
|
||||
Автозагрузка ограничена storage_autoload и storage_unit_size; ручная команда
|
||||
снимает ограничение автозагрузки для конкретного файла. S фиксирует media job и выбирает M.
|
||||
M скачивает ciphertext у A и сообщает S подтверждённое хранение, место и expiry.
|
||||
4. Текстовая квитанция и квитанция медиа независимы. Получатель подтверждает медиа
|
||||
после проверки, публикации файла и commit. S хранит задачу удаления у M до DELETE_ACK
|
||||
4. FILE_RECEIVED подтверждает полную проверенную загрузку, TAKEN — только явное
|
||||
успешное открытие пользователем. Превью, экспорт и автозагрузка не дают TAKEN.
|
||||
S хранит задачу удаления у M до DELETE_ACK
|
||||
либо завершения подтверждённого TTL назначения; поздний STORED не возрождает доставку.
|
||||
5. TTL медиа `dm_media_ttl_sec` по умолчанию 604800 с; квота `dm_media_storage_mb`
|
||||
по умолчанию 1024 МиБ. Настройки на M. Новая custody отклоняется при
|
||||
@@ -231,22 +282,54 @@ Control `ETCP_RT_ID_DM_MEDIA=0x38`: перед body идёт один байт
|
||||
| --- | --- |
|
||||
| OFFER=1 | recipient:8, holder:8, expires:8, file-message |
|
||||
| STORE=2 | recipient:8, file-message |
|
||||
| STORED=3 | UUID:16, expires:8 |
|
||||
| STORED=3 | UUID:16, expires:8, delete_on_open:1 |
|
||||
| RECEIPT=4 / DELETE=5 | файловая квитанция:152 |
|
||||
| DELETE_ACK=6 / EXPIRED=7 | UUID:16 |
|
||||
| REJECT=8 | UUID:16, reason:1 (1 — недостаток квоты) |
|
||||
| RESERVED=9 | UUID:16, expires:8, delete_on_open:1; место принято, ciphertext ещё не готов |
|
||||
| HAVE=10 / HAVE_ACK=11 | UUID:16, cipher_hash:32, probe_token:8, cipher_size:8 |
|
||||
|
||||
Подпись router обязательна. Media receipt содержит `DMFILE01`, conv, seq, author,
|
||||
recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт.
|
||||
Подпись router обязательна. Media receipt содержит домен, conv, seq, author,
|
||||
устройство, UUID и SHA256 полного подписанного сообщения; устройство подписывает первые88 байт.
|
||||
FILE_RECEIVED: `DMFILE01`/`DMFILEG1`, TAKEN: `DMTAKE01`/`DMTAKEG1` для DEVICE/GROUPS.
|
||||
TAKEN разрешён только стороне получателя. `dm_file_targets(id,node,side,received,taken)`
|
||||
хранит отдельные факты; источник уже имеет оригинал и исключён из целей файла.
|
||||
Отдельные таблицы `dm_files`, `dm_custody`, `dm_custody_done`, `dm_media_jobs`,
|
||||
`dm_media_holders` сохраняют состояния и tombstones. Все выданные grants M
|
||||
учитываются при удалении; повторный STORE не продлевает TTL.
|
||||
|
||||
A предлагает собственный файл с holder=A и expires=0. S выбирает M с storage=yes
|
||||
в общей группе A/B, исключая A/B и все прежние назначения этого UUID. Назначение
|
||||
фиксируется до STORE; S/M могут быть одним узлом, local control обрабатывается синхронно.
|
||||
в общей группе A/B, исключая прежние назначения этого UUID. Назначение
|
||||
фиксируется до STORE; S/M и endpoint/M могут совмещаться. Local control обрабатывается синхронно,
|
||||
локальное копирование ciphertext — worker-ом. Endpoint-кеш и custody имеют отдельные
|
||||
пути; отзыв передач по пути не отменяет передачу другой копии того же UUID.
|
||||
Успешная передача control в router или локальный обработчик не означает durable успех.
|
||||
|
||||
Политика задаётся на M в `[chatserver]`:
|
||||
|
||||
```ini
|
||||
dm_media_ttl_sec=604800
|
||||
dm_media_storage_mb=1024
|
||||
dm_media_delete_on_open=yes
|
||||
```
|
||||
|
||||
Первый STORE фиксирует expiry и политику; дубли и смена настройки не продлевают срок
|
||||
и не меняют уже принятую политику, в том числе после restart. При yes первый TAKEN
|
||||
от устройства получателя либо TTL завершает хранение. При no — TTL либо FILE_RECEIVED
|
||||
от ВСЕХ устройств получательской стороны исходного snapshot. Свои устройства автора
|
||||
не задерживают удаление. Факты открытия/скачивания нельзя подменять ACK сообщения.
|
||||
При выполненном условии M сначала устойчиво ставит terminal, отменяет выдачу этой
|
||||
копии, удаляет файл, фиксирует tombstone и лишь затем отправляет DELETE_ACK.
|
||||
Поздние STORE, STORED и worker callbacks не возрождают терминальную копию.
|
||||
|
||||
Получатель параллельно отправляет HAVE известным онлайн-устройствам обеих сторон
|
||||
и известным M. Окно ответов1с; измеряется время запрос→ответ у получателя, затем
|
||||
выбирается минимальный RTT. Один источник передаёт файл; при отказе используются
|
||||
оставшиеся ответы. При смене источника текущая реализация начинает скачивание с0.
|
||||
Проверенный ciphertext сохраняется у каждого endpoint для последующей выдачи
|
||||
авторизованным участникам. UUID сам по себе не даёт доступа. После удаления M
|
||||
доступность зависит от оставшихся копий; отсутствующий файл без источников не восстанавливается.
|
||||
|
||||
EXPIRED относится к промежуточной копии. B помечает ещё не готовый файл expired,
|
||||
но продолжает попытки получить оригинал у A. После проверки исходного ciphertext,
|
||||
публикации и сохранения квитанции файл снова ready; поздний EXPIRED его не меняет.
|
||||
@@ -281,7 +364,11 @@ MediaMetadataRetriever в IO. Другие контейнеры на Android о
|
||||
только задания текущей беседы. Состояния файла и delivery receipt приходят в JSON
|
||||
41; `content_type`, duration, dimensions и waveform преобразуются в общие роли
|
||||
сообщения. Канальные скачивания вызываются контроллером только для каналов;
|
||||
PM-файлы доставляются автоматически через `dm_media`.
|
||||
PM-файлы скачиваются через `dm_media` по настройке либо явной команде пользователя.
|
||||
JSON различает pending/downloading/ready/expired/unavailable и возвращает счётчики
|
||||
доставки сообщения, загрузок и открытий. Qt/Android сохраняют `(conv,author,seq,generation)`
|
||||
до запуска просмотрщика; TAKEN отправляется после его успеха. Копирование в файл
|
||||
и построение превью не считаются открытием.
|
||||
|
||||
Android использует общие `ConversationComposer`/`ConversationMessages` для обеих
|
||||
бесед; Qt — `InputBar` и `MessageDelegate`. Превью Qt создаются в общем пуле
|
||||
@@ -290,6 +377,14 @@ Android использует общие `ConversationComposer`/`ConversationMess
|
||||
|
||||
## Проверки
|
||||
|
||||
Расширение от03.11.2026: `test_dm_identity`, `test_dm_schema`, `test_dm_groups`
|
||||
проверяют удостоверения, канонический контекст, лимит адресатов, миграцию,
|
||||
рассылку A1/A2/B1/B2, собственные реплики, независимые номера авторов, две упаковки
|
||||
ключа файла, per-device факты и закрытие при выходе с rollback.
|
||||
`test_dm_media` дополнен реальным endpoint=M: ручной получатель, локальная custody,
|
||||
перезапуск с другой настройкой, FILE_RECEIVED без удаления, TAKEN с удалением только
|
||||
custody. Результаты окончательных сборок/проверок фиксируются в плане и doc/tasks.md.
|
||||
|
||||
Аудит контрактов DM завершён 27.10.2026: все семь заголовков `src/dm/`, этот документ,
|
||||
`pm.txt` и `pm_invites.md` сверены с реализациями owners и относящимися к ним callers.
|
||||
Это проверка соответствия документации; свежая интерактивная проверка клиентов и
|
||||
|
||||
+3
-2
@@ -7,9 +7,10 @@
|
||||
* Квитанция сохраняется отдельно для повторов PUT и доставки отправителю.
|
||||
*
|
||||
* Роль S — supernode=yes в подписанных adm_tags общей CHAT-группы A/B; storage её не заменяет.
|
||||
* При PUT от A в одной транзакции фиксируются сообщение и, для file, задание dm_mailbox_media.
|
||||
* PUT фиксирует сообщение; независимый MEDIA_REQUEST регистрирует задание dm_mailbox_media.
|
||||
* Сервис ETCP_RT_ID_DM_MAILBOX (0x35); все пакеты подписаны router, числа native-endian:
|
||||
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8].
|
||||
* PUT=1 [recipient:8][message]; DELIVER=2 [message]; RECEIPT=3 [receipt:136]; STORED=4 [conv:8][seq:8][recipient:8].
|
||||
* MEDIA_REQUEST=5 [recipient:8][file-message]; ACK текста не отменяет потребность в файловом хранилище.
|
||||
* STORED подтверждает запись на S, только подписанная B квитанция подтверждает доставку сообщения.
|
||||
* Текст не имеет TTL. Таймер 1с обрабатывает до 32 задач с retry_at через 5с.
|
||||
* Входные буферы заимствуются на время вызова; их копией в пакете/SQLite владеет соответствующий owner.
|
||||
|
||||
+4
-3
@@ -30,9 +30,10 @@ int dm_media_metadata_decode(const uint8_t* data, size_t size, struct attachment
|
||||
|
||||
/* ETCP_RT_ID_DM_MEDIA=0x38: [cmd:1][body], router-подпись обязательна; числа native-endian.
|
||||
* OFFER: [recipient:8][holder:8][expires:8][каноническое file-сообщение]. A предлагает себя с expires=0.
|
||||
* STORE: [recipient:8][file-сообщение] от S к M; STORED: [UUID:16][expires:8].
|
||||
* RECEIPT/DELETE: ["DMFILE01":8][conv:8][seq:8][author:8][recipient:8][UUID:16][SHA256(message):32][sig_B:64].
|
||||
* B подписывает первые 88 байт; DELETE переносит ту же квитанцию, не создаёт новую.
|
||||
* STORE: [recipient:8][file-сообщение] от S к M; STORED/RESERVED: [UUID:16][expires:8][delete_on_open:1].
|
||||
* RECEIPT/DELETE: [domain:8][conv:8][seq:8][author:8][device:8][UUID:16][SHA256(message):32][sig:64].
|
||||
* FILE_RECEIVED: DMFILE01/DMFILEG1; TAKEN: DMTAKE01/DMTAKEG1 для DEVICE/GROUPS.
|
||||
* Устройство подписывает первые88 байт; DELETE переносит проверенный факт, M применяет свою сохранённую политику.
|
||||
* DELETE_ACK/EXPIRED: [UUID:16]; REJECT: [UUID:16][reason:1], reason=1 — недостаток квоты.
|
||||
* EXPIRED завершает промежуточное хранение, а не подтверждает доставку B. */
|
||||
#define DM_MEDIA_OFFER 1
|
||||
|
||||
Reference in new issue
Block a user