Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
5.8 KiB
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: сервер только помнит, что состав уменьшился.