Bare v1: реализация всех шести этапов плана #2
@@ -19,7 +19,9 @@ Bare — маленький независимый инструмент, а не
|
|||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Спецификации завершены, код — по `docs/plan.md`, этап 0. Иконки PWA уже в `web/icons/`.
|
Все шесть этапов `docs/plan.md` сделаны и работают на [bare.xmatic.team](https://bare.xmatic.team): аккаунты, чат 1:1, комнаты с ключом на комнату, TOFU и отпечатки, PWA и пуши, экспорт и импорт истории, лимиты и закалка.
|
||||||
|
|
||||||
|
Осталось ручное: прогон сценариев на iOS Safari (установленное на «Домой» приложение), Android Chrome, десктопных Firefox и Safari. Автоматика гоняла только Chrome. Чеклист — в `docs/plan.md`, этап 4.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
+26
-3
@@ -81,6 +81,11 @@ func serve() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
h := api.New(cfg, st, static, os.Stdout)
|
h := api.New(cfg, st, static, os.Stdout)
|
||||||
|
// Отправщики пушей дописывают начатое и пишут результат в базу, поэтому
|
||||||
|
// остановить их надо раньше, чем закроется st. defer выстроен на это:
|
||||||
|
// h.Close отложен позже st.Close и выполнится раньше него.
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Handler: h,
|
Handler: h,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
@@ -93,7 +98,11 @@ func serve() error {
|
|||||||
|
|
||||||
// Потоки событий не заканчиваются сами: без этого Shutdown ждал бы,
|
// Потоки событий не заканчиваются сами: без этого Shutdown ждал бы,
|
||||||
// пока подключённые клиенты уйдут, до самого таймаута (ADR-004).
|
// пока подключённые клиенты уйдут, до самого таймаута (ADR-004).
|
||||||
srv.RegisterOnShutdown(h.Close)
|
// Здесь только закрытие потоков: колбэк крутится в своей горутине,
|
||||||
|
// и Shutdown его не дожидается — дождаться отправки пушей отсюда
|
||||||
|
// нельзя. Их останавливает h.Close, когда Shutdown уже вернулся
|
||||||
|
// и обработчики отработали.
|
||||||
|
srv.RegisterOnShutdown(h.CloseStreams)
|
||||||
|
|
||||||
// Сначала bind, потом сообщение: строка в журнале означает, что порт занят
|
// Сначала bind, потом сообщение: строка в журнале означает, что порт занят
|
||||||
// нами, а не то, что мы собирались его занять.
|
// нами, а не то, что мы собирались его занять.
|
||||||
@@ -154,15 +163,29 @@ func version() {
|
|||||||
fmt.Println(revision())
|
fmt.Println(revision())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// revision — ревизия сборки. У бинаря из изменённого рабочего дерева
|
||||||
|
// к ней дописывается «+dirty»: сверка хеша со сборкой из тега — единственное
|
||||||
|
// смягчение против подмены клиента (docs/threat-model.md), и чистый хеш
|
||||||
|
// коммита у бинаря с чужими правками сводил бы её на нет (ADR-057).
|
||||||
func revision() string {
|
func revision() string {
|
||||||
info, ok := debug.ReadBuildInfo()
|
info, ok := debug.ReadBuildInfo()
|
||||||
if !ok {
|
if !ok {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
var vcs, modified string
|
||||||
for _, s := range info.Settings {
|
for _, s := range info.Settings {
|
||||||
if s.Key == "vcs.revision" {
|
switch s.Key {
|
||||||
return s.Value
|
case "vcs.revision":
|
||||||
|
vcs = s.Value
|
||||||
|
case "vcs.modified":
|
||||||
|
modified = s.Value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if vcs == "" {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
if modified == "true" {
|
||||||
|
return vcs + "+dirty"
|
||||||
|
}
|
||||||
|
return vcs
|
||||||
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Bare — это PWA-клиент на ванильных веб-технолог
|
|||||||
|
|
||||||
Чаты 1:1: ECDH shared secret → HKDF → AES-GCM.
|
Чаты 1:1: ECDH shared secret → HKDF → AES-GCM.
|
||||||
|
|
||||||
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст), чтобы новое устройство участника получило текущий ключ. Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
|
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст) и отдаёт участнику все, что держит, — два последних: новое устройство получает действующий ключ, а вернувшееся из офлайна — ещё и пропущенный, которым зашифровано лежащее в его очереди (ADR-059). Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
|
||||||
|
|
||||||
Все процедуры побайтно — `docs/crypto.md`.
|
Все процедуры побайтно — `docs/crypto.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-015: Пароль не покидает клиент — два ключа из одного мастера
|
# 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 без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом.
|
ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-018: Комнаты — владелец, состав и атомарный rekey
|
# 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 и при выходе участника.
|
ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-021: Сессии, CSRF, Argon2id и лимиты
|
# 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. Лимиты и защита от перебора — открытый вопрос.
|
ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без параметров. Cookie плюс `fetch POST` — классическая поверхность для CSRF. Лимиты и защита от перебора — открытый вопрос.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-022: Деплой — nginx, systemd, кросс-сборка
|
# 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.
|
Целевой сервер (`ssh xmatic`, Ubuntu 22.04) уже держит nginx на 80/443 с десятком сайтов и certbot. Go на сервере нет. HTTPS обязателен (ADR-002), но TLS в самом бинаре означал бы либо `autocert` — четвёртую зависимость, — либо конфликт за 443 с nginx.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-033: Текст отказа у неотправленного сообщения
|
# 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`.
|
`docs/storage.md` задаёт судьбу исходящего: `202` → `sent`, сетевая ошибка → остаётся `pending`, `4xx` → `failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`.
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера».
|
Уточняет [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 подряд укладываются в одну миллисекунду, и максимум становится неоднозначным.
|
На однозначности «последнего» держится обрезка: сервер хранит два последних `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 такой задачи нет.
|
||||||
@@ -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
@@ -8,7 +8,7 @@
|
|||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bare ./cmd/bare
|
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;
|
||||||
listen [::]:80;
|
listen [::]:80;
|
||||||
server_name bare.xmatic.team;
|
server_name bare.xmatic.team;
|
||||||
|
access_log off;
|
||||||
return 301 https://$host$request_uri;
|
return 301 https://$host$request_uri;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +83,9 @@ server {
|
|||||||
ssl_certificate_key /etc/letsencrypt/live/bare.xmatic.team/privkey.pem;
|
ssl_certificate_key /etc/letsencrypt/live/bare.xmatic.team/privkey.pem;
|
||||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||||
|
|
||||||
|
# журнал запросов ведёт только bare, и ведёт без ника, IP и query (ADR-056)
|
||||||
|
access_log off;
|
||||||
|
|
||||||
client_max_body_size 64k;
|
client_max_body_size 64k;
|
||||||
|
|
||||||
location /api/events {
|
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
@@ -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`.
|
- Аутентификация — 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`.
|
- На всех запросах кроме `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`.
|
- Тело запроса — до 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`.
|
- Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`.
|
||||||
- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043).
|
- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043).
|
||||||
- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027).
|
- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027).
|
||||||
@@ -31,18 +31,20 @@ Room {
|
|||||||
id: roomId, name: string, owner: nick,
|
id: roomId, name: string, owner: nick,
|
||||||
members: nick[], // по joined_at
|
members: nick[], // по joined_at
|
||||||
createdAt: number,
|
createdAt: number,
|
||||||
key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего
|
keys: {keyId, from, iv, ct}[], // завёрнутые ключи для запрашивающего, от старого к новому
|
||||||
needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041)
|
needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041)
|
||||||
}
|
}
|
||||||
|
|
||||||
WrappedKey { to: nick, iv: string, ct: string }
|
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/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 КиБ.
|
`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/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).
|
`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.
|
Сервер в одной транзакции: для `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: msg data: Envelope
|
||||||
event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey)
|
event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey);
|
||||||
|
// keys — один новый ключ получателя либо пусто (ADR-059)
|
||||||
event: room_left data: {id} // получателя удалили или комната удалена
|
event: room_left data: {id} // получателя удалили или комната удалена
|
||||||
event: ready data: {}
|
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` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления.
|
`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, 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).
|
`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041).
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -87,7 +87,7 @@ ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;
|
|||||||
|
|
||||||
Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`.
|
Признак «состав уменьшился, нового ключа ещё не было» (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, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен.
|
Время записи `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`.
|
- Входящее сообщение с уже известным `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).
|
- Исходящее пишется со `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` — локальные, на сервер не уходят.
|
- `unread` и `lastReadId` — локальные, на сервер не уходят.
|
||||||
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
|
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
|
||||||
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
|
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## От кого защищаем
|
## От кого защищаем
|
||||||
|
|
||||||
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет.
|
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов, push-подписки устройств — адрес push-сервиса и ключи подписки `p256dh` и `auth`. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. База вместе с VAPID-ключом, который лежит на той же машине в `/etc/bare/env`, позволяет показать устройству произвольное уведомление от имени bare — вплоть до фишингового текста на экране блокировки; содержимого сообщений это не раскрывает.
|
||||||
|
|
||||||
**Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое.
|
**Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое.
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
**Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета.
|
**Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета.
|
||||||
|
|
||||||
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств и когда они появлялись — серверу видно. Скрытие метаданных — не задача Bare.
|
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств, когда они появлялись и куда им слать пуши, — серверу видно. Скрытие метаданных — не задача Bare.
|
||||||
|
|
||||||
**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`.
|
**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`.
|
||||||
|
|
||||||
@@ -42,4 +42,4 @@
|
|||||||
|
|
||||||
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
|
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
|
||||||
|
|
||||||
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов.
|
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — до четырёх POST на аккаунт-получателя (доля аккаунта в отправке, ADR-048), то есть до 4×N на сообщение в комнату из N участников, в пределах общих лимитов и восьми отправщиков.
|
||||||
|
|||||||
+1
-1
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
|
Лента открывается последними 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).
|
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
|
||||||
|
|
||||||
|
|||||||
+40
-12
@@ -31,7 +31,10 @@ func (s *server) config(w http.ResponseWriter, r *http.Request) {
|
|||||||
//
|
//
|
||||||
// Значение лежит открытым полем iter в ключевом блобе: другого места
|
// Значение лежит открытым полем iter в ключевом блобе: другого места
|
||||||
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
|
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
|
||||||
// тем же статусом 200 — ответ не раскрывает, существует ли ник (ADR-015).
|
// тем же статусом 200. Скрытием существования ника этот ответ
|
||||||
|
// не занимается: после повышения цели у аккаунта, который с тех пор
|
||||||
|
// не входил, iter свой, и по числу его видно (ADR-062). Существование
|
||||||
|
// ника публично и так (ADR-019).
|
||||||
func (s *server) kdf(w http.ResponseWriter, r *http.Request) {
|
func (s *server) kdf(w http.ResponseWriter, r *http.Request) {
|
||||||
iterations := config.KDFIterations
|
iterations := config.KDFIterations
|
||||||
if nick := r.URL.Query().Get("nick"); validNick(nick) {
|
if nick := r.URL.Query().Get("nick"); validNick(nick) {
|
||||||
@@ -66,16 +69,9 @@ func (s *server) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !decode(w, r, &in) {
|
if !decode(w, r, &in) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if code := s.cfg.InviteCode; code != "" {
|
// Форма — раньше инвайт-кода: он даёт право регистрироваться, а права
|
||||||
if in.Invite == "" {
|
// идут после формы (ADR-043). Занятость ника этим не выдаётся: nick_taken
|
||||||
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
|
// живёт дальше по тексту, за инвайтом.
|
||||||
return
|
|
||||||
}
|
|
||||||
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
|
|
||||||
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !validNick(in.Nick) {
|
if !validNick(in.Nick) {
|
||||||
Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _")
|
Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _")
|
||||||
return
|
return
|
||||||
@@ -94,6 +90,22 @@ func (s *server) register(w http.ResponseWriter, r *http.Request) {
|
|||||||
Invalid(w, "blob", err.Error())
|
Invalid(w, "blob", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Лимит — 5 в час на IP (ADR-021) — стоит раньше проверки инвайт-кода:
|
||||||
|
// иначе код подбирался бы запросами без счёта.
|
||||||
|
if wait, ok := s.regs.take(clientIP(r), time.Now()); !ok {
|
||||||
|
s.rateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if code := s.cfg.InviteCode; code != "" {
|
||||||
|
if in.Invite == "" {
|
||||||
|
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
|
||||||
|
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
cred, err := auth.Hash(key)
|
cred, err := auth.Hash(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -139,6 +151,14 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) {
|
|||||||
invalidCredentials(w)
|
invalidCredentials(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Лимит — 10 за 10 минут на пару IP+ник (ADR-021) — стоит раньше
|
||||||
|
// хранилища и argon2: перебор не должен заказывать серверу работу.
|
||||||
|
// Ключ ведра собирается из адреса и ника через байт, которого нет
|
||||||
|
// ни в том ни в другом.
|
||||||
|
if wait, ok := s.logins.take(clientIP(r)+"\x00"+in.Nick, time.Now()); !ok {
|
||||||
|
s.rateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
u, err := s.st.User(r.Context(), in.Nick)
|
u, err := s.st.User(r.Context(), in.Nick)
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
// Считаем впустую: вход с несуществующим ником не должен
|
// Считаем впустую: вход с несуществующим ником не должен
|
||||||
@@ -234,10 +254,18 @@ func (s *server) password(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
sess, _ := auth.From(r)
|
sess, _ := auth.From(r)
|
||||||
if err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash); err != nil {
|
revoked, err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash)
|
||||||
|
if err != nil {
|
||||||
s.internal(w, r, err)
|
s.internal(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Сессия проверяется при подключении к потоку, а не в его цикле,
|
||||||
|
// поэтому отозванная продолжала бы получать конверты до обрыва
|
||||||
|
// соединения. Отзыв доступа закрывает поток сам — тем же способом,
|
||||||
|
// что и удаление устройства (ADR-058).
|
||||||
|
for _, device := range revoked {
|
||||||
|
s.hub.Close(device)
|
||||||
|
}
|
||||||
noContent(w)
|
noContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package api_test
|
package api_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -41,14 +42,23 @@ func account(nick string) map[string]any {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// signUp регистрирует аккаунт и отдаёт cookie сессии.
|
// signUp регистрирует аккаунт и отдаёт cookie сессии. Каждый ник приходит
|
||||||
|
// со своего адреса: регистрация ограничена пятью в час на IP (ADR-021),
|
||||||
|
// и общий адрес упирался бы в лимит на шестом аккаунте теста.
|
||||||
func (e *env) signUp(nick string) *http.Cookie {
|
func (e *env) signUp(nick string) *http.Cookie {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
rec := e.do(http.MethodPost, "/api/register", account(nick))
|
rec := e.do(http.MethodPost, "/api/register", account(nick), fromNick(nick))
|
||||||
expect(e.t, rec, http.StatusCreated, "")
|
expect(e.t, rec, http.StatusCreated, "")
|
||||||
return e.cookie(rec)
|
return e.cookie(rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fromNick — свой адрес соединения на каждый ник, лишь бы разный
|
||||||
|
// и не loopback.
|
||||||
|
func fromNick(nick string) func(*http.Request) {
|
||||||
|
sum := sha256.Sum256([]byte(nick))
|
||||||
|
return withRemote(fmt.Sprintf("198.51.%d.%d:41000", sum[0], sum[1]))
|
||||||
|
}
|
||||||
|
|
||||||
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
|
||||||
e.t.Helper()
|
e.t.Helper()
|
||||||
for _, c := range rec.Result().Cookies() {
|
for _, c := range rec.Result().Cookies() {
|
||||||
|
|||||||
+41
-20
@@ -36,7 +36,11 @@ type server struct {
|
|||||||
st *store.Store
|
st *store.Store
|
||||||
hub *hub.Hub
|
hub *hub.Hub
|
||||||
push *push.Sender
|
push *push.Sender
|
||||||
msgs *buckets
|
// Лимиты ADR-021: у каждого правила своё ведро и свой ключ.
|
||||||
|
regs *buckets // регистрация — по адресу
|
||||||
|
logins *buckets // вход — по паре адрес+ник
|
||||||
|
msgs *buckets // сообщения — по нику
|
||||||
|
writes *buckets // остальные изменяющие запросы — по нику
|
||||||
logw io.Writer
|
logw io.Writer
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,11 +52,18 @@ type Handler struct {
|
|||||||
push *push.Sender
|
push *push.Sender
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close закрывает открытые потоки событий и останавливает отправку
|
// CloseStreams закрывает открытые потоки событий. Без этого остановка
|
||||||
// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами:
|
// сервера ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
|
||||||
// у потока нет конца (ADR-004).
|
// Ничего не ждёт сама и потому годится в http.Server.RegisterOnShutdown.
|
||||||
func (h *Handler) Close() {
|
func (h *Handler) CloseStreams() {
|
||||||
h.hub.CloseAll()
|
h.hub.CloseAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close останавливает всё, что живёт за обработчиком: потоки событий
|
||||||
|
// и отправку пушей, — и дожидается начатых отправок. Отправщики пишут
|
||||||
|
// в базу, поэтому Close обязан случиться до её закрытия.
|
||||||
|
func (h *Handler) Close() {
|
||||||
|
h.CloseStreams()
|
||||||
h.push.Close()
|
h.push.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,12 +79,19 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
|||||||
st: st,
|
st: st,
|
||||||
hub: live,
|
hub: live,
|
||||||
push: push.New(cfg, st, live.Connected, logw),
|
push: push.New(cfg, st, live.Connected, logw),
|
||||||
msgs: newBuckets(messagesPerMinute, messagesBurst),
|
regs: newBuckets(registerRule),
|
||||||
|
logins: newBuckets(loginRule),
|
||||||
|
msgs: newBuckets(messagesRule),
|
||||||
|
writes: newBuckets(writesRule),
|
||||||
logw: logw,
|
logw: logw,
|
||||||
}
|
}
|
||||||
fail := auth.Fail{Error: Error, Internal: s.internal}
|
fail := auth.Fail{Error: Error, Internal: s.internal}
|
||||||
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
|
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
|
||||||
private := auth.Require(st, fail)
|
private := auth.Require(st, fail)
|
||||||
|
// write — сессия плюс общий лимит изменяющих запросов (ADR-021).
|
||||||
|
// Под него идут все непубличные маршруты кроме чтений и отправки
|
||||||
|
// сообщений: у сообщений своё правило.
|
||||||
|
write := func(h http.HandlerFunc) http.Handler { return private(s.limitWrites(h)) }
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("GET /healthz", healthz)
|
mux.HandleFunc("GET /healthz", healthz)
|
||||||
@@ -84,30 +102,33 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write
|
|||||||
mux.HandleFunc("POST /api/login", s.login)
|
mux.HandleFunc("POST /api/login", s.login)
|
||||||
|
|
||||||
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
|
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
|
||||||
mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe)))
|
mux.Handle("DELETE /api/me", write(s.deleteMe))
|
||||||
mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout)))
|
mux.Handle("POST /api/logout", write(s.logout))
|
||||||
mux.Handle("POST /api/password", private(http.HandlerFunc(s.password)))
|
mux.Handle("POST /api/password", write(s.password))
|
||||||
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
|
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
|
||||||
|
|
||||||
mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice)))
|
mux.Handle("POST /api/devices", write(s.createDevice))
|
||||||
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
|
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
|
||||||
mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice)))
|
mux.Handle("DELETE /api/devices/{id}", write(s.deleteDevice))
|
||||||
mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush)))
|
mux.Handle("PUT /api/devices/{id}/push", write(s.setPush))
|
||||||
mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush)))
|
mux.Handle("DELETE /api/devices/{id}/push", write(s.deletePush))
|
||||||
|
|
||||||
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
|
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
|
||||||
mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact)))
|
mux.Handle("POST /api/contacts", write(s.addContact))
|
||||||
mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact)))
|
mux.Handle("DELETE /api/contacts/{nick}", write(s.deleteContact))
|
||||||
|
|
||||||
mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms)))
|
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", write(s.createRoom))
|
||||||
mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers)))
|
mux.Handle("POST /api/rooms/{id}/members", write(s.updateMembers))
|
||||||
mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom)))
|
mux.Handle("POST /api/rooms/{id}/leave", write(s.leaveRoom))
|
||||||
mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom)))
|
mux.Handle("DELETE /api/rooms/{id}", write(s.deleteRoom))
|
||||||
|
|
||||||
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
|
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
|
||||||
|
// Сообщения считаются своим правилом, поэтому мимо write: лимит стоит
|
||||||
|
// в самом обработчике, там, где его место в порядке проверок
|
||||||
|
// (docs/protocol.md, «Сообщения»).
|
||||||
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
|
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
|
||||||
mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack)))
|
mux.Handle("POST /api/ack", write(s.ack))
|
||||||
|
|
||||||
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
|
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
|
||||||
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
|
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -20,9 +21,11 @@ import (
|
|||||||
const origin = "https://bare.test"
|
const origin = "https://bare.test"
|
||||||
|
|
||||||
// env — сервер на временной базе плюс журнал, в который он пишет.
|
// env — сервер на временной базе плюс журнал, в который он пишет.
|
||||||
|
// Обработчик хранится своим типом: тестам нужен не только ServeHTTP,
|
||||||
|
// но и остановка — Close и CloseStreams.
|
||||||
type env struct {
|
type env struct {
|
||||||
t *testing.T
|
t *testing.T
|
||||||
h http.Handler
|
h *api.Handler
|
||||||
st *store.Store
|
st *store.Store
|
||||||
log *syncLog
|
log *syncLog
|
||||||
srv *httptest.Server
|
srv *httptest.Server
|
||||||
@@ -143,6 +146,33 @@ func withDevice(id string) func(*http.Request) {
|
|||||||
return func(r *http.Request) { r.Header.Set("X-Device", id) }
|
return func(r *http.Request) { r.Header.Set("X-Device", id) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withRemote — адрес, с которого пришло соединение. От него зависят лимиты
|
||||||
|
// на IP (ADR-021); httptest ставит всем один и тот же.
|
||||||
|
func withRemote(addr string) func(*http.Request) {
|
||||||
|
return func(r *http.Request) { r.RemoteAddr = addr }
|
||||||
|
}
|
||||||
|
|
||||||
|
// withRealIP — заголовок, который ставит nginx. Читается, только если
|
||||||
|
// соединение пришло с loopback (ADR-055).
|
||||||
|
func withRealIP(ip string) func(*http.Request) {
|
||||||
|
return func(r *http.Request) { r.Header.Set("X-Real-IP", ip) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAfterOf — Retry-After ответа: целые секунды, не меньше одной
|
||||||
|
// (docs/protocol.md, «Общие правила»).
|
||||||
|
func retryAfterOf(t *testing.T, rec *httptest.ResponseRecorder) int {
|
||||||
|
t.Helper()
|
||||||
|
raw := rec.Header().Get("Retry-After")
|
||||||
|
seconds, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Retry-After: получено %q, ожидались целые секунды", raw)
|
||||||
|
}
|
||||||
|
if seconds < 1 {
|
||||||
|
t.Errorf("Retry-After: получено %d, ожидалось не меньше 1", seconds)
|
||||||
|
}
|
||||||
|
return seconds
|
||||||
|
}
|
||||||
|
|
||||||
func withOrigin(value string) func(*http.Request) {
|
func withOrigin(value string) func(*http.Request) {
|
||||||
return func(r *http.Request) {
|
return func(r *http.Request) {
|
||||||
if value == "" {
|
if value == "" {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/xmatic-squad/bare/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// wait — сколько тест ждёт события. Всё локально, задержек быть не должно.
|
// wait — сколько тест ждёт события. Всё локально, задержек быть не должно.
|
||||||
@@ -269,6 +271,43 @@ func TestEventsClosedOnDeviceDelete(t *testing.T) {
|
|||||||
s.ended()
|
s.ended()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Смена пароля с logoutOthers закрывает потоки отозванных сессий:
|
||||||
|
// поток проверяет сессию только при подключении, и без этого отозванное
|
||||||
|
// устройство продолжало бы получать конверты (ADR-058).
|
||||||
|
func TestEventsClosedOnLogoutOthers(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
first, d1 := e.join("marta", 1)
|
||||||
|
|
||||||
|
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
|
||||||
|
expect(t, login, http.StatusOK, "")
|
||||||
|
second := e.cookie(login)
|
||||||
|
d2 := e.addDevice(second, deviceOf(2))
|
||||||
|
|
||||||
|
revoked := e.open(d1, first)
|
||||||
|
revoked.untilReady()
|
||||||
|
kept := e.open(d2, second)
|
||||||
|
kept.untilReady()
|
||||||
|
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/password", map[string]any{
|
||||||
|
"authKey": bytesOf(32, 1),
|
||||||
|
"newAuthKey": bytesOf(32, 9),
|
||||||
|
"blob": blobOf(config.KDFIterations),
|
||||||
|
"logoutOthers": true,
|
||||||
|
}, with(second)), http.StatusNoContent, "")
|
||||||
|
|
||||||
|
revoked.ended()
|
||||||
|
|
||||||
|
// Поток той сессии, ради которой всё затевалось, остаётся живым.
|
||||||
|
select {
|
||||||
|
case ev, ok := <-kept.events:
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("закрылся поток текущей сессии")
|
||||||
|
}
|
||||||
|
t.Fatalf("лишнее событие текущей сессии: %+v", ev)
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Чужое устройство в query — 403 unknown_device, поток не открывается.
|
// Чужое устройство в query — 403 unknown_device, поток не открывается.
|
||||||
func TestEventsUnknownDevice(t *testing.T) {
|
func TestEventsUnknownDevice(t *testing.T) {
|
||||||
e := newEnv(t)
|
e := newEnv(t)
|
||||||
|
|||||||
+136
-32
@@ -2,27 +2,65 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
"math"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/xmatic-squad/bare/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10.
|
// Лимиты ADR-021, все четыре правила. Token bucket в памяти сервера:
|
||||||
// Остальные лимиты — этап 6.
|
// рестарт их обнуляет — для маленького сервера это принято.
|
||||||
const (
|
//
|
||||||
messagesPerMinute = 30
|
// Пакет отдельным числом задан только у сообщений. У остальных правил он
|
||||||
messagesBurst = 10
|
// равен самому лимиту: «5 в час» означает, что за час набегает пять
|
||||||
|
// попыток и потратить их можно разом (ADR-055).
|
||||||
|
var (
|
||||||
|
// registerRule — регистрация: 5 в час на IP.
|
||||||
|
registerRule = rule{count: 5, window: time.Hour, burst: 5}
|
||||||
|
// loginRule — вход: 10 за 10 минут на пару IP+ник.
|
||||||
|
loginRule = rule{count: 10, window: 10 * time.Minute, burst: 10}
|
||||||
|
// messagesRule — сообщения: 30 в минуту на пользователя, пакет 10.
|
||||||
|
messagesRule = rule{count: 30, window: time.Minute, burst: 10}
|
||||||
|
// writesRule — остальные изменяющие запросы: 60 в минуту
|
||||||
|
// на пользователя.
|
||||||
|
writesRule = rule{count: 60, window: time.Minute, burst: 60}
|
||||||
)
|
)
|
||||||
|
|
||||||
// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра.
|
// rule — правило лимита: count запросов за window, пакетом не больше burst.
|
||||||
const sweepAt = 1024
|
type rule struct {
|
||||||
|
count int
|
||||||
|
window time.Duration
|
||||||
|
burst int
|
||||||
|
}
|
||||||
|
|
||||||
// buckets — token bucket в памяти сервера, по ведру на ключ (ник).
|
// generation — сколько ключей карта лимита держит до смены поколения.
|
||||||
// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021).
|
//
|
||||||
|
// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой
|
||||||
|
// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком
|
||||||
|
// новых ключей полных не бывает вовсе — каждое только что потратило токен.
|
||||||
|
// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до
|
||||||
|
// generation, она становится прежней, а прежняя выбрасывается целиком.
|
||||||
|
// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену
|
||||||
|
// переживает; забывается только то, к чему не обращались целое поколение,
|
||||||
|
// а забытое ведро — то же самое, что новое.
|
||||||
|
//
|
||||||
|
// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер,
|
||||||
|
// то есть около мегабайта на правило. Миллион разных адресов памяти
|
||||||
|
// не съедает — он протачивает поколения насквозь.
|
||||||
|
const generation = 4096
|
||||||
|
|
||||||
|
// buckets — token bucket в памяти сервера, по ведру на ключ.
|
||||||
type buckets struct {
|
type buckets struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
rate float64 // токенов в секунду
|
rate float64 // токенов в секунду
|
||||||
burst float64
|
burst float64
|
||||||
seen map[string]*bucket
|
cur map[string]*bucket // нынешнее поколение
|
||||||
|
old map[string]*bucket // прежнее, пока к его ключам ещё обращаются
|
||||||
}
|
}
|
||||||
|
|
||||||
type bucket struct {
|
type bucket struct {
|
||||||
@@ -30,11 +68,11 @@ type bucket struct {
|
|||||||
at time.Time
|
at time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func newBuckets(perMinute, burst int) *buckets {
|
func newBuckets(r rule) *buckets {
|
||||||
return &buckets{
|
return &buckets{
|
||||||
rate: float64(perMinute) / 60,
|
rate: float64(r.count) / r.window.Seconds(),
|
||||||
burst: float64(burst),
|
burst: float64(r.burst),
|
||||||
seen: make(map[string]*bucket),
|
cur: make(map[string]*bucket),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,14 +82,7 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
|||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
e, ok := b.seen[key]
|
e := b.bucket(key, now)
|
||||||
if !ok {
|
|
||||||
if len(b.seen) >= sweepAt {
|
|
||||||
b.sweep(now)
|
|
||||||
}
|
|
||||||
e = &bucket{tokens: b.burst, at: now}
|
|
||||||
b.seen[key] = e
|
|
||||||
}
|
|
||||||
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
|
||||||
e.at = now
|
e.at = now
|
||||||
if e.tokens < 1 {
|
if e.tokens < 1 {
|
||||||
@@ -61,6 +92,27 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
|
|||||||
return 0, true
|
return 0, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// bucket находит ведро ключа или заводит новое. Смена поколения идёт
|
||||||
|
// до поиска: так в нынешней карте никогда не больше generation ключей,
|
||||||
|
// а в обеих вместе — не больше двух таких карт.
|
||||||
|
func (b *buckets) bucket(key string, now time.Time) *bucket {
|
||||||
|
if len(b.cur) >= generation {
|
||||||
|
b.old = b.cur
|
||||||
|
b.cur = make(map[string]*bucket, generation)
|
||||||
|
}
|
||||||
|
if e, ok := b.cur[key]; ok {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if e, ok := b.old[key]; ok {
|
||||||
|
delete(b.old, key)
|
||||||
|
b.cur[key] = e
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
e := &bucket{tokens: b.burst, at: now}
|
||||||
|
b.cur[key] = e
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
// refill — сколько токенов набежало. Время назад не идёт: часы могли
|
||||||
// прыгнуть, но долг за это выставлять некому.
|
// прыгнуть, но долг за это выставлять некому.
|
||||||
func (b *buckets) refill(since, now time.Time) float64 {
|
func (b *buckets) refill(since, now time.Time) float64 {
|
||||||
@@ -71,16 +123,6 @@ func (b *buckets) refill(since, now time.Time) float64 {
|
|||||||
return d.Seconds() * b.rate
|
return d.Seconds() * b.rate
|
||||||
}
|
}
|
||||||
|
|
||||||
// sweep выкидывает полные вёдра: они уже ничего не помнят. Иначе карта
|
|
||||||
// росла бы на каждый новый ник и не уменьшалась никогда.
|
|
||||||
func (b *buckets) sweep(now time.Time) {
|
|
||||||
for key, e := range b.seen {
|
|
||||||
if e.tokens+b.refill(e.at, now) >= b.burst {
|
|
||||||
delete(b.seen, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
|
||||||
// ожидание после отказа сбивало бы клиента с толку.
|
// ожидание после отказа сбивало бы клиента с толку.
|
||||||
func retryAfter(wait time.Duration) int {
|
func retryAfter(wait time.Duration) int {
|
||||||
@@ -89,3 +131,65 @@ func retryAfter(wait time.Duration) int {
|
|||||||
}
|
}
|
||||||
return int(math.Ceil(wait.Seconds()))
|
return int(math.Ceil(wait.Seconds()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rateLimited — 429 с Retry-After в целых секундах (ADR-021).
|
||||||
|
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
||||||
|
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
||||||
|
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
||||||
|
}
|
||||||
|
|
||||||
|
// limitWrites — общий лимит изменяющих запросов: 60 в минуту
|
||||||
|
// на пользователя (ADR-021). Стоит на маршруте, а не в обработчике,
|
||||||
|
// поэтому отвечает раньше разбора тела: смысл лимита в том, чтобы сервер
|
||||||
|
// не брался за работу, а разбор тела — уже работа. Форму это не обгоняет
|
||||||
|
// в смысле ADR-043: 429 говорит не о правах и не о существовании
|
||||||
|
// сущностей, а о частоте.
|
||||||
|
//
|
||||||
|
// Сообщения сюда не входят: у них своё правило, своё ведро и своё место
|
||||||
|
// в порядке проверок (docs/protocol.md, «Сообщения»).
|
||||||
|
func (s *server) limitWrites(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sess, _ := auth.From(r)
|
||||||
|
if wait, ok := s.writes.take(sess.Nick, time.Now()); !ok {
|
||||||
|
s.rateLimited(w, wait)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIP — ключ лимитов, привязанных к адресу.
|
||||||
|
//
|
||||||
|
// X-Real-IP ставит nginx на той же машине (ADR-022), и верить заголовку
|
||||||
|
// можно только тогда, когда соединение пришло оттуда же. Иначе его
|
||||||
|
// подставит кто угодно: новая строка в заголовке — новое ведро, и лимита
|
||||||
|
// на IP не существует вовсе. Соединение не с loopback — заголовок
|
||||||
|
// не читается, ключом становится адрес соединения.
|
||||||
|
func clientIP(r *http.Request) string {
|
||||||
|
remote := connIP(r.RemoteAddr)
|
||||||
|
if !remote.IsValid() {
|
||||||
|
// Адрес соединения не разобрать. Одно общее ведро на всех —
|
||||||
|
// лучше, чем ни одного.
|
||||||
|
return r.RemoteAddr
|
||||||
|
}
|
||||||
|
if remote.IsLoopback() {
|
||||||
|
if ip, err := netip.ParseAddr(strings.TrimSpace(r.Header.Get("X-Real-IP"))); err == nil {
|
||||||
|
return ip.Unmap().WithZone("").String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return remote.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// connIP — адрес, с которого пришло соединение. Невалидный Addr означает,
|
||||||
|
// что RemoteAddr не разобрать.
|
||||||
|
func connIP(remote string) netip.Addr {
|
||||||
|
host, _, err := net.SplitHostPort(remote)
|
||||||
|
if err != nil {
|
||||||
|
host = remote
|
||||||
|
}
|
||||||
|
ip, err := netip.ParseAddr(host)
|
||||||
|
if err != nil {
|
||||||
|
return netip.Addr{}
|
||||||
|
}
|
||||||
|
return ip.Unmap().WithZone("")
|
||||||
|
}
|
||||||
|
|||||||
+108
-35
@@ -1,62 +1,80 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Token bucket из ADR-021: 30 в минуту, пакет 10.
|
// Все четыре правила ADR-021: пакет расходуется целиком, следующий токен
|
||||||
func TestBuckets(t *testing.T) {
|
// набегает ровно через window/count, ведро не переполняется.
|
||||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
func TestRules(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rule rule
|
||||||
|
// token — сколько ждать одного токена на пустом ведре.
|
||||||
|
token time.Duration
|
||||||
|
}{
|
||||||
|
{"регистрация", registerRule, 12 * time.Minute},
|
||||||
|
{"вход", loginRule, time.Minute},
|
||||||
|
{"сообщения", messagesRule, 2 * time.Second},
|
||||||
|
{"изменяющие", writesRule, time.Second},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
b := newBuckets(c.rule)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for i := 0; i < messagesBurst; i++ {
|
for i := 0; i < c.rule.burst; i++ {
|
||||||
if _, ok := b.take("marta", now); !ok {
|
if _, ok := b.take("ключ", now); !ok {
|
||||||
t.Fatalf("запрос %d из пакета отклонён", i+1)
|
t.Fatalf("запрос %d из пакета %d отклонён", i+1, c.rule.burst)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
wait, ok := b.take("marta", now)
|
wait, ok := b.take("ключ", now)
|
||||||
if ok {
|
if ok {
|
||||||
t.Fatal("пакет не кончился")
|
t.Fatal("пакет не кончился")
|
||||||
}
|
}
|
||||||
// Тридцать в минуту — токен раз в две секунды.
|
if wait != c.token {
|
||||||
if wait != 2*time.Second {
|
t.Errorf("ожидание: получено %v, ожидалось %v", wait, c.token)
|
||||||
t.Errorf("ожидание: получено %v, ожидалось 2s", wait)
|
|
||||||
}
|
}
|
||||||
if got := retryAfter(wait); got != 2 {
|
if got, want := retryAfter(wait), int(c.token.Seconds()); got != want {
|
||||||
t.Errorf("Retry-After: получено %d, ожидалось 2", got)
|
t.Errorf("Retry-After: получено %d, ожидалось %d", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Через две секунды набегает ровно один токен.
|
// Восстановление: ровно через это время набегает ровно один токен.
|
||||||
if _, ok := b.take("marta", now.Add(2*time.Second)); !ok {
|
if _, ok := b.take("ключ", now.Add(c.token)); !ok {
|
||||||
t.Error("токен не набежал")
|
t.Error("токен не набежал")
|
||||||
}
|
}
|
||||||
if _, ok := b.take("marta", now.Add(2*time.Second)); ok {
|
if _, ok := b.take("ключ", now.Add(c.token)); ok {
|
||||||
t.Error("набежало больше одного токена")
|
t.Error("набежало больше одного токена")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ведро не переполняется: за час копится пакет, не тридцать в минуту.
|
// За долгую паузу копится пакет, а не весь пропущенный поток.
|
||||||
for i := 0; i < messagesBurst; i++ {
|
for i := 0; i < c.rule.burst; i++ {
|
||||||
if _, ok := b.take("marta", now.Add(time.Hour)); !ok {
|
if _, ok := b.take("ключ", now.Add(24*time.Hour)); !ok {
|
||||||
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
|
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, ok := b.take("marta", now.Add(time.Hour)); ok {
|
if _, ok := b.take("ключ", now.Add(24*time.Hour)); ok {
|
||||||
t.Error("ведро больше пакета")
|
t.Error("ведро больше пакета")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Лимит на ключ: чужое ведро полное.
|
// Ведро на ключ: чужое полное.
|
||||||
if _, ok := b.take("petya", now); !ok {
|
if _, ok := b.take("другой ключ", now); !ok {
|
||||||
t.Error("лимит одного пользователя задел другого")
|
t.Error("лимит одного ключа задел другой")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Часы могут прыгнуть назад; долг за это никому не выставляется.
|
// Часы могут прыгнуть назад; долг за это никому не выставляется.
|
||||||
func TestBucketsClockBack(t *testing.T) {
|
func TestBucketsClockBack(t *testing.T) {
|
||||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
b := newBuckets(messagesRule)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for i := 0; i < messagesBurst; i++ {
|
for i := 0; i < messagesRule.burst; i++ {
|
||||||
b.take("marta", now)
|
b.take("marta", now)
|
||||||
}
|
}
|
||||||
if _, ok := b.take("marta", now.Add(-time.Hour)); ok {
|
if _, ok := b.take("marta", now.Add(-time.Hour)); ok {
|
||||||
@@ -64,32 +82,43 @@ func TestBucketsClockBack(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Полные вёдра выкидываются: карта не растёт на каждый ник навсегда.
|
// Карта лимита не растёт бесконечно: миллион разных ключей проходит
|
||||||
func TestBucketsSweep(t *testing.T) {
|
// сквозь поколения, а вёдер остаётся не больше двух карт.
|
||||||
b := newBuckets(messagesPerMinute, messagesBurst)
|
func TestBucketsBounded(t *testing.T) {
|
||||||
|
b := newBuckets(registerRule)
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
|
|
||||||
for i := 0; i < sweepAt; i++ {
|
for i := 0; i < 1_000_000; i++ {
|
||||||
b.take(string(rune(i)), now)
|
b.take(strconv.Itoa(i), now)
|
||||||
}
|
}
|
||||||
if len(b.seen) != sweepAt {
|
if got := b.size(); got > 2*generation {
|
||||||
t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt)
|
t.Errorf("вёдер: получено %d, ожидалось не больше %d", got, 2*generation)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ключ, по которому ходят, смену поколения переживает: его ведро
|
||||||
|
// переезжает в нынешнюю карту, а не заводится заново.
|
||||||
|
b = newBuckets(registerRule)
|
||||||
|
for i := 0; i < registerRule.burst; i++ {
|
||||||
|
b.take("свой", now)
|
||||||
|
}
|
||||||
|
for i := 0; i < 3*generation; i++ {
|
||||||
|
b.take(strconv.Itoa(i), now)
|
||||||
|
if _, ok := b.take("свой", now); ok {
|
||||||
|
t.Fatalf("ведро забыто на %d-м чужом ключе", i+1)
|
||||||
}
|
}
|
||||||
// Все вёдра успели наполниться заново — чистка их и уносит.
|
|
||||||
b.take("marta", now.Add(time.Hour))
|
|
||||||
if len(b.seen) != 1 {
|
|
||||||
t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ждать меньше секунды бессмысленно: Retry-After в секундах.
|
// Ждать меньше секунды бессмысленно: Retry-After в секундах.
|
||||||
func TestRetryAfter(t *testing.T) {
|
func TestRetryAfter(t *testing.T) {
|
||||||
cases := map[time.Duration]int{
|
cases := map[time.Duration]int{
|
||||||
|
-time.Second: 1,
|
||||||
0: 1,
|
0: 1,
|
||||||
100 * time.Millisecond: 1,
|
100 * time.Millisecond: 1,
|
||||||
time.Second: 1,
|
time.Second: 1,
|
||||||
1500 * time.Millisecond: 2,
|
1500 * time.Millisecond: 2,
|
||||||
2 * time.Second: 2,
|
2 * time.Second: 2,
|
||||||
|
12 * time.Minute: 720,
|
||||||
}
|
}
|
||||||
for wait, want := range cases {
|
for wait, want := range cases {
|
||||||
if got := retryAfter(wait); got != want {
|
if got := retryAfter(wait); got != want {
|
||||||
@@ -98,6 +127,42 @@ func TestRetryAfter(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-Real-IP ставит nginx с той же машины (ADR-022). Заголовку из сети
|
||||||
|
// веры нет: иначе лимит на IP снимался бы новой строкой в заголовке.
|
||||||
|
func TestClientIP(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
remote string
|
||||||
|
real string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"без заголовка", "203.0.113.7:41000", "", "203.0.113.7"},
|
||||||
|
{"заголовок из сети", "203.0.113.7:41000", "198.51.100.1", "203.0.113.7"},
|
||||||
|
{"заголовок от nginx", "127.0.0.1:41000", "198.51.100.1", "198.51.100.1"},
|
||||||
|
{"nginx по ipv6", "[::1]:41000", "198.51.100.1", "198.51.100.1"},
|
||||||
|
{"loopback без заголовка", "127.0.0.1:41000", "", "127.0.0.1"},
|
||||||
|
{"мусор в заголовке", "127.0.0.1:41000", "не адрес", "127.0.0.1"},
|
||||||
|
{"пробелы в заголовке", "127.0.0.1:41000", " 198.51.100.1 ", "198.51.100.1"},
|
||||||
|
{"адрес с портом в заголовке", "127.0.0.1:41000", "198.51.100.1:80", "127.0.0.1"},
|
||||||
|
{"ipv6 клиента", "[2001:db8::1]:41000", "", "2001:db8::1"},
|
||||||
|
{"ipv4 в ipv6-форме", "[::ffff:203.0.113.7]:41000", "", "203.0.113.7"},
|
||||||
|
{"ipv4 в ipv6-форме в заголовке", "127.0.0.1:41000", "::ffff:198.51.100.1", "198.51.100.1"},
|
||||||
|
{"не разобрать соединение", "сокет", "198.51.100.1", "сокет"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, "/api/register", nil)
|
||||||
|
r.RemoteAddr = c.remote
|
||||||
|
if c.real != "" {
|
||||||
|
r.Header.Set("X-Real-IP", c.real)
|
||||||
|
}
|
||||||
|
if got := clientIP(r); got != c.want {
|
||||||
|
t.Errorf("clientIP: получено %q, ожидалось %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ULID: 26 символов Crockford base32, время в первых десяти.
|
// ULID: 26 символов Crockford base32, время в первых десяти.
|
||||||
func TestULIDTime(t *testing.T) {
|
func TestULIDTime(t *testing.T) {
|
||||||
// 01ARZ3NDEK — 2016-07-30T23:54:10.259Z.
|
// 01ARZ3NDEK — 2016-07-30T23:54:10.259Z.
|
||||||
@@ -119,3 +184,11 @@ func TestULIDTime(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// size — сколько вёдер помнят обе карты. Только для тестов: предел размера
|
||||||
|
// проверяется, а не подразумевается.
|
||||||
|
func (b *buckets) size() int {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
return len(b.cur) + len(b.old)
|
||||||
|
}
|
||||||
|
|||||||
+13
-15
@@ -3,7 +3,6 @@ package api
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/xmatic-squad/bare/internal/auth"
|
"github.com/xmatic-squad/bare/internal/auth"
|
||||||
@@ -55,10 +54,6 @@ type messageIn struct {
|
|||||||
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
|
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
|
||||||
// Порядок проверок — docs/protocol.md, «Сообщения».
|
// Порядок проверок — docs/protocol.md, «Сообщения».
|
||||||
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
device, ok := s.device(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var in messageIn
|
var in messageIn
|
||||||
if !decode(w, r, &in) {
|
if !decode(w, r, &in) {
|
||||||
return
|
return
|
||||||
@@ -75,6 +70,14 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Принадлежность устройства — право, а не форма, поэтому проверяется
|
||||||
|
// после разбора тела: кривое тело отвечает bad_json и invalid даже
|
||||||
|
// с чужим X-Device (ADR-043).
|
||||||
|
device, ok := s.device(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
sess, _ := auth.From(r)
|
sess, _ := auth.From(r)
|
||||||
room := in.To.Room != ""
|
room := in.To.Room != ""
|
||||||
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
|
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
|
||||||
@@ -220,10 +223,6 @@ func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
|
|||||||
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
|
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
|
||||||
// устройства их можно убрать (ADR-008).
|
// устройства их можно убрать (ADR-008).
|
||||||
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
||||||
device, ok := s.device(w, r)
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var in struct {
|
var in struct {
|
||||||
IDs []string `json:"ids"`
|
IDs []string `json:"ids"`
|
||||||
}
|
}
|
||||||
@@ -234,15 +233,14 @@ func (s *server) ack(w http.ResponseWriter, r *http.Request) {
|
|||||||
Invalid(w, "ids", "не больше 500 идентификаторов")
|
Invalid(w, "ids", "не больше 500 идентификаторов")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Устройство — право: после формы тела (ADR-043).
|
||||||
|
device, ok := s.device(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
|
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
|
||||||
s.internal(w, r, err)
|
s.internal(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
noContent(w)
|
noContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// rateLimited — 429 с Retry-After в секундах (ADR-021).
|
|
||||||
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
|
|
||||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
|
|
||||||
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -804,3 +804,39 @@ func TestPushPayloadPerDevice(t *testing.T) {
|
|||||||
}
|
}
|
||||||
svc.silent()
|
svc.silent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Остановка обработчика дожидается начатых отправок. Отправщик пишет
|
||||||
|
// результат в базу, поэтому закрывать её раньше нельзя, а колбэк
|
||||||
|
// http.Server.RegisterOnShutdown для этого не годится: сервер запускает
|
||||||
|
// его в своей горутине и ничего не ждёт. Потоки событий там закрывает
|
||||||
|
// CloseStreams, отправку останавливает Close — после Shutdown.
|
||||||
|
func TestCloseWaitsForPush(t *testing.T) {
|
||||||
|
svc, release := newSlowPushService(t)
|
||||||
|
e := pushEnv(t)
|
||||||
|
marta, m1 := e.join("marta", 1)
|
||||||
|
petya, p1 := e.join("petya", 2)
|
||||||
|
e.subscribe("petya", p1, svc)
|
||||||
|
_ = petya
|
||||||
|
|
||||||
|
e.send(marta, m1, "petya", 5)
|
||||||
|
// Отправка началась и висит на медленном сервисе.
|
||||||
|
svc.next()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
e.h.Close()
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
t.Fatal("остановка не дождалась начатой отправки")
|
||||||
|
case <-time.After(quiet):
|
||||||
|
}
|
||||||
|
|
||||||
|
release()
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(wait):
|
||||||
|
t.Fatal("остановка не закончилась после отправки")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
package api_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/xmatic-squad/bare/internal/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nickOf — ник для очередного аккаунта теста.
|
||||||
|
func nickOf(i int) string { return fmt.Sprintf("marta%d", i) }
|
||||||
|
|
||||||
|
// fromIP — соединение с этого адреса, без заголовков.
|
||||||
|
func fromIP(ip string) func(*http.Request) { return withRemote(ip + ":41000") }
|
||||||
|
|
||||||
|
// Регистрация — 5 в час на IP (ADR-021).
|
||||||
|
func TestRegisterRateLimit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
one := fromIP("203.0.113.7")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one), http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one)
|
||||||
|
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||||
|
// Токен набегает раз в двенадцать минут; ведро пусто, значит ждать
|
||||||
|
// почти все 720 секунд.
|
||||||
|
if got := retryAfterOf(t, rec); got < 700 || got > 720 {
|
||||||
|
t.Errorf("Retry-After: получено %d, ожидалось около 720", got)
|
||||||
|
}
|
||||||
|
// Отказ ничего не завёл.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "kolya", "authKey": bytesOf(32, 1)}),
|
||||||
|
http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
|
||||||
|
// Другой адрес — своё ведро.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), fromIP("203.0.113.8")),
|
||||||
|
http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Неудачная регистрация тратит попытку так же, как удачная: иначе занятые
|
||||||
|
// ники перебирались бы без счёта.
|
||||||
|
func TestRegisterRateLimitCountsFailures(t *testing.T) {
|
||||||
|
e := invited(t, "секрет")
|
||||||
|
one := fromIP("203.0.113.7")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
body := account(nickOf(i))
|
||||||
|
body["invite"] = "не секрет"
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", body, one), http.StatusForbidden, "invalid_invite")
|
||||||
|
}
|
||||||
|
right := account("marta")
|
||||||
|
right["invite"] = "секрет"
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", right, one), http.StatusTooManyRequests, "rate_limited")
|
||||||
|
|
||||||
|
// Форма разбирается раньше лимита и попытки не тратит (ADR-043).
|
||||||
|
fresh := fromIP("203.0.113.9")
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
body := account("МАРТА")
|
||||||
|
body["invite"] = "секрет"
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", body, fresh), http.StatusBadRequest, "invalid_nick")
|
||||||
|
}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", right, fresh), http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Вход — 10 за 10 минут на пару IP+ник (ADR-021).
|
||||||
|
func TestLoginRateLimit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
e.signUp("marta")
|
||||||
|
e.signUp("petya")
|
||||||
|
|
||||||
|
one := fromIP("203.0.113.7")
|
||||||
|
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/login", wrong, one), http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
}
|
||||||
|
rec := e.do(http.MethodPost, "/api/login", wrong, one)
|
||||||
|
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||||
|
if got := retryAfterOf(t, rec); got < 55 || got > 60 {
|
||||||
|
t.Errorf("Retry-After: получено %d, ожидалось около 60", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Верный пароль с того же адреса ждёт вместе с неверными: ведро
|
||||||
|
// на паре, а не на исходе попытки.
|
||||||
|
right := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/login", right, one), http.StatusTooManyRequests, "rate_limited")
|
||||||
|
|
||||||
|
// Другой ник с того же адреса — своё ведро.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 9)}, one),
|
||||||
|
http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
// Тот же ник с другого адреса — тоже своё.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/login", right, fromIP("203.0.113.8")), http.StatusOK, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Остальные изменяющие запросы — 60 в минуту на пользователя (ADR-021).
|
||||||
|
func TestWritesRateLimit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
marta := e.signUp("marta")
|
||||||
|
id := deviceOf(1)
|
||||||
|
|
||||||
|
for i := 0; i < 60; i++ {
|
||||||
|
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
|
||||||
|
if rec.Code >= http.StatusMultipleChoices {
|
||||||
|
t.Fatalf("запрос %d из шестидесяти отклонён: %d (%s)", i+1, rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
|
||||||
|
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||||
|
// Шестьдесят в минуту — токен раз в секунду.
|
||||||
|
if got := retryAfterOf(t, rec); got != 1 {
|
||||||
|
t.Errorf("Retry-After: получено %d, ожидалось 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ведро общее на все изменяющие маршруты пользователя.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
|
||||||
|
http.StatusTooManyRequests, "rate_limited")
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/logout", nil, with(marta)),
|
||||||
|
http.StatusTooManyRequests, "rate_limited")
|
||||||
|
|
||||||
|
// Чтения лимитом не считаются: ADR-021 ограничивает изменяющие.
|
||||||
|
expect(t, e.do(http.MethodGet, "/api/devices", nil, with(marta)), http.StatusOK, "")
|
||||||
|
expect(t, e.do(http.MethodGet, "/api/me", nil, with(marta)), http.StatusOK, "")
|
||||||
|
expect(t, e.do(http.MethodGet, "/api/rooms", nil, with(marta)), http.StatusOK, "")
|
||||||
|
|
||||||
|
// Сообщения считаются своим правилом и своим ведром.
|
||||||
|
petya := e.signUp("petya")
|
||||||
|
e.addDevice(petya, deviceOf(2))
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
|
||||||
|
with(marta), withDevice(id)), http.StatusAccepted, "")
|
||||||
|
|
||||||
|
// Другой пользователь чужим лимитом не задет. Строка контакта уже есть:
|
||||||
|
// её завело сообщение, поэтому 200, а не 201 (ADR-019).
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(petya)),
|
||||||
|
http.StatusOK, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Лимит сообщений считается отдельно от общего: тридцать в минуту
|
||||||
|
// не отнимают шестьдесят у остальных запросов (ADR-021).
|
||||||
|
func TestMessagesOutsideWritesLimit(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
marta, m1 := e.join("marta", 1)
|
||||||
|
e.join("petya", 2)
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
|
||||||
|
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||||
|
}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"),
|
||||||
|
with(marta), withDevice(m1)), http.StatusTooManyRequests, "rate_limited")
|
||||||
|
// Пакет сообщений кончился, изменяющие запросы работают.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(marta), withDevice(m1)),
|
||||||
|
http.StatusNoContent, "")
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
|
||||||
|
http.StatusOK, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// X-Real-IP ставит nginx с той же машины: за ним у каждого адреса своё
|
||||||
|
// ведро (ADR-055).
|
||||||
|
func TestRealIPFromLoopback(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
nginx := fromIP("127.0.0.1")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), nginx, withRealIP("198.51.100.7")),
|
||||||
|
http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.7")),
|
||||||
|
http.StatusTooManyRequests, "rate_limited")
|
||||||
|
// Соседний адрес за тем же nginx ждать не должен.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.8")),
|
||||||
|
http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Заголовок из сети не читается: иначе лимит на IP снимался бы новой
|
||||||
|
// строкой в заголовке, то есть не существовал бы вовсе (ADR-055).
|
||||||
|
func TestRealIPFromNetworkIgnored(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
one := fromIP("203.0.113.7")
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one,
|
||||||
|
withRealIP(fmt.Sprintf("198.51.100.%d", i))), http.StatusCreated, "")
|
||||||
|
}
|
||||||
|
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one, withRealIP("198.51.100.200"))
|
||||||
|
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
|
||||||
|
|
||||||
|
// И на входе тоже: ведро на паре адрес соединения + ник.
|
||||||
|
e2 := newEnv(t)
|
||||||
|
e2.signUp("marta")
|
||||||
|
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP(fmt.Sprintf("198.51.100.%d", i))),
|
||||||
|
http.StatusUnauthorized, "invalid_credentials")
|
||||||
|
}
|
||||||
|
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP("198.51.100.200")),
|
||||||
|
http.StatusTooManyRequests, "rate_limited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предел тела — 32 КиБ на всех маршрутах, ответ один: 413 too_large
|
||||||
|
// (ADR-026). Отказ приходит раньше сессии, устройства и разбора тела.
|
||||||
|
func TestTooLargeEverywhere(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
marta, device := e.join("marta", 1)
|
||||||
|
big := strings.Repeat("a", api.MaxBody+1)
|
||||||
|
|
||||||
|
routes := []struct{ method, target string }{
|
||||||
|
{http.MethodPost, "/api/register"},
|
||||||
|
{http.MethodPost, "/api/login"},
|
||||||
|
{http.MethodPost, "/api/logout"},
|
||||||
|
{http.MethodPost, "/api/password"},
|
||||||
|
{http.MethodDelete, "/api/me"},
|
||||||
|
{http.MethodPost, "/api/devices"},
|
||||||
|
{http.MethodDelete, "/api/devices/" + device},
|
||||||
|
{http.MethodPut, "/api/devices/" + device + "/push"},
|
||||||
|
{http.MethodDelete, "/api/devices/" + device + "/push"},
|
||||||
|
{http.MethodPost, "/api/contacts"},
|
||||||
|
{http.MethodDelete, "/api/contacts/petya"},
|
||||||
|
{http.MethodPost, "/api/rooms"},
|
||||||
|
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/members"},
|
||||||
|
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/leave"},
|
||||||
|
{http.MethodDelete, "/api/rooms/" + roomIDOf(1)},
|
||||||
|
{http.MethodPost, "/api/messages"},
|
||||||
|
{http.MethodPost, "/api/ack"},
|
||||||
|
{http.MethodGet, "/api/me"},
|
||||||
|
{http.MethodGet, "/api/events?device=" + device},
|
||||||
|
}
|
||||||
|
for _, route := range routes {
|
||||||
|
t.Run(route.method+" "+route.target, func(t *testing.T) {
|
||||||
|
// С сессией и своим устройством — отказ всё равно по телу.
|
||||||
|
expect(t, e.do(route.method, route.target, big, with(marta), withDevice(device)),
|
||||||
|
http.StatusRequestEntityTooLarge, "too_large")
|
||||||
|
// И без сессии тоже: тело проверяется раньше прав (ADR-043).
|
||||||
|
expect(t, e.do(route.method, route.target, big),
|
||||||
|
http.StatusRequestEntityTooLarge, "too_large")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тело без заявленной длины обрывается при чтении — тем же кодом и там,
|
||||||
|
// где раньше отвечало устройство (ADR-043).
|
||||||
|
func TestTooLargeUnannounced(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
marta, _ := e.join("marta", 1)
|
||||||
|
_, foreign := e.join("petya", 2)
|
||||||
|
|
||||||
|
// Валидный json, чтобы разбор дошёл до предела чтения, а не споткнулся
|
||||||
|
// о первый же байт.
|
||||||
|
body := `{"id":"` + strings.Repeat("a", api.MaxBody) + `"}`
|
||||||
|
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms", "/api/devices", "/api/contacts"} {
|
||||||
|
rec := e.do(http.MethodPost, target, nil, with(marta), withDevice(foreign), func(r *http.Request) {
|
||||||
|
r.Body = io.NopCloser(strings.NewReader(body))
|
||||||
|
r.ContentLength = -1
|
||||||
|
})
|
||||||
|
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Форма запроса разбирается раньше прав: кривое тело с чужим устройством
|
||||||
|
// отвечает про тело, а не про устройство (ADR-043).
|
||||||
|
func TestFormBeforeDevice(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
_, martaDevice := e.join("marta", 1)
|
||||||
|
petya, _ := e.join("petya", 2)
|
||||||
|
|
||||||
|
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms"} {
|
||||||
|
expect(t, e.do(http.MethodPost, target, "{", with(petya), withDevice(martaDevice)),
|
||||||
|
http.StatusBadRequest, "bad_json")
|
||||||
|
// Заголовка нет вовсе — то же самое.
|
||||||
|
expect(t, e.do(http.MethodPost, target, "{", with(petya)),
|
||||||
|
http.StatusBadRequest, "bad_json")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Кривое поле тела — invalid с этим полем, хотя устройство чужое.
|
||||||
|
rec := e.do(http.MethodPost, "/api/messages", message("не ulid", "marta"), with(petya), withDevice(martaDevice))
|
||||||
|
expect(t, rec, http.StatusBadRequest, "invalid")
|
||||||
|
if got := field(t, rec); got != "id" {
|
||||||
|
t.Errorf("field: получено %q, ожидалось \"id\"", got)
|
||||||
|
}
|
||||||
|
// Часы — свойство самого запроса, а не право: clock_skew тоже раньше
|
||||||
|
// (docs/protocol.md, «Сообщения»).
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis()-6*60*1000, 3), "marta"),
|
||||||
|
with(petya), withDevice(martaDevice)), http.StatusBadRequest, "clock_skew")
|
||||||
|
|
||||||
|
ids := make([]string, 501)
|
||||||
|
for i := range ids {
|
||||||
|
ids[i] = ulid(nowMillis(), byte(i))
|
||||||
|
}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(petya), withDevice(martaDevice)),
|
||||||
|
http.StatusBadRequest, "invalid")
|
||||||
|
|
||||||
|
room := map[string]any{
|
||||||
|
"id": "не комната",
|
||||||
|
"name": "общая",
|
||||||
|
"keyId": keyID(40),
|
||||||
|
"keys": keysFor([]string{"petya"}, 40),
|
||||||
|
}
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
|
||||||
|
http.StatusBadRequest, "invalid")
|
||||||
|
|
||||||
|
// Тело по форме — тогда отказ по устройству.
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
|
||||||
|
with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device")
|
||||||
|
}
|
||||||
+37
-23
@@ -20,8 +20,10 @@ import (
|
|||||||
// метаданные, как и состав.
|
// метаданные, как и состав.
|
||||||
const maxRoomName = 64
|
const maxRoomName = 64
|
||||||
|
|
||||||
// roomOut — тип Room из docs/protocol.md. key присутствует всегда,
|
// roomOut — тип Room из docs/protocol.md. keys присутствует всегда,
|
||||||
// пустой — null; needsRekey — состояние комнаты, а не свойство события,
|
// пустой — []; в GET /api/rooms это все удерживаемые сервером ключи
|
||||||
|
// запрашивающего, от старого к новому, в событии room — только новый
|
||||||
|
// (ADR-059). needsRekey — состояние комнаты, а не свойство события,
|
||||||
// поэтому идёт и в списке, и в событии (ADR-041).
|
// поэтому идёт и в списке, и в событии (ADR-041).
|
||||||
type roomOut struct {
|
type roomOut struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -29,7 +31,7 @@ type roomOut struct {
|
|||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Members []string `json:"members"`
|
Members []string `json:"members"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
Key *keyOut `json:"key"`
|
Keys []keyOut `json:"keys"`
|
||||||
NeedsRekey bool `json:"needsRekey"`
|
NeedsRekey bool `json:"needsRekey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,8 +51,9 @@ type keyIn struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
|
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
|
||||||
// текущим ключом и признаком needsRekey: владелец, пропустивший событие,
|
// ключами и признаком needsRekey: владелец, пропустивший событие,
|
||||||
// поднимает долг по ключу отсюда (ADR-041).
|
// поднимает долг по ключу отсюда (ADR-041), а участник, пропустивший
|
||||||
|
// rekey в офлайне, — недостающий ключ (ADR-059).
|
||||||
func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
||||||
sess, _ := auth.From(r)
|
sess, _ := auth.From(r)
|
||||||
list, err := s.st.Rooms(r.Context(), sess.Nick)
|
list, err := s.st.Rooms(r.Context(), sess.Nick)
|
||||||
@@ -60,7 +63,7 @@ func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
out := make([]roomOut, 0, len(list))
|
out := make([]roomOut, 0, len(list))
|
||||||
for _, room := range list {
|
for _, room := range list {
|
||||||
out = append(out, roomJSON(room, room.Key))
|
out = append(out, roomJSON(room, room.Keys))
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
@@ -69,12 +72,6 @@ func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
|
|||||||
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
|
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
|
||||||
// его другие устройства получают комнату вместе с ключом (ADR-018).
|
// его другие устройства получают комнату вместе с ключом (ADR-018).
|
||||||
func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
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 {
|
var in struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -109,6 +106,13 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
keysMismatch(w)
|
keysMismatch(w)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// X-Device здесь необязателен, но чужой и кривой — 403, как и везде,
|
||||||
|
// где устройство важно (docs/protocol.md, «Общие правила»). Проверка
|
||||||
|
// идёт после формы тела: права — после неё (ADR-043).
|
||||||
|
device, ok := s.optionalDevice(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
change, err := s.st.CreateRoom(r.Context(), store.NewRoom{
|
change, err := s.st.CreateRoom(r.Context(), store.NewRoom{
|
||||||
ID: in.ID,
|
ID: in.ID,
|
||||||
Name: in.Name,
|
Name: in.Name,
|
||||||
@@ -130,7 +134,7 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Комната уже записана: остальным устройствам создателя она уходит
|
// Комната уже записана: остальным устройствам создателя она уходит
|
||||||
// событием, отправившему — ответом на запрос.
|
// событием, отправившему — ответом на запрос.
|
||||||
s.sendRoom(r, change, device)
|
s.sendRoom(r, change, device)
|
||||||
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keysFor(change, sess.Nick)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
|
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
|
||||||
@@ -191,7 +195,7 @@ func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Событие room уходит и участникам, и — как room_left — убранным;
|
// Событие room уходит и участникам, и — как room_left — убранным;
|
||||||
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
|
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
|
||||||
s.sendRoom(r, change, "")
|
s.sendRoom(r, change, "")
|
||||||
writeJSON(w, http.StatusOK, roomJSON(change.Room, keyFor(change, sess.Nick)))
|
writeJSON(w, http.StatusOK, roomJSON(change.Room, keysFor(change, sess.Nick)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
|
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
|
||||||
@@ -270,7 +274,7 @@ func keysMismatch(w http.ResponseWriter) {
|
|||||||
// «События», ADR-041).
|
// «События», ADR-041).
|
||||||
func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) {
|
func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) {
|
||||||
for _, member := range change.Members {
|
for _, member := range change.Members {
|
||||||
raw, err := json.Marshal(roomJSON(change.Room, member.Key))
|
raw, err := json.Marshal(roomJSON(change.Room, keyList(member.Key)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.report(r, err)
|
s.report(r, err)
|
||||||
continue
|
continue
|
||||||
@@ -302,31 +306,41 @@ func (s *server) send(devices []string, exclude string, ev hub.Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// roomJSON собирает Room протокола: состав всегда список, ключ — null,
|
// roomJSON собирает Room протокола: состав и ключи всегда списки,
|
||||||
// если его нет.
|
// пустые — [].
|
||||||
func roomJSON(room store.Room, key *store.RoomKey) roomOut {
|
func roomJSON(room store.Room, keys []store.RoomKey) roomOut {
|
||||||
out := roomOut{
|
out := roomOut{
|
||||||
ID: room.ID,
|
ID: room.ID,
|
||||||
Name: room.Name,
|
Name: room.Name,
|
||||||
Owner: room.Owner,
|
Owner: room.Owner,
|
||||||
Members: room.Members,
|
Members: room.Members,
|
||||||
CreatedAt: room.CreatedAt,
|
CreatedAt: room.CreatedAt,
|
||||||
|
Keys: make([]keyOut, 0, len(keys)),
|
||||||
NeedsRekey: room.NeedsRekey,
|
NeedsRekey: room.NeedsRekey,
|
||||||
}
|
}
|
||||||
if out.Members == nil {
|
if out.Members == nil {
|
||||||
out.Members = []string{}
|
out.Members = []string{}
|
||||||
}
|
}
|
||||||
if key != nil {
|
for _, key := range keys {
|
||||||
out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT}
|
out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT})
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// keyFor — ключ участника в итоге изменения: у каждого он свой.
|
// keyList — ключ события: он один, новый (ADR-059). Остальные свои ключи
|
||||||
func keyFor(change store.RoomChange, nick string) *store.RoomKey {
|
// получатель уже видел, а отключённый доберёт их из GET /api/rooms.
|
||||||
|
func keyList(key *store.RoomKey) []store.RoomKey {
|
||||||
|
if key == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []store.RoomKey{*key}
|
||||||
|
}
|
||||||
|
|
||||||
|
// keysFor — ключ участника в итоге изменения: у каждого он свой.
|
||||||
|
func keysFor(change store.RoomChange, nick string) []store.RoomKey {
|
||||||
for _, member := range change.Members {
|
for _, member := range change.Members {
|
||||||
if member.Nick == nick {
|
if member.Nick == nick {
|
||||||
return member.Key
|
return keyList(member.Key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
+84
-31
@@ -17,10 +17,19 @@ type roomBody struct {
|
|||||||
Owner string `json:"owner"`
|
Owner string `json:"owner"`
|
||||||
Members []string `json:"members"`
|
Members []string `json:"members"`
|
||||||
CreatedAt int64 `json:"createdAt"`
|
CreatedAt int64 `json:"createdAt"`
|
||||||
Key *keyBody `json:"key"`
|
Keys []keyBody `json:"keys"`
|
||||||
NeedsRekey bool `json:"needsRekey"`
|
NeedsRekey bool `json:"needsRekey"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// key — текущий ключ: последний в keys, они идут от старого к новому
|
||||||
|
// (ADR-059). nil — ключей нет вовсе.
|
||||||
|
func (r roomBody) key() *keyBody {
|
||||||
|
if len(r.Keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &r.Keys[len(r.Keys)-1]
|
||||||
|
}
|
||||||
|
|
||||||
type keyBody struct {
|
type keyBody struct {
|
||||||
KeyID string `json:"keyId"`
|
KeyID string `json:"keyId"`
|
||||||
From string `json:"from"`
|
From string `json:"from"`
|
||||||
@@ -151,9 +160,9 @@ func TestCreateRoom(t *testing.T) {
|
|||||||
if room.NeedsRekey {
|
if room.NeedsRekey {
|
||||||
t.Error("needsRekey в ответе на создание")
|
t.Error("needsRekey в ответе на создание")
|
||||||
}
|
}
|
||||||
if room.Key == nil || room.Key.KeyID != keyID(40) || room.Key.From != "marta" ||
|
if room.key() == nil || room.key().KeyID != keyID(40) || room.key().From != "marta" ||
|
||||||
room.Key.IV != ivOf(40, 0) || room.Key.CT != ctOf(40, 0) {
|
room.key().IV != ivOf(40, 0) || room.key().CT != ctOf(40, 0) {
|
||||||
t.Errorf("ключ: %+v", room.Key)
|
t.Errorf("ключ: %+v", room.key())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Та же комната приходит списком, с тем же ключом.
|
// Та же комната приходит списком, с тем же ключом.
|
||||||
@@ -161,7 +170,7 @@ func TestCreateRoom(t *testing.T) {
|
|||||||
if len(list) != 1 {
|
if len(list) != 1 {
|
||||||
t.Fatalf("комнат: получено %d, ожидалась 1", len(list))
|
t.Fatalf("комнат: получено %d, ожидалась 1", len(list))
|
||||||
}
|
}
|
||||||
if list[0].ID != room.ID || list[0].Key == nil || list[0].Key.CT != ctOf(40, 0) {
|
if list[0].ID != room.ID || list[0].key() == nil || list[0].key().CT != ctOf(40, 0) {
|
||||||
t.Errorf("список комнат: %+v", list[0])
|
t.Errorf("список комнат: %+v", list[0])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +215,7 @@ func TestCreateRoomConflict(t *testing.T) {
|
|||||||
t.Errorf("занятый id присоединил к чужой комнате: %+v", got)
|
t.Errorf("занятый id присоединил к чужой комнате: %+v", got)
|
||||||
}
|
}
|
||||||
list := e.rooms(marta)
|
list := e.rooms(marta)
|
||||||
if len(list) != 1 || list[0].Name != "общая" || list[0].Key == nil || list[0].Key.KeyID != keyID(40) {
|
if len(list) != 1 || list[0].Name != "общая" || list[0].key() == nil || list[0].key().KeyID != keyID(40) {
|
||||||
t.Errorf("занятый id тронул существующую комнату: %+v", list)
|
t.Errorf("занятый id тронул существующую комнату: %+v", list)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,8 +310,8 @@ func TestMembersAdd(t *testing.T) {
|
|||||||
if nicks(got.Members) != nicks(want) {
|
if nicks(got.Members) != nicks(want) {
|
||||||
t.Errorf("состав: получено %v, ожидалось %v", got.Members, want)
|
t.Errorf("состав: получено %v, ожидалось %v", got.Members, want)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) {
|
if got.key() == nil || got.key().KeyID != keyID(60) || got.key().CT != ctOf(60, 0) {
|
||||||
t.Errorf("ключ владельца в ответе: %+v", got.Key)
|
t.Errorf("ключ владельца в ответе: %+v", got.key())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Каждый видит комнату со своим ключом.
|
// Каждый видит комнату со своим ключом.
|
||||||
@@ -314,9 +323,9 @@ func TestMembersAdd(t *testing.T) {
|
|||||||
if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) {
|
if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) {
|
||||||
t.Errorf("комната у %d: %+v", i, list[0])
|
t.Errorf("комната у %d: %+v", i, list[0])
|
||||||
}
|
}
|
||||||
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" ||
|
if list[0].key() == nil || list[0].key().KeyID != keyID(60) || list[0].key().From != "marta" ||
|
||||||
list[0].Key.CT != ctOf(60, i) {
|
list[0].key().CT != ctOf(60, i) {
|
||||||
t.Errorf("ключ у %d: %+v", i, list[0].Key)
|
t.Errorf("ключ у %d: %+v", i, list[0].key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,8 +393,8 @@ func TestMembersKeysMismatch(t *testing.T) {
|
|||||||
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
||||||
t.Errorf("состав после отказов: %v", got.Members)
|
t.Errorf("состав после отказов: %v", got.Members)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(40) {
|
if got.key() == nil || got.key().KeyID != keyID(40) {
|
||||||
t.Errorf("ключ после отказов: %+v", got.Key)
|
t.Errorf("ключ после отказов: %+v", got.key())
|
||||||
}
|
}
|
||||||
if list := e.rooms(kolya); len(list) != 0 {
|
if list := e.rooms(kolya); len(list) != 0 {
|
||||||
t.Errorf("комната у постороннего: %+v", list)
|
t.Errorf("комната у постороннего: %+v", list)
|
||||||
@@ -533,8 +542,8 @@ func TestLeaveTransfersOwnership(t *testing.T) {
|
|||||||
t.Errorf("состав: %v", got.Members)
|
t.Errorf("состав: %v", got.Members)
|
||||||
}
|
}
|
||||||
// Ключ оставшихся никуда не делся: rekey делает клиент нового владельца.
|
// Ключ оставшихся никуда не делся: rekey делает клиент нового владельца.
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(80) {
|
if got.key() == nil || got.key().KeyID != keyID(80) {
|
||||||
t.Errorf("ключ после выхода владельца: %+v", got.Key)
|
t.Errorf("ключ после выхода владельца: %+v", got.key())
|
||||||
}
|
}
|
||||||
// Новый владелец меняет состав, прежний — уже нет.
|
// Новый владелец меняет состав, прежний — уже нет.
|
||||||
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100),
|
expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100),
|
||||||
@@ -690,8 +699,8 @@ func TestRoomKeysKeepTwo(t *testing.T) {
|
|||||||
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||||
}
|
}
|
||||||
// Текущий ключ участника — последний.
|
// Текущий ключ участника — последний.
|
||||||
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) {
|
if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(80) {
|
||||||
t.Errorf("текущий ключ: %+v", got.Key)
|
t.Errorf("текущий ключ: %+v", got.key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -722,8 +731,8 @@ func TestDeleteAccountWithRooms(t *testing.T) {
|
|||||||
if len(list[0].Members) != 1 || list[0].Members[0] != "petya" {
|
if len(list[0].Members) != 1 || list[0].Members[0] != "petya" {
|
||||||
t.Errorf("состав: %v", list[0].Members)
|
t.Errorf("состав: %v", list[0].Members)
|
||||||
}
|
}
|
||||||
if list[0].Key == nil || list[0].Key.KeyID != keyID(60) {
|
if list[0].key() == nil || list[0].key().KeyID != keyID(60) {
|
||||||
t.Errorf("ключ оставшегося: %+v", list[0].Key)
|
t.Errorf("ключ оставшегося: %+v", list[0].key())
|
||||||
}
|
}
|
||||||
// Ник свободен, а комната, где не осталось никого, исчезла вместе с ним.
|
// Ник свободен, а комната, где не осталось никого, исчезла вместе с ним.
|
||||||
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
|
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
|
||||||
@@ -759,8 +768,8 @@ func TestRoomEventOnCreate(t *testing.T) {
|
|||||||
if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" {
|
if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" {
|
||||||
t.Errorf("комната в событии: %+v", got)
|
t.Errorf("комната в событии: %+v", got)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.CT != ctOf(40, 0) {
|
if got.key() == nil || got.key().CT != ctOf(40, 0) {
|
||||||
t.Errorf("ключ в событии: %+v", got.Key)
|
t.Errorf("ключ в событии: %+v", got.key())
|
||||||
}
|
}
|
||||||
if got.NeedsRekey {
|
if got.NeedsRekey {
|
||||||
t.Error("needsRekey при создании")
|
t.Error("needsRekey при создании")
|
||||||
@@ -808,8 +817,8 @@ func TestRoomEventsOnMembers(t *testing.T) {
|
|||||||
if nicks(got.Members) != "marta,petya" {
|
if nicks(got.Members) != "marta,petya" {
|
||||||
t.Errorf("состав в событии у %s: %v", nick, got.Members)
|
t.Errorf("состав в событии у %s: %v", nick, got.Members)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) {
|
if got.key() == nil || got.key().KeyID != keyID(80) || got.key().CT != ctOf(80, i) {
|
||||||
t.Errorf("ключ в событии у %s: %+v", nick, got.Key)
|
t.Errorf("ключ в событии у %s: %+v", nick, got.key())
|
||||||
}
|
}
|
||||||
if got.NeedsRekey {
|
if got.NeedsRekey {
|
||||||
t.Errorf("needsRekey при смене состава у %s", nick)
|
t.Errorf("needsRekey при смене состава у %s", nick)
|
||||||
@@ -853,8 +862,8 @@ func TestRoomEventOnLeave(t *testing.T) {
|
|||||||
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
if len(got.Members) != 1 || got.Members[0] != "marta" {
|
||||||
t.Errorf("состав в событии: %v", got.Members)
|
t.Errorf("состав в событии: %v", got.Members)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(60) {
|
if got.key() == nil || got.key().KeyID != keyID(60) {
|
||||||
t.Errorf("ключ в событии: %+v", got.Key)
|
t.Errorf("ключ в событии: %+v", got.key())
|
||||||
}
|
}
|
||||||
// Другим устройствам вышедшего — room_left: комната ушла из списка,
|
// Другим устройствам вышедшего — room_left: комната ушла из списка,
|
||||||
// и ждать следующего ready им незачем (ADR-041). Запрос шёл без
|
// и ждать следующего ready им незачем (ADR-041). Запрос шёл без
|
||||||
@@ -959,8 +968,8 @@ func TestNeedsRekeyOutlivesEvent(t *testing.T) {
|
|||||||
if got == nil || !got.NeedsRekey {
|
if got == nil || !got.NeedsRekey {
|
||||||
t.Fatalf("needsRekey в списке комнат: %+v", got)
|
t.Fatalf("needsRekey в списке комнат: %+v", got)
|
||||||
}
|
}
|
||||||
if got.Key == nil || got.Key.KeyID != keyID(60) {
|
if got.key() == nil || got.key().KeyID != keyID(60) {
|
||||||
t.Errorf("ключ в списке: %+v", got.Key)
|
t.Errorf("ключ в списке: %+v", got.key())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rekey закрывает долг.
|
// Rekey закрывает долг.
|
||||||
@@ -970,6 +979,50 @@ func TestNeedsRekeyOutlivesEvent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Участник, пропустивший два rekey в офлайне, получает оба удерживаемых
|
||||||
|
// ключа: без прежнего он не прочитал бы конверт, который лежит в его
|
||||||
|
// очереди с промежуточным keyId (ADR-059).
|
||||||
|
func TestRoomKeysCoverMissedRekey(t *testing.T) {
|
||||||
|
e := newEnv(t)
|
||||||
|
marta, m1 := e.join("marta", 1)
|
||||||
|
petya, p1 := e.join("petya", 2)
|
||||||
|
e.join("kolya", 3)
|
||||||
|
|
||||||
|
room := e.makeRoom(marta, "marta", "общая", 40)
|
||||||
|
// Первый rekey: пришёл kolya. Устройство petya офлайн — событие room
|
||||||
|
// в очередь не кладётся, и ключ до него не доехал.
|
||||||
|
expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil,
|
||||||
|
[]string{"marta", "petya", "kolya"}, 60), http.StatusOK, "")
|
||||||
|
|
||||||
|
id := ulid(nowMillis(), 5)
|
||||||
|
expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(id, room.ID, keyID(60)),
|
||||||
|
with(marta), withDevice(m1)), http.StatusAccepted, "")
|
||||||
|
|
||||||
|
// Второй rekey: kolya ушёл. Текущим стал третий ключ.
|
||||||
|
expect(t, e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80),
|
||||||
|
http.StatusOK, "")
|
||||||
|
|
||||||
|
queued := e.envelopes(p1)
|
||||||
|
if len(queued) != 1 || queued[0].KeyID != keyID(60) {
|
||||||
|
t.Fatalf("очередь petya: %+v", queued)
|
||||||
|
}
|
||||||
|
|
||||||
|
got := e.room(petya, room.ID)
|
||||||
|
if got == nil || len(got.Keys) != 2 {
|
||||||
|
t.Fatalf("ключи petya: %+v", got)
|
||||||
|
}
|
||||||
|
if got.Keys[0].KeyID != keyID(60) || got.Keys[0].CT != ctOf(60, 1) {
|
||||||
|
t.Errorf("пропущенный ключ: %+v", got.Keys[0])
|
||||||
|
}
|
||||||
|
if got.Keys[1].KeyID != keyID(80) || got.Keys[1].CT != ctOf(80, 1) {
|
||||||
|
t.Errorf("текущий ключ: %+v", got.Keys[1])
|
||||||
|
}
|
||||||
|
// Ключ конверта из очереди теперь у него есть.
|
||||||
|
if got.Keys[0].KeyID != queued[0].KeyID {
|
||||||
|
t.Errorf("ключа конверта нет среди выданных: %+v", got.Keys)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
|
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
|
||||||
// с needsRekey и их собственным ключом, владение переходит (ADR-041).
|
// с needsRekey и их собственным ключом, владение переходит (ADR-041).
|
||||||
func TestDeleteAccountLeavesRooms(t *testing.T) {
|
func TestDeleteAccountLeavesRooms(t *testing.T) {
|
||||||
@@ -1012,8 +1065,8 @@ func TestDeleteAccountLeavesRooms(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Каждому — его собственный ключ: он различается порядковым
|
// Каждому — его собственный ключ: он различается порядковым
|
||||||
// номером внутри «шифротекста».
|
// номером внутри «шифротекста».
|
||||||
if got.Key == nil || got.Key.CT != ctOf(60, i+1) {
|
if got.key() == nil || got.key().CT != ctOf(60, i+1) {
|
||||||
t.Errorf("ключ в событии: %+v", got.Key)
|
t.Errorf("ключ в событии: %+v", got.key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,7 +1095,7 @@ func TestMembersDuplicateKeyTarget(t *testing.T) {
|
|||||||
rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80)
|
rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80)
|
||||||
expect(t, rec, http.StatusBadRequest, "keys_mismatch")
|
expect(t, rec, http.StatusBadRequest, "keys_mismatch")
|
||||||
// Отказ ничего не изменил: ключ комнаты прежний.
|
// Отказ ничего не изменил: ключ комнаты прежний.
|
||||||
if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) {
|
if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(60) {
|
||||||
t.Errorf("ключ после keys_mismatch: %+v", got.Key)
|
t.Errorf("ключ после keys_mismatch: %+v", got.key())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+20
-13
@@ -49,16 +49,18 @@ type WrappedKey struct {
|
|||||||
CT string
|
CT string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Room — комната и её состав. Key — текущий ключ того, кто спрашивает;
|
// Room — комната и её состав. Keys — завёрнутые ключи того, кто
|
||||||
// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился,
|
// спрашивает, от старого к новому: сервер держит два последних keyId
|
||||||
// а нового ключа ещё не было (ADR-041).
|
// (ADR-018) и отдаёт участнику все, иначе пропущенный в офлайне ключ
|
||||||
|
// не добыть ничем (ADR-059). Пусто — ключей у него нет. NeedsRekey —
|
||||||
|
// состав уменьшился, а нового ключа ещё не было (ADR-041).
|
||||||
type Room struct {
|
type Room struct {
|
||||||
ID string
|
ID string
|
||||||
Name string
|
Name string
|
||||||
Owner string
|
Owner string
|
||||||
Members []string // по joined_at
|
Members []string // по joined_at
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
Key *RoomKey
|
Keys []RoomKey
|
||||||
NeedsRekey bool
|
NeedsRekey bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,15 +73,17 @@ type Recipient struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left.
|
// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left.
|
||||||
// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient.
|
// Room.Keys всегда пусты — ключ у каждого получателя свой, он в Recipient.
|
||||||
type RoomChange struct {
|
type RoomChange struct {
|
||||||
Room Room
|
Room Room
|
||||||
Members []Recipient // итоговый состав
|
Members []Recipient // итоговый состав
|
||||||
Left []Recipient // выбывшие
|
Left []Recipient // выбывшие
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rooms — комнаты, где пользователь участник, каждая с его текущим
|
// Rooms — комнаты, где пользователь участник, каждая со всеми его
|
||||||
// ключом (docs/protocol.md, «Комнаты»).
|
// завёрнутыми ключами: сервер держит два последних keyId, и участник,
|
||||||
|
// пропустивший rekey в офлайне, добирает пропущенный отсюда
|
||||||
|
// (ADR-059, docs/protocol.md, «Комнаты»).
|
||||||
func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
|
func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
|
||||||
rows, err := s.db.QueryContext(ctx, `
|
rows, err := s.db.QueryContext(ctx, `
|
||||||
SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey
|
SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey
|
||||||
@@ -130,22 +134,25 @@ func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) {
|
|||||||
return nil, fmt.Errorf("store: состав комнат: %w", err)
|
return nil, fmt.Errorf("store: состав комнат: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
keys, err := s.db.QueryContext(ctx, currentKeysQuery+` AND nick = ?`, nick)
|
// Порядок — от старого ключа к новому, тот же, что у обрезки
|
||||||
|
// и у «текущего» (ADR-042): последний в списке и есть текущий.
|
||||||
|
keys, err := s.db.QueryContext(ctx, `
|
||||||
|
SELECT room_id, key_id, sender, iv, ct FROM room_keys
|
||||||
|
WHERE nick = ? AND room_id IN (SELECT room_id FROM room_members WHERE nick = ?)
|
||||||
|
ORDER BY room_id, created_at, key_id`, nick, nick)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
||||||
}
|
}
|
||||||
defer keys.Close()
|
defer keys.Close()
|
||||||
|
|
||||||
for keys.Next() {
|
for keys.Next() {
|
||||||
// Второй столбец — ник владельца ключа, здесь он всегда nick.
|
var room string
|
||||||
var room, member string
|
|
||||||
var k RoomKey
|
var k RoomKey
|
||||||
if err := keys.Scan(&room, &member, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
|
if err := keys.Scan(&room, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil {
|
||||||
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
return nil, fmt.Errorf("store: ключи комнат: %w", err)
|
||||||
}
|
}
|
||||||
if i, ok := at[room]; ok {
|
if i, ok := at[room]; ok {
|
||||||
key := k
|
out[i].Keys = append(out[i].Keys, k)
|
||||||
out[i].Key = &key
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := keys.Err(); err != nil {
|
if err := keys.Err(); err != nil {
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ func TestCreateRoomTakenID(t *testing.T) {
|
|||||||
t.Fatalf("Rooms: %v", err)
|
t.Fatalf("Rooms: %v", err)
|
||||||
}
|
}
|
||||||
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
|
if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" ||
|
||||||
rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" {
|
current(rooms[0]) == nil || current(rooms[0]).KeyID != "k1" {
|
||||||
t.Errorf("комната после отказа: %+v", rooms)
|
t.Errorf("комната после отказа: %+v", rooms)
|
||||||
}
|
}
|
||||||
if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 {
|
if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 {
|
||||||
@@ -96,6 +96,15 @@ func TestCreateRoomTakenID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// current — текущий ключ участника: последний в Keys, они идут от старого
|
||||||
|
// к новому (ADR-059).
|
||||||
|
func current(r Room) *RoomKey {
|
||||||
|
if len(r.Keys) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &r.Keys[len(r.Keys)-1]
|
||||||
|
}
|
||||||
|
|
||||||
// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка
|
// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка
|
||||||
// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018).
|
// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018).
|
||||||
func TestRoomKeysTrimmedToTwo(t *testing.T) {
|
func TestRoomKeysTrimmedToTwo(t *testing.T) {
|
||||||
@@ -120,13 +129,31 @@ func TestRoomKeysTrimmedToTwo(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Rooms %s: %v", nick, err)
|
t.Fatalf("Rooms %s: %v", nick, err)
|
||||||
}
|
}
|
||||||
if len(rooms) != 1 || rooms[0].Key == nil {
|
if len(rooms) != 1 || current(rooms[0]) == nil {
|
||||||
t.Fatalf("комнаты %s: %+v", nick, rooms)
|
t.Fatalf("комнаты %s: %+v", nick, rooms)
|
||||||
}
|
}
|
||||||
if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick {
|
if current(rooms[0]).KeyID != "k3" || current(rooms[0]).CT != "ct-"+nick {
|
||||||
t.Errorf("ключ %s: %+v", nick, rooms[0].Key)
|
t.Errorf("ключ %s: %+v", nick, current(rooms[0]))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Участнику отдаются оба удерживаемых ключа, от старого к новому:
|
||||||
|
// без прежнего он не прочитает сообщение, отправленное до последнего
|
||||||
|
// rekey, пока его не было (ADR-059).
|
||||||
|
rooms, err := s.Rooms(ctx, "petya")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Rooms petya: %v", err)
|
||||||
|
}
|
||||||
|
if len(rooms) != 1 || len(rooms[0].Keys) != 2 {
|
||||||
|
t.Fatalf("ключи petya: %+v", rooms)
|
||||||
|
}
|
||||||
|
if rooms[0].Keys[0].KeyID != "k2" || rooms[0].Keys[1].KeyID != "k3" {
|
||||||
|
t.Errorf("порядок ключей: получено %v, ожидалось [k2 k3]",
|
||||||
|
[]string{rooms[0].Keys[0].KeyID, rooms[0].Keys[1].KeyID})
|
||||||
|
}
|
||||||
|
// Ключей чужой комнаты в ответе нет.
|
||||||
|
if got, err := s.Rooms(ctx, "marta"); err != nil || len(got) != 1 || len(got[0].Keys) != 2 {
|
||||||
|
t.Errorf("ключи marta: %+v, %v", got, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ,
|
// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ,
|
||||||
@@ -147,11 +174,11 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, nick := range []string{"marta", "petya"} {
|
for _, nick := range []string{"marta", "petya"} {
|
||||||
rooms, err := s.Rooms(ctx, nick)
|
rooms, err := s.Rooms(ctx, nick)
|
||||||
if err != nil || len(rooms) != 1 || rooms[0].Key == nil {
|
if err != nil || len(rooms) != 1 || current(rooms[0]) == nil {
|
||||||
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
|
t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err)
|
||||||
}
|
}
|
||||||
if rooms[0].Key.KeyID != "aaa" {
|
if current(rooms[0]).KeyID != "aaa" {
|
||||||
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID)
|
t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, current(rooms[0]).KeyID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Свежим ключом можно писать: он остался ключом комнаты.
|
// Свежим ключом можно писать: он остался ключом комнаты.
|
||||||
|
|||||||
@@ -105,11 +105,23 @@ func TestUsersAndSessions(t *testing.T) {
|
|||||||
t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err)
|
t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Смена пароля с logoutOthers: остаётся только текущая сессия.
|
// Смена пароля с logoutOthers: остаётся только текущая сессия, а
|
||||||
|
// устройства завершённых сессий отдаются обработчику — он закроет
|
||||||
|
// их потоки событий (ADR-058).
|
||||||
|
if _, err := s.RegisterDevice(ctx, "device-live", "marta", live, now); err != nil {
|
||||||
|
t.Fatalf("RegisterDevice: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.RegisterDevice(ctx, "device-other", "marta", other, now); err != nil {
|
||||||
|
t.Fatalf("RegisterDevice: %v", err)
|
||||||
|
}
|
||||||
cred := Credential{Hash: []byte("new"), Salt: []byte("salt2"), Params: "argon2id,m=19456,t=2,p=1"}
|
cred := Credential{Hash: []byte("new"), Salt: []byte("salt2"), Params: "argon2id,m=19456,t=2,p=1"}
|
||||||
if err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live); err != nil {
|
revoked, err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live)
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("SetPassword: %v", err)
|
t.Fatalf("SetPassword: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(revoked) != 1 || revoked[0] != "device-other" {
|
||||||
|
t.Errorf("устройства завершённых сессий: получено %v, ожидалось [device-other]", revoked)
|
||||||
|
}
|
||||||
if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) {
|
if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) {
|
||||||
t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err)
|
t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+43
-6
@@ -80,28 +80,65 @@ func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error
|
|||||||
// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти
|
// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти
|
||||||
// или ключ которого не расшифровать. При logoutOthers в той же транзакции
|
// или ключ которого не расшифровать. При logoutOthers в той же транзакции
|
||||||
// удаляются все сессии пользователя, кроме keep — текущей.
|
// удаляются все сессии пользователя, кроме keep — текущей.
|
||||||
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) error {
|
//
|
||||||
|
// Первое значение — устройства, к которым были привязаны удалённые сессии:
|
||||||
|
// их потоки событий закрывает обработчик. Поток проверяет сессию только
|
||||||
|
// при подключении, поэтому отозванная иначе продолжала бы получать
|
||||||
|
// конверты до обрыва соединения (ADR-058).
|
||||||
|
func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) ([]string, error) {
|
||||||
tx, err := s.db.BeginTx(ctx, nil)
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("store: смена пароля: %w", err)
|
return nil, fmt.Errorf("store: смена пароля: %w", err)
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
|
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`,
|
UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`,
|
||||||
cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil {
|
cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil {
|
||||||
return fmt.Errorf("store: смена пароля: %w", err)
|
return nil, fmt.Errorf("store: смена пароля: %w", err)
|
||||||
}
|
}
|
||||||
|
var revoked []string
|
||||||
if logoutOthers {
|
if logoutOthers {
|
||||||
|
revoked, err = revokedDevices(ctx, tx, nick, keep)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if _, err := tx.ExecContext(ctx, `
|
if _, err := tx.ExecContext(ctx, `
|
||||||
DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil {
|
DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil {
|
||||||
return fmt.Errorf("store: смена пароля: %w", err)
|
return nil, fmt.Errorf("store: смена пароля: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return fmt.Errorf("store: смена пароля: %w", err)
|
return nil, fmt.Errorf("store: смена пароля: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return revoked, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokedDevices — устройства завершаемых сессий, кроме устройства текущей:
|
||||||
|
// её оставляют, и закрывать её поток незачем.
|
||||||
|
func revokedDevices(ctx context.Context, tx *sql.Tx, nick string, keep []byte) ([]string, error) {
|
||||||
|
rows, err := tx.QueryContext(ctx, `
|
||||||
|
SELECT DISTINCT device_id FROM sessions
|
||||||
|
WHERE nick = ? AND token_hash <> ? AND device_id IS NOT NULL
|
||||||
|
AND device_id NOT IN (SELECT device_id FROM sessions WHERE token_hash = ? AND device_id IS NOT NULL)
|
||||||
|
ORDER BY device_id`, nick, keep, keep)
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство,
|
// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство,
|
||||||
|
|||||||
@@ -980,6 +980,10 @@ input[type="password"] {
|
|||||||
.chat-title,
|
.chat-title,
|
||||||
.link {
|
.link {
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
|
/* цель нажатия не меньше 44 px в обе стороны (docs/ui.md,
|
||||||
|
«Доступность»): у коротких надписей вроде «убрать» ширины
|
||||||
|
по тексту не хватает */
|
||||||
|
min-width: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* подсказку «enter — отправить» видит только десктоп: на мобильном
|
/* подсказку «enter — отправить» видит только десктоп: на мобильном
|
||||||
|
|||||||
+19
-4
@@ -7,13 +7,15 @@
|
|||||||
export const MAX_ACK = 500;
|
export const MAX_ACK = 500;
|
||||||
|
|
||||||
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
|
// ApiError — ответ сервера с кодом из перечня docs/protocol.md.
|
||||||
|
// retryAfter — сколько секунд просит ждать 429; у остальных ответов null.
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
constructor(code, message, status, field) {
|
constructor(code, message, status, field, retryAfter = null) {
|
||||||
super(message || code);
|
super(message || code);
|
||||||
this.name = "ApiError";
|
this.name = "ApiError";
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.status = status;
|
this.status = status;
|
||||||
this.field = field;
|
this.field = field;
|
||||||
|
this.retryAfter = retryAfter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +98,19 @@ async function request(method, path, body, { quiet = false, device = null } = {}
|
|||||||
if (code === "unauthenticated" && !quiet) {
|
if (code === "unauthenticated" && !quiet) {
|
||||||
expired();
|
expired();
|
||||||
}
|
}
|
||||||
throw new ApiError(code, data?.message, response.status, data?.field);
|
throw new ApiError(code, data?.message, response.status, data?.field, retryAfter(response));
|
||||||
|
}
|
||||||
|
|
||||||
|
// retryAfter — сколько сервер просит ждать: целые секунды, не меньше одной
|
||||||
|
// (docs/protocol.md, «Общие правила»). Заголовка нет или он не число —
|
||||||
|
// null: паузу выбирает клиент.
|
||||||
|
function retryAfter(response) {
|
||||||
|
const raw = response.headers.get("Retry-After");
|
||||||
|
if (raw === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const seconds = Number(raw);
|
||||||
|
return Number.isInteger(seconds) && seconds > 0 ? seconds : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function config() {
|
export function config() {
|
||||||
@@ -194,8 +208,9 @@ export function removeContact(nick) {
|
|||||||
|
|
||||||
// --- комнаты -----------------------------------------------------------
|
// --- комнаты -----------------------------------------------------------
|
||||||
|
|
||||||
// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым
|
// rooms — комнаты, где мы участники, каждая с нашими завёрнутыми ключами:
|
||||||
// ключом (docs/protocol.md, «Комнаты»).
|
// сервер отдаёт все, которые ещё держит, от старого к новому (ADR-059,
|
||||||
|
// docs/protocol.md, «Комнаты»).
|
||||||
export function rooms() {
|
export function rooms() {
|
||||||
return request("GET", "/api/rooms");
|
return request("GET", "/api/rooms");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -410,6 +410,10 @@ async function changePassword(current, next, logoutOthers) {
|
|||||||
} finally {
|
} finally {
|
||||||
wipe(secret);
|
wipe(secret);
|
||||||
}
|
}
|
||||||
|
// Вход завёл новую сессию, а сессия заводится без устройства: привязку
|
||||||
|
// делает POST /api/devices. Без неё удаление этого устройства с другого
|
||||||
|
// не завершит здешнюю сессию (docs/protocol.md, «Устройства»).
|
||||||
|
await sync.rebindDevice();
|
||||||
}
|
}
|
||||||
|
|
||||||
// deleteAccount входит заново тем же порядком, что и смена пароля: сессии
|
// deleteAccount входит заново тем же порядком, что и смена пароля: сессии
|
||||||
|
|||||||
+17
-1
@@ -35,6 +35,11 @@ const state = {
|
|||||||
// Вопрос идёт прямо сейчас: два сообщения подряд не должны дать
|
// Вопрос идёт прямо сейчас: два сообщения подряд не должны дать
|
||||||
// два запроса разрешения.
|
// два запроса разрешения.
|
||||||
asking: false,
|
asking: false,
|
||||||
|
// Браузер отказал в самой подписке: приватное окно, политика,
|
||||||
|
// недоступный push-сервис. Кнопкой это не включить, поэтому раздел
|
||||||
|
// показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке:
|
||||||
|
// перезагрузка пробует снова — причина могла уйти.
|
||||||
|
refused: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Приглашение установки ловится с первой секунды: браузер показывает его
|
// Приглашение установки ловится с первой секунды: браузер показывает его
|
||||||
@@ -91,7 +96,7 @@ function supported() {
|
|||||||
// отклонённое разрешение, браузер без уведомлений, сервер без
|
// отклонённое разрешение, браузер без уведомлений, сервер без
|
||||||
// VAPID-ключа (ADR-046).
|
// VAPID-ключа (ADR-046).
|
||||||
export async function notifications(key) {
|
export async function notifications(key) {
|
||||||
if (!supported() || !key || Notification.permission === "denied") {
|
if (!supported() || !key || Notification.permission === "denied" || state.refused) {
|
||||||
return "denied";
|
return "denied";
|
||||||
}
|
}
|
||||||
if (await turnedOff()) {
|
if (await turnedOff()) {
|
||||||
@@ -288,6 +293,11 @@ async function current() {
|
|||||||
// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён
|
// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён
|
||||||
// в подписку: сменился ключ — прежняя подписка не годится, push-сервис
|
// в подписку: сменился ключ — прежняя подписка не годится, push-сервис
|
||||||
// подпишет заново.
|
// подпишет заново.
|
||||||
|
//
|
||||||
|
// Отказ самой подписки — не сбой сервера, а браузер, который её не даёт:
|
||||||
|
// приватное окно, политика, недоступный push-сервис. Кнопкой это
|
||||||
|
// не включить, поэтому false, а раздел настроек скажет «запрещены
|
||||||
|
// в браузере» и уберёт кнопку (ADR-046).
|
||||||
async function attach(key) {
|
async function attach(key) {
|
||||||
const registration = await ready();
|
const registration = await ready();
|
||||||
if (registration === null || !registration.pushManager) {
|
if (registration === null || !registration.pushManager) {
|
||||||
@@ -303,11 +313,17 @@ async function attach(key) {
|
|||||||
subscription = null;
|
subscription = null;
|
||||||
}
|
}
|
||||||
if (subscription === null) {
|
if (subscription === null) {
|
||||||
|
try {
|
||||||
subscription = await registration.pushManager.subscribe({
|
subscription = await registration.pushManager.subscribe({
|
||||||
userVisibleOnly: true,
|
userVisibleOnly: true,
|
||||||
applicationServerKey: unb64url(key),
|
applicationServerKey: unb64url(key),
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
state.refused = true;
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
state.refused = false;
|
||||||
await put(subscription);
|
await put(subscription);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
+148
-19
@@ -3,10 +3,10 @@
|
|||||||
// они не ходят — пишет в базу только этот модуль.
|
// они не ходят — пишет в базу только этот модуль.
|
||||||
//
|
//
|
||||||
// Правила — docs/protocol.md («События», «Сообщения», «Комнаты»)
|
// Правила — docs/protocol.md («События», «Сообщения», «Комнаты»)
|
||||||
// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB,
|
// и docs/storage.md: ACK уходит пачкой и только после успешной записи
|
||||||
// исходящее живёт в pending до 202 и держится за свой ULID, пока время
|
// в IndexedDB (ADR-063), исходящее живёт в pending до 202 и держится
|
||||||
// в нём годится серверу; отвергнутый по часам переиспользованный id
|
// за свой ULID, пока время в нём годится серверу; отвергнутый по часам
|
||||||
// меняется на свежий один раз (ADR-036).
|
// переиспользованный id меняется на свежий один раз (ADR-036).
|
||||||
//
|
//
|
||||||
// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший
|
// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший
|
||||||
// от сервера, сверяется с запомненным; изменившийся ложится в pending
|
// от сервера, сверяется с запомненным; изменившийся ложится в pending
|
||||||
@@ -39,6 +39,11 @@ import { ulid, ulidTime, validUlid } from "./ulid.js";
|
|||||||
const RETRY_MIN = 1000;
|
const RETRY_MIN = 1000;
|
||||||
const RETRY_MAX = 30000;
|
const RETRY_MAX = 30000;
|
||||||
|
|
||||||
|
// Пауза перед отправкой подтверждений: идентификаторы копятся и уходят
|
||||||
|
// одним POST /api/ack, не чаще раза в две секунды (ADR-063). Подтверждение
|
||||||
|
// — учёт очереди сервера, а не доставка человеку: сообщение уже на экране.
|
||||||
|
const ACK_DELAY = 2000;
|
||||||
|
|
||||||
// Владение потоком одно на браузерный профиль: устройство у вкладок общее,
|
// Владение потоком одно на браузерный профиль: устройство у вкладок общее,
|
||||||
// а соединение на устройство сервер держит одно (ADR-035).
|
// а соединение на устройство сервер держит одно (ADR-035).
|
||||||
const STREAM_LOCK = "bare-stream";
|
const STREAM_LOCK = "bare-stream";
|
||||||
@@ -88,6 +93,10 @@ const state = {
|
|||||||
// Отложенный разбор конвертов, которые сейчас не разобрать.
|
// Отложенный разбор конвертов, которые сейчас не разобрать.
|
||||||
inboxTimer: null,
|
inboxTimer: null,
|
||||||
hold: RETRY_MIN,
|
hold: RETRY_MIN,
|
||||||
|
// Записанное в базу и ещё не подтверждённое серверу. Множество:
|
||||||
|
// конверт, выданный очередью повторно, подтверждается один раз (ADR-063).
|
||||||
|
acks: new Set(),
|
||||||
|
ackTimer: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- события для экранов -----------------------------------------------
|
// --- события для экранов -----------------------------------------------
|
||||||
@@ -320,6 +329,10 @@ export function stop() {
|
|||||||
clearTimeout(state.inboxTimer);
|
clearTimeout(state.inboxTimer);
|
||||||
state.inboxTimer = null;
|
state.inboxTimer = null;
|
||||||
}
|
}
|
||||||
|
if (state.ackTimer !== null) {
|
||||||
|
clearTimeout(state.ackTimer);
|
||||||
|
state.ackTimer = null;
|
||||||
|
}
|
||||||
if (state.close) {
|
if (state.close) {
|
||||||
state.close();
|
state.close();
|
||||||
state.close = null;
|
state.close = null;
|
||||||
@@ -338,6 +351,9 @@ export function stop() {
|
|||||||
state.owed.clear();
|
state.owed.clear();
|
||||||
state.pending.clear();
|
state.pending.clear();
|
||||||
state.inbox.length = 0;
|
state.inbox.length = 0;
|
||||||
|
// Неподтверждённое не досылается: сервер выдаст эти конверты очередью
|
||||||
|
// при следующем подключении (ADR-063).
|
||||||
|
state.acks.clear();
|
||||||
state.wait = RETRY_MIN;
|
state.wait = RETRY_MIN;
|
||||||
state.hold = RETRY_MIN;
|
state.hold = RETRY_MIN;
|
||||||
setOnline(false);
|
setOnline(false);
|
||||||
@@ -393,6 +409,25 @@ async function ensureDevice() {
|
|||||||
throw new Error("не удалось завести устройство");
|
throw new Error("не удалось завести устройство");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rebindDevice привязывает сессию к устройству заново. Сессия заводится
|
||||||
|
// без устройства (docs/storage.md, sessions.device_id), а привязывает её
|
||||||
|
// POST /api/devices. Смена пароля входит заново (ADR-031) — без этого
|
||||||
|
// новая сессия остаётся ничьей: «это устройство» в настройках не сходится,
|
||||||
|
// а DELETE /api/devices/{id} такую сессию не завершает, хотя обещает
|
||||||
|
// (docs/protocol.md, «Устройства»).
|
||||||
|
//
|
||||||
|
// Отказ ничего не ломает: привязку чинит ближайшее переподключение.
|
||||||
|
export async function rebindDevice() {
|
||||||
|
if (!state.running || state.device === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.registerDevice(state.device);
|
||||||
|
} catch {
|
||||||
|
// Починится при следующем connect.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- поток событий ------------------------------------------------------
|
// --- поток событий ------------------------------------------------------
|
||||||
|
|
||||||
async function connect() {
|
async function connect() {
|
||||||
@@ -408,6 +443,15 @@ async function connect() {
|
|||||||
// Запрос не дошёл — это и есть «нет соединения» (ADR-028).
|
// Запрос не дошёл — это и есть «нет соединения» (ADR-028).
|
||||||
setOnline(false);
|
setOnline(false);
|
||||||
}
|
}
|
||||||
|
if (rateLimited(err)) {
|
||||||
|
// Ведро изменяющих запросов общее на пользователя (ADR-055):
|
||||||
|
// устройство могло не завестись из-за соседнего устройства или
|
||||||
|
// прежней работы этой же вкладки. Это задержка, а не отказ —
|
||||||
|
// без устройства нет ни потока, ни отправки, и сама вкладка
|
||||||
|
// не ожила бы до перезагрузки.
|
||||||
|
retryLater(pause(err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (transient(err)) {
|
if (transient(err)) {
|
||||||
retryLater();
|
retryLater();
|
||||||
}
|
}
|
||||||
@@ -452,6 +496,13 @@ function claimStream() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// owner — держит ли эта вкладка поток событий. Без BroadcastChannel или
|
||||||
|
// navigator.locks арбитража нет вовсе, и вкладка работает как единственная
|
||||||
|
// (ADR-035), поэтому владельцем считается и она.
|
||||||
|
function owner() {
|
||||||
|
return !shared || state.release !== null;
|
||||||
|
}
|
||||||
|
|
||||||
// yieldStream отпускает владение: соседняя вкладка займёт поток сразу.
|
// yieldStream отпускает владение: соседняя вкладка займёт поток сразу.
|
||||||
function yieldStream() {
|
function yieldStream() {
|
||||||
if (state.claim) {
|
if (state.claim) {
|
||||||
@@ -502,12 +553,20 @@ function openStream() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryLater() {
|
// retryLater откладывает восстановление. Без аргумента пауза своя
|
||||||
|
// и удваивается; after — пауза, которую назвал сервер (Retry-After),
|
||||||
|
// и очередь удвоений она не двигает: отказ по частоте не значит, что
|
||||||
|
// поток нездоров. Дольше RETRY_MAX не ждём и в этом случае.
|
||||||
|
function retryLater(after = null) {
|
||||||
if (state.timer !== null || !state.running) {
|
if (state.timer !== null || !state.running) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const delay = state.wait;
|
let delay = state.wait;
|
||||||
|
if (after === null) {
|
||||||
state.wait = Math.min(delay * 2, RETRY_MAX);
|
state.wait = Math.min(delay * 2, RETRY_MAX);
|
||||||
|
} else {
|
||||||
|
delay = Math.min(after, RETRY_MAX);
|
||||||
|
}
|
||||||
state.timer = setTimeout(() => {
|
state.timer = setTimeout(() => {
|
||||||
state.timer = null;
|
state.timer = null;
|
||||||
recover();
|
recover();
|
||||||
@@ -625,7 +684,7 @@ async function flush() {
|
|||||||
state.hold = RETRY_MIN;
|
state.hold = RETRY_MIN;
|
||||||
}
|
}
|
||||||
// ACK — только после успешной записи (docs/storage.md).
|
// ACK — только после успешной записи (docs/storage.md).
|
||||||
await ackAll(acked);
|
ackLater(acked);
|
||||||
}
|
}
|
||||||
|
|
||||||
// postpone откладывает повторный разбор: причина, по которой конверт не
|
// postpone откладывает повторный разбор: причина, по которой конверт не
|
||||||
@@ -752,7 +811,32 @@ async function reopen() {
|
|||||||
notify(messages);
|
notify(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ackAll(ids) {
|
// ackLater копит подтверждения и отправляет их пачкой. По запросу
|
||||||
|
// на конверт получатель оживлённой комнаты тратил общее ведро одними
|
||||||
|
// подтверждениями и упирался в 429 на всём изменяющем — включая выход
|
||||||
|
// из комнаты и смену пароля (ADR-063). Правило docs/storage.md остаётся
|
||||||
|
// дословным: сюда попадает только то, что уже записано в IndexedDB.
|
||||||
|
function ackLater(ids) {
|
||||||
|
for (const id of ids) {
|
||||||
|
state.acks.add(id);
|
||||||
|
}
|
||||||
|
if (state.acks.size === 0 || state.ackTimer !== null || !state.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.ackTimer = setTimeout(() => {
|
||||||
|
state.ackTimer = null;
|
||||||
|
ackNow();
|
||||||
|
}, ACK_DELAY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ackNow отдаёт накопленное. Больше MAX_ACK за раз сервер не принимает,
|
||||||
|
// поэтому длинная пачка идёт кусками.
|
||||||
|
async function ackNow() {
|
||||||
|
const ids = [...state.acks];
|
||||||
|
state.acks.clear();
|
||||||
|
if (ids.length === 0 || !state.running) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
for (let i = 0; i < ids.length; i += api.MAX_ACK) {
|
for (let i = 0; i < ids.length; i += api.MAX_ACK) {
|
||||||
try {
|
try {
|
||||||
await api.ack(state.device, ids.slice(i, i + api.MAX_ACK));
|
await api.ack(state.device, ids.slice(i, i + api.MAX_ACK));
|
||||||
@@ -800,6 +884,18 @@ async function refreshContacts() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resumePending повторяет неотправленное там, где это законно: повтор
|
||||||
|
// принадлежит владельцу потока, иначе одно сообщение ушло бы дважды,
|
||||||
|
// с разными ULID (ADR-035). Невладеющая вкладка отдаёт свой список
|
||||||
|
// владельцу — он повторит его после ближайшего ready.
|
||||||
|
async function resumePending() {
|
||||||
|
if (owner()) {
|
||||||
|
await retryPending();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
share({ kind: "pending", ids: [...state.pending] });
|
||||||
|
}
|
||||||
|
|
||||||
// retryPending повторяет неотправленное после подключения. Идёт прямо,
|
// retryPending повторяет неотправленное после подключения. Идёт прямо,
|
||||||
// без serial: afterReady уже внутри очереди.
|
// без serial: afterReady уже внутри очереди.
|
||||||
async function retryPending() {
|
async function retryPending() {
|
||||||
@@ -912,7 +1008,9 @@ export function trustKey(nick) {
|
|||||||
// Владелец, чей rekey упирался в этот ключ, доводит его до конца.
|
// Владелец, чей rekey упирался в этот ключ, доводит его до конца.
|
||||||
await payRekeys();
|
await payRekeys();
|
||||||
await reopen();
|
await reopen();
|
||||||
await retryPending();
|
// «Доверять новому ключу» нажимают в любой вкладке, а повторяет
|
||||||
|
// неотправленное владелец потока (ADR-035).
|
||||||
|
await resumePending();
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -945,7 +1043,7 @@ function usableRoom(r) {
|
|||||||
&& typeof r.owner === "string"
|
&& typeof r.owner === "string"
|
||||||
&& Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string")
|
&& Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string")
|
||||||
&& Number.isFinite(r.createdAt)
|
&& Number.isFinite(r.createdAt)
|
||||||
&& (r.key === null || r.key === undefined || usableKey(r.key));
|
&& Array.isArray(r.keys) && r.keys.every(usableKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
function usableKey(k) {
|
function usableKey(k) {
|
||||||
@@ -1006,10 +1104,15 @@ async function senderKey(nick) {
|
|||||||
return record.pending ? null : record.publicKey;
|
return record.pending ? null : record.publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его
|
// takeRoomKeys разворачивает завёрнутые нам ключи комнаты и кладёт их
|
||||||
// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»).
|
// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»).
|
||||||
// Отдаёт, появился ли новый ключ.
|
// Отдаёт, появился ли новый ключ.
|
||||||
//
|
//
|
||||||
|
// Ключей бывает несколько: `GET /api/rooms` отдаёт все, которые сервер
|
||||||
|
// ещё держит, — участник, пропустивший rekey в офлайне, добирает отсюда
|
||||||
|
// недостающий keyId и читает конверт, пришедший с ним (ADR-059).
|
||||||
|
// Событие room несёт один ключ, новый.
|
||||||
|
//
|
||||||
// Уже известный keyId не трогается: клиент держит все ключи комнаты.
|
// Уже известный keyId не трогается: клиент держит все ключи комнаты.
|
||||||
// Не развернувшийся не теряется — сервер отдаёт его снова с каждым
|
// Не развернувшийся не теряется — сервер отдаёт его снова с каждым
|
||||||
// GET /api/rooms.
|
// GET /api/rooms.
|
||||||
@@ -1019,9 +1122,20 @@ async function senderKey(nick) {
|
|||||||
// до запроса его публичного ключа: TOFU запоминает первый ключ молча,
|
// до запроса его публичного ключа: TOFU запоминает первый ключ молча,
|
||||||
// поэтому незнакомый распространитель — это подмена, а не первый
|
// поэтому незнакомый распространитель — это подмена, а не первый
|
||||||
// контакт (ADR-039).
|
// контакт (ADR-039).
|
||||||
async function takeRoomKey(room) {
|
async function takeRoomKeys(room) {
|
||||||
const wrapped = room.key;
|
let fresh = false;
|
||||||
if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) {
|
// Порядок — от старого ключа к новому: текущим у нас становится
|
||||||
|
// последний сохранённый (ADR-042).
|
||||||
|
for (const wrapped of room.keys) {
|
||||||
|
if (await takeRoomKey(room, wrapped)) {
|
||||||
|
fresh = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fresh;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function takeRoomKey(room, wrapped) {
|
||||||
|
if (!room.members.includes(wrapped.from)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (await db.roomKey(room.id, wrapped.keyId)) {
|
if (await db.roomKey(room.id, wrapped.keyId)) {
|
||||||
@@ -1069,7 +1183,7 @@ async function applyRoom(room) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const changed = await saveRoom(room);
|
const changed = await saveRoom(room);
|
||||||
const fresh = await takeRoomKey(room);
|
const fresh = await takeRoomKeys(room);
|
||||||
if (changed) {
|
if (changed) {
|
||||||
announceChats();
|
announceChats();
|
||||||
}
|
}
|
||||||
@@ -1115,7 +1229,7 @@ async function refreshRooms() {
|
|||||||
}
|
}
|
||||||
seen.add(room.id);
|
seen.add(room.id);
|
||||||
const moved = await saveRoom(room);
|
const moved = await saveRoom(room);
|
||||||
const key = await takeRoomKey(room);
|
const key = await takeRoomKeys(room);
|
||||||
changed = changed || moved;
|
changed = changed || moved;
|
||||||
fresh = fresh || key;
|
fresh = fresh || key;
|
||||||
if (moved || key) {
|
if (moved || key) {
|
||||||
@@ -1335,7 +1449,9 @@ async function changeRoom(roomId, add, remove) {
|
|||||||
announceRoom(roomId);
|
announceRoom(roomId);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
await reopen();
|
await reopen();
|
||||||
await retryPending();
|
// Состав меняют из любой вкладки; повтор неотправленного — дело
|
||||||
|
// владельца потока (ADR-035).
|
||||||
|
await resumePending();
|
||||||
}
|
}
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
@@ -1567,8 +1683,9 @@ async function post(message, peer, roomId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся
|
// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся
|
||||||
// pending и повторится при следующем подключении (ADR-027). Удалённое
|
// pending и повторится при следующем подключении (ADR-027). Потерянное
|
||||||
// устройство чинится тем же способом — переподключением. Остальные 4xx —
|
// устройство — то же самое: это не отказ сообщению, и чинится он
|
||||||
|
// переподключением, а не текстом в ленте (ADR-060). Остальные 4xx —
|
||||||
// failed с текстом отказа (ADR-033).
|
// failed с текстом отказа (ADR-033).
|
||||||
async function settle(message, err) {
|
async function settle(message, err) {
|
||||||
if (err instanceof NetworkError) {
|
if (err instanceof NetworkError) {
|
||||||
@@ -1600,6 +1717,18 @@ function transient(err) {
|
|||||||
|| (err instanceof ApiError && err.status >= 500);
|
|| (err instanceof ApiError && err.status >= 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rateLimited — 429: сервер ответил и просит подождать (ADR-055).
|
||||||
|
// Сообщению это отказ — «слишком часто, попробуйте позже» и failed
|
||||||
|
// (docs/storage.md), а устройству и потоку — всего лишь задержка.
|
||||||
|
function rateLimited(err) {
|
||||||
|
return err instanceof ApiError && err.code === "rate_limited";
|
||||||
|
}
|
||||||
|
|
||||||
|
// pause — пауза из Retry-After в миллисекундах; заголовка не было — null.
|
||||||
|
function pause(err) {
|
||||||
|
return typeof err.retryAfter === "number" ? err.retryAfter * 1000 : null;
|
||||||
|
}
|
||||||
|
|
||||||
// --- действия экранов ---------------------------------------------------
|
// --- действия экранов ---------------------------------------------------
|
||||||
|
|
||||||
// openDm заводит личный чат с ником и отдаёт chatId. Строку списка
|
// openDm заводит личный чат с ником и отдаёт chatId. Строку списка
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
// Имя кэша содержит версию; версия — константа, она меняется при релизе,
|
||||||
// и старые кэши уходят в activate.
|
// и старые кэши уходят в activate.
|
||||||
|
|
||||||
const VERSION = "v3";
|
const VERSION = "v4";
|
||||||
const CACHE = `bare-${VERSION}`;
|
const CACHE = `bare-${VERSION}`;
|
||||||
|
|
||||||
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
// Оболочка — всё, из чего клиент поднимается без сети. Список явный:
|
||||||
|
|||||||
Reference in New Issue
Block a user