Files
bare/docs/decisions/039-room-key-sender-is-member.md
mayatnikovandClaude Opus 5 05586218e1 Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним
атомарным запросом с проверкой keys[].to против итогового состава; обрезка
до двух последних ключей; передача владения по joined_at; события room
каждому со своим ключом и room_left удалённым; членство и keyId в проверках
сообщения; миграция 002.

Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента;
предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой
сохранённого raw; заворачивание и разворачивание ключей комнаты, включая
себе — тем же кодом, без ветвления; расшифровка любым известным keyId;
экраны участников, создание комнаты, карточка контакта с двумя отпечатками.

ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание,
а оно происходит до запроса — создатель обязан привязать ключ к идентификатору,
которого по прежнему протоколу ещё не существовало.
ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер
подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник
незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ
сервера. Одно подменённое поле в ответе, без сговора и подмены кода.
ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец,
офлайн в момент выхода участника, не узнавал о долге никогда, и комната
навсегда оставалась на ключе, который вышедший знает.
ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при
возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой
комнаты.

Приёмка на боевом: комната на троих, добавленный четвёртый читает только
новое, вышедший после rekey новых не получает и писать не может,
keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device.
Подмена public_key в базе даёт у собеседника предупреждение и блокирует
отправку — проверено в Chrome.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-22 22:18:13 +03:00

4.2 KiB

ADR-039: Завёрнутый ключ комнаты принимается только от участника

Уточняет ADR-018 и ADR-016: у распространителя ключа комнаты появляется проверяемое условие.

Контекст

docs/crypto.md говорит про отправителя завёрнутого ключа одно: «Публичный ключ from проходит через TOFU как любой другой». Этого мало.

TOFU защищает от подмены ключа знакомого ника, а не от появления незнакомого. Первый ключ запоминается молча — так и задумано (ADR-016). Значит сервер, подставивший в Room.key запись, завёрнутую посторонним аккаунтом, получает молчаливое доверие: клиент спрашивает GET /api/users/<посторонний>, впервые видит этот ник, запоминает его ключ без предупреждения, разворачивает ключ комнаты и делает его текущим — последний полученный побеждает. Следующее сообщение уходит ключом, который знает подставивший.

Стоит это одного подменённого поля в ответе GET /api/rooms конкретному участнику. Ни подмены клиентского кода, ни сговора с участником не нужно, а на экране комната не меняется: владелец и состав приходят прежние. docs/threat-model.md обещает обратное — «дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка».

ADR-018 при этом уже называет распространителя: ключ заворачивает клиент-владелец, каждому участнику и себе. Условие есть, просто оно не проверялось.

Решение

  • Клиент отвергает завёрнутый ключ комнаты, если from не входит в состав, пришедший в том же Room. Ключ не разворачивается и не сохраняется.
  • Проверка идёт до GET /api/users/{from}: подставной ник не попадает и в TOFU, следа от него не остаётся.
  • Требовать именно владельца нельзя: владение переходит по ADR-018, и у действующих участников остаётся ключ прежнего владельца. Состав — то условие, которое переживает передачу владения.
  • Строка записана в docs/crypto.md, «Заворачивание участнику».

Следствия

  • Чтобы подсунуть ключ, серверу придётся показать подставной ник в составе комнаты. Это видно на экране участников — то есть подмена перестаёт быть невидимой, ровно как обещает модель угроз.
  • Ключ, завёрнутый ником, который успел выйти из комнаты, новое устройство участника не развернёт: комната для него остаётся без ключа до rekey, входящее показывается как «нет ключа комнаты». ADR-018 такой случай уже допускает, а долг по rekey теперь переживает офлайн (ADR-041), поэтому окно короткое.
  • Уже сохранённый ключ проверка не трогает: roomKeys заполняется один раз на keyId.