Files
bare/docs/decisions/041-needs-rekey-is-state.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

5.8 KiB
Raw Permalink Blame History

ADR-041: Долг по ключу комнаты — состояние, а не событие

Уточняет ADR-018: «шлёт остальным событие room с needsRekey: true» дополняется признаком, который событие переживает.

Контекст

ADR-018 описывает окно без rekey как временное: «Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает». Значит, вернувшись, владелец обязан ключ сменить.

Вернуть его было нечем. needsRekey жил только в живом событии room: GET /api/rooms этого признака не нёс вовсе, в очередь событие не кладётся, а клиентский долг держался в памяти вкладки и умирал от перезагрузки. Владелец, не подключённый в ту секунду, когда участник вышел, не узнавал о долге никогда, и комната оставалась на ключе, который унёс вышедший, — до следующей смены состава, то есть, возможно, навсегда. Перезагрузка страницы у подключённого владельца давала то же самое, вместе с полосой «нужен новый ключ комнаты», которая исчезала молча.

docs/protocol.md при этом утверждает: «room и room_left в очередь не кладутся: клиент после каждого ready перечитывает GET /api/rooms… поэтому пропуск события во время офлайна ничего не ломает». Для needsRekey утверждение было ложным.

Тот же провал на втором пути. DELETE /api/me уносит участника из всех его комнат, но не шлёт оставшимся ничего: ни room с needsRekey, ни уведомления новому владельцу. По составу это тот же выход участника, что и POST /api/rooms/{id}/leave, и ADR-018 требует того же rekey. Хуже: room_keys.sender внешним ключом не защищён, поэтому текущий ключ комнаты остаётся завёрнутым исчезнувшим ником, и новое устройство оставшегося участника получить ключ уже не может.

Третье место — собственный выход. POST /api/rooms/{id}/leave шлёт room оставшимся и ничего — другим устройствам вышедшего. Они держат комнату в списке до следующего ready, то есть часами: при живом потоке ready не наступает.

Решение

  • rooms получает колонку needs_rekey (миграция 002). Ставится в 1, когда состав уменьшился, а участники остались: выход участника и удаление аккаунта. Снимается в 0 при POST /api/rooms/{id}/members — любая смена состава раздаёт новый ключ всему итоговому составу.
  • GET /api/rooms отдаёт признак полем needsRekey. Клиент-владелец поднимает долг из списка комнат так же, как из события, и потому переживает и офлайн, и перезагрузку вкладки.
  • DELETE /api/me — выход из всех комнат пользователя: оставшимся уходит event: room с needsRekey: true и их собственным текущим ключом, владение и пустые комнаты обрабатываются как при выходе (ADR-018).
  • POST /api/rooms/{id}/leave шлёт event: room_left устройствам вышедшего, кроме отправившего запрос: их состояние сходится сразу, а не к следующему ready.
  • Правятся docs/protocol.md («Типы», «Комнаты», «Аккаунт», «События») и docs/storage.md (миграция 002).

Следствия

  • Утверждение протокола про пропуск событий во время офлайна становится верным: всё, что несёт событие room, есть и в GET /api/rooms.
  • Комната не остаётся на ключе вышедшего дольше, чем владелец не заходит. Окно снова временное, как и обещает ADR-018.
  • Полоса «нужен новый ключ комнаты: подтвердите ключ @x» переживает перезагрузку: после ready владелец снова упирается в тот же неподтверждённый ключ и снова её показывает. Отдельного поля в chats для этого не нужно.
  • Признак — метаданные комнаты, серверу и так известные: он знает состав и знает, что ключ не менялся. Нового про ключи сервер не узнаёт.
  • Клиент по-прежнему решает сам, делать ли rekey: сервер только помнит, что состав уменьшился.