Сервер: комнаты, состав и завёрнутые ключи; смена состава и 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
3.0 KiB
ADR-037: Идентификатор комнаты генерирует клиент
Контекст
docs/crypto.md вплетает roomId в заворачивание ключа комнаты дважды: в info вывода wrapK и в AAD шифротекста. Ключ заворачивается до запроса — POST /api/rooms несёт keys[] с готовым шифротекстом.
docs/protocol.md и docs/storage.md при этом отдавали выдачу roomId серверу. Создатель комнаты обязан привязать ключ к идентификатору, которого ещё не существует.
Обойти это нечем. Ключ себе при создании — не формальность: ADR-018 требует его ровно для других устройств создателя, и без него второе устройство получает комнату с нечитаемым ключом. Эндпоинта, который дослал бы ключ после ответа, в протоколе нет; POST /api/rooms/{id}/members меняет keyId, то есть делает rekey сразу после создания — лишний круг и комната без действующего ключа в промежутке.
Решение
roomIdгенерирует клиент: 16 случайных байт base64url — какdeviceId(ADR-017) иkeyId.POST /api/roomsпринимаетid. Сервер проверяет форму, как у любого идентификатора, и отвергает занятый —409 room_conflict. Клиент берёт новый идентификатор и повторяет, как приdevice_conflict.- Слияния с существующей комнатой нет: повторный
POSTс занятымidне присоединяет и не перезаписывает. - Правятся
docs/protocol.md(тело запроса и перечень кодов) иdocs/storage.md(комментарий кrooms.id).
Следствия
- Заворачивание себе при создании привязано к настоящему
roomId, и второе устройство создателя читает комнату. - Сервер доверяет клиенту не больше прежнего: он принимает форму идентификатора и отказывает занятому.
roomIdбыл и остаётся метаданными — он открыт серверу в любом случае. Угадывание чужого идентификатора ничего не даёт: доступ проверяется поroom_members, а не по знаниюid.- Коллизия 128 случайных бит невозможна на практике;
room_conflictсуществует ради целостности, а не ради сценария.