From b801ce533d5e158e246eee34166faee15b7cc4a7 Mon Sep 17 00:00:00 2001 From: evgeny Date: Tue, 3 Nov 2026 13:44:45 +0100 Subject: [PATCH] docs: describe subgroup DM delivery, file keys and retention protocol --- doc/dm_arch.md | 133 +++++++++++++++++++++++++++++++++++++------- src/dm/dm_mailbox.h | 5 +- src/dm/dm_media.h | 7 ++- 3 files changed, 121 insertions(+), 24 deletions(-) diff --git a/doc/dm_arch.md b/doc/dm_arch.md index f1a375613..b24cac574 100644 --- a/doc/dm_arch.md +++ b/doc/dm_arch.md @@ -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. Это проверка соответствия документации; свежая интерактивная проверка клиентов и diff --git a/src/dm/dm_mailbox.h b/src/dm/dm_mailbox.h index 1ad97e553..e17e53ddf 100644 --- a/src/dm/dm_mailbox.h +++ b/src/dm/dm_mailbox.h @@ -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. diff --git a/src/dm/dm_media.h b/src/dm/dm_media.h index 0db59db8f..5ebef6008 100644 --- a/src/dm/dm_media.h +++ b/src/dm/dm_media.h @@ -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