Этап 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,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), и на экране это не видно.
- Закрытая вкладка уносит с собой до двух секунд неподтверждённого — те же конверты придут очередью в следующий раз.
- Новых текстов интерфейса решение не заводит.