Этап 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
This commit is contained in:
2026-08-22 22:18:13 +03:00
co-authored by Claude Opus 5
parent 63a7a1ef52
commit 05586218e1
38 changed files with 4692 additions and 201 deletions
+23
View File
@@ -0,0 +1,23 @@
# 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` существует ради целостности, а не ради сценария.