Этап 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:
@@ -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` существует ради целостности, а не ради сценария.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-038: Тексты экранов комнат и порядок полос
|
||||
|
||||
## Контекст
|
||||
|
||||
Этап 3 рисует экраны комнат по `docs/ui.md`, и в трёх местах документ описывает состояние, но слов не даёт.
|
||||
|
||||
- «Участники»: «удалить комнату» — «с подтверждением», а текста подтверждения нет. У подтверждений ADR-028 и ADR-029 свои строки записаны, у этого — нет.
|
||||
- «Карточка контакта»: у ника с `pending` показываются «оба отпечатка, старый и новый». Два блока по 64 hex подряд без пометок неразличимы, а перепутать их — подтвердить не тот ключ.
|
||||
- «Участники»: полоса «нужен новый ключ комнаты: подтвердите ключ @x» привязана к `needsRekey`. Тот же тупик даёт добавление участника: rekey не выполняется, если ключ кого-то из итогового состава не подтверждён (ADR-016), и операция обрывается до запроса. Состояние то же самое, а показать его нечем.
|
||||
|
||||
Четвёртое место — про поведение, а не про текст. ADR-033 оставил в чате одну полосу на три причины и не сказал, какая из них главная.
|
||||
|
||||
## Решение
|
||||
|
||||
- Подтверждение удаления комнаты — «комната будет удалена у всех участников.» с кнопками «удалить» и «отмена». Про историю в тексте ничего нет: она на устройствах и не трогается.
|
||||
- Отпечатки в карточке контакта помечаются «старый» и «новый».
|
||||
- Текст «нужен новый ключ комнаты: подтвердите ключ @x» показывается и тогда, когда неподтверждённый ключ обрывает добавление или удаление участника, — но строкой состояния формы, а не полосой: у отказа формы место одно, и оно под ней (ADR-028). Полоса остаётся за состоянием комнаты, строка — за неудавшимся действием. Ников бывает несколько, через запятую.
|
||||
- В чате полоса одна, и предупреждение о смене ключа перебивает отказ отправки и «нет соединения»: только оно блокирует ввод, и пока оно висит, повторять отправку всё равно нечем.
|
||||
- Строки записаны в `docs/ui.md` — разделы «Чат», «Карточка контакта», «Участники».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Экраны комнат собраны из `docs/ui.md` целиком: слов, которых нет в документе, в них не осталось.
|
||||
- Владелец, упёршийся в неподтверждённый ключ, видит одну и ту же строку независимо от того, сам он менял состав или участник вышел. Это одно состояние, и выход из него один — подтвердить ключ.
|
||||
- Порядок полос зафиксирован: три причины не спорят за одно место.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-039: Завёрнутый ключ комнаты принимается только от участника
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-016](016-key-trust-tofu.md): у распространителя ключа комнаты появляется проверяемое условие.
|
||||
|
||||
## Контекст
|
||||
|
||||
`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`.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-040: Возврат к доверенному ключу закрывает состояние pending
|
||||
|
||||
Уточняет [ADR-016](016-key-trust-tofu.md): у состояния «ключ изменился» появляется второй выход.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-016 знает одно состояние и один выход из него: ключ ника изменился, отправка блокируется до явного «доверять новому ключу». `docs/ui.md` даёт под это ровно одну кнопку.
|
||||
|
||||
Выход оказался не единственным возможным, а единственным записанным. Сервер, отдавший чужой ключ и вернувший обратно настоящий, оставляет клиент в тупике: в `pending` лежит ключ, которому доверять нельзя, а кнопка «доверять новому ключу» продвинула бы в основные именно его — то есть уже отозванную подмену. Отправка при этом заблокирована, и разблокировать её человеку нечем.
|
||||
|
||||
Клиент этапа 3 снимал `pending` сам, когда сервер снова отдавал доверенный ключ. Поведение верное, но в документах его не было, а `CLAUDE.md` и `docs/plan.md` запрещают дописывать спецификацию молча.
|
||||
|
||||
## Решение
|
||||
|
||||
- Публичный ключ, совпавший с доверенным, закрывает состояние `pending`: запись возвращается к прежнему ключу, полоса в чате и второй отпечаток в карточке контакта исчезают, отправка разблокируется.
|
||||
- Человеку об этом не сообщается: смены ключа не случилось, а состояние обещало ровно смену.
|
||||
- Кнопка «доверять новому ключу» остаётся единственным выходом там, где новый ключ никуда не делся.
|
||||
- Строка записана в `docs/ui.md`, «Карточка контакта».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Тупика нет: из состояния выходит либо человек — подтверждением, либо сам сервер — возвратом к прежнему ключу.
|
||||
- Подмена, откатившаяся до того, как человек посмотрел на экран, следа в состоянии не оставляет. След остаётся в ленте: сообщение, зашифрованное подменным ключом, так и лежит нерасшифрованным с пометкой «ключ изменился» — расшифровать его нечем, ключа подменщика у нас нет и не будет.
|
||||
- Отдельного поля «здесь была подмена» не заводится: `peers` держит доверие, а не журнал. Журнал подмен — отдельное решение, если понадобится.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-041: Долг по ключу комнаты — состояние, а не событие
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md): «шлёт остальным событие `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: сервер только помнит, что состав уменьшился.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-042: Порядок ключей комнаты и «текущий ключ»
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера».
|
||||
|
||||
## Контекст
|
||||
|
||||
На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным.
|
||||
|
||||
Код этапа 3 это починил: сервер поднимает время нового ключа до `последний + 1`, а при равенстве доопределяет порядок по `key_id`; клиент делает то же со своим `receivedAt`. Инвариант несущий, а записан был только комментариями в коде.
|
||||
|
||||
Второе: порядка сервера клиент не знает и знать не может. В `Room.key` приходит один текущий ключ без номера и без времени, так что клиент считает текущим тот, который получил последним. В гонке двух rekey с разных устройств владельца эти порядки расходятся: устройство, чей ответ пришёл раньше события соседнего, считает текущим свой ключ, а сервер — чужой.
|
||||
|
||||
## Решение
|
||||
|
||||
- Инвариант записывается в `docs/storage.md`: время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id`. То же — про клиентский `roomKeys.receivedAt`.
|
||||
- Клиент держит порядок получения, а не порядок сервера. Это осознанный предел: номера ключа в протоколе нет и не заводится.
|
||||
- Расхождение безвредно, пока оба ключа живы, а живы они, пока комната держит два последних `keyId`. Сообщение, отправленное ключом, который сервер уже обрезал, получает `400 unknown_key` и хоронится как `failed` (ADR-033).
|
||||
|
||||
## Следствия
|
||||
|
||||
- Обрезка до двух `keyId` не выбрасывает ничей текущий ключ: самый свежий `key_id` есть у каждого участника (иначе `keys_mismatch`), и он остаётся всегда.
|
||||
- `created_at` в `room_keys` перестаёт быть в точности «миллисекундами Unix»: у двух rekey в одну миллисекунду второе время сдвинуто вперёд. Это записано рядом с колонкой.
|
||||
- Порядковый номер ключа в `Room.key` — возможное расширение отдельным ADR, если расхождение порядков когда-нибудь окажется дорогим.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-043: Форма запроса проверяется раньше прав
|
||||
|
||||
Уточняет [ADR-026](026-protocol-error-codes.md): перечень кодов исчерпывающий, значит и порядок их выдачи должен быть записан.
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/protocol.md` перечисляет проверки `POST /api/rooms/{id}/members` в одном порядке — «Только владелец (`403 not_owner`). Проверки: все `add` существуют…», — а сервер отвечает в другом: форму ников и завёрнутых ключей он разбирает до обращения к хранилищу, то есть до проверки владения. Порядок наружу виден: не владелец с кривым ником в `add` получал не `not_owner`.
|
||||
|
||||
Иначе и не сделать: чтобы спросить хранилище о правах, запрос сначала надо разобрать. Утечки в этом нет — проверка чисто синтаксическая и о комнате ничего не сообщает.
|
||||
|
||||
Два кода при этом расходились с документом. Повтор ника в `keys[].to` отвечал `400 invalid`, хотя множество `keys[].to` составу в этом случае не равно и протокол называет `400 keys_mismatch`. Ошибка формы ника в `add` отвечала `404 unknown_user`, а та же ошибка в `remove` — `400 invalid`: один класс входа, два разных ответа.
|
||||
|
||||
## Решение
|
||||
|
||||
- Форма запроса проверяется раньше прав и раньше существования сущностей. Записано строкой в «Общих правилах» `docs/protocol.md`: `400 bad_json`, `413 too_large` и `400 invalid` приходят и на запрос, который отвергли бы и по правам.
|
||||
- Повтор ника в `keys[].to` — `400 keys_mismatch`, как и любое другое несовпадение с итоговым составом.
|
||||
- Ник неверной формы в `add` — `400 invalid` с полем `add`, как и в `remove`. Несуществующий ник верной формы остаётся `404 unknown_user`.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Перечень кодов остаётся исчерпывающим, а порядок их выдачи — записанным, а не выведенным из чтения кода.
|
||||
- Снаружи по ответу видно, что запрос разобран, но не видно ничего о комнате: `403 not_owner` одинаков и для чужой комнаты, и для несуществующей.
|
||||
- Клиенту разница не важна: свои ники он приводит к форме ADR-019 до запроса.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-044: Экран комнаты, которой у нас больше нет
|
||||
|
||||
Дополняет [ADR-038](038-room-screens-texts.md): у чата появляется четвёртая причина для полосы.
|
||||
|
||||
## Контекст
|
||||
|
||||
Комната уходит из списка тремя путями: человек вышел сам, владелец его убрал, владелец удалил комнату. Открытый экран чата при этом оставался рабочим: лента, поле ввода и кнопка `>` на месте. Отправка доходила до сервера, получала `403 not_member` и садилась как `failed` с текстом «сервер не справился, попробуйте позже» — то есть человеку сообщали, что виноват сервер, тогда как он просто больше не участник. «Повторить» в этом состоянии не срабатывает никогда.
|
||||
|
||||
`docs/ui.md` этого состояния не описывает вовсе, хотя приходит оно и без действий человека: событие `room_left` застаёт его в открытом чате.
|
||||
|
||||
## Решение
|
||||
|
||||
- Чат комнаты, из состава которой нас больше нет, показывает полосу над вводом цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован — и поле, и кнопка `>`.
|
||||
- Полоса перебивает отказ отправки и «нет соединения» на тех же основаниях, что и предупреждение о ключе (ADR-038): она блокирует ввод, и пока она висит, повторять отправку всё равно нечем.
|
||||
- Лента остаётся на месте и остаётся читаемой: история на устройстве — единственная копия, и она не трогается.
|
||||
- Строка записана в `docs/ui.md`, «Чат».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Причин у полосы в чате становится четыре, показывается по-прежнему одна. Порядок: не участник, ключ изменился, отказ отправки, нет соединения.
|
||||
- Отдельного текста для `403 not_member` не заводится: до сервера отправка из такого чата больше не доходит.
|
||||
- Экран участников покинутой комнаты отдельного состояния не получает: состав там пустеет сам, а «выйти из комнаты» и «удалить комнату» отвечают тем же, чем и раньше.
|
||||
Reference in New Issue
Block a user