Этап 3: TOFU и комнаты — ключ комнаты, атомарный rekey, участники
Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним атомарным запросом с проверкой keys[].to против итогового состава; обрезка до двух последних ключей; передача владения по joined_at; события room каждому со своим ключом и room_left удалённым; членство и keyId в проверках сообщения; миграция 002. Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента; предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой сохранённого raw; заворачивание и разворачивание ключей комнаты, включая себе — тем же кодом, без ветвления; расшифровка любым известным keyId; экраны участников, создание комнаты, карточка контакта с двумя отпечатками. ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание, а оно происходит до запроса — создатель обязан привязать ключ к идентификатору, которого по прежнему протоколу ещё не существовало. ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ сервера. Одно подменённое поле в ответе, без сговора и подмены кода. ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец, офлайн в момент выхода участника, не узнавал о долге никогда, и комната навсегда оставалась на ключе, который вышедший знает. ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой комнаты. Приёмка на боевом: комната на троих, добавленный четвёртый читает только новое, вышедший после rekey новых не получает и писать не может, keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device. Подмена public_key в базе даёт у собеседника предупреждение и блокирует отправку — проверено в Chrome. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -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).
|
||||
|
||||
## Сообщение
|
||||
|
||||
```
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-037: Идентификатор комнаты генерирует клиент
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/crypto.md` вплетает `roomId` в заворачивание ключа комнаты дважды: в `info` вывода `wrapK` и в AAD шифротекста. Ключ заворачивается до запроса — `POST /api/rooms` несёт `keys[]` с готовым шифротекстом.
|
||||
|
||||
`docs/protocol.md` и `docs/storage.md` при этом отдавали выдачу `roomId` серверу. Создатель комнаты обязан привязать ключ к идентификатору, которого ещё не существует.
|
||||
|
||||
Обойти это нечем. Ключ себе при создании — не формальность: ADR-018 требует его ровно для других устройств создателя, и без него второе устройство получает комнату с нечитаемым ключом. Эндпоинта, который дослал бы ключ после ответа, в протоколе нет; `POST /api/rooms/{id}/members` меняет `keyId`, то есть делает rekey сразу после создания — лишний круг и комната без действующего ключа в промежутке.
|
||||
|
||||
## Решение
|
||||
|
||||
- `roomId` генерирует клиент: 16 случайных байт base64url — как `deviceId` (ADR-017) и `keyId`.
|
||||
- `POST /api/rooms` принимает `id`. Сервер проверяет форму, как у любого идентификатора, и отвергает занятый — `409 room_conflict`. Клиент берёт новый идентификатор и повторяет, как при `device_conflict`.
|
||||
- Слияния с существующей комнатой нет: повторный `POST` с занятым `id` не присоединяет и не перезаписывает.
|
||||
- Правятся `docs/protocol.md` (тело запроса и перечень кодов) и `docs/storage.md` (комментарий к `rooms.id`).
|
||||
|
||||
## Следствия
|
||||
|
||||
- Заворачивание себе при создании привязано к настоящему `roomId`, и второе устройство создателя читает комнату.
|
||||
- Сервер доверяет клиенту не больше прежнего: он принимает форму идентификатора и отказывает занятому.
|
||||
- `roomId` был и остаётся метаданными — он открыт серверу в любом случае. Угадывание чужого идентификатора ничего не даёт: доступ проверяется по `room_members`, а не по знанию `id`.
|
||||
- Коллизия 128 случайных бит невозможна на практике; `room_conflict` существует ради целостности, а не ради сценария.
|
||||
@@ -0,0 +1,25 @@
|
||||
# ADR-038: Тексты экранов комнат и порядок полос
|
||||
|
||||
## Контекст
|
||||
|
||||
Этап 3 рисует экраны комнат по `docs/ui.md`, и в трёх местах документ описывает состояние, но слов не даёт.
|
||||
|
||||
- «Участники»: «удалить комнату» — «с подтверждением», а текста подтверждения нет. У подтверждений ADR-028 и ADR-029 свои строки записаны, у этого — нет.
|
||||
- «Карточка контакта»: у ника с `pending` показываются «оба отпечатка, старый и новый». Два блока по 64 hex подряд без пометок неразличимы, а перепутать их — подтвердить не тот ключ.
|
||||
- «Участники»: полоса «нужен новый ключ комнаты: подтвердите ключ @x» привязана к `needsRekey`. Тот же тупик даёт добавление участника: rekey не выполняется, если ключ кого-то из итогового состава не подтверждён (ADR-016), и операция обрывается до запроса. Состояние то же самое, а показать его нечем.
|
||||
|
||||
Четвёртое место — про поведение, а не про текст. ADR-033 оставил в чате одну полосу на три причины и не сказал, какая из них главная.
|
||||
|
||||
## Решение
|
||||
|
||||
- Подтверждение удаления комнаты — «комната будет удалена у всех участников.» с кнопками «удалить» и «отмена». Про историю в тексте ничего нет: она на устройствах и не трогается.
|
||||
- Отпечатки в карточке контакта помечаются «старый» и «новый».
|
||||
- Текст «нужен новый ключ комнаты: подтвердите ключ @x» показывается и тогда, когда неподтверждённый ключ обрывает добавление или удаление участника, — но строкой состояния формы, а не полосой: у отказа формы место одно, и оно под ней (ADR-028). Полоса остаётся за состоянием комнаты, строка — за неудавшимся действием. Ников бывает несколько, через запятую.
|
||||
- В чате полоса одна, и предупреждение о смене ключа перебивает отказ отправки и «нет соединения»: только оно блокирует ввод, и пока оно висит, повторять отправку всё равно нечем.
|
||||
- Строки записаны в `docs/ui.md` — разделы «Чат», «Карточка контакта», «Участники».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Экраны комнат собраны из `docs/ui.md` целиком: слов, которых нет в документе, в них не осталось.
|
||||
- Владелец, упёршийся в неподтверждённый ключ, видит одну и ту же строку независимо от того, сам он менял состав или участник вышел. Это одно состояние, и выход из него один — подтвердить ключ.
|
||||
- Порядок полос зафиксирован: три причины не спорят за одно место.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR-039: Завёрнутый ключ комнаты принимается только от участника
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-016](016-key-trust-tofu.md): у распространителя ключа комнаты появляется проверяемое условие.
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/crypto.md` говорит про отправителя завёрнутого ключа одно: «Публичный ключ `from` проходит через TOFU как любой другой». Этого мало.
|
||||
|
||||
TOFU защищает от подмены ключа знакомого ника, а не от появления незнакомого. Первый ключ запоминается молча — так и задумано (ADR-016). Значит сервер, подставивший в `Room.key` запись, завёрнутую посторонним аккаунтом, получает молчаливое доверие: клиент спрашивает `GET /api/users/<посторонний>`, впервые видит этот ник, запоминает его ключ без предупреждения, разворачивает ключ комнаты и делает его текущим — последний полученный побеждает. Следующее сообщение уходит ключом, который знает подставивший.
|
||||
|
||||
Стоит это одного подменённого поля в ответе `GET /api/rooms` конкретному участнику. Ни подмены клиентского кода, ни сговора с участником не нужно, а на экране комната не меняется: владелец и состав приходят прежние. `docs/threat-model.md` обещает обратное — «дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка».
|
||||
|
||||
ADR-018 при этом уже называет распространителя: ключ заворачивает клиент-владелец, каждому участнику и себе. Условие есть, просто оно не проверялось.
|
||||
|
||||
## Решение
|
||||
|
||||
- Клиент отвергает завёрнутый ключ комнаты, если `from` не входит в состав, пришедший в том же `Room`. Ключ не разворачивается и не сохраняется.
|
||||
- Проверка идёт до `GET /api/users/{from}`: подставной ник не попадает и в TOFU, следа от него не остаётся.
|
||||
- Требовать именно владельца нельзя: владение переходит по ADR-018, и у действующих участников остаётся ключ прежнего владельца. Состав — то условие, которое переживает передачу владения.
|
||||
- Строка записана в `docs/crypto.md`, «Заворачивание участнику».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Чтобы подсунуть ключ, серверу придётся показать подставной ник в составе комнаты. Это видно на экране участников — то есть подмена перестаёт быть невидимой, ровно как обещает модель угроз.
|
||||
- Ключ, завёрнутый ником, который успел выйти из комнаты, новое устройство участника не развернёт: комната для него остаётся без ключа до rekey, входящее показывается как «нет ключа комнаты». ADR-018 такой случай уже допускает, а долг по rekey теперь переживает офлайн (ADR-041), поэтому окно короткое.
|
||||
- Уже сохранённый ключ проверка не трогает: `roomKeys` заполняется один раз на `keyId`.
|
||||
@@ -0,0 +1,24 @@
|
||||
# ADR-040: Возврат к доверенному ключу закрывает состояние pending
|
||||
|
||||
Уточняет [ADR-016](016-key-trust-tofu.md): у состояния «ключ изменился» появляется второй выход.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-016 знает одно состояние и один выход из него: ключ ника изменился, отправка блокируется до явного «доверять новому ключу». `docs/ui.md` даёт под это ровно одну кнопку.
|
||||
|
||||
Выход оказался не единственным возможным, а единственным записанным. Сервер, отдавший чужой ключ и вернувший обратно настоящий, оставляет клиент в тупике: в `pending` лежит ключ, которому доверять нельзя, а кнопка «доверять новому ключу» продвинула бы в основные именно его — то есть уже отозванную подмену. Отправка при этом заблокирована, и разблокировать её человеку нечем.
|
||||
|
||||
Клиент этапа 3 снимал `pending` сам, когда сервер снова отдавал доверенный ключ. Поведение верное, но в документах его не было, а `CLAUDE.md` и `docs/plan.md` запрещают дописывать спецификацию молча.
|
||||
|
||||
## Решение
|
||||
|
||||
- Публичный ключ, совпавший с доверенным, закрывает состояние `pending`: запись возвращается к прежнему ключу, полоса в чате и второй отпечаток в карточке контакта исчезают, отправка разблокируется.
|
||||
- Человеку об этом не сообщается: смены ключа не случилось, а состояние обещало ровно смену.
|
||||
- Кнопка «доверять новому ключу» остаётся единственным выходом там, где новый ключ никуда не делся.
|
||||
- Строка записана в `docs/ui.md`, «Карточка контакта».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Тупика нет: из состояния выходит либо человек — подтверждением, либо сам сервер — возвратом к прежнему ключу.
|
||||
- Подмена, откатившаяся до того, как человек посмотрел на экран, следа в состоянии не оставляет. След остаётся в ленте: сообщение, зашифрованное подменным ключом, так и лежит нерасшифрованным с пометкой «ключ изменился» — расшифровать его нечем, ключа подменщика у нас нет и не будет.
|
||||
- Отдельного поля «здесь была подмена» не заводится: `peers` держит доверие, а не журнал. Журнал подмен — отдельное решение, если понадобится.
|
||||
@@ -0,0 +1,31 @@
|
||||
# ADR-041: Долг по ключу комнаты — состояние, а не событие
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md): «шлёт остальным событие `room` с `needsRekey: true`» дополняется признаком, который событие переживает.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-018 описывает окно без rekey как временное: «Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает». Значит, вернувшись, владелец обязан ключ сменить.
|
||||
|
||||
Вернуть его было нечем. `needsRekey` жил только в живом событии `room`: `GET /api/rooms` этого признака не нёс вовсе, в очередь событие не кладётся, а клиентский долг держался в памяти вкладки и умирал от перезагрузки. Владелец, не подключённый в ту секунду, когда участник вышел, не узнавал о долге никогда, и комната оставалась на ключе, который унёс вышедший, — до следующей смены состава, то есть, возможно, навсегда. Перезагрузка страницы у подключённого владельца давала то же самое, вместе с полосой «нужен новый ключ комнаты», которая исчезала молча.
|
||||
|
||||
`docs/protocol.md` при этом утверждает: «room и room_left в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms`… поэтому пропуск события во время офлайна ничего не ломает». Для `needsRekey` утверждение было ложным.
|
||||
|
||||
Тот же провал на втором пути. `DELETE /api/me` уносит участника из всех его комнат, но не шлёт оставшимся ничего: ни `room` с `needsRekey`, ни уведомления новому владельцу. По составу это тот же выход участника, что и `POST /api/rooms/{id}/leave`, и ADR-018 требует того же rekey. Хуже: `room_keys.sender` внешним ключом не защищён, поэтому текущий ключ комнаты остаётся завёрнутым исчезнувшим ником, и новое устройство оставшегося участника получить ключ уже не может.
|
||||
|
||||
Третье место — собственный выход. `POST /api/rooms/{id}/leave` шлёт `room` оставшимся и ничего — другим устройствам вышедшего. Они держат комнату в списке до следующего `ready`, то есть часами: при живом потоке `ready` не наступает.
|
||||
|
||||
## Решение
|
||||
|
||||
- `rooms` получает колонку `needs_rekey` (миграция 002). Ставится в 1, когда состав уменьшился, а участники остались: выход участника и удаление аккаунта. Снимается в 0 при `POST /api/rooms/{id}/members` — любая смена состава раздаёт новый ключ всему итоговому составу.
|
||||
- `GET /api/rooms` отдаёт признак полем `needsRekey`. Клиент-владелец поднимает долг из списка комнат так же, как из события, и потому переживает и офлайн, и перезагрузку вкладки.
|
||||
- `DELETE /api/me` — выход из всех комнат пользователя: оставшимся уходит `event: room` с `needsRekey: true` и их собственным текущим ключом, владение и пустые комнаты обрабатываются как при выходе (ADR-018).
|
||||
- `POST /api/rooms/{id}/leave` шлёт `event: room_left` устройствам вышедшего, кроме отправившего запрос: их состояние сходится сразу, а не к следующему `ready`.
|
||||
- Правятся `docs/protocol.md` («Типы», «Комнаты», «Аккаунт», «События») и `docs/storage.md` (миграция 002).
|
||||
|
||||
## Следствия
|
||||
|
||||
- Утверждение протокола про пропуск событий во время офлайна становится верным: всё, что несёт событие `room`, есть и в `GET /api/rooms`.
|
||||
- Комната не остаётся на ключе вышедшего дольше, чем владелец не заходит. Окно снова временное, как и обещает ADR-018.
|
||||
- Полоса «нужен новый ключ комнаты: подтвердите ключ @x» переживает перезагрузку: после `ready` владелец снова упирается в тот же неподтверждённый ключ и снова её показывает. Отдельного поля в `chats` для этого не нужно.
|
||||
- Признак — метаданные комнаты, серверу и так известные: он знает состав и знает, что ключ не менялся. Нового про ключи сервер не узнаёт.
|
||||
- Клиент по-прежнему решает сам, делать ли rekey: сервер только помнит, что состав уменьшился.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-042: Порядок ключей комнаты и «текущий ключ»
|
||||
|
||||
Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера».
|
||||
|
||||
## Контекст
|
||||
|
||||
На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным.
|
||||
|
||||
Код этапа 3 это починил: сервер поднимает время нового ключа до `последний + 1`, а при равенстве доопределяет порядок по `key_id`; клиент делает то же со своим `receivedAt`. Инвариант несущий, а записан был только комментариями в коде.
|
||||
|
||||
Второе: порядка сервера клиент не знает и знать не может. В `Room.key` приходит один текущий ключ без номера и без времени, так что клиент считает текущим тот, который получил последним. В гонке двух rekey с разных устройств владельца эти порядки расходятся: устройство, чей ответ пришёл раньше события соседнего, считает текущим свой ключ, а сервер — чужой.
|
||||
|
||||
## Решение
|
||||
|
||||
- Инвариант записывается в `docs/storage.md`: время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id`. То же — про клиентский `roomKeys.receivedAt`.
|
||||
- Клиент держит порядок получения, а не порядок сервера. Это осознанный предел: номера ключа в протоколе нет и не заводится.
|
||||
- Расхождение безвредно, пока оба ключа живы, а живы они, пока комната держит два последних `keyId`. Сообщение, отправленное ключом, который сервер уже обрезал, получает `400 unknown_key` и хоронится как `failed` (ADR-033).
|
||||
|
||||
## Следствия
|
||||
|
||||
- Обрезка до двух `keyId` не выбрасывает ничей текущий ключ: самый свежий `key_id` есть у каждого участника (иначе `keys_mismatch`), и он остаётся всегда.
|
||||
- `created_at` в `room_keys` перестаёт быть в точности «миллисекундами Unix»: у двух rekey в одну миллисекунду второе время сдвинуто вперёд. Это записано рядом с колонкой.
|
||||
- Порядковый номер ключа в `Room.key` — возможное расширение отдельным ADR, если расхождение порядков когда-нибудь окажется дорогим.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-043: Форма запроса проверяется раньше прав
|
||||
|
||||
Уточняет [ADR-026](026-protocol-error-codes.md): перечень кодов исчерпывающий, значит и порядок их выдачи должен быть записан.
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/protocol.md` перечисляет проверки `POST /api/rooms/{id}/members` в одном порядке — «Только владелец (`403 not_owner`). Проверки: все `add` существуют…», — а сервер отвечает в другом: форму ников и завёрнутых ключей он разбирает до обращения к хранилищу, то есть до проверки владения. Порядок наружу виден: не владелец с кривым ником в `add` получал не `not_owner`.
|
||||
|
||||
Иначе и не сделать: чтобы спросить хранилище о правах, запрос сначала надо разобрать. Утечки в этом нет — проверка чисто синтаксическая и о комнате ничего не сообщает.
|
||||
|
||||
Два кода при этом расходились с документом. Повтор ника в `keys[].to` отвечал `400 invalid`, хотя множество `keys[].to` составу в этом случае не равно и протокол называет `400 keys_mismatch`. Ошибка формы ника в `add` отвечала `404 unknown_user`, а та же ошибка в `remove` — `400 invalid`: один класс входа, два разных ответа.
|
||||
|
||||
## Решение
|
||||
|
||||
- Форма запроса проверяется раньше прав и раньше существования сущностей. Записано строкой в «Общих правилах» `docs/protocol.md`: `400 bad_json`, `413 too_large` и `400 invalid` приходят и на запрос, который отвергли бы и по правам.
|
||||
- Повтор ника в `keys[].to` — `400 keys_mismatch`, как и любое другое несовпадение с итоговым составом.
|
||||
- Ник неверной формы в `add` — `400 invalid` с полем `add`, как и в `remove`. Несуществующий ник верной формы остаётся `404 unknown_user`.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Перечень кодов остаётся исчерпывающим, а порядок их выдачи — записанным, а не выведенным из чтения кода.
|
||||
- Снаружи по ответу видно, что запрос разобран, но не видно ничего о комнате: `403 not_owner` одинаков и для чужой комнаты, и для несуществующей.
|
||||
- Клиенту разница не важна: свои ники он приводит к форме ADR-019 до запроса.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-044: Экран комнаты, которой у нас больше нет
|
||||
|
||||
Дополняет [ADR-038](038-room-screens-texts.md): у чата появляется четвёртая причина для полосы.
|
||||
|
||||
## Контекст
|
||||
|
||||
Комната уходит из списка тремя путями: человек вышел сам, владелец его убрал, владелец удалил комнату. Открытый экран чата при этом оставался рабочим: лента, поле ввода и кнопка `>` на месте. Отправка доходила до сервера, получала `403 not_member` и садилась как `failed` с текстом «сервер не справился, попробуйте позже» — то есть человеку сообщали, что виноват сервер, тогда как он просто больше не участник. «Повторить» в этом состоянии не срабатывает никогда.
|
||||
|
||||
`docs/ui.md` этого состояния не описывает вовсе, хотя приходит оно и без действий человека: событие `room_left` застаёт его в открытом чате.
|
||||
|
||||
## Решение
|
||||
|
||||
- Чат комнаты, из состава которой нас больше нет, показывает полосу над вводом цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован — и поле, и кнопка `>`.
|
||||
- Полоса перебивает отказ отправки и «нет соединения» на тех же основаниях, что и предупреждение о ключе (ADR-038): она блокирует ввод, и пока она висит, повторять отправку всё равно нечем.
|
||||
- Лента остаётся на месте и остаётся читаемой: история на устройстве — единственная копия, и она не трогается.
|
||||
- Строка записана в `docs/ui.md`, «Чат».
|
||||
|
||||
## Следствия
|
||||
|
||||
- Причин у полосы в чате становится четыре, показывается по-прежнему одна. Порядок: не участник, ключ изменился, отказ отправки, нет соединения.
|
||||
- Отдельного текста для `403 not_member` не заводится: до сервера отправка из такого чата больше не доходит.
|
||||
- Экран участников покинутой комнаты отдельного состояния не получает: состав там пустеет сам, а «выйти из комнаты» и «удалить комнату» отвечают тем же, чем и раньше.
|
||||
+9
-8
@@ -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`.
|
||||
|
||||
## Статика и служебное
|
||||
|
||||
|
||||
+14
-2
@@ -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,
|
||||
|
||||
+7
-3
@@ -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>`)
|
||||
|
||||
`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка, старый и новый, кнопка «доверять новому ключу». Кнопка «убрать из списка».
|
||||
`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка с пометками «старый» и «новый» и кнопка «доверять новому ключу». Кнопка «убрать из списка».
|
||||
|
||||
`pending` снимает и сервер, снова отдавший доверенный ключ: смены ключа не случилось, состояние закрывается само и молча (ADR-040).
|
||||
|
||||
## Участники (`#/room/<id>/members`)
|
||||
|
||||
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением. Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x».
|
||||
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
|
||||
|
||||
## Настройки (`#/settings`)
|
||||
|
||||
|
||||
+10
-4
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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", "это устройство не ваше")
|
||||
}
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
// Удаление устройства уносит очередь и сессии устройства.
|
||||
|
||||
+26
-10
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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, "")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Долг по ключу комнаты — состояние, а не свойство события (ADR-041):
|
||||
-- состав уменьшился, а нового ключа ещё не было. Ставится при выходе
|
||||
-- участника и удалении аккаунта, снимается при смене состава и rekey.
|
||||
ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;
|
||||
+60
-2
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
+98
-10
@@ -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
|
||||
}
|
||||
|
||||
+91
-1
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 привязывает открытые поля конверта к шифротексту: подмена
|
||||
|
||||
+107
-3
@@ -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);
|
||||
}
|
||||
|
||||
+20
-4
@@ -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));
|
||||
}
|
||||
|
||||
+771
-60
File diff suppressed because it is too large
Load Diff
+162
-22
@@ -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 — последнее своё неотправленное сообщение с текстом отказа
|
||||
|
||||
+1
-1
@@ -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";
|
||||
|
||||
+74
-18
@@ -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) {
|
||||
|
||||
@@ -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(", ")}`;
|
||||
}
|
||||
+74
-29
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user