Этап 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
This commit is contained in:
2026-08-23 06:22:52 +03:00
co-authored by Claude Opus 5
parent 0c878477d2
commit db45978b16
45 changed files with 1522 additions and 282 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Bare — это PWA-клиент на ванильных веб-технолог
Чаты 1:1: ECDH shared secret → HKDF → AES-GCM.
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст), чтобы новое устройство участника получило текущий ключ. Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст) и отдаёт участнику все, что держит, — два последних: новое устройство получает действующий ключ, а вернувшееся из офлайна — ещё и пропущенный, которым зашифровано лежащее в его очереди (ADR-059). Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
Все процедуры побайтно — `docs/crypto.md`.
@@ -1,5 +1,7 @@
# ADR-015: Пароль не покидает клиент — два ключа из одного мастера
Уточнён [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает и потоки событий отозванных сессий; [ADR-062](062-kdf-answer-and-nick-existence.md): `GET /api/kdf` не обещает скрывать существование ника.
## Контекст
ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом.
@@ -1,5 +1,7 @@
# ADR-018: Комнаты — владелец, состав и атомарный rekey
Уточнён [ADR-039](039-room-key-sender-is-member.md) (кто вправе раздавать ключ комнаты), [ADR-041](041-needs-rekey-is-state.md) (`needsRekey` — состояние комнаты, а не свойство события), [ADR-042](042-current-room-key-order.md) (какой ключ считается текущим) и [ADR-059](059-room-keys-kept-are-handed-out.md) (участник получает все удерживаемые ключи, а не только текущий).
## Контекст
ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника.
@@ -1,5 +1,7 @@
# ADR-021: Сессии, CSRF, Argon2id и лимиты
Уточнён [ADR-055](055-limit-keys-and-bounds.md): пакеты правил, состав «остальных изменяющих», место лимита в порядке проверок, границы карт и доверие к `X-Real-IP`; [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает потоки событий отозванных сессий, а не только удаляет их строки.
## Контекст
ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без параметров. Cookie плюс `fetch POST` — классическая поверхность для CSRF. Лимиты и защита от перебора — открытый вопрос.
@@ -1,5 +1,7 @@
# ADR-022: Деплой — nginx, systemd, кросс-сборка
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева).
## Контекст
Целевой сервер (`ssh xmatic`, Ubuntu 22.04) уже держит nginx на 80/443 с десятком сайтов и certbot. Go на сервере нет. HTTPS обязателен (ADR-002), но TLS в самом бинаре означал бы либо `autocert` — четвёртую зависимость, — либо конфликт за 443 с nginx.
@@ -1,5 +1,7 @@
# ADR-033: Текст отказа у неотправленного сообщения
Уточнён [ADR-036](036-resend-keeps-ulid.md) (новая попытка заводит запись без поля `error`) и [ADR-060](060-unknown-device-keeps-pending.md) (`403 unknown_device` — не отказ сообщению).
## Контекст
`docs/storage.md` задаёт судьбу исходящего: `202``sent`, сетевая ошибка → остаётся `pending`, `4xx``failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`.
@@ -2,6 +2,8 @@
Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера».
Уточнён [ADR-059](059-room-keys-kept-are-handed-out.md): порядок относится ко всем удерживаемым ключам, а не только к последнему.
## Контекст
На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным.
@@ -0,0 +1,39 @@
# ADR-055: Ключи лимитов, границы карт и доверие к X-Real-IP
Уточняет [ADR-021](021-sessions-csrf-limits.md): четыре правила названы там, всё остальное про них — здесь.
Уточнён [ADR-063](063-ack-goes-in-batches.md): `POST /api/ack` приходит пачками не только при подключении — клиент копит подтверждения и шлёт их не чаще раза в две секунды.
## Контекст
ADR-021 задаёт лимиты одним списком: регистрация — 5 в час на IP, вход — 10 за 10 минут на пару IP+ник, сообщения — 30 в минуту на пользователя пакетом 10, остальные изменяющие запросы — 60 в минуту на пользователя. Этап 6 доводит список до кода, и пять вещей списком не решены.
**Пакет.** Он назван только у сообщений. У остальных правил его нет, а token bucket без него не собрать.
**«Остальные изменяющие».** Какие именно и одним ли ведром — не сказано. `POST /api/ack` изменяет очередь, но приходит пачками после каждого подключения; `GET` не изменяет ничего.
**Место в порядке проверок.** У сообщений оно записано (`docs/protocol.md`, «Сообщения»): лимит последний, после формы и прав. Для общего лимита такого места нет: чтобы спросить ведро, нужен только ник сессии, а разбор тела — уже та работа, ради отказа от которой лимит и заводится.
**Размер карт.** Ведро заводится на каждый новый ключ, а ключ — чужой адрес: их бывает сколько угодно. Выбрасывать полные вёдра, как делал этап 2, под потоком новых ключей бесполезно — полных не бывает, каждое только что потратило токен. Миллион адресов давал миллион вёдер и рост памяти без предела.
**X-Real-IP.** ADR-021 говорит «только если соединение с `127.0.0.1`». Соединение с `::1` приходит с той же машины и заслуживает того же доверия, а по букве оно его не получает: тогда все клиенты за таким nginx складываются в одно ведро, и лимит на IP превращается в лимит на сервер.
Рядом — расхождение в `docs/deploy.md`: «Логи» разрешают писать ник «для ошибок аутентификации по лимитам». Такой строки в коде нет и не заводится: ник — данные пользователя, а отказ и так видно по статусу.
## Решение
- **Пакет равен лимиту**, где ADR-021 его не назвал: 5 в час — пакет 5, 10 за 10 минут — 10, 60 в минуту — 60. За окно набегает ровно лимит, и потратить его можно разом. Отдельный пакет остаётся у сообщений: 30 в минуту, пакет 10.
- **«Остальные изменяющие»** — все непубличные маршруты, кроме `GET`, одним ведром на пользователя, включая `POST /api/ack`. Чтения не ограничиваются: ADR-021 ограничивает изменяющие, и большего v1 не вводит. `POST /api/messages` в это ведро не входит — у него своё правило.
- **Общий лимит стоит на маршруте**, сразу за проверкой сессии, и отвечает раньше разбора тела. ADR-043 это не нарушает: `429` говорит не о правах и не о существовании сущностей, а о частоте; `401 unauthenticated` стоит там же и раньше.
- **У регистрации, входа и сообщений** лимит стоит в обработчике: после проверки формы и до работы. У сообщений это записанное место в порядке проверок. У входа — раньше обращения к хранилищу и argon2: перебор не должен заказывать серверу работу. У регистрации — раньше проверки инвайт-кода, иначе код подбирается запросами без счёта.
- **Форма регистрации проверяется раньше инвайт-кода** (ADR-043). Занятость ника по-прежнему за ним: `409 nick_taken` живёт после проверки кода, и без кода ники не перебрать.
- **Карты вёдер ограничены сменой поколения.** Карт две: нынешняя и прежняя. Как только нынешняя дорастает до 4096 ключей, она становится прежней, а прежняя выбрасывается целиком. Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену переживает. Обе карты вместе — не больше 8192 вёдер на правило.
- **X-Real-IP читается с любого loopback-адреса** — `127.0.0.0/8` и `::1`. Соединение не с loopback — заголовок не читается вовсе, ключом становится адрес соединения.
- **Ник в журнал не пишется никогда**, включая отказы по лимитам; строка про это убрана из `docs/deploy.md`.
## Следствия
- Лимит на IP держится ровно до тех пор, пока nginx — единственный, кто ходит на порт. Прямой доступ к `8411` снаружи снял бы его целиком, поэтому порт слушается на `127.0.0.1` (ADR-022).
- Миллион разных адресов стоит около мегабайта на правило, а не гигабайта. Цена — поток чужих ключей протирает ведро того, кого лимит держал: забытое ведро равно новому. ADR-021 уже принял, что рестарт обнуляет лимиты; это то же самое, только чаще.
- Промахнувшийся инвайт-кодом пять раз ждёт час. Числа ADR-021 не меняются: барьер от ботов дороже удобства опечатки.
- Клиент отличает `429` от прочих отказов по коду `rate_limited` и показывает «слишком часто, попробуйте позже» (ADR-028). Новых текстов интерфейса решение не заводит.
@@ -0,0 +1,21 @@
# ADR-056: nginx не ведёт журнал запросов
Уточняет [ADR-022](022-deploy-nginx-systemd.md): к конфигу nginx добавляется `access_log off`.
## Контекст
`docs/deploy.md` обещает в разделе «Логи», что данных пользователя в журнале нет: ника не пишет даже отказ по лимитам, IP не пишется вовсе. На это обещание опирается [ADR-047](047-push-endpoint.md) — ради него отправщик пушей не печатает текст ошибки транспорта, потому что внутри него адрес подписки.
Обещание держал только сам bare. Блок nginx в том же документе не задавал ни `access_log`, ни `log_format`, а на Ubuntu 22.04 `/etc/nginx/nginx.conf` включает `access_log /var/log/nginx/access.log` формата `combined` в http-блоке, и оба server-блока его наследуют. То есть на целевой машине рядом с чистым журналом bare лежал журнал nginx с `$remote_addr` и полным URI каждого запроса: `/api/users/marta`, `DELETE /api/contacts/marta`, `/api/kdf?nick=marta`, `/api/events?device=…`. Это и IP, и социальный граф с временными метками — ровно то, что из журнала bare убирали руками.
## Решение
- Оба server-блока `bare.xmatic.team` содержат `access_log off`. Журнал запросов ведёт только bare, и ведёт по своим правилам: шаблон маршрута вместо пути, без ника, без IP, без query.
- `error_log` остаётся: это журнал сбоев, а не запросов. Он пишется при отказах nginx и содержит адрес клиента; строка про это есть в `docs/deploy.md`.
- Обещание раздела «Логи» распространяется на всё развёртывание, а не только на бинарь.
## Следствия
- Отладка «кто и когда пришёл» средствами nginx исчезает. Для маленького сервера это приемлемо: статус и длительность есть в журнале bare, а разбирать поведение конкретного человека — не задача оператора.
- Счётчики трафика и аналитика по журналу тоже исчезают. Их и не было: сбора статистики Bare не ведёт.
- Обещание модели угроз становится проверяемым целиком: `/var/log/nginx/access.log` для этого домена пуст по конфигурации, а не по случайности.
@@ -0,0 +1,21 @@
# ADR-057: `bare version` помечает сборку из изменённого дерева
Уточняет [ADR-022](022-deploy-nginx-systemd.md): проверка подлинности бинаря опирается на ревизию, значит ревизия обязана быть честной.
## Контекст
`docs/threat-model.md` называет единственное смягчение против активно-злонамеренного оператора: «статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить». `docs/deploy.md` доводит это до двух проверок после деплоя — `sha256sum` на сервере и `bare version`.
`revision()` брала из `debug.ReadBuildInfo()` первое значение `vcs.revision` и печатала его как есть. Рядом лежит `vcs.modified`, и его никто не читал: бинарь, собранный из дерева с правками, печатал чистый хеш коммита, к которому его содержимое отношения не имеет. При этом `scripts/deploy.sh` собирает именно рабочее дерево — штатный путь деплоя такие бинари и порождает.
## Решение
- `revision()` читает `vcs.modified` вместе с `vcs.revision`. При `vcs.modified = true` к хешу дописывается `+dirty`.
- Ревизии нет вовсе — прежнее `unknown`.
- Строка про версию бинаря в `docs/deploy.md` говорит то же.
## Следствия
- Сверка «хеш файла на сервере против сборки из тега» перестаёт молча проходить для бинаря из грязного дерева: `bare version` называет его грязным раньше, чем сойдётся или не сойдётся `sha256sum`.
- Релиз, собранный из чистого тега, печатает прежнюю строку — привычка не ломается.
- Проверять это в тесте нечем: `vcs.*` появляется только у собранного бинаря, а `go test` их не проставляет. Проверка ручная, она в `docs/deploy.md`.
@@ -0,0 +1,24 @@
# ADR-058: отозванная сессия теряет и свой поток событий
Уточняет [ADR-021](021-sessions-csrf-limits.md) и [ADR-015](015-password-never-leaves-client.md): «смена пароля по желанию завершает остальные сессии» — значит завершает, а не помечает.
## Контекст
`docs/protocol.md` обещает у `POST /api/password`: «при `logoutOthers` удаляются все сессии кроме текущей». Строки действительно удалялись, и следующий запрос отозванной сессии получал `401`. Но поток событий сессию проверяет один раз, при подключении: `GET /api/events` открывает поток и дальше читает только очередь и живые события. Открытый поток удаление строки переживал — и продолжал получать `event: msg` с конвертами.
Практический смысл сценария — угнанное устройство. Человек меняет пароль с галочкой «выйти на других устройствах» ровно затем, чтобы отцепить чужую руку; отцеплялась она только от запросов, а живую доставку продолжала получать до обрыва соединения.
Рядом стоит `DELETE /api/devices/{id}`: он закрывает поток явно (`hub.Close`), и `docs/protocol.md` это обещает — «Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`». Два способа отобрать доступ вели себя по-разному.
## Решение
- `Store.SetPassword` при `logoutOthers` отдаёт устройства, к которым были привязаны удалённые сессии. Обработчик `POST /api/password` закрывает их потоки через `hub.Close` — тем же способом, что и удаление устройства.
- Устройство текущей сессии не трогается: она и есть та, которую оставляют.
- Периодической перепроверки сессии в цикле SSE не заводится: поток закрывает тот, кто отзывает доступ, а не таймер. Сессия, отозванная иначе (истёк срок), доживает до обрыва потока — как и раньше, новых прав это не даёт: очередь и события идут устройству, а устройство остаётся своим.
- Строка записана в `docs/protocol.md`, «Аккаунт».
## Следствия
- Отзыв доступа выглядит одинаково с обеих сторон: и удаление устройства, и смена пароля с галочкой закрывают поток и оставляют следующему запросу `401`.
- Отозванное устройство переподключается сразу и получает `401 unauthenticated` — то есть уходит на экран входа, а не молчит до перезагрузки.
- Сессия без устройства (её ещё не привязали `POST /api/devices`) закрывать нечего: потока у неё и нет.
@@ -0,0 +1,26 @@
# ADR-059: участник получает все удерживаемые ключи комнаты, а не только текущий
Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-042](042-current-room-key-order.md): сервер держит два последних `keyId` — значит, и раздаёт два.
## Контекст
`GET /api/rooms` и событие `room` отдавали участнику ровно один ключ — текущий. Других источников ключа у клиента нет: запросить конкретный `keyId` протокол не умеет.
Этого хватает на одну смену ключа и не хватает на две. Комната `{владелец, D, E}`, устройство D офлайн. Владелец добавляет участника — rekey `K1`; кто-то пишет сообщение ключом `K1`; владелец убирает участника — rekey `K2`. События `room` в очередь не кладутся (`docs/protocol.md`, «События»), поэтому `K1` до D не дошёл, а конверт с `keyId = K1` лежит в его очереди и дождётся подключения. D возвращается, забирает конверт и спрашивает `GET /api/rooms` — там `K2`. `K1` на сервере есть (обрезка держит два последних), но не отдаётся никому и никогда.
Сообщение остаётся `undecryptable: "unknown_key"` навсегда, а `docs/storage.md` обещает обратное: «Нерасшифрованное сообщение хранит `raw` для повторной попытки после … получения недостающего `keyId`». Получить его было нечем. Конфиденциальность цела — это потеря читаемости у законного участника.
## Решение
- Поле `key` типа `Room` заменяется на `keys` — список завёрнутых для запрашивающего ключей комнаты, от старого к новому. Порядок — время записи и `key_id` при равенстве, тот же, что у обрезки (ADR-042).
- `GET /api/rooms` отдаёт все ключи, которые сервер ещё держит (до двух, ADR-018). Событие `room` и ответы `POST /api/rooms` и `POST /api/rooms/{id}/members` несут один — только что розданный: подключённому устройству остальные уже приходили, а отключённое доберёт их из `GET /api/rooms` после `ready`.
- Клиент сохраняет ключи по порядку: текущим у него остаётся последний полученный (ADR-042), поэтому исходящее по-прежнему шифруется свежим ключом.
- Отдельного эндпоинта «ключ по (roomId, keyId)» не заводится: он был бы четвёртым способом получить то же самое.
- Правятся `docs/protocol.md` («Типы», «Комнаты», «События») и `docs/storage.md`.
## Следствия
- Сообщение, отправленное между двумя rekey, читается участником, который в это время был офлайн. Ради этого сервер и держал два ключа.
- Три смены ключа за время офлайна по-прежнему теряют средний: сервер держит два последних `keyId`, а не всю историю. Это прежняя цена ADR-018, и она записана.
- Сервер не узнаёт о ключах ничего нового: он и раньше хранил обе записи и раздавал одну из них.
- Ответ `GET /api/rooms` вырастает на один завёрнутый ключ на комнату. Это десятки байт.
@@ -0,0 +1,23 @@
# ADR-060: `403 unknown_device` не хоронит сообщение
Уточняет [ADR-033](033-failed-message-reason.md) и правило `docs/storage.md` про судьбу исходящего.
## Контекст
`docs/storage.md` делит отказы на два класса: сетевая ошибка и `500` оставляют сообщение `pending` и повторяются при следующем подключении, прочие `4xx``failed` с текстом отказа.
`403 unknown_device` в этот раздел не укладывается. Он означает не «сообщение не годится», а «устройства, от имени которого мы пишем, у сервера больше нет»: его удалили с другого устройства, либо оно отмерло по сроку (ADR-017). Текст у сообщения при этом появился бы посторонний — про сервер, который не справился, — а «повторить» не сработало бы ни разу: тот же `X-Device` получит тот же отказ.
Чинится это не сообщением, а устройством: клиент переподключается, `POST /api/devices` заводит устройство заново, и неотправленное уходит после `ready`. Клиент так и делал — оставлял запись `pending` и заводил повтор, — но в документах исключения не было, а `CLAUDE.md` запрещает дописывать спецификацию молча.
## Решение
- `403 unknown_device` — не отказ сообщению, а потерянное устройство: запись остаётся `pending`, клиент переподключается и повторяет её после `ready`.
- Правило записано строкой в `docs/storage.md` рядом с прежним делением отказов.
- Остальные `4xx` не меняются: `failed` с текстом отказа.
## Следствия
- Удаление устройства с другого устройства не превращает набранное в отвергнутое: сообщение уходит, как только устройство завелось заново.
- Перечень причин, по которым сообщение остаётся `pending`, становится закрытым: сеть, `500`, отложенная отправка (нет ключа комнаты, ключ собеседника ждёт подтверждения) и потерянное устройство.
- Бесконечного круга нет: пока устройства нет, сообщение просто лежит; полоса про отказ отправки в чате не появляется, потому что отказа сообщению не было.
@@ -0,0 +1,23 @@
# ADR-061: имя комнаты в подсказке ввода обрезается
Уточняет `docs/ui.md`, «Чат»: у placeholder появляется предел длины.
## Контекст
`docs/ui.md` задаёт подсказку строки ввода: «сообщение в #general» / «сообщение». Имя комнаты бывает до 64 символов (ADR-021), а строка ввода растёт под placeholder так же, как под набранный текст: имя в 64 символа занимает в ней три строки на десктопе и больше на телефоне. Подсказка при этом не текст, а приглашение — раздувать под неё поле ввода нечем оправдать.
Клиент этапа 2 обрезал имя до двенадцати символов многоточием. Поведение верное — «сообщение в #длинноеимя…» умещается в одну строку на самом узком из целевых экранов (360 px), — но в документе его не было.
Обрезать разметкой нельзя: `text-overflow` к placeholder не применяется.
## Решение
- Имя комнаты в подсказке ввода обрезается до двенадцати символов и заканчивается многоточием: «сообщение в #длинноеимя…».
- Считаются символы, а не единицы utf-16: имя ограничено символами, и разрезать пару посередине незачем.
- В шапке чата имя остаётся полным: там его обрезает разметка, и место у него своё.
- Строка записана в `docs/ui.md`, «Чат».
## Следствия
- Строка ввода остаётся в одну строку при любом имени комнаты.
- Две комнаты с одинаковым началом длинного имени дают одинаковую подсказку. Это подсказка, а не заголовок: имя целиком видно в шапке над лентой.
@@ -0,0 +1,23 @@
# ADR-062: `GET /api/kdf` не обещает скрывать существование ника
Уточняет [ADR-015](015-password-never-leaves-client.md): «ответ не раскрывает существование ника» верно не всегда.
## Контекст
`GET /api/kdf?nick=` отдаёт число итераций PBKDF2: для известного ника — `iter` из его ключевого блоба, для неизвестного — целевое значение сервера. ADR-015 и `docs/protocol.md` называли это свойство прямо: ответ не раскрывает, существует ли ник.
Утверждение держится ровно до первого повышения цели. ADR-013 и ADR-030 предусматривают повышение с автоматической перешифровкой блоба при следующем входе: у аккаунтов, заведённых раньше и с тех пор не входивших, в блобе остаётся прежнее число. Тогда известный ник отвечает старым значением, неизвестный — новым, и разница видна снаружи. Сейчас цель не менялась, поэтому оракул спящий, — но он следует прямо из документированного пути обновления, а не из ошибки.
Прятать существование ника Bare и не обещал в остальном: ADR-019 говорит про ник открытым текстом — «что он существует, узнать можно. Это не считается утечкой», а `POST /api/register` отвечает `409 nick_taken`. Ради согласованности одной строки нет смысла ни отдавать всем целевое значение (клиенту нужно настоящее — иначе не расшифровать блоб), ни заводить второй запрос за `iter` после входа.
## Решение
- Формулировка правится: `GET /api/kdf` отвечает `200` и неизвестному нику, поэтому по статусу существование ника не видно; но число итераций у существующего аккаунта — его собственное, и после повышения цели оно может отличаться от целевого. Скрытием существования ника этот ответ не занимается.
- Существование ника остаётся публичным фактом (ADR-019), и это записано там же, где раньше стояло обещание: `docs/protocol.md`, «Публичные».
- Код не меняется: разное число итераций у разных аккаунтов — свойство постепенного повышения (ADR-013), а не дефект.
## Следствия
- Перечень того, что видно снаружи без сессии, становится честным: существование ника видно и через регистрацию, и через `kdf`.
- Повышение цели KDF остаётся возможным без миграции всех аккаунтов разом — ровно ради этого `iter` и лежит рядом с блобом.
- Если скрывать существование ника когда-нибудь понадобится, это отдельное решение и другой протокол входа; в v1 такой задачи нет.
+31
View File
@@ -0,0 +1,31 @@
# ADR-063: подтверждения копятся и уходят пачкой
Уточняет [ADR-055](055-limit-keys-and-bounds.md): «`POST /api/ack` приходит пачками» — теперь это правда и в живой доставке, а не только при подключении.
## Контекст
ADR-055 положил `POST /api/ack` в общее ведро «60 изменяющих запросов в минуту на пользователя» и обосновал это тем, что ACK приходит пачками после каждого подключения. Для воспроизведения очереди посылка верна: конверты приходят подряд, разбираются одним заходом и подтверждаются одним запросом.
В живой доставке она неверна. Разбор входящих откладывается на следующий такт цикла событий, поэтому конверты, пришедшие в разных тактах, разбираются по одному, и каждый разбор заканчивался своим подтверждением — один `POST /api/ack` на конверт.
Следствие достаётся получателю, и оно измерено. Четверо пишут одному по своему пределу в 30 сообщений в минуту, три минуты: 365 конвертов, 346 подтверждений в журнале сервера, из них 336 — ровно с одним идентификатором. Через 59 секунд ведро кончилось: 103 подтверждения получили `429`, десять пробных изменяющих запросов получателя — все десять `429`, три попытки выйти из комнаты — все три `429`. В очереди сервера осталось 103 неподтверждённых конверта: записаны у получателя, но сервер их не забудет.
Человек, которому пишут часто, теряет возможность уйти: `429` получает всё изменяющее — выход из комнаты, смена пароля, удаление аккаунта.
Второй путь — вынести `/api/ack` из общего ведра пятым правилом ADR-021 — отвергнут: лимит на подтверждения либо не существует вовсе, либо это ещё одно правило, ещё одно ведро и ещё одна карта. Пачка дешевле и не расширяет ADR-021.
## Решение
- Идентификаторы записанного копятся, `POST /api/ack` уходит не чаще раза в две секунды и несёт всё, что накопилось. Задержка ничего не стоит: подтверждение — учёт очереди сервера, а не доставка человеку, сообщение к этому моменту уже на экране.
- Правило `docs/storage.md` остаётся дословным: в накопитель попадает только то, что уже записано в IndexedDB. Неудачная запись не подтверждается ничем.
- Накопитель — множество: конверт, выданный очередью повторно, подтверждается один раз.
- Неудачная отправка бросает накопленное: сервер выдаст эти конверты заново, а `put` по тому же `id` дублей не создаёт (ADR-017).
- Выход гасит таймер и очищает накопитель. Неподтверждённое вернётся очередью при следующем подключении.
- Место лимита не меняется: `/api/ack` остаётся в общем ведре (ADR-055).
## Следствия
- Поток сообщений тратит на подтверждения не больше половины общего ведра — 30 запросов в минуту в худшем случае. Выйти из комнаты, сменить пароль и удалить аккаунт получатель может в любой момент. Тот же прогон после правки: 354 конверта, 83 подтверждения (одно раз в две секунды, по четыре идентификатора в каждом), ни одного `429`, десять пробных изменяющих запросов прошли, выход из комнаты прошёл с первой попытки, очередь сервера пуста.
- Конверт живёт в очереди сервера на пару секунд дольше. Реконнект в этот промежуток выдаёт его заново; запись по тому же `id` не даёт ни дубля в ленте, ни второго непрочитанного (ADR-034), и на экране это не видно.
- Закрытая вкладка уносит с собой до двух секунд неподтверждённого — те же конверты придут очередью в следующий раз.
- Новых текстов интерфейса решение не заводит.
+8 -2
View File
@@ -8,7 +8,7 @@
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bare ./cmd/bare
```
Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`. `/healthz` отвечает только `ok`.
Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`; у сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057). `/healthz` отвечает только `ok`.
## Первичная настройка сервера (один раз)
@@ -70,6 +70,7 @@ server {
listen 80;
listen [::]:80;
server_name bare.xmatic.team;
access_log off;
return 301 https://$host$request_uri;
}
@@ -82,6 +83,9 @@ server {
ssl_certificate_key /etc/letsencrypt/live/bare.xmatic.team/privkey.pem;
add_header Strict-Transport-Security "max-age=31536000" always;
# журнал запросов ведёт только bare, и ведёт без ника, IP и query (ADR-056)
access_log off;
client_max_body_size 64k;
location /api/events {
@@ -140,4 +144,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud
## Логи
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.
+13 -10
View File
@@ -6,9 +6,9 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati
- Аутентификация — cookie `bare_session` (ADR-021). Без неё — `401 unauthenticated`. Публичные: `GET /api/config`, `GET /api/kdf`, `POST /api/register`, `POST /api/login`.
- На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`.
- Заголовок `X-Device: <deviceId>` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`.
- Заголовок `X-Device: <deviceId>` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. Принадлежность — право, поэтому проверяется после разбора тела и его формы (ADR-043).
- Тело запроса — до 32 КиБ, иначе `413 too_large`.
- Rate limiting — `429` с `Retry-After` (секунды).
- Rate limiting — `429 rate_limited` с `Retry-After` в целых секундах, не меньше одной. Правила (ADR-021): регистрация — 5 в час на IP; вход — 10 за 10 минут на пару IP+ник; сообщения — 30 в минуту на пользователя, пакет 10; остальные изменяющие запросы — 60 в минуту на пользователя, одним ведром на все маршруты. Чтения не ограничиваются. Общий лимит отвечает раньше разбора тела; у регистрации, входа и сообщений он стоит на своём месте в порядке проверок эндпоинта (ADR-055).
- Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`.
- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043).
- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027).
@@ -31,18 +31,20 @@ Room {
id: roomId, name: string, owner: nick,
members: nick[], // по joined_at
createdAt: number,
key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего
keys: {keyId, from, iv, ct}[], // завёрнутые ключи для запрашивающего, от старого к новому
needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041)
}
WrappedKey { to: nick, iv: string, ct: string }
```
`keys` — все ключи запрашивающего, которые сервер ещё держит, в `GET /api/rooms`; ровно один, только что розданный, — в событии `room` и в ответах `POST /api/rooms` и `POST /api/rooms/{id}/members` (ADR-059). Пусто, если ключа у него нет.
## Публичные
`GET /api/config``200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}`
`GET /api/kdf?nick=<nick>``200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом.
`GET /api/kdf?nick=<nick>``200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. Скрытием существования ника ответ не занимается: у аккаунта, не входившего после повышения цели, число итераций своё (ADR-062), а сам факт, что ник существует, публичен (ADR-019).
`POST /api/register {nick, authKey, publicKey: JWK, blob: string, invite?: string}``201 {nick}` + cookie. Ошибки: `400 invalid_nick`, `409 nick_taken`, `403 invite_required`, `403 invalid_invite`. `authKey` — base64url 32 байт, `publicKey` — JWK `kty=EC, crv=P-256` с `x`, `y` без `d`; `blob` — до 8 КиБ.
@@ -54,7 +56,7 @@ WrappedKey { to: nick, iv: string, ct: string }
`POST /api/logout``204`, cookie стирается.
`POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}``204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей.
`POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}``204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей, а устройствам удалённых сессий поток событий закрывается — их следующий запрос получает `401` (ADR-058).
`DELETE /api/me {authKey}``204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. Удаление аккаунта — выход из всех его комнат: оставшимся участникам уходит `event: room` с `needsRekey: true`, каждому со своим ключом (ADR-041).
@@ -88,7 +90,7 @@ WrappedKey { to: nick, iv: string, ct: string }
Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши устройствам получателей по правилам ADR-023 и ADR-045.
`POST /api/ack {ids: string[]}``204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`.
`POST /api/ack {ids: string[]}``204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. Клиент копит подтверждения и шлёт их пачкой, не чаще раза в две секунды: маршрут живёт в общем ведре изменяющих запросов, и запрос на конверт съедал бы его целиком (ADR-063).
## События
@@ -106,22 +108,23 @@ WrappedKey { to: nick, iv: string, ct: string }
```
event: msg data: Envelope
event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey)
event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey);
// keys — один новый ключ получателя либо пусто (ADR-059)
event: room_left data: {id} // получателя удалили или комната удалена
event: ready data: {}
```
`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey`, есть и в `GET /api/rooms` (ADR-041).
`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey` и ключ, есть и в `GET /api/rooms` (ADR-041, ADR-059).
`id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления.
## Комнаты
`GET /api/rooms``200 Room[]` — комнаты, где пользователь участник, с его текущим ключом и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041).
`GET /api/rooms``200 Room[]` — комнаты, где пользователь участник, со всеми его ключами, которые сервер ещё держит (до двух, ADR-018), и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). Ключи идут от старого к новому; последний — текущий. Участник, пропустивший rekey в офлайне, добирает пропущенный `keyId` только отсюда: события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059).
`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`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `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`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041).
+3 -2
View File
@@ -87,7 +87,7 @@ 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`.
Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. Участнику отдаются все его удерживаемые ключи, а не только текущий: пропущенный в офлайне `keyId` иначе не добыть ничем — события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059).
Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен.
@@ -139,9 +139,10 @@ peers key: nick
Правила:
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`.
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Подтверждения копятся и уходят пачкой, не чаще раза в две секунды: в накопитель идёт только записанное, а запрос на конверт тратил бы общее ведро лимита одними подтверждениями (ADR-063).
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`.
- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202``sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx``failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id``failed` с текстом про часы (ADR-036).
- Исключение среди `4xx` одно: `403 unknown_device` — не отказ сообщению, а потерянное устройство. Запись остаётся `pending`, клиент заводит устройство заново при переподключении и повторяет её после `ready` (ADR-060).
- `unread` и `lastReadId` — локальные, на сервер не уходят.
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
+3 -3
View File
@@ -8,7 +8,7 @@
## От кого защищаем
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет.
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов, push-подписки устройств — адрес push-сервиса и ключи подписки `p256dh` и `auth`. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. База вместе с VAPID-ключом, который лежит на той же машине в `/etc/bare/env`, позволяет показать устройству произвольное уведомление от имени bare — вплоть до фишингового текста на экране блокировки; содержимого сообщений это не раскрывает.
**Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое.
@@ -24,7 +24,7 @@
**Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета.
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств и когда они появлялись — серверу видно. Скрытие метаданных — не задача Bare.
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств, когда они появлялись и куда им слать пуши, — серверу видно. Скрытие метаданных — не задача Bare.
**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`.
@@ -42,4 +42,4 @@
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов.
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — до четырёх POST на аккаунт-получателя (доля аккаунта в отправке, ADR-048), то есть до 4×N на сообщение в комнату из N участников, в пределах общих лимитов и восьми отправщиков.
+1 -1
View File
@@ -36,7 +36,7 @@
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).