Files
bare/docs/decisions/018-rooms-membership-rekey.md
mayatnikovandClaude Opus 5 db45978b16 Этап 6: закалка — лимиты ADR-021, аудит модели угроз, сверка документов
Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин
на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After;
X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок
отменял бы лимит на IP; карты вёдер ограничены поколениями.

Аудит нашёл то, что пропустили пять раундов ревью:
ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md.
Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare.
ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная
сессия продолжала получать сообщения.
ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший
офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа —
вопреки обещанию storage.md о повторной попытке после получения keyId.
ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой
комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих
запросах, включая выход из комнаты: 116 отказов за прогон стало нулём.
ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version,
403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода,
kdf как оракул после повышения цели KDF.

Модель угроз пополнена тем, что действительно видит оператор: push-подписки
лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это
произвольное уведомление на экране блокировки.

README приведён к v1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
2026-08-23 06:22:52 +03:00

5.6 KiB
Raw Permalink Blame History

ADR-018: Комнаты — владелец, состав и атомарный rekey

Уточнён ADR-039 (кто вправе раздавать ключ комнаты), ADR-041 (needsRekey — состояние комнаты, а не свойство события), ADR-042 (какой ключ считается текущим) и ADR-059 (участник получает все удерживаемые ключи, а не только текущий).

Контекст

ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника.

Решение

  • Комнату создаёт любой пользователь и становится её владельцем. Владелец добавляет и удаляет участников по нику, может удалить комнату. Любой участник может выйти. Приглашений по ссылке нет. Имя комнаты — до 64 символов, открытый текст на сервере: это метаданные.
  • Ключ комнаты — 32 случайных байта с идентификатором keyId (16 случайных байт, base64url). Идентификатор случайный, а не порядковый: гонка двух одновременных rekey даёт два разных ключа, оба доходят до всех, конфликта номеров нет. Текущий ключ — последний полученный в порядке сервера; сообщения несут keyId, клиент держит все ключи комнаты и расшифровывает любым известным.
  • Ключ участнику заворачивается на ECDH между распространителем и участником: HKDF(ECDH(priv_D, pub_M), info="bare-roomkey-v1|roomId|keyId") → AES-GCM. Распространитель заворачивает ключ и себе — для собственных других устройств.
  • Завёрнутые ключи сервер хранит постоянно, не в транзитной очереди: таблица room_keys, по одной записи на участника и ключ, последние два ключа комнаты. Новое устройство участника получает текущий ключ вместе со списком комнат. Это уточняет ADR-008: к «метаданным комнат» относятся и завёрнутые ключи — шифротекст, серверу бесполезный.
  • Смена состава и rekey — один запрос POST /api/rooms/{id}/members {add, remove, keyId, keys}. Клиент-владелец сначала получает публичные ключи итогового состава (с проверкой TOFU, ADR-016), генерирует ключ, заворачивает каждому, затем отправляет. Сервер проверяет, что множество keys[].to равно итоговому составу, и применяет всё в одной транзакции. Состав без ключа или ключ без состава невозможны.
  • Выход участника: сервер удаляет его из состава и его ключи, шлёт остальным событие room с needsRekey: true. Клиент владельца, получив его, выполняет rekey тем же запросом с пустыми add/remove. Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает; новых сообщений сервер ему не доставляет.
  • Выход владельца передаёт владение участнику с самым ранним joined_at. Выход последнего участника удаляет комнату.

Следствия

  • Серверу ключи недоступны по-прежнему: он хранит и раздаёт только шифротекст.
  • Владелец — единственная роль. Администраторов, модераторов и прав на уровне сообщений нет.
  • Новый участник не читает прошлое: его не существует на сервере. Сообщение, отправленное на старом ключе одновременно с rekey, новое устройство прочитать не сможет — показывается как нерасшифрованное. Редкий и честный случай.
  • Если член комнаты сговорился с сервером, он может остаться читателем после выхода до rekey. В модели угроз сервер и участник по отдельности не защищаемые стороны; их сговор — тем более.