Files
utun2/doc/encrypted_attachments.md
T

28 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 имеют разные назначения: первый идентифицирует сетевые блоки, второй — контейнер и ключ. Повтор доставки передаёт прежний контейнер.

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

В подписанном сообщении 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. Квота медиакеша учитывает обе копии; очистка кеша не удаляет ключ. При повторном открытии удалённая открытая копия восстанавливается с сохранённым ключом.

После полной проверенной загрузки обычный клиент автоматически запрашивает ключ автора. Фоновая копия узла с подписанной ролью 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 ровно по одному разу. Баланс выделений восстанавливается.

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. На 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, конечные файлы и чужие имена сохраняются.