diff --git a/docs/crypto.md b/docs/crypto.md index 65321cb..63ac956 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -78,6 +78,8 @@ ct = AES-GCM(wrapK, iv, roomKey, AAD = utf8("bare-roomkey-v1|" + roomId + "| Публичный ключ `from` проходит через TOFU как любой другой. Заворачивание самому себе — `ECDH(myPrivate, myPublic)`, без исключений в коде. +Ключ, чей `from` не входит в состав комнаты, пришедший тем же `Room`, отвергается до запроса публичного ключа: TOFU запоминает первый ключ ника молча, поэтому незнакомый распространитель — это подмена, а не первый контакт (ADR-039). + ## Сообщение ``` diff --git a/docs/decisions/037-client-room-id.md b/docs/decisions/037-client-room-id.md new file mode 100644 index 0000000..6a4b60d --- /dev/null +++ b/docs/decisions/037-client-room-id.md @@ -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` существует ради целостности, а не ради сценария. diff --git a/docs/decisions/038-room-screens-texts.md b/docs/decisions/038-room-screens-texts.md new file mode 100644 index 0000000..646b2f5 --- /dev/null +++ b/docs/decisions/038-room-screens-texts.md @@ -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` целиком: слов, которых нет в документе, в них не осталось. +- Владелец, упёршийся в неподтверждённый ключ, видит одну и ту же строку независимо от того, сам он менял состав или участник вышел. Это одно состояние, и выход из него один — подтвердить ключ. +- Порядок полос зафиксирован: три причины не спорят за одно место. diff --git a/docs/decisions/039-room-key-sender-is-member.md b/docs/decisions/039-room-key-sender-is-member.md new file mode 100644 index 0000000..f21ef39 --- /dev/null +++ b/docs/decisions/039-room-key-sender-is-member.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`. diff --git a/docs/decisions/040-trusted-key-returned-clears-pending.md b/docs/decisions/040-trusted-key-returned-clears-pending.md new file mode 100644 index 0000000..65ef4ab --- /dev/null +++ b/docs/decisions/040-trusted-key-returned-clears-pending.md @@ -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` держит доверие, а не журнал. Журнал подмен — отдельное решение, если понадобится. diff --git a/docs/decisions/041-needs-rekey-is-state.md b/docs/decisions/041-needs-rekey-is-state.md new file mode 100644 index 0000000..c829a01 --- /dev/null +++ b/docs/decisions/041-needs-rekey-is-state.md @@ -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: сервер только помнит, что состав уменьшился. diff --git a/docs/decisions/042-current-room-key-order.md b/docs/decisions/042-current-room-key-order.md new file mode 100644 index 0000000..7b06415 --- /dev/null +++ b/docs/decisions/042-current-room-key-order.md @@ -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, если расхождение порядков когда-нибудь окажется дорогим. diff --git a/docs/decisions/043-form-before-rights.md b/docs/decisions/043-form-before-rights.md new file mode 100644 index 0000000..309d081 --- /dev/null +++ b/docs/decisions/043-form-before-rights.md @@ -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 до запроса. diff --git a/docs/decisions/044-left-room-screen.md b/docs/decisions/044-left-room-screen.md new file mode 100644 index 0000000..20dea39 --- /dev/null +++ b/docs/decisions/044-left-room-screen.md @@ -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` не заводится: до сервера отправка из такого чата больше не доходит. +- Экран участников покинутой комнаты отдельного состояния не получает: состав там пустеет сам, а «выйти из комнаты» и «удалить комнату» отвечают тем же, чем и раньше. diff --git a/docs/protocol.md b/docs/protocol.md index c2aee13..ab9c577 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,6 +10,7 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043). - Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027). - Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). @@ -31,7 +32,7 @@ Room { members: nick[], // по joined_at createdAt: number, key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего - needsRekey: boolean // только в событии после выхода участника + needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041) } WrappedKey { to: nick, iv: string, ct: string } @@ -55,7 +56,7 @@ WrappedKey { to: nick, iv: string, ct: string } `POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}` → `204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей. -`DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. +`DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. Удаление аккаунта — выход из всех его комнат: оставшимся участникам уходит `event: room` с `needsRekey: true`, каждому со своим ключом (ADR-041). `GET /api/users/{nick}` → `200 {nick, publicKey}` | `404 unknown_user`. @@ -110,25 +111,25 @@ event: room_left data: {id} // получателя удалили ил event: ready data: {} ``` -`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. +`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey`, есть и в `GET /api/rooms` (ADR-041). `id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления. ## Комнаты -`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом. +`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). -`POST /api/rooms {name, keyId, keys: WrappedKey[]}` → `201 Room`. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. +`POST /api/rooms {id, name, keyId, keys: WrappedKey[]}` → `201 Room`. `id` — 16 случайных байт base64url, генерирует клиент (ADR-037): ключ комнаты заворачивается до запроса и привязан к идентификатору. Занятый `id` — `409 room_conflict`, клиент берёт новый. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. -`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`). Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. +`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. -`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`. +`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041). `DELETE /api/rooms/{id}` → `204`. Только владелец. Всем участникам — `event: room_left`. ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `room_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. ## Статика и служебное diff --git a/docs/storage.md b/docs/storage.md index 51868ba..e0d0d00 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -44,7 +44,7 @@ CREATE TABLE contacts ( ); CREATE TABLE rooms ( - id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент name TEXT NOT NULL, owner TEXT NOT NULL REFERENCES users(nick), created_at INTEGER NOT NULL @@ -79,9 +79,19 @@ CREATE TABLE queue ( CREATE INDEX queue_created ON queue(created_at); ``` +### Миграция 002 + +```sql +ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0; +``` + +Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`. + Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. -Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает комнаты, где он владелец (передача или удаление), остальное — каскад. +Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен. + +Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает его комнаты — убирает членство и ключи, передаёт владение или удаляет опустевшую комнату, ставит `needs_rekey` там, где участники остались (ADR-041), — остальное уносит каскад. ### Фоновая чистка, раз в час @@ -119,6 +129,8 @@ messages key: id (ULID) roomKeys key: [roomId, keyId] {roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt} + // receivedAt строго больше receivedAt всех прежних ключей той же комнаты; + // текущий ключ — последний по нему, то есть в порядке получения (ADR-042) peers key: nick {nick, publicKey: JWK, fingerprint, firstSeen, diff --git a/docs/ui.md b/docs/ui.md index ad289ff..d1716c0 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -36,7 +36,9 @@ Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. -Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. +Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038). + +Комната, из состава которой нас больше нет (вышли сами, убрал владелец, комната удалена), — та же полоса цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован, лента остаётся. Эта полоса перебивает и предупреждение о ключе (ADR-044). Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется. @@ -44,11 +46,13 @@ ## Карточка контакта (`#/contact/`) -`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка, старый и новый, кнопка «доверять новому ключу». Кнопка «убрать из списка». +`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка с пометками «старый» и «новый» и кнопка «доверять новому ключу». Кнопка «убрать из списка». + +`pending` снимает и сервер, снова отдавший доверенный ключ: смены ключа не случилось, состояние закрывается само и молча (ADR-040). ## Участники (`#/room//members`) -Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением. Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». +Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038). ## Настройки (`#/settings`) diff --git a/internal/api/account.go b/internal/api/account.go index 769b509..5ee8fa8 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -256,13 +256,19 @@ func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) { if !s.confirm(w, in.AuthKey, u) { return } - // Устройства, сессии, контакты, членство и очереди уносит каскад. - // Комнаты, где пользователь владелец, требуют передачи владения - // (ADR-018) — это этап 3, до появления комнат случай не наступает. - if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil { + // Устройства, сессии, контакты, членство, ключи комнат и очереди уносит + // каскад; комнаты, где пользователь владелец, меняют владельца или + // удаляются пустыми (ADR-018) — всё в одной транзакции хранилища. + changes, err := s.st.DeleteUser(r.Context(), u.Nick) + if err != nil { s.internal(w, r, err) return } + // Удаление аккаунта — выход из всех его комнат: оставшимся уходит room + // с needsRekey, каждому со своим ключом (ADR-041). + for _, change := range changes { + s.sendRoom(r, change, "") + } auth.ClearCookie(w) noContent(w) } diff --git a/internal/api/api.go b/internal/api/api.go index cdc9cba..aefbccc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -84,6 +84,12 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact))) + mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms))) + mux.Handle("POST /api/rooms", private(http.HandlerFunc(s.createRoom))) + mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers))) + mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom))) + mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom))) + mux.Handle("GET /api/events", private(http.HandlerFunc(s.events))) mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage))) mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack))) diff --git a/internal/api/devices.go b/internal/api/devices.go index 7287e75..76e0788 100644 --- a/internal/api/devices.go +++ b/internal/api/devices.go @@ -115,6 +115,18 @@ func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) { return id, true } +// optionalDevice — то же для маршрутов, где заголовок необязателен: +// он всего лишь просит не возвращать эхо отправившему устройству. Пустой +// X-Device — пусто, непустой обязан быть своим устройством: правило +// принадлежности общее для всех маршрутов, где устройство важно +// (docs/protocol.md, «Общие правила»). +func (s *server) optionalDevice(w http.ResponseWriter, r *http.Request) (string, bool) { + if r.Header.Get("X-Device") == "" { + return "", true + } + return s.device(w, r) +} + func unknownDevice(w http.ResponseWriter) { Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше") } diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go index 9cc7911..21cc5b0 100644 --- a/internal/api/devices_test.go +++ b/internal/api/devices_test.go @@ -151,6 +151,36 @@ func TestForeignDevice(t *testing.T) { // Со своим устройством — обычная отправка. expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"), with(petya), withDevice(petyaDevice)), http.StatusAccepted, "") + + // Комнаты: заголовок здесь необязателен — он всего лишь просит не слать + // событие отправившему устройству, — но принадлежность проверяется + // та же (docs/protocol.md, «Общие правила», «Комнаты»). + room := map[string]any{ + "id": roomIDOf(40), + "name": "общая", + "keyId": keyID(40), + "keys": keysFor([]string{"petya"}, 40), + } + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(deviceOf(9))), + http.StatusForbidden, "unknown_device") + // Отказ ничего не создал: идентификатор комнаты свободен. + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya)), http.StatusCreated, "") + + leave := "/api/rooms/" + roomIDOf(40) + "/leave" + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + // Отказ ничего не изменил: из комнаты никто не вышел. + if got := e.room(petya, roomIDOf(40)); got == nil { + t.Fatal("комната пропала после отказа по устройству") + } + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(petyaDevice)), + http.StatusNoContent, "") } // Удаление устройства уносит очередь и сессии устройства. diff --git a/internal/api/messages.go b/internal/api/messages.go index ab56c50..929d5c1 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -74,14 +74,23 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { return } - if in.To.Room != "" { - // Комнаты — этап 3. Участников нет ни у одной комнаты, потому что - // нет и самих комнат: единственный возможный ответ — not_member. - Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") - return - } sess, _ := auth.From(r) - if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { + room := in.To.Room != "" + if room { + member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) + if err != nil { + s.internal(w, r, err) + return + } + if !member { + Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") + return + } + if !knownKey { + Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа") + return + } + } else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { return } @@ -92,7 +101,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { env := envelope{ ID: in.ID, - To: target{DM: in.To.DM}, + To: target{DM: in.To.DM, Room: in.To.Room}, From: sess.Nick, KeyID: in.KeyID, IV: in.IV, @@ -104,14 +113,21 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { s.internal(w, r, err) return } - devices, err := s.st.DeliverDM(r.Context(), store.Delivery{ + delivery := store.Delivery{ From: env.From, To: env.To.DM, + Room: env.To.Room, Exclude: device, MsgID: env.ID, Envelope: string(raw), Now: env.TS, - }) + } + var devices []string + if room { + devices, err = s.st.DeliverRoom(r.Context(), delivery) + } else { + devices, err = s.st.DeliverDM(r.Context(), delivery) + } if err != nil { s.internal(w, r, err) return diff --git a/internal/api/rooms.go b/internal/api/rooms.go new file mode 100644 index 0000000..5e884cd --- /dev/null +++ b/internal/api/rooms.go @@ -0,0 +1,385 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + "unicode/utf8" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/store" +) + +// Комнаты (ADR-018): владелец меняет состав, ключи заворачивают клиенты. +// Сервер проверяет форму и права, хранит шифротекст и раздаёт события. + +// maxRoomName — имя комнаты, символов (ADR-021). Имя открыто: это +// метаданные, как и состав. +const maxRoomName = 64 + +// roomOut — тип Room из docs/protocol.md. key присутствует всегда, +// пустой — null; needsRekey — состояние комнаты, а не свойство события, +// поэтому идёт и в списке, и в событии (ADR-041). +type roomOut struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Members []string `json:"members"` + CreatedAt int64 `json:"createdAt"` + Key *keyOut `json:"key"` + NeedsRekey bool `json:"needsRekey"` +} + +// keyOut — завёрнутый ключ комнаты для того, кто его получает. +type keyOut struct { + KeyID string `json:"keyId"` + From string `json:"from"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// keyIn — запись keys[] запроса: WrappedKey из docs/protocol.md. +type keyIn struct { + To string `json:"to"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// GET /api/rooms — комнаты, где пользователь участник, каждая с его +// текущим ключом и признаком needsRekey: владелец, пропустивший событие, +// поднимает долг по ключу отсюда (ADR-041). +func (s *server) rooms(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Rooms(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + out := make([]roomOut, 0, len(list)) + for _, room := range list { + out = append(out, roomJSON(room, room.Key)) + } + writeJSON(w, http.StatusOK, out) +} + +// POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент +// (ADR-037), ключ приходит ровно один и заворачивается создателем себе: +// его другие устройства получают комнату вместе с ключом (ADR-018). +func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { + // X-Device здесь необязателен, но чужой и кривой — 403, как и везде, + // где устройство важно (docs/protocol.md, «Общие правила»). + device, ok := s.optionalDevice(w, r) + if !ok { + return + } + var in struct { + ID string `json:"id"` + Name string `json:"name"` + KeyID string `json:"keyId"` + Keys []keyIn `json:"keys"` + } + if !decode(w, r, &in) { + return + } + // Идентификатор комнаты генерирует клиент: ключ заворачивается до + // запроса и привязан к roomId в info и AAD (ADR-037). + if !validID(in.ID) { + Invalid(w, "id", "id комнаты — не 16 байт base64url") + return + } + if !validRoomName(in.Name) { + Invalid(w, "name", "имя комнаты: 1–64 символа") + return + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return + } + keys, ok := wrappedKeys(w, in.Keys) + if !ok { + return + } + sess, _ := auth.From(r) + // Состав новой комнаты — один создатель, поэтому и ключ ровно один. + // Несовпадение — то же самое, что при rekey: keys не по составу. + if len(keys) != 1 || keys[0].To != sess.Nick { + keysMismatch(w) + return + } + change, err := s.st.CreateRoom(r.Context(), store.NewRoom{ + ID: in.ID, + Name: in.Name, + Owner: sess.Nick, + KeyID: in.KeyID, + Key: keys[0], + Now: time.Now().UnixMilli(), + }) + if errors.Is(err, store.ErrRoomExists) { + // Занятый идентификатор не присоединяет к чужой комнате и не + // перезаписывает свою: клиент берёт новый (ADR-037). + Error(w, http.StatusConflict, "room_conflict", "такая комната уже есть") + return + } + if err != nil { + s.internal(w, r, err) + return + } + // Комната уже записана: остальным устройствам создателя она уходит + // событием, отправившему — ответом на запрос. + s.sendRoom(r, change, device) + writeJSON(w, http.StatusCreated, roomJSON(change.Room, keyFor(change, sess.Nick))) +} + +// POST /api/rooms/{id}/members — смена состава и rekey одним запросом +// (ADR-018). Пустые add и remove — чистый rekey. +func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) { + var in struct { + Add []string `json:"add"` + Remove []string `json:"remove"` + KeyID string `json:"keyId"` + Keys []keyIn `json:"keys"` + } + if !decode(w, r, &in) { + return + } + add, ok := uniqueNicks(in.Add) + if !ok { + // Форма — это форма: несуществующий ник верной формы отвечает + // unknown_user, а ник не по форме — invalid, как и в remove + // (ADR-043). + Invalid(w, "add", "добавить можно только ник a–z, 0–9, _") + return + } + remove, ok := uniqueNicks(in.Remove) + if !ok { + Invalid(w, "remove", "убрать можно только участника комнаты") + return + } + for _, nick := range remove { + for _, other := range add { + if nick == other { + Invalid(w, "remove", "один ник нельзя добавить и убрать одним запросом") + return + } + } + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return + } + keys, ok := wrappedKeys(w, in.Keys) + if !ok { + return + } + sess, _ := auth.From(r) + change, err := s.st.UpdateMembers(r.Context(), store.MembersChange{ + RoomID: r.PathValue("id"), + Owner: sess.Nick, + Add: add, + Remove: remove, + KeyID: in.KeyID, + Keys: keys, + Now: time.Now().UnixMilli(), + }) + if err != nil { + s.roomError(w, r, err) + return + } + // Событие room уходит и участникам, и — как room_left — убранным; + // каждому участнику со своим ключом (docs/protocol.md, «Комнаты»). + s.sendRoom(r, change, "") + writeJSON(w, http.StatusOK, roomJSON(change.Room, keyFor(change, sess.Nick))) +} + +// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит +// участнику с наименьшим joined_at, опустевшая комната удаляется; +// оставшимся уходит room с needsRekey (ADR-018), другим устройствам +// вышедшего — room_left (ADR-041). +// +// Не участник и несуществующая комната отвечают тем же 204: выходить +// неоткуда, а отдельного кода на этот случай в протоколе нет. +func (s *server) leaveRoom(w http.ResponseWriter, r *http.Request) { + // Заголовок необязателен, но чужой и кривой — 403, как и везде, + // где устройство важно (docs/protocol.md, «Общие правила»). + device, ok := s.optionalDevice(w, r) + if !ok { + return + } + sess, _ := auth.From(r) + change, err := s.st.LeaveRoom(r.Context(), r.PathValue("id"), sess.Nick) + if errors.Is(err, store.ErrNotFound) { + noContent(w) + return + } + if err != nil { + s.internal(w, r, err) + return + } + s.sendRoom(r, change, device) + noContent(w) +} + +// DELETE /api/rooms/{id} — удаление комнаты владельцем. Всем участникам, +// включая его самого, уходит room_left. +func (s *server) deleteRoom(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + change, err := s.st.DeleteRoom(r.Context(), r.PathValue("id"), sess.Nick) + if err != nil { + s.roomError(w, r, err) + return + } + s.sendRoom(r, change, "") + noContent(w) +} + +// roomError переводит отказы хранилища в коды протокола +// (docs/protocol.md, «Комнаты»). +func (s *server) roomError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, store.ErrNotOwner): + Error(w, http.StatusForbidden, "not_owner", "комнату меняет её владелец") + case errors.Is(err, store.ErrUnknownUser): + unknownUser(w) + case errors.Is(err, store.ErrNotMember): + Invalid(w, "remove", "убрать можно только участника комнаты") + case errors.Is(err, store.ErrOwnerRemoval): + Error(w, http.StatusBadRequest, "owner", "владельца убрать нельзя") + case errors.Is(err, store.ErrKeyExists): + Error(w, http.StatusConflict, "key_exists", "такой ключ у комнаты уже был") + case errors.Is(err, store.ErrKeysMismatch): + keysMismatch(w) + default: + s.internal(w, r, err) + } +} + +func keysMismatch(w http.ResponseWriter) { + Error(w, http.StatusBadRequest, "keys_mismatch", "ключи не совпадают с составом комнаты") +} + +// sendRoom раздаёт события изменившейся комнаты: room участникам, каждому +// с его собственным ключом, и room_left выбывшим. exclude — устройство, +// которому событие не нужно; пусто — нужно всем. +// +// Событие в очередь не кладётся: клиент после каждого ready перечитывает +// GET /api/rooms, а всё, что несёт room, включая needsRekey, есть и там, +// поэтому пропуск во время офлайна ничего не ломает (docs/protocol.md, +// «События», ADR-041). +func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) { + for _, member := range change.Members { + raw, err := json.Marshal(roomJSON(change.Room, member.Key)) + if err != nil { + s.report(r, err) + continue + } + s.send(member.Devices, exclude, hub.Event{Name: "room", Data: string(raw)}) + } + if len(change.Left) == 0 { + return + } + raw, err := json.Marshal(struct { + ID string `json:"id"` + }{change.Room.ID}) + if err != nil { + s.report(r, err) + return + } + for _, gone := range change.Left { + s.send(gone.Devices, exclude, hub.Event{Name: "room_left", Data: string(raw)}) + } +} + +// send отдаёт событие подключённым устройствам, кроме exclude. +func (s *server) send(devices []string, exclude string, ev hub.Event) { + for _, device := range devices { + if device == exclude { + continue + } + s.hub.Send(device, ev) + } +} + +// roomJSON собирает Room протокола: состав всегда список, ключ — null, +// если его нет. +func roomJSON(room store.Room, key *store.RoomKey) roomOut { + out := roomOut{ + ID: room.ID, + Name: room.Name, + Owner: room.Owner, + Members: room.Members, + CreatedAt: room.CreatedAt, + NeedsRekey: room.NeedsRekey, + } + if out.Members == nil { + out.Members = []string{} + } + if key != nil { + out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT} + } + return out +} + +// keyFor — ключ участника в итоге изменения: у каждого он свой. +func keyFor(change store.RoomChange, nick string) *store.RoomKey { + for _, member := range change.Members { + if member.Nick == nick { + return member.Key + } + } + return nil +} + +// wrappedKeys проверяет форму завёрнутых ключей. Содержимое сервер +// не проверяет и проверить не может: это шифротекст (ADR-018). +func wrappedKeys(w http.ResponseWriter, in []keyIn) ([]store.WrappedKey, bool) { + out := make([]store.WrappedKey, 0, len(in)) + seen := make(map[string]bool, len(in)) + for _, k := range in { + if !validNick(k.To) { + Invalid(w, "keys", "keys[].to — не ник") + return nil, false + } + if seen[k.To] { + // Два ключа одному участнику — это множество keys[].to, + // не равное составу, а не отдельный отказ (ADR-043). + keysMismatch(w) + return nil, false + } + seen[k.To] = true + if _, ok := decodeExactly(k.IV, ivLen); !ok { + Invalid(w, "keys", "iv — не 12 байт base64url") + return nil, false + } + if ct, err := b64.DecodeString(k.CT); err != nil || len(ct) < minCTLen { + Invalid(w, "keys", "ct — не base64url или слишком короткий") + return nil, false + } + out = append(out, store.WrappedKey{To: k.To, IV: k.IV, CT: k.CT}) + } + return out, true +} + +// uniqueNicks разбирает список ников запроса: повторы схлопываются, +// порядок сохраняется. Второе значение — прошёл ли список проверку формы. +func uniqueNicks(list []string) ([]string, bool) { + out := make([]string, 0, len(list)) + seen := make(map[string]bool, len(list)) + for _, nick := range list { + if !validNick(nick) { + return nil, false + } + if seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + return out, true +} + +// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021). +func validRoomName(name string) bool { + return name != "" && utf8.RuneCountInString(name) <= maxRoomName +} diff --git a/internal/api/rooms_test.go b/internal/api/rooms_test.go new file mode 100644 index 0000000..ec6379f --- /dev/null +++ b/internal/api/rooms_test.go @@ -0,0 +1,1037 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" +) + +// roomBody — тип Room из docs/protocol.md, как его видит клиент. +type roomBody struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Members []string `json:"members"` + CreatedAt int64 `json:"createdAt"` + Key *keyBody `json:"key"` + NeedsRekey bool `json:"needsRekey"` +} + +type keyBody struct { + KeyID string `json:"keyId"` + From string `json:"from"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// keyID — идентификатор ключа комнаты: 16 байт base64url (docs/crypto.md). +func keyID(seed byte) string { return bytesOf(16, seed) } + +// roomIDOf — идентификатор комнаты: его генерирует клиент (ADR-037). +func roomIDOf(seed byte) string { return bytesOf(16, seed+100) } + +// wrapped — завёрнутый ключ участнику. Порядковый номер входит в «шифротекст»: +// по нему видно, что каждому ушёл его собственный ключ. Содержимое сервер +// не проверяет и проверить не может. +func wrapped(to string, seed byte, nth int) map[string]any { + return map[string]any{"to": to, "iv": ivOf(seed, nth), "ct": ctOf(seed, nth)} +} + +func ivOf(seed byte, nth int) string { return bytesOf(12, seed+byte(nth)) } +func ctOf(seed byte, nth int) string { return bytesOf(48, seed+byte(nth)) } +func keysFor(to []string, seed byte) []any { + out := make([]any, 0, len(to)) + for i, nick := range to { + out = append(out, wrapped(nick, seed, i)) + } + return out +} + +// makeRoom заводит комнату: состав — один создатель, ключ ровно один. +func (e *env) makeRoom(c *http.Cookie, owner, name string, seed byte, opts ...func(*http.Request)) roomBody { + e.t.Helper() + body := map[string]any{ + "id": roomIDOf(seed), + "name": name, + "keyId": keyID(seed), + "keys": keysFor([]string{owner}, seed), + } + rec := e.do(http.MethodPost, "/api/rooms", body, append([]func(*http.Request){with(c)}, opts...)...) + expect(e.t, rec, http.StatusCreated, "") + var room roomBody + decodeBody(e.t, rec, &room) + return room +} + +// changeMembers — смена состава и rekey одним запросом: to — итоговый +// состав, которому заворачивается новый ключ. +func (e *env) changeMembers(c *http.Cookie, id string, add, remove, to []string, seed byte) *httptest.ResponseRecorder { + e.t.Helper() + body := map[string]any{ + "add": add, + "remove": remove, + "keyId": keyID(seed), + "keys": keysFor(to, seed), + } + return e.do(http.MethodPost, "/api/rooms/"+id+"/members", body, with(c)) +} + +func (e *env) rooms(c *http.Cookie) []roomBody { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/rooms", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var out []roomBody + decodeBody(e.t, rec, &out) + return out +} + +// room — комната из списка; nil, если её там нет. +func (e *env) room(c *http.Cookie, id string) *roomBody { + e.t.Helper() + for _, got := range e.rooms(c) { + if got.ID == id { + room := got + return &room + } + } + return nil +} + +// roomMessage — тело POST /api/messages в комнату. +func roomMessage(id, room, key string) map[string]any { + return map[string]any{ + "id": id, + "to": map[string]string{"room": room}, + "keyId": key, + "iv": bytesOf(12, 21), + "ct": bytesOf(48, 23), + } +} + +// nicks — состав как множество: joined_at у сервера в миллисекундах, +// и две операции подряд попадают в одну и ту же. Порядок по joined_at +// проверяет TestMembersJoinOrder, где операции разведены во времени. +func nicks(members []string) string { + sorted := append([]string(nil), members...) + sort.Strings(sorted) + return strings.Join(sorted, ",") +} + +// tick разводит операции по разным миллисекундам. +func tick() { time.Sleep(2 * time.Millisecond) } + +// field достаёт поле, на котором остановилась валидация. +func field(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Field string `json:"field"` + } + decodeBody(t, rec, &body) + return body.Field +} + +func TestCreateRoom(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + + room := e.makeRoom(marta, "marta", "общая", 40) + if room.ID != roomIDOf(40) { + t.Errorf("id комнаты: получено %q, ожидалось %q", room.ID, roomIDOf(40)) + } + if room.Name != "общая" || room.Owner != "marta" || room.CreatedAt == 0 { + t.Errorf("комната: %+v", room) + } + if len(room.Members) != 1 || room.Members[0] != "marta" { + t.Errorf("состав: %v", room.Members) + } + if room.NeedsRekey { + t.Error("needsRekey в ответе на создание") + } + if room.Key == nil || room.Key.KeyID != keyID(40) || room.Key.From != "marta" || + room.Key.IV != ivOf(40, 0) || room.Key.CT != ctOf(40, 0) { + t.Errorf("ключ: %+v", room.Key) + } + + // Та же комната приходит списком, с тем же ключом. + list := e.rooms(marta) + if len(list) != 1 { + t.Fatalf("комнат: получено %d, ожидалась 1", len(list)) + } + if list[0].ID != room.ID || list[0].Key == nil || list[0].Key.CT != ctOf(40, 0) { + t.Errorf("список комнат: %+v", list[0]) + } + + // Вторая комната — другой идентификатор. + other := e.makeRoom(marta, "marta", "вторая", 60) + if other.ID == room.ID { + t.Error("идентификаторы комнат совпали") + } + if got := e.rooms(marta); len(got) != 2 { + t.Errorf("комнат: получено %d, ожидалось 2", len(got)) + } + // Чужому комната не видна. + petya, _ := e.join("petya", 2) + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("чужие комнаты: %+v", got) + } +} + +// Занятый идентификатор комнаты не присоединяет к чужой и не перезаписывает +// свою: клиент берёт новый (ADR-037). +func TestCreateRoomConflict(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + + taken := func(c *http.Cookie, owner string) { + t.Helper() + body := map[string]any{ + "id": room.ID, + "name": "чужая", + "keyId": keyID(60), + "keys": keysFor([]string{owner}, 60), + } + expect(t, e.do(http.MethodPost, "/api/rooms", body, with(c)), http.StatusConflict, "room_conflict") + } + taken(petya, "petya") + taken(marta, "marta") + + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("занятый id присоединил к чужой комнате: %+v", got) + } + list := e.rooms(marta) + if len(list) != 1 || list[0].Name != "общая" || list[0].Key == nil || list[0].Key.KeyID != keyID(40) { + t.Errorf("занятый id тронул существующую комнату: %+v", list) + } +} + +func TestCreateRoomRejects(t *testing.T) { + cases := []struct { + name string + change func(map[string]any) + status int + code string + field string + }{ + {"нет id", func(m map[string]any) { delete(m, "id") }, http.StatusBadRequest, "invalid", "id"}, + {"кривой id", func(m map[string]any) { m["id"] = "room-1" }, http.StatusBadRequest, "invalid", "id"}, + {"пустое имя", func(m map[string]any) { m["name"] = "" }, http.StatusBadRequest, "invalid", "name"}, + {"имя длиннее 64", func(m map[string]any) { + m["name"] = strings.Repeat("я", 65) + }, http.StatusBadRequest, "invalid", "name"}, + {"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"}, + {"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""}, + {"ключ чужому", func(m map[string]any) { + m["keys"] = keysFor([]string{"petya"}, 40) + }, http.StatusBadRequest, "keys_mismatch", ""}, + {"два ключа", func(m map[string]any) { + m["keys"] = keysFor([]string{"marta", "petya"}, 40) + }, http.StatusBadRequest, "keys_mismatch", ""}, + {"кривой iv", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "marta", "iv": bytesOf(16, 1), "ct": ctOf(40, 0)}} + }, http.StatusBadRequest, "invalid", "keys"}, + {"короткий ct", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "marta", "iv": ivOf(40, 0), "ct": bytesOf(8, 1)}} + }, http.StatusBadRequest, "invalid", "keys"}, + {"кривой ник в ключе", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "МАРТА", "iv": ivOf(40, 0), "ct": ctOf(40, 0)}} + }, http.StatusBadRequest, "invalid", "keys"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + + body := map[string]any{ + "id": roomIDOf(40), + "name": "общая", + "keyId": keyID(40), + "keys": keysFor([]string{"marta"}, 40), + } + c.change(body) + rec := e.do(http.MethodPost, "/api/rooms", body, with(marta)) + expect(t, rec, c.status, c.code) + if c.field != "" { + if got := field(t, rec); got != c.field { + t.Errorf("field: получено %q, ожидалось %q", got, c.field) + } + } + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("отвергнутое создание завело комнату: %+v", got) + } + }) + } +} + +// Смена состава и rekey — один запрос: у каждого участника свой ключ +// (ADR-018). +func TestMembersAdd(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + rec := e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60) + expect(t, rec, http.StatusOK, "") + + var got roomBody + decodeBody(t, rec, &got) + want := []string{"marta", "petya", "kolya"} + if nicks(got.Members) != nicks(want) { + t.Errorf("состав: получено %v, ожидалось %v", got.Members, want) + } + if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) { + t.Errorf("ключ владельца в ответе: %+v", got.Key) + } + + // Каждый видит комнату со своим ключом. + for i, c := range []*http.Cookie{marta, petya, kolya} { + list := e.rooms(c) + if len(list) != 1 { + t.Fatalf("комнат у %d: получено %d, ожидалась 1", i, len(list)) + } + if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) { + t.Errorf("комната у %d: %+v", i, list[0]) + } + if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" || + list[0].Key.CT != ctOf(60, i) { + t.Errorf("ключ у %d: %+v", i, list[0].Key) + } + } + + // Повторное добавление участника ничего не меняет. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, want, 80), http.StatusOK, "") + if got := e.room(marta, room.ID); nicks(got.Members) != nicks(want) { + t.Errorf("состав после повторного добавления: %v", got.Members) + } +} + +// Состав идёт по joined_at: кто вступил раньше, тот и раньше в списке +// (docs/protocol.md, «Типы»). Повторное добавление участника его не двигает. +func TestMembersJoinOrder(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya"}, 60), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 80), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya", "petya"}, 100), + http.StatusOK, "") + + want := "marta,kolya,petya" + if got := e.room(marta, room.ID); strings.Join(got.Members, ",") != want { + t.Errorf("состав: получено %v, ожидалось %q", got.Members, want) + } +} + +// Множество keys[].to обязано равняться итоговому составу; отказ не меняет +// ни состава, ни ключей (docs/protocol.md, «Комнаты»). +func TestMembersKeysMismatch(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + + cases := []struct { + name string + add []string + to []string + }{ + {"ключ не всем", []string{"petya"}, []string{"marta"}}, + {"ключ лишнему", []string{"petya"}, []string{"marta", "petya", "kolya"}}, + {"ключ вместо участника", []string{"petya"}, []string{"marta", "kolya"}}, + {"ключей нет вовсе", []string{"petya"}, nil}, + {"чистый rekey без себя", nil, []string{"petya"}}, + } + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := e.changeMembers(marta, room.ID, c.add, nil, c.to, byte(60+i*10)) + expect(t, rec, http.StatusBadRequest, "keys_mismatch") + }) + } + + // Ни один отказ не изменил ни состава, ни ключа. + got := e.room(marta, room.ID) + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав после отказов: %v", got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(40) { + t.Errorf("ключ после отказов: %+v", got.Key) + } + if list := e.rooms(kolya); len(list) != 0 { + t.Errorf("комната у постороннего: %+v", list) + } + // Комната по-прежнему работает на старом ключе. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusAccepted, "") +} + +// keyId обязан быть новым для комнаты: повтор — 409, и запрос не проходит +// целиком (docs/protocol.md, «Комнаты»). +func TestMembersKeyExists(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + + // Тот же keyId, что у ключа при создании. + rec := e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 40) + expect(t, rec, http.StatusConflict, "key_exists") + + // Состав не изменился: проверки идут до записи. + got := e.room(marta, room.ID) + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав после key_exists: %v", got.Members) + } + + // Новый keyId проходит, а повтор уже его — снова 409. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "petya"}, 60), + http.StatusConflict, "key_exists") +} + +func TestMembersRejects(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + // Не владелец — 403 not_owner, хоть участник, хоть посторонний. + expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"marta", "petya"}, 80), + http.StatusForbidden, "not_owner") + kolya, _ := e.join("kolya2", 4) + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"marta", "petya"}, 80), + http.StatusForbidden, "not_owner") + // Несуществующая комната неотличима от чужой. + expect(t, e.changeMembers(marta, bytesOf(16, 9), nil, nil, []string{"marta"}, 80), + http.StatusForbidden, "not_owner") + expect(t, e.changeMembers(marta, "мусор", nil, nil, []string{"marta"}, 80), + http.StatusForbidden, "not_owner") + + // Владельца убрать нельзя. + expect(t, e.changeMembers(marta, room.ID, nil, []string{"marta"}, []string{"petya"}, 80), + http.StatusBadRequest, "owner") + + // Несуществующий ник в add — unknown_user; ник не по форме — invalid + // с полем add, как и в remove (ADR-043). + expect(t, e.changeMembers(marta, room.ID, []string{"nikogo"}, nil, []string{"marta", "petya", "nikogo"}, 80), + http.StatusNotFound, "unknown_user") + rec := e.changeMembers(marta, room.ID, []string{"МАРТА"}, nil, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + if got := field(t, rec); got != "add" { + t.Errorf("field: получено %q, ожидалось \"add\"", got) + } + + // Убрать можно только участника. + rec = e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + if got := field(t, rec); got != "remove" { + t.Errorf("field: получено %q, ожидалось \"remove\"", got) + } + + // Один ник в add и remove сразу — противоречие. + rec = e.changeMembers(marta, room.ID, []string{"kolya"}, []string{"kolya"}, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + + // Ни один отказ не тронул состав. + got := e.room(marta, room.ID) + if nicks(got.Members) != "marta,petya" { + t.Errorf("состав после отказов: %v", got.Members) + } +} + +// Убранный участник теряет комнату, ключи и доставку. +func TestMembersRemove(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + expect(t, e.changeMembers(marta, room.ID, nil, []string{"petya"}, []string{"marta"}, 80), + http.StatusOK, "") + + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("комната у убранного: %+v", got) + } + if got := e.room(marta, room.ID); len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав: %+v", got) + } + // Убранный не пишет и не получает. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(80)), + with(petya), withDevice(p1)), http.StatusForbidden, "not_member") + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), room.ID, keyID(80)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("убранному пришло сообщение: %v", got) + } +} + +// Выход владельца передаёт владение участнику с наименьшим joined_at +// (ADR-018). +func TestLeaveTransfersOwnership(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + // Владение получает участник с наименьшим joined_at, поэтому petya + // и kolya вступают в разные миллисекунды. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 80), + http.StatusOK, "") + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комната у вышедшего: %+v", got) + } + got := e.room(petya, room.ID) + if got == nil { + t.Fatal("комната пропала у оставшихся") + } + if got.Owner != "petya" { + t.Errorf("владелец: получено %q, ожидалось \"petya\"", got.Owner) + } + if nicks(got.Members) != "kolya,petya" { + t.Errorf("состав: %v", got.Members) + } + // Ключ оставшихся никуда не делся: rekey делает клиент нового владельца. + if got.Key == nil || got.Key.KeyID != keyID(80) { + t.Errorf("ключ после выхода владельца: %+v", got.Key) + } + // Новый владелец меняет состав, прежний — уже нет. + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100), + http.StatusForbidden, "not_owner") + expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"petya", "kolya"}, 100), + http.StatusOK, "") +} + +// Выход последнего участника удаляет комнату (ADR-018). +func TestLeaveDeletesEmptyRoom(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + room := e.makeRoom(marta, "marta", "общая", 40) + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комнаты после выхода: %+v", got) + } + // Комнаты больше нет: писать в неё некому. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + + // Повторный выход и выход не участника — тот же 204. + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 9)+"/leave", nil, with(marta)), http.StatusNoContent, "") + petya, _ := e.join("petya", 2) + other := e.makeRoom(petya, "petya", "вторая", 60) + expect(t, e.do(http.MethodPost, "/api/rooms/"+other.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(petya); len(got) != 1 { + t.Errorf("чужой выход тронул комнату: %+v", got) + } +} + +// DELETE /api/rooms/{id} — только владелец; комната исчезает у всех. +func TestDeleteRoom(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(petya)), http.StatusForbidden, "not_owner") + expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 9), nil, with(marta)), http.StatusForbidden, "not_owner") + if got := e.rooms(petya); len(got) != 1 { + t.Fatalf("комната пропала до удаления: %+v", got) + } + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комнаты у владельца: %+v", got) + } + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("комнаты у участника: %+v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + // Удалять больше нечего — и это уже чужая комната. + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusForbidden, "not_owner") +} + +// Конверт комнаты уходит на все устройства всех участников, кроме +// отправившего (ADR-017). +func TestRoomMessageFanout(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + petya, p1 := e.join("petya", 3) + p2 := e.addDevice(petya, deviceOf(4)) + kolya, k1 := e.join("kolya", 5) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + id := ulid(nowMillis(), 6) + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(id, room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + if got := e.queue(m1); len(got) != 0 { + t.Errorf("эхо отправившему устройству: %v", got) + } + for _, device := range []string{m2, p1, p2} { + got := e.envelopes(device) + if len(got) != 1 { + t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got)) + } + if got[0].ID != id || got[0].From != "marta" || got[0].To.Room != room.ID || got[0].To.DM != "" { + t.Errorf("конверт для %s: %+v", device, got[0]) + } + if got[0].KeyID != keyID(60) || got[0].TS == 0 { + t.Errorf("конверт для %s: %+v", device, got[0]) + } + } + // Посторонний не получает и не пишет. + if got := e.queue(k1); len(got) != 0 { + t.Errorf("конверт постороннему: %v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 7), room.ID, keyID(60)), + with(kolya), withDevice(k1)), http.StatusForbidden, "not_member") + + // Контактов комната не заводит: список комнат приходит из GET /api/rooms. + if got := e.contacts(marta); len(got) != 0 { + t.Errorf("сообщение в комнату завело контакт: %+v", got) + } +} + +// keyId сообщения обязан быть ключом этой комнаты (docs/protocol.md). +func TestRoomMessageUnknownKey(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + other := e.makeRoom(petya, "petya", "чужая", 60) + + // Ключ другой комнаты — не ключ этой. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(99)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + // Свой — принимается. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + // Членство проверяется раньше ключа. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), other.ID, keyID(99)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("отвергнутое сообщение попало в очередь: %v", got) + } +} + +// Прежний ключ комнаты остаётся рабочим, пока его не вытеснила обрезка: +// у комнаты живут два последних keyId (ADR-018). +func TestRoomKeysKeepTwo(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + room := e.makeRoom(marta, "marta", "общая", 40) + + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 60), http.StatusOK, "") + // Два последних — первый ещё жив. + for _, key := range []string{keyID(40), keyID(60)} { + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, key), + with(marta), withDevice(m1)), http.StatusAccepted, "") + } + + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "") + // Третий rekey вытеснил самый старый ключ. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + for _, key := range []string{keyID(60), keyID(80)} { + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, key), + with(marta), withDevice(m1)), http.StatusAccepted, "") + } + // Текущий ключ участника — последний. + if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) { + t.Errorf("текущий ключ: %+v", got.Key) + } +} + +// Удаление аккаунта передаёт владение по ADR-018, а комнату без участников +// удаляет. +func TestDeleteAccountWithRooms(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + shared := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, shared.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + alone := e.makeRoom(marta, "marta", "своя", 80) + + expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)), + http.StatusNoContent, "") + + // Комната с оставшимся участником живёт, комната без участников — + // исчезла вместе с владельцем. + list := e.rooms(petya) + if len(list) != 1 || list[0].ID != shared.ID || list[0].ID == alone.ID { + t.Fatalf("комнаты petya: %+v", list) + } + if list[0].Owner != "petya" { + t.Errorf("владелец после удаления аккаунта: получено %q, ожидалось \"petya\"", list[0].Owner) + } + if len(list[0].Members) != 1 || list[0].Members[0] != "petya" { + t.Errorf("состав: %v", list[0].Members) + } + if list[0].Key == nil || list[0].Key.KeyID != keyID(60) { + t.Errorf("ключ оставшегося: %+v", list[0].Key) + } + // Ник свободен, а комната, где не осталось никого, исчезла вместе с ним. + expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "") + fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + expect(t, fresh, http.StatusOK, "") + if got := e.rooms(e.cookie(fresh)); len(got) != 0 { + t.Errorf("комнаты нового аккаунта: %+v", got) + } +} + +// Событие room уходит остальным устройствам создателя, но не отправившему +// (docs/protocol.md, «Комнаты»). +func TestRoomEventOnCreate(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + + sender := e.open(m1, marta) + sender.untilReady() + other := e.open(m2, marta) + other.untilReady() + + room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1)) + + ev := other.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" { + t.Errorf("комната в событии: %+v", got) + } + if got.Key == nil || got.Key.CT != ctOf(40, 0) { + t.Errorf("ключ в событии: %+v", got.Key) + } + if got.NeedsRekey { + t.Error("needsRekey при создании") + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Смена состава: room всем участникам — каждому со своим ключом, +// room_left убранным. +func TestRoomEventsOnMembers(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + kolya, k1 := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60), http.StatusOK, "") + + streams := map[string]*stream{ + "marta": e.open(m1, marta), + "petya": e.open(p1, petya), + "kolya": e.open(k1, kolya), + } + for _, s := range streams { + s.untilReady() + } + + expect(t, e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80), + http.StatusOK, "") + + for i, nick := range []string{"marta", "petya"} { + ev := streams[nick].next() + if ev.name != "room" { + t.Fatalf("событие у %s: получено %q, ожидалось \"room\"", nick, ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if nicks(got.Members) != "marta,petya" { + t.Errorf("состав в событии у %s: %v", nick, got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) { + t.Errorf("ключ в событии у %s: %+v", nick, got.Key) + } + if got.NeedsRekey { + t.Errorf("needsRekey при смене состава у %s", nick) + } + } + + ev := streams["kolya"].next() + if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие у убранного: %+v", ev) + } +} + +// Выход участника: остальным — room с needsRekey (ADR-018). +func TestRoomEventOnLeave(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + owner := e.open(m1, marta) + owner.untilReady() + leaving := e.open(p1, petya) + leaving.untilReady() + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)), http.StatusNoContent, "") + + ev := owner.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if !got.NeedsRekey { + t.Errorf("needsRekey: получено false, ожидалось true: %s", ev.data) + } + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав в событии: %v", got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ в событии: %+v", got.Key) + } + // Другим устройствам вышедшего — room_left: комната ушла из списка, + // и ждать следующего ready им незачем (ADR-041). Запрос шёл без + // X-Device, поэтому событие получает и это устройство. + gone := leaving.next() + if gone.name != "room_left" || gone.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие вышедшему: %+v", gone) + } +} + +// Выход с X-Device: room_left уходит другим устройствам вышедшего, +// но не отправившему запрос (ADR-041). +func TestRoomEventOnLeaveExcludesSender(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + sender := e.open(p1, petya) + sender.untilReady() + other := e.open(p2, petya) + other.untilReady() + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya), withDevice(p1)), + http.StatusNoContent, "") + + ev := other.next() + if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие другому устройству: %+v", ev) + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Удаление комнаты: room_left всем участникам, включая владельца. +func TestRoomEventOnDelete(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + owner := e.open(m1, marta) + owner.untilReady() + member := e.open(p1, petya) + member.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "") + + want := `{"id":"` + room.ID + `"}` + for _, s := range []*stream{owner, member} { + ev := s.next() + if ev.name != "room_left" || ev.data != want { + t.Errorf("событие: %+v", ev) + } + } +} + +// Комнаты требуют сессии, как и всё непубличное. +func TestRoomsNeedSession(t *testing.T) { + e := newEnv(t) + expect(t, e.do(http.MethodGet, "/api/rooms", nil), http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodPost, "/api/rooms", map[string]any{"name": "общая"}), + http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 1)+"/leave", nil), + http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 1), nil), + http.StatusUnauthorized, "unauthenticated") + // Идентификатор комнаты в журнал не уходит: пишется шаблон маршрута. + if strings.Contains(e.log.String(), bytesOf(16, 1)) { + t.Errorf("идентификатор комнаты в журнале: %q", e.log.String()) + } +} + +// Долг по ключу — состояние комнаты: владелец, пропустивший событие, +// поднимает его из GET /api/rooms, а смена состава долг снимает (ADR-041). +func TestNeedsRekeyOutlivesEvent(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + if got := e.room(marta, room.ID); got.NeedsRekey { + t.Error("needsRekey до выхода участника") + } + + // Владелец не подключён: событие room ему уходить некуда. + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)), + http.StatusNoContent, "") + + got := e.room(marta, room.ID) + if got == nil || !got.NeedsRekey { + t.Fatalf("needsRekey в списке комнат: %+v", got) + } + if got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ в списке: %+v", got.Key) + } + + // Rekey закрывает долг. + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "") + if got := e.room(marta, room.ID); got.NeedsRekey { + t.Error("needsRekey после rekey") + } +} + +// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room +// с needsRekey и их собственным ключом, владение переходит (ADR-041). +func TestDeleteAccountLeavesRooms(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + kolya, k1 := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60), http.StatusOK, "") + + first := e.open(p1, petya) + first.untilReady() + second := e.open(k1, kolya) + second.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)), + http.StatusNoContent, "") + + for i, s := range []*stream{first, second} { + ev := s.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if !got.NeedsRekey { + t.Errorf("needsRekey в событии: %s", ev.data) + } + // Владение — участнику с наименьшим joined_at; petya и kolya + // вступили одной операцией, поэтому порядок решает ник. + if got.Owner != "kolya" { + t.Errorf("владелец в событии: %q, ожидался kolya", got.Owner) + } + if nicks(got.Members) != nicks([]string{"petya", "kolya"}) { + t.Errorf("состав в событии: %v", got.Members) + } + // Каждому — его собственный ключ: он различается порядковым + // номером внутри «шифротекста». + if got.Key == nil || got.Key.CT != ctOf(60, i+1) { + t.Errorf("ключ в событии: %+v", got.Key) + } + } + + // Признак пережил и рассылку: новый владелец увидит его после ready. + if got := e.room(kolya, room.ID); got == nil || !got.NeedsRekey || got.Owner != "kolya" { + t.Errorf("комната у нового владельца: %+v", got) + } + // Ключи удалённого аккаунта ушли каскадом, комната жива. + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 80), + http.StatusOK, "") + if got := e.room(petya, room.ID); got.NeedsRekey { + t.Error("needsRekey после rekey нового владельца") + } +} + +// Два ключа одному участнику — это множество keys[].to, не равное +// составу, и код у него тот же (ADR-043). +func TestMembersDuplicateKeyTarget(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80) + expect(t, rec, http.StatusBadRequest, "keys_mismatch") + // Отказ ничего не изменил: ключ комнаты прежний. + if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ после keys_mismatch: %+v", got.Key) + } +} diff --git a/internal/store/cleanup.go b/internal/store/cleanup.go index 5fa1914..70d9a7f 100644 --- a/internal/store/cleanup.go +++ b/internal/store/cleanup.go @@ -44,26 +44,14 @@ func (s *Store) Cleanup(ctx context.Context, now time.Time) error { {"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}}, {"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}}, {"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}}, - // Ключи комнат: у каждой комнаты остаются два последних key_id. - // Возраст key_id — время его самой поздней записи: ключ раздаётся - // участникам не одной строкой, а по строке на участника. - {"ключи комнат", ` - DELETE FROM room_keys WHERE (room_id, key_id) NOT IN ( - SELECT room_id, key_id FROM ( - SELECT room_id, key_id, - ROW_NUMBER() OVER ( - PARTITION BY room_id - ORDER BY MAX(created_at) DESC, key_id DESC - ) AS rn - FROM room_keys - GROUP BY room_id, key_id - ) WHERE rn <= ? - )`, []any{roomKeysKept}}, } for _, step := range steps { if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil { return fmt.Errorf("store: чистка (%s): %w", step.what, err) } } - return nil + // Ключи комнат: у каждой комнаты остаются два последних key_id. Тем же + // запросом обрезает их rekey (internal/store/rooms.go): порядок один, + // иначе чистка и rekey держали бы разные ключи. + return trimRoomKeys(ctx, s.db, "") } diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql index 0f9fbed..9dbf76d 100644 --- a/internal/store/migrations/001_init.sql +++ b/internal/store/migrations/001_init.sql @@ -38,7 +38,7 @@ CREATE TABLE contacts ( ); CREATE TABLE rooms ( - id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент (ADR-037) name TEXT NOT NULL, owner TEXT NOT NULL REFERENCES users(nick), created_at INTEGER NOT NULL diff --git a/internal/store/migrations/002_room_needs_rekey.sql b/internal/store/migrations/002_room_needs_rekey.sql new file mode 100644 index 0000000..3d83316 --- /dev/null +++ b/internal/store/migrations/002_room_needs_rekey.sql @@ -0,0 +1,4 @@ +-- Долг по ключу комнаты — состояние, а не свойство события (ADR-041): +-- состав уменьшился, а нового ключа ещё не было. Ставится при выходе +-- участника и удалении аккаунта, снимается при смене состава и rekey. +ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/queue.go b/internal/store/queue.go index d798abe..c84ae6c 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -57,10 +57,12 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error { // Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы // разложить его по очередям. Envelope сервер не разбирает, поэтому id -// приходит отдельным полем. +// приходит отдельным полем. Заполнено ровно одно из To и Room — адресат +// у конверта один (docs/protocol.md, «Типы»). type Delivery struct { From string // отправитель, он же один из получателей - To string // собеседник + To string // собеседник личного чата + Room string // комната Exclude string // устройство отправителя: эхо ему не нужно (ADR-017) MsgID string // id конверта, вторая половина ключа очереди Envelope string // готовый JSON конверта @@ -109,6 +111,38 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { return devices, nil } +// DeliverRoom кладёт конверт комнаты в очередь всех устройств всех +// участников, кроме отправившего (ADR-018), и возвращает эти устройства. +// Контактов у комнаты нет: список комнат клиент берёт из GET /api/rooms. +// +// Членство и keyId проверены раньше, отдельным запросом: между проверкой +// и этой транзакцией состав мог измениться, поэтому получателей она берёт +// из состава на момент доставки. +func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: доставка в комнату: %w", err) + } + defer tx.Rollback() + + devices, err := roomDeviceIDs(ctx, tx, d.Room, d.Exclude) + if err != nil { + return nil, err + } + for _, id := range devices { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT(device_id, msg_id) DO NOTHING`, + id, d.MsgID, d.Envelope, d.Now); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: доставка в комнату: %w", err) + } + return devices, nil +} + // deviceIDs — устройства обоих собеседников, кроме отправившего. func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) { rows, err := tx.QueryContext(ctx, ` @@ -131,3 +165,27 @@ func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]str } return out, nil } + +// roomDeviceIDs — устройства всех участников комнаты, кроме отправившего. +func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick + WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude) + if err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + return out, nil +} diff --git a/internal/store/rooms.go b/internal/store/rooms.go new file mode 100644 index 0000000..8255d77 --- /dev/null +++ b/internal/store/rooms.go @@ -0,0 +1,771 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +// Комнаты (ADR-007, ADR-018): владелец меняет состав, ключи заворачивают +// клиенты. Сервер хранит и раздаёт завёрнутые ключи, но прочитать их +// не может — это шифротекст, как и всё остальное в базе. + +// Ошибки комнат, которые обработчику нужно различать. Остальное — +// внутренние сбои. +var ( + // ErrNotOwner — комнату меняет не её владелец. Несуществующая комната + // отвечает тем же: знать о ней постороннему незачем. + ErrNotOwner = errors.New("store: не владелец комнаты") + // ErrUnknownUser — в add ник, которого нет. + ErrUnknownUser = errors.New("store: нет такого ника") + // ErrNotMember — в remove ник, который не участник комнаты. + ErrNotMember = errors.New("store: не участник комнаты") + // ErrOwnerRemoval — владельца из состава убрать нельзя. + ErrOwnerRemoval = errors.New("store: владельца убрать нельзя") + // ErrKeyExists — такой keyId у комнаты уже был. + ErrKeyExists = errors.New("store: ключ комнаты уже есть") + // ErrKeysMismatch — множество keys[].to не равно итоговому составу. + ErrKeysMismatch = errors.New("store: ключи не по составу") + // ErrRoomExists — идентификатор комнаты занят (ADR-037). + ErrRoomExists = errors.New("store: такая комната уже есть") +) + +// RoomKey — завёрнутый ключ комнаты, каким его видит участник +// (docs/protocol.md, «Типы»). Развернуть его может только он. +type RoomKey struct { + KeyID string + From string // кто завернул + IV string + CT string +} + +// WrappedKey — запись keys[] запроса: кому предназначен ключ и что в нём. +// Заворачивал тот, кто прислал запрос. +type WrappedKey struct { + To string + IV string + CT string +} + +// Room — комната и её состав. Key — текущий ключ того, кто спрашивает; +// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился, +// а нового ключа ещё не было (ADR-041). +type Room struct { + ID string + Name string + Owner string + Members []string // по joined_at + CreatedAt int64 + Key *RoomKey + NeedsRekey bool +} + +// Recipient — участник, его устройства и его текущий ключ: событие room +// уходит каждому со своим ключом (docs/protocol.md, «Комнаты»). +type Recipient struct { + Nick string + Devices []string + Key *RoomKey +} + +// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left. +// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient. +type RoomChange struct { + Room Room + Members []Recipient // итоговый состав + Left []Recipient // выбывшие +} + +// Rooms — комнаты, где пользователь участник, каждая с его текущим +// ключом (docs/protocol.md, «Комнаты»). +func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey + FROM rooms r JOIN room_members m ON m.room_id = r.id + WHERE m.nick = ? ORDER BY r.created_at, r.id`, nick) + if err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + defer rows.Close() + + var out []Room + at := make(map[string]int) + for rows.Next() { + var r Room + if err := rows.Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey); err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + r.Members = []string{} + at[r.ID] = len(out) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + if len(out) == 0 { + return nil, nil + } + + members, err := s.db.QueryContext(ctx, ` + SELECT room_id, nick FROM room_members + WHERE room_id IN (SELECT room_id FROM room_members WHERE nick = ?) + ORDER BY room_id, joined_at, nick`, nick) + if err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + defer members.Close() + + for members.Next() { + var room, member string + if err := members.Scan(&room, &member); err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + if i, ok := at[room]; ok { + out[i].Members = append(out[i].Members, member) + } + } + if err := members.Err(); err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + + keys, err := s.db.QueryContext(ctx, currentKeysQuery+` AND nick = ?`, nick) + if err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + defer keys.Close() + + for keys.Next() { + // Второй столбец — ник владельца ключа, здесь он всегда nick. + var room, member string + var k RoomKey + if err := keys.Scan(&room, &member, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + if i, ok := at[room]; ok { + key := k + out[i].Key = &key + } + } + if err := keys.Err(); err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + return out, nil +} + +// NewRoom — что нужно, чтобы завести комнату. Идентификатор выдаёт +// клиент (ADR-037), ключ ровно один — себе (docs/protocol.md, «Комнаты»). +type NewRoom struct { + ID string + Name string + Owner string + KeyID string + Key WrappedKey + Now int64 +} + +// CreateRoom заводит комнату, её единственного участника-владельца и его +// завёрнутый ключ — в одной транзакции. Идентификатор приходит от клиента +// (ADR-037); занятый — ErrRoomExists, без слияния с существующей комнатой. +func (s *Store) CreateRoom(ctx context.Context, n NewRoom) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + defer tx.Rollback() + + // Проверка до вставки, а не разбор ошибки драйвера: она читаема + // и не зависит от текста, который вернёт SQLite. + if _, err := roomRow(ctx, tx, n.ID); err == nil { + return RoomChange{}, ErrRoomExists + } else if !errors.Is(err, ErrNotFound) { + return RoomChange{}, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO rooms (id, name, owner, created_at) VALUES (?, ?, ?, ?)`, + n.ID, n.Name, n.Owner, n.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?)`, + n.ID, n.Owner, n.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты (участник): %w", err) + } + if err := insertKeys(ctx, tx, n.ID, n.Owner, n.KeyID, []WrappedKey{n.Key}, n.Now); err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, []string{n.Owner}) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + + key := RoomKey{KeyID: n.KeyID, From: n.Owner, IV: n.Key.IV, CT: n.Key.CT} + return RoomChange{ + Room: Room{ + ID: n.ID, + Name: n.Name, + Owner: n.Owner, + Members: []string{n.Owner}, + CreatedAt: n.Now, + }, + Members: []Recipient{{Nick: n.Owner, Devices: devices[n.Owner], Key: &key}}, + }, nil +} + +// MembersChange — смена состава и rekey одним запросом (ADR-018). +// Add и Remove — ники без повторов и без пересечения; пустые — чистый rekey. +type MembersChange struct { + RoomID string + Owner string // от чьего имени идёт запрос: он обязан быть владельцем + Add []string + Remove []string + KeyID string + Keys []WrappedKey + Now int64 +} + +// UpdateMembers меняет состав и раздаёт новый ключ — всё в одной +// транзакции: состав без ключа или ключ без состава невозможны (ADR-018). +// Порядок проверок — docs/protocol.md, «Комнаты»; отказ на любой из них +// не меняет ни строки. +func (s *Store) UpdateMembers(ctx context.Context, c MembersChange) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, c.RoomID) + if errors.Is(err, ErrNotFound) { + return RoomChange{}, ErrNotOwner + } + if err != nil { + return RoomChange{}, err + } + if room.Owner != c.Owner { + return RoomChange{}, ErrNotOwner + } + + current, err := roomMembers(ctx, tx, c.RoomID) + if err != nil { + return RoomChange{}, err + } + for _, nick := range c.Add { + ok, err := userExists(ctx, tx, nick) + if err != nil { + return RoomChange{}, err + } + if !ok { + return RoomChange{}, ErrUnknownUser + } + } + member := make(map[string]bool, len(current)) + for _, nick := range current { + member[nick] = true + } + for _, nick := range c.Remove { + if !member[nick] { + return RoomChange{}, ErrNotMember + } + } + for _, nick := range c.Remove { + if nick == room.Owner { + return RoomChange{}, ErrOwnerRemoval + } + } + used, err := keyUsed(ctx, tx, c.RoomID, c.KeyID) + if err != nil { + return RoomChange{}, err + } + if used { + return RoomChange{}, ErrKeyExists + } + if !sameNicks(afterChange(current, c.Add, c.Remove), keyTargets(c.Keys)) { + return RoomChange{}, ErrKeysMismatch + } + + for _, nick := range c.Remove { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_members WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (убрать): %w", err) + } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (ключи убранного): %w", err) + } + } + for _, nick := range c.Add { + // Уже состоящего участника запрос не двигает: joined_at остаётся + // прежним, порядок состава не прыгает. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?) + ON CONFLICT(room_id, nick) DO NOTHING`, c.RoomID, nick, c.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (добавить): %w", err) + } + } + if err := insertKeys(ctx, tx, c.RoomID, c.Owner, c.KeyID, c.Keys, c.Now); err != nil { + return RoomChange{}, err + } + if err := trimRoomKeys(ctx, tx, c.RoomID); err != nil { + return RoomChange{}, err + } + // Ключ роздан всему итоговому составу — долг закрыт (ADR-041). + if _, err := tx.ExecContext(ctx, ` + UPDATE rooms SET needs_rekey = 0 WHERE id = ?`, c.RoomID); err != nil { + return RoomChange{}, fmt.Errorf("store: снятие долга по ключу: %w", err) + } + room.NeedsRekey = false + + final, err := roomMembers(ctx, tx, c.RoomID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, final) + if err != nil { + return RoomChange{}, err + } + left, err := devicesOf(ctx, tx, c.Remove) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава: %w", err) + } + + room.Members = final + change := RoomChange{Room: room} + wrapped := make(map[string]WrappedKey, len(c.Keys)) + for _, k := range c.Keys { + wrapped[k.To] = k + } + for _, nick := range final { + k := wrapped[nick] + key := RoomKey{KeyID: c.KeyID, From: c.Owner, IV: k.IV, CT: k.CT} + change.Members = append(change.Members, Recipient{Nick: nick, Devices: devices[nick], Key: &key}) + } + for _, nick := range c.Remove { + change.Left = append(change.Left, Recipient{Nick: nick, Devices: left[nick]}) + } + return change, nil +} + +// LeaveRoom убирает участника и его ключи. Вышел владелец — владение +// получает участник с наименьшим joined_at; не осталось никого — комната +// удаляется (ADR-018). В RoomChange.Members — оставшиеся с их текущими +// ключами: им уходит room с needsRekey. В RoomChange.Left — сам вышедший: +// его другим устройствам уходит room_left, иначе комната висела бы у них +// до следующего ready (ADR-041). Не участник — ErrNotFound. +func (s *Store) LeaveRoom(ctx context.Context, roomID, nick string) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, roomID) + if err != nil { + return RoomChange{}, err + } + var one int + err = tx.QueryRowContext(ctx, ` + SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?`, roomID, nick).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return RoomChange{}, ErrNotFound + } + if err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + gone, err := devicesOf(ctx, tx, []string{nick}) + if err != nil { + return RoomChange{}, err + } + change, err := leaveRoom(ctx, tx, room, nick) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + change.Left = []Recipient{{Nick: nick, Devices: gone[nick]}} + return change, nil +} + +// leaveRoom убирает участника и его ключи внутри чужой транзакции: это +// общее у POST /api/rooms/{id}/leave и удаления аккаунта — по составу +// комнаты это один и тот же выход участника (ADR-018, ADR-041). +// +// В RoomChange.Members — оставшиеся с их текущими ключами и признаком +// needsRekey; пусто, если комната опустела и удалена. Проверку членства +// и рассылку берут на себя вызывающие. +func leaveRoom(ctx context.Context, tx *sql.Tx, room Room, nick string) (RoomChange, error) { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_members WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты (ключи): %w", err) + } + rest, err := roomMembers(ctx, tx, room.ID) + if err != nil { + return RoomChange{}, err + } + if len(rest) == 0 { + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление пустой комнаты: %w", err) + } + // Комнаты больше нет: ключ ей не нужен, и долга за ней не остаётся. + room.Members = nil + room.NeedsRekey = false + return RoomChange{Room: room}, nil + } + if room.Owner == nick { + room.Owner = rest[0] + if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, room.Owner, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: передача владения: %w", err) + } + } + // Состав уменьшился: комнате нужен новый ключ. Признак ждёт владельца + // в базе, а не только в событии, — офлайн его больше не теряет (ADR-041). + if _, err := tx.ExecContext(ctx, ` + UPDATE rooms SET needs_rekey = 1 WHERE id = ?`, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: долг по ключу комнаты: %w", err) + } + room.NeedsRekey = true + keys, err := currentKeys(ctx, tx, room.ID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, rest) + if err != nil { + return RoomChange{}, err + } + + room.Members = rest + change := RoomChange{Room: room} + for _, member := range rest { + r := Recipient{Nick: member, Devices: devices[member]} + if k, ok := keys[member]; ok { + key := k + r.Key = &key + } + change.Members = append(change.Members, r) + } + return change, nil +} + +// DeleteRoom удаляет комнату целиком; членство и ключи уносит каскад. +// В RoomChange.Left — все участники: им уходит room_left. Не владелец +// и несуществующая комната — ErrNotOwner. +func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, roomID) + if errors.Is(err, ErrNotFound) { + return RoomChange{}, ErrNotOwner + } + if err != nil { + return RoomChange{}, err + } + if room.Owner != owner { + return RoomChange{}, ErrNotOwner + } + members, err := roomMembers(ctx, tx, roomID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, members) + if err != nil { + return RoomChange{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, roomID); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + + room.Members = members + change := RoomChange{Room: room} + for _, member := range members { + change.Left = append(change.Left, Recipient{Nick: member, Devices: devices[member]}) + } + return change, nil +} + +// RoomAccess — что сервер проверяет перед отправкой в комнату +// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если +// есть хоть одна строка room_keys с таким key_id (docs/storage.md). +func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) { + err = s.db.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?), + EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`, + roomID, nick, roomID, keyID).Scan(&member, &knownKey) + if err != nil { + return false, false, fmt.Errorf("store: доступ к комнате: %w", err) + } + return member, knownKey, nil +} + +// currentKeysQuery — текущий ключ участника: строка room_keys с максимальным +// created_at (docs/storage.md). Порядок при совпадении времени тот же, что +// у обрезки, — иначе «текущий» и «оставленный» могли бы разойтись. +const currentKeysQuery = ` + SELECT room_id, nick, key_id, sender, iv, ct FROM ( + SELECT room_id, nick, key_id, sender, iv, ct, + ROW_NUMBER() OVER ( + PARTITION BY room_id, nick + ORDER BY created_at DESC, key_id DESC + ) AS rn + FROM room_keys + ) WHERE rn = 1` + +// currentKeys — текущие ключи всех участников комнаты. +func currentKeys(ctx context.Context, tx *sql.Tx, roomID string) (map[string]RoomKey, error) { + rows, err := tx.QueryContext(ctx, currentKeysQuery+` AND room_id = ?`, roomID) + if err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + defer rows.Close() + + out := make(map[string]RoomKey) + for rows.Next() { + var room, nick string + var k RoomKey + if err := rows.Scan(&room, &nick, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + out[nick] = k + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + return out, nil +} + +// execer — то общее у *sql.DB и *sql.Tx, что нужно обрезке ключей: её +// зовут и транзакция rekey, и фоновая чистка. +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// trimRoomKeys оставляет у комнаты два последних keyId (ADR-018). Пустой +// room — все комнаты: это фоновая чистка (docs/storage.md). Запрос один +// на оба случая, иначе rekey и чистка держали бы разные ключи. +// +// Текущий ключ участника обрезка не трогает: новый keyId раздаётся всем +// участникам сразу (иначе keys_mismatch), поэтому самый свежий key_id +// комнаты есть у каждого, а он остаётся всегда. Какой из ключей свежий — +// однозначно: время ключа строго растёт (insertKeys). +// +// Возраст key_id — время его самой поздней записи: ключ раздаётся не одной +// строкой, а по строке на участника. +func trimRoomKeys(ctx context.Context, x execer, room string) error { + _, err := x.ExecContext(ctx, ` + DELETE FROM room_keys + WHERE (? = '' OR room_id = ?) + AND (room_id, key_id) NOT IN ( + SELECT room_id, key_id FROM ( + SELECT room_id, key_id, + ROW_NUMBER() OVER ( + PARTITION BY room_id + ORDER BY MAX(created_at) DESC, key_id DESC + ) AS rn + FROM room_keys + GROUP BY room_id, key_id + ) WHERE rn <= ? + )`, room, room, roomKeysKept) + if err != nil { + return fmt.Errorf("store: обрезка ключей комнаты: %w", err) + } + return nil +} + +// roomRow читает комнату без состава и ключей. Нет такой — ErrNotFound. +func roomRow(ctx context.Context, tx *sql.Tx, roomID string) (Room, error) { + var r Room + err := tx.QueryRowContext(ctx, ` + SELECT id, name, owner, created_at, needs_rekey FROM rooms WHERE id = ?`, roomID). + Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey) + if errors.Is(err, sql.ErrNoRows) { + return Room{}, ErrNotFound + } + if err != nil { + return Room{}, fmt.Errorf("store: чтение комнаты: %w", err) + } + return r, nil +} + +// roomMembers — состав комнаты по joined_at (docs/protocol.md, «Типы»). +func roomMembers(ctx context.Context, tx *sql.Tx, roomID string) ([]string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT nick FROM room_members WHERE room_id = ? ORDER BY joined_at, nick`, roomID) + if err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var nick string + if err := rows.Scan(&nick); err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + out = append(out, nick) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + return out, nil +} + +// userExists — есть ли такой ник. +func userExists(ctx context.Context, tx *sql.Tx, nick string) (bool, error) { + var one int + err := tx.QueryRowContext(ctx, `SELECT 1 FROM users WHERE nick = ?`, nick).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("store: проверка ника: %w", err) + } + return true, nil +} + +// keyUsed — был ли уже такой keyId у комнаты. +func keyUsed(ctx context.Context, tx *sql.Tx, roomID, keyID string) (bool, error) { + var one int + err := tx.QueryRowContext(ctx, ` + SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ? LIMIT 1`, roomID, keyID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("store: проверка ключа комнаты: %w", err) + } + return true, nil +} + +// insertKeys раскладывает завёрнутые ключи по участникам. +// +// Время ключа — строго позже всех прежних ключей комнаты. Текущий ключ +// участника — строка с максимальным created_at (docs/storage.md), а два +// rekey подряд укладываются в одну миллисекунду. Без этого «последним» +// оказался бы прежний ключ, обрезка до двух последних keyId выбросила бы +// свежий, и комната откатилась бы на ключ, которого у новых участников нет. +func insertKeys(ctx context.Context, tx *sql.Tx, roomID, sender, keyID string, keys []WrappedKey, now int64) error { + var last int64 + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(created_at), 0) FROM room_keys WHERE room_id = ?`, roomID).Scan(&last); err != nil { + return fmt.Errorf("store: время ключа комнаты: %w", err) + } + if now <= last { + now = last + 1 + } + for _, k := range keys { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + roomID, k.To, keyID, sender, k.IV, k.CT, now); err != nil { + return fmt.Errorf("store: раздача ключа комнаты: %w", err) + } + } + return nil +} + +// devicesOf — устройства перечисленных пользователей. +func devicesOf(ctx context.Context, tx *sql.Tx, nicks []string) (map[string][]string, error) { + out := make(map[string][]string, len(nicks)) + if len(nicks) == 0 { + return out, nil + } + args := make([]any, 0, len(nicks)) + for _, nick := range nicks { + args = append(args, nick) + } + rows, err := tx.QueryContext(ctx, ` + SELECT nick, id FROM devices WHERE nick IN (?`+ + strings.Repeat(", ?", len(nicks)-1)+`) ORDER BY nick, id`, args...) + if err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + defer rows.Close() + + for rows.Next() { + var nick, id string + if err := rows.Scan(&nick, &id); err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + out[nick] = append(out[nick], id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + return out, nil +} + +// afterChange — итоговый состав: текущий без remove плюс add. +func afterChange(current, add, remove []string) []string { + gone := make(map[string]bool, len(remove)) + for _, nick := range remove { + gone[nick] = true + } + out := make([]string, 0, len(current)+len(add)) + seen := make(map[string]bool, len(current)+len(add)) + for _, nick := range current { + if gone[nick] || seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + for _, nick := range add { + if seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + return out +} + +// keyTargets — кому предназначены завёрнутые ключи. +func keyTargets(keys []WrappedKey) []string { + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k.To) + } + return out +} + +// sameNicks — совпадают ли множества ников. Порядок не важен: ключи +// приходят в порядке клиента, состав — по joined_at. +// +// Найденный ник из множества вычёркивается: два ключа одному участнику +// вместо ключа другому — это keys_mismatch, а не совпадение по длине. +// Иначе такой запрос дошёл бы до вставки и упал на ключе room_keys уже +// внутри транзакции, отдав клиенту 500 вместо разбираемого кода. +func sameNicks(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := make(map[string]bool, len(a)) + for _, nick := range a { + set[nick] = true + } + for _, nick := range b { + if !set[nick] { + return false + } + delete(set, nick) + } + return len(set) == 0 +} diff --git a/internal/store/rooms_test.go b/internal/store/rooms_test.go new file mode 100644 index 0000000..866bff8 --- /dev/null +++ b/internal/store/rooms_test.go @@ -0,0 +1,334 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +// room — комната из трёх ников для тестов хранилища. +func newTestStore(t *testing.T, nicks ...string) (*Store, context.Context) { + t.Helper() + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + for i, nick := range nicks { + err := s.CreateUser(ctx, User{ + Nick: nick, + Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"}, + PublicKey: `{"kty":"EC"}`, + KeyBlob: `{"v":1}`, + CreatedAt: int64(i + 1), + }) + if err != nil { + t.Fatalf("CreateUser %s: %v", nick, err) + } + } + return s, ctx +} + +// wrap — завёрнутый ключ участнику: содержимое хранилищу безразлично. +func wrap(nicks ...string) []WrappedKey { + out := make([]WrappedKey, 0, len(nicks)) + for _, nick := range nicks { + out = append(out, WrappedKey{To: nick, IV: "iv-" + nick, CT: "ct-" + nick}) + } + return out +} + +// rekey — смена состава и раздача нового ключа. +func rekey(t *testing.T, s *Store, ctx context.Context, room, owner, keyID string, add, remove, to []string, now int64) RoomChange { + t.Helper() + change, err := s.UpdateMembers(ctx, MembersChange{ + RoomID: room, + Owner: owner, + Add: add, + Remove: remove, + KeyID: keyID, + Keys: wrap(to...), + Now: now, + }) + if err != nil { + t.Fatalf("UpdateMembers %s: %v", keyID, err) + } + return change +} + +// makeRoom — комната с одним владельцем и его ключом. +func makeRoom(t *testing.T, s *Store, ctx context.Context, owner string, now int64) RoomChange { + t.Helper() + change, err := s.CreateRoom(ctx, NewRoom{ + ID: "room-1", + Name: "общая", + Owner: owner, + KeyID: "k1", + Key: wrap(owner)[0], + Now: now, + }) + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + return change +} + +// Занятый идентификатор комнаты — ErrRoomExists, и ни одной строки +// существующая комната при этом не теряет (ADR-037). +func TestCreateRoomTakenID(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + + _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "чужая", Owner: "petya", + KeyID: "k2", Key: wrap("petya")[0], Now: 2000}) + if !errors.Is(err, ErrRoomExists) { + t.Fatalf("CreateRoom с занятым id: получено %v, ожидалось ErrRoomExists", err) + } + rooms, err := s.Rooms(ctx, "marta") + if err != nil { + t.Fatalf("Rooms: %v", err) + } + if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" || + rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" { + t.Errorf("комната после отказа: %+v", rooms) + } + if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 { + t.Errorf("занятый id присоединил чужого: %+v, %v", got, err) + } +} + +// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка +// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018). +func TestRoomKeysTrimmedToTwo(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + rekey(t, s, ctx, "room-1", "marta", "k3", []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 3000) + + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи после rekey: получено %v, ожидалось [k2 k3]", got) + } + // Фоновая чистка ничего не добавляет к обрезке: запрос у них один. + if err := s.Cleanup(ctx, time.Now()); err != nil { + t.Fatalf("Cleanup: %v", err) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи после чистки: получено %v, ожидалось [k2 k3]", got) + } + // Текущий ключ есть у каждого участника, и он последний. + for _, nick := range []string{"marta", "petya", "kolya"} { + rooms, err := s.Rooms(ctx, nick) + if err != nil { + t.Fatalf("Rooms %s: %v", nick, err) + } + if len(rooms) != 1 || rooms[0].Key == nil { + t.Fatalf("комнаты %s: %+v", nick, rooms) + } + if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick { + t.Errorf("ключ %s: %+v", nick, rooms[0].Key) + } + } +} + +// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ, +// и обрезка его не выбрасывает (docs/storage.md, «Текущий ключ комнаты»). +// Идентификаторы ключей случайны, поэтому свежий вполне может оказаться +// меньше прежнего по порядку сортировки — на этом и построен случай. +func TestRoomKeysWithinOneMillisecond(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "marta", + KeyID: "ccc", Key: wrap("marta")[0], Now: 1000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + rekey(t, s, ctx, "room-1", "marta", "bbb", []string{"petya"}, nil, []string{"marta", "petya"}, 1000) + rekey(t, s, ctx, "room-1", "marta", "aaa", nil, nil, []string{"marta", "petya"}, 1000) + + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"aaa", "bbb"}) { + t.Errorf("ключи: получено %v, ожидалось [aaa bbb]", got) + } + for _, nick := range []string{"marta", "petya"} { + rooms, err := s.Rooms(ctx, nick) + if err != nil || len(rooms) != 1 || rooms[0].Key == nil { + t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err) + } + if rooms[0].Key.KeyID != "aaa" { + t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID) + } + } + // Свежим ключом можно писать: он остался ключом комнаты. + member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") + if err != nil || !member || !known { + t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err) + } +} + +// Убранный участник теряет и членство, и все свои ключи. +func TestRoomRemoveDropsKeys(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + change := rekey(t, s, ctx, "room-1", "marta", "k3", nil, []string{"petya"}, []string{"marta"}, 3000) + if len(change.Left) != 1 || change.Left[0].Nick != "petya" { + t.Errorf("выбывшие: %+v", change.Left) + } + if got := ids(t, s, `SELECT nick FROM room_keys ORDER BY nick, key_id`); !equal(got, []string{"marta", "marta"}) { + t.Errorf("ключи после удаления участника: %v", got) + } + rooms, err := s.Rooms(ctx, "petya") + if err != nil || len(rooms) != 0 { + t.Errorf("комнаты убранного: %+v, %v", rooms, err) + } +} + +// Отказ в середине не оставляет следов: состав и ключи те же (docs/protocol.md). +func TestRoomChangeIsAtomic(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + cases := []struct { + name string + c MembersChange + want error + }{ + {"не владелец", MembersChange{RoomID: "room-1", Owner: "petya", KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner}, + {"нет комнаты", MembersChange{RoomID: "нет", Owner: "marta", KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner}, + {"нет ника", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"никого"}, KeyID: "k3", + Keys: wrap("marta", "petya", "никого"), Now: 3000}, ErrUnknownUser}, + {"не участник", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"kolya"}, KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotMember}, + {"владелец", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"marta"}, KeyID: "k3", + Keys: wrap("petya"), Now: 3000}, ErrOwnerRemoval}, + {"ключ уже был", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k2", + Keys: wrap("marta", "petya", "kolya"), Now: 3000}, ErrKeyExists}, + {"ключи не по составу", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrKeysMismatch}, + // Два ключа одному вместо ключа другому: длина сходится, состав — нет. + {"два ключа одному", MembersChange{RoomID: "room-1", Owner: "marta", KeyID: "k3", + Keys: wrap("marta", "marta"), Now: 3000}, ErrKeysMismatch}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := s.UpdateMembers(ctx, c.c); !errors.Is(err, c.want) { + t.Fatalf("получено %v, ожидалось %v", err, c.want) + } + if got := ids(t, s, `SELECT nick FROM room_members WHERE room_id = 'room-1' ORDER BY nick`); !equal(got, []string{"marta", "petya"}) { + t.Errorf("состав: получено %v, ожидалось [marta petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k1", "k2"}) { + t.Errorf("ключи: получено %v, ожидалось [k1 k2]", got) + } + }) + } +} + +// Удаление аккаунта передаёт владение участнику с наименьшим joined_at, +// а комнату без участников удаляет (ADR-018). +func TestDeleteUserRooms(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"kolya"}, nil, []string{"marta", "kolya"}, 2000) + rekey(t, s, ctx, "room-1", "marta", "k3", []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 3000) + + // Вторая комната — только владелец. + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-2", Name: "своя", Owner: "marta", + KeyID: "k1", Key: wrap("marta")[0], Now: 4000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + + if _, err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if got := ids(t, s, `SELECT id FROM rooms ORDER BY id`); !equal(got, []string{"room-1"}) { + t.Errorf("комнаты: получено %v, ожидалось [room-1]", got) + } + if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"kolya"}) { + t.Errorf("владелец: получено %v, ожидалось [kolya]", got) + } + if got := ids(t, s, `SELECT nick FROM room_members ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) { + t.Errorf("состав: получено %v, ожидалось [kolya petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) { + t.Errorf("ключи: получено %v, ожидалось [kolya petya]", got) + } +} + +// Удаление аккаунта простого участника чужую комнату не трогает: членство +// и ключи уносит каскад, владелец и остальные на месте. +func TestDeleteUserMember(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "petya", + KeyID: "k1", Key: wrap("petya")[0], Now: 1000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + rekey(t, s, ctx, "room-1", "petya", "k2", []string{"marta"}, nil, []string{"petya", "marta"}, 2000) + + if _, err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"petya"}) { + t.Errorf("комнаты: получено %v, ожидалось [petya]", got) + } + if got := ids(t, s, `SELECT nick FROM room_members`); !equal(got, []string{"petya"}) { + t.Errorf("состав: получено %v, ожидалось [petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) { + t.Errorf("ключи: получено %v, ожидалось [petya]", got) + } +} + +// Выход владельца передаёт владение; выход последнего удаляет комнату. +func TestLeaveRoom(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + // Не участник — ErrNotFound, комната не тронута. + if _, err := s.LeaveRoom(ctx, "нет", "marta"); !errors.Is(err, ErrNotFound) { + t.Errorf("выход из несуществующей комнаты: %v", err) + } + + change, err := s.LeaveRoom(ctx, "room-1", "marta") + if err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + if change.Room.Owner != "petya" { + t.Errorf("владелец: получено %q, ожидалось \"petya\"", change.Room.Owner) + } + if len(change.Members) != 1 || change.Members[0].Nick != "petya" { + t.Fatalf("оставшиеся: %+v", change.Members) + } + if change.Members[0].Key == nil || change.Members[0].Key.KeyID != "k2" { + t.Errorf("ключ оставшегося: %+v", change.Members[0].Key) + } + if !change.Room.NeedsRekey { + t.Error("needsRekey после выхода участника") + } + if len(change.Left) != 1 || change.Left[0].Nick != "marta" { + t.Errorf("room_left вышедшему: %+v", change.Left) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) { + t.Errorf("ключи после выхода: %v", got) + } + + last, err := s.LeaveRoom(ctx, "room-1", "petya") + if err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + // Комнаты больше нет: room слать некому, room_left уходит другим + // устройствам вышедшего (ADR-041). + if len(last.Members) != 0 { + t.Errorf("получатели после выхода последнего: %+v", last.Members) + } + if len(last.Left) != 1 || last.Left[0].Nick != "petya" { + t.Errorf("room_left после выхода последнего: %+v", last.Left) + } + if got := ids(t, s, `SELECT id FROM rooms`); len(got) != 0 { + t.Errorf("пустая комната осталась: %v", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); len(got) != 0 { + t.Errorf("ключи удалённой комнаты остались: %v", got) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7948b84..a1df6f4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -22,11 +22,12 @@ func TestMigrateAndRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "bare.db") first := open(t, path) - if got := first.Applied(); len(got) != 1 || got[0] != "001_init.sql" { - t.Fatalf("применённые миграции: получено %v, ожидалось [001_init.sql]", got) + want := []string{"001_init.sql", "002_room_needs_rekey.sql"} + if got := first.Applied(); !equal(got, want) { + t.Fatalf("применённые миграции: получено %v, ожидалось %v", got, want) } - if got := version(t, first); got != 1 { - t.Errorf("user_version: получено %d, ожидалась 1", got) + if got := version(t, first); got != len(want) { + t.Errorf("user_version: получено %d, ожидалась %d", got, len(want)) } // Все восемь таблиц из docs/storage.md на месте. for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} { @@ -52,8 +53,8 @@ func TestMigrateAndRestart(t *testing.T) { if got := second.Applied(); len(got) != 0 { t.Errorf("повторный старт применил %v, ожидалось ничего", got) } - if got := version(t, second); got != 1 { - t.Errorf("user_version после перезапуска: получено %d, ожидалась 1", got) + if got := version(t, second); got != len(want) { + t.Errorf("user_version после перезапуска: получено %d, ожидалась %d", got, len(want)) } } @@ -124,7 +125,7 @@ func TestUsersAndSessions(t *testing.T) { } // Удаление пользователя уносит сессии каскадом. - if err := s.DeleteUser(ctx, "marta"); err != nil { + if _, err := s.DeleteUser(ctx, "marta"); err != nil { t.Fatalf("DeleteUser: %v", err) } if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) { diff --git a/internal/store/users.go b/internal/store/users.go index 2e38fe5..21d23b2 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -104,16 +104,104 @@ func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, b return nil } -// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство -// и очереди уносит каскад. +// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство, +// ключи комнат и очереди уносит каскад. // -// Комнаты, где пользователь владелец, каскадом не удаляются: rooms.owner -// ссылается на users(nick) без ON DELETE, и удаление такого пользователя -// упрётся в внешний ключ. Передача владения и удаление пустых комнат — -// ADR-018, этап 3; до появления комнат случай не наступает. -func (s *Store) DeleteUser(ctx context.Context, nick string) error { - if _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { - return fmt.Errorf("store: удаление пользователя: %w", err) +// Удаление аккаунта — выход из всех его комнат (ADR-041): по составу это +// тот же уход участника, что и POST /api/rooms/{id}/leave. Членство и ключи +// убираются до каскада, чтобы собрать оставшихся с их устройствами и +// текущими ключами; владение переходит участнику с наименьшим joined_at, +// опустевшая комната удаляется, у остальных выставляется needs_rekey. +// Всё вместе — одна транзакция; события рассылает обработчик после неё. +func (s *Store) DeleteUser(ctx context.Context, nick string) ([]RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) } - return nil + defer tx.Rollback() + + rooms, err := memberRooms(ctx, tx, nick) + if err != nil { + return nil, err + } + var changes []RoomChange + for _, id := range rooms { + room, err := roomRow(ctx, tx, id) + if err != nil { + return nil, err + } + change, err := leaveRoom(ctx, tx, room, nick) + if err != nil { + return nil, err + } + // Комната опустела и удалена — рассылать некому. + if len(change.Members) > 0 { + changes = append(changes, change) + } + } + // Владелец всегда состоит в своей комнате: из состава его не убрать, + // а выход передаёт владение (ADR-018), — так что здесь уже пусто. + // Проверка остаётся ради внешнего ключа: rooms.owner ссылается на + // users(nick) без ON DELETE, и забытая строка заперла бы удаление. + owned, err := ownedRooms(ctx, tx, nick) + if err != nil { + return nil, err + } + for _, room := range owned { + var heir string + err := tx.QueryRowContext(ctx, ` + SELECT nick FROM room_members + WHERE room_id = ? AND nick <> ? ORDER BY joined_at, nick LIMIT 1`, room, nick).Scan(&heir) + if errors.Is(err, sql.ErrNoRows) { + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room); err != nil { + return nil, fmt.Errorf("store: удаление пустой комнаты: %w", err) + } + continue + } + if err != nil { + return nil, fmt.Errorf("store: передача владения: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, heir, room); err != nil { + return nil, fmt.Errorf("store: передача владения: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) + } + return changes, nil +} + +// ownedRooms — комнаты, где пользователь владелец. +func ownedRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) { + return roomIDs(ctx, tx, `SELECT id FROM rooms WHERE owner = ? ORDER BY created_at, id`, nick) +} + +// memberRooms — комнаты, где пользователь участник. +func memberRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) { + return roomIDs(ctx, tx, `SELECT room_id FROM room_members WHERE nick = ? ORDER BY joined_at, room_id`, nick) +} + +// roomIDs — идентификаторы комнат по запросу с одним параметром. +func roomIDs(ctx context.Context, tx *sql.Tx, query, nick string) ([]string, error) { + rows, err := tx.QueryContext(ctx, query, nick) + if err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + return out, nil } diff --git a/web/app.css b/web/app.css index 550c4a6..3bb9a83 100644 --- a/web/app.css +++ b/web/app.css @@ -393,7 +393,20 @@ input[type="password"] { font-size: 15px; } +/* имя комнаты бывает длиной в 64 символа (ADR-018): в шапке оно + обрезается, как и в строке сайдбара, — иначе страница едет вбок */ + +.head .chat-title, +.head .title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .back { + flex: none; min-height: 32px; padding: 6px 10px; border: 1px solid var(--edge); @@ -444,12 +457,73 @@ input[type="password"] { margin-top: 14px; } +.fp-label { + margin: 12px 0 4px; + font-size: 11px; + color: var(--stone); +} + +.block--first > .fp-label:first-child { + margin-top: 0; +} + .fp-hint { margin: 10px 0 0; font-size: 11px; color: var(--mute); } +/* участники комнаты — docs/ui.md, «Участники» */ + +.members { + margin: 0; + padding: 0; + list-style: none; +} + +.member { + display: flex; + align-items: center; + gap: 10px; + min-height: 28px; + font-size: 13px; + color: var(--text2); +} + +.member__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.member .tag { + color: var(--stone); + font-size: 11px; +} + +.member .link { + margin-left: auto; + color: var(--mute); + font-size: 12px; +} + +/* строка ввода как отдельная форма: «новый чат» и «добавить участника» */ + +.form--row + .form--row { + margin-top: 12px; +} + +.form--row .button { + margin-top: 10px; +} + +/* две кнопки подряд не слипаются в двойную рамку */ + +.block > .button + .button { + margin-top: 10px; +} + /* чат: шапка, лента, ввод — docs/identity/screens.html */ .chat-title { @@ -663,6 +737,13 @@ input[type="password"] { cursor: pointer; } +/* ввод заблокирован предупреждением о ключе: кнопка «>» тоже гаснет */ + +.input__send[disabled] { + color: var(--stone); + cursor: default; +} + .counter, .enter { flex: none; @@ -675,15 +756,24 @@ input[type="password"] { margin-left: 0; } -/* полоса над вводом: нет соединения — stone, отказ отправки — mark */ +/* полоса над вводом: нет соединения — stone, отказ отправки и смена + ключа — mark. У полосы про ключ есть кнопка, поэтому строка — flex */ .bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 8px; margin: 0 0 10px; font-size: 11px; line-height: 1.5; color: var(--stone); } +.bar[hidden] { + display: none; +} + .bar--mark { color: var(--mark); } diff --git a/web/js/api.js b/web/js/api.js index 16057c4..d2f13d2 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -181,6 +181,40 @@ export function removeContact(nick) { return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`); } +// --- комнаты ----------------------------------------------------------- + +// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым +// ключом (docs/protocol.md, «Комнаты»). +export function rooms() { + return request("GET", "/api/rooms"); +} + +// createRoom заводит комнату. Идентификатор генерирует клиент: ключ +// заворачивается до запроса и привязан к roomId (ADR-037). Занятый +// идентификатор — 409 room_conflict, берётся новый. +// +// X-Device передаётся, чтобы это же устройство не получило комнату ещё +// и событием: она приходит ответом (docs/protocol.md, «Комнаты»). +export function createRoom(device, body) { + return request("POST", "/api/rooms", body, { device }); +} + +// changeMembers — смена состава и rekey одним запросом (ADR-018). +export function changeMembers(id, body) { + return request("POST", `/api/rooms/${encodeURIComponent(id)}/members`, body); +} + +// leaveRoom — выход из комнаты. X-Device передаётся по той же причине, +// что и при создании: комната уходит из списка здесь же, а другим +// устройствам вышедшего сервер шлёт room_left (ADR-041). +export function leaveRoom(device, id) { + return request("POST", `/api/rooms/${encodeURIComponent(id)}/leave`, undefined, { device }); +} + +export function removeRoom(id) { + return request("DELETE", `/api/rooms/${encodeURIComponent(id)}`); +} + // --- сообщения --------------------------------------------------------- // sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017). @@ -208,6 +242,10 @@ export function ack(device, ids) { export function stream(device, handlers) { const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`); source.addEventListener("msg", (event) => handlers.msg(parse(event.data))); + // room и room_left в очередь не кладутся: пропуск во время офлайна + // чинится перечитыванием GET /api/rooms после ready (docs/protocol.md). + source.addEventListener("room", (event) => handlers.room(parse(event.data))); + source.addEventListener("room_left", (event) => handlers.roomLeft(parse(event.data))); source.addEventListener("ready", () => handlers.ready()); source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED)); return () => source.close(); diff --git a/web/js/crypto.js b/web/js/crypto.js index f5322b5..3862032 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -14,6 +14,8 @@ const INFO_AUTH = "bare-auth-v1"; const INFO_KEK = "bare-kek-v1"; const BLOB_AAD = "bare-blob-v1|"; const DM_SALT = "bare-dm-v1"; +const WRAP_SALT = "bare-wrap-v1"; +const ROOM_AAD = "bare-roomkey-v1|"; const MSG_AAD = "bare-msg-v1|"; // DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного @@ -24,6 +26,10 @@ export const DM_KEY_ID = "dm"; export const SECRET_LEN = 32; const IV_LEN = 12; +// ROOM_KEY_LEN — ключ комнаты: 32 случайных байта (docs/crypto.md, +// «Комната»). +export const ROOM_KEY_LEN = 32; + // ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных // байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). const ID_LEN = 16; @@ -290,6 +296,90 @@ export async function dmKey(privateKey, peerPublicJwk, me, peer) { ); } +// --- комната ----------------------------------------------------------- + +// roomLabel — метка комнаты для AAD сообщения: "room:" + roomId. Совпадает +// с ключом чата в IndexedDB, но собирается здесь: crypto.js не знает о базе. +export function roomLabel(roomId) { + return `room:${roomId}`; +} + +// newRoomKey — новый ключ комнаты: 32 случайных байта и случайный keyId +// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания, +// потом распространитель импортирует их себе non-extractable и затирает. +export function newRoomKey() { + return { keyId: newId(), bytes: random(ROOM_KEY_LEN) }; +} + +// importRoomKey — ключ комнаты как non-extractable AES-GCM-256. Наружу +// он больше не выходит: в IndexedDB кладётся объект CryptoKey. +export function importRoomKey(bytes) { + return subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} + +// roomAad и wrapKey — ровно то, что написано в docs/crypto.md, +// «Заворачивание участнику». Заворачивание самому себе идёт этим же кодом: +// ECDH(myPrivate, myPublic), без исключений. +function roomAad({ roomId, keyId, from, to }) { + return utf8(`${ROOM_AAD}${roomId}|${keyId}|${from}|${to}`); +} + +async function wrapKey(privateKey, peerPublicJwk, roomId, keyId) { + const publicKey = await importPublic(peerPublicJwk); + const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256); + const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); + wipe(new Uint8Array(shared)); + return subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: utf8(WRAP_SALT), + info: utf8(`${ROOM_AAD}${roomId}|${keyId}`), + }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// wrapRoomKey заворачивает сырые байты ключа комнаты участнику. Отдаёт +// запись keys[] запроса: {to, iv, ct} (docs/protocol.md, «Типы»). +export async function wrapRoomKey(privateKey, memberPublicJwk, { roomId, keyId, from, to }, roomKey) { + const key = await wrapKey(privateKey, memberPublicJwk, roomId, keyId); + const iv = random(IV_LEN); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: roomAad({ roomId, keyId, from, to }) }, + key, + roomKey, + ); + return { to, iv: b64url(iv), ct: b64url(ct) }; +} + +// unwrapRoomKey разворачивает завёрнутый нам ключ той же схемой и сразу +// импортирует его non-extractable: сырые байты дальше не идут. +// Публичный ключ отправителя проходит через TOFU до вызова (ADR-016). +export async function unwrapRoomKey(privateKey, senderPublicJwk, { roomId, keyId, from, to, iv, ct }) { + const key = await wrapKey(privateKey, senderPublicJwk, roomId, keyId); + const nonce = unb64url(iv); + if (nonce.length !== IV_LEN) { + throw new Error("iv — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv: nonce, additionalData: roomAad({ roomId, keyId, from, to }) }, + key, + unb64url(ct), + ); + const bytes = new Uint8Array(plain); + if (bytes.length !== ROOM_KEY_LEN) { + wipe(bytes); + throw new Error("ключ комнаты — не 32 байта"); + } + const imported = await importRoomKey(bytes); + wipe(bytes); + return imported; +} + // --- сообщение --------------------------------------------------------- // messageAad привязывает открытые поля конверта к шифротексту: подмена diff --git a/web/js/db.js b/web/js/db.js index 090acc7..3539801 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -118,9 +118,14 @@ export function peerOf(chatId) { return chatId.startsWith(DM) ? chatId.slice(DM.length) : null; } +// roomIdOf — какая комната; у личного чата комнаты нет. +export function roomIdOf(chatId) { + return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null; +} + // blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»: -// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3), -// до этого вместо имени стоит идентификатор. +// сигил ставит экран. Комнате имя, владелец и состав приходят из +// GET /api/rooms и события room; до этого вместо имени стоит идентификатор. export function blankChat(id) { const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false }; const peer = peerOf(id); @@ -161,6 +166,39 @@ export function putChat(record) { return put("chats", record); } +// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись +// одной транзакцией, чтобы не разъехаться с параллельным markRead. +// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь. +export async function mergeChat(id, patch) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const known = await value(store.get(id)); + const record = known ?? blankChat(id); + // Новую запись пишем всегда: она могла совпасть с пустой по всем полям + // патча и осталась бы ненаписанной. + let changed = known === undefined; + for (const [key, next] of Object.entries(patch)) { + if (same(record[key], next)) { + continue; + } + record[key] = next; + changed = true; + } + if (changed) { + store.put(record); + } + await done(tx); + return changed; +} + +function same(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => item === b[i]); + } + return a === b; +} + // markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает // к последнему сообщению. Обе величины локальные, на сервер не уходят // (docs/storage.md). @@ -307,6 +345,23 @@ export async function pendingMessages() { return out; } +// undecryptable — всё, что не удалось расшифровать и что хранит raw для +// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит +// проход курсором; попытка повторяется редко: при подтверждении ключа +// и при появлении недостающего ключа комнаты. +export async function undecryptable() { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + const out = []; + await cursor(store.openCursor(), (record) => { + if (record.undecryptable && record.raw) { + out.push(record); + } + return true; + }); + return out; +} + // cursor обходит курсор, пока step не скажет «хватит». function cursor(request, step) { return new Promise((resolve, reject) => { @@ -322,10 +377,59 @@ function cursor(request, step) { }); } +// --- ключи комнат ------------------------------------------------------- + +// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки, +// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] — +// нижняя (тот же приём, что и в индексе "chat"). +function roomRange(roomId) { + return IDBKeyRange.bound([roomId], [roomId, []]); +} + +export function roomKey(roomId, keyId) { + return get("roomKeys", [roomId, keyId]); +} + +// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний +// и есть текущий (docs/storage.md, ADR-042). +export async function roomKeysOf(roomId) { + const db = await open(); + const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys"); + const list = await value(store.getAll(roomRange(roomId))); + return list.sort((a, b) => a.receivedAt - b.receivedAt); +} + +// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли +// это. Клиент держит все ключи комнаты и расшифровывает любым известным +// (ADR-018), поэтому уже сохранённый ключ не перезаписывается. +// +// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ +// — последний полученный этим устройством, а два rekey подряд укладываются +// в одну миллисекунду (ADR-042, docs/storage.md). +export async function saveRoomKey({ roomId, keyId, key, from }) { + const db = await open(); + const tx = db.transaction("roomKeys", "readwrite"); + const store = tx.objectStore("roomKeys"); + const list = await value(store.getAll(roomRange(roomId))); + let receivedAt = Date.now(); + let known = false; + for (const record of list) { + known = known || record.keyId === keyId; + if (record.receivedAt >= receivedAt) { + receivedAt = record.receivedAt + 1; + } + } + if (!known) { + store.put({ roomId, keyId, key, from, receivedAt }); + } + await done(tx); + return !known; +} + // --- собеседники -------------------------------------------------------- // peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом -// получении ключа; сверка изменившегося ключа и pending — этап 3. +// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения. export function peer(nick) { return get("peers", nick); } diff --git a/web/js/main.js b/web/js/main.js index 9a38e18..c8453ee 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -26,6 +26,7 @@ import { DESKTOP, clear, wide } from "./ui/dom.js"; import { renderAuth } from "./ui/auth.js"; import { renderChat } from "./ui/chat.js"; import { renderContact } from "./ui/contact.js"; +import { renderMembers } from "./ui/members.js"; import { renderNew } from "./ui/new.js"; import { renderSettings } from "./ui/settings.js"; import { frame } from "./ui/shell.js"; @@ -69,9 +70,11 @@ const ctx = { // --- роутинг ----------------------------------------------------------- -// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут -// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список. +// route разбирает hash. Маршруты — docs/ui.md, «Каркас». Ник — форма +// ADR-019, идентификатор комнаты — 16 случайных байт base64url +// (docs/crypto.md, «Идентификаторы»). Всё, что не разобралось, — список. const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/; +const ROOM_ROUTE = /^#\/room\/([A-Za-z0-9_-]{22})(\/members)?$/; function route() { const hash = location.hash || "#/"; @@ -85,6 +88,10 @@ function route() { if (nick) { return { kind: nick[1], nick: nick[2] }; } + const room = ROOM_ROUTE.exec(hash); + if (room) { + return { kind: room[2] ? "members" : "room", roomId: room[1] }; + } return { kind: "root" }; } @@ -101,7 +108,14 @@ async function render() { } const where = route(); // На десктопе `#/` показывает первый чат — тот, что вверху списка. - let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null; + // У экранов «карточка контакта» и «участники» открытого чата нет: + // в сайдбаре не выделен никто. + let chatId = null; + if (where.kind === "dm") { + chatId = sync.dmChatId(where.nick); + } else if (where.kind === "room") { + chatId = sync.roomChatId(where.roomId); + } if (where.kind === "root" && wide()) { const list = await sync.chats().catch(() => []); if (mine !== state.paint) { @@ -123,7 +137,9 @@ async function render() { } else if (where.kind === "new") { renderNew(main, ctx); } else if (where.kind === "contact") { - renderContact(main, ctx, where.nick); + parts.push(renderContact(main, ctx, where.nick)); + } else if (where.kind === "members") { + parts.push(renderMembers(main, ctx, where.roomId)); } else if (chatId !== null) { parts.push(renderChat(main, ctx, chatId)); } diff --git a/web/js/sync.js b/web/js/sync.js index a78656f..870207c 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -2,11 +2,17 @@ // Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js // они не ходят — пишет в базу только этот модуль. // -// Правила — docs/protocol.md («События», «Сообщения») и docs/storage.md: -// ACK уходит только после успешной записи в IndexedDB, исходящее живёт -// в pending до 202 и держится за свой ULID, пока время в нём годится -// серверу; отвергнутый по часам переиспользованный id меняется на свежий -// один раз (ADR-036). +// Правила — docs/protocol.md («События», «Сообщения», «Комнаты») +// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB, +// исходящее живёт в pending до 202 и держится за свой ULID, пока время +// в нём годится серверу; отвергнутый по часам переиспользованный id +// меняется на свежий один раз (ADR-036). +// +// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший +// от сервера, сверяется с запомненным; изменившийся ложится в pending +// и блокирует отправку до подтверждения. Ключи комнат — docs/crypto.md, +// «Комната»: владелец заворачивает новый ключ каждому участнику, клиент +// держит все ключи комнаты и расшифровывает любым известным. import * as api from "./api.js"; import { ApiError, NetworkError } from "./api.js"; @@ -16,9 +22,15 @@ import { dmKey, dmLabel, fingerprintOf, + importRoomKey, newId, + newRoomKey, openMessage, + roomLabel, sealMessage, + unwrapRoomKey, + wipe, + wrapRoomKey, } from "./crypto.js"; import { ulid, ulidTime, validUlid } from "./ulid.js"; @@ -41,6 +53,9 @@ const state = { running: false, nick: null, privateKey: null, + // Свой публичный ключ: он проверен при входе, и спрашивать его у сервера + // незачем — заворачивание себе идёт по нему (docs/crypto.md, «Комната»). + publicKey: null, device: null, close: null, // закрыть поток событий online: false, @@ -53,6 +68,17 @@ const state = { // Ключи личных чатов — только в памяти: в IndexedDB они не пишутся, // а выводятся заново из peers (docs/crypto.md, «Чат 1:1»). keys: new Map(), + // Ключи комнат: "|" → CryptoKey. Это кэш над хранилищем + // roomKeys, где ключи и живут. + roomKeys: new Map(), + // Комнаты, где rekey упёрся в неподтверждённый ключ: roomId → ники + // (ADR-016). Состояние экрана, в базу не пишется. + blocked: new Map(), + // Комнаты, которым мы должны новый ключ: участник вышел, а rekey + // не прошёл. Долг поднимается и из события room, и из GET /api/rooms: + // это состояние комнаты, и офлайн владельца его не теряет (ADR-041). + // Отдаётся после ready и после подтверждения ключа (ADR-018). + owed: new Set(), // Неотправленное. Полный проход по messages делается один раз при // старте: индекса по статусу в схеме нет (docs/storage.md). pending: new Set(), @@ -68,12 +94,16 @@ const state = { const bus = new EventTarget(); -// on подписывает обработчик и отдаёт функцию отписки. События три: +// on подписывает обработчик и отдаёт функцию отписки. События пять: // // "net" {online} — доходят ли запросы до сервера // "chats" {} — список чатов изменился // "messages" {chatId, ids, removed} — в чате появились, изменились // или исчезли сообщения +// "peers" {nick} — доверие к ключу ника изменилось: +// появился pending или его подтвердили +// "rooms" {id} — комната изменилась: имя, состав, +// ключ, потребность в rekey // // removed непуст, только когда повтор отправки выдал сообщению новый // ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним @@ -129,6 +159,21 @@ function announceChats() { share({ kind: "chats" }); } +// announcePeer — доверие к ключу ника изменилось: карточке контакта нужен +// новый отпечаток, чату — полоса про смену ключа (docs/ui.md). +function announcePeer(nick) { + emit("peers", { nick }); + share({ kind: "peers", nick }); +} + +// announceRoom — комната изменилась: экрану участников нужен свежий состав +// и знание, чей ключ мешает rekey. Список ников едет вместе с событием: +// он живёт в памяти вкладки, а в базе его нет. +function announceRoom(id) { + emit("rooms", { id }); + share({ kind: "rooms", id, blocked: needsTrust(id) }); +} + // --- соседние вкладки --------------------------------------------------- // share отдаёт изменение соседним вкладкам. Канал открыт, только пока @@ -172,6 +217,31 @@ function receive(data) { case "chats": emit("chats"); return; + case "peers": + if (typeof data.nick === "string") { + // Ключ личного чата выведен из ключа собеседника и лежит в памяти + // этой вкладки: доверие изменилось — выводим заново из peers + // (docs/crypto.md, «Чат 1:1»). Без этого вкладка продолжила бы + // шифровать ключом, который человек только что отверг. + state.keys.delete(data.nick); + // Сохранённые raw перебирает владелец потока: конверты, разобранные + // устаревшим ключом до сброса, иначе остались бы нерасшифрованными. + if (state.release) { + serial(reopen); + } + emit("peers", { nick: data.nick }); + } + return; + case "rooms": + if (typeof data.id === "string") { + if (Array.isArray(data.blocked) && data.blocked.length > 0) { + state.blocked.set(data.id, data.blocked); + } else { + state.blocked.delete(data.id); + } + emit("rooms", { id: data.id }); + } + return; case "pending": // Соседняя вкладка не отправила сообщение и повторять его не будет: // повторяет владелец потока. @@ -202,16 +272,17 @@ export async function start() { } let meta; try { - meta = await db.meta(["nick", "privateKey"]); + meta = await db.meta(["nick", "privateKey", "publicKey"]); } catch { return; } - if (!meta.nick || !meta.privateKey) { + if (!meta.nick || !meta.privateKey || !meta.publicKey) { return; } state.running = true; state.nick = meta.nick; state.privateKey = meta.privateKey; + state.publicKey = meta.publicKey; openChannel(); try { for (const m of await db.pendingMessages()) { @@ -242,8 +313,12 @@ export function stop() { closeChannel(); state.nick = null; state.privateKey = null; + state.publicKey = null; state.device = null; state.keys.clear(); + state.roomKeys.clear(); + state.blocked.clear(); + state.owed.clear(); state.pending.clear(); state.inbox.length = 0; state.wait = RETRY_MIN; @@ -372,6 +447,18 @@ function openStream() { schedule(); } }, + // Комнаты разбираются в общей очереди работ: приём сообщений и раздача + // ключей не должны перемешиваться. + room: (room) => { + if (room) { + serial(() => applyRoom(room)); + } + }, + roomLeft: (data) => { + if (typeof data?.id === "string") { + serial(() => forgetRoom(data.id)); + } + }, ready: () => { state.wait = RETRY_MIN; setOnline(true); @@ -544,25 +631,46 @@ function usable(e) { // не разобрать по причине, которая пройдёт»: конверт остаётся и у нас, // и в очереди сервера — ACK по нему не уходит. Ошибка AEAD // и неизвестный keyId причиной не являются — -// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»). +// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение») +// вместе с raw: по нему попытка повторяется, когда ключ появится +// или когда новый ключ собеседника подтвердят (docs/storage.md). async function decode(envelope) { const me = state.nick; - const peer = envelope.to.dm + const room = typeof envelope.to.room === "string" ? envelope.to.room : null; + const peer = room === null ? (envelope.from === me ? envelope.to.dm : envelope.from) : null; const base = { id: envelope.id, - chatId: peer === null ? db.roomChatId(envelope.to.room) : db.dmChatId(peer), + chatId: room === null ? db.dmChatId(peer) : db.roomChatId(room), from: envelope.from, text: null, ts: envelope.ts, status: "sent", }; - // Комнаты — этап 3: ключа комнаты на устройстве ещё нет. - if (peer === null || envelope.keyId !== DM_KEY_ID) { - return { ...base, undecryptable: "unknown_key", raw: envelope }; + + if (room !== null) { + // Комната расшифровывается любым известным ключом по keyId конверта: + // клиент держит все ключи комнаты (ADR-018). + let key; + try { + key = await roomKeyOf(room, envelope.keyId); + } catch { + return null; + } + if (key === null) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + try { + return { ...base, text: await openMessage(key, { ...envelope, chat: roomLabel(room) }) }; + } catch { + return { ...base, undecryptable: "bad_aead", raw: envelope }; + } } + if (envelope.keyId !== DM_KEY_ID) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } let key; try { key = await chatKey(peer); @@ -577,12 +685,45 @@ async function decode(envelope) { const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) }); return { ...base, text }; } catch { - // Смену ключа собеседника разбирает TOFU (ADR-016) — этап 3; - // до тех пор любая неудача AEAD выглядит одинаково. - return { ...base, undecryptable: "bad_aead", raw: envelope }; + // Расшифровка идёт доверенным ключом. Не сошлось, а у ника ждёт + // подтверждения новый, — сообщение зашифровано им (ADR-016). + const known = await db.peer(peer).catch(() => null); + return { + ...base, + undecryptable: known?.pending ? "key_changed" : "bad_aead", + raw: envelope, + }; } } +// reopen — повторная расшифровка сохранённого raw: пришёл недостающий ключ +// комнаты или подтверждён новый ключ собеседника (docs/storage.md). +// Записи свои, а не входящие: перезапись по id здесь законна (ADR-034). +async function reopen() { + let list; + try { + list = await db.undecryptable(); + } catch { + return; + } + const messages = []; + for (const record of list) { + if (!usable(record.raw)) { + continue; + } + const fresh = await decode(record.raw); + if (fresh === null || fresh.text === null) { + continue; + } + messages.push(fresh); + } + if (messages.length === 0) { + return; + } + await db.saveMessages({ messages, me: state.nick }); + notify(messages); +} + async function ackAll(ids) { for (let i = 0; i < ids.length; i += api.MAX_ACK) { try { @@ -598,10 +739,13 @@ async function ackAll(ids) { // --- после ready -------------------------------------------------------- // afterReady — очередь выдана целиком. Клиент перечитывает контакты -// и повторяет неотправленное (docs/ui.md, «Сеть и состояния»). -// Комнаты — этап 3. +// и комнаты и повторяет неотправленное (docs/ui.md, «Сеть и состояния»): +// события room и room_left в очередь не кладутся, и пропущенное во время +// офлайна восстанавливается только этим (docs/protocol.md, «События»). async function afterReady() { await refreshContacts(); + await refreshRooms(); + await payRekeys(); await retryPending(); } @@ -614,7 +758,9 @@ async function refreshContacts() { } let changed = false; for (const contact of list) { - await rememberPeer(contact.nick, contact.publicKey, contact.createdAt); + // Список контактов — главное место сверки TOFU: ключи всех собеседников + // приходят от сервера после каждого ready (ADR-016). + await seePeer(contact.nick, contact.publicKey, contact.createdAt).catch(() => {}); const chatId = db.dmChatId(contact.nick); if (!(await db.chat(chatId))) { await db.putChat(db.blankChat(chatId)); @@ -646,7 +792,8 @@ async function retryPending() { // --- собеседники -------------------------------------------------------- -// chatKey — ключ личного чата из памяти или выведенный заново. +// chatKey — ключ личного чата из памяти или выведенный заново. Выводится +// он из доверенного ключа: ждущий подтверждения в дело не идёт (ADR-016). async function chatKey(peer) { const cached = state.keys.get(peer); if (cached) { @@ -658,43 +805,566 @@ async function chatKey(peer) { return key; } -// knownPeer — запись TOFU. Ключа нет — берём у сервера и запоминаем -// как есть: сверка изменившегося ключа — этап 3 (ADR-016). +// knownPeer — запись TOFU. Ника ещё нет — берём ключ у сервера: первый +// ключ запоминается молча, trust on first use (ADR-016). async function knownPeer(nick) { const known = await db.peer(nick); if (known) { return known; } const user = await api.user(nick); - return rememberPeer(user.nick, user.publicKey); + return seePeer(user.nick, user.publicKey); } -// rememberPeer запоминает ключ при первом контакте. Уже знакомый ник -// не трогается: смена ключа — состояние, а не перезапись (ADR-016). -async function rememberPeer(nick, publicKey, firstSeen = Date.now()) { +// seePeer — сверка TOFU. Зовётся при каждом получении публичного ключа ника, +// откуда бы он ни пришёл: GET /api/users, список контактов, состав комнаты, +// отправитель завёрнутого ключа (ADR-016). +// +// Первый ключ ника запоминается молча. Совпавший — ничего не меняет. +// Изменившийся ложится в pending: отправка этому нику блокируется, входящее +// его ключом остаётся нерасшифрованным, rekey ему не выполняется — всё +// до явного «доверять новому ключу». +// +// Сервер, вернувшийся к доверенному ключу, снимает pending: смены не +// случилось, а подтверждать было бы уже отозванный ключ (ADR-040, +// docs/ui.md, «Карточка контакта»). +async function seePeer(nick, publicKey, firstSeen = Date.now()) { + const fingerprint = await fingerprintOf(publicKey); const known = await db.peer(nick); - if (known) { + if (!known) { + const record = { nick, publicKey, fingerprint, firstSeen, pending: null }; + await db.putPeer(record); + return record; + } + if (known.fingerprint === fingerprint) { + if (!known.pending) { + return known; + } + const record = { ...known, pending: null }; + await db.putPeer(record); + announcePeer(nick); + return record; + } + if (known.pending?.fingerprint === fingerprint) { return known; } - const record = { - nick, - publicKey, - fingerprint: await fingerprintOf(publicKey), - firstSeen, - pending: null, - }; + const record = { ...known, pending: { publicKey, fingerprint, seenAt: Date.now() } }; await db.putPeer(record); + announcePeer(nick); return record; } +// trustKey — «доверять новому ключу» из карточки контакта (docs/ui.md). +// Ключ из pending становится основным, pending чистится, и всё, что +// упиралось в старый ключ, повторяется: завёрнутые ключи комнат от этого +// ника, сохранённые raw и неотправленное. +// +// Отдаёт, было ли что подтверждать. +export function trustKey(nick) { + return serial(async () => { + if (!state.running) { + return false; + } + const known = await db.peer(nick); + if (!known?.pending) { + return false; + } + await db.putPeer({ + nick: known.nick, + publicKey: known.pending.publicKey, + fingerprint: known.pending.fingerprint, + // firstSeen — когда ник встретился впервые, а не когда сменил ключ. + firstSeen: known.firstSeen, + pending: null, + }); + // Ключ личного чата выводится из ключа собеседника — выводим заново. + state.keys.delete(nick); + announcePeer(nick); + await refreshRooms(); + // Владелец, чей rekey упирался в этот ключ, доводит его до конца. + await payRekeys(); + await reopen(); + await retryPending(); + return true; + }); +} + +// --- комнаты ------------------------------------------------------------ + +// TrustNeeded — rekey не выполняется участнику с изменившимся и +// неподтверждённым ключом (ADR-016). nicks — чьи ключи ждут подтверждения; +// владелец повторяет операцию после «доверять новому ключу». +export class TrustNeeded extends Error { + constructor(nicks) { + super("нужно подтвердить ключ"); + this.name = "TrustNeeded"; + this.nicks = nicks; + } +} + +// needsTrust — чьи ключи мешают rekey комнаты (docs/ui.md, «Участники»). +export function needsTrust(roomId) { + return state.blocked.get(roomId) ?? []; +} + +// usableRoom и usableKey — форма Room и завёрнутого ключа +// (docs/protocol.md, «Типы»). Сервер её держит, но записи собираются +// из этих полей, и мусор до базы не доходит. +function usableRoom(r) { + return r !== null && typeof r === "object" + && typeof r.id === "string" && r.id !== "" + && typeof r.name === "string" + && typeof r.owner === "string" + && Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string") + && Number.isFinite(r.createdAt) + && (r.key === null || r.key === undefined || usableKey(r.key)); +} + +function usableKey(k) { + return k !== null && typeof k === "object" + && typeof k.keyId === "string" && k.keyId !== "" + && typeof k.from === "string" + && typeof k.iv === "string" && typeof k.ct === "string"; +} + +// roomKeyOf — ключ комнаты по keyId конверта; null, если такого нет. +async function roomKeyOf(roomId, keyId) { + const at = `${roomId}|${keyId}`; + const cached = state.roomKeys.get(at); + if (cached) { + return cached; + } + const record = await db.roomKey(roomId, keyId); + if (!record) { + return null; + } + state.roomKeys.set(at, record.key); + return record.key; +} + +// currentKeyId — текущий ключ комнаты: последний полученный этим +// устройством. Им шифруется исходящее. Порядок — получения, а не сервера: +// номера ключа протокол не несёт, и в гонке двух rekey эти порядки могут +// разойтись (ADR-042). Оба ключа при этом живы, сервер принимает любой. +async function currentKeyId(roomId) { + const list = await db.roomKeysOf(roomId); + return list.length > 0 ? list[list.length - 1].keyId : null; +} + +// saveRoom кладёт комнату в список чатов. Имя, владелец и состав приходят +// от сервера; лента, счётчик непрочитанных и граница «новых» — местные. +function saveRoom(room) { + return db.mergeChat(db.roomChatId(room.id), { + type: "room", + roomId: room.id, + title: room.name, + owner: room.owner, + members: [...room.members], + // Комната в списке есть, пока мы её участники. + hidden: false, + }); +} + +// senderKey — публичный ключ того, кто завернул ключ комнаты. Свой берётся +// с устройства: он проверен при входе и от сервера не зависит. Чужой +// приходит от сервера и проходит через TOFU; ключ, ждущий подтверждения, — +// null: разворачивать им нельзя (ADR-016). +async function senderKey(nick) { + if (nick === state.nick) { + return state.publicKey; + } + const user = await api.user(nick); + const record = await seePeer(nick, user.publicKey); + return record.pending ? null : record.publicKey; +} + +// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его +// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»). +// Отдаёт, появился ли новый ключ. +// +// Уже известный keyId не трогается: клиент держит все ключи комнаты. +// Не развернувшийся не теряется — сервер отдаёт его снова с каждым +// GET /api/rooms. +// +// Заворачивает ключ участник комнаты — владелец или тот, кто им был +// до передачи владения (ADR-018). Ключ от постороннего ника отвергается +// до запроса его публичного ключа: TOFU запоминает первый ключ молча, +// поэтому незнакомый распространитель — это подмена, а не первый +// контакт (ADR-039). +async function takeRoomKey(room) { + const wrapped = room.key; + if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) { + return false; + } + if (await db.roomKey(room.id, wrapped.keyId)) { + return false; + } + let publicKey; + try { + publicKey = await senderKey(wrapped.from); + } catch { + // Ключа отправителя сейчас не добыть: попробуем при следующем ready. + return false; + } + if (publicKey === null) { + return false; + } + let key; + try { + key = await unwrapRoomKey(state.privateKey, publicKey, { + roomId: room.id, + keyId: wrapped.keyId, + from: wrapped.from, + to: state.nick, + iv: wrapped.iv, + ct: wrapped.ct, + }); + } catch { + return false; + } + const stored = await db.saveRoomKey({ + roomId: room.id, + keyId: wrapped.keyId, + key, + from: wrapped.from, + }); + if (stored) { + state.roomKeys.set(`${room.id}|${wrapped.keyId}`, key); + } + return stored; +} + +// applyRoom разбирает событие room: создание, смена состава, rekey, выход +// участника (docs/protocol.md, «События»). +async function applyRoom(room) { + if (!state.running || !usableRoom(room)) { + return; + } + const changed = await saveRoom(room); + const fresh = await takeRoomKey(room); + if (changed) { + announceChats(); + } + announceRoom(room.id); + if (fresh) { + await reopen(); + await retryPending(); + } + // Участник вышел — комната осталась на ключе, который он знает. Новый + // раздаёт владелец тем же запросом с пустыми add и remove (ADR-018). + if (room.needsRekey === true && room.owner === state.nick) { + await rekey(room.id); + } +} + +// refreshRooms перечитывает комнаты после каждого ready: события room +// и room_left в очередь не кладутся (docs/protocol.md, «События»). +async function refreshRooms() { + // Список известных комнат читается до запроса: комната, заведённая + // соседней вкладкой, пока ответ летел, в него не попадёт, а прятать + // её нельзя — она есть и на сервере, и в базе (ADR-035). + let known; + try { + known = await db.chats(); + } catch { + known = []; + } + let list; + try { + list = await api.rooms(); + } catch { + return; + } + if (!Array.isArray(list)) { + return; + } + let changed = false; + let fresh = false; + const seen = new Set(); + for (const room of list) { + if (!usableRoom(room)) { + continue; + } + seen.add(room.id); + const moved = await saveRoom(room); + const key = await takeRoomKey(room); + changed = changed || moved; + fresh = fresh || key; + if (moved || key) { + announceRoom(room.id); + } + // Долг по ключу — состояние комнаты, а не свойство события (ADR-041): + // владелец поднимает его и после офлайна, и после перезагрузки вкладки. + // Отдаёт долг payRekeys — он идёт следом за refreshRooms. + if (room.needsRekey === true && room.owner === state.nick) { + state.owed.add(room.id); + } + } + // Комнату, из которой нас убрали, пока мы были офлайн, видно только так: + // события мы не получили, а в списке её больше нет. + for (const chat of known) { + if (chat.type === "room" && !seen.has(chat.roomId)) { + await db.hideChat(chat.id, true); + state.blocked.delete(chat.roomId); + state.owed.delete(chat.roomId); + announceRoom(chat.roomId); + changed = true; + } + } + if (changed) { + announceChats(); + } + // retryPending зовёт afterReady следом — второй раз не нужно. + if (fresh) { + await reopen(); + } +} + +// forgetRoom убирает комнату из списка: нас удалили, комната удалена или +// мы вышли сами. История на устройстве не трогается — она единственная +// копия, а новых сообщений в этой комнате нам уже не доставят. +async function forgetRoom(roomId) { + state.blocked.delete(roomId); + state.owed.delete(roomId); + const chatId = db.roomChatId(roomId); + const record = await db.chat(chatId); + if (!record || record.hidden) { + return; + } + await db.hideChat(chatId, true); + announceChats(); + announceRoom(roomId); +} + +// memberKeys — публичные ключи итогового состава с проверкой TOFU +// (ADR-016, ADR-018). Ник с неподтверждённым ключом останавливает всю +// операцию: rekey ему не выполняется, а состав без ключа невозможен. +async function memberKeys(members) { + const keys = new Map(); + const blocked = []; + for (const nick of members) { + if (nick === state.nick) { + keys.set(nick, state.publicKey); + continue; + } + const user = await api.user(nick); + const record = await seePeer(nick, user.publicKey); + if (record.pending) { + blocked.push(nick); + continue; + } + keys.set(nick, record.publicKey); + } + if (blocked.length > 0) { + throw new TrustNeeded(blocked); + } + return keys; +} + +// distribute генерирует ключ комнаты и заворачивает его каждому участнику, +// включая себя: заворачивание себе — ECDH(myPrivate, myPublic), тем же кодом +// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания, +// потом импортируются non-extractable и затираются. +async function distribute(roomId, members, keys) { + const { keyId, bytes } = newRoomKey(); + try { + const wrapped = []; + for (const nick of members) { + wrapped.push(await wrapRoomKey( + state.privateKey, + keys.get(nick), + { roomId, keyId, from: state.nick, to: nick }, + bytes, + )); + } + return { keyId, wrapped, key: await importRoomKey(bytes) }; + } finally { + wipe(bytes); + } +} + +// keepRoomKey кладёт свой же розданный ключ: у распространителя он +// не разворачивается, а берётся из сырых байт до их затирания. +async function keepRoomKey(roomId, keyId, key) { + const stored = await db.saveRoomKey({ roomId, keyId, key, from: state.nick }); + if (stored) { + state.roomKeys.set(`${roomId}|${keyId}`, key); + } + return stored; +} + +// createRoom заводит комнату. Идентификатор генерирует клиент: ключ +// заворачивается до запроса и привязан к roomId (ADR-037). Отдаёт chatId. +export function createRoom(name) { + return serial(async () => { + const title = String(name ?? "").trim(); + if (!state.running || title === "") { + return null; + } + const keys = await memberKeys([state.nick]); + for (let attempt = 0; attempt < 3; attempt += 1) { + const roomId = newId(); + const { keyId, wrapped, key } = await distribute(roomId, [state.nick], keys); + let room; + try { + room = await api.createRoom(state.device, { + id: roomId, + name: title, + keyId, + keys: wrapped, + }); + } catch (err) { + if (err instanceof ApiError && err.code === "room_conflict") { + continue; + } + throw err; + } + await keepRoomKey(roomId, keyId, key); + // Форма ответа так же непроверена, как форма события. Чужой + // идентификатор в ответе означал бы ключ, привязанный не к той + // комнате: roomId вплетён в info и AAD (ADR-037). + if (usableRoom(room) && room.id === roomId) { + await saveRoom(room); + } + announceChats(); + announceRoom(roomId); + return db.roomChatId(roomId); + } + throw new Error("не удалось завести комнату"); + }); +} + +// changeMembers — смена состава и rekey одним запросом (ADR-018): владелец +// получает публичные ключи итогового состава с проверкой TOFU, генерирует +// ключ, заворачивает каждому и только потом отправляет. +// +// Ники приходят уже приведёнными к форме ADR-019: их проверяет экран. +export function changeMembers(roomId, { add = [], remove = [] } = {}) { + return serial(() => changeRoom(roomId, add, remove)); +} + +// rekey — новый ключ прежнему составу: тот же запрос с пустыми add +// и remove (ADR-018). Зовётся у владельца, получившего room с needsRekey. +// Неудача долг не снимает: попытка повторится после ready. +async function rekey(roomId) { + state.owed.add(roomId); + try { + await changeRoom(roomId, [], []); + } catch (err) { + if (err instanceof TrustNeeded) { + // Владелец видит, чей ключ надо подтвердить, и повторяет операцию + // после подтверждения (ADR-016). + state.blocked.set(roomId, err.nicks); + announceRoom(roomId); + return; + } + // Сеть или отказ сервера: попытка повторится после следующего ready. + } +} + +// payRekeys отдаёт долги по ключам комнат. Комната, которой мы больше +// не владеем или из которой ушли, долг снимает: новый ключ раздаёт +// её владелец. +async function payRekeys() { + for (const roomId of [...state.owed]) { + let record; + try { + record = await db.chat(db.roomChatId(roomId)); + } catch { + return; + } + if (!record || record.type !== "room" || record.hidden || record.owner !== state.nick) { + state.owed.delete(roomId); + if (state.blocked.delete(roomId)) { + announceRoom(roomId); + } + continue; + } + await rekey(roomId); + } +} + +async function changeRoom(roomId, add, remove) { + if (!state.running) { + return null; + } + const record = await db.chat(db.roomChatId(roomId)); + if (!record || record.type !== "room") { + throw new Error("нет такой комнаты"); + } + const members = finalMembers(record.members ?? [], add, remove); + const keys = await memberKeys(members); + const { keyId, wrapped, key } = await distribute(roomId, members, keys); + const room = await api.changeMembers(roomId, { add, remove, keyId, keys: wrapped }); + const stored = await keepRoomKey(roomId, keyId, key); + // Ключ роздан всему составу: долг закрыт, и подтверждать больше нечего. + state.owed.delete(roomId); + state.blocked.delete(roomId); + if (usableRoom(room)) { + await saveRoom(room); + } + announceChats(); + announceRoom(roomId); + if (stored) { + await reopen(); + await retryPending(); + } + return room; +} + +// finalMembers — итоговый состав: текущий без remove плюс add, без повторов +// и с сохранением порядка. +function finalMembers(current, add, remove) { + const gone = new Set(remove); + const seen = new Set(); + const out = []; + for (const nick of [...current.filter((nick) => !gone.has(nick)), ...add]) { + if (seen.has(nick)) { + continue; + } + seen.add(nick); + out.push(nick); + } + return out; +} + +// leaveRoom — «выйти из комнаты». Владение уходит участнику с наименьшим +// joined_at, опустевшая комната удаляется — это дело сервера (ADR-018). +export function leaveRoom(roomId) { + return serial(async () => { + await api.leaveRoom(state.device, roomId); + await forgetRoom(roomId); + }); +} + +// deleteRoom — «удалить комнату», только у владельца. Участникам уходит +// room_left. +export function deleteRoom(roomId) { + return serial(async () => { + await api.removeRoom(roomId); + await forgetRoom(roomId); + }); +} + // --- отправка ----------------------------------------------------------- +// Postponed — отправить сейчас нечем, но причина пройдёт: нет ключа комнаты +// или ключ собеседника изменился и ждёт подтверждения (ADR-016). Сообщение +// остаётся pending и уходит, когда причина уйдёт. +class Postponed extends Error { + constructor() { + super("отправка отложена"); + this.name = "Postponed"; + } +} + // send — новое исходящее сообщение. Пустая строка не отправляется; // предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»). // Отдаёт id записи или null, если отправлять нечего. export function send(chatId, text) { const body = String(text ?? "").trim(); - if (!state.running || body === "" || db.peerOf(chatId) === null) { + const known = db.peerOf(chatId) !== null || db.roomIdOf(chatId) !== null; + if (!state.running || body === "" || !known) { return Promise.resolve(null); } return serial(() => attempt({ chatId, text: body }, null)); @@ -734,7 +1404,8 @@ const REUSE = 4 * 60 * 1000; // по часам. async function attempt(source, previousId, fresh = false) { const peer = db.peerOf(source.chatId); - if (peer === null) { + const roomId = db.roomIdOf(source.chatId); + if (peer === null && roomId === null) { state.pending.delete(previousId); return null; } @@ -760,7 +1431,7 @@ async function attempt(source, previousId, fresh = false) { me: state.nick, }); notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []); - const err = await post(message, peer); + const err = await post(message, peer, roomId); if (err === null) { return message.id; } @@ -786,6 +1457,43 @@ function reusable(id) { return ms !== null && Math.abs(Date.now() - ms) < REUSE; } +// dmEnvelope — конверт личного чата. Ключ собеседника изменился и ждёт +// подтверждения — отправка блокируется (ADR-016): сообщение остаётся +// pending и уходит после «доверять новому ключу». +async function dmEnvelope(message, peer) { + const known = await db.peer(peer); + if (known?.pending) { + throw new Postponed(); + } + const sealed = await sealMessage(await chatKey(peer), { + id: message.id, + chat: dmLabel(state.nick, peer), + from: state.nick, + keyId: DM_KEY_ID, + text: message.text, + }); + return { id: message.id, to: { dm: peer }, keyId: DM_KEY_ID, iv: sealed.iv, ct: sealed.ct }; +} + +// roomEnvelope — конверт комнаты: keyId текущего ключа, chat — "room:" +// (docs/crypto.md, «Сообщение»). Ключа ещё нет — отправка откладывается +// до его прихода. +async function roomEnvelope(message, roomId) { + const keyId = await currentKeyId(roomId); + const key = keyId === null ? null : await roomKeyOf(roomId, keyId); + if (key === null) { + throw new Postponed(); + } + const sealed = await sealMessage(key, { + id: message.id, + chat: roomLabel(roomId), + from: state.nick, + keyId, + text: message.text, + }); + return { id: message.id, to: { room: roomId }, keyId, iv: sealed.iv, ct: sealed.ct }; +} + // post шифрует и отдаёт конверт серверу. from в AAD — собственный ник: // сервер проставит то же значение из сессии, и AAD сойдётся у получателя // (docs/crypto.md, «Сообщение»). @@ -793,23 +1501,12 @@ function reusable(id) { // Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt — // clock_skew на переиспользованном идентификаторе кончается не полосой, // а второй попыткой. -async function post(message, peer) { +async function post(message, peer, roomId) { let envelope; try { - const sealed = await sealMessage(await chatKey(peer), { - id: message.id, - chat: dmLabel(state.nick, peer), - from: state.nick, - keyId: DM_KEY_ID, - text: message.text, - }); - envelope = { - id: message.id, - to: { dm: peer }, - keyId: DM_KEY_ID, - iv: sealed.iv, - ct: sealed.ct, - }; + envelope = peer !== null + ? await dmEnvelope(message, peer) + : await roomEnvelope(message, roomId); } catch (err) { return err; } @@ -860,10 +1557,12 @@ async function settle(message, err) { notify([failed]); } -// transient — отказ, который пройдёт сам: запрос не дошёл или сервер -// не справился. Повтор допустим (ADR-027). +// transient — отказ, который пройдёт сам: запрос не дошёл, сервер +// не справился или шифровать пока нечем. Повтор допустим (ADR-027). function transient(err) { - return err instanceof NetworkError || (err instanceof ApiError && err.status >= 500); + return err instanceof NetworkError + || err instanceof Postponed + || (err instanceof ApiError && err.status >= 500); } // --- действия экранов --------------------------------------------------- @@ -873,7 +1572,7 @@ function transient(err) { // Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»). export async function openDm(peer) { const answer = await api.addContact(peer); - await rememberPeer(answer.nick, answer.publicKey); + await seePeer(answer.nick, answer.publicKey); const chatId = db.dmChatId(answer.nick); const existing = await db.chat(chatId); if (!existing || existing.hidden) { @@ -906,6 +1605,18 @@ export async function markRead(chatId) { } // Чтение для экранов. Писать в базу им не нужно: всё, что меняет -// состояние, живёт здесь. dmChatId и peerOf — форма ключа чата -// (docs/storage.md): экраны собирают её из ника маршрута, а не из строки. -export { chats, chat, message, messagesBefore, peer, dmChatId, peerOf, PAGE } from "./db.js"; +// состояние, живёт здесь. dmChatId, roomChatId, peerOf и roomIdOf — форма +// ключа чата (docs/storage.md): экраны собирают её из ника или +// идентификатора маршрута, а не из строки. +export { + chats, + chat, + message, + messagesBefore, + peer, + dmChatId, + roomChatId, + peerOf, + roomIdOf, + PAGE, +} from "./db.js"; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index ff7ed8a..813706a 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -16,11 +16,20 @@ const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numer const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" }); const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" }); -// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет — -// это про комнату; всё остальное в личном чате означает чужой ключ. +// Тексты нерасшифрованного — docs/ui.md, «Чат». Строк там две, а причин +// в записи три (docs/storage.md): «нет ключа комнаты» — это unknown_key +// в комнате, всё остальное сводится к «ключ изменился». const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты"; const KEY_CHANGED = "не удалось расшифровать: ключ изменился"; +// Сколько символов имени комнаты попадает в подсказку ввода. Строка ввода +// растёт под placeholder так же, как под текст, и имя в 64 символа (ADR-018) +// занимало бы три строки. Имя укорачивается многоточием, как в шапке, только +// разметкой: text-overflow к placeholder не применяется. Двенадцать — +// столько, чтобы «сообщение в #имя…» умещалось в одну строку на самом +// узком из целевых экранов (360 px). +const NAME_IN_HINT = 12; + // Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее // сообщение подматывает ленту только тогда, когда он и так смотрит конец. const NEAR_BOTTOM = 80; @@ -32,8 +41,18 @@ export function renderChat(root, ctx, chatId) { chatId, me: ctx.me.nick, peer: sync.peerOf(chatId), + roomId: sync.roomIdOf(chatId), limit: ctx.config?.maxMessageChars ?? LIMIT, alive: true, + // Имя комнаты; до чтения записи чата вместо него идентификатор, + // как в db.blankChat. + name: sync.roomIdOf(chatId), + // Ключ собеседника изменился и ждёт подтверждения: ввод заблокирован + // (ADR-016). + blocked: false, + // Комнаты у нас больше нет: вышли сами, убрал владелец, комната + // удалена. Ввод заблокирован, лента остаётся (ADR-044). + gone: false, // Лента: записи по возрастанию id и их строки в разметке. items: [], nodes: new Map(), @@ -58,6 +77,18 @@ export function renderChat(root, ctx, chatId) { } }); const offNet = sync.on("net", () => paintBar(view)); + // Доверие к ключу собеседника меняет полосу и ввод; имя комнаты приходит + // из GET /api/rooms и события room, иногда позже первой отрисовки. + const offPeers = sync.on("peers", (detail) => { + if (view.peer !== null && detail.nick === view.peer) { + run(view, () => checkPeer(view)); + } + }); + const offRooms = sync.on("rooms", (detail) => { + if (view.roomId !== null && detail.id === view.roomId) { + run(view, () => refreshRoom(view)); + } + }); const media = matchMedia(DESKTOP); const onMedia = () => paint(view, true); media.addEventListener("change", onMedia); @@ -68,6 +99,8 @@ export function renderChat(root, ctx, chatId) { view.alive = false; offMessages(); offNet(); + offPeers(); + offRooms(); media.removeEventListener("change", onMedia); }; } @@ -81,20 +114,65 @@ function run(view, task) { // --- разметка ----------------------------------------------------------- -// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева -// нужен там, где виден один экран за раз; на десктопе его прячет CSS. +// head — шапка: имя чата, по нажатию — участники комнаты или карточка +// контакта (docs/ui.md, «Чат»). «назад» слева нужен там, где виден один +// экран за раз; на десктопе его прячет CSS. function head(view) { const bar = el("div", "head"); const back = el("button", "back back--chat", "назад"); back.type = "button"; back.addEventListener("click", () => view.ctx.go("#/")); - const title = el("button", "chat-title", `@${view.peer}`); - title.type = "button"; - title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`)); - bar.append(back, title); + view.title = el("button", "chat-title", titleText(view)); + view.title.type = "button"; + view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null + ? `#/room/${view.roomId}/members` + : `#/contact/${view.peer}`)); + bar.append(back, view.title); return bar; } +function titleText(view) { + return view.roomId !== null ? `#${view.name}` : `@${view.peer}`; +} + +// shortName — имя комнаты для подсказки ввода: длинное обрезается +// многоточием. Считается символами, а не единицами utf-16: имя ограничено +// символами (docs/protocol.md), и разрезать пару посередине незачем. +function shortName(name) { + const chars = Array.from(name); + return chars.length > NAME_IN_HINT ? `${chars.slice(0, NAME_IN_HINT).join("")}…` : name; +} + +// refreshRoom обновляет имя комнаты в шапке и placeholder ввода — имя +// приходит от сервера и бывает известно позже первой отрисовки — и состояние +// членства: комната, из состава которой нас больше нет, гасит ввод +// и показывает полосу (ADR-044). +async function refreshRoom(view, known) { + if (view.roomId === null) { + return; + } + let record = known; + if (record === undefined) { + try { + record = await sync.chat(view.chatId); + } catch { + return; + } + } + if (!view.alive) { + return; + } + view.name = record?.title || view.roomId; + view.title.textContent = titleText(view); + view.field.placeholder = `сообщение в #${shortName(view.name)}`; + // Скрытая запись комнаты — это room_left или собственный выход: + // отправлять больше некуда, и сервер ответил бы not_member. + view.gone = record?.hidden === true; + view.field.disabled = view.gone; + view.send.disabled = view.gone; + paintBar(view); +} + // composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>» // цветом mark. Enter отправляет только на десктопе; на мобильном он делает // перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»). @@ -118,10 +196,10 @@ function composer(view) { view.counter = el("span", "counter"); view.counter.hidden = true; - const send = el("button", "input__send", ">"); - send.type = "submit"; + view.send = el("button", "input__send", ">"); + view.send.type = "submit"; - row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send); + row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send); form.append(view.bar, row); view.field.addEventListener("input", () => count(view)); @@ -152,7 +230,7 @@ function count(view) { function submit(view) { const text = view.field.value; - if (text.trim() === "") { + if (view.blocked || view.gone || text.trim() === "") { return; } view.field.value = ""; @@ -174,6 +252,11 @@ async function load(view) { if (!view.alive) { return; } + await refreshRoom(view, record ?? null); + await checkPeer(view); + if (!view.alive) { + return; + } view.items = list; view.newId = firstUnread(record, list, view.me); paint(view, true); @@ -342,7 +425,7 @@ function text(view, record) { const node = el("div", "text"); if (record.text === null) { node.classList.add("text--none"); - node.textContent = view.peer === null && record.undecryptable === "unknown_key" + node.textContent = view.roomId !== null && record.undecryptable === "unknown_key" ? NO_ROOM_KEY : KEY_CHANGED; return node; @@ -362,25 +445,82 @@ function text(view, record) { return node; } -// paintBar — полоса над вводом. Причина одна за раз: отказ отправки +// checkPeer — состояние доверия к ключу собеседника. Ключ изменился +// и ждёт подтверждения — ввод заблокирован до «доверять новому ключу» +// (docs/ui.md, «Чат»). В комнате блокировать нечего: ключ там симметричный, +// а чьи ключи мешают rekey, показывает экран участников. +async function checkPeer(view) { + if (view.peer === null) { + return; + } + let record = null; + try { + record = await sync.peer(view.peer); + } catch { + // Базы нет — считаем ключ прежним: отправка не блокируется. + } + if (!view.alive) { + return; + } + view.blocked = !!record?.pending; + // Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном. + view.field.disabled = view.blocked; + view.send.disabled = view.blocked; + paintBar(view); +} + +// paintBar — полоса над вводом. Причина одна за раз (ADR-033). Порядок: +// комнаты у нас больше нет — перебивает всё, отправлять некуда (ADR-044); +// предупреждение о ключе — только оно блокирует ввод в личном чате, +// и пока оно висит, повторять отправку нечем (ADR-038); отказ отправки // перебивает «нет соединения», потому что он про конкретное сообщение -// и уходит при следующей попытке (ADR-033). +// и уходит при следующей попытке. function paintBar(view) { + if (view.gone) { + band(view, "bar bar--mark", "вы больше не участник комнаты", false); + return; + } + if (view.blocked) { + band(view, "bar bar--mark", `ключ @${view.peer} изменился. сверьте отпечаток лично.`, true); + return; + } const failed = lastFailed(view); if (failed) { - view.bar.className = "bar bar--mark"; - view.bar.textContent = failed.error; - view.bar.hidden = false; + band(view, "bar bar--mark", failed.error, false); return; } if (!sync.online()) { - view.bar.className = "bar"; - view.bar.textContent = "нет соединения"; - view.bar.hidden = false; + band(view, "bar", "нет соединения", false); return; } + clear(view.bar); view.bar.hidden = true; - view.bar.textContent = ""; +} + +// band — сама полоса. Кнопка «доверять новому ключу» стоит в ней же: +// подтверждение — единственный выход из состояния (ADR-016). +function band(view, className, caption, trust) { + clear(view.bar); + view.bar.className = className; + view.bar.append(el("span", null, caption)); + if (trust) { + const yes = el("button", "link", "доверять новому ключу"); + yes.type = "button"; + yes.addEventListener("click", () => { + yes.disabled = true; + run(view, async () => { + try { + await sync.trustKey(view.peer); + } finally { + // Удалось — полосу перерисует событие «peers»; нет — кнопка + // снова готова к нажатию. + yes.disabled = false; + } + }); + }); + view.bar.append(yes); + } + view.bar.hidden = false; } // lastFailed — последнее своё неотправленное сообщение с текстом отказа diff --git a/web/js/ui/chats.js b/web/js/ui/chats.js index 1589403..74400be 100644 --- a/web/js/ui/chats.js +++ b/web/js/ui/chats.js @@ -1,7 +1,7 @@ // Список чатов в сайдбаре — docs/ui.md, «Список чатов». // // Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит -// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет. +// sync.chats). Пустая секция не рисуется. import * as sync from "../sync.js"; import { clear, el } from "./dom.js"; diff --git a/web/js/ui/contact.js b/web/js/ui/contact.js index 5411c11..c180a03 100644 --- a/web/js/ui/contact.js +++ b/web/js/ui/contact.js @@ -1,33 +1,38 @@ // Карточка контакта — docs/ui.md, «Карточка контакта». // -// Смена ключа собеседника и «доверять новому ключу» появятся вместе -// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending. +// Отпечаток доверенного ключа, а если ключ ника изменился и ждёт +// подтверждения — оба отпечатка и «доверять новому ключу» (ADR-016). import * as sync from "../sync.js"; import { fingerprintGroups } from "../crypto.js"; -import { button, el, message, setError, setNote } from "./dom.js"; +import { button, clear, el, message, setError, setNote } from "./dom.js"; +// renderContact рисует карточку в root и отдаёт отписку. export function renderContact(root, ctx, nick) { + const view = { + ctx, + nick, + alive: true, + // Событий «peers» приходит больше одного подряд; рисует последнее. + generation: 0, + }; root.append(head(ctx, nick)); const body = el("div", "body settings"); + view.card = el("section", "block block--first"); + body.append(view.card, remove(ctx, nick)); root.append(body); - const card = el("section", "block block--first"); - body.append(card, remove(ctx, nick)); - - // Отпечаток лежит в записи TOFU; её может ещё не быть, если чат - // открыли до первого ключа. - sync.peer(nick).then((record) => { - if (!record?.fingerprint || !card.isConnected) { - return; + const off = sync.on("peers", (detail) => { + if (detail.nick === view.nick) { + paint(view); } - const groups = fingerprintGroups(record.fingerprint); - card.append( - el("p", "fp", groups.slice(0, 8).join(" ")), - el("p", "fp", groups.slice(8).join(" ")), - el("p", "fp-hint", "сверьте с собеседником голосом или лично"), - ); - }).catch(() => {}); + }); + paint(view); + + return () => { + view.alive = false; + off(); + }; } function head(ctx, nick) { @@ -39,6 +44,57 @@ function head(ctx, nick) { return bar; } +// paint рисует отпечатки. Записи TOFU может ещё не быть: чат открыли +// раньше, чем пришёл первый ключ. +async function paint(view) { + const mine = ++view.generation; + let record = null; + try { + record = await sync.peer(view.nick); + } catch { + // Базы нет — показывать нечего. + } + if (!view.alive || mine !== view.generation || !record?.fingerprint) { + return; + } + clear(view.card); + if (!record.pending) { + fingerprint(view.card, record.fingerprint, null); + view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично")); + return; + } + // Ключ изменился: показываем оба отпечатка с пометками, чтобы + // подтверждали не наугад (ADR-038). + fingerprint(view.card, record.fingerprint, "старый"); + fingerprint(view.card, record.pending.fingerprint, "новый"); + view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"), trust(view)); +} + +// fingerprint — 64 hex группами по 4 в две строки (ADR-016). +function fingerprint(box, value, label) { + if (label) { + box.append(el("p", "fp-label", label)); + } + const groups = fingerprintGroups(value); + box.append(el("p", "fp", groups.slice(0, 8).join(" ")), el("p", "fp", groups.slice(8).join(" "))); +} + +// trust — «доверять новому ключу»: ключ из pending становится основным, +// и всё, что в него упиралось, повторяется (ADR-016). +function trust(view) { + const yes = button("доверять новому ключу"); + yes.addEventListener("click", async () => { + yes.disabled = true; + try { + await sync.trustKey(view.nick); + } catch { + // Не вышло — запись цела, экран перерисуется с теми же ключами. + yes.disabled = false; + } + }); + return yes; +} + // remove — «убрать из списка»: строка контакта уходит с сервера, история // на устройстве остаётся (ADR-019). function remove(ctx, nick) { diff --git a/web/js/ui/members.js b/web/js/ui/members.js new file mode 100644 index 0000000..e76a0c7 --- /dev/null +++ b/web/js/ui/members.js @@ -0,0 +1,249 @@ +// Участники комнаты — docs/ui.md, «Участники». +// +// Состав, владелец и то, чей ключ мешает rekey, приходят из sync: экран +// не пишет в базу и не ходит в сеть сам. + +import * as sync from "../sync.js"; +import { button, clear, confirmPanel, el, message, setError, setNote } from "./dom.js"; + +// renderMembers рисует экран в root и отдаёт отписку. +export function renderMembers(root, ctx, roomId) { + const view = { + ctx, + roomId, + chatId: sync.roomChatId(roomId), + me: ctx.me.nick, + alive: true, + // Событий «rooms» приходит больше одного подряд; рисует последнее. + generation: 0, + }; + + view.title = el("span", "title", "#"); + root.append(head(view)); + + const body = el("div", "body settings"); + + // Полоса «нужен новый ключ комнаты» — единственный акцент экрана + // (docs/identity/brief.md). + view.warn = el("p", "bar bar--mark"); + view.warn.hidden = true; + view.warn.setAttribute("aria-live", "polite"); + + const people = el("section", "block block--first"); + view.list = el("ul", "members"); + view.note = message(); + people.append(view.list, add(view), view.note); + + body.append(view.warn, people, exit(view)); + root.append(body); + + const off = sync.on("rooms", (detail) => { + if (detail.id === view.roomId) { + paint(view); + } + }); + paint(view); + + return () => { + view.alive = false; + off(); + }; +} + +function head(view) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => view.ctx.go(`#/room/${view.roomId}`)); + bar.append(back, view.title); + return bar; +} + +// --- разметка ----------------------------------------------------------- + +// add — строка ввода `@ник` и «добавить». Видна только владельцу: состав +// меняет он (ADR-018). +function add(view) { + const form = el("form", "form form--row"); + form.noValidate = true; + form.hidden = true; + + const line = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = "@ник"; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + line.append(prompt, field); + + const go = el("button", "button", "добавить"); + go.type = "submit"; + form.append(line, go); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (go.disabled) { + return; + } + setNote(view.note, ""); + // Ник вводят как в списке: с «@» или без. Ники строчные (ADR-019). + const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + if (nick === "") { + field.focus(); + return; + } + field.value = nick; + go.disabled = true; + try { + await sync.changeMembers(view.roomId, { add: [nick] }); + field.value = ""; + } catch (err) { + setError(view.note, failure(view.ctx, err)); + field.focus(); + } finally { + go.disabled = false; + } + }); + + view.add = form; + return form; +} + +// exit — «выйти из комнаты» всем, «удалить комнату» владельцу +// (docs/ui.md, «Участники»). +function exit(view) { + const box = el("section", "block"); + const note = message(); + + const leave = button("выйти из комнаты"); + leave.addEventListener("click", async () => { + leave.disabled = true; + setNote(note, ""); + try { + await sync.leaveRoom(view.roomId); + view.ctx.go("#/"); + } catch (err) { + setError(note, failure(view.ctx, err)); + leave.disabled = false; + } + }); + + const drop = button("удалить комнату"); + drop.hidden = true; + const panel = confirmPanel("комната будет удалена у всех участников.", "удалить"); + + drop.addEventListener("click", () => { + setNote(note, ""); + drop.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + panel.root.hidden = true; + drop.hidden = false; + drop.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + try { + await sync.deleteRoom(view.roomId); + view.ctx.go("#/"); + } catch (err) { + setError(note, failure(view.ctx, err)); + panel.yes.disabled = false; + panel.no.disabled = false; + panel.root.hidden = true; + drop.hidden = false; + } + }); + + view.drop = drop; + view.panel = panel.root; + box.append(leave, drop, panel.root, note); + return box; +} + +// --- состояние ---------------------------------------------------------- + +// paint перечитывает комнату и рисует состав. Владение переходит к участнику +// с наименьшим joined_at (ADR-018), поэтому владельческие части экрана +// появляются и исчезают вместе с составом. +async function paint(view) { + const mine = ++view.generation; + let record = null; + try { + record = await sync.chat(view.chatId); + } catch { + // Базы нет — рисуем пустой состав: выйти из комнаты это не мешает. + } + if (!view.alive || mine !== view.generation) { + return; + } + const members = record?.members ?? []; + const owner = record?.owner ?? null; + const mineRoom = owner === view.me; + + view.title.textContent = `#${record?.title || view.roomId}`; + + clear(view.list); + for (const nick of members) { + view.list.append(person(view, nick, owner, mineRoom)); + } + + view.add.hidden = !mineRoom; + view.drop.hidden = !mineRoom || !view.panel.hidden; + if (!mineRoom) { + view.panel.hidden = true; + } + + const blocked = sync.needsTrust(view.roomId); + view.warn.textContent = blocked.length > 0 ? trustText(blocked) : ""; + view.warn.hidden = blocked.length === 0; +} + +// person — строка участника: ник, пометка «владелец», «убрать» у владельца. +// Владельца убрать нельзя (docs/protocol.md, `400 owner`), поэтому кнопки +// в его строке нет. +function person(view, nick, owner, mineRoom) { + const row = el("li", "member"); + row.append(el("span", "member__name", `@${nick}`)); + if (nick === owner) { + row.append(el("span", "tag", "владелец")); + return row; + } + if (!mineRoom) { + return row; + } + const drop = el("button", "link", "убрать"); + drop.type = "button"; + drop.addEventListener("click", async () => { + drop.disabled = true; + setNote(view.note, ""); + try { + await sync.changeMembers(view.roomId, { remove: [nick] }); + } catch (err) { + setError(view.note, failure(view.ctx, err)); + drop.disabled = false; + } + }); + row.append(drop); + return row; +} + +// failure — текст отказа. Неподтверждённый ключ обрывает смену состава +// до запроса, и состояние у него то же, что у несделанного rekey: +// комнате нужен новый ключ, а раздать его некому (ADR-016, ADR-038). +function failure(ctx, err) { + if (err instanceof sync.TrustNeeded) { + return trustText(err.nicks); + } + return ctx.errorText(err); +} + +function trustText(nicks) { + return `нужен новый ключ комнаты: подтвердите ключ ${nicks.map((nick) => `@${nick}`).join(", ")}`; +} diff --git a/web/js/ui/new.js b/web/js/ui/new.js index 73cca3e..4042ed5 100644 --- a/web/js/ui/new.js +++ b/web/js/ui/new.js @@ -1,61 +1,106 @@ -// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится -// вместе с комнатами (этап 3): создавать пока нечего. +// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает +// личный чат, `#имя комнаты` заводит комнату. import * as sync from "../sync.js"; import { el, message, setError, setNote } from "./dom.js"; +// Имя комнаты — до 64 символов (ADR-018, ADR-021). maxLength считает +// единицы UTF-16, а сервер — руны: за предел это не выпустит. +const ROOM_NAME_MAX = 64; + export function renderNew(root, ctx) { root.append(head(ctx)); const body = el("div", "body"); - const form = el("form", "form"); - form.noValidate = true; - - const row = el("div", "input"); - const prompt = el("span", "p", ">"); - prompt.setAttribute("aria-hidden", "true"); - const field = el("input", "input__field"); - field.type = "text"; - field.placeholder = "@ник"; - field.autocapitalize = "off"; - field.autocomplete = "off"; - field.spellcheck = false; - const go = el("button", "input__send", ">"); - go.type = "submit"; - row.append(prompt, field, go); - + // Место под ошибку одно на оба поля: строка состояния у экрана одна + // (ADR-028). const note = message(); - form.append(row, note); - form.addEventListener("submit", async (event) => { + const dm = row("@ник"); + const room = row("#имя комнаты"); + room.field.maxLength = ROOM_NAME_MAX; + + dm.form.addEventListener("submit", async (event) => { event.preventDefault(); - if (go.disabled) { + if (dm.go.disabled) { return; } setNote(note, ""); // Ник вводят как в списке: с «@» или без. Регистр не хранится — // ники строчные (ADR-019). - const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase(); if (nick === "") { - field.focus(); + dm.field.focus(); return; } - field.value = nick; - go.disabled = true; + dm.field.value = nick; + dm.go.disabled = true; try { await sync.openDm(nick); ctx.go(`#/dm/${nick}`); } catch (err) { setError(note, ctx.errorText(err)); - field.focus(); + dm.field.focus(); } finally { - go.disabled = false; + dm.go.disabled = false; } }); - body.append(form); + room.form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (room.go.disabled) { + return; + } + setNote(note, ""); + // Имя вводят как в списке: с «#» или без. Регистр имени комнаты + // сохраняется — это её название, а не идентификатор. + const name = room.field.value.trim().replace(/^#/, "").trim(); + if (name === "") { + room.field.focus(); + return; + } + room.go.disabled = true; + try { + const chatId = await sync.createRoom(name); + if (chatId === null) { + room.field.focus(); + return; + } + ctx.go(`#/room/${sync.roomIdOf(chatId)}`); + } catch (err) { + setError(note, ctx.errorText(err)); + room.field.focus(); + } finally { + room.go.disabled = false; + } + }); + + body.append(dm.form, room.form, note); root.append(body); - field.focus(); + dm.field.focus(); +} + +// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark +// (docs/identity/brief.md, «Компоновка»). +function row(placeholder) { + const form = el("form", "form form--row"); + form.noValidate = true; + + const line = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = placeholder; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + const go = el("button", "input__send", ">"); + go.type = "submit"; + line.append(prompt, field, go); + + form.append(line); + return { form, field, go }; } function head(ctx) {