Files
utun2/doc/dm_arch.md
T

25 KiB

DM: доставка сообщений и файлов

Личные беседы принадлежат src/dm/. Общие CHAT-группы используются для знакомства, проверки ролей и маршрутизации; группа не становится идентичностью беседы.

Реализовано

  • История и постоянная исходящая очередь сообщений в общей SQLite ядра.
  • Доставка через живой маршрут router, включая BGP-транзит.
  • При отсутствии маршрута — подписанный PUT доступному суперузлу общей группы сторон.
  • Приём после проверки подписи и расшифровки, подтверждение только после commit.
  • Доставка с суперузла после возвращения получателя, независимо от отправителя.
  • Точное удаление промежуточного сообщения и сохранение квитанции для поздних повторов.
  • Подготовка, шифрование, проверка и durable публикация медиа в рабочих потоках.
  • Прямая передача файлов либо custody на отдельном M, доставка после restart и подтверждённое удаление.
  • TTL и квота M; вложения и статусы доставки в Qt и Android.
  • Голосовые Opus и видео MP4 с зашифрованными метаданными; общий ввод и отображение с каналами.

Идентичность и шифрование

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
content_key = SHA256(X25519(my_private, target_public) || "utun_dm_content")
msg_nonce   = SHA256(conv_id || author || seq)[..13]
media_nonce = SHA256("utun_dm_media" || author || media_id || part)[..13]

Префикс conv_hash — 9 ASCII-байт без NUL. ID узлов подаются в хеш в порядке байтов хоста; native_u64 читает первые 8 байт хеша в том же порядке. Нулевой conv_id заменяется на 1.

Сообщения и медиа используют один content_key target, без ключей отдельных файлов. AES-256-CCM: nonce 13 байт, tag 16 байт. Максимальная порция plaintext — 65535 байт; порция медиа — 32768 байт. UUID медиа новый для каждой подготовки. Повтор доставки использует неизменяемый ciphertext. Повторная подготовка после ошибки требует нового UUID.

Каноническое тело сообщения:

[conv:8][seq:8][ts:8][author:8][ct_len:1][ct][enc_len:2][ciphertext+tag][author_sig:64]

Автор подписывает все поля до sig. conv связывает автора с получателем. Для ct=file перед подписью добавляется descriptor [UUID:16][plaintext_size:8][SHA256(ciphertext):32]. В зашифрованном теле находится attachment_info; пути хранения строятся только из UUID. Размер plaintext сообщения ограничен 1024 байтами. seq — положительное int64.

[kind:1][name_len:2][duration_ms:4][width:2][height:2][UTF8_name:name_len][waveform:100, только voice]

Поля метаданных многобайтной длины имеют big-endian формат. kind: 1=file, 2=voice, 3=video. Имя — basename до 255 байт, без разделителей, управляющих символов и некорректного UTF-8. FILE имеет нулевые duration/dimensions; VOICE — положительную длительность и 100 символов уровней waveform; VIDEO — длительность и размеры. Длительность ограничена 24 часами, размеры — 16384. Метаданные проверяются до локального commit и до подтверждения получателем; S/M видят только ciphertext.

Имя собеседника ограничено DM_PEER_NAME_MAX (63 байта без NUL): создание, загрузка и снимок бесед сохраняют только целые UTF-8 code points. Для 32 букв «Я» сохраняется 31 буква (62 байта). При повреждённой кодировке, включая NUL внутри SQL TEXT, остаётся корректный префикс; WARN содержит conv_id, смещение и длину. Снимок нормализует также имя из nodes при пустом peer_name. Чтение не меняет БД; при следующей штатной записи беседы нормализованное имя сохраняется вместе с ней. Подробности исправления: dm_audit_findings.txt, раздел9.

Медиа ciphertext — последовательность порций с отдельным tag каждой порции. Последняя порция короче; пустой файл содержит один tag. Размер plaintext и SHA256 ciphertext должны входить в подписанный manifest. Потоковая расшифровка проверяет все tags, точный размер и хеш; владелец публикует временный файл только после успеха и собственного durable сохранения.

Владение и данные

  • dm_core: беседы, история, outbox и подтверждение получателя.
  • dm_mailbox: промежуточные сообщения суперузла и квитанции.
  • dm_crypto: вывод conv_id, content_key и nonce сообщений без БД/сети.
  • media_crypto: общие CCM и потоковые файловые примитивы для DM и групповых вложений.
  • dm_media: файлы endpoint, загрузки и отдельная custody M.
  • dm_mailbox_media: durable задания S, выбор M и повтор удаления до DELETE_ACK.
  • file_transfer: подписанная передача байтов, backpressure и отдельный CM handle каждой операции.
  • attachment: формат, валидация и тип метаданных, без сети и БД.
  • voice_file: общий Opus-кодек отправки; attachment_send готовит файл в worker и передаёт результат отдельно в chat_core либо dm_media.
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)
dm_outbox(conv_id, seq, body, retry_at)
dm_pending_messages(recipient, sender, seq, msg, group_id, retry_at)
dm_mail_receipts(recipient, sender, seq, receipt, group_id)

Запись исходящего сообщения, увеличение seq и запись неизменяемого body в outbox — одна транзакция. При ошибке откатывается всё, сообщение не выходит в сеть. Приём, создание/обновление беседы и история — также одна транзакция.

Dedup — точный ключ (conv,dir,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). Файловая квитанция сохраняется только если она уже была получена; удаление само её не создаёт. После commit отменяются незавершённые операции; физическая уборка файлов выполняется отдельно. Это локальная операция: история адресата и промежуточные копии ею не отзываются.

Точный входящий удалённый повтор получает квитанцию сообщения без повторного создания истории/файла и без открытия скрытой беседы. Иное тело с тем же ключом удалённой записи отклоняется. Впервые доставленное сообщение, включая меньший seq, открывает беседу; явный dm_start также снимает hidden без смены поколения. Отложенные отправки проверяют generation, чтобы старый запрос не попал в повторно открытую беседу.

Загрузка беседы различает FOUND, NOT_FOUND и ERROR: отсутствие означает только успешно завершённый SELECT без строки. Ошибка SQL, повреждённые ключи или отказ X25519 прекращают открытие/приём/отправку/приглашение без пересоздания беседы и сброса счётчиков. Полный снимок выдаётся только после успешной загрузки. Исправление и отказные проверки описаны в dm_audit_findings.txt.

У каждой локальной очереди таймер 1с; порция обработки до 32 задач, повтор задачи через 5с. retry_at предотвращает голодание хвоста очереди. Перед отправкой проверяется backpressure router. Потеря пути сохраняет durable задачу; остановка отменяет таймеры, но сохраняет БД. При старте очередь продолжает работу.

Подтверждения и протокол

Сервис ETCP_RT_ID_DM=0x34: MSG=1, RECEIPT=2.

["DMACK001":8][conv:8][seq:8][author:8][recipient:8][SHA256(message):32][recipient_sig:64]

Квитанцию создаёт только получатель после commit. Подпись охватывает первые 72 байта. Отправитель удаляет outbox только после проверки подписи и точного хеша тела. История беседы сохраняется. Подтверждение транспорта router не заменяет квитанцию.

Сервис ETCP_RT_ID_DM_MAILBOX=0x35:

PUT=1     [recipient:8][message]
DELIVER=2 [message]
RECEIPT=3 [receipt:136]
STORED=4  [conv:8][seq:8]

Все пакеты идут с подписью router; unsigned control отклоняется. PUT принимается только от автора, при роли supernode=yes у локального узла в общей группе автора и получателя. Роль берётся из подписанного adm_tags; node_type=4 — производная сетевой классификации, для авторизации не используется. storage=yes само по себе не даёт права хранить сообщения.

STORED подтверждает durable запись суперузлом, outbox до квитанции получателя остаётся у автора. Суперузел повторяет DELIVER при появлении живого маршрута. RECEIPT проверяется по точному сохранённому телу; запись квитанции и удаление копии — одна транзакция. Квитанция пересылается автору. Поздний PUT возвращает её и не возрождает задачу.

dm_mail_receipts содержит только служебные квитанции; доставленное содержимое с промежуточного узла удаляется. Ранее использовавшийся dm_mail/PULL/HELLO и диапазонные ACK не поддерживаются: это devel, совместимость не сохраняется. TTL текстовых сообщений не введён; недельный TTL из pm.txt относится к медиа.

Доставка медиа по pm.txt

  1. Общая передача байтов: явные peer/group/file/size, backpressure и CM handle на всю активную операцию. Владение handle сохраняется после DOWN/TIMEOUT. Используется ETCP_RT_ID_FILE_TRANSFER=0x37. Один callback читает/отправляет не более 8 КиБ, продолжение через call_soon; inflight ограничен 16 пакетами.
  2. Отдельный owner dm_media: подготовка ciphertext, manifest, отправка/скачивание, временные файлы, TTL и лимит хранения. Каталоги dm_media/ и dm_pending/ вне группового обхода очистки orphan-файлов; групповые объявления HAVE_BLOCK не используются.
  3. Суперузел сначала durable регистрирует сообщение и media job, затем выбирает M. M скачивает ciphertext у A и сообщает S подтверждённое хранение, место и expiry.
  4. Текстовая квитанция и квитанция медиа независимы. Получатель подтверждает медиа после проверки, публикации файла и commit. S хранит задачу удаления у M до DELETE_ACK; поздний STORED не возрождает завершённую доставку.
  5. TTL медиа dm_media_ttl_sec по умолчанию 604800 с; квота dm_media_storage_mb по умолчанию 1024 МиБ. Настройки на M. Новая custody отклоняется при недостатке квоты; уже подтверждённые копии не вытесняются молча раньше срока.
  6. Stop снимает новые задачи и закрывает transfer/CM handles, затем ждёт media_async с живыми owner-контекстами. Группы уничтожаются после owners.

При проверке квоты M ошибки SQL чтения занятого места логируются и прекращают обработку STORE без REJECT и регистрации custody. Назначение M сохраняется на S; следующий STORE приходит через существующий таймер повторов. Квота сравнивается только после получения строки SUM и успешного finalize. Реальный недостаток места вызывает REJECT и выбор другого M. Исправление и регрессии: dm_audit_findings.txt, раздел6.

Control ETCP_RT_ID_DM_MEDIA=0x38: OFFER, STORE, STORED, RECEIPT, DELETE, DELETE_ACK, EXPIRED, REJECT; точные форматы заданы в dm_media.h и реализациях owners. Подпись router обязательна. Media receipt содержит DMFILE01, conv, seq, author, recipient, UUID и SHA256 полного подписанного сообщения; B подписывает первые 88 байт. Отдельные таблицы dm_files, dm_custody, dm_custody_done, dm_media_jobs, dm_media_holders сохраняют состояния и tombstones. Все выданные grants M учитываются при удалении; повторный STORE не продлевает TTL.

Qt и Android получают историю через события ядра: 41 — conv + JSON сообщений, 45 — JSON бесед. Снимки ограничены 200 записями. Отправка вложения вызывает dm_send_file в uasync; длительная обработка выполняется в media_async. Android копирует выбранный URI и сохраняет полученный файл через Dispatchers.IO; Qt сохраняет файл из контекстного меню в отдельном потоке. Выбор «как файл» сохраняет оригинал; «как видео» в Qt использует общий FFmpeg worker и метаданные конечного MP4. Android записывает MP4 через CameraX; импорт MP4 проходит через платформенный MediaMetadataRetriever в IO. Другие контейнеры на Android отправляются как файлы.

Голосовая запись отдаёт worker собственный PCM либо компрессор без копирования в GUI/uasync; worker выполняет flush, Opus-кодирование и вычисление waveform. Кодирование Qt и Android использует один voice_file. Адресат фиксируется до записи, выбора URI и подготовки; завершение не читает текущую беседу GUI. Подготовленный PM-файл и Android cache удаляются после копирования, ошибки или отмены; пользовательский исходник Qt остаётся во владении пользователя.

Событие 46 [target_len:1][target][job_UUID:16][state:1] сообщает начало (0), готовность (1) и ошибку (2). Target канала — числовой ID, PM — dm:<conv_id>. Подготовка, доставка и воспроизведение имеют независимые состояния. UI отображает только задания текущей беседы. Состояния файла и delivery receipt приходят в JSON 41; content_type, duration, dimensions и waveform преобразуются в общие роли сообщения. Канальные скачивания вызываются контроллером только для каналов; PM-файлы доставляются автоматически через dm_media.

Android использует общие ConversationComposer/ConversationMessages для обеих бесед; Qt — InputBar и MessageDelegate. Превью Qt создаются в общем пуле с двумя workers; декодирование длинного Opus-файла выполняется вне GUI. Оба плеера проверяют заголовок, пакеты и end marker; Qt ограничивает PCM буфер 256 МиБ.

Проверки

Аудит 26.10.2026 частичный: полностью сверены dm_crypto.c/.h, прочитаны dm_core.c и dm_history.c; контракты остальных owners и платформенные callers ещё не проверены полностью. Первоначальная проверка отказа SQLite выявила сброс счётчиков; дефект исправлен. test_dm_load проверяет 79 отказных сценариев: чтение, bind/step/finalize, короткие ключи и X25519; открытие/приём/отправку/JSON/invite до и после удаления; отдельные отказы подготовки файла, отсутствие беседы, restart и продолжение seq. При отказе сравниваются все поля DM-таблиц и отсутствие SQL-записей/событий успеха. Исправление не означает завершения аудита остальных owners.

test_dm_names: 68 случаев с точным сравнением имени в SQL и JSON при создании, загрузке, штатной записи, fallback из nodes и перезапуске. ASCII и 2/3/4-байтовые символы проверяются до/на/за границей 63 байт; отдельно — overlong, surrogate, значения выше U+10FFFF, неверные/оборванные последовательности, NUL и JSON-экранирование. Снимки не меняют SQL; 273 полных JSON сохраняются для независимого строгого парсера.

test_dm: симметрия ключа target, CCM round-trip, пустое тело, пределы длины, неверный ключ/tag, разделение nonce; потоковые файлы на границах 32 КиБ, неверные author/UUID/hash, обрезание и лишние байты.

test_dm_e2e: реальные A–C–B соединения и BGP; нет физического A–B линка. Проверяет out-of-order, dedup/conflict, подписи, SQL rollback при приёме и enqueue, ACK чужого тела, отказ mailbox без роли, точное удаление и поздний PUT. Первый сценарий передаёт 40 offline-сообщений (больше порции pump) и перезапускает источник с pending outbox; второй перезапускает суперузел и доставляет первое сообщение при выключенном источнике.

test_dm_media: реальные A/B/S/M с раздельными ролями supernode и storage; прямая передача, custody, restart S/M при выключенном A, точный файл на B, удаление копии и тела S, поздние STORE/STORED, TTL, отказ по квоте, неверная квитанция, SQL rollback регистрации, состояния JSON и отмена операции с CM handle. Голосовое передаётся напрямую; метаданные видео сохраняются после offline custody и restart S/M при выключенном источнике. В Linux дополнительно проверяются prepare/step/finalize и неожиданный SQLITE_DONE при подсчёте места, отсутствие REJECT/STORED и SQL-записей при ошибке; три сбоя в первых трёх STORE, успешный четвёртый STORE на том же M и последующая доставка после restart. Проверка настоящей квоты требует один REJECT до регистрации custody.

test_dm_worker: повторное шифрование 8 МиБ не менее 0,5 с и проверка расшифровки; work выполняется вне uasync, done в его потоке, heartbeat продолжает работать. Destroy отменяет уже posted completion, callback вызывается ровно один раз.

test_attachment: строгий формат, повреждения и граничные длины, реальное кодирование/декодирование 50 Opus-кадров, длительность и waveform. test_attachment_send: выбранная беседа после смены текущего target, освобождение PCM в worker, остановка до completion без регистрации и удаление отклонённого cache. Qt test_voice_file: реальные PCM/Opus, повреждённые файлы и отмена async decoder.

Проверка 01.10.2026: чистые сборки Qt и Android APK; полный check.sh — 117 PASS, 0 FAIL, 1 SKIP (auto_socket_dynamic требует root), proxy/burst/load PASS. Qt CTest: 7 PASS, 0 FAIL; Android APK собран после clean. В тесте worker максимальный интервал heartbeat составил 10,3 мс. Интерактивная проверка GUI на телефоне и десктопе не выполнялась.