Files
utun2/doc/encrypted_attachments.md
T

35 KiB
Raw Blame History

Зашифрованные вложения группового чата

В desktop chatgui после выбора файла или перетаскивания в чат рядом с его именем появляется флажок «Зашифровать вложение». Он применяется к выбранному файлу, изображению или видео. Удаление, замена файла и переход в другую беседу сбрасывают флажок. Выбор фиксируется вместе с адресатом при отправке, до подготовки. В DM флажок скрыт: используется существующее автоматическое E2E-шифрование лички.

Android показывает зашифрованное вложение отдельной карточкой со статусом запроса ключа. После одобрения карточка обновляется на исходный файл, изображение, видео или голосовое. В интерфейс передаётся только расшифрованный путь; контейнер .uenc нельзя открыть или сохранить как готовое вложение. До расшифровки цитата показывает название «Зашифрованное вложение», после — обычный preview исходного вложения. Список чатов сохраняет общее название зашифрованного вложения вместо manifest.

Автор генерирует независимые случайные crypto_UUID и ключ AES-256 на каждую подготовку. Ключ, UUID, автор, пути и метаданные записываются в локальную SQLite собственной короткой транзакцией до передачи подготовленного файла worker. Проверяются prepare, все bind, step, ровно одна добавленная строка, finalize и commit. Внешняя транзакция отклоняется без изменений. До принятия ошибка возвращает -1: файл остаётся у вызывающего, worker и событие отправки не создаются. После commit возвращается 0, файл принадлежит задаче и удаляется также при её отказе; ненулевой request получает один MSG_SUBMITTED, в том числе синхронно при отказе запуска worker. Исходный пользовательский файл сохраняется: общий frontend передаёт сюда собственную подготовленную копию.

Неудачный rollback с оставшейся открытой транзакцией отключает модуль вложений и запрашивает остановку ядра. Desktop и Android учитывают запрос в своих poll-циклах; владелец освобождает ядро и закрывает SQLite после возврата из poll. Текущий poll может доставить другие callbacks до выхода. Политика общей БД остаётся WAL/synchronous=NORMAL: завершённый commit до worker не является гарантией сохранности после отключения питания. История дефекта и проверок — в chat_audit_findings.txt.

Готовый контейнер передаётся через обычные media_index/media_delivery. UUID транспортного медиа и crypto_UUID имеют разные назначения: первый идентифицирует сетевые блоки, второй — контейнер и ключ. Повтор доставки передаёт прежний контейнер.

Связь авторского ключа с message_id и SHA256 контейнера фиксируется собственной транзакцией до публикации сообщения. Проверяются все SQL-этапы и одна строка; точный повтор допустим, замена сообщения или hash запрещена. Внешняя транзакция остаётся нетронутой, SQL-отказ сохраняет прежнюю пару. UUID-префикс принимает только канонический base64url с правильными padding/завершающими битами и lowercase hex; при отказе выходной UUID не изменяется.

Подписанное сообщение и контейнер

В подписанном сообщении ct=application/x-utun-encrypted, базовое имя тела — <crypto_UUID:32hex>.uenc. Остальные поля manifest описывают шифротекст: размер, SHA256 всего контейнера, UUID и подписи блоков. Ключ, исходное имя, MIME, waveform и остальные медиаметаданные в историю канала не записываются.

Контейнер:

magic "UTENC001":8
crypto_UUID:16
plaintext_size:8BE
encrypted_metadata_size:2BE
SHA256(encrypted_file_body):32
encrypted_metadata:variable
encrypted_file_body:variable

Метаданные содержат MIME в 32 байтах и сериализованный attachment_info. MIME — непустая NUL-строка длиной1..31 байт: ASCII33..126 без двойной кавычки и обратной косой черты. Общий encrypted_file_validate_type применяется в send, write и read; это проверка представления поля, без полного разбора MIME-грамматики. Send отклоняет неверное значение до создания задачи/ключа/worker, write — до файловых операций. Read проверяет расшифрованное аутентифицированное поле и при ошибке сохраняет прежние info/type. Сырые неверные строки в диагностику не выводятся.

Каждая часть шифруется AES-256-CCM с тегом 16 байт и nonce 13 байт. Nonce метаданных использует part=UINT64_MAX; файл — порции 32768 байт с номерами от нуля. Общие примитивы находятся в media_async/media_crypto; формат существующего DM при их выделении не менялся.

Получатель проверяет подписанный SHA256 полного контейнера, UUID, tag метаданных, все tags файла, точный размер и hash тела. Результат пишется во временный файл и публикуется после успешной проверки и durable rename. Подготовка файлов и криптография выполняются в media_async worker.

encrypted_file_write/read возвращают закрытый проверенный file_io_temp, не изменяя конечное назначение. Задача владеет им до publish/discard; запись идёт в эксклюзивно созданный соседний файл с правами 0600 на POSIX. Одинаковые файлы, включая /./, hardlink и конечную ссылку назначения, отклоняются до подготовки; публикация повторно проверяет идентичность. При ошибке read не выдаёт новые метаданные.

Режим FILE_IO_DURABLE действует весь срок temp: flush/sync открытого файла/close, затем rename и сохранение каталога. После успешного rename ошибка directory sync не откатывает установленный результат; temp уже отсоединён от владельца. Completion не удаляет файлы по заранее вычисленному имени: очистка учитывает, какие артефакты задача действительно создала или опубликовала.

Перед расшифровкой собственная SQL-транзакция фиксирует decrypting=1 ровно для одного подходящего файла с выбранным ключом. Worker запускается только после commit. Завершение также владеет транзакцией: отличает удалённую строку от ошибки чтения, проверяет все bind/step/changes/finalize и сохраняет метаданные/ready либо ошибку. До commit оно остаётся в списке задач instance; SQL-отказ повторяется таймером с готовым результатом, без повторного worker. Внешняя транзакция остаётся нетронутой. Лимит16 включает workers и такие завершения; отказ новой отправки сохраняет source у вызывающего. Событие/публикация сообщения следуют после commit результата. Quiesce останавливает повторы; после завершения workers destroy освобождает остаток списка и принадлежащие ему результаты. При restart незавершённая расшифровка может выполниться заново по сохранённому ключу и контейнеру.

Реестр file_io защищает активные temp от очистки кеша, включая время после close. Восстановление до запуска workers и очистка после их завершения удаляют оставшиеся <UUID>.uenc.tmp.<UUID> / <UUID>.file.tmp.<UUID> в media/decrypted. Конечные файлы и имена других форматов этот проход сохраняет; активные temp другого instance того же процесса остаются защищены общим реестром.

Хранение и разрешения

Локальные таблицы не регистрируются в db_sync или Merkle:

  • attachment_keys: ключ автора/получателя, связь с общим message_id, crypto_UUID, hash и пути двух копий, расшифрованные метаданные и состояние.
  • attachment_key_requests: один запрос на (channel,message_id,peer), направление, решение и дата создания.
  • attachment_key_outbox: команда, адресат и время следующей попытки.

Шифротекст хранится в media/<channel>/<crypto_UUID>.uenc, локальная открытая копия — в decrypted/<channel>/<crypto_UUID>.file. В media_files и объявлениях держателей остаётся только шифротекст, включая backfill после restart. Квота медиакеша учитывает обе копии; очистка кеша не удаляет ключ. При повторном открытии удалённая открытая копия восстанавливается с сохранённым ключом.

Производная запись контейнера создаётся из проверенной истории собственной транзакцией: новая строка проверяется целиком, точный дубликат сохраняет ключ, метаданные и флаги. SQL/OOM-отказ (-1) допускает повтор; неверный manifest или коллизия (-2) его не запускают. Ошибка этого индекса не отменяет событие уже сохранённого сообщения. Общая очередь backfill удерживает до16 каналов, через2с повторяет чтение истории; переполнение ставит полный scan. Обрабатывается отделённый batch, поэтому SQL-отказ во время прохода может безопасно поставить следующий. Startup и ручная загрузка также восстанавливают индекс, включая сохранённый ciphertext. Секретный ключ из истории не восстанавливается.

wanted и сброс ready/error при ручной загрузке фиксируются вместе. Состояние downloaded и автоматический запрос обычного участника также сохраняются отдельной проверенной транзакцией, событие следует после commit; неизменный повтор молчит. Новая проверенная передача сбрасывает прежний error для повторной расшифровки, простое обнаружение старого cache сохраняет error. Внешняя транзакция запрещена. JSON истории вместе с manifest ограничен DB_SYNC_MESSAGE_MAX (4000байт), как для остальных сообщений. Ручной обработчик использует тот же предел; отдельного достижимого усечения на4096байтах не было.

При локальном удалении канала SQLite-trigger удаляет ключи, запросы и outbox в той же транзакции, что channels; незавершённый каскад, включая RAISE(IGNORE), отменяет удаление. Также проверяется отсутствие канала и Merkle-хешей после DELETE. SQL-ошибка сохраняет все записи и файлы; сетевые ресурсы к этому моменту уже могут быть остановлены, поэтому требуется повтор команды. Внешняя транзакция отклоняется до отмены ресурсов. Открытая после отказа rollback транзакция требует stop владельцем. Только после commit удаляются ciphertext через media_index и собственные канонические decrypted/<channel>/<UUID>.file. Посторонние имена, подкаталоги и temp этот проход сохраняет. I/O-ошибка не возвращает удалённый канал: она логируется, очистку можно повторить. Завершение активного worker отдельно удалит свой результат, увидев отсутствие строки.

После полной проверенной загрузки обычный клиент автоматически запрашивает ключ автора. Фоновая копия узла с подписанной ролью storage=yes запрос не создаёт; ручная пользовательская загрузка на таком узле запрашивает доступ. Сетевая классификация peers.storage для этого не используется.

Таймер создаёт исходящий REQUEST собственной BEGIN IMMEDIATE/COMMIT: повторно проверяет, что файл скачан и нужен, ключ отсутствует и прежнего запроса нет; затем сохраняет ровно один исходящий запрос и команду outbox. Все SQL-этапы, число строк и commit проверяются. Уведомление и отправка следуют после commit. Отказ сохраняет прежние строки, следующий проход повторяет подготовку. При внешней транзакции весь проход файлов и отправки откладывается, не меняя её. Если rollback не закрыл собственную транзакцию, таймер не запускается повторно: модуль снимается и владелец останавливает ядро после poll.

В шапке desktop-клиента кнопка «Запросы расшифровки» видна при наличии запросов без решения и показывает их число. Список содержит участника, вложение, дату и отдельные кнопки выдачи/отказа. Двойной клик переводит к исходному сообщению. После выдачи видны ожидание доставки и подтверждённая доставка ключа; отказ также сохраняется.

Адресный протокол

Router-сервис ETCP_RT_ID_ATTACHMENT_KEYS=0x42. Каждый пакет обязан иметь ROUTER_FLAG_SIGNED | ROUTER_FLAG_ENCRYPTED; промежуточные узлы ключа не видят. Протокол не создаёт личную беседу и не публикует служебные сообщения в канал.

magic "AK01":4 | command:1 | message_id:32 | crypto_UUID:16 | cipher_hash:32
GRANT дополнительно содержит key:32

REQUEST=1, REQUEST_ACK=2, GRANT=3, DENY=4, RESULT_ACK=5

Автор принимает запрос только на собственное опубликованное сообщение с сохранённым ключом. Участие запрашивающего проверяется по полному подписанному мемберу, join и дереву приглашений; удалённые мемберы отклоняются. Проверка повторяется при решении и перед каждой отправкой GRANT. Получатель принимает ответ только от автора, по своему существующему запросу и точной идентичности контейнера. Конфликтующие ключи и результаты отклоняются.

Приём REQUEST использует собственную BEGIN IMMEDIATE/COMMIT. Внешняя транзакция отклоняется без изменений до чтения идентичности/прав. Под блокировкой проверяются UUID/hash контейнера, допуск участника и авторство, затем читается существующий запрос. Отсутствующий запрос вставляется со state=0 и проверкой одной строки; дубликат сохраняет решение и created_at. Только incoming=1 и state=0/2/3/4 допустимы:0 ожидает автора, 2/4 повторяют GRANT в outbox,3 повторяет DENY. Все SQL-этапы проверяются; ACK/событие отправляются после commit. При отказе прежние запрос/очередь сохраняются, ACK не отправляется. Если после commit ACK не доставлен, клиент повторяет REQUEST; сохранённый запрос позволяет повторно подтвердить приём и прежнее решение. Неудачный rollback с открытой транзакцией требует quiesce/stop и закрытия ядра владельцем после poll. История дефекта и исправления — chat_audit_findings.txt.

GRANT/DENY появляются в outbox в одной собственной BEGIN IMMEDIATE/COMMIT с решением автора. Проверка прав также выполняется внутри неё. Изменяется ровно один входящий запрос в state=0; уже принятое решение повторной командой не заменяется. Общий помощник записи outbox проверяет prepare, все bind, step, ровно одну строку и finalize, транзакцией управляет вызывающий. Внешняя транзакция отклоняется до проверки прав и остаётся нетронутой. SQL-отказ, включая пропуск записи через RAISE(IGNORE), сохраняет прежние состояние и очередь. Возврат0 и единственное уведомление UI следуют после проверенного commit; доставка выполняется позже. Trampoline при ошибке отправляет событие с success=0, позволяя повторить команду. Неудачный rollback с открытой транзакцией отключает модуль и запрашивает stop ядра после poll. История дефекта и исправления — в chat_audit_findings.txt.

Ответы REQUEST_ACK/GRANT/DENY/RESULT_ACK применяются собственной короткой транзакцией: чтение запроса и очереди, запись ключа при GRANT, изменение состояния и удаление ожидаемой команды. Все prepare/bind/step, числа строк, finalize и commit проверяются. Удаляемая команда может уже отсутствовать после REQUEST_ACK либо предыдущего результата; если она существовала, пропуск DELETE считается ошибкой. Внешняя транзакция отклоняется без изменений.

RESULT_ACK для GRANT/DENY и уведомление интерфейса отправляются после commit. При SQL-отказе прежние ключ/состояние/outbox сохраняются, подтверждение не отправляется, автор повторяет выдачу. Если rollback оставил открытую транзакцию, модуль отключается и запрашивает остановку ядра владельцем после poll. Найденный ранее bind-дефект и его исправление описаны в chat_audit_findings.txt. Повторы возвращают прежнее решение и не создают новых строк. Таймер обрабатывает ограниченную порцию очереди с backpressure и повтором через 5 секунд; restart продолжает очереди.

Для доставки нужен живой маршрут обеих сторон: офлайн-mailbox этого сервиса не реализован. Запрос ждёт появления автора; решение ждёт появления получателя. Уже выданный ключ невозможно отозвать у получателя. Защита содержимого не скрывает автора, канал, время и размер шифротекста. Автоматическая публикация транскрипции зашифрованного голосового в канал не запускается.

Проверки

test_encrypted_file: границы порций, пустой файл, metadata roundtrip, неверные ключ/автор/UUID/hash, подмена метаданных с пересчитанным внешним hash, лишние байты, alias обоих API, сохранность существующего назначения/метаданных, 23 сценария отказов I/O с контролем fd/temp, включая ошибку после rename. Для MIME проверяются все256 значений первого байта, NULL и границы поля, восемь ранних отказов writer с сохранением файлов/output, три допустимых roundtrip и семь аутентифицированных некорректных wire-полей с сохранением info/type/target. test_media_primitives проверяет оба режима temp и границу durable-публикации; test_attachment_send проверяет восемь отказов MIME до ключей/worker/файлов, затем останавливает сервис после настоящего crypto rename до completion.

test_chat_attachment проверяет 13 SQL-отказов с сохранением source/прежних строк/файлов, отсутствием worker и события до принятия, затем настоящую публикацию при каждом повторе. Отдельное readonly-соединение проверяет точные ключ, автора, UUID, метаданные и пути до worker. Дополнительно проверяются настоящий commit veto, INSERT ABORT/IGNORE, внешняя транзакция, синхронный отказ запуска, restart с опубликованным ключом и отказ rollback с закрытием БД владельцем. Дополнительно:9 SQL-отказов захвата расшифровки,15 записи успешного результата и8 записи ошибки настоящего worker. Проверяются ABORT/IGNORE, commit veto, внешняя транзакция, точные ключ/метаданные/флаги через reader перед worker и в событии, один worker при повторе completion, отмена ожидающего результата при delete/stop, restart сервиса и rollback fault без повторного таймера. 16 готовых авторских задач удерживаются при отказе чтения; семнадцатая сохраняет source, снятие отказа публикует все16 ровно по одному разу. Баланс выделений восстанавливается. Удаление проверяется14 отказами: ABORT/IGNORE каналов/трёх локальных таблиц/Merkle, prepare/bind/step/finalize удаления метаданных; дополнительно настоящий commit veto, внешняя транзакция и rollback fault с остановкой владельцем. На отказе reader видит прежние точные ключ/метаданные/request/state/created/outbox/retry, файлы сохранены; в единственном CHANNEL_DELETED — канал/история/локальные записи/Merkle уже отсутствуют. Дополнительные проверки:13 SQL-отказов привязки авторской пары,25 наблюдения новой/существующей идентичности и60 операций wanted/downloaded. Сверяются все поля через независимый reader, в том числе в событии; старые ключи/метаданные/соседняя запись сохраняются. Проверяются канонический UUID,коллизия/внешняя транзакция/commit veto/ABORT/IGNORE, повторная ошибка в отделённом batch,восстановление при startup без сети и17 каналов с fallback после заполнения очереди16. Ручной обработчик передаёт все23 подписанных блока manifest длиной3845байт в порт загрузки; отказ порта не удерживает контекст.

test_attachment_requests проверяет247 SQL-отказов/повторов новой записи и дубликатов 0/2/3/4, с пустой/существующей очередью. Подписанный мембер и сообщение создаются штатными API, локальная запись вложения — фикстурой. Readonly reader в точке ACK и события проверяет committed key/error/request/state/date/command/retry и соседний ключ. Дополнительно: ABORT/IGNORE при INSERT запроса и INSERT/UPDATE outbox, настоящий commit veto, внешняя транзакция, неверные состояние/направление/идентичность/флаги/мембер, потеря ACK из-за маршрута/backpressure/send failure и повтор с прежней датой, отказ rollback с остановкой и закрытием БД владельцем. Тот же тест проверяет52 SQL-отказа создания исходящего REQUEST с пустой/существующей outbox, ABORT/IGNORE, commit veto, внешнюю транзакцию до создания и до отправки, отсутствие повторного события/запроса, rollback fault без повторного таймера. Readonly reader видит обе строки до события и перед REQUEST; при отсутствии маршрута, backpressure и ошибке send настоящий restart сохраняет очередь и исходную дату запроса.

test_attachment_decisions проверяет116 SQL-отказов/повторов выдачи и отказа, с пустой и существующей очередью: чтение подписанного мембера, допуск автора, запись состояния, outbox, BEGIN/COMMIT. Участник имеет настоящие join/invitation подписи, сообщение сохранено штатным chat_core; запись локального вложения создаётся фикстурой. Readonly-соединение в событии проверяет committed ключ/состояние/команду/retry_at/created_at и сохранность соседнего ключа. Дополнительно: настоящий commit veto, ABORT/IGNORE для UPDATE состояния и INSERT/UPDATE очереди, внешняя транзакция, удалённый мембер, неверное направление и повторное решение, один GUI event, остановка при отказе rollback.

test_encrypted_attachment: реальные A–S–B/C, скачивание с S при выключенном A, перезапуски автора с запросами и получателя с ключом, выдача B и отказ C, отсутствие ключа на S/C, отрицательные control-пакеты, повтор GRANT, восстановление удалённого plaintext без нового согласия и удаление канала. На Linux первый настоящий REQUEST от B обрабатывается внутри внешней транзакции: проверяются её сохранность, отсутствие ACK/события/авторской записи и сохранение очереди B. После rollback владельцем и restart автора настоящий повтор REQUEST принимается; readonly reader видит сохранённый pending-запрос до отправки реального REQUEST_ACK. Первый bind hash при создании индекса на B возвращает NOMEM. В событии сообщения reader видит committed историю и отсутствие индекса,очередь восстановления уже создана; перед настоящим REQUEST reader подтверждает восстановленные UUID/hash/downloaded/wanted. На Linux пропуск GRANT outbox и внешняя транзакция автора отвергаются без изменения ожидающего запроса и без события. После restart автора повтор решения сохраняет state+outbox; отдельное readonly-соединение подтверждает их в момент единственного события UI. На Linux первый bind полученного GRANT намеренно возвращает SQLITE_NOMEM: проверяются полный откат и отсутствие ACK, сохранение GRANT у автора, затем реальная повторная выдача и расшифровка. Перед каждым RESULT_ACK получателя отдельное readonly-соединение видит ключ. test_attachment_results отдельно проверяет 105 SQL-отказов/повторов четырёх ответов, commit veto, ABORT/IGNORE для key/state/outbox, внешнюю транзакцию, backpressure, дубликаты, конфликт ключей/решений и остановку при отказе rollback. В точке ACK/события readonly-соединение проверяет committed ключ/состояние/очередь и сохранность соседней записи. При startup/restart дополнительно проверяются оба дерева: stale temp удаляются, открытый и закрытый активные temp, конечные файлы и чужие имена сохраняются.