From ae55c846aafabd1e456f515fbb35aa5a94753742 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 10:53:24 +0300 Subject: [PATCH 1/8] =?UTF-8?q?ADR-015=E2=80=A6024=20=D0=B8=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D1=86=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?v1:=20=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D1=84=D0=B8=D1=8F,=20=D0=BF=D1=80=D0=BE=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=D0=BE=D0=BB,=20=D1=85=D1=80=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5,=20UI,=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9,=20=D0=BF?= =?UTF-8?q?=D0=BB=D0=B0=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Закрыты все открытые вопросы проектирования. Пароль не покидает клиент (два ключа из мастера), TOFU для публичных ключей, устройства и конверт, атомарный rekey комнат, регистрация и контакты, схема SQLite и драйвер без cgo, сессии и CSRF, деплой через nginx+systemd, правила пушей, айдентика «Скобы» на системном mono. Иконки PWA в web/icons. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- .gitignore | 3 + CLAUDE.md | 10 +- README.md | 5 +- docs/architecture.md | 28 +++- docs/crypto.md | 115 ++++++++++++++ docs/decisions/002-go-server.md | 2 + docs/decisions/005-accounts.md | 2 + docs/decisions/006-e2ee-webcrypto.md | 2 + docs/decisions/007-room-keys.md | 2 + docs/decisions/008-server-relay.md | 2 + docs/decisions/011-web-push.md | 2 + .../015-password-never-leaves-client.md | 21 +++ docs/decisions/016-key-trust-tofu.md | 20 +++ docs/decisions/017-devices-and-envelope.md | 26 ++++ docs/decisions/018-rooms-membership-rekey.md | 22 +++ .../019-registration-and-contacts.md | 19 +++ .../020-storage-schema-and-driver.md | 20 +++ docs/decisions/021-sessions-csrf-limits.md | 30 ++++ docs/decisions/022-deploy-nginx-systemd.md | 20 +++ docs/decisions/023-push-and-service-worker.md | 20 +++ docs/decisions/024-identity-and-ui.md | 21 +++ docs/deploy.md | 135 +++++++++++++++++ docs/identity/brief.md | 50 +++++++ docs/identity/mark.svg | 1 + docs/identity/screens.html | 140 ++++++++++++++++++ docs/open-questions.md | 11 +- docs/plan.md | 110 ++++++++++++++ docs/protocol.md | 135 +++++++++++++++++ docs/storage.md | 138 +++++++++++++++++ docs/threat-model.md | 16 +- docs/ui.md | 77 ++++++++++ web/icons/icon-180.png | Bin 0 -> 1724 bytes web/icons/icon-192.png | Bin 0 -> 1796 bytes web/icons/icon-512.png | Bin 0 -> 6485 bytes web/icons/icon.svg | 1 + web/icons/mark.svg | 1 + 36 files changed, 1187 insertions(+), 20 deletions(-) create mode 100644 docs/crypto.md create mode 100644 docs/decisions/015-password-never-leaves-client.md create mode 100644 docs/decisions/016-key-trust-tofu.md create mode 100644 docs/decisions/017-devices-and-envelope.md create mode 100644 docs/decisions/018-rooms-membership-rekey.md create mode 100644 docs/decisions/019-registration-and-contacts.md create mode 100644 docs/decisions/020-storage-schema-and-driver.md create mode 100644 docs/decisions/021-sessions-csrf-limits.md create mode 100644 docs/decisions/022-deploy-nginx-systemd.md create mode 100644 docs/decisions/023-push-and-service-worker.md create mode 100644 docs/decisions/024-identity-and-ui.md create mode 100644 docs/deploy.md create mode 100644 docs/identity/brief.md create mode 100644 docs/identity/mark.svg create mode 100644 docs/identity/screens.html create mode 100644 docs/plan.md create mode 100644 docs/protocol.md create mode 100644 docs/storage.md create mode 100644 docs/ui.md create mode 100644 web/icons/icon-180.png create mode 100644 web/icons/icon-192.png create mode 100644 web/icons/icon-512.png create mode 100644 web/icons/icon.svg create mode 100644 web/icons/mark.svg diff --git a/.gitignore b/.gitignore index da68a24..a221960 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,6 @@ # локальная база *.db *.db-* + +# локальные секреты деплоя +/env diff --git a/CLAUDE.md b/CLAUDE.md index b6abd98..df28075 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,8 +5,14 @@ ## Жёсткие ограничения - Клиент: HTML, CSS, vanilla JS, нативные браузерные API. Никаких фреймворков, npm-зависимостей и сборки — ES-модули как есть. -- Сервер: Go, стандартная библиотека. Допущены ровно три внешних пакета: webpush-go, драйвер SQLite, argon2. Ничего сверх — без обсуждения. -- Криптография на клиенте: только WebCrypto. +- Сервер: Go, стандартная библиотека. Допущены ровно три прямые зависимости: `github.com/SherClockHolmes/webpush-go`, `modernc.org/sqlite`, `golang.org/x/crypto` (argon2). Транзитивные — допускаются. Ничего сверх — без обсуждения. +- Криптография на клиенте: только WebCrypto, строго по `docs/crypto.md` — константы и порядок операций не менять. +- Клиент без inline-стилей, inline-скриптов и `innerHTML`: CSP `default-src 'self'` без исключений. +- Деплой: `ssh xmatic`, `bare.xmatic.team`, `127.0.0.1:8411`, по `docs/deploy.md`. + +## Спецификации + +`docs/crypto.md`, `docs/protocol.md`, `docs/storage.md`, `docs/ui.md` — обязательны к исполнению наравне с ADR. Порядок работ — `docs/plan.md`. Расхождение кода и документа чинится через ADR, не молча. ## Процесс diff --git a/README.md b/README.md index 08deb9f..2c75743 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,14 @@ Bare — маленький независимый инструмент, а не - [Архитектура](docs/architecture.md) — обзор системы - [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет - [Решения](docs/decisions/) — ADR по ключевым решениям +- [Криптография](docs/crypto.md), [протокол](docs/protocol.md), [хранение](docs/storage.md) — спецификации для кода +- [Интерфейс](docs/ui.md) и [айдентика](docs/identity/brief.md) +- [Деплой](docs/deploy.md) и [план реализации](docs/plan.md) - [Открытые вопросы](docs/open-questions.md) ## Статус -Проектирование. Кода ещё нет — сначала документы. +Спецификации завершены, код — по `docs/plan.md`, этап 0. Иконки PWA уже в `web/icons/`. ## Лицензия diff --git a/docs/architecture.md b/docs/architecture.md index 0b068a8..9f6d7e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,7 +12,9 @@ Bare — это PWA-клиент на ванильных веб-технолог Регистрация — ник и пароль. Ник уникален и является идентификатором пользователя. Без email, телефона, OAuth и интеграций. Восстановления пароля нет. -Серверная аутентификация: Argon2id, сессия в httpOnly cookie. +Пароль не покидает клиент. Из него выводится мастер-ключ, из мастера — два независимых ключа: `authKey` для входа и `kek` для ключевого блоба. Сервер хранит Argon2id от `authKey`; сессия — в httpOnly cookie с `SameSite=Strict`, плюс проверка `Origin`. Смена пароля и повышение итераций KDF — одна операция, есть в v1. Удаление аккаунта есть. + +Ники — `[a-z0-9_]{2,32}`. Регистрация открыта; оператор может включить общий инвайт-код. Чат 1:1 начинается с ввода ника, согласия не требуется. Блокировок в v1 нет. Пароль — не короче 12 символов; правил про регистры и спецсимволы нет: длина важнее состава. UI рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет. @@ -22,15 +24,19 @@ Bare — это PWA-клиент на ванильных веб-технолог Идентичность пользователя — ECDH-пара (P-256). Приватный ключ шифруется ключом, выведенным из пароля (PBKDF2-HMAC-SHA256, не менее 600 000 итераций, целевое значение — 1 000 000), и хранится на сервере как блоб — сервер видит только шифротекст. Параметры KDF лежат рядом с блобом и читаются клиентом при входе: их можно повышать без миграции всех аккаунтов разом. Рядом с приватным ключом в блобе живёт случайный 32-байтовый секрет аккаунта — из него выводятся ключи экспорта истории. Отсюда два следствия. Сброс пароля невозможен by design. Мультидевайс-вход прост: новый девайс вводит пароль, скачивает блоб, расшифровывает ключ. -Чаты 1:1: ECDH shared secret → AES-GCM. +Публичные ключи раздаёт сервер, доверие — TOFU: клиент запоминает ключ при первом контакте, смена ключа блокирует отправку до явного подтверждения по отпечатку. Подписей сообщений нет; отправителя проставляет сервер из сессии. -Комнаты: у комнаты симметричный ключ, он раздаётся участникам зашифрованным на их публичные ключи. При изменении состава — rekey. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников. +Чаты 1:1: ECDH shared secret → HKDF → AES-GCM. + +Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст), чтобы новое устройство участника получило текущий ключ. Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников. + +Все процедуры побайтно — `docs/crypto.md`. Forward secrecy — осознанный non-goal v1. ## Хранение -Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов, транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. +Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов (включая завёрнутые ключи комнат), транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: устройство — случайный идентификатор, который клиент создаёт при первом входе; доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. Схема — `docs/storage.md`, протокол — `docs/protocol.md`. Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM. @@ -44,6 +50,10 @@ Forward secrecy — осознанный non-goal v1. Это единственный механизм переноса истории между устройствами. Осознанно. +## Транспорт + +Конверт сообщения: открытые `id` (ULID клиента, расхождение с часами сервера не больше 5 минут), адресат, отправитель (ставит сервер), `keyId`, `iv`, `ct`, серверное время. Открытые поля привязаны к шифротексту через AAD. Приём — SSE с воспроизведением очереди при каждом подключении и ACK после записи в IndexedDB; отправка — `fetch POST`. + ## Пуши Web Push + VAPID. Одна пара ключей, никаких регистраций и оплат у вендоров, никакого Firebase SDK. @@ -52,7 +62,15 @@ Web Push + VAPID. Одна пара ключей, никаких регистр iOS: пуши работают только у PWA, установленного на экран «Домой», поэтому онбординг-баннер установки — обязательная часть продукта. Разрешение на уведомления запрашивается после осмысленного действия (первое отправленное сообщение), не при входе. -Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки. +Подписка принадлежит устройству. Пуш уходит, только если устройство не подключено по SSE и у него нет неотработанного пуша: одно молчащее устройство — один пуш. Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки. + +## Развёртывание + +TLS терминирует nginx на том же сервере, Bare слушает `127.0.0.1:8411` под systemd. Статика встроена в бинарь. Сборка — кросс-компиляция без cgo. Подробности — `docs/deploy.md`. + +## Интерфейс + +Айдентика «Скобы», системный моноширинный шрифт, одна светлая тема, русский язык без i18n. Экраны — `docs/ui.md`. ## Scope v1 diff --git a/docs/crypto.md b/docs/crypto.md new file mode 100644 index 0000000..0563374 --- /dev/null +++ b/docs/crypto.md @@ -0,0 +1,115 @@ +# Криптография + +Все операции — WebCrypto (`crypto.subtle`), все случайные байты — `crypto.getRandomValues`. Кодировка бинарных полей в JSON — base64url без паддинга. Строки в UTF-8, пароль нормализуется в NFC. Названия констант — буквальные строки, они входят в вывод ключей и менять их нельзя. + +## Аккаунт + +### Мастер-ключ и два ключа из него + +``` +salt = SHA-256(utf8("bare-v1:" + nick)) // 32 байта +master = PBKDF2-HMAC-SHA256(utf8(NFC(password)), salt, iter, 256 бит) +authKey = HKDF-SHA256(master, salt = пусто, info = "bare-auth-v1", 32 байта) → base64url +kek = HKDF-SHA256(master, salt = пусто, info = "bare-kek-v1") → AES-GCM-256 +``` + +`iter` — из `GET /api/kdf?nick=` перед входом, из `GET /api/config` при регистрации. Целевое значение сервера — 1 000 000, нижняя граница — 600 000 (ADR-013). WebCrypto: `deriveBits` из PBKDF2, результат импортируется `importKey("raw", …, "HKDF")`, дальше `deriveBits`/`deriveKey`. + +`authKey` — единственное, что уходит на сервер. Пароль и `master` не покидают память клиента и не пишутся в IndexedDB. + +### Ключевая пара и секрет аккаунта + +- Пара — `generateKey({name: "ECDH", namedCurve: "P-256"}, extractable: true, ["deriveBits"])`. Экспорт: публичный — JWK, приватный — JWK (только для упаковки в блоб). +- Секрет аккаунта — 32 случайных байта. +- Отпечаток — `SHA-256(exportKey("raw", publicKey))`, 65-байтовая несжатая точка. Показывается как 64 hex-символа группами по 4, нижний регистр. + +### Ключевой блоб + +``` +plain = JSON {"priv": , "secret": } +iv = 12 случайных байт +ct = AES-GCM(kek, iv, utf8(plain), AAD = utf8("bare-blob-v1|" + nick)) +blob = JSON {"v": 1, "iter": iter, "iv": iv, "ct": ct} +``` + +Сервер хранит `blob` как непрозрачную строку. При входе клиент читает `iter` из блоба, а не из ответа `/api/kdf`: расхождение означает несогласованность данных и показывается как ошибка. + +### Хранение на устройстве + +После расшифровки блоба: + +- приватный ключ — `importKey("jwk", priv, ECDH P-256, extractable: false, ["deriveBits"])`, объект `CryptoKey` кладётся в IndexedDB `meta.privateKey`; +- секрет — `importKey("raw", secret, "HKDF", extractable: false, ["deriveKey", "deriveBits"])` → `meta.accountSecret`; +- публичный ключ — JWK → `meta.publicKey`; отпечаток → `meta.fingerprint`. + +Сырые байты приватного ключа и секрета живут в памяти только во время входа, регистрации и смены пароля. + +### Повышение итераций и смена пароля + +Одна процедура. Вход: старый `authKey` уже вычислен. Клиент выводит `master'` с новым паролем или новым `iter`, получает `authKey'` и `kek'`, собирает новый `blob` из сырых байт (они есть: при входе — только что расшифрованы; при смене пароля — блоб скачивается и расшифровывается старым `kek` заново). Отправляет `POST /api/password {authKey, newAuthKey, blob, logoutOthers}`. + +Автоматическое повышение происходит, когда `blob.iter < config.kdfIterations`, сразу после входа, с `logoutOthers: false`. + +## Чат 1:1 + +``` +shared = ECDH.deriveBits(myPrivate, peerPublic, 256) +dmKey = HKDF-SHA256(shared, salt = utf8("bare-dm-v1"), info = utf8(a + "\0" + b)) → AES-GCM-256 +``` + +`a`, `b` — ники пары по возрастанию. Ключ симметричен для обеих сторон и всех их устройств. Кэшируется в памяти, в IndexedDB не пишется — выводится заново из `peers`. + +## Комната + +### Ключ + +`roomKey` — 32 случайных байта, `keyId` — 16 случайных байт base64url. Распространитель держит сырые байты только до конца заворачивания, потом импортирует себе non-extractable AES-GCM-256. + +### Заворачивание участнику + +``` +shared = ECDH.deriveBits(distributorPrivate, memberPublic, 256) +wrapK = HKDF-SHA256(shared, salt = utf8("bare-wrap-v1"), info = utf8("bare-roomkey-v1|" + roomId + "|" + keyId)) → AES-GCM-256 +iv = 12 случайных байт +ct = AES-GCM(wrapK, iv, roomKey, AAD = utf8("bare-roomkey-v1|" + roomId + "|" + keyId + "|" + from + "|" + to)) +``` + +Запись `{to, iv, ct}` уходит на сервер в `keys[]`. Участник разворачивает той же схемой со своим приватным и публичным ключом `from`, импортирует `roomKey` как non-extractable AES-GCM-256 и хранит в IndexedDB `roomKeys[roomId, keyId]`. + +Публичный ключ `from` проходит через TOFU как любой другой. Заворачивание самому себе — `ECDH(myPrivate, myPublic)`, без исключений в коде. + +## Сообщение + +``` +chat = "dm:" + a + ":" + b | "room:" + roomId +aad = utf8("bare-msg-v1|" + id + "|" + chat + "|" + from + "|" + keyId) +plain = JSON {"t": text} +iv = 12 случайных байт +ct = AES-GCM(key, iv, utf8(plain), aad) +``` + +`key` — `dmKey` при `keyId = "dm"`, иначе `roomKeys[roomId, keyId]`. `from` — собственный ник отправителя; сервер проставляет то же значение из сессии, поэтому AAD сходится у получателя. Расшифровка с неизвестным `keyId` или ошибкой AEAD не является фатальной: сообщение сохраняется как нерасшифрованное с кодом причины. + +## Экспорт `.bare` + +``` +salt = 16 случайных байт +exportKey = HKDF-SHA256(accountSecret, salt, info = utf8("bare-export-v1")) → AES-GCM-256 +iv = 12 случайных байт +payload = JSON {"v": 1, "exportedAt": ms, "chats": [...], "messages": [...], "peers": [...]} +ct = AES-GCM(exportKey, iv, utf8(payload), AAD = header) +file = header || ct +header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) || iv (12) // 65 байт +``` + +Импорт: проверить magic и версию, сравнить `fingerprint` со своим — при несовпадении показать «архив создан другим аккаунтом» и остановиться, иначе вывести ключ и расшифровать. Слияние — идемпотентное по `id` сообщений и `id` чатов; записи `peers` из архива добавляются только для ников, которых в локальном TOFU ещё нет. + +## Идентификаторы + +- ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется. +- `deviceId`, `keyId`, `roomId` — 16 случайных байт base64url (22 символа). +- Сессионный токен — 32 случайных байта, на сервере хранится `SHA-256`. + +## Что сервер проверяет, а что нет + +Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму: base64url, длины (`iv` = 12 байт, `ct` не короче 16), существование `keyId` для комнаты, формат ULID и его время. diff --git a/docs/decisions/002-go-server.md b/docs/decisions/002-go-server.md index 0e2436a..4037803 100644 --- a/docs/decisions/002-go-server.md +++ b/docs/decisions/002-go-server.md @@ -1,5 +1,7 @@ # ADR-002: Сервер на Go, один бинарь +Уточнён [ADR-020](020-storage-schema-and-driver.md): три прямые зависимости, транзитивные допускаются; драйвер — `modernc.org/sqlite`. Развёртывание — [ADR-022](022-deploy-nginx-systemd.md). + ## Контекст Серверу Bare нужно немного: HTTP, SSE, SQLite, хеширование паролей, Web Push. Простота развёртывания и аудита важнее богатства экосистемы. diff --git a/docs/decisions/005-accounts.md b/docs/decisions/005-accounts.md index a6d2767..c19fe90 100644 --- a/docs/decisions/005-accounts.md +++ b/docs/decisions/005-accounts.md @@ -1,5 +1,7 @@ # ADR-005: Аккаунт — ник и пароль +Уточнён [ADR-015](015-password-never-leaves-client.md): на сервер уходит не пароль, а выведенный из него `authKey`. Открытость регистрации закрыта [ADR-019](019-registration-and-contacts.md). + ## Контекст Email, телефон и OAuth тянут за собой внешние сервисы, интеграции и утечку идентичности. Bare — независимый инструмент без внешних завязок. diff --git a/docs/decisions/006-e2ee-webcrypto.md b/docs/decisions/006-e2ee-webcrypto.md index 9b005e2..4f753ea 100644 --- a/docs/decisions/006-e2ee-webcrypto.md +++ b/docs/decisions/006-e2ee-webcrypto.md @@ -1,5 +1,7 @@ # ADR-006: E2EE на WebCrypto, ключ за паролем +Уточнён [ADR-015](015-password-never-leaves-client.md) (два ключа из мастера) и [ADR-016](016-key-trust-tofu.md) (доверие к публичным ключам). + ## Контекст Оператор не должен уметь читать сообщения. Крипто-библиотеки на клиенте противоречат нулю зависимостей и аудируемости — вся криптография должна быть нативной. diff --git a/docs/decisions/007-room-keys.md b/docs/decisions/007-room-keys.md index fc14b81..fb40f3f 100644 --- a/docs/decisions/007-room-keys.md +++ b/docs/decisions/007-room-keys.md @@ -1,5 +1,7 @@ # ADR-007: Симметричный ключ комнаты и rekey +Конкретизирован [ADR-018](018-rooms-membership-rekey.md): владелец, случайный `keyId`, атомарный rekey, постоянное хранение завёрнутых ключей. + ## Контекст Сообщение в комнате должны читать все участники, но не сервер. Шифровать каждое сообщение отдельно каждому участнику — квадратичный объём работы и трафика. diff --git a/docs/decisions/008-server-relay.md b/docs/decisions/008-server-relay.md index ee28355..acbcb80 100644 --- a/docs/decisions/008-server-relay.md +++ b/docs/decisions/008-server-relay.md @@ -1,5 +1,7 @@ # ADR-008: Сервер — реле с per-device очередью +Уточнён [ADR-017](017-devices-and-envelope.md) (идентификация устройства, конверт, ACK) и [ADR-018](018-rooms-membership-rekey.md): к метаданным комнат относятся завёрнутые ключи. + ## Контекст Сервер никогда не является местом, где живёт история (философия, п. 2). Но получатель бывает офлайн — сообщение надо где-то подержать до доставки. diff --git a/docs/decisions/011-web-push.md b/docs/decisions/011-web-push.md index 967c297..a47bb17 100644 --- a/docs/decisions/011-web-push.md +++ b/docs/decisions/011-web-push.md @@ -1,5 +1,7 @@ # ADR-011: Web Push + VAPID, пуш — сигнал +Правила отправки и service worker — [ADR-023](023-push-and-service-worker.md). + ## Контекст Без уведомлений чат бесполезен. Firebase SDK и вендорские кабинеты — зависимость и завязка, несовместимые с философией. diff --git a/docs/decisions/015-password-never-leaves-client.md b/docs/decisions/015-password-never-leaves-client.md new file mode 100644 index 0000000..d7f3483 --- /dev/null +++ b/docs/decisions/015-password-never-leaves-client.md @@ -0,0 +1,21 @@ +# ADR-015: Пароль не покидает клиент — два ключа из одного мастера + +## Контекст + +ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом. + +## Решение + +- Пароль никогда не отправляется на сервер. Клиент выводит мастер-ключ: `master = PBKDF2-HMAC-SHA256(NFC(пароль), salt = SHA-256("bare-v1:" + nick), iterations, 256 бит)`. Соль детерминированная — известна до входа без запроса к серверу. +- Из мастера через HKDF-SHA256 выводятся два независимых ключа: `authKey = HKDF(master, info="bare-auth-v1")` — 32 байта, уходит на сервер как «пароль»; `kek = HKDF(master, info="bare-kek-v1")` — AES-GCM-256, шифрует ключевой блоб и сервер его не видит. +- Сервер хранит `argon2id(authKey)`. Argon2id остаётся (ADR-002, ADR-005): он защищает дамп базы от использования `authKey` как готового пароля для входа. +- Перед входом клиент спрашивает `GET /api/kdf?nick=` и получает число итераций. Для несуществующего ника сервер отвечает текущим целевым значением — ответ не раскрывает существование ника. +- Повышение итераций и смена пароля — одна и та же операция `POST /api/password`: клиент, имея пароль в памяти, выводит новый `authKey`, перешифровывает блоб новым `kek` и отправляет оба вместе со старым `authKey` для подтверждения. Сервер заменяет хеш и блоб атомарно. Смена пароля входит в v1. +- Смена пароля по желанию пользователя завершает остальные сессии (`logoutOthers: true`); автоматическое повышение итераций — нет. + +## Следствия + +- Пассивный оператор не получает материал ключа ни при регистрации, ни при входе. Модель угроз становится честной. +- Соль из ника означает, что перерегистрация под тем же ником с тем же паролем даёт тот же мастер. Ключевая пара при этом новая — старые архивы нечитаемы (ADR-014), мастер это не спасает. +- PBKDF2 выполняется один раз на вход; на слабом телефоне 1 000 000 итераций — до нескольких секунд. UI показывает «вычисляем ключ». +- Вопрос «смена пароля в v1 или позже» закрыт: в v1. diff --git a/docs/decisions/016-key-trust-tofu.md b/docs/decisions/016-key-trust-tofu.md new file mode 100644 index 0000000..2052064 --- /dev/null +++ b/docs/decisions/016-key-trust-tofu.md @@ -0,0 +1,20 @@ +# ADR-016: Доверие к ключам — TOFU и отпечаток + +## Контекст + +Публичные ключи собеседников клиент получает от сервера. Сервер, подменивший ключ, становится посредником в чате 1:1 и получает ключ комнаты при rekey. Модель угроз описывает подмену клиентского кода, но не подмену ключа — это отдельный, более дешёвый для оператора вектор. Подписи сообщений потребовали бы вторую ключевую пару (ECDH-ключ P-256 в WebCrypto не подписывает) и усложнили бы протокол. + +## Решение + +- Trust On First Use. Клиент запоминает публичный ключ ника при первом получении (хранилище `peers` в IndexedDB). При каждом последующем получении ключа сверяет с запомненным. +- Отпечаток ключа — `SHA-256(raw-точка публичного ключа P-256, 65 байт)`, показывается как 64 hex-символа группами по 4. Свой отпечаток виден в настройках; чужой — в карточке контакта. Сверка — вне канала, голосом или лично. +- Изменение ключа — не ошибка, а состояние: в чате появляется предупреждение «ключ @nick изменился, сверьте отпечаток». Отправка этому нику блокируется до явного «доверять новому ключу». Входящие, зашифрованные новым ключом, показываются как нерасшифрованные с той же подсказкой. +- Rekey комнаты участнику с изменившимся и не подтверждённым ключом не выполняется: владелец видит, чей ключ надо подтвердить, и повторяет операцию после подтверждения. +- Подписей сообщений в v1 нет. Отправитель в конверте проставляется сервером из сессии. В 1:1 подлинность следует из самого ключа: валидный шифротекст может создать только владелец общего секрета. В комнате любой участник может создать валидный шифротекст от чужого имени только в сговоре с сервером, который проставляет `from`. + +## Следствия + +- Сервер получает возможность подмены ключа только при первом контакте; после этого подмена видна. +- Защита стоит ровно столько, сколько люди готовы сверять отпечатки. Это честно записано в модели угроз. +- Новое устройство начинает с пустым хранилищем TOFU; импорт архива `.bare` переносит и его. +- Подписи и второй ключ — возможное расширение отдельным ADR, если появится требование защиты от сговора участника с сервером. diff --git a/docs/decisions/017-devices-and-envelope.md b/docs/decisions/017-devices-and-envelope.md new file mode 100644 index 0000000..e585a54 --- /dev/null +++ b/docs/decisions/017-devices-and-envelope.md @@ -0,0 +1,26 @@ +# ADR-017: Устройства, конверт сообщения и доставка + +## Контекст + +Очередь per-device (ADR-008) требует идентификации устройства. Формат конверта определяет, какие метаданные видит сервер, — это часть модели угроз, а не деталь реализации. Время сообщения: клиентский ULID (ADR-009) несёт часы клиента, которые врут. + +## Решение + +**Устройство.** Клиент при первом входе на устройстве генерирует `deviceId` — 16 случайных байт, base64url — и хранит его в IndexedDB. Регистрирует через `POST /api/devices`; идентификатор принадлежит аккаунту. Заголовок `X-Device` обязателен на запросах, где важно устройство: ACK, отправка (чтобы не возвращать эхо отправившему устройству), push-подписка; поток событий получает устройство в query — `EventSource` не умеет заголовки. Устройство, не появлявшееся 90 дней, удаляется вместе с очередью и подпиской. + +**Конверт.** JSON, открытые поля: `id` (ULID, генерирует клиент), `to` (`{dm: nick}` или `{room: id}`), `from` (ставит сервер из сессии, клиентское значение игнорируется), `keyId` (`"dm"` для 1:1, идентификатор ключа для комнаты), `iv`, `ct`, `ts` (миллисекунды сервера). Внутри шифротекста — JSON `{t: текст}`. Открытые поля привязаны к шифротексту через AAD: `bare-msg-v1|id|chat|from|keyId`, где `chat` — `dm:a:b` (ники по возрастанию) или `room:id`. + +**Часы.** Сервер принимает сообщение, только если метка времени в ULID отличается от серверных часов не больше чем на 5 минут; иначе `400 clock_skew` и клиент просит проверить часы. ULID присваивается в момент попытки отправки, не в момент набора: отложенное офлайном сообщение получает свежий идентификатор при повторе. Сортировка — по `id`, отображение времени — по `ts`. + +**Доставка.** `POST /api/messages` в одной транзакции кладёт конверт в очередь каждого устройства каждого получателя (в 1:1 получатели — оба ника, в комнате — все участники), кроме устройства-отправителя. Подключённым по SSE устройствам конверт отправляется сразу. `POST /api/ack {ids}` удаляет конверты из очереди устройства. При подключении SSE сервер сначала отдаёт всю очередь устройства, затем `event: ready`, затем живые события. Каждые 20 секунд — комментарий-пинг. + +**Идемпотентность.** Повторный `POST` с тем же `id` после ACK получателей породит повторную доставку; клиент сливает по `id` и дублей не показывает. Сервер не хранит историю идентификаторов — это противоречило бы ADR-008. + +**Лимиты.** Текст — до 4000 символов, тело запроса — до 32 КиБ. Rate limiting — ADR-021. + +## Следствия + +- Серверу видны: кто, кому или в какую комнату, когда и какого размера. Ровно то, что модель угроз и так относит к метаданным. +- Отправитель получает своё сообщение на другие устройства тем же путём, что и получатели: мультидевайс без отдельной логики. +- Устройство определяется браузерным профилем: два браузера на одном телефоне — два устройства. +- Чистка IndexedDB браузером стирает `deviceId`; следующий вход создаёт новое устройство, старое отомрёт по сроку. diff --git a/docs/decisions/018-rooms-membership-rekey.md b/docs/decisions/018-rooms-membership-rekey.md new file mode 100644 index 0000000..35afefe --- /dev/null +++ b/docs/decisions/018-rooms-membership-rekey.md @@ -0,0 +1,22 @@ +# ADR-018: Комнаты — владелец, состав и атомарный rekey + +## Контекст + +ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника. + +## Решение + +- Комнату создаёт любой пользователь и становится её владельцем. Владелец добавляет и удаляет участников по нику, может удалить комнату. Любой участник может выйти. Приглашений по ссылке нет. Имя комнаты — до 64 символов, открытый текст на сервере: это метаданные. +- Ключ комнаты — 32 случайных байта с идентификатором `keyId` (16 случайных байт, base64url). Идентификатор случайный, а не порядковый: гонка двух одновременных rekey даёт два разных ключа, оба доходят до всех, конфликта номеров нет. Текущий ключ — последний полученный в порядке сервера; сообщения несут `keyId`, клиент держит все ключи комнаты и расшифровывает любым известным. +- Ключ участнику заворачивается на ECDH между распространителем и участником: `HKDF(ECDH(priv_D, pub_M), info="bare-roomkey-v1|roomId|keyId") → AES-GCM`. Распространитель заворачивает ключ и себе — для собственных других устройств. +- Завёрнутые ключи сервер хранит постоянно, не в транзитной очереди: таблица `room_keys`, по одной записи на участника и ключ, последние два ключа комнаты. Новое устройство участника получает текущий ключ вместе со списком комнат. Это уточняет ADR-008: к «метаданным комнат» относятся и завёрнутые ключи — шифротекст, серверу бесполезный. +- Смена состава и rekey — один запрос `POST /api/rooms/{id}/members {add, remove, keyId, keys}`. Клиент-владелец сначала получает публичные ключи итогового состава (с проверкой TOFU, ADR-016), генерирует ключ, заворачивает каждому, затем отправляет. Сервер проверяет, что множество `keys[].to` равно итоговому составу, и применяет всё в одной транзакции. Состав без ключа или ключ без состава невозможны. +- Выход участника: сервер удаляет его из состава и его ключи, шлёт остальным событие `room` с `needsRekey: true`. Клиент владельца, получив его, выполняет rekey тем же запросом с пустыми `add`/`remove`. Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает; новых сообщений сервер ему не доставляет. +- Выход владельца передаёт владение участнику с самым ранним `joined_at`. Выход последнего участника удаляет комнату. + +## Следствия + +- Серверу ключи недоступны по-прежнему: он хранит и раздаёт только шифротекст. +- Владелец — единственная роль. Администраторов, модераторов и прав на уровне сообщений нет. +- Новый участник не читает прошлое: его не существует на сервере. Сообщение, отправленное на старом ключе одновременно с rekey, новое устройство прочитать не сможет — показывается как нерасшифрованное. Редкий и честный случай. +- Если член комнаты сговорился с сервером, он может остаться читателем после выхода до rekey. В модели угроз сервер и участник по отдельности не защищаемые стороны; их сговор — тем более. diff --git a/docs/decisions/019-registration-and-contacts.md b/docs/decisions/019-registration-and-contacts.md new file mode 100644 index 0000000..19b800c --- /dev/null +++ b/docs/decisions/019-registration-and-contacts.md @@ -0,0 +1,19 @@ +# ADR-019: Регистрация, ники и контакты + +## Контекст + +Открытые вопросы: открытая регистрация или инвайты; как добавляется контакт и начинается чат 1:1. Bare — маленький инструмент для небольших групп, а не публичная сеть. + +## Решение + +- Ник: `^[a-z0-9_]{2,32}$`. Только строчные — уникальность без регистровых коллизий и омоглифов. Ник постоянен, смены нет. +- Регистрация открыта. Оператор может задать один общий инвайт-код (`BARE_INVITE_CODE` в окружении); если задан, регистрация требует его. Персональных инвайтов, ссылок и списков нет. +- Чат 1:1 начинается с ввода ника: клиент получает публичный ключ (`GET /api/users/{nick}`) и пишет. Согласия получателя не требуется — как в e-mail. Контакт — строка в списке чатов, которую сервер заводит обеим сторонам при первом сообщении в любую сторону, чтобы новое устройство видело список чатов без истории. `DELETE /api/contacts/{nick}` убирает чат из списка, не блокируя собеседника. +- Блокировки в v1 нет. Защита от спама — инвайт-код и rate limiting (ADR-021). +- Удаление аккаунта: `DELETE /api/me` с подтверждением `authKey` удаляет аккаунт, устройства, контакты, членство и очереди. Комнаты, где пользователь владелец, передаются по правилу ADR-018. + +## Следствия + +- Ник — публичный идентификатор; что он существует, узнать можно. Это не секрет и не считается утечкой. +- Общий инвайт-код — барьер от ботов, не от людей, которым его передали. Большего v1 не обещает. +- Блокировка и персональные инвайты — кандидаты на следующие ADR, если понадобятся. diff --git a/docs/decisions/020-storage-schema-and-driver.md b/docs/decisions/020-storage-schema-and-driver.md new file mode 100644 index 0000000..edc6a82 --- /dev/null +++ b/docs/decisions/020-storage-schema-and-driver.md @@ -0,0 +1,20 @@ +# ADR-020: Схема SQLite, миграции и драйвер + +## Контекст + +ADR-003 выбирает SQLite, но не схему и не драйвер. Выбор драйвера решает, нужен ли cgo: от этого зависит, можно ли собрать бинарь для Linux на Mac одной командой. Формулировка «ровно три внешних пакета» требует уточнения: прямые зависимости или всё содержимое `go.sum`. + +## Решение + +- Драйвер — `modernc.org/sqlite`, чистый Go. Сборка с `CGO_ENABLED=0`, кросс-компиляция тривиальна. +- «Три внешних пакета» — три прямые зависимости в `go.mod`: `modernc.org/sqlite`, `github.com/SherClockHolmes/webpush-go`, `golang.org/x/crypto` (ради `argon2`). Их транзитивные зависимости допускаются: они не выбираются нами и не импортируются напрямую. +- Режим базы: `journal_mode=WAL`, `busy_timeout=5000`, `foreign_keys=ON`, `synchronous=NORMAL`. Один файл, путь из конфигурации. +- Миграции — нумерованные SQL-файлы, встроенные в бинарь через `embed`. Версия схемы — `PRAGMA user_version`. При старте сервер применяет недостающие миграции по порядку, каждую в своей транзакции. Откатов нет: новая миграция исправляет предыдущую. +- Таблицы: `users`, `sessions`, `devices`, `contacts`, `rooms`, `room_members`, `room_keys`, `queue`. Полная схема — `docs/storage.md`. Никаких таблиц с историей сообщений. +- Фоновые задачи раз в час: удаление из `queue` записей старше 30 дней, устройств с `last_seen` старше 90 дней, истёкших сессий, лишних ключей комнат сверх двух последних. + +## Следствия + +- `go build` без тулчейна C. Деплой — `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`. +- `modernc.org/sqlite` медленнее cgo-варианта в разы на тяжёлых запросах. Для очереди и метаданных маленького чата это незаметно. +- Бэкап — копия файла базы при остановленном сервере или `VACUUM INTO`. WAL-файл без основного файла бесполезен. diff --git a/docs/decisions/021-sessions-csrf-limits.md b/docs/decisions/021-sessions-csrf-limits.md new file mode 100644 index 0000000..45bc2ba --- /dev/null +++ b/docs/decisions/021-sessions-csrf-limits.md @@ -0,0 +1,30 @@ +# ADR-021: Сессии, CSRF, Argon2id и лимиты + +## Контекст + +ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без параметров. Cookie плюс `fetch POST` — классическая поверхность для CSRF. Лимиты и защита от перебора — открытый вопрос. + +## Решение + +**Argon2id.** Вход — `authKey` (32 случайных байта с точки зрения сервера, ADR-015), поэтому параметры умеренные: memory 19 MiB, time 2, parallelism 1, соль 16 байт, выход 32 байта. Параметры записываются рядом с хешем; повышение — перехеш при очередном входе. + +**Сессия.** Токен — 32 случайных байта; в базе хранится `SHA-256(токен)`. Cookie `bare_session`: `HttpOnly; Secure; SameSite=Strict; Path=/`; срок 90 дней без продления. После регистрации устройства сессия привязывается к нему. `POST /api/logout` удаляет сессию; смена пароля по желанию завершает остальные; удаление устройства завершает его сессии. + +**CSRF.** Два независимых барьера: `SameSite=Strict` и проверка заголовка `Origin` на всех запросах кроме `GET`/`HEAD` — он обязан равняться `BARE_ORIGIN`. Приложение живёт на одном origin, сторонних встраиваний нет. + +**Заголовки.** `Content-Security-Policy: default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`. Никаких inline-скриптов и inline-стилей — CSP их запрещает, и это правило для клиентского кода. `Referrer-Policy: no-referrer`, `X-Content-Type-Options: nosniff`, HSTS на nginx. + +**Лимиты.** Token bucket в памяти сервера: +- регистрация — 5 в час на IP; +- вход — 10 за 10 минут на пару IP+ник; +- сообщения — 30 в минуту на пользователя, пакет 10; +- остальные изменяющие запросы — 60 в минуту на пользователя. +Превышение — `429` с `Retry-After`. IP берётся из `X-Real-IP`, только если соединение с `127.0.0.1` (nginx, ADR-022). + +**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. + +## Следствия + +- Сторонний сайт не может ни отправить сообщение, ни выйти из аккаунта от имени пользователя. +- Лимиты живут в памяти: рестарт их обнуляет. Для маленького сервера это приемлемо. +- 90-дневный вход без продления — раз в квартал пароль вводится заново на каждом устройстве. diff --git a/docs/decisions/022-deploy-nginx-systemd.md b/docs/decisions/022-deploy-nginx-systemd.md new file mode 100644 index 0000000..36fca2e --- /dev/null +++ b/docs/decisions/022-deploy-nginx-systemd.md @@ -0,0 +1,20 @@ +# ADR-022: Деплой — nginx, systemd, кросс-сборка + +## Контекст + +Целевой сервер (`ssh xmatic`, Ubuntu 22.04) уже держит nginx на 80/443 с десятком сайтов и certbot. Go на сервере нет. HTTPS обязателен (ADR-002), но TLS в самом бинаре означал бы либо `autocert` — четвёртую зависимость, — либо конфликт за 443 с nginx. + +## Решение + +- TLS терминирует nginx. Bare слушает `127.0.0.1:8411` (порт свободен; 8090 занят PocketBase). Сертификат — certbot для `bare.xmatic.team`, как у остальных сайтов на машине. +- nginx проксирует всё на бинарь; для `/api/events` — `proxy_buffering off`, `proxy_read_timeout 1h`, HTTP/1.1 к апстриму. Сервер дополнительно шлёт `X-Accel-Buffering: no`. Конфиг — `docs/deploy.md`. +- Бинарь под systemd: пользователь `bare`, `/opt/bare/bare`, база в `/var/lib/bare/bare.db`, секреты в `/etc/bare/env` (режим 0600). Юнит с `ProtectSystem=strict`, `ProtectHome=yes`, `NoNewPrivileges=yes`. +- Конфигурация — переменные окружения с префиксом `BARE_`: `ADDR`, `DB`, `ORIGIN`, `VAPID_PUBLIC`, `VAPID_PRIVATE`, `VAPID_SUBJECT`, `INVITE_CODE`. Подкоманда `bare vapid` генерирует пару ключей. Подкоманда `bare serve` запускает сервер. +- Клиентская статика встроена в бинарь через `embed`: артефакт деплоя — ровно один файл, и обещание ADR-001 «код в продакшене байт в байт совпадает с репозиторием» проверяется сравнением с тегом. +- Сборка локально: `GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build`. Деплой — `scripts/deploy.sh`: сборка, `scp`, `install`, `systemctl restart`. Без контейнеров. + +## Следствия + +- Проверка подлинности клиента сводится к проверке бинаря: хеш файла на сервере против сборки из тега. +- Зависимость от чужого nginx на той же машине — осознанная: он уже там и уже умеет сертификаты. +- Статику отдаёт Go, не nginx: заголовки безопасности и ETag в одном месте. diff --git a/docs/decisions/023-push-and-service-worker.md b/docs/decisions/023-push-and-service-worker.md new file mode 100644 index 0000000..6478172 --- /dev/null +++ b/docs/decisions/023-push-and-service-worker.md @@ -0,0 +1,20 @@ +# ADR-023: Правила пушей и service worker + +## Контекст + +ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker. + +## Решение + +- Push-подписка принадлежит устройству (`devices.push_subscription`). Ставится `PUT /api/devices/{id}/push`, снимается `DELETE`. +- Пуш отправляется при постановке сообщения в очередь устройства, если выполняются оба условия: устройство не подключено по SSE и у устройства не висит неотработанный пуш (`push_pending = 0`). После отправки `push_pending = 1`; сбрасывается при подключении SSE. Одно молчащее устройство получает один пуш, не ленту. +- Полезная нагрузка: `{title, body: "новое сообщение", chat}` — `title` это `@nick` или `#имя комнаты`, `chat` — идентификатор для перехода. `TTL` 24 часа, urgency `normal`. Ответы 404/410 от push-сервиса удаляют подписку. +- Service worker: `push` → `showNotification` с `tag = chat` (новое уведомление заменяет старое в том же чате); `notificationclick` → фокус открытого окна или открытие `/#/`. +- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`. +- Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки. + +## Следствия + +- Сервер знает только, что у устройства есть что забрать; содержимое в пуше не появляется. +- Пользователь с пятью непрочитанными чатами получает один пуш про первый. Остальное — при открытии. Осознанно. +- Релиз без смены версии в `sw.js` обновит статику только по ETag при следующем revalidate, не мгновенно. diff --git a/docs/decisions/024-identity-and-ui.md b/docs/decisions/024-identity-and-ui.md new file mode 100644 index 0000000..5e90d44 --- /dev/null +++ b/docs/decisions/024-identity-and-ui.md @@ -0,0 +1,21 @@ +# ADR-024: Айдентика «Скобы», интерфейс и язык + +## Контекст + +Исследование айдентики (Claude Design, «Исследование айдентики Bare») дало шесть направлений и две мини-айдентики; мок чата построен на варианте 1h «Скобы» и использует только моноширинный шрифт, без «пузырей». Открытые вопросы: язык интерфейса и i18n, визуальная айдентика. Мок содержит элементы, которых в scope v1 нет. + +## Решение + +- Айдентика — 1h «Скобы»: знак из четырёх углов, палитра bone/ink/mark/stone, разметочная эстетика — тонкие линии, много воздуха, прямые углы. Бриф — `docs/identity/brief.md`, знак — `docs/identity/mark.svg`, эталонные экраны — `docs/identity/screens.html`. +- Шрифт интерфейса — системный моноширинный стек: `ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace`. Fragment Mono из исследования не содержит базовой кириллицы (U+0400–045F) — в моке русский текст и так рендерился фолбэком. Ноль загрузок шрифтов: продукт не тянет ничего извне. +- Одна тема — светлая. Тёмной темы и переключателя в v1 нет. +- Язык — русский, строки в коде. i18n не закладывается; появление второго языка — отдельный ADR. +- Из мока исключены как не входящие в v1: тема канала в шапке, счётчик «N онлайн» и присутствие вообще. Остаются: список каналов и личных, счётчик непрочитанных, разделители дат и «новые», строка ввода с `>` и подсказкой `enter — отправить`, подпись «ты: @nick». +- Экраны, которых в исследовании нет (вход, контакт, участники, настройки, предупреждение о ключе, баннер установки), описаны словами в `docs/ui.md` в той же системе. +- Non-goals интерфейса v1: присутствие, «печатает», статусы прочтения, аватары, темы, анимации. + +## Следствия + +- Клиент без единого внешнего ресурса: CSP `default-src 'self'` без исключений. +- Вид зависит от системного шрифта платформы; это принято — разметка, а не брендбук. +- Иконки PWA — растеризованный знак, лежат в репозитории как бинарные файлы; это ассеты, не сборка. diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..cdfb48d --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,135 @@ +# Деплой + +Цель — `ssh xmatic` (Ubuntu 22.04, x86_64), домен `bare.xmatic.team`, A-запись на IP сервера. Решения — ADR-022. + +## Сборка + +```sh +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`. + +## Первичная настройка сервера (один раз) + +```sh +sudo useradd --system --home /var/lib/bare --shell /usr/sbin/nologin bare +sudo mkdir -p /opt/bare /var/lib/bare /etc/bare +sudo chown bare:bare /var/lib/bare +``` + +`/etc/bare/env` (владелец root, режим 0600): + +``` +BARE_ADDR=127.0.0.1:8411 +BARE_DB=/var/lib/bare/bare.db +BARE_ORIGIN=https://bare.xmatic.team +BARE_VAPID_PUBLIC=<из bare vapid> +BARE_VAPID_PRIVATE=<из bare vapid> +BARE_VAPID_SUBJECT=mailto:admin@xmatic.team +BARE_INVITE_CODE=<пусто или код> +``` + +`bare vapid` печатает пару ключей; выполняется локально один раз, результат вписывается в файл. + +`/etc/systemd/system/bare.service`: + +```ini +[Unit] +Description=Bare chat +After=network-online.target +Wants=network-online.target + +[Service] +User=bare +Group=bare +EnvironmentFile=/etc/bare/env +ExecStart=/opt/bare/bare serve +Restart=on-failure +RestartSec=2 +StateDirectory=bare +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +ReadWritePaths=/var/lib/bare + +[Install] +WantedBy=multi-user.target +``` + +`/etc/nginx/sites-available/bare.xmatic.team` (затем симлинк в `sites-enabled`): + +```nginx +server { + listen 80; + listen [::]:80; + server_name bare.xmatic.team; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name bare.xmatic.team; + + ssl_certificate /etc/letsencrypt/live/bare.xmatic.team/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/bare.xmatic.team/privkey.pem; + add_header Strict-Transport-Security "max-age=31536000" always; + + client_max_body_size 64k; + + location /api/events { + proxy_pass http://127.0.0.1:8411; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_cache off; + gzip off; + proxy_read_timeout 1h; + } + + location / { + proxy_pass http://127.0.0.1:8411; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Connection ""; + } +} +``` + +Сертификат: сначала временный конфиг только с блоком `:80` (без `return`, с `root` для ACME) или `certbot --nginx -d bare.xmatic.team` — на машине certbot уже обслуживает соседние сайты, использовать тот же способ, что у них (`ls /etc/letsencrypt/renewal/` показывает, какой плагин). + +```sh +sudo nginx -t && sudo systemctl reload nginx +sudo systemctl daemon-reload && sudo systemctl enable --now bare +``` + +## Обновление — `scripts/deploy.sh` + +```sh +#!/bin/sh +set -eu +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/bare ./cmd/bare +scp /tmp/bare xmatic:/tmp/bare +ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sudo systemctl restart bare && sleep 1 && curl -fsS http://127.0.0.1:8411/healthz' +``` + +Сверка подлинности: `sha256sum /opt/bare/bare` на сервере равен хешу сборки из тега на той же версии Go с теми же флагами. + +## Проверка после деплоя + +- `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff. +- `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации. +- `journalctl -u bare -f` — старт, применённые миграции, нет ошибок. + +## Бэкап + +`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. + +## Логи + +Сервер пишет в stdout: время, метод, путь, статус, длительность; ник — только для ошибок аутентификации по лимитам; IP не пишется. journald хранит по своим правилам. diff --git a/docs/identity/brief.md b/docs/identity/brief.md new file mode 100644 index 0000000..c42d934 --- /dev/null +++ b/docs/identity/brief.md @@ -0,0 +1,50 @@ +# Айдентика «Скобы» + +Источник — исследование «Исследование айдентики Bare» (Claude Design), вариант 1h и мок чата 2a/2b. Здесь — то, что из него принято (ADR-024). + +## Знак + +Четыре угла рамки, из которой вынули содержимое. Пустота внутри и есть знак. Файл — `mark.svg` (viewBox 64, штрих 7); для 16 px штрих 9 (`web/icons/mark.svg`). + +Правила: внутрь рамки ничего не помещать; не скруглять; не замыкать в квадрат; не наклонять и не анимировать; один цвет на знак; охранное поле — длина одного уголка. На тёмном и акцентном фоне знак всегда bone. + +Wordmark — слово `bare` строчными рядом со знаком, тем же шрифтом, что интерфейс. «Bare» с заглавной — только в тексте. + +## Цвет + +| имя | значение | роль | +|-------|------------------------------|------| +| bone | `#F7F5F0` | фон | +| ink | `#1B1917` | текст, рамки, активный элемент | +| text2 | `#3C3B38` | вторичный текст | +| mute | `#6E6D68` | авторы, подписи | +| stone | `#A9A59D` | время, placeholder, pending | +| line | `#E7E3DA` | разделители | +| edge | `#DEDCD6` | внешние границы | +| mark | `oklch(55% 0.19 20)`, fallback `#C82D40` | один акцент: непрочитанные, «новые», `>` ввода, свой ник, предупреждения | + +Акцент — не чаще одного смыслового элемента на экран. Не для кнопок и заливок. Никаких градиентов, теней, скруглений. + +CSS-переменные: `--bone --ink --text2 --mute --stone --line --edge --mark`. + +## Шрифт + +Один: системный моноширинный. + +```css +font-family: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; +``` + +Размеры: текст сообщений 14 px / 1.55; автор и время 12 px; заголовки секций 10 px, разрядка 0.14em, uppercase; подписи 11 px; имя чата в шапке 15 px. Шрифты не загружаются. + +## Компоновка + +- Десктоп: сайдбар 224 px с правой границей `line`, шапка 64 px, отступы контента 32 px; сообщения — сетка `132px 1fr`, column-gap 20, row-gap 6. +- Мобильный: шапка 56 px, отступы 20 px, ввод с min-height 44 px. +- Ввод — рамка 1 px ink, без скруглений, `>` цветом mark слева. +- Активный элемент списка — инверсия: фон ink, текст bone. +- Разделители — 1 px `line`; разделитель «новые» — 1 px mark. + +## Голос + +Короткие фразы, строчные буквы, без восклицаний и маркетинга. Ошибки говорят, что случилось и что делать. Примеры в `docs/ui.md`. diff --git a/docs/identity/mark.svg b/docs/identity/mark.svg new file mode 100644 index 0000000..2ae6d75 --- /dev/null +++ b/docs/identity/mark.svg @@ -0,0 +1 @@ + diff --git a/docs/identity/screens.html b/docs/identity/screens.html new file mode 100644 index 0000000..a903ed0 --- /dev/null +++ b/docs/identity/screens.html @@ -0,0 +1,140 @@ + + + + + +Bare — эталонные экраны + + + +

Bare — эталонные экраны

+

вариант 1h «скобы» · системный mono · без баблов · только scope v1

+ +
+
+
2a десктоп · 1120
+
+ +
+
#general
+
+
вторник, 18 августа
+
+
marta 11:52
+
выкатила статику на bare.xmatic.team, кэш чистится сам
+
+
вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит
+
lev 11:58
+
смотрю network: один html, один css, ноль js до первого сообщения. красиво
+
kir 12:03
+
это и есть план. если фича требует бандлер — фича не нужна
+
+
доки пишу прямо в readme, отдельного сайта не будет
+
новые
+
marta 12:41
+
кто-то с hn спрашивает, где мобильное приложение
+
lev 12:42
+
ответил: браузер и есть приложение
+
+
+
+
>сообщение в #generalenter — отправить
+
+
+
+
+ +
+
2b мобильный · 390
+
+
#general
+
+
18 авг
+
marta 11:52

выкатила статику на bare.xmatic.team, кэш чистится сам

вес страницы — 14 кб

+
lev 11:58

один html, один css, ноль js до первого сообщения. красиво

+
kir 12:03

это и есть план. если фича требует бандлер — фича не нужна

+
новые
+
marta 12:41

кто-то с hn спрашивает, где мобильное приложение

+
lev 12:42

ответил: браузер и есть приложение

+
+
+
>сообщение
+
+
+
+
+ + diff --git a/docs/open-questions.md b/docs/open-questions.md index 50c8ad5..8b3e929 100644 --- a/docs/open-questions.md +++ b/docs/open-questions.md @@ -2,11 +2,8 @@ Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда. -- Регистрация: открытая или по инвайтам? -- Механика добавления контакта и приглашения в комнату: по нику? по ссылке? -- Лимиты: длина сообщения, rate limiting, антиспам. -- Смена пароля (= перешифровка ключевого блоба): в v1 или позже? -- Идентификация устройства для per-device очередей. - Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено. -- Язык интерфейса (ru/en); нужна ли i18n. -- Визуальная айдентика: отдельный бриф будет добавлен в `docs/identity/`. +- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало. +- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016). + +Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей. diff --git a/docs/plan.md b/docs/plan.md new file mode 100644 index 0000000..07c080c --- /dev/null +++ b/docs/plan.md @@ -0,0 +1,110 @@ +# План реализации v1 + +Документ для исполнителя — человека или агента. Всё, что здесь, выводится из ADR и спецификаций; при расхождении правы ADR. Этапы идут по порядку, каждый заканчивается работающим деплоем на `bare.xmatic.team` и коммитом. + +## Источники истины + +| вопрос | документ | +|---|---| +| что и почему | `philosophy.md`, `architecture.md`, `threat-model.md`, `decisions/` | +| криптография, байт в байт | `crypto.md` | +| HTTP-API, SSE, коды ошибок | `protocol.md` | +| схема SQLite, IndexedDB, формат `.bare` | `storage.md` | +| экраны, тексты, поведение | `ui.md`, `identity/` | +| сервер, nginx, systemd | `deploy.md` | + +## Раскладка репозитория + +``` +cmd/bare/main.go подкоманды: serve, vapid, version +internal/config/ переменные BARE_* +internal/store/ SQLite, migrations/*.sql (embed), запросы +internal/auth/ argon2id, сессии, cookie +internal/hub/ SSE-соединения по deviceId +internal/push/ webpush-go, правила ADR-023 +internal/api/ маршруты, валидация, лимиты, заголовки +internal/web/ embed web/, отдача статики +web/ + index.html app.css manifest.json sw.js + icons/ уже в репозитории + js/main.js загрузка, роутинг, состояние + js/api.js fetch-обёртки, SSE, ACK + js/crypto.js всё из crypto.md + js/db.js IndexedDB из storage.md + js/ulid.js ULID + js/ui/*.js экраны из ui.md + js/export.js .bare +scripts/deploy.sh +``` + +Go — последняя стабильная версия, маршрутизация `net/http` с шаблонами методов (`"POST /api/messages"`). Прямые зависимости ровно три (ADR-020). Клиент — ES-модули, без сборки, без inline-стилей и скриптов, `innerHTML` запрещён. + +## Этап 0 — скелет и деплой + +- `go mod init`, `cmd/bare`, `serve` слушает `BARE_ADDR`, отдаёт `web/` из `embed`, `/healthz`, заголовки безопасности. +- `web/index.html` — страница со знаком и словом `bare`, `app.css` с переменными из `identity/brief.md`, `manifest.json`, пустой `sw.js` с версией. +- `scripts/deploy.sh`; на сервере — пользователь, каталоги, `env`, юнит, nginx, сертификат по `deploy.md`. + +Готово, когда `https://bare.xmatic.team/` открывается с правильным CSP, `/healthz` отвечает `ok`, `journalctl -u bare` чист. + +## Этап 1 — аккаунты + +- Миграция 001, `store` с `user_version`, фоновая чистка. +- `auth`: argon2id с параметрами ADR-021, сессии, cookie, проверка `Origin`. +- Эндпоинты: `config`, `kdf`, `register`, `login`, `logout`, `me`, `password`, `DELETE /api/me`, `users/{nick}`. +- Клиент: `crypto.js` (мастер, authKey, kek, блоб, ключевая пара, отпечаток), экран входа и регистрации, сохранение `CryptoKey` в IndexedDB, автоповышение итераций, настройки с «сменить пароль» и «выйти». +- Тесты Go: миграции на пустой базе, регистрация и вход, неверный `authKey`, смена пароля с `logoutOthers`. + +Готово, когда регистрация и вход работают на телефоне и десктопе, вход на втором устройстве расшифровывает тот же ключ (отпечатки совпадают), пароль в сетевых запросах не встречается. + +## Этап 2 — чат 1:1 + +- `devices`, `hub`, `queue`, `POST /api/messages`, `ack`, `events` с воспроизведением очереди и пингом. +- Клиент: `ulid.js`, `db.js`, `api.js` с SSE и ACK после записи, шифрование сообщений, экран чата (десктоп и мобильный по эталону), список чатов, «новый чат», разделители дат и «новые», pending/failed, повтор после реконнекта. +- Контакты: `GET/POST/DELETE /api/contacts`, автосоздание при первом сообщении. +- Тесты Go: фан-аут по устройствам без эха отправителю, ACK удаляет, повтор очереди при реконнекте, `clock_skew`, лимит 30/мин. + +Готово, когда два аккаунта переписываются в реальном времени, второе устройство получателя получает копию, офлайн-устройство получает очередь при открытии, в базе — только шифротекст. + +## Этап 3 — ключи и комнаты + +- TOFU: хранилище `peers`, карточка контакта, предупреждение о смене ключа, «доверять новому ключу», повторная расшифровка `raw`. +- Комнаты: `rooms` и `room_keys`, все эндпоинты из `protocol.md`, события `room`/`room_left`, передача владения, rekey при выходе. +- Клиент: создание комнаты, участники, заворачивание и разворачивание ключей, хранение `roomKeys`, отправка с текущим `keyId`, расшифровка любым известным. +- Тесты Go: `keys_mismatch`, `key_exists`, выход владельца, удаление пустой комнаты, обрезка ключей до двух. + +Готово, когда трое переписываются в комнате, добавленный четвёртый читает только новое, вышедший не получает новых сообщений после rekey, подмена `public_key` в базе вручную вызывает предупреждение у собеседника. + +## Этап 4 — PWA и пуши + +- `sw.js`: кэш оболочки, `push`, `notificationclick`; `manifest.json` с иконками; `apple-touch-icon`. +- `PUT/DELETE /api/devices/{id}/push`, отправка по правилам ADR-023, обработка 404/410. +- Клиент: запрос разрешения после первого сообщения, настройки уведомлений, баннер установки на iOS, `beforeinstallprompt`. + +Готово, когда закрытое PWA на iPhone и Android получает пуш и открывается на нужном чате; повторные сообщения до открытия пуш не порождают. + +## Этап 5 — история + +- Экспорт и импорт `.bare` по `crypto.md` и `storage.md`; идемпотентность; «архив создан другим аккаунтом». +- Настройки: устройства (список, удаление), занятое место, удаление аккаунта. +- Пагинация ленты по 50 с подгрузкой вверх. + +Готово, когда экспорт с одного устройства и импорт на другом дают одинаковую ленту без дублей, повторный импорт ничего не меняет, чужой архив отклоняется до расшифровки. + +## Этап 6 — закалка + +- Rate limiting по всем правилам ADR-021, `413`, `429` с `Retry-After`. +- Проверка CSP в консоли браузера: ноль нарушений. +- Прогон модели угроз по коду: пароль не уходит, `from` ставит сервер, `Origin` проверяется, cookie с нужными флагами. +- Синхронизация документов с кодом: расхождение — правка документа через ADR или правка кода. + +## Определение готовности v1 + +Все шесть этапов; тесты Go зелёные; ручной прогон сценариев из каждого «готово, когда» на iOS Safari (PWA), Android Chrome, десктопных Chrome, Firefox, Safari; `README.md` обновлён со статуса «проектирование». + +## Правила для исполнителя + +- Сомнение в спецификации — сначала ADR, потом код. Не дописывать спецификацию молча. +- Новая зависимость, новый эндпоинт, новое поле в конверте — только через ADR. +- Каждый этап — отдельный коммит или серия коммитов с деплоем; не копить. +- Не добавлять фич сверх `ui.md`: ни тем, ни аватаров, ни «печатает», ни статусов прочтения. diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..89c143b --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,135 @@ +# Протокол + +HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: application/json`. Все остальные пути — статика клиента. Время — миллисекунды Unix. Ошибка — статус и тело `{"error": "код", "message": "текст для человека"}`. + +## Общие правила + +- Аутентификация — cookie `bare_session` (ADR-021). Без неё — `401 unauthenticated`. Публичные: `GET /api/config`, `GET /api/kdf`, `POST /api/register`, `POST /api/login`. +- На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`. +- Заголовок `X-Device: ` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. +- Тело запроса — до 32 КиБ, иначе `413`. +- Rate limiting — `429` с `Retry-After` (секунды). +- Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. + +## Типы + +``` +Envelope { + id: string // ULID, 26 символов + to: {dm: nick} | {room: roomId} + from: nick // ставит сервер + keyId: string // "dm" | keyId комнаты + iv: string // base64url, 12 байт + ct: string // base64url + ts: number // ставит сервер +} + +Room { + id: roomId, name: string, owner: nick, + members: nick[], // по joined_at + createdAt: number, + key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего + needsRekey: boolean // только в событии после выхода участника +} + +WrappedKey { to: nick, iv: string, ct: string } +``` + +## Публичные + +`GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}` + +`GET /api/kdf?nick=` → `200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. + +`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/login {nick, authKey}` → `200 {nick, publicKey, blob}` + cookie. Ошибка одна: `401 invalid_credentials`. + +## Аккаунт + +`GET /api/me` → `200 {nick, publicKey, createdAt}` + +`POST /api/logout` → `204`, cookie стирается. + +`POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}` → `204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей. + +`DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. + +`GET /api/users/{nick}` → `200 {nick, publicKey}` | `404 unknown_user`. + +## Устройства + +`POST /api/devices {id}` → `201 {id}` при создании, `200 {id}` если уже есть у этого пользователя; `409 device_conflict`, если `id` занят другим пользователем (клиент генерирует новый). Обновляет `last_seen` и привязывает текущую сессию к устройству. + +`GET /api/devices` → `200 [{id, createdAt, lastSeen, hasPush, current: bool}]`. + +`DELETE /api/devices/{id}` → `204`. Удаляет очередь, подписку и сессии, привязанные к устройству. Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`. + +`PUT /api/devices/{id}/push {subscription}` → `204`. `subscription` — объект `PushSubscription.toJSON()`. Сбрасывает `push_pending`. + +`DELETE /api/devices/{id}/push` → `204`. + +## Контакты + +`GET /api/contacts` → `200 [{nick, publicKey, createdAt}]`. + +`POST /api/contacts {nick}` → `201 {nick, publicKey}` | `200` если уже есть | `404 unknown_user` | `400 self`. + +`DELETE /api/contacts/{nick}` → `204`. Только своя строка; зеркальная у собеседника остаётся. + +## Сообщения + +`POST /api/messages {id, to, keyId, iv, ct}` → `202 {id, ts}`. + +Проверки по порядку: формат полей (`400 invalid`); время ULID в пределах ±5 минут от серверного (`400 clock_skew`); для `dm` — существование ника (`404 unknown_user`), не себе (`400 self`); для `room` — членство (`403 not_member`), `keyId` среди ключей комнаты (`400 unknown_key`); лимит (`429`). + +Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши по правилам ADR-023. + +`POST /api/ack {ids: string[]}` → `204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. + +## События + +`GET /api/events?device=` → `text/event-stream`. Заголовки ответа: `Cache-Control: no-cache`, `X-Accel-Buffering: no`. Одно соединение на устройство: новое закрывает предыдущее. + +Порядок после подключения: + +1. `push_pending` устройства сбрасывается, `last_seen` обновляется. +2. Все строки `queue` устройства по `created_at, msg_id` — каждая как `event: msg`. +3. `event: ready` с данными `{}`. +4. Живые события. +5. Каждые 20 секунд — строка `: ping`. + +События: + +``` +event: msg data: Envelope +event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey) +event: room_left data: {id} // получателя удалили или комната удалена +event: ready data: {} +``` + +`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. + +`id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления. + +## Комнаты + +`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом. + +`POST /api/rooms {name, keyId, keys: WrappedKey[]}` → `201 Room`. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. + +`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`). Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. + +`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`. + +`DELETE /api/rooms/{id}` → `204`. Только владелец. Всем участникам — `event: room_left`. + +## Коды ошибок + +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`. + +## Статика и служебное + +- `GET /` → `index.html`; `/app.css`, `/js/*.js`, `/sw.js`, `/manifest.json`, `/icons/*` — из `embed`, с `ETag` и `Cache-Control: no-cache`. `sw.js` — дополнительно `Service-Worker-Allowed: /`. +- Заголовки безопасности на всех ответах — ADR-021. +- `GET /healthz` → `200 ok`, без аутентификации, для проверок после деплоя. diff --git a/docs/storage.md b/docs/storage.md new file mode 100644 index 0000000..e2608b3 --- /dev/null +++ b/docs/storage.md @@ -0,0 +1,138 @@ +# Хранение + +## Сервер — SQLite + +Режим: `journal_mode=WAL`, `synchronous=NORMAL`, `foreign_keys=ON`, `busy_timeout=5000`. Версия схемы — `PRAGMA user_version`; миграции — `internal/store/migrations/NNN_*.sql`, встроены через `embed`, применяются по порядку при старте, каждая в транзакции. Время — миллисекунды Unix в `INTEGER`. + +### Миграция 001 + +```sql +CREATE TABLE users ( + nick TEXT PRIMARY KEY, + auth_hash BLOB NOT NULL, -- argon2id(authKey), 32 байта + auth_salt BLOB NOT NULL, -- 16 байт + auth_params TEXT NOT NULL, -- "argon2id,m=19456,t=2,p=1" + public_key TEXT NOT NULL, -- JWK, JSON + key_blob TEXT NOT NULL, -- непрозрачный JSON клиента + created_at INTEGER NOT NULL +); + +CREATE TABLE devices ( + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + push_subscription TEXT, -- JSON PushSubscription или NULL + push_pending INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX devices_nick ON devices(nick); + +CREATE TABLE sessions ( + token_hash BLOB PRIMARY KEY, -- SHA-256(токен) + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + device_id TEXT REFERENCES devices(id) ON DELETE CASCADE, -- NULL до POST /api/devices + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX sessions_nick ON sessions(nick); + +CREATE TABLE contacts ( + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + peer TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (nick, peer) +); + +CREATE TABLE rooms ( + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + name TEXT NOT NULL, + owner TEXT NOT NULL REFERENCES users(nick), + created_at INTEGER NOT NULL +); + +CREATE TABLE room_members ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + joined_at INTEGER NOT NULL, + PRIMARY KEY (room_id, nick) +); +CREATE INDEX room_members_nick ON room_members(nick); + +CREATE TABLE room_keys ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + key_id TEXT NOT NULL, + sender TEXT NOT NULL, -- кто завернул + iv TEXT NOT NULL, + ct TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (room_id, nick, key_id) +); + +CREATE TABLE queue ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + msg_id TEXT NOT NULL, + envelope TEXT NOT NULL, -- готовый JSON Envelope + created_at INTEGER NOT NULL, + PRIMARY KEY (device_id, msg_id) +); +CREATE INDEX queue_created ON queue(created_at); +``` + +Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. + +Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает комнаты, где он владелец (передача или удаление), остальное — каскад. + +### Фоновая чистка, раз в час + +```sql +DELETE FROM queue WHERE created_at < :now - 30 дней; +DELETE FROM devices WHERE last_seen < :now - 90 дней; +DELETE FROM sessions WHERE expires_at < :now; +-- room_keys: оставить два последних key_id на комнату +``` + +### Чего в базе нет + +Истории сообщений, плейнтекста, паролей, ключей в открытом виде, IP-адресов, логов доставки. + +## Клиент — IndexedDB + +База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). + +``` +meta key: string → value + deviceId, nick, publicKey (JWK), fingerprint, + privateKey (CryptoKey ECDH, non-extractable), + accountSecret (CryptoKey HKDF, non-extractable), + notificationsAsked (bool), installBannerDismissed (bool) + +chats key: id // "dm:" | "room:" + {id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[], + lastId: ULID|null, lastReadId: ULID|null, unread: number, hidden: bool} + +messages key: id (ULID) + index "chat": [chatId, id] + {id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed", + undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope} + +roomKeys key: [roomId, keyId] + {roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt} + +peers key: nick + {nick, publicKey: JWK, fingerprint, firstSeen, + pending: {publicKey, fingerprint, seenAt} | null} // новый ключ, ждущий подтверждения +``` + +Правила: + +- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Повтор доставки — `put` с тем же `id`, без дублей. +- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка → остаётся `pending` и повторяется при следующем подключении; `4xx` → `failed` с текстом ошибки. При каждой попытке отправки `pending` получает новый ULID (старая запись удаляется, новая пишется): сообщение ещё не покидало устройство, а его время должно совпадать с временем фактической отправки — иначе после долгого офлайна сервер ответит `clock_skew`. +- `unread` и `lastReadId` — локальные, на сервер не уходят. +- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`. +- Пагинация — курсор по индексу `chat` назад от последнего, по 50. +- При старте: `navigator.storage.persist()`; в настройках — `storage.estimate()`. + +## Экспорт `.bare` + +Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. diff --git a/docs/threat-model.md b/docs/threat-model.md index 09bd0b5..7973e2a 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -8,7 +8,7 @@ ## От кого защищаем -**Пассивный оператор сервера.** Админ с полным доступом к базе и диску видит: ники, argon2-хеши, зашифрованные ключевые блобы, метаданные комнат и контактов, транзитную очередь шифротекстов. Плейнтекста у него нет. +**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. **Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое. @@ -18,11 +18,15 @@ ## От кого не защищаем -**Активно-злонамеренный оператор.** Оператор, способный подменить клиентский код, может украсть ключи и плейнтекст. Это фундаментальный предел web-E2EE: клиент каждый раз загружается с сервера. Смягчение — открытый код и клиент из нескольких читаемых файлов без сборки: подмену можно заметить глазами. Гарантии нет. +**Активно-злонамеренный оператор.** Оператор, способный подменить клиентский код, может украсть ключи и плейнтекст. Это фундаментальный предел web-E2EE: клиент каждый раз загружается с сервера. Смягчение — открытый код, клиент из нескольких читаемых файлов без сборки, статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить. Гарантии нет. -**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается — серверу видно. Скрытие метаданных — не задача Bare. +**Подмена публичного ключа.** Ключи раздаёт сервер. Защита — TOFU (ADR-016): подмена возможна только при первом контакте, дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка. Защита работает ровно настолько, насколько люди сверяют отпечатки; если не сверяют — первый контакт остаётся на доверии к серверу. -**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB). Доступ к устройству — доступ к истории. Защита устройства — зона ответственности пользователя и ОС. +**Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета. + +**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств и когда они появлялись — серверу видно. Скрытие метаданных — не задача Bare. + +**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`. **Слабый пароль.** Пароль — материал ключа. Ключевой блоб хранится на сервере, и его стойкость к оффлайн-перебору равна стойкости пароля. Гарантия «оператор не читает сообщения» действует в пределах стойкости пароля пользователя: слабый пароль — слабое E2EE. Это осознанная цена парольного мультидевайса. Контрмеры (ADR-013): PBKDF2-HMAC-SHA256 с не менее чем 600 000 итераций, пароль от 12 символов, рекомендация парольной фразы в UI. @@ -32,6 +36,8 @@ ## Осознанные пределы v1 -**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1. Осознанный non-goal v1. +**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1 и завёрнутые ключи комнат. Осознанный non-goal v1. + +**Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером. **Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша. diff --git a/docs/ui.md b/docs/ui.md new file mode 100644 index 0000000..9de3fc7 --- /dev/null +++ b/docs/ui.md @@ -0,0 +1,77 @@ +# Интерфейс + +Визуальная система — `docs/identity/brief.md`, эталон экрана чата — `docs/identity/screens.html`. Здесь — состав экранов, поведение и тексты. Все тексты — русские, строчными, как в моке; заглавная только в начале предложений из нескольких слов. + +## Каркас + +Одна страница `index.html`, роутинг по hash: `#/` — список (на десктопе — первый чат), `#/dm/`, `#/room/`, `#/room//members`, `#/contact/`, `#/settings`, `#/new`. Десктоп (≥ 760 px): сайдбар 224 px + чат. Мобильный: один экран за раз, «назад» — в шапке слева. + +Без inline-стилей и inline-скриптов (CSP). Рендер — `document.createElement` и `textContent`; `innerHTML` не используется нигде: сообщения — пользовательские данные. + +## Вход и регистрация + +Одна страница, два режима переключателем «вход / регистрация». Логотип-знак и `bare` сверху. + +Поля: `ник`, `пароль`. В регистрации дополнительно `инвайт-код`, если `config.inviteRequired`, и текст под паролем: + +> пароль — это ключ шифрования, а не запись в базе. восстановления нет. не короче 12 символов; лучше — фраза из нескольких слов. + +Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». + +## Список чатов (сайдбар) + +Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка `+ новый чат`. + +## Новый чат (`#/new`) + +Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе». + +## Чат + +Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн». + +Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`. + +Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. + +Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. + +Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»). + +## Карточка контакта (`#/contact/`) + +`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка, старый и новый, кнопка «доверять новому ключу». Кнопка «убрать из списка». + +## Участники (`#/room//members`) + +Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением. Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». + +## Настройки (`#/settings`) + +- «ты: @nick», свой отпечаток. +- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка включить/выключить. На iOS вне PWA — текст про установку. +- «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»». +- «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». +- «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». +- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». +- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». +- «удалить аккаунт»: пароль + подтверждение. + +## Баннер установки (iOS) + +Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — `installBannerDismissed`, повтор не показывается. + +## Уведомления + +Запрос разрешения — после первого успешно отправленного сообщения, один раз (`notificationsAsked`). После `granted` — `pushManager.subscribe` с `vapidPublicKey` и `PUT /api/devices/{id}/push`. Отказ — молча; включить можно в настройках. + +## Сеть и состояния + +- SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. +- Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`. +- `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут». +- `401` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). + +## Доступность + +Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px. diff --git a/web/icons/icon-180.png b/web/icons/icon-180.png new file mode 100644 index 0000000000000000000000000000000000000000..6cd1f34d05a6d7430204e6a3294fda06f156484a GIT binary patch literal 1724 zcmeAS@N?(olHy`uVBq!ia0vp^TR@nD4M^IaWitX&jKx9jP7LeL$-D$|EK(yp(|mmy zw18|52FCVG1{RPKAeI7R1_q`DOmLAc3z!jXkYuUs7H0+qHX~0L$B+ufw|5QmLfu8$ zKfcz`=4eg5_~2M-#v$z_eWvw{JDoHSIe7>+P6)V`AjYWaBNcRM2JZyc3l_TqXQ{W? zyuV{!ey*oJyy)W_OXKG?|JU!BRQld}M}0|!@lFAWmILL@XA*f>{wX_TDDk*9TyQ>` zDdAL{puyB4r{#B8U>38ppu(4cLaDY3G7?=33){Oc8?-z-5WvD=8#1R&(S*HC$>GJq zML|MH%SkH1T5{w%0`y*~cGVQk#K zo7wWupZ72C?pCk9YrN^^*RRW8zPuc-wA43ym&mUlKMs}@6~!8}f6dQT-D~V>epJt@&x$E`%-Akt zdS+v~F;h!k+r5LP90I%8LaWV`9bTlpkd0|(SePBKmClk5)A>)H%RhJjY3gIsdFR#% zx}CMXzxQ|XuTM|CKMDf96}-pn@}cJ6?(OC?r7m$Zd2{S|8sX%hYsJ*!C*IjMO+ev` zP*v7tL4}fC9wu8_7#2>y5Inn&Vc})hMGIAd!QC?NRtXEoTst1+7?3+Ww1=}S{jwdm-=((jP z^0RQ)cxD_u-3lD+J3IB{e@lcSBo{* ze&1QL-&*m@&6_Vx{=fM>Yrl2%eW&y5rEWZZ`KI>#mEU!FmR0@%|D%DC(0aK29;d*r z?<`;U1IvwQi<%d685UL(Em`t&{rlIi{WW)-&)<3fyXkw&{W*KSPHp|TPKNKDyzIR< zQ7N`ek(@i8o|x6M&xwUY_Rum7ZAPbbU$=NgrWU@$;3-oX7H(Esyk3COseOayDX)eL zk&`cmcQ7n;z9EvT>hNOC$rsu#4Hp!1x;6sA_^?21k79`IR&h;jec;7=(_hqMM%k7*7szp3q{an^LB{Ts5-gDb~ literal 0 HcmV?d00001 diff --git a/web/icons/icon-192.png b/web/icons/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..6e6238fb0af9ec85f5190476a4903b3b338827cd GIT binary patch literal 1796 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE4M+yv$zcaljKx9jP7LeL$-D$|EK(yp(|mmy zw18|52FCVG1{RPKAeI7R1_q`DOmL9{3z!jXkYwIx=?@GHY*n5vjv*C{Z|@rBg@p^W zJ@lS(ODIq0bgOFk4g)=%)}srq>`-2~MPs5jvuq^y&4!6F0^g_IXqdQ%agjYY!#j_3 zqyKZ-&C1`+eE;{H_FvbMzb_K%-`{>D&oo)+N9v_JNy;DO8JTu)1Pd!1xbl_TpP@V< zi-~2%uDXYu5BPk18yJGG>)SBiXz_@H(d}|6Ol6?(MJNEh8&CwX?9gdUs6B9=T2D zpFW-a^vRQy6T6l5_wL>u9k=g~u~Af7+H2MuG5;PvR!?oPv$5g1{q)0!4I$SO9fjlH zs0S$JxL;LvXsDWdLt`QXqfnytDmg}`E3c2Zb+T|w;J6+5l|x`boVuBpibI3TjlNax z4GdqcHU=s&GN~LY3$bNlS#iCySBO(UL2%pRSAsWw{TJ70`f|8^|FrY-|9AHMD|q(r z-)sK)opuGKU-NF>t(B4e|L)GqmzSUZ`E#^<{>vZo%S953f7;jmseb)y&(57Ucgwpw%khAW!YPtJZjUsdYo%Sj7!d24>Qo&5TBd8m5L&rhjWKa0xO zm2BAmeeI*9GAD)RhWQSXQ|1OvV_-ZLmb{UNiRHwCH$9txKH4VLohqQMOvQ#l0|5m^LhsKnb%dfAhYil?6nP>Yu zH}uNfxpPC$hu7TjU(mJohGs7Vxod$rZj; z9t{kl(Hn!M7@3wVEDLZ2CZHQ#b1wn?z`M0s3+RWVVQ;3mH85nZkPQK*N3Cs1%T1VA zJe=Py=mG}YMp5gff(i~&S?r;}^hnss4PWD1J0@7!)=EkKUODsk@AZf6c5eDva&m=! zR8PvM;0OPfw=WZU08EPpcdlQbKkfPXdihWP&X!kStJ{@wLa8_Im3qKpBiBg(28P!& zXJ)uGF!Y`hNwsBUT9cl(NRW}qL@Rj8R~C*9^JaFsv2a{i;S+cbSP)gEsYwAHiA7`M z+W+;9@7}$;wp`9)2j`WAu9AmXI0W3% zH!w3Ym1G3A889-nT$Fs{(7+%YHAT^2aVMl_~MzFm? zCYXq1PRGn5)L(3wSy{G8nZPKx1hs>zBO;1zH(bclL8)zB>1yluzNhhD|JYx0e_Za} zec$`^+xI^2ecx-}D%n{SFHe>OK)iZ;K`8)`z$p&QDHQSR7#zQ*~zrJuybWh_e`JO9PIC9ivT++n`EvEtQ;sqE#u`N6rH-cxQx zm|-~{2=ZY=*|EH-G;?gN`26t>iA6+ZVGq;ORwa{hJ4rbj``^E zaL7FnasQm=JE%|8DhDo)4S9Fb9{r$-Y*xY45}|OHaj^N0=73PRn`3D{5Vb)m6zR>V zNY}bMg(7X};XzWr=bmxCSX*7@c*;35V{S8>&AF8$AHFGTw{?BBW;SQm@Au#I`=?Xe z4qNs8JOswOv^HK9wngSc-P1#wpjmdNPWN~vWpU`T!j0F+aDwIHX)iuAEhIsl+oLvZ z$ER(ljFMI)A5aUG2V!-+>}>x#ALuxFAQu`>yt5j&pXC%~IbM8e4enQ$qjptVk$mt! z=Ydjl>2zIq6Ny?0MeA@^xAc@)ocDJ1Cxj~X90a*{Nj+qWrEnCT2#IY?oZxP|5NI7sTQ zF2vR~G|fj{5pymB$wS9S#KUS4LDcYp2XJP}2%_Yt^DD5mWfcpHm=3%RBg=xk{Lyrr zu{kJxi5ti8Hj*fcd)~TVv__L@4p%R3qk#wMOGuIUPjph=?@-3{owT@IF6)#>ql%bR zA=G-0I|hQoX{}YJ@Hgcldu#UWVnZ(?Zg2I_mP*v5ho`z$Z&QG7ooV7KrFqbqMob<^ zUB4A)geif6@tnI$Mdq(X4FCLaPaI}%Pay{tQ@5WH+qSca%V&SQzEMoJWTe<~p=uS* z1#=<;=I5HM_(vGSL8O>9syl*NKe>Yg>BH{18w1)ATj2d(d0+pZfBasr>v#DlT~(RR zk^aaF`p|2Rj&4hnqC@q@p(Q8UaTt75D4n;D7C(t&UP9A(9g$CoIUC a5KvvP6@A-#<}ptAdqcf#XMrhyPs3mNiFqdg literal 0 HcmV?d00001 diff --git a/web/icons/icon.svg b/web/icons/icon.svg new file mode 100644 index 0000000..67b9d0f --- /dev/null +++ b/web/icons/icon.svg @@ -0,0 +1 @@ + diff --git a/web/icons/mark.svg b/web/icons/mark.svg new file mode 100644 index 0000000..8d29c13 --- /dev/null +++ b/web/icons/mark.svg @@ -0,0 +1 @@ + -- 2.54.0 From 32717cb7dd90ce9d48e9b5e5d2a62ee346a0fe86 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 11:49:11 +0300 Subject: [PATCH 2/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=200:=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B5=D0=BB=D0=B5=D1=82=20=D1=81=D0=B5=D1=80=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B0,=20=D1=81=D1=82=D0=B0=D1=82=D0=B8=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B8=D0=B7=20embed,=20=D0=B4=D0=B5=D0=BF=D0=BB=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=BD=D0=B0=20bare.xmatic.team?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Один бинарь на stdlib, ноль зависимостей: подкоманды serve, vapid, version; статика из embed с ETag и Cache-Control: no-cache; /healthz; заголовки безопасности ADR-021 на всех ответах, включая ошибки; лимит тела 32 КиБ. Путь в логе — в percent-форме: подделать строку журнала запросом нельзя. Ни IP, ни ника, ни query в логах нет. Клиент — знак «Скобы» и слово bare: без script-тегов, inline-стилей и внешних ресурсов. ADR-025: go:embed не выходит за каталог пакета, поэтому объявление статики живёт в корне модуля, а клиент остаётся в web/. ADR-026: 413 отдаётся кодом too_large, неподдерживаемый метод — 404; оба добавлены в перечень protocol.md. Сервер: пользователь bare, /opt/bare, /var/lib/bare, /etc/bare/env 0600, systemd-юнит, nginx с сертификатом certbot (плагин nginx, как у соседей). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- cmd/bare/main.go | 145 +++++++++++++++++++ docs/decisions/025-embed-static-root.md | 17 +++ docs/decisions/026-protocol-error-codes.md | 17 +++ docs/plan.md | 1 + docs/protocol.md | 5 +- embed.go | 12 ++ go.mod | 3 + internal/api/api.go | 127 ++++++++++++++++ internal/api/api_test.go | 160 +++++++++++++++++++++ internal/config/config.go | 57 ++++++++ internal/web/web.go | 134 +++++++++++++++++ scripts/deploy.sh | 5 + web/app.css | 56 ++++++++ web/index.html | 24 ++++ web/manifest.json | 15 ++ web/sw.js | 8 ++ 16 files changed, 784 insertions(+), 2 deletions(-) create mode 100644 cmd/bare/main.go create mode 100644 docs/decisions/025-embed-static-root.md create mode 100644 docs/decisions/026-protocol-error-codes.md create mode 100644 embed.go create mode 100644 go.mod create mode 100644 internal/api/api.go create mode 100644 internal/api/api_test.go create mode 100644 internal/config/config.go create mode 100644 internal/web/web.go create mode 100755 scripts/deploy.sh create mode 100644 web/app.css create mode 100644 web/index.html create mode 100644 web/manifest.json create mode 100644 web/sw.js diff --git a/cmd/bare/main.go b/cmd/bare/main.go new file mode 100644 index 0000000..10223ec --- /dev/null +++ b/cmd/bare/main.go @@ -0,0 +1,145 @@ +// Команда bare: сервер чата одним бинарём. +// +// bare serve запустить http-сервер +// bare vapid напечатать пару vapid-ключей +// bare version напечатать ревизию сборки +package main + +import ( + "context" + "crypto/ecdh" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "net" + "net/http" + "os" + "os/signal" + "runtime/debug" + "syscall" + "time" + + "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/web" +) + +func main() { + if len(os.Args) < 2 { + usage() + os.Exit(2) + } + + var err error + switch os.Args[1] { + case "serve": + err = serve() + case "vapid": + err = vapid() + case "version": + version() + default: + usage() + os.Exit(2) + } + if err != nil { + fmt.Fprintln(os.Stderr, "bare:", err) + os.Exit(1) + } +} + +func usage() { + fmt.Fprint(os.Stderr, `bare — сервер чата + +использование: + bare serve запустить http-сервер + bare vapid напечатать пару vapid-ключей + bare version напечатать ревизию сборки + +настройка — переменные окружения BARE_*, см. docs/deploy.md +`) +} + +func serve() error { + cfg, err := config.Load() + if err != nil { + return err + } + static, err := web.New() + if err != nil { + return err + } + + srv := &http.Server{ + Handler: api.New(static, os.Stdout), + ReadHeaderTimeout: 10 * time.Second, + IdleTimeout: 120 * time.Second, + // OPTIONS * иначе обслуживает net/http сам, в обход middleware: + // ответ уходил бы без заголовков безопасности (ADR-021). + DisableGeneralOptionsHandler: true, + // WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004). + } + + // Сначала bind, потом сообщение: строка в журнале означает, что порт занят + // нами, а не то, что мы собирались его занять. + ln, err := net.Listen("tcp", cfg.Addr) + if err != nil { + return err + } + + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() + + failed := make(chan error, 1) + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + failed <- err + } + }() + fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin) + + select { + case err := <-failed: + return err + case <-ctx.Done(): + } + + // Второй сигнал больше не перехватываем: он завершает процесс сразу. + stop() + fmt.Println("bare завершается") + shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return srv.Shutdown(shutdown) +} + +// vapid печатает пару ключей P-256 в формате, который ждёт webpush-go: +// приватный — 32 байта скаляра, публичный — 65 байт несжатой точки, +// оба base64url без паддинга. +func vapid() error { + priv, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + return err + } + b64 := base64.RawURLEncoding + fmt.Printf("BARE_VAPID_PUBLIC=%s\n", b64.EncodeToString(priv.PublicKey().Bytes())) + fmt.Printf("BARE_VAPID_PRIVATE=%s\n", b64.EncodeToString(priv.Bytes())) + return nil +} + +func version() { + fmt.Println(revision()) +} + +func revision() string { + info, ok := debug.ReadBuildInfo() + if !ok { + return "unknown" + } + for _, s := range info.Settings { + if s.Key == "vcs.revision" { + return s.Value + } + } + return "unknown" +} diff --git a/docs/decisions/025-embed-static-root.md b/docs/decisions/025-embed-static-root.md new file mode 100644 index 0000000..6024a3f --- /dev/null +++ b/docs/decisions/025-embed-static-root.md @@ -0,0 +1,17 @@ +# ADR-025: Встраивание статики объявляется в корне модуля + +## Контекст + +ADR-022 требует один артефакт деплоя: клиентская статика вкомпилирована в бинарь. Раскладка в `docs/plan.md` кладёт отдачу статики в `internal/web/`, а сам клиент — в `web/` в корне. Директива `//go:embed` встраивает только файлы каталога своего пакета и ниже: из `internal/web/` до корневого `web/` не дотянуться. Варианты — перенести клиент внутрь `internal/web/`, продублировать файлы или объявить встраивание в корне. + +## Решение + +Клиент остаётся в `web/` в корне: путь в репозитории совпадает с путём в URL, и его видно первым в дереве. Встраивание объявляется рядом — файл `embed.go` в корне модуля, `package bare`, `//go:embed web` и `var Web embed.FS`. Логики в пакете нет, только объявление. + +`internal/web/` получает подкаталог через `fs.Sub(bare.Web, "web")` и отвечает за отдачу: ETag, `Cache-Control`, `Content-Type`, 304, `Service-Worker-Allowed`. + +## Следствия + +- В корне модуля появляется пакет `bare` из одного файла — он не растёт: всё, что не объявление `embed.FS`, идёт в `internal/`. +- `internal/web/` импортирует корневой пакет; обратной зависимости нет и не будет. +- Раскладка в `docs/plan.md` дополнена строкой `embed.go`; в остальном не меняется. diff --git a/docs/decisions/026-protocol-error-codes.md b/docs/decisions/026-protocol-error-codes.md new file mode 100644 index 0000000..d12b401 --- /dev/null +++ b/docs/decisions/026-protocol-error-codes.md @@ -0,0 +1,17 @@ +# ADR-026: Код `too_large` и 404 на неподдерживаемый метод + +## Контекст + +Этап 0 обнажил два места, где код знает больше протокола. Первое: `413` описан в общих правилах (`ADR-021`, «тело запроса — до 32 КиБ»), но кода ошибки для него в перечне `protocol.md` нет, а сервер уже отдаёт `{"error": "too_large"}`. Второе: `POST` к известному пути статики отвечает `404 not_found`; в перечне правил есть только «неизвестный путь — `404 not_found`», решение про метод жило комментарием в коде. + +## Решение + +- `413` отдаётся с кодом `too_large`. Код добавлен в перечень «Коды ошибок» `protocol.md`, строка про лимит тела уточнена до `413 too_large`. +- Неподдерживаемый метод на известном пути — тоже `404 not_found`. Кода `405` в протоколе нет и не появится: клиент ходит по фиксированному набору маршрутов, а лишний код — лишняя ветка у обеих сторон. +- Тело ошибки в форме `{"error", "message"}` собирает `internal/api`; остальные пакеты пользуются его хелпером, чтобы коды не расходились между пакетами. + +## Следствия + +- Клиент разбирает `error` по перечню из `protocol.md`, и перечень исчерпывающий. +- Отсутствие `405` означает, что перебор методов не отличается от перебора путей — снаружи виден только `404`. +- `internal/web` импортирует `internal/api`; обратной зависимости нет. diff --git a/docs/plan.md b/docs/plan.md index 07c080c..718627f 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -16,6 +16,7 @@ ## Раскладка репозитория ``` +embed.go //go:embed web в корне модуля (ADR-025) cmd/bare/main.go подкоманды: serve, vapid, version internal/config/ переменные BARE_* internal/store/ SQLite, migrations/*.sql (embed), запросы diff --git a/docs/protocol.md b/docs/protocol.md index 89c143b..ff54fc2 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -7,9 +7,10 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Аутентификация — cookie `bare_session` (ADR-021). Без неё — `401 unauthenticated`. Публичные: `GET /api/config`, `GET /api/kdf`, `POST /api/register`, `POST /api/login`. - На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`. - Заголовок `X-Device: ` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. -- Тело запроса — до 32 КиБ, иначе `413`. +- Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). ## Типы @@ -126,7 +127,7 @@ event: ready data: {} ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`. ## Статика и служебное diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..60fd2f3 --- /dev/null +++ b/embed.go @@ -0,0 +1,12 @@ +// Package bare встраивает клиентскую статику в бинарь. +// +// Директива go:embed видит только каталог своего пакета и ниже, поэтому +// объявление живёт в корне модуля, а не в internal/web (ADR-025). +package bare + +import "embed" + +// Web — каталог web/ как есть: index.html, app.css, manifest.json, sw.js, icons/. +// +//go:embed web +var Web embed.FS diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..cfe0663 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/xmatic-squad/bare + +go 1.27.0 diff --git a/internal/api/api.go b/internal/api/api.go new file mode 100644 index 0000000..2310195 --- /dev/null +++ b/internal/api/api.go @@ -0,0 +1,127 @@ +// Package api собирает маршруты и общие для всех ответов правила: +// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout. +package api + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +// MaxBody — предел тела запроса, 32 КиБ (ADR-021). +const MaxBody = 32 << 10 + +// maxLogPath — сколько байт пути попадает в строку лога. +const maxLogPath = 256 + +// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет. +const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" + +// New собирает обработчик: /healthz, всё остальное — статика. +// log — куда писать строки запросов; nil отключает лог. +func New(static http.Handler, logw io.Writer) http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /healthz", healthz) + mux.Handle("/", static) + return logging(logw, headers(limitBody(mux))) +} + +func healthz(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusOK) + io.WriteString(w, "ok") +} + +// Error пишет ошибку в форме протокола: {"error": код, "message": текст}. +// Единственное место, где эта форма собирается, — коды берутся из +// перечня в docs/protocol.md. +func Error(w http.ResponseWriter, status int, code, message string) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{ + "error": code, + "message": message, + }) +} + +// NotFound — ответ на неизвестный путь и на неподдерживаемый метод +// известного пути (ADR-026). +func NotFound(w http.ResponseWriter) { + Error(w, http.StatusNotFound, "not_found", "такого пути нет") +} + +// headers ставит заголовки безопасности на каждый ответ, включая ошибки. +func headers(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("Content-Security-Policy", csp) + h.Set("Referrer-Policy", "no-referrer") + h.Set("X-Content-Type-Options", "nosniff") + next.ServeHTTP(w, r) + }) +} + +// limitBody отрезает тело на 32 КиБ. Заявленный размер сверх лимита +// отклоняется сразу, незаявленный — обрывается при чтении. +func limitBody(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.ContentLength > MaxBody { + Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ") + return + } + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, MaxBody) + } + next.ServeHTTP(w, r) + }) +} + +// logging пишет время, метод, путь, статус и длительность. +// Ни IP, ни ник, ни query в лог не попадают. +func logging(out io.Writer, next http.Handler) http.Handler { + if out == nil { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + start := time.Now() + rec := &recorder{ResponseWriter: w, status: http.StatusOK} + next.ServeHTTP(rec, r) + fmt.Fprintf(out, "%s %s %s %d %s\n", + start.Format(time.RFC3339), + r.Method, + logPath(r.URL), + rec.status, + time.Since(start).Round(time.Microsecond)) + }) +} + +// logPath даёт путь в percent-форме: перевод строки, escape-последовательности +// и прочие управляющие байты в журнал не попадают — иначе любой запрос +// подделывал бы строки в journald. Длинный путь обрезается. +func logPath(u *url.URL) string { + p := u.EscapedPath() + if len(p) > maxLogPath { + return p[:maxLogPath] + "…" + } + return p +} + +type recorder struct { + http.ResponseWriter + status int +} + +func (r *recorder) WriteHeader(status int) { + r.status = status + r.ResponseWriter.WriteHeader(status) +} + +// Unwrap отдаёт исходный ResponseWriter: через него http.ResponseController +// добирается до Flush и Hijack. Без этого SSE (docs/protocol.md, «События») +// буферизовался бы — лог стоит самым внешним слоем и виден всем маршрутам. +func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter } diff --git a/internal/api/api_test.go b/internal/api/api_test.go new file mode 100644 index 0000000..1f7f6e8 --- /dev/null +++ b/internal/api/api_test.go @@ -0,0 +1,160 @@ +package api_test + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/web" +) + +func handler(t *testing.T) http.Handler { + t.Helper() + static, err := web.New() + if err != nil { + t.Fatalf("web.New: %v", err) + } + return api.New(static, nil) +} + +func TestHealthz(t *testing.T) { + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + + res := rec.Result() + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode) + } + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("чтение тела: %v", err) + } + if string(body) != "ok" { + t.Errorf("тело: получено %q, ожидалось \"ok\"", body) + } + + want := map[string]string{ + "Content-Security-Policy": "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", + } + for header, value := range want { + if got := res.Header.Get(header); got != value { + t.Errorf("%s: получено %q, ожидалось %q", header, got, value) + } + } +} + +func TestStaticNotModified(t *testing.T) { + h := handler(t) + + first := httptest.NewRecorder() + h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil)) + if first.Code != http.StatusOK { + t.Fatalf("статус: получено %d, ожидалось 200", first.Code) + } + etag := first.Header().Get("ETag") + if etag == "" { + t.Fatal("нет ETag") + } + if got := first.Header().Get("Cache-Control"); got != "no-cache" { + t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got) + } + + req := httptest.NewRequest(http.MethodGet, "/app.css", nil) + req.Header.Set("If-None-Match", etag) + second := httptest.NewRecorder() + h.ServeHTTP(second, req) + + if second.Code != http.StatusNotModified { + t.Errorf("статус: получено %d, ожидалось 304", second.Code) + } + if second.Body.Len() != 0 { + t.Errorf("тело 304 не пустое: %q", second.Body.String()) + } + if got := second.Header().Get("ETag"); got != etag { + t.Errorf("ETag на 304: получено %q, ожидалось %q", got, etag) + } +} + +func TestBodyTooLarge(t *testing.T) { + body := strings.NewReader(strings.Repeat("a", api.MaxBody+1)) + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body)) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Fatalf("статус: получено %d, ожидалось 413", rec.Code) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("разбор тела: %v", err) + } + if got["error"] != "too_large" { + t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"]) + } +} + +// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026). +func TestStaticRejectsWrite(t *testing.T) { + rec := httptest.NewRecorder() + handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil)) + + if rec.Code != http.StatusNotFound { + t.Fatalf("статус: получено %d, ожидалось 404", rec.Code) + } + var got map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { + t.Fatalf("разбор тела: %v", err) + } + if got["error"] != "not_found" { + t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"]) + } +} + +// Путь из запроса не должен уметь дописать строку в журнал. +func TestLogPathEscaped(t *testing.T) { + static, err := web.New() + if err != nil { + t.Fatalf("web.New: %v", err) + } + var log bytes.Buffer + h := api.New(static, &log) + + target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms" + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil)) + + line := log.String() + if n := strings.Count(line, "\n"); n != 1 { + t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line) + } + if !strings.Contains(line, "%0a") { + t.Errorf("путь не в percent-форме: %q", line) + } + + log.Reset() + long := "/" + strings.Repeat("z", 4096) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil)) + if len(log.String()) > 512 { + t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String())) + } +} + +// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера +// не должна прятать Flush от http.ResponseController. +func TestFlushThroughMiddleware(t *testing.T) { + var flushErr error + h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + flushErr = http.NewResponseController(w).Flush() + }), io.Discard) + + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil)) + if flushErr != nil { + t.Errorf("Flush: %v", flushErr) + } +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..c3639df --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,57 @@ +// Package config читает конфигурацию из переменных окружения BARE_* (ADR-022). +package config + +import ( + "fmt" + "os" + "strings" +) + +// Config — всё, что сервер знает о своём окружении. +type Config struct { + Addr string // BARE_ADDR — адрес прослушивания + DB string // BARE_DB — путь к файлу SQLite + Origin string // BARE_ORIGIN — единственный допустимый Origin (ADR-021) + VAPIDPublic string // BARE_VAPID_PUBLIC + VAPIDPrivate string // BARE_VAPID_PRIVATE + VAPIDSubject string // BARE_VAPID_SUBJECT + InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию +} + +// Значения по умолчанию — локальный запуск без окружения. +const ( + defaultAddr = "127.0.0.1:8411" + defaultDB = "bare.db" + defaultOrigin = "http://127.0.0.1:8411" +) + +// Load читает окружение. Незаданная переменная берёт значение по умолчанию; +// заданная пустой — ошибка: пустой адрес, путь к базе или origin неработоспособны. +func Load() (*Config, error) { + c := &Config{ + Addr: env("BARE_ADDR", defaultAddr), + DB: env("BARE_DB", defaultDB), + Origin: env("BARE_ORIGIN", defaultOrigin), + VAPIDPublic: env("BARE_VAPID_PUBLIC", ""), + VAPIDPrivate: env("BARE_VAPID_PRIVATE", ""), + VAPIDSubject: env("BARE_VAPID_SUBJECT", ""), + InviteCode: env("BARE_INVITE_CODE", ""), + } + for _, v := range []struct{ key, value string }{ + {"BARE_ADDR", c.Addr}, + {"BARE_DB", c.DB}, + {"BARE_ORIGIN", c.Origin}, + } { + if v.value == "" { + return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key) + } + } + return c, nil +} + +func env(key, fallback string) string { + if v, ok := os.LookupEnv(key); ok { + return strings.TrimSpace(v) + } + return fallback +} diff --git a/internal/web/web.go b/internal/web/web.go new file mode 100644 index 0000000..57cd721 --- /dev/null +++ b/internal/web/web.go @@ -0,0 +1,134 @@ +// Package web отдаёт клиентскую статику из embed (ADR-025). +// +// Файлы читаются в память один раз при старте: их немного и они неизменны. +// Ни листинга каталогов, ни доступа к файловой системе сервера здесь нет — +// отдаётся только то, что вкомпилировано в бинарь. +package web + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "io/fs" + "net/http" + "path" + "strconv" + "strings" + + bare "github.com/xmatic-squad/bare" + "github.com/xmatic-squad/bare/internal/api" +) + +// Handler — карта «путь в URL → файл». +type Handler struct { + files map[string]file +} + +type file struct { + data []byte + etag string // сильный, SHA-256 содержимого, в кавычках + ctype string +} + +// New читает web/ из embed и готовит ответы. +func New() (*Handler, error) { + root, err := fs.Sub(bare.Web, "web") + if err != nil { + return nil, err + } + h := &Handler{files: make(map[string]file)} + err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + data, err := fs.ReadFile(root, p) + if err != nil { + return err + } + sum := sha256.Sum256(data) + h.files["/"+p] = file{ + data: data, + etag: `"` + hex.EncodeToString(sum[:]) + `"`, + ctype: contentType(path.Ext(p)), + } + return nil + }) + if err != nil { + return nil, err + } + index, ok := h.files["/index.html"] + if !ok { + return nil, errors.New("web: в embed нет index.html") + } + h.files["/"] = index + return h, nil +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + // Статика читается только чтением. Для прочих методов путь считается + // неизвестным: кода 405 в протоколе нет (ADR-026). + if r.Method != http.MethodGet && r.Method != http.MethodHead { + api.NotFound(w) + return + } + f, ok := h.files[r.URL.Path] + if !ok { + api.NotFound(w) + return + } + + head := w.Header() + head.Set("ETag", f.etag) + head.Set("Cache-Control", "no-cache") + if r.URL.Path == "/sw.js" { + head.Set("Service-Worker-Allowed", "/") + } + if match(r.Header.Get("If-None-Match"), f.etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + head.Set("Content-Type", f.ctype) + head.Set("Content-Length", strconv.Itoa(len(f.data))) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + w.Write(f.data) +} + +// match разбирает If-None-Match: список тегов, «*» или слабые формы W/"...". +func match(header, etag string) bool { + for _, part := range strings.Split(header, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if part == "*" || strings.TrimPrefix(part, "W/") == etag { + return true + } + } + return false +} + +func contentType(ext string) string { + switch ext { + case ".html": + return "text/html; charset=utf-8" + case ".css": + return "text/css; charset=utf-8" + case ".js": + return "text/javascript; charset=utf-8" + case ".json": + return "application/json; charset=utf-8" + case ".svg": + return "image/svg+xml" + case ".png": + return "image/png" + default: + return "application/octet-stream" + } +} diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..86f9e58 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,5 @@ +#!/bin/sh +set -eu +GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /tmp/bare ./cmd/bare +scp /tmp/bare xmatic:/tmp/bare +ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sudo systemctl restart bare && sleep 1 && curl -fsS http://127.0.0.1:8411/healthz' diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..46f9bf3 --- /dev/null +++ b/web/app.css @@ -0,0 +1,56 @@ +/* айдентика «скобы» — docs/identity/brief.md, ADR-024 */ + +:root { + --bone: #F7F5F0; + --ink: #1B1917; + --text2: #3C3B38; + --mute: #6E6D68; + --stone: #A9A59D; + --line: #E7E3DA; + --edge: #DEDCD6; + --mark: #C82D40; + --mark: oklch(55% 0.19 20); + --mono: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace; + + /* тема одна, светлая (ADR-024): системная тёмная ничего не перекрашивает */ + color-scheme: light; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bone); + color: var(--ink); + font-family: var(--mono); + font-size: 14px; + line-height: 1.55; + -webkit-text-size-adjust: 100%; +} + +/* заставка: знак и слово, больше пока ничего */ + +.boot { + min-height: 100%; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; +} + +.boot .mark { + width: 20px; + height: 20px; +} + +.boot .word { + font-size: 15px; + letter-spacing: -0.02em; +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..88370b2 --- /dev/null +++ b/web/index.html @@ -0,0 +1,24 @@ + + + + + + +bare + + + + + + +
+ + bare +
+ + diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000..8fdaf54 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "bare", + "short_name": "bare", + "lang": "ru", + "start_url": "/", + "scope": "/", + "display": "standalone", + "background_color": "#F7F5F0", + "theme_color": "#F7F5F0", + "icons": [ + { "src": "/icons/icon.svg", "type": "image/svg+xml", "sizes": "any" }, + { "src": "/icons/icon-192.png", "type": "image/png", "sizes": "192x192" }, + { "src": "/icons/icon-512.png", "type": "image/png", "sizes": "512x512" } + ] +} diff --git a/web/sw.js b/web/sw.js new file mode 100644 index 0000000..9f2a8df --- /dev/null +++ b/web/sw.js @@ -0,0 +1,8 @@ +// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023). +// версия кэша меняется при релизе. + +const CACHE = "bare-v1"; + +self.addEventListener("install", () => {}); + +self.addEventListener("activate", () => {}); -- 2.54.0 From 597c55301c73ab3ebb567bb3922232eb1678477b Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 14:06:07 +0300 Subject: [PATCH 3/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=201:=20=D0=B0?= =?UTF-8?q?=D0=BA=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D1=8B=20=E2=80=94=20argon2?= =?UTF-8?q?id,=20=D1=81=D0=B5=D1=81=D1=81=D0=B8=D0=B8,=20=D0=BA=D0=BB?= =?UTF-8?q?=D1=8E=D1=87=D0=B5=D0=B2=D0=BE=D0=B9=20=D0=B1=D0=BB=D0=BE=D0=B1?= =?UTF-8?q?,=20=D0=B2=D1=85=D0=BE=D0=B4=20=D0=B8=20=D1=80=D0=B5=D0=B3?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D1=80=D0=B0=D1=86=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite (WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена, cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта. Ник в журнал не попадает: для /api/ пишется шаблон маршрута. Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки; db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки со сменой пароля, выходом и удалением аккаунта. Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток. ADR-027: код internal для 500, причина только в журнале. ADR-028: тексты состояний клиента сведены в ui.md. ADR-029: вход под другим ником стирает историю только после подтверждения. ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах. ADR-031: служебный выход перед повторным входом не заканчивает сеанс. ADR-032: каталог состояния 0700, файлы базы 0600. Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- cmd/bare/main.go | 16 +- docs/crypto.md | 2 +- docs/decisions/027-internal-error-code.md | 18 + docs/decisions/028-client-state-texts.md | 34 ++ docs/decisions/029-login-under-other-nick.md | 21 + docs/decisions/030-kdf-iterations-bounds.md | 22 + docs/decisions/031-relogin-service-logout.md | 22 + docs/decisions/032-state-permissions.md | 22 + docs/deploy.md | 9 +- docs/protocol.md | 3 +- docs/storage.md | 2 +- docs/ui.md | 25 +- go.mod | 17 + go.sum | 52 +++ internal/api/account.go | 344 ++++++++++++++ internal/api/account_test.go | 464 +++++++++++++++++++ internal/api/api.go | 139 +++++- internal/api/api_test.go | 236 +++++++--- internal/api/valid.go | 123 +++++ internal/auth/auth.go | 96 ++++ internal/auth/auth_test.go | 85 ++++ internal/auth/session.go | 139 ++++++ internal/config/config.go | 16 + internal/store/cleanup.go | 69 +++ internal/store/migrations/001_init.sql | 73 +++ internal/store/sessions.go | 58 +++ internal/store/store.go | 149 ++++++ internal/store/store_test.go | 213 +++++++++ internal/store/users.go | 119 +++++ web/app.css | 384 ++++++++++++++- web/index.html | 21 +- web/js/api.js | 130 ++++++ web/js/crypto.js | 242 ++++++++++ web/js/db.js | 114 +++++ web/js/main.js | 338 ++++++++++++++ web/js/ui/auth.js | 188 ++++++++ web/js/ui/dom.js | 94 ++++ web/js/ui/settings.js | 179 +++++++ web/js/ui/shell.js | 38 ++ 39 files changed, 4220 insertions(+), 96 deletions(-) create mode 100644 docs/decisions/027-internal-error-code.md create mode 100644 docs/decisions/028-client-state-texts.md create mode 100644 docs/decisions/029-login-under-other-nick.md create mode 100644 docs/decisions/030-kdf-iterations-bounds.md create mode 100644 docs/decisions/031-relogin-service-logout.md create mode 100644 docs/decisions/032-state-permissions.md create mode 100644 go.sum create mode 100644 internal/api/account.go create mode 100644 internal/api/account_test.go create mode 100644 internal/api/valid.go create mode 100644 internal/auth/auth.go create mode 100644 internal/auth/auth_test.go create mode 100644 internal/auth/session.go create mode 100644 internal/store/cleanup.go create mode 100644 internal/store/migrations/001_init.sql create mode 100644 internal/store/sessions.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/store/users.go create mode 100644 web/js/api.js create mode 100644 web/js/crypto.js create mode 100644 web/js/db.js create mode 100644 web/js/main.js create mode 100644 web/js/ui/auth.js create mode 100644 web/js/ui/dom.js create mode 100644 web/js/ui/settings.js create mode 100644 web/js/ui/shell.js diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 10223ec..076e3ce 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -22,6 +22,7 @@ import ( "github.com/xmatic-squad/bare/internal/api" "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/store" "github.com/xmatic-squad/bare/internal/web" ) @@ -70,9 +71,17 @@ func serve() error { if err != nil { return err } + st, err := store.Open(cfg.DB) + if err != nil { + return err + } + defer st.Close() + for _, name := range st.Applied() { + fmt.Printf("bare применил миграцию %s\n", name) + } srv := &http.Server{ - Handler: api.New(static, os.Stdout), + Handler: api.New(cfg, st, static, os.Stdout), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // OPTIONS * иначе обслуживает net/http сам, в обход middleware: @@ -91,6 +100,11 @@ func serve() error { ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() + // Фоновая чистка живёт столько же, сколько сервер (docs/storage.md). + go st.RunCleanup(ctx, func(err error) { + fmt.Fprintln(os.Stderr, "bare:", err) + }) + failed := make(chan error, 1) go func() { if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { diff --git a/docs/crypto.md b/docs/crypto.md index 0563374..65321cb 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -13,7 +13,7 @@ authKey = HKDF-SHA256(master, salt = пусто, info = "bare-auth-v1", 32 ба kek = HKDF-SHA256(master, salt = пусто, info = "bare-kek-v1") → AES-GCM-256 ``` -`iter` — из `GET /api/kdf?nick=` перед входом, из `GET /api/config` при регистрации. Целевое значение сервера — 1 000 000, нижняя граница — 600 000 (ADR-013). WebCrypto: `deriveBits` из PBKDF2, результат импортируется `importKey("raw", …, "HKDF")`, дальше `deriveBits`/`deriveKey`. +`iter` — из `GET /api/kdf?nick=` перед входом, из `GET /api/config` при регистрации. Целевое значение сервера — 1 000 000, границы — от 600 000 до 10 000 000 (ADR-013, ADR-030). Границы держат обе стороны: сервер не принимает блоб с `iter` вне них, клиент проверяет пришедшее число до `deriveBits` и не считает по нему ничего. WebCrypto: `deriveBits` из PBKDF2, результат импортируется `importKey("raw", …, "HKDF")`, дальше `deriveBits`/`deriveKey`. `authKey` — единственное, что уходит на сервер. Пароль и `master` не покидают память клиента и не пишутся в IndexedDB. diff --git a/docs/decisions/027-internal-error-code.md b/docs/decisions/027-internal-error-code.md new file mode 100644 index 0000000..913da93 --- /dev/null +++ b/docs/decisions/027-internal-error-code.md @@ -0,0 +1,18 @@ +# ADR-027: Код `internal` для сбоя на стороне сервера + +## Контекст + +[ADR-026](026-protocol-error-codes.md) сделал перечень кодов ошибок в `protocol.md` исчерпывающим: клиент разбирает поле `error`, а не статус. Кода для `500` в перечне нет, а сбои существуют — недоступная база, ошибка записи. Этап 1 упёрся в это на первом же запросе к хранилищу: отвечать телом без кода нельзя, придумывать код в коде молча — тоже. + +## Решение + +- Сбой на стороне сервера — `500` с кодом `internal`. Сообщение общее и не зависит от причины. +- Причина уходит только в журнал сервера: ни текст ошибки базы, ни имена таблиц, ни ник клиенту не показываются. Наружу — код и статус. +- Код добавлен в перечень «Коды ошибок» `protocol.md` и в общие правила. +- `internal` — не ветка протокола, а признак поломки: ни один сценарий клиента на него не рассчитывает, повтор запроса допустим. + +## Следствия + +- Перечень кодов снова исчерпывающий: у любого ответа сервера есть разбираемый код. +- Оператор видит причину в `journalctl`, клиент — нет. +- `500` в журнале означает ошибку в коде или в окружении и разбирается, а не считается нормой. diff --git a/docs/decisions/028-client-state-texts.md b/docs/decisions/028-client-state-texts.md new file mode 100644 index 0000000..0da02dd --- /dev/null +++ b/docs/decisions/028-client-state-texts.md @@ -0,0 +1,34 @@ +# ADR-028: Тексты состояний клиента + +## Контекст + +`docs/ui.md` задаёт пять ошибок формы входа и тексты экранов. Этап 1 упёрся в состояния, которых в этих перечнях нет, а показать их надо: + +- запрос не дошёл (сети нет, сервер молчит) и ответ с кодом, на который у клиента нет сценария, — `500 internal` (ADR-027), `429`, `too_large`; +- ключевой блоб не разбирается или не расшифровывается; +- `iter` в блобе расходится с ответом `GET /api/kdf` — `docs/crypto.md` прямо требует показать это ошибкой; +- пароль короче 12 символов: проверить длину может только клиент, сервер пароля не видит (ADR-013, ADR-015); +- в настройках — несовпадение нового пароля с повтором, подтверждение опасной операции не тем паролем, ответ об успешной смене пароля. + +Придумывать эти строки в коде молча нельзя: тексты — часть интерфейса, а не деталь реализации. + +## Решение + +Перечни `docs/ui.md` дополняются разделом «Тексты состояний». Правила прежние: строчные, коротко, говорят, что случилось. Ошибка — строкой цветом `mark`, ответ об успехе — той же строкой цветом `mute`. + +- «нет соединения» — запрос не дошёл. Тот же текст, что у полосы в чате: состояние одно. +- «сервер не справился, попробуйте позже» — код ответа, на который у клиента нет сценария. +- «слишком часто, попробуйте позже» — `429 rate_limited`. +- «пароль: не короче 12 символов» — проверка клиента при регистрации и смене пароля. +- «пароли не совпадают» — новый пароль и повтор различаются. +- «ключ аккаунта повреждён» — блоб не разобран, не расшифрован или не соответствует публичному ключу аккаунта. +- «параметры ключа не совпали» — `iter` блоба не равен ответу `GET /api/kdf`. +- «неверный пароль» — `401 invalid_credentials` в настройках, где ник заведомо свой. +- «пароль изменён» — ответ на успешную смену. +- «аккаунт и вся история будут удалены навсегда.» — подтверждение удаления аккаунта. + +## Следствия + +- `docs/ui.md` остаётся единственным местом, где живут тексты интерфейса. +- Клиент разбирает `error` по перечню `docs/protocol.md`; всё, чего в перечне нет, и всё, что случилось до ответа, сводится к двум строкам — «нет соединения» и «сервер не справился, попробуйте позже». +- Новый экран приносит свои тексты в `docs/ui.md` тем же порядком: сначала документ, потом код. diff --git a/docs/decisions/029-login-under-other-nick.md b/docs/decisions/029-login-under-other-nick.md new file mode 100644 index 0000000..f71244b --- /dev/null +++ b/docs/decisions/029-login-under-other-nick.md @@ -0,0 +1,21 @@ +# ADR-029: Вход под другим ником стирает историю только после подтверждения + +## Контекст + +`docs/storage.md` держит правило «один аккаунт на браузерный профиль»: база стирается целиком, потому что история на устройстве — единственная копия. Стирание там разрешено только после подтверждения, и `docs/ui.md` описывает это подтверждение ровно в одном месте — у кнопки «выйти» в настройках. + +Экран входа в это правило не попал, а достижим с целой базой: по `docs/ui.md` («Сеть и состояния») `401` выбрасывает на экран входа и намеренно оставляет IndexedDB нетронутой. Ввод другого ника в форму входа или регистрации уносил всю историю прежнего аккаунта молча, до единого вопроса. + +Просто отказать во входе под другим ником нельзя: с экрана входа выйти из прежнего аккаунта нечем — сессии уже нет, настройки недоступны. Отказ запер бы устройство. + +## Решение + +- Если на устройстве лежат ключи другого ника, экран входа спрашивает подтверждение до вычисления ключа: «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена». Текст — в `docs/ui.md`, «Вход и регистрация». +- Подтверждение — согласие, а не стирание: база уносится там же, где и раньше, — после успешного входа или регистрации. Отказ сервера ничего не удаляет. +- Правило «один аккаунт на браузерный профиль» остаётся. Меняется одно: молчаливого стирания нет ни на одном экране. + +## Следствия + +- Единственная копия истории не исчезает без вопроса ни в одном сценарии. +- Кнопки «экспортировать» в этом подтверждении нет: экспорта нет вовсе до этапа 5. Когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`. +- Сравнивается ник, а не отпечаток: перерегистрация под тем же ником вопроса не вызовет. Смена ключа у знакомого ника — предмет TOFU (ADR-016), этап 3. diff --git a/docs/decisions/030-kdf-iterations-bounds.md b/docs/decisions/030-kdf-iterations-bounds.md new file mode 100644 index 0000000..7ce97c7 --- /dev/null +++ b/docs/decisions/030-kdf-iterations-bounds.md @@ -0,0 +1,22 @@ +# ADR-030: Верхняя граница итераций KDF и проверка границ на клиенте + +Уточняет [ADR-013](013-password-policy-kdf.md): нижняя граница остаётся, к ней добавляется верхняя. + +## Контекст + +ADR-013 задаёт целевое число итераций PBKDF2 и нижнюю границу, верхней нет. `iter` — единственное поле ключевого блоба, которое сервер разбирает сам и потом сам же раздаёт клиентам через `GET /api/kdf`, то есть отвечает за его вменяемость. Регистрация одним запросом с `iter = 10^12` принималась: аккаунт после этого нельзя ни открыть, ни удалить — обе операции начинаются с PBKDF2, который не заканчивается. + +С другой стороны, клиент брал число итераций из `GET /api/kdf` и `GET /api/config` как есть и считал по нему `authKey`, который тут же уходит на сервер. Нижнюю границу не проверял никто, кроме сервера, и только у блоба — а PBKDF2 считает клиент, и проверить параметр перед вычислением может только он. + +## Решение + +- Границы числа итераций — от 600 000 до 10 000 000. Верхняя — порядок над целевым значением 1 000 000: запас на повышение и предел, за которым вход перестаёт заканчиваться. +- Сервер отвергает ключевой блоб с `iter` вне границ: `400 invalid`, `field: blob`. +- Клиент проверяет границы до `deriveBits`: и число из `GET /api/kdf` и `GET /api/config`, и `iter` при разборе блоба. Число от сервера вне границ — «параметры ключа не совпали»; `iter` блоба вне границ — «ключ аккаунта повреждён», как любой другой дефект его формы (ADR-028). +- Границы записаны в `docs/crypto.md` рядом с целевым значением. + +## Следствия + +- Аккаунт с неоткрываемым `iter` завести нельзя. +- Ослабить KDF ответом `/api/kdf` тоже нельзя: границу держат обе стороны, и клиентская стоит раньше вычисления. Активно-злонамеренный оператор остаётся вне модели угроз — он подменит и сам клиент. +- Поднять целевое значение выше верхней границы без правки границы не выйдет. Это и требуется: такое повышение — решение, а не настройка. diff --git a/docs/decisions/031-relogin-service-logout.md b/docs/decisions/031-relogin-service-logout.md new file mode 100644 index 0000000..9357983 --- /dev/null +++ b/docs/decisions/031-relogin-service-logout.md @@ -0,0 +1,22 @@ +# ADR-031: Служебный выход перед повторным входом + +## Контекст + +Ключевой блоб отдаёт только `POST /api/login`: `GET /api/me` его не возвращает, отдельного эндпоинта в `docs/protocol.md` нет. Поэтому смена пароля проходит через вход. Вход заводит новую сессию и перезаписывает cookie, а cookie — `HttpOnly`: прежний токен после этого недостижим, закрыть ту сессию клиенту уже нечем. Оставлять её живой нельзя — украденная cookie пережила бы смену пароля, ради которой всё и затевалось. Значит, выход идёт первым, до входа. + +Но `POST /api/logout` — обычный непубличный запрос, и `401 unauthenticated` на нём по `docs/ui.md` («Сеть и состояния») выбрасывает на экран входа. Отсюда отказ. Смена пароля с неверным старым паролем закрывает сессию и падает на входе; клиент остаётся на настройках и показывает «неверный пароль». Вторая попытка, уже с верным паролем, начинается с того же служебного выхода, получает `401` — и уходит на экран входа молча: ошибка пишется в узел, которого в документе уже нет. Удаление аккаунта после такой попытки получает `401` на `DELETE /api/me` и тоже уезжает на экран входа, ничего не удалив. + +## Решение + +- Смена пароля и удаление аккаунта начинаются со служебного выхода, потом входят заново. Порядок «выход → вход» не оставляет на сервере сессию, токена от которой нет ни у кого. +- Служебный выход не заканчивает сеанс для пользователя. `401 unauthenticated` на нём означает «сессии и так нет» и считается успехом: следующий шаг открывает новую. Обработчик истёкшей сессии на таком ответе не зовётся, ошибка не бросается. Прочие отказы — нет сети, `500` — поднимаются наверх и показываются как есть: при живой сессии входить заново нельзя. +- Мимо обработчика идёт ровно этот вызов. `401 unauthenticated` на любом другом запросе по-прежнему ведёт на экран входа с сохранением IndexedDB. +- Удаление аккаунта входит прямым `POST /api/login`, без разбора блоба: аккаунт с испорченным блобом обязан удаляться. +- Кнопка «выйти» пользуется тем же вызовом: сеанс там заканчивает сам клиент — стирает базу и рисует экран входа, — а не ответ сервера. + +## Следствия + +- Сорвавшаяся смена пароля оставляет клиент без сессии, но на своём экране и со своей строкой: «неверный пароль», «нет соединения». Следующая попытка — смена пароля или удаление аккаунта — начинается с того же служебного выхода и проходит целиком. +- Перезагрузка страницы в этом состоянии показывает экран входа: `GET /api/me` отвечает `401`, IndexedDB цела. Это обычный сценарий истёкшей сессии, отдельного обхождения не требует. +- Сервер не меняется: `POST /api/logout` и `POST /api/login` работают как записано в `docs/protocol.md`. +- В `docs/ui.md` правило уточняется до `401 unauthenticated`: `401 invalid_credentials` — ошибка формы, на экран входа она не выбрасывала и раньше. diff --git a/docs/decisions/032-state-permissions.md b/docs/decisions/032-state-permissions.md new file mode 100644 index 0000000..c472753 --- /dev/null +++ b/docs/decisions/032-state-permissions.md @@ -0,0 +1,22 @@ +# ADR-032: Права на каталог состояния и файлы базы + +Уточняет [ADR-022](022-deploy-nginx-systemd.md): к юниту добавлены `StateDirectoryMode` и `UMask`. + +## Контекст + +`docs/deploy.md` задавал владельца `/var/lib/bare`, но не режим. `StateDirectory=bare` создаёт каталог с режимом 0755, SQLite кладёт базу с 0644 — на целевой машине, где живут ещё десяток сайтов и чужие сервисы, файл базы читал любой локальный пользователь. + +В базе нет плейнтекста, но есть `argon2id(authKey)` и ключевые блобы. Модель угроз прямо называет стойкость блоба к оффлайн-перебору равной стойкости пароля: раздавать этот материал соседям по машине незачем. Пункт «кража базы или бэкапа» подразумевает злоумышленника, а не любого пользователя системы. + +## Решение + +- Каталог `/var/lib/bare` — режим 0700, владелец `bare`. В юните `StateDirectoryMode=0700`, чтобы это переживало рестарт. +- Файлы базы — 0600. В юните `UMask=0077`: `bare.db`, `-wal` и `-shm` создаются закрытыми. +- `/etc/bare` — 0700, `/etc/bare/env` — 0600, владелец `root`: там VAPID-ключи. +- Бэкап наследует те же права; `VACUUM INTO` пишет в тот же каталог. + +## Следствия + +- Локальный пользователь без root не читает ни базу, ни секреты окружения. +- От оператора машины это не защищает и не должно: он остаётся вне модели угроз. +- Восстановление из бэкапа требует восстановить и права; строка про это есть в `docs/deploy.md`. diff --git a/docs/deploy.md b/docs/deploy.md index cdfb48d..ca226a1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -16,8 +16,11 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bar sudo useradd --system --home /var/lib/bare --shell /usr/sbin/nologin bare sudo mkdir -p /opt/bare /var/lib/bare /etc/bare sudo chown bare:bare /var/lib/bare +sudo chmod 0700 /var/lib/bare /etc/bare ``` +Права закрыты намеренно (ADR-032): в базе лежат `argon2id(authKey)` и ключевые блобы, машина общая. + `/etc/bare/env` (владелец root, режим 0600): ``` @@ -48,6 +51,8 @@ ExecStart=/opt/bare/bare serve Restart=on-failure RestartSec=2 StateDirectory=bare +StateDirectoryMode=0700 +UMask=0077 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes @@ -128,8 +133,8 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud ## Бэкап -`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. +`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. Копия наследует режим 0600 (ADR-032); при восстановлении в другое место права надо выставить руками. ## Логи -Сервер пишет в stdout: время, метод, путь, статус, длительность; ник — только для ошибок аутентификации по лимитам; IP не пишется. journald хранит по своим правилам. +Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. journald хранит по своим правилам. diff --git a/docs/protocol.md b/docs/protocol.md index ff54fc2..c2aee13 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,6 +10,7 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027). - Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). ## Типы @@ -127,7 +128,7 @@ event: ready data: {} ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. ## Статика и служебное diff --git a/docs/storage.md b/docs/storage.md index e2608b3..ba9204f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -98,7 +98,7 @@ DELETE FROM sessions WHERE expires_at < :now; ## Клиент — IndexedDB -База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). +База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029). ``` meta key: string → value diff --git a/docs/ui.md b/docs/ui.md index 9de3fc7..f14d895 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -16,7 +16,9 @@ > пароль — это ключ шифрования, а не запись в базе. восстановления нет. не короче 12 символов; лучше — фраза из нескольких слов. -Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». +Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний». + +Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает. ## Список чатов (сайдбар) @@ -53,9 +55,9 @@ - «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»». - «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». - «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». -- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». +- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён». - «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». -- «удалить аккаунт»: пароль + подтверждение. +- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена». ## Баннер установки (iOS) @@ -70,7 +72,22 @@ - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. - Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`. - `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут». -- `401` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). +- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет. + +## Тексты состояний + +Общие для всех форм строки (ADR-028). Ошибка — цветом `mark`, ответ об успехе — цветом `mute`, место одно. + +| состояние | текст | +|---|---| +| запрос не дошёл | «нет соединения» | +| код ответа, на который нет сценария (`internal`, `too_large`, прочее) | «сервер не справился, попробуйте позже» | +| `429 rate_limited` | «слишком часто, попробуйте позже» | +| пароль короче 12 символов | «пароль: не короче 12 символов» | +| новый пароль и повтор различаются | «пароли не совпадают» | +| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» | +| `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» | +| `401 invalid_credentials` в настройках | «неверный пароль» | ## Доступность diff --git a/go.mod b/go.mod index cfe0663..6459dbb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module github.com/xmatic-squad/bare go 1.27.0 + +require ( + golang.org/x/crypto v0.55.0 + modernc.org/sqlite v1.57.0 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.74.4 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e2c6978 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= +github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= +modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= +modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k= +modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg= +modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/api/account.go b/internal/api/account.go new file mode 100644 index 0000000..769b509 --- /dev/null +++ b/internal/api/account.go @@ -0,0 +1,344 @@ +package api + +import ( + "crypto/subtle" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/store" +) + +// GET /api/config — то, что клиенту нужно знать до входа. +func (s *server) config(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, struct { + InviteRequired bool `json:"inviteRequired"` + VAPIDPublicKey string `json:"vapidPublicKey"` + KDFIterations int `json:"kdfIterations"` + MaxMessageChars int `json:"maxMessageChars"` + }{ + InviteRequired: s.cfg.InviteCode != "", + VAPIDPublicKey: s.cfg.VAPIDPublic, + KDFIterations: config.KDFIterations, + MaxMessageChars: config.MaxMessageChars, + }) +} + +// GET /api/kdf?nick= — сколько итераций PBKDF2 брать для этого ника. +// +// Значение лежит открытым полем iter в ключевом блобе: другого места +// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение +// тем же статусом 200 — ответ не раскрывает, существует ли ник (ADR-015). +func (s *server) kdf(w http.ResponseWriter, r *http.Request) { + iterations := config.KDFIterations + if nick := r.URL.Query().Get("nick"); validNick(nick) { + u, err := s.st.User(r.Context(), nick) + switch { + case err == nil: + if iter, err := blobIterations(u.KeyBlob); err == nil { + iterations = iter + } + case errors.Is(err, store.ErrNotFound): + // молча: целевое значение + default: + s.internal(w, r, err) + return + } + } + writeJSON(w, http.StatusOK, struct { + Iterations int `json:"iterations"` + }{iterations}) +} + +// POST /api/register — регистрация. Сервер проверяет только форму: +// содержимое блоба и стойкость пароля ему недоступны by design. +func (s *server) register(w http.ResponseWriter, r *http.Request) { + var in struct { + Nick string `json:"nick"` + AuthKey string `json:"authKey"` + PublicKey json.RawMessage `json:"publicKey"` + Blob string `json:"blob"` + Invite string `json:"invite"` + } + if !decode(w, r, &in) { + 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 + } + } + if !validNick(in.Nick) { + Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _") + return + } + key, ok := authKey(in.AuthKey) + if !ok { + Invalid(w, "authKey", "authKey — не 32 байта base64url") + return + } + public, err := publicKeyJSON(in.PublicKey) + if err != nil { + Invalid(w, "publicKey", err.Error()) + return + } + if _, err := blobIterations(in.Blob); err != nil { + Invalid(w, "blob", err.Error()) + return + } + + cred, err := auth.Hash(key) + if err != nil { + s.internal(w, r, err) + return + } + err = s.st.CreateUser(r.Context(), store.User{ + Nick: in.Nick, + Cred: cred, + PublicKey: public, + KeyBlob: in.Blob, + CreatedAt: time.Now().UnixMilli(), + }) + if errors.Is(err, store.ErrNickTaken) { + Error(w, http.StatusConflict, "nick_taken", "ник занят") + return + } + if err != nil { + s.internal(w, r, err) + return + } + if err := s.startSession(w, r, in.Nick); err != nil { + s.internal(w, r, err) + return + } + writeJSON(w, http.StatusCreated, struct { + Nick string `json:"nick"` + }{in.Nick}) +} + +// POST /api/login — вход. Ошибка одна на все случаи: неверный ник, +// неверный authKey и кривая форма неразличимы снаружи. +func (s *server) login(w http.ResponseWriter, r *http.Request) { + var in struct { + Nick string `json:"nick"` + AuthKey string `json:"authKey"` + } + if !decode(w, r, &in) { + return + } + key, ok := authKey(in.AuthKey) + if !ok || !validNick(in.Nick) { + invalidCredentials(w) + return + } + u, err := s.st.User(r.Context(), in.Nick) + if errors.Is(err, store.ErrNotFound) { + // Считаем впустую: вход с несуществующим ником не должен + // отвечать заметно быстрее входа с неверным authKey. + auth.Waste(key) + invalidCredentials(w) + return + } + if err != nil { + s.internal(w, r, err) + return + } + valid, rehash := auth.Verify(key, u.Cred) + if !valid { + invalidCredentials(w) + return + } + if rehash { + // Параметры отстали от текущих (ADR-021). Не удалось перехешировать — + // не повод отказывать во входе: старый хеш остаётся рабочим. + if cred, err := auth.Hash(key); err != nil { + s.report(r, err) + } else if err := s.st.SetAuth(r.Context(), u.Nick, cred); err != nil { + s.report(r, err) + } + } + if err := s.startSession(w, r, u.Nick); err != nil { + s.internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + Blob string `json:"blob"` + }{u.Nick, json.RawMessage(u.PublicKey), u.KeyBlob}) +} + +// GET /api/me — кто вошёл. +func (s *server) me(w http.ResponseWriter, r *http.Request) { + u, ok := s.self(w, r) + if !ok { + return + } + writeJSON(w, http.StatusOK, struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` + }{u.Nick, json.RawMessage(u.PublicKey), u.CreatedAt}) +} + +// POST /api/logout — выход на этом устройстве. +func (s *server) logout(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + if err := s.st.DeleteSession(r.Context(), sess.TokenHash); err != nil { + s.internal(w, r, err) + return + } + auth.ClearCookie(w) + noContent(w) +} + +// POST /api/password — смена пароля и повышение итераций: одна операция +// (ADR-015). Хеш и блоб меняются в одной транзакции. +func (s *server) password(w http.ResponseWriter, r *http.Request) { + var in struct { + AuthKey string `json:"authKey"` + NewAuthKey string `json:"newAuthKey"` + Blob string `json:"blob"` + LogoutOthers bool `json:"logoutOthers"` + } + if !decode(w, r, &in) { + return + } + u, ok := s.self(w, r) + if !ok { + return + } + if !s.confirm(w, in.AuthKey, u) { + return + } + newKey, ok := authKey(in.NewAuthKey) + if !ok { + Invalid(w, "newAuthKey", "newAuthKey — не 32 байта base64url") + return + } + if _, err := blobIterations(in.Blob); err != nil { + Invalid(w, "blob", err.Error()) + return + } + cred, err := auth.Hash(newKey) + if err != nil { + s.internal(w, r, err) + return + } + sess, _ := auth.From(r) + if err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash); err != nil { + s.internal(w, r, err) + return + } + noContent(w) +} + +// DELETE /api/me — удаление аккаунта, подтверждённое authKey. +func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) { + var in struct { + AuthKey string `json:"authKey"` + } + if !decode(w, r, &in) { + return + } + u, ok := s.self(w, r) + if !ok { + return + } + if !s.confirm(w, in.AuthKey, u) { + return + } + // Устройства, сессии, контакты, членство и очереди уносит каскад. + // Комнаты, где пользователь владелец, требуют передачи владения + // (ADR-018) — это этап 3, до появления комнат случай не наступает. + if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil { + s.internal(w, r, err) + return + } + auth.ClearCookie(w) + noContent(w) +} + +// GET /api/users/{nick} — публичный ключ собеседника. Доверие к нему — +// TOFU на клиенте (ADR-016). +func (s *server) user(w http.ResponseWriter, r *http.Request) { + nick := r.PathValue("nick") + if !validNick(nick) { + unknownUser(w) + return + } + u, err := s.st.User(r.Context(), nick) + if errors.Is(err, store.ErrNotFound) { + unknownUser(w) + return + } + if err != nil { + s.internal(w, r, err) + return + } + writeJSON(w, http.StatusOK, struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + }{u.Nick, json.RawMessage(u.PublicKey)}) +} + +// self читает пользователя сессии. Строки нет — сессия недействительна: +// аккаунт удалён на другом устройстве. +func (s *server) self(w http.ResponseWriter, r *http.Request) (store.User, bool) { + sess, _ := auth.From(r) + u, err := s.st.User(r.Context(), sess.Nick) + if errors.Is(err, store.ErrNotFound) { + Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход") + return store.User{}, false + } + if err != nil { + s.internal(w, r, err) + return store.User{}, false + } + return u, true +} + +// confirm проверяет authKey — подтверждение опасной операции. +func (s *server) confirm(w http.ResponseWriter, given string, u store.User) bool { + key, ok := authKey(given) + if !ok { + invalidCredentials(w) + return false + } + if valid, _ := auth.Verify(key, u.Cred); !valid { + invalidCredentials(w) + return false + } + return true +} + +// startSession выдаёт сессию и ставит cookie. +func (s *server) startSession(w http.ResponseWriter, r *http.Request, nick string) error { + token, hash, err := auth.NewToken() + if err != nil { + return err + } + now := time.Now() + expires := now.Add(auth.TTL) + if err := s.st.CreateSession(r.Context(), hash, nick, now.UnixMilli(), expires.UnixMilli()); err != nil { + return err + } + auth.SetCookie(w, token, expires) + return nil +} + +func invalidCredentials(w http.ResponseWriter) { + Error(w, http.StatusUnauthorized, "invalid_credentials", "неверный ник или пароль") +} + +func unknownUser(w http.ResponseWriter) { + Error(w, http.StatusNotFound, "unknown_user", "такого ника нет") +} diff --git a/internal/api/account_test.go b/internal/api/account_test.go new file mode 100644 index 0000000..40c77c2 --- /dev/null +++ b/internal/api/account_test.go @@ -0,0 +1,464 @@ +package api_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/config" +) + +// bytesOf — детерминированные «случайные» байты: содержимое сервер +// не проверяет, ему важна только форма. +func bytesOf(n int, seed byte) string { + raw := make([]byte, n) + for i := range raw { + raw[i] = seed + byte(i) + } + return base64.RawURLEncoding.EncodeToString(raw) +} + +// blobOf — ключевой блоб в форме docs/crypto.md. +func blobOf(iter int) string { + return fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`, iter, bytesOf(12, 7), bytesOf(48, 11)) +} + +func jwk() map[string]string { + return map[string]string{"kty": "EC", "crv": "P-256", "x": bytesOf(32, 3), "y": bytesOf(32, 5)} +} + +func account(nick string) map[string]any { + return map[string]any{ + "nick": nick, + "authKey": bytesOf(32, 1), + "publicKey": jwk(), + "blob": blobOf(config.KDFIterations), + } +} + +// signUp регистрирует аккаунт и отдаёт cookie сессии. +func (e *env) signUp(nick string) *http.Cookie { + e.t.Helper() + rec := e.do(http.MethodPost, "/api/register", account(nick)) + expect(e.t, rec, http.StatusCreated, "") + return e.cookie(rec) +} + +func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie { + e.t.Helper() + for _, c := range rec.Result().Cookies() { + if c.Name == "bare_session" { + return c + } + } + e.t.Fatal("в ответе нет cookie bare_session") + return nil +} + +func TestConfig(t *testing.T) { + e := newEnv(t) + rec := e.do(http.MethodGet, "/api/config", nil) + expect(t, rec, http.StatusOK, "") + + var got struct { + InviteRequired bool `json:"inviteRequired"` + VAPIDPublicKey string `json:"vapidPublicKey"` + KDFIterations int `json:"kdfIterations"` + MaxMessageChars int `json:"maxMessageChars"` + } + decodeBody(t, rec, &got) + if got.InviteRequired { + t.Error("inviteRequired: получено true, ожидалось false") + } + if got.VAPIDPublicKey != "vapid" { + t.Errorf("vapidPublicKey: получено %q", got.VAPIDPublicKey) + } + if got.KDFIterations != 1_000_000 { + t.Errorf("kdfIterations: получено %d, ожидалось 1000000", got.KDFIterations) + } + if got.MaxMessageChars != 4000 { + t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars) + } +} + +func TestRegisterAndLogin(t *testing.T) { + e := newEnv(t) + + rec := e.do(http.MethodPost, "/api/register", account("marta")) + expect(t, rec, http.StatusCreated, "") + var created struct { + Nick string `json:"nick"` + } + decodeBody(t, rec, &created) + if created.Nick != "marta" { + t.Errorf("ник в ответе: получено %q", created.Nick) + } + + c := e.cookie(rec) + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" { + t.Errorf("флаги cookie: %+v", c) + } + if c.MaxAge < 89*24*3600 || c.MaxAge > 90*24*3600 { + t.Errorf("срок cookie: получено %d секунд, ожидалось около 90 суток", c.MaxAge) + } + + // Занятый ник. + expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusConflict, "nick_taken") + + // Сессия из регистрации работает. + me := e.do(http.MethodGet, "/api/me", nil, with(c)) + expect(t, me, http.StatusOK, "") + var self struct { + Nick string `json:"nick"` + PublicKey map[string]string `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` + } + decodeBody(t, me, &self) + if self.Nick != "marta" || self.PublicKey["crv"] != "P-256" || self.CreatedAt == 0 { + t.Errorf("GET /api/me: %+v", self) + } + + // Вход тем же authKey отдаёт публичный ключ и блоб. + login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + expect(t, login, http.StatusOK, "") + var in struct { + Nick string `json:"nick"` + PublicKey map[string]string `json:"publicKey"` + Blob string `json:"blob"` + } + decodeBody(t, login, &in) + if in.Nick != "marta" || in.Blob != blobOf(config.KDFIterations) || in.PublicKey["x"] != bytesOf(32, 3) { + t.Errorf("вход: %+v", in) + } + if _, ok := in.PublicKey["d"]; ok { + t.Error("в публичном ключе есть d") + } + e.cookie(login) + + // Неверный authKey и несуществующий ник неразличимы. + bad := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}) + expect(t, bad, http.StatusUnauthorized, "invalid_credentials") + none := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "никого", "authKey": bytesOf(32, 1)}) + expect(t, none, http.StatusUnauthorized, "invalid_credentials") + unknown := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 1)}) + expect(t, unknown, http.StatusUnauthorized, "invalid_credentials") +} + +func TestRegisterRejects(t *testing.T) { + private := jwk() + private["d"] = bytesOf(32, 13) + + cases := []struct { + name string + change func(map[string]any) + status int + code string + field string + }{ + {"кривой ник", func(m map[string]any) { m["nick"] = "Марта" }, http.StatusBadRequest, "invalid_nick", ""}, + {"короткий ник", func(m map[string]any) { m["nick"] = "m" }, http.StatusBadRequest, "invalid_nick", ""}, + {"ник с заглавной", func(m map[string]any) { m["nick"] = "Marta" }, http.StatusBadRequest, "invalid_nick", ""}, + {"короткий authKey", func(m map[string]any) { m["authKey"] = bytesOf(16, 1) }, http.StatusBadRequest, "invalid", "authKey"}, + {"authKey не base64url", func(m map[string]any) { m["authKey"] = strings.Repeat("=", 44) }, http.StatusBadRequest, "invalid", "authKey"}, + {"приватный ключ в jwk", func(m map[string]any) { m["publicKey"] = private }, http.StatusBadRequest, "invalid", "publicKey"}, + {"чужая кривая", func(m map[string]any) { + k := jwk() + k["crv"] = "P-384" + m["publicKey"] = k + }, http.StatusBadRequest, "invalid", "publicKey"}, + {"нет публичного ключа", func(m map[string]any) { delete(m, "publicKey") }, http.StatusBadRequest, "invalid", "publicKey"}, + {"слабый iter", func(m map[string]any) { m["blob"] = blobOf(599_999) }, http.StatusBadRequest, "invalid", "blob"}, + // Неподъёмный iter сервер отдал бы клиентам из GET /api/kdf (ADR-030). + {"неподъёмный iter", func(m map[string]any) { + m["blob"] = blobOf(config.KDFMaxIterations + 1) + }, http.StatusBadRequest, "invalid", "blob"}, + {"iter в триллион", func(m map[string]any) { m["blob"] = blobOf(1_000_000_000_000) }, http.StatusBadRequest, "invalid", "blob"}, + {"дробный iter", func(m map[string]any) { + m["blob"] = `{"v":1,"iter":1e6,"iv":"` + bytesOf(12, 7) + `","ct":"` + bytesOf(48, 11) + `"}` + }, http.StatusBadRequest, "invalid", "blob"}, + {"версия блоба", func(m map[string]any) { + m["blob"] = strings.Replace(blobOf(config.KDFIterations), `"v":1`, `"v":2`, 1) + }, http.StatusBadRequest, "invalid", "blob"}, + {"блоб больше 8 КиБ", func(m map[string]any) { + m["blob"] = fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`, + config.KDFIterations, bytesOf(12, 7), strings.Repeat("a", 8<<10)) + }, http.StatusBadRequest, "invalid", "blob"}, + {"блоб не json", func(m map[string]any) { m["blob"] = "не json" }, http.StatusBadRequest, "invalid", "blob"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + body := account("marta") + c.change(body) + rec := e.do(http.MethodPost, "/api/register", body) + expect(t, rec, c.status, c.code) + if c.field != "" { + var got struct { + Field string `json:"field"` + } + decodeBody(t, rec, &got) + if got.Field != c.field { + t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field) + } + } + // Ни одна из этих регистраций не должна была создать аккаунт. + expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}), + http.StatusUnauthorized, "invalid_credentials") + }) + } +} + +func TestRegisterBadJSON(t *testing.T) { + e := newEnv(t) + expect(t, e.do(http.MethodPost, "/api/register", "{"), http.StatusBadRequest, "bad_json") +} + +func TestInvite(t *testing.T) { + e := invited(t, "секрет") + + rec := e.do(http.MethodGet, "/api/config", nil) + var cfg struct { + InviteRequired bool `json:"inviteRequired"` + } + decodeBody(t, rec, &cfg) + if !cfg.InviteRequired { + t.Error("inviteRequired: получено false, ожидалось true") + } + + expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusForbidden, "invite_required") + + wrong := account("marta") + wrong["invite"] = "не секрет" + expect(t, e.do(http.MethodPost, "/api/register", wrong), http.StatusForbidden, "invalid_invite") + + right := account("marta") + right["invite"] = "секрет" + expect(t, e.do(http.MethodPost, "/api/register", right), http.StatusCreated, "") +} + +func TestKDF(t *testing.T) { + e := newEnv(t) + + // Неизвестный ник — целевое значение, тем же статусом. + for _, nick := range []string{"marta", "", "МАРТА", strings.Repeat("x", 40)} { + rec := e.do(http.MethodGet, "/api/kdf?nick="+nick, nil) + expect(t, rec, http.StatusOK, "") + var got struct { + Iterations int `json:"iterations"` + } + decodeBody(t, rec, &got) + if got.Iterations != config.KDFIterations { + t.Errorf("iterations для %q: получено %d, ожидалось %d", nick, got.Iterations, config.KDFIterations) + } + } + + // Известный ник — iter из его блоба. + body := account("marta") + body["blob"] = blobOf(700_000) + expect(t, e.do(http.MethodPost, "/api/register", body), http.StatusCreated, "") + + rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil) + expect(t, rec, http.StatusOK, "") + var got struct { + Iterations int `json:"iterations"` + } + decodeBody(t, rec, &got) + if got.Iterations != 700_000 { + t.Errorf("iterations: получено %d, ожидалось 700000", got.Iterations) + } +} + +func TestOrigin(t *testing.T) { + e := newEnv(t) + body := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)} + + expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("")), http.StatusForbidden, "bad_origin") + expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("https://зло.example")), http.StatusForbidden, "bad_origin") + expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("null")), http.StatusForbidden, "bad_origin") + + // GET без Origin работает. + expect(t, e.do(http.MethodGet, "/api/config", nil), http.StatusOK, "") + expect(t, e.do(http.MethodGet, "/", nil), http.StatusOK, "") + + // Свой Origin проходит: дальше — обычная ошибка входа, не 403. + expect(t, e.do(http.MethodPost, "/api/login", body), http.StatusUnauthorized, "invalid_credentials") +} + +func TestPasswordChange(t *testing.T) { + e := newEnv(t) + first := e.signUp("marta") + + // Второе устройство: свой вход, своя сессия. + 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) + + newBlob := blobOf(config.KDFIterations) + change := map[string]any{ + "authKey": bytesOf(32, 1), + "newAuthKey": bytesOf(32, 9), + "blob": newBlob, + "logoutOthers": true, + } + + // Без сессии — 401 unauthenticated, а не invalid_credentials. + expect(t, e.do(http.MethodPost, "/api/password", change), http.StatusUnauthorized, "unauthenticated") + + // Неверный старый authKey. + wrong := map[string]any{"authKey": bytesOf(32, 42), "newAuthKey": bytesOf(32, 9), "blob": newBlob} + expect(t, e.do(http.MethodPost, "/api/password", wrong, with(second)), http.StatusUnauthorized, "invalid_credentials") + + // Слабый новый блоб не принимается. + weak := map[string]any{"authKey": bytesOf(32, 1), "newAuthKey": bytesOf(32, 9), "blob": blobOf(599_999)} + expect(t, e.do(http.MethodPost, "/api/password", weak, with(second)), http.StatusBadRequest, "invalid") + + expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "") + + // Текущая сессия жива, остальные — нет. + expect(t, e.do(http.MethodGet, "/api/me", nil, with(second)), http.StatusOK, "") + expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusUnauthorized, "unauthenticated") + + // Старый authKey больше не подходит, новый отдаёт новый блоб. + expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}), + http.StatusUnauthorized, "invalid_credentials") + fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}) + expect(t, fresh, http.StatusOK, "") + var got struct { + Blob string `json:"blob"` + } + decodeBody(t, fresh, &got) + if got.Blob != newBlob { + t.Errorf("блоб после смены пароля: получено %q", got.Blob) + } +} + +// Повышение итераций — та же операция без выхода на других устройствах. +func TestPasswordKeepsOtherSessions(t *testing.T) { + e := newEnv(t) + first := e.signUp("marta") + login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + second := e.cookie(login) + + change := map[string]any{ + "authKey": bytesOf(32, 1), + "newAuthKey": bytesOf(32, 9), + "blob": blobOf(config.KDFIterations), + "logoutOthers": false, + } + expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "") + expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusOK, "") +} + +func TestSessionRequired(t *testing.T) { + e := newEnv(t) + e.signUp("marta") + + for _, target := range []string{"/api/me", "/api/users/marta"} { + expect(t, e.do(http.MethodGet, target, nil), http.StatusUnauthorized, "unauthenticated") + } + expect(t, e.do(http.MethodPost, "/api/logout", nil), http.StatusUnauthorized, "unauthenticated") + + garbage := &http.Cookie{Name: "bare_session", Value: "not-a-token"} + expect(t, e.do(http.MethodGet, "/api/me", nil, with(garbage)), http.StatusUnauthorized, "unauthenticated") + + stranger := &http.Cookie{Name: "bare_session", Value: bytesOf(32, 77)} + expect(t, e.do(http.MethodGet, "/api/me", nil, with(stranger)), http.StatusUnauthorized, "unauthenticated") +} + +func TestLogout(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + + rec := e.do(http.MethodPost, "/api/logout", nil, with(c)) + expect(t, rec, http.StatusNoContent, "") + if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 { + t.Errorf("cookie не стёрта: %+v", cleared) + } + expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated") +} + +func TestUsers(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + + rec := e.do(http.MethodGet, "/api/users/marta", nil, with(c)) + expect(t, rec, http.StatusOK, "") + var got struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + } + decodeBody(t, rec, &got) + if got.Nick != "marta" || !strings.Contains(string(got.PublicKey), `"P-256"`) { + t.Errorf("ответ: %s", rec.Body.String()) + } + + expect(t, e.do(http.MethodGet, "/api/users/petya", nil, with(c)), http.StatusNotFound, "unknown_user") + expect(t, e.do(http.MethodGet, "/api/users/МАРТА", nil, with(c)), http.StatusNotFound, "unknown_user") +} + +func TestDeleteMe(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + + expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 42)}, with(c)), + http.StatusUnauthorized, "invalid_credentials") + + rec := e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(c)) + expect(t, rec, http.StatusNoContent, "") + if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 { + t.Errorf("cookie не стёрта: %+v", cleared) + } + + // Сессия ушла каскадом, ник свободен. + expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}), + http.StatusUnauthorized, "invalid_credentials") + expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "") +} + +// Ник не должен попадать в журнал (docs/deploy.md, «Логи»). +func TestNickStaysOutOfLog(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + e.log.Reset() + + e.do(http.MethodGet, "/api/users/marta", nil, with(c)) + e.do(http.MethodGet, "/api/kdf?nick=marta", nil) + e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + + if strings.Contains(e.log.String(), "marta") { + t.Errorf("ник в журнале: %q", e.log.String()) + } + if !strings.Contains(e.log.String(), "/api/users/{nick}") { + t.Errorf("шаблон маршрута не в журнале: %q", e.log.String()) + } +} + +// Отказ до маршрутизации — шаблона ещё нет, а путь с ником в журнал +// попадать не должен всё равно (docs/deploy.md, «Логи»). +func TestNickStaysOutOfLogBeforeRouting(t *testing.T) { + e := newEnv(t) + + // 403 bad_origin: любой не-GET со стороннего сайта. + e.do(http.MethodPost, "/api/users/marta", nil, withOrigin("https://зло.example")) + e.do(http.MethodDelete, "/api/contacts/marta", nil, withOrigin("")) + // 413 too_large: тело больше предела, ответ до маршрутизации. + e.do(http.MethodGet, "/api/users/marta", strings.Repeat("a", api.MaxBody+1)) + + line := e.log.String() + if strings.Count(line, "\n") != 3 { + t.Fatalf("строк в журнале: %q", line) + } + if strings.Contains(line, "marta") { + t.Errorf("ник в журнале: %q", line) + } + if strings.Count(line, "/api/ ") != 3 { + t.Errorf("вместо пути ожидалось \"/api/\": %q", line) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 2310195..cd6f305 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,14 +1,21 @@ // Package api собирает маршруты и общие для всех ответов правила: -// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout. +// заголовки безопасности (ADR-021), проверку Origin, лимит тела запроса, +// лог в stdout. package api import ( "encoding/json" + "errors" "fmt" "io" "net/http" "net/url" + "strings" "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/store" ) // MaxBody — предел тела запроса, 32 КиБ (ADR-021). @@ -20,13 +27,42 @@ const maxLogPath = 256 // csp — политика из ADR-021. HSTS ставит nginx, здесь его нет. const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" -// New собирает обработчик: /healthz, всё остальное — статика. -// log — куда писать строки запросов; nil отключает лог. -func New(static http.Handler, logw io.Writer) http.Handler { +// server — общее для обработчиков: настройки, база, куда писать журнал. +type server struct { + cfg *config.Config + st *store.Store + logw io.Writer +} + +// New собирает обработчик: /api/, /healthz, всё остальное — статика. +// logw — куда писать строки запросов и причины отказов; nil отключает лог. +func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler { + s := &server{cfg: cfg, st: st, logw: logw} + fail := auth.Fail{Error: Error, Internal: s.internal} + // Сессия проверяется на всех непубличных маршрутах (docs/protocol.md). + private := auth.Require(st, fail) + mux := http.NewServeMux() mux.HandleFunc("GET /healthz", healthz) + + mux.HandleFunc("GET /api/config", s.config) + mux.HandleFunc("GET /api/kdf", s.kdf) + mux.HandleFunc("POST /api/register", s.register) + mux.HandleFunc("POST /api/login", s.login) + + mux.Handle("GET /api/me", private(http.HandlerFunc(s.me))) + mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe))) + mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout))) + mux.Handle("POST /api/password", private(http.HandlerFunc(s.password))) + mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user))) + + // Всё прочее под /api/ — 404, включая неподдерживаемый метод известного + // пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт + // запросам к /api/ уходить в обработчик статики. + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) }) mux.Handle("/", static) - return logging(logw, headers(limitBody(mux))) + + return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))) } func healthz(w http.ResponseWriter, r *http.Request) { @@ -36,17 +72,23 @@ func healthz(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") } +// errorBody — единственная форма ошибки в протоколе. +type errorBody struct { + Error string `json:"error"` + Field string `json:"field,omitempty"` + Message string `json:"message"` +} + // Error пишет ошибку в форме протокола: {"error": код, "message": текст}. // Единственное место, где эта форма собирается, — коды берутся из // перечня в docs/protocol.md. func Error(w http.ResponseWriter, status int, code, message string) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{ - "error": code, - "message": message, - }) + writeJSON(w, status, errorBody{Error: code, Message: message}) +} + +// Invalid — 400 invalid с полем, на котором остановилась валидация. +func Invalid(w http.ResponseWriter, field, message string) { + writeJSON(w, http.StatusBadRequest, errorBody{Error: "invalid", Field: field, Message: message}) } // NotFound — ответ на неизвестный путь и на неподдерживаемый метод @@ -55,6 +97,50 @@ func NotFound(w http.ResponseWriter) { Error(w, http.StatusNotFound, "not_found", "такого пути нет") } +// internal — 500: сбой на нашей стороне. Клиенту уходит только код, +// причина — в журнал сервера (ADR-027). +func (s *server) internal(w http.ResponseWriter, r *http.Request, err error) { + s.report(r, err) + Error(w, http.StatusInternalServerError, "internal", "сервер не справился, попробуйте позже") +} + +// report кладёт причину в журнал. Ник в строку не попадает: пишется +// шаблон маршрута (docs/deploy.md, «Логи»). +func (s *server) report(r *http.Request, err error) { + if s.logw == nil { + return + } + fmt.Fprintf(s.logw, "%s %s %s ошибка: %v\n", + time.Now().Format(time.RFC3339), r.Method, logTarget(r), err) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + json.NewEncoder(w).Encode(v) +} + +func noContent(w http.ResponseWriter) { + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusNoContent) +} + +// decode разбирает тело запроса в v. Ответ об ошибке уже написан, +// если вернулось false. +func decode(w http.ResponseWriter, r *http.Request, v any) bool { + if err := json.NewDecoder(r.Body).Decode(v); err != nil { + var large *http.MaxBytesError + if errors.As(err, &large) { + Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ") + return false + } + Error(w, http.StatusBadRequest, "bad_json", "тело запроса — не json") + return false + } + return true +} + // headers ставит заголовки безопасности на каждый ответ, включая ошибки. func headers(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -94,12 +180,39 @@ func logging(out io.Writer, next http.Handler) http.Handler { fmt.Fprintf(out, "%s %s %s %d %s\n", start.Format(time.RFC3339), r.Method, - logPath(r.URL), + logTarget(r), rec.status, time.Since(start).Round(time.Microsecond)) }) } +// logTarget — что пишется в журнал вместо пути. Для маршрутов /api/ — +// шаблон, а не путь: ник из GET /api/users/{nick} в журнал попадать +// не должен (docs/deploy.md, «Логи»). Для статики — сам путь: там +// пользовательских данных нет, а знать, какой файл не нашёлся, полезно. +// Шаблон известен после маршрутизации, поэтому вызывается после ответа. +// +// Шаблона может не быть вовсе: проверка Origin и предел тела отвечают +// раньше маршрутизации. Тогда для /api/ пишется голое "/api/" — путь +// с ником в журнал не уходит и в этом случае. +func logTarget(r *http.Request) string { + if p := patternPath(r.Pattern); strings.HasPrefix(p, "/api/") { + return p + } + if strings.HasPrefix(r.URL.Path, "/api/") { + return "/api/" + } + return logPath(r.URL) +} + +// patternPath отрезает от шаблона метод: "GET /api/users/{nick}" → путь. +func patternPath(pattern string) string { + if i := strings.LastIndexByte(pattern, ' '); i >= 0 { + return pattern[i+1:] + } + return pattern +} + // logPath даёт путь в percent-форме: перевод строки, escape-последовательности // и прочие управляющие байты в журнал не попадают — иначе любой запрос // подделывал бы строки в journald. Длинный путь обрезается. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 1f7f6e8..fdc07b0 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -6,37 +6,140 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "github.com/xmatic-squad/bare/internal/api" + "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/store" "github.com/xmatic-squad/bare/internal/web" ) -func handler(t *testing.T) http.Handler { +const origin = "https://bare.test" + +// env — сервер на временной базе плюс журнал, в который он пишет. +type env struct { + t *testing.T + h http.Handler + st *store.Store + log *bytes.Buffer +} + +func newEnv(t *testing.T) *env { return invited(t, "") } + +// invited — сервер на временной базе; непустой code включает инвайты. +func invited(t *testing.T, code string) *env { t.Helper() static, err := web.New() if err != nil { t.Fatalf("web.New: %v", err) } - return api.New(static, nil) + st, err := store.Open(filepath.Join(t.TempDir(), "bare.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + t.Cleanup(func() { st.Close() }) + + cfg := &config.Config{ + Addr: "127.0.0.1:0", + DB: "bare.db", + Origin: origin, + VAPIDPublic: "vapid", + InviteCode: code, + } + e := &env{t: t, st: st, log: &bytes.Buffer{}} + e.h = api.New(cfg, st, static, e.log) + return e +} + +// do отправляет запрос. Origin для методов кроме GET и HEAD ставится сам — +// без него любой такой запрос получил бы 403 (ADR-021). +func (e *env) do(method, target string, body any, opts ...func(*http.Request)) *httptest.ResponseRecorder { + e.t.Helper() + var reader io.Reader + switch v := body.(type) { + case nil: + case string: + reader = strings.NewReader(v) + default: + raw, err := json.Marshal(v) + if err != nil { + e.t.Fatalf("сборка тела: %v", err) + } + reader = bytes.NewReader(raw) + } + r := httptest.NewRequest(method, target, reader) + if method != http.MethodGet && method != http.MethodHead { + r.Header.Set("Origin", origin) + } + for _, opt := range opts { + opt(r) + } + rec := httptest.NewRecorder() + e.h.ServeHTTP(rec, r) + return rec +} + +func with(c *http.Cookie) func(*http.Request) { + return func(r *http.Request) { + if c != nil { + r.AddCookie(c) + } + } +} + +func withOrigin(value string) func(*http.Request) { + return func(r *http.Request) { + if value == "" { + r.Header.Del("Origin") + return + } + r.Header.Set("Origin", value) + } +} + +// code достаёт код ошибки из тела ответа. +func code(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("разбор тела %q: %v", rec.Body.String(), err) + } + return body.Error +} + +// expect проверяет статус и код ошибки; код "" — ответ без ошибки. +func expect(t *testing.T, rec *httptest.ResponseRecorder, status int, errCode string) { + t.Helper() + if rec.Code != status { + t.Fatalf("статус: получено %d (%s), ожидалось %d", rec.Code, rec.Body.String(), status) + } + if errCode != "" { + if got := code(t, rec); got != errCode { + t.Errorf("код ошибки: получено %q, ожидалось %q", got, errCode) + } + } +} + +func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) { + t.Helper() + if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil { + t.Fatalf("разбор тела %q: %v", rec.Body.String(), err) + } } func TestHealthz(t *testing.T) { - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + e := newEnv(t) + rec := e.do(http.MethodGet, "/healthz", nil) - res := rec.Result() - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode) + if rec.Code != http.StatusOK { + t.Errorf("статус: получено %d, ожидалось 200", rec.Code) } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatalf("чтение тела: %v", err) - } - if string(body) != "ok" { - t.Errorf("тело: получено %q, ожидалось \"ok\"", body) + if rec.Body.String() != "ok" { + t.Errorf("тело: получено %q, ожидалось \"ok\"", rec.Body.String()) } want := map[string]string{ @@ -45,17 +148,16 @@ func TestHealthz(t *testing.T) { "X-Content-Type-Options": "nosniff", } for header, value := range want { - if got := res.Header.Get(header); got != value { + if got := rec.Header().Get(header); got != value { t.Errorf("%s: получено %q, ожидалось %q", header, got, value) } } } func TestStaticNotModified(t *testing.T) { - h := handler(t) + e := newEnv(t) - first := httptest.NewRecorder() - h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil)) + first := e.do(http.MethodGet, "/app.css", nil) if first.Code != http.StatusOK { t.Fatalf("статус: получено %d, ожидалось 200", first.Code) } @@ -67,11 +169,9 @@ func TestStaticNotModified(t *testing.T) { t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got) } - req := httptest.NewRequest(http.MethodGet, "/app.css", nil) - req.Header.Set("If-None-Match", etag) - second := httptest.NewRecorder() - h.ServeHTTP(second, req) - + second := e.do(http.MethodGet, "/app.css", nil, func(r *http.Request) { + r.Header.Set("If-None-Match", etag) + }) if second.Code != http.StatusNotModified { t.Errorf("статус: получено %d, ожидалось 304", second.Code) } @@ -84,52 +184,44 @@ func TestStaticNotModified(t *testing.T) { } func TestBodyTooLarge(t *testing.T) { - body := strings.NewReader(strings.Repeat("a", api.MaxBody+1)) - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body)) + e := newEnv(t) + rec := e.do(http.MethodPost, "/api/login", strings.Repeat("a", api.MaxBody+1)) + expect(t, rec, http.StatusRequestEntityTooLarge, "too_large") +} - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("статус: получено %d, ожидалось 413", rec.Code) - } - var got map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("разбор тела: %v", err) - } - if got["error"] != "too_large" { - t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"]) - } +// Тело без заявленной длины обрывается при чтении — тем же кодом. +func TestBodyTooLargeUnannounced(t *testing.T) { + e := newEnv(t) + // Тело — валидный json, чтобы разбор дошёл до предела чтения, а не + // споткнулся о первый же байт. + body := `{"nick":"` + strings.Repeat("a", api.MaxBody) + `"}` + rec := e.do(http.MethodPost, "/api/login", nil, func(r *http.Request) { + r.Body = io.NopCloser(strings.NewReader(body)) + r.ContentLength = -1 + }) + expect(t, rec, http.StatusRequestEntityTooLarge, "too_large") } // Неподдерживаемый метод на известном пути — 404 not_found (ADR-026). func TestStaticRejectsWrite(t *testing.T) { - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil)) + e := newEnv(t) + expect(t, e.do(http.MethodPost, "/app.css", nil), http.StatusNotFound, "not_found") +} - if rec.Code != http.StatusNotFound { - t.Fatalf("статус: получено %d, ожидалось 404", rec.Code) - } - var got map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("разбор тела: %v", err) - } - if got["error"] != "not_found" { - t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"]) - } +func TestMethodOnKnownAPIPathIs404(t *testing.T) { + e := newEnv(t) + expect(t, e.do(http.MethodPost, "/api/me", nil), http.StatusNotFound, "not_found") + expect(t, e.do(http.MethodGet, "/api/nope", nil), http.StatusNotFound, "not_found") } // Путь из запроса не должен уметь дописать строку в журнал. func TestLogPathEscaped(t *testing.T) { - static, err := web.New() - if err != nil { - t.Fatalf("web.New: %v", err) - } - var log bytes.Buffer - h := api.New(static, &log) + e := newEnv(t) target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms" - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil)) + e.do(http.MethodGet, target, nil) - line := log.String() + line := e.log.String() if n := strings.Count(line, "\n"); n != 1 { t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line) } @@ -137,19 +229,25 @@ func TestLogPathEscaped(t *testing.T) { t.Errorf("путь не в percent-форме: %q", line) } - log.Reset() - long := "/" + strings.Repeat("z", 4096) - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil)) - if len(log.String()) > 512 { - t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String())) + e.log.Reset() + e.do(http.MethodGet, "/"+strings.Repeat("z", 4096), nil) + if len(e.log.String()) > 512 { + t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(e.log.String())) } } // SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера // не должна прятать Flush от http.ResponseController. func TestFlushThroughMiddleware(t *testing.T) { + st, err := store.Open(filepath.Join(t.TempDir(), "bare.db")) + if err != nil { + t.Fatalf("store.Open: %v", err) + } + defer st.Close() + var flushErr error - h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cfg := &config.Config{Addr: "127.0.0.1:0", DB: "bare.db", Origin: origin} + h := api.New(cfg, st, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { flushErr = http.NewResponseController(w).Flush() }), io.Discard) @@ -158,3 +256,17 @@ func TestFlushThroughMiddleware(t *testing.T) { t.Errorf("Flush: %v", flushErr) } } + +func TestInternalErrorHasCode(t *testing.T) { + e := newEnv(t) + // Закрытая база — единственный простой способ получить сбой хранилища. + e.st.Close() + rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil) + expect(t, rec, http.StatusInternalServerError, "internal") + if !strings.Contains(e.log.String(), "ошибка:") { + t.Errorf("причина не попала в журнал: %q", e.log.String()) + } + if strings.Contains(rec.Body.String(), "sql") { + t.Errorf("причина уехала клиенту: %q", rec.Body.String()) + } +} diff --git a/internal/api/valid.go b/internal/api/valid.go new file mode 100644 index 0000000..0244736 --- /dev/null +++ b/internal/api/valid.go @@ -0,0 +1,123 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + + "github.com/xmatic-squad/bare/internal/config" +) + +// Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму: +// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»). +const ( + authKeyLen = 32 // байт + ivLen = 12 // байт + minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает + maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md +) + +// b64 — кодировка бинарных полей протокола: base64url без паддинга. +var b64 = base64.RawURLEncoding + +// nickRe — ник по ADR-019: только строчные, без регистровых коллизий. +var nickRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`) + +func validNick(nick string) bool { return nickRe.MatchString(nick) } + +// decodeExactly разбирает base64url и требует ровно n байт. +func decodeExactly(s string, n int) ([]byte, bool) { + raw, err := b64.DecodeString(s) + if err != nil || len(raw) != n { + return nil, false + } + return raw, true +} + +// authKey разбирает authKey клиента: base64url ровно 32 байта. +func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) } + +// jwkPublic — публичный ключ в том виде, в каком сервер его хранит +// и отдаёт: четыре поля и ничего больше. +type jwkPublic struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` +} + +// publicKeyJSON проверяет JWK и отдаёт его канонический JSON. +// +// Поле d — приватный ключ. Его наличие означает, что клиент собирается +// отдать серверу материал, которого у сервера не должно быть ни при каких +// условиях, поэтому такой запрос отвергается целиком, а не чистится молча. +// Всё, что не kty, crv, x и y, отбрасывается: хранится ровно то, что нужно. +func publicKeyJSON(raw json.RawMessage) (string, error) { + var in struct { + Kty string `json:"kty"` + Crv string `json:"crv"` + X string `json:"x"` + Y string `json:"y"` + D json.RawMessage `json:"d"` + } + if len(raw) == 0 { + return "", errors.New("нет публичного ключа") + } + if err := json.Unmarshal(raw, &in); err != nil { + return "", errors.New("публичный ключ — не jwk") + } + if in.D != nil { + return "", errors.New("приватному ключу на сервере не место") + } + if in.Kty != "EC" || in.Crv != "P-256" { + return "", errors.New("ожидается ключ ec p-256") + } + if _, ok := decodeExactly(in.X, 32); !ok { + return "", errors.New("x — не 32 байта base64url") + } + if _, ok := decodeExactly(in.Y, 32); !ok { + return "", errors.New("y — не 32 байта base64url") + } + out, err := json.Marshal(jwkPublic{Kty: in.Kty, Crv: in.Crv, X: in.X, Y: in.Y}) + if err != nil { + return "", err + } + return string(out), nil +} + +// blobIterations проверяет форму ключевого блоба (docs/crypto.md, +// «Ключевой блоб») и отдаёт iter. Это единственное поле блоба, которое +// сервер читает: его же отдаёт GET /api/kdf. Всё остальное — непрозрачный +// шифротекст. +func blobIterations(blob string) (int, error) { + if blob == "" { + return 0, errors.New("нет ключевого блоба") + } + if len(blob) > maxBlob { + return 0, errors.New("ключевой блоб больше 8 КиБ") + } + var b struct { + V int `json:"v"` + Iter int `json:"iter"` + IV string `json:"iv"` + CT string `json:"ct"` + } + if err := json.Unmarshal([]byte(blob), &b); err != nil { + return 0, errors.New("ключевой блоб — не json") + } + if b.V != 1 { + return 0, fmt.Errorf("версия блоба %d, ожидается 1", b.V) + } + if b.Iter < config.KDFMinIterations || b.Iter > config.KDFMaxIterations { + return 0, fmt.Errorf("iter вне границ %d…%d", config.KDFMinIterations, config.KDFMaxIterations) + } + if _, ok := decodeExactly(b.IV, ivLen); !ok { + return 0, errors.New("iv — не 12 байт base64url") + } + if ct, err := b64.DecodeString(b.CT); err != nil || len(ct) < minCTLen { + return 0, errors.New("ct — не base64url или слишком короткий") + } + return b.Iter, nil +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b417387 --- /dev/null +++ b/internal/auth/auth.go @@ -0,0 +1,96 @@ +// Package auth — argon2id, сессии, cookie и проверки на входе (ADR-021). +// +// Пароля здесь нет: клиент присылает authKey, выведенный из пароля +// (ADR-015). Сервер хранит argon2id от authKey — чтобы дамп базы не давал +// готового ключа для входа. +package auth + +import ( + "crypto/rand" + "crypto/subtle" + "fmt" + + "golang.org/x/crypto/argon2" + + "github.com/xmatic-squad/bare/internal/store" +) + +// Параметры argon2id из ADR-021. Вход — 32 случайных байта с точки зрения +// сервера, поэтому параметры умеренные. +const ( + SaltLen = 16 // байт + KeyLen = 32 // байт +) + +// Params — параметры одного хеша. Пишутся рядом с ним в users.auth_params +// и читаются оттуда при проверке: повышение параметров — перехеш при +// очередном входе, а не миграция всех аккаунтов разом. +type Params struct { + Memory uint32 // КиБ + Time uint32 + Threads uint8 +} + +// Current — параметры для новых хешей. +var Current = Params{Memory: 19456, Time: 2, Threads: 1} + +// String — форма записи в базе: "argon2id,m=19456,t=2,p=1". +func (p Params) String() string { + return fmt.Sprintf("argon2id,m=%d,t=%d,p=%d", p.Memory, p.Time, p.Threads) +} + +// ParseParams разбирает строку из users.auth_params. +func ParseParams(s string) (Params, error) { + var p Params + n, err := fmt.Sscanf(s, "argon2id,m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads) + if err != nil || n != 3 { + return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s) + } + if p.Memory == 0 || p.Time == 0 || p.Threads == 0 { + return Params{}, fmt.Errorf("auth: нулевой параметр в %q", s) + } + // Sscanf не жалуется на хвост после последнего числа; сверка с обратной + // записью делает разбор точным. + if p.String() != s { + return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s) + } + return p, nil +} + +// Hash считает argon2id от authKey с текущими параметрами и новой солью. +func Hash(authKey []byte) (store.Credential, error) { + salt := make([]byte, SaltLen) + if _, err := rand.Read(salt); err != nil { + return store.Credential{}, fmt.Errorf("auth: соль: %w", err) + } + return store.Credential{ + Hash: derive(authKey, salt, Current), + Salt: salt, + Params: Current.String(), + }, nil +} + +// Verify сверяет authKey с хешем из базы. Второе значение — нужен ли +// перехеш: параметры записи отстали от текущих. +func Verify(authKey []byte, cred store.Credential) (ok, rehash bool) { + p, err := ParseParams(cred.Params) + if err != nil || len(cred.Hash) != KeyLen || len(cred.Salt) == 0 { + return false, false + } + got := derive(authKey, cred.Salt, p) + if subtle.ConstantTimeCompare(got, cred.Hash) != 1 { + return false, false + } + return true, p != Current || len(cred.Salt) != SaltLen +} + +// Waste считает столько же, сколько Verify, и выбрасывает результат. +// Вход с несуществующим ником не должен отвечать заметно быстрее входа +// с неверным authKey: одна ошибка на все случаи (docs/protocol.md). +func Waste(authKey []byte) { + derive(authKey, make([]byte, SaltLen), Current) +} + +func derive(authKey, salt []byte, p Params) []byte { + return argon2.IDKey(authKey, salt, p.Time, p.Memory, p.Threads, KeyLen) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..a65d475 --- /dev/null +++ b/internal/auth/auth_test.go @@ -0,0 +1,85 @@ +package auth + +import ( + "crypto/sha256" + "encoding/base64" + "testing" + + "github.com/xmatic-squad/bare/internal/store" +) + +func TestParams(t *testing.T) { + if got := Current.String(); got != "argon2id,m=19456,t=2,p=1" { + t.Errorf("запись параметров: получено %q", got) + } + got, err := ParseParams("argon2id,m=19456,t=2,p=1") + if err != nil || got != Current { + t.Errorf("разбор параметров: получено %+v, %v", got, err) + } + for _, bad := range []string{"", "argon2id", "argon2i,m=1,t=1,p=1", "argon2id,m=0,t=2,p=1", "argon2id,m=19456,t=2,p=1,junk"} { + if _, err := ParseParams(bad); err == nil { + t.Errorf("%q разобрано, ожидалась ошибка", bad) + } + } +} + +func TestHashVerify(t *testing.T) { + key := []byte("тридцать два байта authKey, ну почти") + cred, err := Hash(key) + if err != nil { + t.Fatalf("Hash: %v", err) + } + if len(cred.Hash) != KeyLen || len(cred.Salt) != SaltLen || cred.Params != Current.String() { + t.Fatalf("хеш: %d байт, соль %d байт, параметры %q", len(cred.Hash), len(cred.Salt), cred.Params) + } + if ok, rehash := Verify(key, cred); !ok || rehash { + t.Errorf("верный authKey: ok=%v rehash=%v", ok, rehash) + } + if ok, _ := Verify([]byte("другой ключ"), cred); ok { + t.Error("неверный authKey принят") + } + // Битая строка параметров — не повод считать хеш подошедшим. + broken := cred + broken.Params = "argon2id" + if ok, _ := Verify(key, broken); ok { + t.Error("хеш с неразобранными параметрами принят") + } +} + +func TestVerifyAsksForRehash(t *testing.T) { + key := []byte("authKey") + old := Params{Memory: 8192, Time: 1, Threads: 1} + cred := store.Credential{ + Hash: derive(key, make([]byte, SaltLen), old), + Salt: make([]byte, SaltLen), + Params: old.String(), + } + ok, rehash := Verify(key, cred) + if !ok || !rehash { + t.Errorf("устаревшие параметры: ok=%v rehash=%v, ожидалось true/true", ok, rehash) + } +} + +func TestToken(t *testing.T) { + token, hash, err := NewToken() + if err != nil { + t.Fatalf("NewToken: %v", err) + } + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || len(raw) != TokenLen { + t.Fatalf("токен: %q (%v)", token, err) + } + sum := sha256.Sum256(raw) + if string(hash) != string(sum[:]) { + t.Error("в базу уходит не sha-256 токена") + } + got, ok := TokenHash(token) + if !ok || string(got) != string(hash) { + t.Error("TokenHash не совпал с NewToken") + } + for _, bad := range []string{"", "не base64!", base64.RawURLEncoding.EncodeToString([]byte("коротко"))} { + if _, ok := TokenHash(bad); ok { + t.Errorf("мусор %q принят за токен", bad) + } + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..7370127 --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,139 @@ +package auth + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/store" +) + +// Сессия по ADR-021: токен 32 случайных байта, в базе SHA-256 от него, +// в cookie — base64url. Срок 90 дней без продления. +const ( + CookieName = "bare_session" + TokenLen = 32 + TTL = 90 * 24 * time.Hour +) + +// NewToken выдаёт токен для cookie и его SHA-256 для базы. +func NewToken() (token string, hash []byte, err error) { + raw := make([]byte, TokenLen) + if _, err := rand.Read(raw); err != nil { + return "", nil, fmt.Errorf("auth: токен: %w", err) + } + sum := sha256.Sum256(raw) + return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil +} + +// TokenHash разбирает токен из cookie в его SHA-256. Мусор — false. +func TokenHash(token string) ([]byte, bool) { + raw, err := base64.RawURLEncoding.DecodeString(token) + if err != nil || len(raw) != TokenLen { + return nil, false + } + sum := sha256.Sum256(raw) + return sum[:], true +} + +// SetCookie ставит cookie сессии. Secure стоит всегда: браузеры считают +// localhost и 127.0.0.1 доверенным происхождением, поэтому локальная +// разработка по http этим не ломается. +func SetCookie(w http.ResponseWriter, token string, expires time.Time) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: token, + Path: "/", + Expires: expires, + MaxAge: int(time.Until(expires).Seconds()), + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// ClearCookie стирает cookie сессии. +func ClearCookie(w http.ResponseWriter) { + http.SetCookie(w, &http.Cookie{ + Name: CookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteStrictMode, + }) +} + +// Fail — как auth отвечает на отказ. Тело ошибки в форме протокола +// собирает internal/api (ADR-026), а импортировать его отсюда нельзя: +// api импортирует auth. Поэтому хелперы передаются значениями. +type Fail struct { + // Error пишет ошибку протокола: статус, код, текст для человека. + Error func(w http.ResponseWriter, status int, code, message string) + // Internal пишет 500 и кладёт причину в журнал сервера. + Internal func(w http.ResponseWriter, r *http.Request, err error) +} + +// Origin — второй барьер CSRF рядом с SameSite=Strict (ADR-021). +// На всех методах кроме GET и HEAD заголовок Origin обязан равняться +// origin сервера; отсутствующий Origin — тоже отказ. +func Origin(origin string, fail Fail) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + if r.Header.Get("Origin") != origin { + fail.Error(w, http.StatusForbidden, "bad_origin", "запрос не с этого сайта") + return + } + } + next.ServeHTTP(w, r) + }) + } +} + +// Require пропускает дальше только запросы с живой сессией и кладёт её +// в контекст. Без сессии — 401 unauthenticated. +func Require(st *store.Store, fail Fail) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sess, err := session(r, st) + if errors.Is(err, store.ErrNotFound) { + fail.Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход") + return + } + if err != nil { + fail.Internal(w, r, err) + return + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess))) + }) + } +} + +// session читает cookie и находит сессию. Нет cookie, мусор в ней +// и истёкшая сессия неразличимы: store.ErrNotFound. +func session(r *http.Request, st *store.Store) (store.Session, error) { + c, err := r.Cookie(CookieName) + if err != nil { + return store.Session{}, store.ErrNotFound + } + hash, ok := TokenHash(c.Value) + if !ok { + return store.Session{}, store.ErrNotFound + } + return st.Session(r.Context(), hash, time.Now().UnixMilli()) +} + +type sessionKey struct{} + +// From отдаёт сессию из контекста. Её кладёт Require. +func From(r *http.Request) (store.Session, bool) { + sess, ok := r.Context().Value(sessionKey{}).(store.Session) + return sess, ok +} diff --git a/internal/config/config.go b/internal/config/config.go index c3639df..411e370 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,22 @@ const ( defaultOrigin = "http://127.0.0.1:8411" ) +// Параметры, которые сервер сообщает клиенту в GET /api/config. Это +// константы, а не переменные окружения: их значения — часть криптосистемы +// (ADR-013) и протокола (ADR-021), а не настройка машины. +const ( + // KDFIterations — целевое число итераций PBKDF2 на клиенте. + KDFIterations = 1_000_000 + // KDFMinIterations — нижняя граница: блоб с меньшим iter сервер не примет. + KDFMinIterations = 600_000 + // KDFMaxIterations — верхняя граница (ADR-030). Сервер сам раздаёт iter + // из блоба в GET /api/kdf, и с неподъёмным значением аккаунт нельзя + // ни открыть, ни удалить: обе операции начинаются с PBKDF2. + KDFMaxIterations = 10_000_000 + // MaxMessageChars — предел текста сообщения. + MaxMessageChars = 4000 +) + // Load читает окружение. Незаданная переменная берёт значение по умолчанию; // заданная пустой — ошибка: пустой адрес, путь к базе или origin неработоспособны. func Load() (*Config, error) { diff --git a/internal/store/cleanup.go b/internal/store/cleanup.go new file mode 100644 index 0000000..5fa1914 --- /dev/null +++ b/internal/store/cleanup.go @@ -0,0 +1,69 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Сроки хранения из docs/storage.md. +const ( + queueTTL = 30 * 24 * time.Hour // недоставленное сообщение + deviceTTL = 90 * 24 * time.Hour // молчащее устройство + roomKeysKept = 2 // ключей комнаты на комнату + + cleanupEvery = time.Hour +) + +// RunCleanup чистит базу раз в час, пока не отменён ctx. Первый проход — +// сразу при старте: сервер, который перезапускают чаще раза в час, иначе +// не чистился бы никогда. Ошибку отдаёт report; nil — молчать. +func (s *Store) RunCleanup(ctx context.Context, report func(error)) { + tick := time.NewTicker(cleanupEvery) + defer tick.Stop() + for { + if err := s.Cleanup(ctx, time.Now()); err != nil && report != nil && ctx.Err() == nil { + report(err) + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +// Cleanup выполняет один проход чистки (docs/storage.md, «Фоновая чистка»). +func (s *Store) Cleanup(ctx context.Context, now time.Time) error { + ms := now.UnixMilli() + steps := []struct { + what string + query string + args []any + }{ + {"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}}, + {"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}}, + {"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}}, + // Ключи комнат: у каждой комнаты остаются два последних key_id. + // Возраст key_id — время его самой поздней записи: ключ раздаётся + // участникам не одной строкой, а по строке на участника. + {"ключи комнат", ` + DELETE FROM room_keys WHERE (room_id, key_id) NOT IN ( + SELECT room_id, key_id FROM ( + SELECT room_id, key_id, + ROW_NUMBER() OVER ( + PARTITION BY room_id + ORDER BY MAX(created_at) DESC, key_id DESC + ) AS rn + FROM room_keys + GROUP BY room_id, key_id + ) WHERE rn <= ? + )`, []any{roomKeysKept}}, + } + for _, step := range steps { + if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil { + return fmt.Errorf("store: чистка (%s): %w", step.what, err) + } + } + return nil +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..0f9fbed --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,73 @@ +-- Полная схема v1 (docs/storage.md, ADR-020). Время — миллисекунды Unix. +-- Таблицы этапов 2–3 создаются сразу: схема одна, миграция одна. + +CREATE TABLE users ( + nick TEXT PRIMARY KEY, + auth_hash BLOB NOT NULL, -- argon2id(authKey), 32 байта + auth_salt BLOB NOT NULL, -- 16 байт + auth_params TEXT NOT NULL, -- "argon2id,m=19456,t=2,p=1" + public_key TEXT NOT NULL, -- JWK, JSON + key_blob TEXT NOT NULL, -- непрозрачный JSON клиента + created_at INTEGER NOT NULL +); + +CREATE TABLE devices ( + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL, + push_subscription TEXT, -- JSON PushSubscription или NULL + push_pending INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX devices_nick ON devices(nick); + +CREATE TABLE sessions ( + token_hash BLOB PRIMARY KEY, -- SHA-256(токен) + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + device_id TEXT REFERENCES devices(id) ON DELETE CASCADE, -- NULL до POST /api/devices + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL +); +CREATE INDEX sessions_nick ON sessions(nick); + +CREATE TABLE contacts ( + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + peer TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + created_at INTEGER NOT NULL, + PRIMARY KEY (nick, peer) +); + +CREATE TABLE rooms ( + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + name TEXT NOT NULL, + owner TEXT NOT NULL REFERENCES users(nick), + created_at INTEGER NOT NULL +); + +CREATE TABLE room_members ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + joined_at INTEGER NOT NULL, + PRIMARY KEY (room_id, nick) +); +CREATE INDEX room_members_nick ON room_members(nick); + +CREATE TABLE room_keys ( + room_id TEXT NOT NULL REFERENCES rooms(id) ON DELETE CASCADE, + nick TEXT NOT NULL REFERENCES users(nick) ON DELETE CASCADE, + key_id TEXT NOT NULL, + sender TEXT NOT NULL, -- кто завернул + iv TEXT NOT NULL, + ct TEXT NOT NULL, + created_at INTEGER NOT NULL, + PRIMARY KEY (room_id, nick, key_id) +); + +CREATE TABLE queue ( + device_id TEXT NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + msg_id TEXT NOT NULL, + envelope TEXT NOT NULL, -- готовый JSON Envelope + created_at INTEGER NOT NULL, + PRIMARY KEY (device_id, msg_id) +); +CREATE INDEX queue_created ON queue(created_at); diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..ac758a9 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// Session — строка sessions. Токена здесь нет: в базе лежит только +// SHA-256 от него (ADR-021). +type Session struct { + TokenHash []byte + Nick string + DeviceID string // пусто, пока сессия не привязана к устройству + CreatedAt int64 + ExpiresAt int64 +} + +// CreateSession записывает сессию. tokenHash — SHA-256 токена из cookie. +func (s *Store) CreateSession(ctx context.Context, tokenHash []byte, nick string, createdAt, expiresAt int64) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO sessions (token_hash, nick, device_id, created_at, expires_at) + VALUES (?, ?, NULL, ?, ?)`, tokenHash, nick, createdAt, expiresAt) + if err != nil { + return fmt.Errorf("store: создание сессии: %w", err) + } + return nil +} + +// Session читает живую сессию по хешу токена. Истёкшая считается +// отсутствующей: чистит её фоновая задача, а не запрос. +func (s *Store) Session(ctx context.Context, tokenHash []byte, now int64) (Session, error) { + var ( + sess Session + device sql.NullString + ) + err := s.db.QueryRowContext(ctx, ` + SELECT token_hash, nick, device_id, created_at, expires_at + FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now). + Scan(&sess.TokenHash, &sess.Nick, &device, &sess.CreatedAt, &sess.ExpiresAt) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("store: чтение сессии: %w", err) + } + sess.DeviceID = device.String + return sess, nil +} + +// DeleteSession удаляет одну сессию — выход на этом устройстве. +func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil { + return fmt.Errorf("store: удаление сессии: %w", err) + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c8a0e41 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,149 @@ +// Package store — SQLite: открытие базы, миграции, запросы (ADR-020). +// +// В базе только шифротексты и метаданные: истории сообщений, плейнтекста +// и паролей здесь нет и не будет (docs/storage.md). +package store + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "net/url" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed migrations +var migrations embed.FS + +// Ошибки, которые обработчикам нужно различать. Остальное — внутренние сбои. +var ( + // ErrNotFound — строки нет. + ErrNotFound = errors.New("store: не найдено") + // ErrNickTaken — ник уже занят. + ErrNickTaken = errors.New("store: ник занят") +) + +// Store — база и её единственное соединение на запись. +type Store struct { + db *sql.DB + applied []string +} + +// Applied — миграции, применённые при этом открытии базы. Пусто, если +// схема уже была свежей. +func (s *Store) Applied() []string { return s.applied } + +// Open открывает базу, ставит режим из docs/storage.md и применяет миграции. +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", dsn(path)) + if err != nil { + return nil, fmt.Errorf("store: открытие %s: %w", path, err) + } + + // Одно соединение на всю базу. modernc.org/sqlite, как и любой SQLite, + // допускает ровно одного писателя; при нескольких соединениях запись + // упирается в SQLITE_BUSY, а busy_timeout лечит это ожиданием, а не + // корректностью — «database is locked» всё равно возможен на upgrade + // транзакции из read в write. Пул из одной штуки убирает класс ошибок + // целиком: очередь выстраивает database/sql. Цена — чтения ждут запись; + // для чата на десятки человек это незаметно. SSE держит соединение + // с клиентом, а не с базой, поэтому поток событий пул не занимает. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("store: %s недоступна: %w", path, err) + } + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close закрывает базу. +func (s *Store) Close() error { return s.db.Close() } + +// dsn собирает строку соединения с режимом из docs/storage.md. +// Прагмы применяются к каждому новому соединению; journal_mode=WAL +// хранится в самом файле, остальные — свойство соединения. +func dsn(path string) string { + q := url.Values{} + q.Add("_pragma", "journal_mode(WAL)") + q.Add("_pragma", "synchronous(NORMAL)") + q.Add("_pragma", "foreign_keys(1)") + q.Add("_pragma", "busy_timeout(5000)") + return "file:" + (&url.URL{Path: path}).EscapedPath() + "?" + q.Encode() +} + +// migrate применяет недостающие миграции по порядку, каждую в своей +// транзакции. Версия схемы — PRAGMA user_version, она же номер последней +// применённой миграции. Откатов нет: ошибку правит следующая миграция. +func (s *Store) migrate() error { + files, err := migrationFiles() + if err != nil { + return err + } + var version int + if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("store: чтение user_version: %w", err) + } + if version > len(files) { + return fmt.Errorf("store: база версии %d новее бинаря (%d миграций)", version, len(files)) + } + for i := version; i < len(files); i++ { + name := files[i] + body, err := fs.ReadFile(migrations, "migrations/"+name) + if err != nil { + return fmt.Errorf("store: чтение миграции %s: %w", name, err) + } + if err := s.applyMigration(i+1, name, string(body)); err != nil { + return err + } + s.applied = append(s.applied, name) + } + return nil +} + +func (s *Store) applyMigration(version int, name, body string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + defer tx.Rollback() + + if _, err := tx.Exec(body); err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + // user_version не принимает подстановку, поэтому число подставляется + // форматированием; version — счётчик миграций, не пользовательские данные. + if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + return tx.Commit() +} + +// migrationFiles отдаёт имена миграций в порядке номеров. +func migrationFiles() ([]string, error) { + entries, err := fs.ReadDir(migrations, "migrations") + if err != nil { + return nil, fmt.Errorf("store: каталог миграций: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + names = append(names, e.Name()) + } + // Имена вида NNN_*.sql: лексикографический порядок совпадает с числовым. + sort.Strings(names) + return names, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..7948b84 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,213 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func open(t *testing.T, path string) *Store { + t.Helper() + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestMigrateAndRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "bare.db") + + first := open(t, path) + if got := first.Applied(); len(got) != 1 || got[0] != "001_init.sql" { + t.Fatalf("применённые миграции: получено %v, ожидалось [001_init.sql]", got) + } + if got := version(t, first); got != 1 { + t.Errorf("user_version: получено %d, ожидалась 1", got) + } + // Все восемь таблиц из docs/storage.md на месте. + for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} { + var name string + err := first.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) + if err != nil { + t.Errorf("таблица %s: %v", table, err) + } + } + // Внешние ключи включены — иначе каскадные удаления молча не работают. + var fk int + if err := first.db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil || fk != 1 { + t.Errorf("foreign_keys: получено %d (%v), ожидалась 1", fk, err) + } + var mode string + if err := first.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" { + t.Errorf("journal_mode: получено %q (%v), ожидался wal", mode, err) + } + first.Close() + + // Повторный старт на той же базе ничего не применяет. + second := open(t, path) + if got := second.Applied(); len(got) != 0 { + t.Errorf("повторный старт применил %v, ожидалось ничего", got) + } + if got := version(t, second); got != 1 { + t.Errorf("user_version после перезапуска: получено %d, ожидалась 1", got) + } +} + +func version(t *testing.T, s *Store) int { + t.Helper() + var v int + if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil { + t.Fatalf("user_version: %v", err) + } + return v +} + +func TestUsersAndSessions(t *testing.T) { + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + + u := User{ + Nick: "marta", + Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"}, + PublicKey: `{"kty":"EC"}`, + KeyBlob: `{"v":1}`, + CreatedAt: 1, + } + if err := s.CreateUser(ctx, u); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := s.CreateUser(ctx, u); !errors.Is(err, ErrNickTaken) { + t.Errorf("повторный ник: получено %v, ожидалось ErrNickTaken", err) + } + if _, err := s.User(ctx, "нет-такого"); !errors.Is(err, ErrNotFound) { + t.Errorf("чужой ник: получено %v, ожидалось ErrNotFound", err) + } + + now := time.Now().UnixMilli() + live := []byte("token-hash-1") + other := []byte("token-hash-2") + if err := s.CreateSession(ctx, live, "marta", now, now+1000); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if err := s.CreateSession(ctx, other, "marta", now, now+1000); err != nil { + t.Fatalf("CreateSession: %v", err) + } + sess, err := s.Session(ctx, live, now) + if err != nil || sess.Nick != "marta" || sess.DeviceID != "" { + t.Fatalf("Session: %+v, %v", sess, err) + } + if _, err := s.Session(ctx, live, now+2000); !errors.Is(err, ErrNotFound) { + t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err) + } + + // Смена пароля с logoutOthers: остаётся только текущая сессия. + 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 { + t.Fatalf("SetPassword: %v", err) + } + if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) { + t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err) + } + if _, err := s.Session(ctx, live, now); err != nil { + t.Errorf("текущая сессия после logoutOthers: %v", err) + } + got, err := s.User(ctx, "marta") + if err != nil { + t.Fatalf("User: %v", err) + } + if string(got.Cred.Hash) != "new" || got.KeyBlob != `{"v":1,"new":true}` { + t.Errorf("хеш и блоб: получено %q / %q", got.Cred.Hash, got.KeyBlob) + } + + // Удаление пользователя уносит сессии каскадом. + if err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) { + t.Errorf("сессия после удаления аккаунта: получено %v, ожидалось ErrNotFound", err) + } +} + +func TestCleanup(t *testing.T) { + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + now := time.Now() + ms := now.UnixMilli() + day := int64(24 * 60 * 60 * 1000) + + exec := func(query string, args ...any) { + t.Helper() + if _, err := s.db.ExecContext(ctx, query, args...); err != nil { + t.Fatalf("%s: %v", query, err) + } + } + exec(`INSERT INTO users (nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at) + VALUES ('marta', x'00', x'00', 'argon2id,m=19456,t=2,p=1', '{}', '{}', ?)`, ms) + exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('old', 'marta', ?, ?)`, ms, ms-100*day) + exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('new', 'marta', ?, ?)`, ms, ms) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('old', 'cascade', '{}', ?)`, ms) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'stale', '{}', ?)`, ms-40*day) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'fresh', '{}', ?)`, ms) + exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'01', 'marta', ?, ?)`, ms, ms-day) + exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'02', 'marta', ?, ?)`, ms, ms+day) + exec(`INSERT INTO rooms (id, name, owner, created_at) VALUES ('r', 'общая', 'marta', ?)`, ms) + for i, key := range []string{"k1", "k2", "k3"} { + exec(`INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at) + VALUES ('r', 'marta', ?, 'marta', 'iv', 'ct', ?)`, key, ms+int64(i)) + } + + if err := s.Cleanup(ctx, now); err != nil { + t.Fatalf("Cleanup: %v", err) + } + + if got := ids(t, s, `SELECT id FROM devices ORDER BY id`); !equal(got, []string{"new"}) { + t.Errorf("устройства: получено %v, ожидалось [new]", got) + } + // Очередь устройства 'old' ушла каскадом вместе с ним, 'stale' — по сроку. + if got := ids(t, s, `SELECT msg_id FROM queue ORDER BY msg_id`); !equal(got, []string{"fresh"}) { + t.Errorf("очередь: получено %v, ожидалось [fresh]", got) + } + if got := ids(t, s, `SELECT hex(token_hash) FROM sessions ORDER BY token_hash`); !equal(got, []string{"02"}) { + t.Errorf("сессии: получено %v, ожидалось [02]", got) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи комнат: получено %v, ожидалось [k2 k3]", got) + } +} + +func ids(t *testing.T, s *Store, query string) []string { + t.Helper() + rows, err := s.db.Query(query) + if err != nil { + t.Fatalf("%s: %v", query, err) + } + defer rows.Close() + var out []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + return out +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..2e38fe5 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// Credential — argon2id-хеш authKey, его соль и параметры (ADR-021). +// Параметры лежат рядом с хешем, чтобы их можно было повышать перехешем +// при очередном входе, а не миграцией всех аккаунтов разом. +type Credential struct { + Hash []byte + Salt []byte + Params string +} + +// User — строка users. PublicKey и KeyBlob — JSON клиента; сервер их +// не расшифровывает и не интерпретирует сверх проверки формы. +type User struct { + Nick string + Cred Credential + PublicKey string + KeyBlob string + CreatedAt int64 +} + +// CreateUser заводит пользователя. Занятый ник — ErrNickTaken. +func (s *Store) CreateUser(ctx context.Context, u User) error { + // ON CONFLICT DO NOTHING вместо разбора кода ошибки драйвера: + // занятый ник виден по нулю затронутых строк. + res, err := s.db.ExecContext(ctx, ` + INSERT INTO users (nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(nick) DO NOTHING`, + u.Nick, u.Cred.Hash, u.Cred.Salt, u.Cred.Params, u.PublicKey, u.KeyBlob, u.CreatedAt) + if err != nil { + return fmt.Errorf("store: создание пользователя: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("store: создание пользователя: %w", err) + } + if n == 0 { + return ErrNickTaken + } + return nil +} + +// User читает пользователя по нику. Нет такого — ErrNotFound. +func (s *Store) User(ctx context.Context, nick string) (User, error) { + var u User + err := s.db.QueryRowContext(ctx, ` + SELECT nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at + FROM users WHERE nick = ?`, nick). + Scan(&u.Nick, &u.Cred.Hash, &u.Cred.Salt, &u.Cred.Params, &u.PublicKey, &u.KeyBlob, &u.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, fmt.Errorf("store: чтение пользователя: %w", err) + } + return u, nil +} + +// SetAuth заменяет только хеш authKey — перехеш при входе, когда параметры +// в базе отстали от текущих (ADR-021). +func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ? WHERE nick = ?`, + cred.Hash, cred.Salt, cred.Params, nick) + if err != nil { + return fmt.Errorf("store: перехеш: %w", err) + } + return nil +} + +// SetPassword заменяет хеш authKey и ключевой блоб в одной транзакции: +// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти +// или ключ которого не расшифровать. При logoutOthers в той же транзакции +// удаляются все сессии пользователя, кроме keep — текущей. +func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` + UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`, + cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + if logoutOthers { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + return nil +} + +// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство +// и очереди уносит каскад. +// +// Комнаты, где пользователь владелец, каскадом не удаляются: rooms.owner +// ссылается на users(nick) без ON DELETE, и удаление такого пользователя +// упрётся в внешний ключ. Передача владения и удаление пустых комнат — +// ADR-018, этап 3; до появления комнат случай не наступает. +func (s *Store) DeleteUser(ctx context.Context, nick string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { + return fmt.Errorf("store: удаление пользователя: %w", err) + } + return nil +} diff --git a/web/app.css b/web/app.css index 46f9bf3..855cc52 100644 --- a/web/app.css +++ b/web/app.css @@ -35,10 +35,33 @@ body { -webkit-text-size-adjust: 100%; } -/* заставка: знак и слово, больше пока ничего */ +/* #app — колонка ровно в высоту окна: от неё считают высоту экраны, + поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается + только содержимое */ + +#app { + display: flex; + flex-direction: column; + height: 100%; +} + +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +/* знак: четыре угла, один цвет, без скруглений */ + +.mark { + width: 18px; + height: 18px; + flex: none; +} + +/* заставка: знак и слово, пока не поднялись модули */ .boot { - min-height: 100%; + flex: 1; display: flex; align-items: center; justify-content: center; @@ -54,3 +77,360 @@ body { font-size: 15px; letter-spacing: -0.02em; } + +/* формы: рамка 1 px ink, прямые углы, цель нажатия 44 px */ + +.form { + max-width: 360px; +} + +.field { + display: block; + margin-bottom: 14px; +} + +.field > span { + display: block; + margin-bottom: 6px; + font-size: 11px; + color: var(--mute); +} + +input[type="text"], +input[type="password"] { + display: block; + width: 100%; + min-height: 44px; + padding: 11px 14px; + border: 1px solid var(--ink); + border-radius: 0; + background: var(--bone); + color: var(--ink); + font: inherit; + font-size: 14px; +} + +.button { + display: block; + width: 100%; + min-height: 44px; + padding: 11px 14px; + border: 1px solid var(--ink); + border-radius: 0; + background: var(--bone); + color: var(--ink); + font: inherit; + font-size: 14px; + text-align: center; + cursor: pointer; +} + +.button[disabled] { + border-color: var(--edge); + color: var(--stone); + cursor: default; +} + +/* hidden обязан прятать: display у .button перебивает таблицу браузера, + поэтому кнопке нужно отдельное правило */ + +.button[hidden] { + display: none; +} + +.check { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + font-size: 12px; + color: var(--text2); + cursor: pointer; +} + +.check input { + width: 15px; + height: 15px; + accent-color: var(--ink); +} + +.row { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.row .button { + flex: 1; + width: auto; +} + +/* подписи и строки состояния: акцент mark — только ошибка */ + +.hint { + margin: -4px 0 14px; + font-size: 11px; + line-height: 1.5; + color: var(--mute); +} + +.message { + min-height: 18px; + margin: 10px 0 0; + font-size: 12px; + color: var(--mute); +} + +.message--error { + color: var(--mark); +} + +.confirm { + margin: 12px 0 0; + font-size: 12px; + color: var(--text2); +} + +/* вход и регистрация */ + +.auth { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow-y: auto; +} + +.auth__inner { + width: 100%; + max-width: 320px; +} + +.auth .form { + max-width: none; +} + +.auth__brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; + font-size: 15px; + letter-spacing: -0.02em; +} + +.tabs { + display: flex; + align-items: center; + gap: 2px; + margin-bottom: 22px; +} + +.tab { + min-height: 32px; + padding: 6px 10px; + border: 0; + background: none; + color: var(--mute); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.tab.is-on { + background: var(--ink); + color: var(--bone); +} + +.tabs__sep { + color: var(--stone); + font-size: 13px; +} + +/* каркас: сайдбар и экран */ + +.shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.side { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.brand { + flex: none; + display: flex; + align-items: center; + gap: 10px; + height: 56px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + font-size: 15px; + letter-spacing: -0.02em; +} + +.list { + flex: 1; + min-height: 0; + padding: 20px; + overflow-y: auto; +} + +.section { + margin: 0; + padding: 0 8px 10px; + font-size: 10px; + font-weight: 400; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--stone); +} + +.items { + margin: 0 0 22px; + padding: 0; + list-style: none; +} + +.me { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 44px; + margin-top: auto; + padding: 16px 20px; + border: 0; + border-top: 1px solid var(--line); + background: none; + color: var(--mute); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.me i { + flex: none; + width: 6px; + height: 6px; + background: var(--ink); +} + +.main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + height: 56px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + font-size: 15px; +} + +.back { + min-height: 32px; + padding: 6px 10px; + border: 1px solid var(--edge); + border-radius: 0; + background: none; + color: var(--mute); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.body { + flex: 1; + min-height: 0; + padding: 20px; + overflow-y: auto; +} + +/* настройки */ + +.settings { + max-width: 460px; +} + +.block { + padding: 20px 0; + border-top: 1px solid var(--line); +} + +.block--first { + padding-top: 0; + border-top: 0; +} + +.self { + margin: 0 0 10px; + font-size: 13px; + color: var(--text2); +} + +.fp { + margin: 0; + font-size: 12px; + color: var(--mute); +} + +.block .form { + margin-top: 14px; +} + +@media (min-width: 760px) { + .shell { + display: grid; + grid-template-columns: 224px 1fr; + } + + .side { + border-right: 1px solid var(--line); + } + + .brand { + height: 64px; + } + + .head { + height: 64px; + padding: 0 32px; + } + + .body { + padding: 28px 32px; + } +} + +/* мобильный: один экран за раз, цели нажатия не меньше 44 px */ + +@media (max-width: 759px) { + .shell[data-screen="list"] .main { + display: none; + } + + .shell[data-screen="screen"] .side { + display: none; + } + + .tab, + .back { + min-height: 44px; + } +} diff --git a/web/index.html b/web/index.html index 88370b2..94c876e 100644 --- a/web/index.html +++ b/web/index.html @@ -9,16 +9,19 @@ + -
- - bare -
+
+
+ + bare +
+
diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..f296443 --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,130 @@ +// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md: +// JSON в обе стороны, cookie сессии, ошибка — {error, message}. +// SSE и ACK появятся на этапе 2. + +// ApiError — ответ сервера с кодом из перечня docs/protocol.md. +export class ApiError extends Error { + constructor(code, message, status, field) { + super(message || code); + this.name = "ApiError"; + this.code = code; + this.status = status; + this.field = field; + } +} + +// NetworkError — запрос не дошёл: сети нет, сервер не ответил. +// Это состояние клиента, а не код протокола. +export class NetworkError extends Error { + constructor() { + super("нет соединения"); + this.name = "NetworkError"; + } +} + +// Тексты состояний и ошибок — docs/ui.md и ADR-028. +const TEXT = { + invalid_credentials: "неверный ник или пароль", + nick_taken: "ник занят", + invalid_nick: "ник: 2–32 символа, a–z, 0–9, _", + invite_required: "нужен инвайт-код", + invalid_invite: "инвайт-код не подходит", + rate_limited: "слишком часто, попробуйте позже", +}; + +export function errorText(err) { + if (err instanceof NetworkError) { + return "нет соединения"; + } + if (err instanceof ApiError && TEXT[err.code]) { + return TEXT[err.code]; + } + return "сервер не справился, попробуйте позже"; +} + +// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла +// или её завершили с другого устройства. IndexedDB при этом не трогается +// (docs/ui.md, «Сеть и состояния»). +let expired = () => {}; + +export function onSessionExpired(handler) { + expired = handler; +} + +// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где +// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession). +async function request(method, path, body, { quiet = false } = {}) { + const init = { method, credentials: "same-origin", cache: "no-store" }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + let response; + try { + response = await fetch(path, init); + } catch { + throw new NetworkError(); + } + + let data = null; + if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) { + data = await response.json().catch(() => null); + } + if (response.ok) { + return data; + } + + const code = typeof data?.error === "string" ? data.error : "internal"; + // Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials — + // обычная ошибка формы входа, 401 unauthenticated — выход на экран входа. + if (code === "unauthenticated" && !quiet) { + expired(); + } + throw new ApiError(code, data?.message, response.status, data?.field); +} + +export function config() { + return request("GET", "/api/config"); +} + +export function kdf(nick) { + return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`); +} + +export function register(body) { + return request("POST", "/api/register", body); +} + +export function login(nick, authKey) { + return request("POST", "/api/login", { nick, authKey }); +} + +export function me() { + return request("GET", "/api/me"); +} + +// dropSession — служебный выход перед повторным входом (ADR-031). Смена +// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie: +// прежнюю сессию закрываем сами, пока её токен ещё при нас. +// +// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не +// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные +// отказы поднимаются наверх: при живой сессии входить заново нельзя, +// её строка осталась бы на сервере без владельца. +export async function dropSession() { + try { + await request("POST", "/api/logout", undefined, { quiet: true }); + } catch (err) { + if (!(err instanceof ApiError) || err.code !== "unauthenticated") { + throw err; + } + } +} + +export function password(body) { + return request("POST", "/api/password", body); +} + +export function deleteMe(authKey) { + return request("DELETE", "/api/me", { authKey }); +} diff --git a/web/js/crypto.js b/web/js/crypto.js new file mode 100644 index 0000000..1e92ae1 --- /dev/null +++ b/web/js/crypto.js @@ -0,0 +1,242 @@ +// Криптография клиента — всё по docs/crypto.md. +// +// Только WebCrypto: crypto.subtle и crypto.getRandomValues. Строки-константы +// входят в вывод ключей, менять их нельзя. Бинарные поля — base64url без +// паддинга, пароль нормализуется в NFC. +// +// Модуль не знает про DOM: его можно импортировать в node и прогнать. + +const subtle = globalThis.crypto.subtle; + +// Константы вывода ключей (docs/crypto.md). Буквальные строки. +const SALT_PREFIX = "bare-v1:"; +const INFO_AUTH = "bare-auth-v1"; +const INFO_KEK = "bare-kek-v1"; +const BLOB_AAD = "bare-blob-v1|"; + +// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM. +export const SECRET_LEN = 32; +const IV_LEN = 12; + +// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от +// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а +// считает по нему клиент, поэтому проверить его может только он. +export const MIN_ITERATIONS = 600_000; +export const MAX_ITERATIONS = 10_000_000; + +export function validIterations(iterations) { + return Number.isSafeInteger(iterations) + && iterations >= MIN_ITERATIONS + && iterations <= MAX_ITERATIONS; +} + +// Пустая соль HKDF: «salt = пусто» из docs/crypto.md. +const EMPTY = new Uint8Array(0); +const ECDH_P256 = { name: "ECDH", namedCurve: "P-256" }; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function utf8(text) { + return encoder.encode(text); +} + +export function random(length) { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + return bytes; +} + +export function b64url(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +export function unb64url(text) { + if (typeof text !== "string" || /[^A-Za-z0-9_-]/.test(text)) { + throw new Error("не base64url"); + } + const padded = text.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function hex(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let out = ""; + for (let i = 0; i < bytes.length; i += 1) { + out += bytes[i].toString(16).padStart(2, "0"); + } + return out; +} + +// fingerprintGroups режет отпечаток на группы по 4 символа: так его +// показывают человеку (ADR-016). +export function fingerprintGroups(fingerprint) { + return fingerprint.match(/.{1,4}/g) ?? []; +} + +// wipe затирает сырые байты, когда они больше не нужны. +export function wipe(bytes) { + if (bytes instanceof Uint8Array) { + bytes.fill(0); + } +} + +// accountSalt — соль KDF: детерминированная, известна до входа. +export async function accountSalt(nick) { + return new Uint8Array(await subtle.digest("SHA-256", utf8(SALT_PREFIX + nick))); +} + +// deriveAccountKeys выводит мастер-ключ из пароля и два независимых ключа +// из него: authKey уходит на сервер, kek шифрует ключевой блоб. +// Пароль и мастер остаются в памяти и затираются здесь же. +export async function deriveAccountKeys(nick, password, iterations) { + // Ослабленный или неподъёмный iter не должен доходить до PBKDF2: + // authKey, выведенный по нему, уходит на сервер (ADR-030). + if (!validIterations(iterations)) { + throw new Error("iter вне границ"); + } + const salt = await accountSalt(nick); + const secret = utf8(password.normalize("NFC")); + const material = await subtle.importKey("raw", secret, "PBKDF2", false, ["deriveBits"]); + wipe(secret); + + const masterBits = await subtle.deriveBits( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + material, + 256, + ); + const master = await subtle.importKey("raw", masterBits, "HKDF", false, ["deriveBits", "deriveKey"]); + wipe(new Uint8Array(masterBits)); + + const authBits = await subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_AUTH) }, + master, + 256, + ); + const kek = await subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_KEK) }, + master, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + const authKey = b64url(authBits); + wipe(new Uint8Array(authBits)); + return { authKey, kek }; +} + +// generateIdentity — ключевая пара аккаунта и секрет для экспорта истории. +// Пара extractable: приватный ключ нужно упаковать в блоб. +export async function generateIdentity() { + const keyPair = await subtle.generateKey(ECDH_P256, true, ["deriveBits"]); + return { keyPair, secret: random(SECRET_LEN) }; +} + +// publicJwk — публичный ключ ровно в той форме, в какой его хранит сервер. +export function publicJwk(jwk) { + return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; +} + +export async function exportPublicJwk(publicKey) { + return publicJwk(await subtle.exportKey("jwk", publicKey)); +} + +export async function exportPrivateJwk(privateKey) { + return subtle.exportKey("jwk", privateKey); +} + +// importPublic — чужой или свой публичный ключ из JWK. Extractable: из него +// считается отпечаток по сырой точке. +export async function importPublic(jwk) { + return subtle.importKey("jwk", publicJwk(jwk), ECDH_P256, true, []); +} + +// importPrivate — приватный ключ после расшифровки блоба: наружу больше +// не выходит (docs/crypto.md, «Хранение на устройстве»). +export async function importPrivate(jwk) { + return subtle.importKey("jwk", jwk, ECDH_P256, false, ["deriveBits"]); +} + +// importSecret — секрет аккаунта как ключ HKDF, тоже non-extractable. +export async function importSecret(bytes) { + return subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey", "deriveBits"]); +} + +// fingerprint — SHA-256 несжатой точки публичного ключа, 64 hex строчными. +export async function fingerprint(publicKey) { + const raw = await subtle.exportKey("raw", publicKey); + return hex(await subtle.digest("SHA-256", raw)); +} + +export async function fingerprintOf(jwk) { + return fingerprint(await importPublic(jwk)); +} + +// sealBlob собирает ключевой блоб: приватный ключ и секрет аккаунта под kek. +// Ник в AAD — блоб одного аккаунта не подходит другому. +export async function sealBlob({ nick, iterations, kek, priv, secret }) { + const plain = utf8(JSON.stringify({ priv, secret: b64url(secret) })); + const iv = random(IV_LEN); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) }, + kek, + plain, + ); + wipe(plain); + return JSON.stringify({ v: 1, iter: iterations, iv: b64url(iv), ct: b64url(ct) }); +} + +// parseBlob читает форму блоба, не расшифровывая: iter нужен до того, +// как появится kek. +export function parseBlob(text) { + const blob = JSON.parse(text); + if (blob === null || typeof blob !== "object") { + throw new Error("блоб — не объект"); + } + if (blob.v !== 1) { + throw new Error("версия блоба не 1"); + } + if (!validIterations(blob.iter)) { + throw new Error("iter блоба вне границ"); + } + if (typeof blob.iv !== "string" || typeof blob.ct !== "string") { + throw new Error("iv или ct блоба — не строка"); + } + return blob; +} + +// openBlob расшифровывает блоб и отдаёт сырые JWK приватного ключа +// и байты секрета. Жить им — до импорта в CryptoKey. +export async function openBlob(blob, kek, nick) { + const iv = unb64url(blob.iv); + const ct = unb64url(blob.ct); + if (iv.length !== IV_LEN) { + throw new Error("iv блоба — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) }, + kek, + ct, + ); + const bytes = new Uint8Array(plain); + const parsed = JSON.parse(decoder.decode(bytes)); + wipe(bytes); + if (parsed === null || typeof parsed !== "object" || parsed.priv === null || typeof parsed.priv !== "object") { + throw new Error("в блобе нет приватного ключа"); + } + const secret = unb64url(parsed.secret); + if (secret.length !== SECRET_LEN) { + throw new Error("секрет аккаунта — не 32 байта"); + } + return { priv: parsed.priv, secret }; +} diff --git a/web/js/db.js b/web/js/db.js new file mode 100644 index 0000000..e226d00 --- /dev/null +++ b/web/js/db.js @@ -0,0 +1,114 @@ +// IndexedDB клиента — схема из docs/storage.md, «Клиент — IndexedDB». +// +// База `bare`, версия 1, все хранилища заводятся сразу: одна версия — одна +// схема, даже если часть хранилищ наполняется на следующих этапах. +// CryptoKey кладётся объектом: structured clone умеет их хранить, +// и приватный ключ остаётся non-extractable. + +const NAME = "bare"; +const VERSION = 1; + +let opening = null; + +export function open() { + if (!opening) { + opening = new Promise((resolve, reject) => { + const request = indexedDB.open(NAME, VERSION); + request.onupgradeneeded = () => create(request.result); + request.onsuccess = () => { + const db = request.result; + // Соседняя вкладка стирает базу при выходе из аккаунта — отпускаем + // соединение, иначе удаление зависнет заблокированным. + db.onversionchange = () => { + db.close(); + opening = null; + }; + resolve(db); + }; + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("база занята другой вкладкой")); + }); + opening.catch(() => { + opening = null; + }); + } + return opening; +} + +function create(db) { + db.createObjectStore("meta"); // ключ — строка снаружи значения + db.createObjectStore("chats", { keyPath: "id" }); + const messages = db.createObjectStore("messages", { keyPath: "id" }); + messages.createIndex("chat", ["chatId", "id"]); + db.createObjectStore("roomKeys", { keyPath: ["roomId", "keyId"] }); + db.createObjectStore("peers", { keyPath: "nick" }); +} + +function done(tx) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error ?? new Error("транзакция отменена")); + }); +} + +function value(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// meta читает несколько ключей одной транзакцией. +export async function meta(keys) { + const db = await open(); + const store = db.transaction("meta", "readonly").objectStore("meta"); + const out = {}; + await Promise.all(keys.map(async (key) => { + out[key] = await value(store.get(key)); + })); + return out; +} + +// putMeta пишет пары «ключ — значение» одной транзакцией. +export async function putMeta(entries) { + const db = await open(); + const tx = db.transaction("meta", "readwrite"); + const store = tx.objectStore("meta"); + for (const [key, item] of Object.entries(entries)) { + store.put(item, key); + } + await done(tx); +} + +// persist просит браузер не вычищать базу: история на устройстве — +// единственная копия (docs/storage.md). +export async function persist() { + if (!navigator.storage?.persist) { + return false; + } + try { + return await navigator.storage.persist(); + } catch { + return false; + } +} + +// destroy стирает базу целиком: выход из аккаунта уносит историю +// (docs/storage.md, один аккаунт на браузерный профиль). +export async function destroy() { + const db = await open().catch(() => null); + if (db) { + db.close(); + } + opening = null; + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + // Блокировка — не ответ: соседние вкладки закрывают соединение + // по versionchange, после чего удаление доходит до success. + // Сказать «история удалена», не удалив её, нельзя. + request.onblocked = () => {}; + }); +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..49cacf8 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,338 @@ +// Загрузка, роутинг по hash и состояние аккаунта. +// +// Экраны из js/ui/ занимаются только разметкой: всё, что меняет состояние — +// вход, регистрация, смена пароля, выход, удаление аккаунта — живёт здесь +// и отдаётся экранам через ctx. + +import * as api from "./api.js"; +import * as db from "./db.js"; +import { + deriveAccountKeys, + exportPrivateJwk, + exportPublicJwk, + fingerprintOf, + generateIdentity, + importPrivate, + importSecret, + openBlob, + parseBlob, + publicJwk, + sealBlob, + validIterations, + wipe, +} from "./crypto.js"; +import { clear } from "./ui/dom.js"; +import { renderAuth } from "./ui/auth.js"; +import { renderSettings } from "./ui/settings.js"; +import { frame } from "./ui/shell.js"; + +// Минимальная длина пароля — ADR-013. +const MIN_PASSWORD = 12; + +// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер. +const NICK = /^[a-z0-9_]{2,32}$/; + +const state = { config: null, me: null }; + +// AccountError — то, что случилось с ключевым материалом, а не с сетью. +// Сообщение уже пригодно для показа человеку (ADR-028). +class AccountError extends Error { + constructor(text) { + super(text); + this.name = "AccountError"; + } +} + +const ctx = { + minPassword: MIN_PASSWORD, + validNick: (nick) => NICK.test(nick), + storedNick, + get config() { + return state.config; + }, + get me() { + return state.me; + }, + errorText, + ensureConfig, + signUp, + signIn, + changePassword, + deleteAccount, + signOut, + go, +}; + +// --- роутинг ----------------------------------------------------------- + +// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»; +// на этом этапе есть только список и настройки, остальные ведут в пустой +// список: чатов, контактов и комнат ещё нет. +function render() { + const app = document.getElementById("app"); + clear(app); + if (!state.me) { + renderAuth(app, ctx); + return; + } + const settings = (location.hash || "#/") === "#/settings"; + const { root, main } = frame(ctx, settings ? "screen" : "list"); + if (settings) { + renderSettings(main, ctx); + } + app.append(root); +} + +function go(hash) { + if (location.hash === hash) { + render(); + } else { + location.hash = hash; + } +} + +// --- состояние --------------------------------------------------------- + +async function ensureConfig() { + if (!state.config) { + state.config = await api.config(); + } + return state.config; +} + +// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи +// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт. +async function restore() { + let who; + try { + who = await api.me(); + } catch { + return null; + } + let meta; + try { + meta = await db.meta(["nick", "publicKey", "fingerprint", "privateKey"]); + } catch { + return null; + } + if (!meta.privateKey || meta.nick !== who.nick) { + return null; + } + return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint }; +} + +// storedNick — чьи ключи лежат на устройстве. Вход под другим ником стирает +// базу, поэтому экран входа сначала спрашивает (ADR-029). +async function storedNick() { + try { + const meta = await db.meta(["nick"]); + return meta.nick ?? null; + } catch { + return null; + } +} + +// --- аккаунт ----------------------------------------------------------- + +// derive — вывод ключей по числу итераций, пришедшему от сервера. Границы +// проверяются до PBKDF2: authKey уходит на сервер сразу после вычисления, +// а слишком большой iter не заканчивается вовсе (ADR-030). +async function derive(nick, password, iterations) { + if (!validIterations(iterations)) { + throw new AccountError("параметры ключа не совпали"); + } + return deriveAccountKeys(nick, password, iterations); +} + +async function signUp(nick, password, invite) { + const config = await ensureConfig(); + const iterations = config.kdfIterations; + const { authKey, kek } = await derive(nick, password, iterations); + const { keyPair, secret } = await generateIdentity(); + try { + const priv = await exportPrivateJwk(keyPair.privateKey); + const body = { + nick, + authKey, + publicKey: await exportPublicJwk(keyPair.publicKey), + blob: await sealBlob({ nick, iterations, kek, priv, secret }), + }; + if (invite) { + body.invite = invite; + } + await api.register(body); + await adopt(nick, priv, secret); + } finally { + wipe(secret); + } +} + +async function signIn(nick, password) { + const config = await ensureConfig(); + const { authKey, iterations, priv, secret } = await unlock(nick, password); + try { + await adopt(nick, priv, secret); + if (iterations < config.kdfIterations) { + await raise(nick, password, authKey, priv, secret, config.kdfIterations); + } + } finally { + wipe(secret); + } +} + +// unlock скачивает ключевой блоб и расшифровывает его. Блоб отдаёт только +// вход — другого источника в протоколе нет, поэтому смена пароля тоже +// проходит через login (docs/crypto.md, «Повышение итераций и смена пароля»). +async function unlock(nick, password) { + const { iterations } = await api.kdf(nick); + const { authKey, kek } = await derive(nick, password, iterations); + const account = await api.login(nick, authKey); + + let blob; + try { + blob = parseBlob(account.blob); + } catch { + throw new AccountError("ключ аккаунта повреждён"); + } + // Расшифровка идёт по iter из блоба; расхождение с ответом /api/kdf + // означает несогласованность данных (docs/crypto.md, «Ключевой блоб»). + if (blob.iter !== iterations) { + throw new AccountError("параметры ключа не совпали"); + } + let opened; + try { + opened = await openBlob(blob, kek, nick); + } catch { + throw new AccountError("ключ аккаунта повреждён"); + } + // Публичный ключ, который сервер раздаёт собеседникам, обязан + // соответствовать приватному из блоба. + if (opened.priv.x !== account.publicKey?.x || opened.priv.y !== account.publicKey?.y) { + wipe(opened.secret); + throw new AccountError("ключ аккаунта повреждён"); + } + return { authKey, iterations, priv: opened.priv, secret: opened.secret }; +} + +// adopt кладёт ключи на устройство. Сырые байты дальше не идут: +// в IndexedDB попадают только non-extractable CryptoKey (docs/crypto.md). +async function adopt(nick, priv, secret) { + const previous = await db.meta(["nick"]); + if (previous.nick && previous.nick !== nick) { + // На устройстве история другого аккаунта: один аккаунт на браузерный + // профиль (docs/storage.md). Согласие на стирание спрашивает экран + // входа до вычисления ключа (ADR-029) — здесь оно уже получено. + await db.destroy(); + } + const publicKey = publicJwk(priv); + const fingerprint = await fingerprintOf(publicKey); + await db.putMeta({ + nick, + publicKey, + fingerprint, + privateKey: await importPrivate(priv), + accountSecret: await importSecret(secret), + }); + state.me = { nick, publicKey, fingerprint }; +} + +// raise — автоматическое повышение итераций сразу после входа, молча +// и без завершения чужих сессий (docs/crypto.md). +async function raise(nick, password, authKey, priv, secret, iterations) { + try { + const next = await derive(nick, password, iterations); + await api.password({ + authKey, + newAuthKey: next.authKey, + blob: await sealBlob({ nick, iterations, kek: next.kek, priv, secret }), + logoutOthers: false, + }); + } catch { + // Не вышло — вход уже состоялся, повторим при следующем. + } +} + +async function changePassword(current, next, logoutOthers) { + const config = await ensureConfig(); + const nick = state.me.nick; + // unlock входит заново — иначе не добыть блоб, — а вход заводит новую + // сессию и перезаписывает cookie. Прежнюю закрываем сами и до входа: + // иначе её строка осталась бы жить на сервере, а токена от неё нет уже + // ни у кого. Сорвавшийся unlock после этого оставляет клиент без сессии, + // но не на экране входа: следующая попытка начинается с того же + // служебного выхода и проходит целиком (ADR-031). + await api.dropSession(); + const { authKey, priv, secret } = await unlock(nick, current); + try { + const iterations = config.kdfIterations; + const derived = await derive(nick, next, iterations); + await api.password({ + authKey, + newAuthKey: derived.authKey, + blob: await sealBlob({ nick, iterations, kek: derived.kek, priv, secret }), + logoutOthers, + }); + } finally { + wipe(secret); + } +} + +// deleteAccount входит заново тем же порядком, что и смена пароля: сессии +// может уже не быть — её закрывает сорвавшаяся смена пароля, — а DELETE +// /api/me без сессии не проходит. Блоб здесь не нужен, поэтому вход прямой, +// без unlock: аккаунт с испорченным блобом обязан удаляться (ADR-031). +async function deleteAccount(password) { + const nick = state.me.nick; + const { iterations } = await api.kdf(nick); + const { authKey } = await derive(nick, password, iterations); + await api.dropSession(); + await api.login(nick, authKey); + await api.deleteMe(authKey); + await forget(); +} + +async function signOut() { + try { + await api.dropSession(); + } catch { + // Сети нет или сервер не справился; база стирается в любом случае. + } + await forget(); +} + +// forget уносит историю: она на этом устройстве единственная копия +// (docs/storage.md, docs/ui.md). +async function forget() { + await db.destroy(); + state.me = null; +} + +function errorText(err) { + if (err instanceof AccountError) { + return err.message; + } + return api.errorText(err); +} + +// --- старт ------------------------------------------------------------- + +async function boot() { + db.persist(); + try { + await ensureConfig(); + } catch { + // Ошибку покажем на первой попытке входа: экран рисуется и без конфигурации. + } + state.me = await restore(); + render(); + addEventListener("hashchange", render); + // 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела. + api.onSessionExpired(() => { + if (state.me) { + state.me = null; + render(); + } + }); +} + +boot(); diff --git a/web/js/ui/auth.js b/web/js/ui/auth.js new file mode 100644 index 0000000..4d5043d --- /dev/null +++ b/web/js/ui/auth.js @@ -0,0 +1,188 @@ +// Экран входа и регистрации — docs/ui.md, «Вход и регистрация». + +import { clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js"; + +const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. " + + "не короче 12 символов; лучше — фраза из нескольких слов."; + +const MODES = [ + ["in", "вход"], + ["up", "регистрация"], +]; + +export function renderAuth(root, ctx) { + // Состояние экрана переживает перерисовку: режим, введённый ник и ошибка. + const view = { mode: "in", nick: "", error: "" }; + const paint = () => { + clear(root); + root.append(screen(ctx, view, paint)); + }; + paint(); +} + +function screen(ctx, view, paint) { + const main = el("main", "auth"); + const inner = el("div", "auth__inner"); + main.append(inner); + + const brand = el("div", "auth__brand"); + brand.append(mark(), el("span", null, "bare")); + inner.append(brand); + inner.append(tabs(view, paint)); + + const form = el("form", "form"); + form.noValidate = true; + + const register = view.mode === "up"; + const nick = field("ник", { name: "nick", autocomplete: "username" }); + nick.input.value = view.nick; + const password = field("пароль", { + type: "password", + name: "password", + autocomplete: register ? "new-password" : "current-password", + }); + form.append(nick.wrap, password.wrap); + + let invite = null; + if (register) { + form.append(el("p", "hint", HINT)); + if (ctx.config?.inviteRequired) { + invite = field("инвайт-код", { name: "invite", autocomplete: "off" }); + form.append(invite.wrap); + } + } + + const label = register ? "регистрация" : "вход"; + const submit = el("button", "button", label); + submit.type = "submit"; + form.append(submit); + + // На устройстве могут лежать ключи другого ника: вход под этим сотрёт + // историю прежнего, поэтому сначала подтверждение (ADR-029). + const wipe = confirmPanel("", "удалить"); + form.append(wipe.root); + + const note = message(); + form.append(note); + if (view.error) { + setError(note, view.error); + } + + const fail = (text) => { + view.error = text; + if (text) { + setError(note, text); + } else { + setNote(note, ""); + } + }; + + // run — то, ради чего экран: PBKDF2 и запрос к серверу. Вызывается либо + // сразу, либо после подтверждения стирания. + const run = async () => { + submit.disabled = true; + submit.textContent = "вычисляем ключ…"; + const pass = password.input.value; + try { + if (register) { + await ctx.signUp(view.nick, pass, invite ? invite.input.value.trim() : ""); + } else { + await ctx.signIn(view.nick, pass); + } + ctx.go("#/"); + } catch (err) { + submit.disabled = false; + submit.textContent = label; + fail(ctx.errorText(err)); + } + }; + + const hideWipe = () => { + wipe.root.hidden = true; + submit.hidden = false; + }; + wipe.no.addEventListener("click", () => { + hideWipe(); + submit.focus(); + }); + wipe.yes.addEventListener("click", () => { + hideWipe(); + run(); + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) { + return; + } + view.nick = nick.input.value.trim().toLowerCase(); + nick.input.value = view.nick; + const pass = password.input.value; + fail(""); + hideWipe(); + + if (!ctx.validNick(view.nick)) { + fail("ник: 2–32 символа, a–z, 0–9, _"); + nick.input.focus(); + return; + } + // Длина пароля проверяется в обоих режимах: короче минимума пароля + // нет ни у одного аккаунта, считать по нему PBKDF2 незачем (docs/ui.md). + if ([...pass].length < ctx.minPassword) { + fail(`пароль: не короче ${ctx.minPassword} символов`); + password.input.focus(); + return; + } + try { + await ctx.ensureConfig(); + } catch (err) { + fail(ctx.errorText(err)); + return; + } + // Поле инвайта могло не появиться: конфигурация приехала только что. + if (register && ctx.config.inviteRequired && invite === null) { + view.error = "нужен инвайт-код"; + paint(); + return; + } + + const other = await ctx.storedNick(); + if (other && other !== view.nick) { + wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`; + wipe.root.hidden = false; + submit.hidden = true; + wipe.yes.focus(); + return; + } + await run(); + }); + + inner.append(form); + return main; +} + +// tabs — переключатель «вход / регистрация». +function tabs(view, paint) { + const wrap = el("div", "tabs"); + MODES.forEach(([mode, text], index) => { + if (index > 0) { + wrap.append(el("span", "tabs__sep", "/")); + } + const tab = el("button", "tab", text); + tab.type = "button"; + const on = view.mode === mode; + if (on) { + tab.classList.add("is-on"); + } + tab.setAttribute("aria-pressed", String(on)); + tab.addEventListener("click", () => { + if (view.mode !== mode) { + view.mode = mode; + view.error = ""; + paint(); + } + }); + wrap.append(tab); + }); + return wrap; +} diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js new file mode 100644 index 0000000..f6271d5 --- /dev/null +++ b/web/js/ui/dom.js @@ -0,0 +1,94 @@ +// Сборка узлов. innerHTML не используется нигде: сообщения и ники — +// пользовательские данные (docs/ui.md, CSP из ADR-021). + +const SVG = "http://www.w3.org/2000/svg"; + +export function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +export function clear(node) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} + +// mark — знак «скобы»: четыре угла рамки (docs/identity/brief.md). +export function mark() { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "mark"); + svg.setAttribute("viewBox", "0 0 64 64"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "7"); + svg.setAttribute("aria-hidden", "true"); + for (const d of ["M10 26V10h16", "M38 10h16v16", "M54 38v16H38", "M26 54H10V38"]) { + const path = document.createElementNS(SVG, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; +} + +// field — подпись и строка ввода. Отдаёт и то, и другое: подпись идёт +// в форму, ввод нужен обработчику. +export function field(label, { type = "text", name, autocomplete } = {}) { + const wrap = el("label", "field"); + wrap.append(el("span", null, label)); + const input = el("input"); + input.type = type; + if (name) { + input.name = name; + } + if (autocomplete) { + input.autocomplete = autocomplete; + } + input.autocapitalize = "off"; + input.spellcheck = false; + wrap.append(input); + return { wrap, input }; +} + +export function button(text, className = "button") { + const node = el("button", className, text); + node.type = "button"; + return node; +} + +// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили. +// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа. +export function confirmPanel(question, yesLabel) { + const root = el("div"); + root.hidden = true; + const text = el("p", "confirm", question); + const yes = button(yesLabel); + const no = button("отмена"); + const row = el("div", "row"); + row.append(yes, no); + root.append(text, row); + return { root, text, yes, no }; +} + +// message — строка состояния под формой: ошибка цветом mark, ответ — mute. +export function message() { + const node = el("p", "message"); + node.setAttribute("aria-live", "polite"); + return node; +} + +export function setError(node, text) { + node.className = "message message--error"; + node.textContent = text; +} + +export function setNote(node, text) { + node.className = "message"; + node.textContent = text; +} diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js new file mode 100644 index 0000000..4130793 --- /dev/null +++ b/web/js/ui/settings.js @@ -0,0 +1,179 @@ +// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы, +// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта. +// Уведомления, устройства, история и установка приложения — дальше по плану. + +import { ApiError } from "../api.js"; +import { fingerprintGroups } from "../crypto.js"; +import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; + +export function renderSettings(root, ctx) { + root.append(head(ctx)); + const body = el("div", "body settings"); + body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); + root.append(body); +} + +function head(ctx) { + const bar = el("div", "head"); + const back = button("назад", "back"); + back.addEventListener("click", () => ctx.go("#/")); + bar.append(back, el("span", "title", "настройки")); + return bar; +} + +// identity — «ты: @nick» и свой отпечаток группами по 4 в две строки. +function identity(ctx) { + const box = el("section", "block block--first"); + box.append(el("p", "self", `ты: @${ctx.me.nick}`)); + const groups = fingerprintGroups(ctx.me.fingerprint ?? ""); + box.append(el("p", "fp", groups.slice(0, 8).join(" "))); + box.append(el("p", "fp", groups.slice(8).join(" "))); + return box; +} + +function passwordBlock(ctx) { + const box = block("сменить пароль"); + const form = el("form", "form"); + form.noValidate = true; + + const current = field("старый", { type: "password", autocomplete: "current-password" }); + const next = field("новый", { type: "password", autocomplete: "new-password" }); + const again = field("повтор", { type: "password", autocomplete: "new-password" }); + form.append(current.wrap, next.wrap, again.wrap); + + const check = el("label", "check"); + const others = el("input"); + others.type = "checkbox"; + // Смена пароля по желанию завершает остальные сессии (ADR-015); + // снять галочку можно, но это осознанный выбор, а не умолчание. + others.checked = true; + check.append(others, el("span", null, "выйти на других устройствах")); + form.append(check); + + const submit = el("button", "button", "сменить пароль"); + submit.type = "submit"; + const note = message(); + form.append(submit, note); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) { + return; + } + setNote(note, ""); + if (next.input.value !== again.input.value) { + setError(note, "пароли не совпадают"); + return; + } + if ([...next.input.value].length < ctx.minPassword) { + setError(note, `пароль: не короче ${ctx.minPassword} символов`); + return; + } + submit.disabled = true; + submit.textContent = "вычисляем ключ…"; + try { + await ctx.changePassword(current.input.value, next.input.value, others.checked); + for (const input of [current.input, next.input, again.input]) { + input.value = ""; + } + setNote(note, "пароль изменён"); + } catch (err) { + setError(note, passwordError(ctx, err)); + } finally { + submit.disabled = false; + submit.textContent = "сменить пароль"; + } + }); + + box.append(form); + return box; +} + +function exitBlock(ctx) { + const box = block("выйти"); + const start = button("выйти"); + // Кнопки «экспортировать» пока нет: экспорт — этап 5 (docs/plan.md). + const panel = confirmPanel("история на этом устройстве будет удалена. экспортировать сначала?", "выйти"); + + start.addEventListener("click", () => { + start.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + panel.root.hidden = true; + start.hidden = false; + start.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + await ctx.signOut(); + ctx.go("#/"); + }); + + box.append(start, panel.root); + return box; +} + +function deleteBlock(ctx) { + const box = block("удалить аккаунт"); + const form = el("form", "form"); + form.noValidate = true; + + const password = field("пароль", { type: "password", autocomplete: "current-password" }); + const submit = el("button", "button", "удалить аккаунт"); + submit.type = "submit"; + const panel = confirmPanel("аккаунт и вся история будут удалены навсегда.", "удалить"); + const note = message(); + form.append(password.wrap, submit, panel.root, note); + + const back = () => { + panel.root.hidden = true; + panel.yes.disabled = false; + panel.no.disabled = false; + panel.yes.textContent = "удалить"; + submit.hidden = false; + }; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + setNote(note, ""); + submit.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + back(); + submit.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + panel.yes.textContent = "вычисляем ключ…"; + try { + await ctx.deleteAccount(password.input.value); + ctx.go("#/"); + } catch (err) { + back(); + setError(note, passwordError(ctx, err)); + } + }); + + box.append(form); + return box; +} + +function block(title) { + const box = el("section", "block"); + box.append(el("h2", "section", title)); + return box; +} + +// passwordError: в настройках 401 означает ровно одно — не тот пароль. +function passwordError(ctx, err) { + if (err instanceof ApiError && err.code === "invalid_credentials") { + return "неверный пароль"; + } + return ctx.errorText(err); +} diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js new file mode 100644 index 0000000..5b015cf --- /dev/null +++ b/web/js/ui/shell.js @@ -0,0 +1,38 @@ +// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» +// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые. + +import { el, mark } from "./dom.js"; + +// frame отдаёт корень и место под экран. screen — что показывать +// на мобильном, где виден один экран за раз: "list" или "screen". +export function frame(ctx, screen) { + const root = el("div", "shell"); + root.dataset.screen = screen; + const main = el("main", "main"); + root.append(side(ctx), main); + return { root, main }; +} + +function side(ctx) { + const nav = el("nav", "side"); + + const brand = el("div", "brand"); + brand.append(mark(), el("span", null, "bare")); + nav.append(brand); + + const list = el("div", "list"); + for (const title of ["каналы", "личные"]) { + list.append(el("h2", "section", title), el("ul", "items")); + } + nav.append(list); + + const me = el("button", "me"); + me.type = "button"; + const dot = el("i"); + dot.setAttribute("aria-hidden", "true"); + me.append(dot, el("span", null, `ты: @${ctx.me.nick}`)); + me.addEventListener("click", () => ctx.go("#/settings")); + nav.append(me); + + return nav; +} -- 2.54.0 From 63a7a1ef52803e5657c73c9957342eb0968f7092 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 18:18:04 +0300 Subject: [PATCH 4/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=202:=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=201:1=20=E2=80=94=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0,=20=D0=BE=D1=87=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D0=B4=D1=8C,=20SSE,=20=D1=88=D0=B8=D1=84=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=BE=D0=BE=D0=B1=D1=89?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: регистрация устройств и X-Device, hub с одним потоком на устройство, очередь per-device с фан-аутом без эха отправителю, POST /api/messages с проверками в порядке protocol.md, ACK, SSE с воспроизведением очереди, ready и пингом раз в 20 секунд, контакты в обе стороны при первом сообщении, лимит 30 сообщений в минуту. Клиент: ULID, ключ 1:1 из ECDH через HKDF, шифрование конверта с AAD, sync.js как единственный писатель в IndexedDB, ACK строго после записи, список чатов, экран чата по эталону, разделители дат и «новые», pending и failed с повтором, полоса «нет соединения». ADR-033: у неотправленного есть текст отказа — clock_skew стало видно. ADR-034: входящее с известным id не перезаписывает запись. Собеседник знает открытый id конверта и подменял им чужое сообщение в чужой истории — вплоть до стирания своего присланного, чего «удалить у всех не существует» не допускает. ADR-035: один поток событий на браузерный профиль (locks + BroadcastChannel): две вкладки отбирали поток друг у друга и оставались без живой доставки. ADR-036: повтор отправки сохраняет ULID, пока он в пределах окна часов, — иначе потерянный ответ давал у собеседника два сообщения вместо одного. Приёмка на боевом сервере: два аккаунта, пять устройств, живая доставка, копия на второе устройство, очередь офлайн-устройству, ACK, подмена from игнорируется, чужой deviceId и запрос без Origin отбиваются, плейнтекста в базе и WAL ноль вхождений. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- cmd/bare/main.go | 7 +- docs/decisions/033-failed-message-reason.md | 22 + docs/decisions/034-incoming-message-id.md | 22 + docs/decisions/035-one-stream-per-profile.md | 22 + docs/decisions/036-resend-keeps-ulid.md | 33 + docs/storage.md | 8 +- docs/ui.md | 3 + internal/api/api.go | 43 +- internal/api/api_test.go | 48 +- internal/api/contacts.go | 97 ++ internal/api/contacts_test.go | 85 ++ internal/api/devices.go | 120 +++ internal/api/devices_test.go | 180 ++++ internal/api/events.go | 118 +++ internal/api/events_test.go | 284 ++++++ internal/api/limit.go | 91 ++ internal/api/limit_test.go | 121 +++ internal/api/messages.go | 201 ++++ internal/api/messages_test.go | 377 ++++++++ internal/api/ulid.go | 37 + internal/api/valid.go | 8 + internal/hub/hub.go | 123 +++ internal/hub/hub_test.go | 170 ++++ internal/store/contacts.go | 65 ++ internal/store/devices.go | 129 +++ internal/store/queue.go | 133 +++ web/app.css | 373 +++++++- web/js/api.js | 102 ++- web/js/crypto.js | 94 ++ web/js/db.js | 253 +++++ web/js/main.js | 125 ++- web/js/sync.js | 911 +++++++++++++++++++ web/js/ui/chat.js | 425 +++++++++ web/js/ui/chats.js | 73 ++ web/js/ui/contact.js | 61 ++ web/js/ui/dom.js | 9 + web/js/ui/new.js | 68 ++ web/js/ui/shell.js | 27 +- web/js/ulid.js | 104 +++ 39 files changed, 5126 insertions(+), 46 deletions(-) create mode 100644 docs/decisions/033-failed-message-reason.md create mode 100644 docs/decisions/034-incoming-message-id.md create mode 100644 docs/decisions/035-one-stream-per-profile.md create mode 100644 docs/decisions/036-resend-keeps-ulid.md create mode 100644 internal/api/contacts.go create mode 100644 internal/api/contacts_test.go create mode 100644 internal/api/devices.go create mode 100644 internal/api/devices_test.go create mode 100644 internal/api/events.go create mode 100644 internal/api/events_test.go create mode 100644 internal/api/limit.go create mode 100644 internal/api/limit_test.go create mode 100644 internal/api/messages.go create mode 100644 internal/api/messages_test.go create mode 100644 internal/api/ulid.go create mode 100644 internal/hub/hub.go create mode 100644 internal/hub/hub_test.go create mode 100644 internal/store/contacts.go create mode 100644 internal/store/devices.go create mode 100644 internal/store/queue.go create mode 100644 web/js/sync.js create mode 100644 web/js/ui/chat.js create mode 100644 web/js/ui/chats.js create mode 100644 web/js/ui/contact.js create mode 100644 web/js/ui/new.js create mode 100644 web/js/ulid.js diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 076e3ce..17f5e7c 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -80,8 +80,9 @@ func serve() error { fmt.Printf("bare применил миграцию %s\n", name) } + h := api.New(cfg, st, static, os.Stdout) srv := &http.Server{ - Handler: api.New(cfg, st, static, os.Stdout), + Handler: h, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // OPTIONS * иначе обслуживает net/http сам, в обход middleware: @@ -90,6 +91,10 @@ func serve() error { // WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004). } + // Потоки событий не заканчиваются сами: без этого Shutdown ждал бы, + // пока подключённые клиенты уйдут, до самого таймаута (ADR-004). + srv.RegisterOnShutdown(h.Close) + // Сначала bind, потом сообщение: строка в журнале означает, что порт занят // нами, а не то, что мы собирались его занять. ln, err := net.Listen("tcp", cfg.Addr) diff --git a/docs/decisions/033-failed-message-reason.md b/docs/decisions/033-failed-message-reason.md new file mode 100644 index 0000000..db98880 --- /dev/null +++ b/docs/decisions/033-failed-message-reason.md @@ -0,0 +1,22 @@ +# ADR-033: Текст отказа у неотправленного сообщения + +## Контекст + +`docs/storage.md` задаёт судьбу исходящего: `202` → `sent`, сетевая ошибка → остаётся `pending`, `4xx` → `failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`. + +`docs/ui.md` описывает вторую половину так же наполовину. У сообщения в ленте есть пометка «не отправлено · повторить», одна на все причины. `clock_skew` записан в «Сеть и состояния» с текстом «проверьте часы на устройстве: расхождение больше 5 минут», но где он показывается — не сказано, а показать его негде: пометка у сообщения фиксирована, строка состояния формы (ADR-028) в чате не живёт. + +Этап 2 упёрся в это на первой же отправке. Причина отказа известна ровно в момент ответа сервера, а сообщение живёт дальше и переживает перезагрузку страницы. + +## Решение + +- Запись `messages` получает необязательное поле `error: string` — текст отказа, из-за которого сообщение стало `failed`. Тексты берутся из тех же перечней, что и у форм: коды `docs/protocol.md` и строки ADR-028. Новых строк интерфейса это решение не заводит. +- Поле живёт только у `failed`. Новая попытка отправки заводит запись с новым ULID и без него. +- Текст показывается полосой над вводом цветом `mark` — там же, где «нет соединения» и предупреждение о ключе. Место одно, как требует ADR-028; пометка «не отправлено · повторить» у самого сообщения не меняется. +- Поле служебное: на сервер не уходит и в архив `.bare` не пишется, как и `raw`. + +## Следствия + +- `clock_skew` наконец видно: расхождение часов объясняется словами, а не молчаливым «не отправлено». +- Текст переживает перезагрузку вместе с сообщением: он часть записи, а не состояние экрана. +- Причин у полосы над вводом становится три — нет соединения, ключ изменился, отказ отправки. Больше одной сразу не показывается: полоса одна. diff --git a/docs/decisions/034-incoming-message-id.md b/docs/decisions/034-incoming-message-id.md new file mode 100644 index 0000000..c3bb942 --- /dev/null +++ b/docs/decisions/034-incoming-message-id.md @@ -0,0 +1,22 @@ +# ADR-034: Входящее сообщение с известным id не перезаписывает запись + +## Контекст + +`docs/storage.md` описывал повтор доставки одной строкой: «`put` с тем же `id`, без дублей». Подразумевался тот же самый конверт — сервер выдаёт очередь заново при каждом подключении и вправе прислать конверт дважды (ADR-017). Клиент так и делал: писал `put` по `id` безусловно. + +`id` — открытое поле конверта, собеседник видит его сразу, как получает сообщение. Истории идентификаторов сервер не хранит: это противоречило бы ADR-008, — поэтому `POST /api/messages` с чужим `id` он принимает и раскладывает по очередям как любой другой. AAD сходится: в него входят `id`, `chat`, `from` и `keyId`, а `from` сервер ставит из сессии — конверт собеседника валиден и расшифровывается. Дальше `put` перезаписывал мою строку: в ленте вместо моего сообщения оказывался чужой текст, исходное исчезало, и фан-аут разносил подмену на остальные мои устройства. Тем же приёмом собеседник стирал и то, что прислал сам, — это прямо противоречит модели угроз: «„Удалить у всех“ после доставки не существует». + +Вторая половина того же места — счётчик. Все чтения `messages` выпускались до первого `put`, поэтому две копии одного конверта в одной пачке обе считались новыми и `unread` рос дважды. Такая пачка — не гипотеза: конверт, попавший в очередь в момент подключения, приходит и выдачей очереди, и живым событием. + +## Решение + +- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных. Строку, которая уже лежит на устройстве, входящий конверт не трогает. +- Повторный `id` внутри одной пачки учитывается один раз. +- Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`, где `id` свой и запись своя. +- Правило записано строкой в `docs/storage.md`. + +## Следствия + +- Знание `id` не даёт собеседнику ничего: подменяющий конверт у получателя молча пропадает. +- Нерасшифрованное чинится не повторной доставкой, а полем `raw` — оно для этого и хранится (`docs/storage.md`). +- Дубль доставки, разрешённый ADR-017, больше не двигает счётчик непрочитанных. diff --git a/docs/decisions/035-one-stream-per-profile.md b/docs/decisions/035-one-stream-per-profile.md new file mode 100644 index 0000000..d91cb18 --- /dev/null +++ b/docs/decisions/035-one-stream-per-profile.md @@ -0,0 +1,22 @@ +# ADR-035: Один поток событий на браузерный профиль + +## Контекст + +Устройство определяется браузерным профилем (ADR-017), а `docs/protocol.md` держит одно соединение на устройство: новое закрывает предыдущее. Про несколько вкладок одного профиля не сказано нигде, и клиент открывал `EventSource` в каждой. + +Две вкладки одного аккаунта отбирали поток друг у друга бесконечно: сервер закрывал предыдущее соединение, браузер переподключался через три секунды и закрывал соседнее. Замер — семь соединений за двадцать секунд, каждое ровно по три секунды, и после каждого `ready` ещё `GET /api/contacts` и повтор `pending`. Живой доставки при этом нет ни у одной вкладки: сообщения приходят только выдачей очереди, полоса состояния мигает, сервер получает два десятка запросов в минуту на пользователя — и так, пока открыты обе вкладки. + +## Решение + +- Поток открывает одна вкладка профиля — та, что держит замок `navigator.locks` с именем `bare-stream`. Замок берётся на всё время работы синхронизации и отпускается при выходе и при закрытии вкладки; следующая вкладка получает его сразу и открывает поток. +- Остальные вкладки потока не открывают. Экраны, чтение базы и отправка у них работают как прежде: `POST` потока не требует. +- Вкладки рассказывают друг другу об изменениях через `BroadcastChannel`: то же, что вкладка раздаёт своим экранам, — изменения лент, список чатов, состояние сети. Пишет в базу каждая сама, поэтому рассылают все, а не только владелец. +- Неотправленное повторяет только владелец потока: иначе одно сообщение ушло бы дважды, с разными ULID. +- Оба API нужны вместе: замок выбирает владельца, канал раздаёт его находки. Нет хотя бы одного — вкладка работает как единственная, то есть как до этого решения. + +## Следствия + +- Соединений к серверу столько, сколько браузерных профилей, а не открытых вкладок. +- Вкладка-наблюдатель показывает то же, что владелец, с задержкой в один `postMessage`. +- Новых текстов интерфейса решение не заводит: наблюдатель видит те же полосы, что владелец. +- Зависимостей не прибавляется: `navigator.locks` и `BroadcastChannel` — нативные браузерные API (ADR-001). diff --git a/docs/decisions/036-resend-keeps-ulid.md b/docs/decisions/036-resend-keeps-ulid.md new file mode 100644 index 0000000..51f9bd5 --- /dev/null +++ b/docs/decisions/036-resend-keeps-ulid.md @@ -0,0 +1,33 @@ +# ADR-036: Повтор отправки сохраняет ULID + +## Контекст + +ADR-017 присваивает ULID в момент попытки отправки, а не набора: сервер принимает сообщение, только если время в идентификаторе расходится с его часами не больше чем на пять минут. `docs/storage.md` довёл это до правила «при каждой попытке отправки `pending` получает новый ULID»: старая запись удалялась, новая писалась. + +У правила есть цена. `POST /api/messages` кладёт конверт в очередь и только потом отвечает `202`. Ответ теряется: обрыв на мобильной сети, закрытая вкладка, `502` от прокси. Сервер сообщение принял и разослал, клиент считает его неотправленным, оставляет `pending` и после следующего `ready` шлёт заново — уже с другим идентификатором. Собеседник видит один и тот же текст дважды, двумя разными записями, и склеить их нечем: `id` у них разные. Обрыв сразу после отправки — обычное дело на телефоне, а дубль остаётся в истории навсегда. + +Второй половины проблемы больше нет. ADR-034 заставил получателя игнорировать входящее с уже известным `id` целиком: ни записи, ни счётчика. Значит повтор с тем же идентификатором безвреден — сервер положит конверт в очередь (`ON CONFLICT DO NOTHING` либо новая строка, если прежнюю уже подтвердили), получатель его молча пропустит и подтвердит. Менять `id` нужно ровно тогда, когда прежний перестал годиться серверу. + +У переиспользования есть своя цена. Возраст идентификатора клиент считает по своим часам, сервер — по своим. Клиент отстаёт на две минуты, сообщение пролежало `pending` три с половиной: клиент видит запас нетронутым, сервер видит пять с половиной и отвечает `400 clock_skew` — тогда как прежнее правило дало бы свежий `id` с расхождением в две минуты и `202`. Кнопка «повторить» при этом бесполезна первые минуты: она берёт тот же `id` и получает тот же отказ, пока возраст не перевалит за запас. Оставить это пользователю нельзя: часы отстают на пару минут у любого устройства, которое давно не сверялось со временем, а сообщение при этом не уходит вовсе. + +## Решение + +- Повтор отправки идёт с прежним ULID. Запись не удаляется и не заводится заново: меняется только её состояние. +- Новый ULID берётся, когда время прежнего разошлось с текущим больше чем на четыре минуты. Тогда работает прежний порядок: старая запись удаляется, новая пишется. +- Запас — минута под серверным окном ±5 минут: за неё успевают шифрование, очередь работ клиента и сама сеть, так что дошедший запрос застаёт окно ещё открытым. +- Первая отправка ULID генерирует, как и раньше. +- Клиент сравнивает время идентификатора со своими часами: других у него нет, и первый ULID берётся из них же. +- `clock_skew` на переиспользованном идентификаторе отменяет переиспользование: прежний `id` снимается, попытка идёт второй раз со свежим. Ровно один раз — это та самая ситуация, ради которой `id` и меняется. Отказ на свежем `id` означает, что часы врут по-настоящему: сообщение становится `failed` с текстом про часы (ADR-033), второго круга нет. +- Сохранённый `id` оставляет и прежнее `ts`: время показа идёт за идентификатором, пока `202` не принесёт серверное. +- Правило записано строкой в `docs/storage.md` вместо прежнего. + +## Следствия + +- Потерянный ответ на `POST` больше не оборачивается дублем: повтор приходит собеседнику с тем же `id` и молча пропадает у него по ADR-034. +- Остаточный случай остаётся. Если ответ потерялся, а повтор случился позже окна — вкладку закрыли на час, устройство ушло в офлайн, — идентификатор сменится, и дубль появится. Иначе нельзя: сервер такое сообщение не примет вовсе. Вероятность теперь ничтожна, а раньше дубль давал любой обрыв. +- Экран не мигает. `removed` в уведомлении пуст, лента находит сообщение по прежнему `id` и перерисовывает одну строку вместо всей ленты. +- Умеренно врущие часы пользователь не разбирает. Расхождение, которое сервер видит только из-за переиспользования, снимает вторая попытка со свежим `id`; полоса про часы остаётся за настоящим расхождением — тем, что больше пяти минут и от идентификатора не зависит. +- Уточняется ADR-017: «ULID присваивается в момент попытки отправки» верно для идентификатора старше запаса. Более свежий переживает попытку, и время в нём — время первой из них. +- Уточняется ADR-033: новая попытка заводит запись без поля `error`, но не обязательно с новым ULID. +- Уточняется ADR-035. Правило «неотправленное повторяет только владелец потока» остаётся, но причина мельчает: две вкладки послали бы одно и то же сообщение дважды с одним `id`, а не два разных. +- Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе. diff --git a/docs/storage.md b/docs/storage.md index ba9204f..51868ba 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -114,6 +114,7 @@ chats key: id // "dm:" | "room:" messages key: id (ULID) index "chat": [chatId, id] {id, chatId, from, text: string|null, ts, status: "pending"|"sent"|"failed", + error?: string, // текст отказа у failed undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope} roomKeys key: [roomId, keyId] @@ -126,8 +127,9 @@ peers key: nick Правила: -- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Повтор доставки — `put` с тем же `id`, без дублей. -- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка → остаётся `pending` и повторяется при следующем подключении; `4xx` → `failed` с текстом ошибки. При каждой попытке отправки `pending` получает новый ULID (старая запись удаляется, новая пишется): сообщение ещё не покидало устройство, а его время должно совпадать с временем фактической отправки — иначе после долгого офлайна сервер ответит `clock_skew`. +- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. +- Входящее сообщение с уже известным `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). - `unread` и `lastReadId` — локальные, на сервер не уходят. - Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`. - Пагинация — курсор по индексу `chat` назад от последнего, по 50. @@ -135,4 +137,4 @@ peers key: nick ## Экспорт `.bare` -Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. +Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. diff --git a/docs/ui.md b/docs/ui.md index f14d895..ad289ff 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -38,6 +38,8 @@ Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. +Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется. + Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»). ## Карточка контакта (`#/contact/`) @@ -70,6 +72,7 @@ ## Сеть и состояния - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. +- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035). - Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`. - `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут». - `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет. diff --git a/internal/api/api.go b/internal/api/api.go index cd6f305..cdc9cba 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -15,6 +15,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/config" + "github.com/xmatic-squad/bare/internal/hub" "github.com/xmatic-squad/bare/internal/store" ) @@ -27,17 +28,36 @@ const maxLogPath = 256 // csp — политика из ADR-021. HSTS ставит nginx, здесь его нет. const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'" -// server — общее для обработчиков: настройки, база, куда писать журнал. +// server — общее для обработчиков: настройки, база, открытые потоки +// событий, лимиты, куда писать журнал. type server struct { cfg *config.Config st *store.Store + hub *hub.Hub + msgs *buckets logw io.Writer } +// Handler — обработчик всех маршрутов и живые SSE-потоки за ним. +type Handler struct { + http.Handler + hub *hub.Hub +} + +// Close закрывает открытые потоки событий. Без него остановка сервера +// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004). +func (h *Handler) Close() { h.hub.CloseAll() } + // New собирает обработчик: /api/, /healthz, всё остальное — статика. // logw — куда писать строки запросов и причины отказов; nil отключает лог. -func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler { - s := &server{cfg: cfg, st: st, logw: logw} +func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler { + s := &server{ + cfg: cfg, + st: st, + hub: hub.New(), + msgs: newBuckets(messagesPerMinute, messagesBurst), + logw: logw, + } fail := auth.Fail{Error: Error, Internal: s.internal} // Сессия проверяется на всех непубличных маршрутах (docs/protocol.md). private := auth.Require(st, fail) @@ -56,13 +76,28 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/password", private(http.HandlerFunc(s.password))) mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user))) + mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice))) + mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices))) + mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice))) + + mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts))) + mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) + mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact))) + + mux.Handle("GET /api/events", private(http.HandlerFunc(s.events))) + mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage))) + mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack))) + // Всё прочее под /api/ — 404, включая неподдерживаемый метод известного // пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт // запросам к /api/ уходить в обработчик статики. mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) }) mux.Handle("/", static) - return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))) + return &Handler{ + Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))), + hub: s.hub, + } } func healthz(w http.ResponseWriter, r *http.Request) { diff --git a/internal/api/api_test.go b/internal/api/api_test.go index fdc07b0..f3df3e3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "path/filepath" "strings" + "sync" "testing" "github.com/xmatic-squad/bare/internal/api" @@ -23,7 +24,34 @@ type env struct { t *testing.T h http.Handler st *store.Store - log *bytes.Buffer + log *syncLog + srv *httptest.Server +} + +// syncLog — журнал сервера в памяти. Под замком, потому что пишут в него +// и обработчики, вызванные напрямую, и обработчики настоящего сервера +// из live: у них разные горутины. +type syncLog struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (l *syncLog) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.Write(p) +} + +func (l *syncLog) String() string { + l.mu.Lock() + defer l.mu.Unlock() + return l.buf.String() +} + +func (l *syncLog) Reset() { + l.mu.Lock() + defer l.mu.Unlock() + l.buf.Reset() } func newEnv(t *testing.T) *env { return invited(t, "") } @@ -48,7 +76,7 @@ func invited(t *testing.T, code string) *env { VAPIDPublic: "vapid", InviteCode: code, } - e := &env{t: t, st: st, log: &bytes.Buffer{}} + e := &env{t: t, st: st, log: &syncLog{}} e.h = api.New(cfg, st, static, e.log) return e } @@ -81,6 +109,18 @@ func (e *env) do(method, target string, body any, opts ...func(*http.Request)) * return rec } +// live поднимает настоящий сервер на том же обработчике. Нужен потоку +// событий: httptest.ResponseRecorder не отдаёт тело, пока обработчик +// не вернулся, а поток не возвращается никогда. +func (e *env) live() *httptest.Server { + e.t.Helper() + if e.srv == nil { + e.srv = httptest.NewServer(e.h) + e.t.Cleanup(e.srv.Close) + } + return e.srv +} + func with(c *http.Cookie) func(*http.Request) { return func(r *http.Request) { if c != nil { @@ -89,6 +129,10 @@ func with(c *http.Cookie) func(*http.Request) { } } +func withDevice(id string) func(*http.Request) { + return func(r *http.Request) { r.Header.Set("X-Device", id) } +} + func withOrigin(value string) func(*http.Request) { return func(r *http.Request) { if value == "" { diff --git a/internal/api/contacts.go b/internal/api/contacts.go new file mode 100644 index 0000000..c37942f --- /dev/null +++ b/internal/api/contacts.go @@ -0,0 +1,97 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/store" +) + +// Контакт — строка в списке чатов, не разрешение на переписку: писать +// можно любому нику, согласия не требуется (ADR-019). + +// GET /api/contacts — список чатов 1:1 с публичными ключами собеседников. +func (s *server) contacts(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Contacts(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + type contactOut struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` + } + out := make([]contactOut, 0, len(list)) + for _, c := range list { + out = append(out, contactOut{c.Nick, json.RawMessage(c.PublicKey), c.CreatedAt}) + } + writeJSON(w, http.StatusOK, out) +} + +// POST /api/contacts — завести чат с ником вручную, до первого сообщения. +func (s *server) addContact(w http.ResponseWriter, r *http.Request) { + var in struct { + Nick string `json:"nick"` + } + if !decode(w, r, &in) { + return + } + sess, _ := auth.From(r) + peer, ok := s.peer(w, r, in.Nick, sess.Nick) + if !ok { + return + } + created, err := s.st.AddContact(r.Context(), sess.Nick, peer.Nick, time.Now().UnixMilli()) + if err != nil { + s.internal(w, r, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + }{peer.Nick, json.RawMessage(peer.PublicKey)}) +} + +// DELETE /api/contacts/{nick} — убрать чат из списка. Зеркальная строка +// у собеседника остаётся: это не блокировка (ADR-019). Строки не было — +// тот же 204, удалять нечего. +func (s *server) deleteContact(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + if err := s.st.DeleteContact(r.Context(), sess.Nick, r.PathValue("nick")); err != nil { + s.internal(w, r, err) + return + } + noContent(w) +} + +// peer читает собеседника по нику. Порядок отказов — docs/protocol.md: +// сначала существование ника, потом запрет писать себе. +func (s *server) peer(w http.ResponseWriter, r *http.Request, nick, me string) (store.User, bool) { + if !validNick(nick) { + unknownUser(w) + return store.User{}, false + } + u, err := s.st.User(r.Context(), nick) + if errors.Is(err, store.ErrNotFound) { + unknownUser(w) + return store.User{}, false + } + if err != nil { + s.internal(w, r, err) + return store.User{}, false + } + if u.Nick == me { + Error(w, http.StatusBadRequest, "self", "нельзя писать себе") + return store.User{}, false + } + return u, true +} diff --git a/internal/api/contacts_test.go b/internal/api/contacts_test.go new file mode 100644 index 0000000..2fff340 --- /dev/null +++ b/internal/api/contacts_test.go @@ -0,0 +1,85 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "testing" +) + +// contactOut — строка ответа GET /api/contacts. +type contactOut struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + CreatedAt int64 `json:"createdAt"` +} + +func (e *env) contacts(c *http.Cookie) []contactOut { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/contacts", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var out []contactOut + decodeBody(e.t, rec, &out) + return out +} + +func TestContacts(t *testing.T) { + e := newEnv(t) + marta := e.signUp("marta") + petya := e.signUp("petya") + + if got := e.contacts(marta); len(got) != 0 { + t.Fatalf("контакты нового аккаунта: %+v", got) + } + + rec := e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)) + expect(t, rec, http.StatusCreated, "") + var added struct { + Nick string `json:"nick"` + PublicKey json.RawMessage `json:"publicKey"` + } + decodeBody(t, rec, &added) + if added.Nick != "petya" || len(added.PublicKey) == 0 { + t.Errorf("ответ: %s", rec.Body.String()) + } + + // Повтор — 200 и та же строка. + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)), http.StatusOK, "") + if got := e.contacts(marta); len(got) != 1 || got[0].Nick != "petya" { + t.Errorf("контакты marta: %+v", got) + } + // Зеркальной строки POST не заводит: она появляется при первом + // сообщении (ADR-019). + if got := e.contacts(petya); len(got) != 0 { + t.Errorf("контакты petya: %+v", got) + } + + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(marta)), + http.StatusBadRequest, "self") + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "kolya"}, with(marta)), + http.StatusNotFound, "unknown_user") + expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "МАРТА"}, with(marta)), + http.StatusNotFound, "unknown_user") +} + +// Удаляется только своя строка: зеркальная у собеседника остаётся, +// это не блокировка (ADR-019). +func TestDeleteContact(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "") + if got := e.contacts(marta); len(got) != 0 { + t.Errorf("контакты marta: %+v", got) + } + if got := e.contacts(petya); len(got) != 1 || got[0].Nick != "marta" { + t.Errorf("контакты petya: %+v", got) + } + + // Удалять нечего — тот же ответ. + expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "") + expect(t, e.do(http.MethodDelete, "/api/contacts/kolya", nil, with(marta)), http.StatusNoContent, "") +} diff --git a/internal/api/devices.go b/internal/api/devices.go new file mode 100644 index 0000000..7287e75 --- /dev/null +++ b/internal/api/devices.go @@ -0,0 +1,120 @@ +package api + +import ( + "errors" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/store" +) + +// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент +// (ADR-017), сервер только проверяет форму и принадлежность. +type deviceID struct { + ID string `json:"id"` +} + +// POST /api/devices — регистрация устройства. Уже заведённое своё — +// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id. +func (s *server) createDevice(w http.ResponseWriter, r *http.Request) { + var in deviceID + if !decode(w, r, &in) { + return + } + if !validID(in.ID) { + Invalid(w, "id", "id — не 16 байт base64url") + return + } + sess, _ := auth.From(r) + created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli()) + if errors.Is(err, store.ErrDeviceTaken) { + Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть") + return + } + if err != nil { + s.internal(w, r, err) + return + } + status := http.StatusOK + if created { + status = http.StatusCreated + } + writeJSON(w, status, deviceID{in.ID}) +} + +// deviceOut — строка ответа GET /api/devices. +type deviceOut struct { + ID string `json:"id"` + CreatedAt int64 `json:"createdAt"` + LastSeen int64 `json:"lastSeen"` + HasPush bool `json:"hasPush"` + Current bool `json:"current"` +} + +// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе +// нет, только факт её наличия. +func (s *server) devices(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Devices(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + out := make([]deviceOut, 0, len(list)) + for _, d := range list { + out = append(out, deviceOut{ + ID: d.ID, + CreatedAt: d.CreatedAt, + LastSeen: d.LastSeen, + HasPush: d.HasPush, + Current: d.ID == sess.DeviceID, + }) + } + writeJSON(w, http.StatusOK, out) +} + +// DELETE /api/devices/{id} — удаление устройства: очередь, подписка +// и сессии уходят каскадом, открытый поток событий закрывается. +// +// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего, +// а отдельного кода на этот случай в протоколе нет (docs/protocol.md). +func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + id := r.PathValue("id") + deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if deleted { + s.hub.Close(id) + } + noContent(w) +} + +// device читает X-Device и проверяет, что устройство принадлежит +// пользователю сессии. Заголовка нет, форма кривая, устройство чужое — +// всё это 403 unknown_device (docs/protocol.md, «Общие правила»). +func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) { + sess, _ := auth.From(r) + id := r.Header.Get("X-Device") + if !validID(id) { + unknownDevice(w) + return "", false + } + owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick) + if err != nil { + s.internal(w, r, err) + return "", false + } + if !owned { + unknownDevice(w) + return "", false + } + return id, true +} + +func unknownDevice(w http.ResponseWriter) { + Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше") +} diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go new file mode 100644 index 0000000..9cc7911 --- /dev/null +++ b/internal/api/devices_test.go @@ -0,0 +1,180 @@ +package api_test + +import ( + "net/http" + "testing" +) + +// deviceOf — идентификатор устройства: 16 байт base64url (docs/crypto.md). +func deviceOf(seed byte) string { return bytesOf(16, seed) } + +// join регистрирует аккаунт и его устройство, отдаёт cookie и id. +func (e *env) join(nick string, seed byte) (*http.Cookie, string) { + e.t.Helper() + c := e.signUp(nick) + return c, e.addDevice(c, deviceOf(seed)) +} + +// addDevice регистрирует устройство под уже открытой сессией. +func (e *env) addDevice(c *http.Cookie, id string) string { + e.t.Helper() + rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + if rec.Code != http.StatusCreated && rec.Code != http.StatusOK { + e.t.Fatalf("регистрация устройства: %d (%s)", rec.Code, rec.Body.String()) + } + return id +} + +func TestDevices(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + id := deviceOf(1) + + first := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + expect(t, first, http.StatusCreated, "") + var created struct { + ID string `json:"id"` + } + decodeBody(t, first, &created) + if created.ID != id { + t.Errorf("id в ответе: получено %q, ожидалось %q", created.ID, id) + } + + // Повтор — то же устройство того же пользователя. + expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)), http.StatusOK, "") + + rec := e.do(http.MethodGet, "/api/devices", nil, with(c)) + expect(t, rec, http.StatusOK, "") + var list []struct { + ID string `json:"id"` + CreatedAt int64 `json:"createdAt"` + LastSeen int64 `json:"lastSeen"` + HasPush bool `json:"hasPush"` + Current bool `json:"current"` + } + decodeBody(t, rec, &list) + if len(list) != 1 { + t.Fatalf("устройств: получено %d, ожидалось 1", len(list)) + } + if list[0].ID != id || list[0].CreatedAt == 0 || list[0].LastSeen == 0 || list[0].HasPush || !list[0].Current { + t.Errorf("устройство: %+v", list[0]) + } + + // Второе устройство — своя сессия, свой вход. current у каждой сессии + // своё: сессия привязана к устройству (ADR-021). + 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) + e.addDevice(second, deviceOf(2)) + + rec = e.do(http.MethodGet, "/api/devices", nil, with(c)) + decodeBody(t, rec, &list) + if len(list) != 2 { + t.Fatalf("устройств: получено %d, ожидалось 2", len(list)) + } + if !list[0].Current || list[1].Current { + t.Errorf("текущее устройство первой сессии: %+v", list) + } + rec = e.do(http.MethodGet, "/api/devices", nil, with(second)) + decodeBody(t, rec, &list) + if list[0].Current || !list[1].Current { + t.Errorf("текущее устройство второй сессии: %+v", list) + } + + // Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает + // 404 not_found (ADR-026). + expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)), + http.StatusNotFound, "not_found") +} + +// Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017). +func TestDeviceConflict(t *testing.T) { + e := newEnv(t) + marta, id := e.join("marta", 1) + petya := e.signUp("petya") + + expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(petya)), + http.StatusConflict, "device_conflict") + + // Устройство осталось за прежним владельцем. + rec := e.do(http.MethodGet, "/api/devices", nil, with(marta)) + var mine []struct { + ID string `json:"id"` + } + decodeBody(t, rec, &mine) + if len(mine) != 1 || mine[0].ID != id { + t.Errorf("устройства marta: %+v", mine) + } + rec = e.do(http.MethodGet, "/api/devices", nil, with(petya)) + var theirs []struct { + ID string `json:"id"` + } + decodeBody(t, rec, &theirs) + if len(theirs) != 0 { + t.Errorf("устройства petya: %+v", theirs) + } +} + +func TestDeviceIDForm(t *testing.T) { + e := newEnv(t) + c := e.signUp("marta") + + for _, id := range []any{"", "короткий", bytesOf(8, 1), bytesOf(32, 1), 42} { + rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)) + if rec.Code == http.StatusCreated || rec.Code == http.StatusOK { + t.Errorf("id %v принят: %d", id, rec.Code) + } + } +} + +// X-Device чужого пользователя — 403 unknown_device на всех маршрутах, +// где устройство важно (docs/protocol.md, «Общие правила»). +func TestForeignDevice(t *testing.T) { + e := newEnv(t) + _, martaDevice := e.join("marta", 1) + petya, petyaDevice := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "marta") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + + // Заголовка нет вовсе или в нём мусор — тот же ответ. + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(deviceOf(9))), + http.StatusForbidden, "unknown_device") + + // Со своим устройством — обычная отправка. + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"), + with(petya), withDevice(petyaDevice)), http.StatusAccepted, "") +} + +// Удаление устройства уносит очередь и сессии устройства. +func TestDeleteDevice(t *testing.T) { + e := newEnv(t) + marta, martaDevice := e.join("marta", 1) + petya, petyaDevice := e.join("petya", 2) + + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(martaDevice)), http.StatusAccepted, "") + if got := e.queue(petyaDevice); len(got) != 1 { + t.Fatalf("очередь petya: получено %d конвертов, ожидался 1", len(got)) + } + + // Чужое устройство удалить нельзя — и это не ошибка. + expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(marta)), http.StatusNoContent, "") + if got := e.queue(petyaDevice); len(got) != 1 { + t.Errorf("очередь petya после чужого удаления: получено %d конвертов", len(got)) + } + + expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(petya)), http.StatusNoContent, "") + if got := e.queue(petyaDevice); len(got) != 0 { + t.Errorf("очередь после удаления устройства: получено %d конвертов, ожидалось 0", len(got)) + } + // Сессия, привязанная к устройству, ушла каскадом. + expect(t, e.do(http.MethodGet, "/api/me", nil, with(petya)), http.StatusUnauthorized, "unauthenticated") +} diff --git a/internal/api/events.go b/internal/api/events.go new file mode 100644 index 0000000..42acb4f --- /dev/null +++ b/internal/api/events.go @@ -0,0 +1,118 @@ +package api + +import ( + "fmt" + "io" + "net/http" + "time" + + "github.com/xmatic-squad/bare/internal/auth" +) + +// pingEvery — период комментария-пинга: он держит соединение живым +// через прокси и показывает клиенту, что поток цел (docs/protocol.md). +const pingEvery = 20 * time.Second + +// GET /api/events?device= — поток событий устройства (ADR-004). +// Устройство передаётся в query: EventSource не умеет заголовки. +// +// Last-Event-ID игнорируется: механизм восстановления — не докрутка +// по идентификатору, а повторная выдача очереди при каждом подключении. +func (s *server) events(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + device := r.URL.Query().Get("device") + if !validID(device) { + unknownDevice(w) + return + } + owned, err := s.st.DeviceOwned(r.Context(), device, sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if !owned { + unknownDevice(w) + return + } + + // Порядок — docs/protocol.md, «События»: сначала push_pending и + // last_seen, потом поток, потом очередь. Сорвавшаяся запись last_seen + // не должна рвать исправный поток устройства, а он был бы уже закрыт + // открытием нового. + now := time.Now().UnixMilli() + if err := s.st.TouchDevice(r.Context(), device, now); err != nil { + s.internal(w, r, err) + return + } + + // Поток открывается до чтения очереди: конверт, попавший в очередь + // между выборкой и подпиской, иначе пролежал бы там до следующего + // подключения. Обратная крайность — дубль, а его клиент сливает по id + // (ADR-017). Открытие закрывает прежний поток этого устройства. + stream := s.hub.Open(device) + defer stream.Close() + + queued, err := s.st.Queue(r.Context(), device) + if err != nil { + s.internal(w, r, err) + return + } + + head := w.Header() + head.Set("Content-Type", "text/event-stream") + head.Set("Cache-Control", "no-cache") + // nginx буферизует ответы проксируемых приложений; для потока это + // означало бы, что события копятся и не уходят (docs/deploy.md). + head.Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + + send := sender(w) + for _, envelope := range queued { + if !send("msg", envelope) { + return + } + } + if !send("ready", "{}") { + return + } + + ping := time.NewTicker(pingEvery) + defer ping.Stop() + for { + select { + case <-r.Context().Done(): + // Клиент ушёл. + return + case <-stream.Done(): + // Поток закрыли: новое соединение того же устройства, + // удаление устройства или остановка сервера. + return + case ev := <-stream.Events(): + if !send(ev.Name, ev.Data) { + return + } + case <-ping.C: + if !write(w, ": ping\n\n") { + return + } + } + } +} + +// sender собирает функцию записи события. Данные — компактный JSON +// без переводов строки, поэтому кадр SSE собирается одной строкой data. +// Ответ false означает, что писать больше некуда: соединение оборвалось. +func sender(w http.ResponseWriter) func(name, data string) bool { + return func(name, data string) bool { + return write(w, fmt.Sprintf("event: %s\ndata: %s\n\n", name, data)) + } +} + +func write(w http.ResponseWriter, frame string) bool { + if _, err := io.WriteString(w, frame); err != nil { + return false + } + // Без Flush кадр остался бы в буфере net/http до конца ответа, + // а конца у потока нет. + return http.NewResponseController(w).Flush() == nil +} diff --git a/internal/api/events_test.go b/internal/api/events_test.go new file mode 100644 index 0000000..b08b37d --- /dev/null +++ b/internal/api/events_test.go @@ -0,0 +1,284 @@ +package api_test + +import ( + "bufio" + "context" + "io" + "net/http" + "net/url" + "strings" + "testing" + "time" +) + +// wait — сколько тест ждёт события. Всё локально, задержек быть не должно. +const wait = 2 * time.Second + +// sseEvent — одно событие потока. +type sseEvent struct { + name string + data string +} + +// stream — открытый GET /api/events. Идёт через настоящий сервер: +// httptest.ResponseRecorder не отдаёт тело, пока обработчик не вернулся. +type stream struct { + t *testing.T + ctx context.Context + cancel context.CancelFunc + events chan sseEvent + head http.Header +} + +// open подключается к потоку событий устройства. +func (e *env) open(device string, c *http.Cookie) *stream { + e.t.Helper() + srv := e.live() + ctx, cancel := context.WithCancel(context.Background()) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, + srv.URL+"/api/events?device="+url.QueryEscape(device), nil) + if err != nil { + cancel() + e.t.Fatalf("запрос: %v", err) + } + req.AddCookie(c) + resp, err := srv.Client().Do(req) + if err != nil { + cancel() + e.t.Fatalf("подключение: %v", err) + } + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + cancel() + e.t.Fatalf("статус потока: получено %d, ожидалось 200", resp.StatusCode) + } + s := &stream{t: e.t, ctx: ctx, cancel: cancel, events: make(chan sseEvent, 64), head: resp.Header} + go s.read(resp.Body) + e.t.Cleanup(s.close) + return s +} + +// read разбирает кадры SSE: строки event и data, пустая строка — конец +// события, строка с двоеточия — комментарий-пинг. +func (s *stream) read(body io.ReadCloser) { + defer body.Close() + defer close(s.events) + + sc := bufio.NewScanner(body) + var ev sseEvent + for sc.Scan() { + line := sc.Text() + switch { + case line == "": + if ev.name == "" { + continue + } + select { + case s.events <- ev: + case <-s.ctx.Done(): + return + } + ev = sseEvent{} + case strings.HasPrefix(line, ":"): + case strings.HasPrefix(line, "event: "): + ev.name = strings.TrimPrefix(line, "event: ") + case strings.HasPrefix(line, "data: "): + ev.data = strings.TrimPrefix(line, "data: ") + } + } +} + +// next ждёт следующее событие. +func (s *stream) next() sseEvent { + s.t.Helper() + select { + case ev, ok := <-s.events: + if !ok { + s.t.Fatal("поток закрылся, события нет") + } + return ev + case <-time.After(wait): + s.t.Fatal("событие не пришло") + } + return sseEvent{} +} + +// untilReady собирает события до ready — то, что лежало в очереди. +func (s *stream) untilReady() []sseEvent { + s.t.Helper() + var out []sseEvent + for { + ev := s.next() + if ev.name == "ready" { + if ev.data != "{}" { + s.t.Errorf("данные ready: получено %q, ожидалось \"{}\"", ev.data) + } + return out + } + out = append(out, ev) + } +} + +// ended ждёт, что поток закроет сервер. +func (s *stream) ended() { + s.t.Helper() + select { + case ev, ok := <-s.events: + if ok { + s.t.Fatalf("вместо закрытия пришло событие %q", ev.name) + } + case <-time.After(wait): + s.t.Fatal("поток не закрылся") + } +} + +func (s *stream) close() { s.cancel() } + +// Порядок после подключения: очередь, ready, живые события +// (docs/protocol.md, «События»). +func TestEventsQueueThenReady(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + first := ulid(nowMillis(), 3) + second := ulid(nowMillis()+1, 4) + for _, id := range []string{first, second} { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + + s := e.open(p1, petya) + for header, value := range map[string]string{ + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + } { + if got := s.head.Get(header); got != value { + t.Errorf("%s: получено %q, ожидалось %q", header, got, value) + } + } + + queued := s.untilReady() + if len(queued) != 2 { + t.Fatalf("событий из очереди: получено %d, ожидалось 2", len(queued)) + } + for i, ev := range queued { + if ev.name != "msg" { + t.Errorf("событие %d: получено %q, ожидалось \"msg\"", i, ev.name) + } + if !strings.Contains(ev.data, `"from":"marta"`) { + t.Errorf("конверт %d: %s", i, ev.data) + } + } + if !strings.Contains(queued[0].data, first) || !strings.Contains(queued[1].data, second) { + t.Errorf("порядок очереди: %q, %q", queued[0].data, queued[1].data) + } + + // Реконнект без ACK повторяет очередь целиком: Last-Event-ID сервер + // не смотрит (docs/protocol.md, «События»). + s.close() + again := e.open(p1, petya) + if got := again.untilReady(); len(got) != 2 { + t.Fatalf("после реконнекта: получено %d событий, ожидалось 2", len(got)) + } + + // После ACK очередь пуста, остаётся только ready. + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}}, + with(petya), withDevice(p1)), http.StatusNoContent, "") + again.close() + third := e.open(p1, petya) + if got := third.untilReady(); len(got) != 0 { + t.Errorf("после ack: получено %d событий, ожидалось 0", len(got)) + } +} + +// Подключённое устройство получает конверт сразу после коммита. +func TestEventsLive(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + s := e.open(p1, petya) + if got := s.untilReady(); len(got) != 0 { + t.Fatalf("очередь нового устройства: %+v", got) + } + + id := ulid(nowMillis(), 3) + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + ev := s.next() + if ev.name != "msg" || !strings.Contains(ev.data, id) { + t.Errorf("живое событие: %+v", ev) + } + // Живая доставка не отменяет ACK: конверт лежит в очереди до него. + if got := e.queue(p1); len(got) != 1 { + t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got)) + } +} + +// Отправитель эха не получает даже живьём, другие его устройства — да. +func TestEventsNoEchoToSender(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + e.join("petya", 3) + + sender := e.open(m1, marta) + sender.untilReady() + other := e.open(m2, marta) + other.untilReady() + + id := ulid(nowMillis(), 4) + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + if ev := other.next(); ev.name != "msg" || !strings.Contains(ev.data, id) { + t.Errorf("второе устройство отправителя: %+v", ev) + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Одно соединение на устройство: новое закрывает предыдущее. +func TestEventsSingleConnection(t *testing.T) { + e := newEnv(t) + petya, p1 := e.join("petya", 1) + + first := e.open(p1, petya) + first.untilReady() + second := e.open(p1, petya) + second.untilReady() + + first.ended() +} + +// Удаление устройства закрывает его поток (docs/protocol.md, «Устройства»). +func TestEventsClosedOnDeviceDelete(t *testing.T) { + e := newEnv(t) + petya, p1 := e.join("petya", 1) + + s := e.open(p1, petya) + s.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/devices/"+p1, nil, with(petya)), http.StatusNoContent, "") + s.ended() +} + +// Чужое устройство в query — 403 unknown_device, поток не открывается. +func TestEventsUnknownDevice(t *testing.T) { + e := newEnv(t) + _, martaDevice := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + for _, device := range []string{martaDevice, deviceOf(9), "мусор", ""} { + rec := e.do(http.MethodGet, "/api/events?device="+url.QueryEscape(device), nil, with(petya)) + expect(t, rec, http.StatusForbidden, "unknown_device") + } + // Без сессии — обычный 401. + expect(t, e.do(http.MethodGet, "/api/events?device="+martaDevice, nil), http.StatusUnauthorized, "unauthenticated") +} diff --git a/internal/api/limit.go b/internal/api/limit.go new file mode 100644 index 0000000..b9eeacf --- /dev/null +++ b/internal/api/limit.go @@ -0,0 +1,91 @@ +package api + +import ( + "math" + "sync" + "time" +) + +// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10. +// Остальные лимиты — этап 6. +const ( + messagesPerMinute = 30 + messagesBurst = 10 +) + +// sweepAt — с какого размера карты имеет смысл выкидывать полные вёдра. +const sweepAt = 1024 + +// buckets — token bucket в памяти сервера, по ведру на ключ (ник). +// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021). +type buckets struct { + mu sync.Mutex + rate float64 // токенов в секунду + burst float64 + seen map[string]*bucket +} + +type bucket struct { + tokens float64 + at time.Time +} + +func newBuckets(perMinute, burst int) *buckets { + return &buckets{ + rate: float64(perMinute) / 60, + burst: float64(burst), + seen: make(map[string]*bucket), + } +} + +// take забирает токен. Второе значение — можно ли; если нет, первое — +// сколько ждать до следующего токена. +func (b *buckets) take(key string, now time.Time) (time.Duration, bool) { + b.mu.Lock() + defer b.mu.Unlock() + + e, ok := b.seen[key] + 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.at = now + if e.tokens < 1 { + return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false + } + e.tokens-- + return 0, true +} + +// refill — сколько токенов набежало. Время назад не идёт: часы могли +// прыгнуть, но долг за это выставлять некому. +func (b *buckets) refill(since, now time.Time) float64 { + d := now.Sub(since) + if d <= 0 { + return 0 + } + 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 — значение заголовка в секундах, не меньше одной: нулевое +// ожидание после отказа сбивало бы клиента с толку. +func retryAfter(wait time.Duration) int { + if wait < time.Second { + return 1 + } + return int(math.Ceil(wait.Seconds())) +} diff --git a/internal/api/limit_test.go b/internal/api/limit_test.go new file mode 100644 index 0000000..90366e0 --- /dev/null +++ b/internal/api/limit_test.go @@ -0,0 +1,121 @@ +package api + +import ( + "testing" + "time" +) + +// Token bucket из ADR-021: 30 в минуту, пакет 10. +func TestBuckets(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < messagesBurst; i++ { + if _, ok := b.take("marta", now); !ok { + t.Fatalf("запрос %d из пакета отклонён", i+1) + } + } + wait, ok := b.take("marta", now) + if ok { + t.Fatal("пакет не кончился") + } + // Тридцать в минуту — токен раз в две секунды. + if wait != 2*time.Second { + t.Errorf("ожидание: получено %v, ожидалось 2s", wait) + } + if got := retryAfter(wait); got != 2 { + t.Errorf("Retry-After: получено %d, ожидалось 2", got) + } + + // Через две секунды набегает ровно один токен. + if _, ok := b.take("marta", now.Add(2*time.Second)); !ok { + t.Error("токен не набежал") + } + if _, ok := b.take("marta", now.Add(2*time.Second)); ok { + t.Error("набежало больше одного токена") + } + + // Ведро не переполняется: за час копится пакет, не тридцать в минуту. + for i := 0; i < messagesBurst; i++ { + if _, ok := b.take("marta", now.Add(time.Hour)); !ok { + t.Fatalf("запрос %d после долгой паузы отклонён", i+1) + } + } + if _, ok := b.take("marta", now.Add(time.Hour)); ok { + t.Error("ведро больше пакета") + } + + // Лимит на ключ: чужое ведро полное. + if _, ok := b.take("petya", now); !ok { + t.Error("лимит одного пользователя задел другого") + } +} + +// Часы могут прыгнуть назад; долг за это никому не выставляется. +func TestBucketsClockBack(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < messagesBurst; i++ { + b.take("marta", now) + } + if _, ok := b.take("marta", now.Add(-time.Hour)); ok { + t.Error("время назад добавило токенов") + } +} + +// Полные вёдра выкидываются: карта не растёт на каждый ник навсегда. +func TestBucketsSweep(t *testing.T) { + b := newBuckets(messagesPerMinute, messagesBurst) + now := time.Now() + + for i := 0; i < sweepAt; i++ { + b.take(string(rune(i)), now) + } + if len(b.seen) != sweepAt { + t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt) + } + // Все вёдра успели наполниться заново — чистка их и уносит. + b.take("marta", now.Add(time.Hour)) + if len(b.seen) != 1 { + t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen)) + } +} + +// Ждать меньше секунды бессмысленно: Retry-After в секундах. +func TestRetryAfter(t *testing.T) { + cases := map[time.Duration]int{ + 0: 1, + 100 * time.Millisecond: 1, + time.Second: 1, + 1500 * time.Millisecond: 2, + 2 * time.Second: 2, + } + for wait, want := range cases { + if got := retryAfter(wait); got != want { + t.Errorf("retryAfter(%v): получено %d, ожидалось %d", wait, got, want) + } + } +} + +// ULID: 26 символов Crockford base32, время в первых десяти. +func TestULIDTime(t *testing.T) { + // 01ARZ3NDEK — 2016-07-30T23:54:10.259Z. + ms, ok := ulidTime("01ARZ3NDEKTSV4RRFFQ69G5FAV") + if !ok || ms != 1469922850259 { + t.Errorf("ulidTime: получено %d, %v; ожидалось 1469922850259", ms, ok) + } + for _, id := range []string{ + "", + "01ARZ3NDEKTSV4RRFFQ69G5FA", // 25 символов + "01ARZ3NDEKTSV4RRFFQ69G5FAVX", // 27 символов + "01arz3ndektsv4rrffq69g5fav", // строчные + "01ARZ3NDEKTSV4RRFFQ69G5FAU", // U вне алфавита Crockford + "81ARZ3NDEKTSV4RRFFQ69G5FAV", // время больше 48 бит + "01ARZ3NDEKTSV4RRFFQ69G5F☺", + } { + if _, ok := ulidTime(id); ok { + t.Errorf("принят кривой ulid %q", id) + } + } +} diff --git a/internal/api/messages.go b/internal/api/messages.go new file mode 100644 index 0000000..ab56c50 --- /dev/null +++ b/internal/api/messages.go @@ -0,0 +1,201 @@ +package api + +import ( + "encoding/json" + "net/http" + "strconv" + "time" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/store" +) + +// clockSkew — на сколько метка времени ULID вправе разойтись с часами +// сервера (ADR-017). +const clockSkew = 5 * time.Minute + +// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора +// у него нет (docs/crypto.md, «Сообщение»). +const dmKeyID = "dm" + +// maxAck — сколько идентификаторов принимает один ACK. +const maxAck = 500 + +// target — адресат конверта: ровно одно из двух. +type target struct { + DM string `json:"dm,omitempty"` + Room string `json:"room,omitempty"` +} + +// envelope — конверт из docs/protocol.md. Порядок полей — как в нём. +// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017). +type envelope struct { + ID string `json:"id"` + To target `json:"to"` + From string `json:"from"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` + TS int64 `json:"ts"` +} + +// messageIn — тело POST /api/messages. Полей from и ts здесь нет +// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017). +type messageIn struct { + ID string `json:"id"` + To target `json:"to"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// POST /api/messages — отправка. Сервер не умеет проверять шифротекст, +// он проверяет форму и раскладывает конверт по очередям (ADR-008). +// Порядок проверок — docs/protocol.md, «Сообщения». +func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { + device, ok := s.device(w, r) + if !ok { + return + } + var in messageIn + if !decode(w, r, &in) { + return + } + ms, ok := checkForm(w, in) + if !ok { + return + } + + now := time.Now() + if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew { + Error(w, http.StatusBadRequest, "clock_skew", + "проверьте часы на устройстве: расхождение больше 5 минут") + return + } + + if in.To.Room != "" { + // Комнаты — этап 3. Участников нет ни у одной комнаты, потому что + // нет и самих комнат: единственный возможный ответ — not_member. + Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") + return + } + sess, _ := auth.From(r) + if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { + return + } + + if wait, ok := s.msgs.take(sess.Nick, now); !ok { + s.rateLimited(w, wait) + return + } + + env := envelope{ + ID: in.ID, + To: target{DM: in.To.DM}, + From: sess.Nick, + KeyID: in.KeyID, + IV: in.IV, + CT: in.CT, + TS: now.UnixMilli(), + } + raw, err := json.Marshal(env) + if err != nil { + s.internal(w, r, err) + return + } + devices, err := s.st.DeliverDM(r.Context(), store.Delivery{ + From: env.From, + To: env.To.DM, + Exclude: device, + MsgID: env.ID, + Envelope: string(raw), + Now: env.TS, + }) + if err != nil { + s.internal(w, r, err) + return + } + // Очередь уже записана: подключённое устройство получает конверт + // сразу, остальные — при подключении. Пуши — этап 4. + for _, id := range devices { + s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)}) + } + writeJSON(w, http.StatusAccepted, struct { + ID string `json:"id"` + TS int64 `json:"ts"` + }{env.ID, env.TS}) +} + +// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер +// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан, +// если вернулось false. +func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) { + ms, ok := ulidTime(in.ID) + if !ok { + Invalid(w, "id", "id — не ulid из 26 символов") + return 0, false + } + if (in.To.DM == "") == (in.To.Room == "") { + Invalid(w, "to", "to — ровно одно из dm и room") + return 0, false + } + if in.To.DM != "" { + if !validNick(in.To.DM) { + Invalid(w, "to", "ник: 2–32 символа, a–z, 0–9, _") + return 0, false + } + if in.KeyID != dmKeyID { + Invalid(w, "keyId", `keyId личного чата — "dm"`) + return 0, false + } + } else { + if !validID(in.To.Room) { + Invalid(w, "to", "room — не 16 байт base64url") + return 0, false + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return 0, false + } + } + if _, ok := decodeExactly(in.IV, ivLen); !ok { + Invalid(w, "iv", "iv — не 12 байт base64url") + return 0, false + } + if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen { + Invalid(w, "ct", "ct — не base64url или слишком короткий") + return 0, false + } + return ms, true +} + +// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди +// устройства их можно убрать (ADR-008). +func (s *server) ack(w http.ResponseWriter, r *http.Request) { + device, ok := s.device(w, r) + if !ok { + return + } + var in struct { + IDs []string `json:"ids"` + } + if !decode(w, r, &in) { + return + } + if len(in.IDs) > maxAck { + Invalid(w, "ids", "не больше 500 идентификаторов") + return + } + if err := s.st.Ack(r.Context(), device, in.IDs); err != nil { + s.internal(w, r, err) + return + } + 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", "слишком часто, попробуйте позже") +} diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go new file mode 100644 index 0000000..1d1e721 --- /dev/null +++ b/internal/api/messages_test.go @@ -0,0 +1,377 @@ +package api_test + +import ( + "context" + "encoding/json" + "net/http" + "strconv" + "testing" + "time" +) + +// crockford — алфавит ULID (docs/crypto.md, «Идентификаторы»). +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +func nowMillis() int64 { return time.Now().UnixMilli() } + +// ulid собирает ULID с заданным временем: первые десять символов — +// 48 бит миллисекунд, остальные шестнадцать — 80 бит «случайности». +func ulid(ms int64, seed byte) string { + out := make([]byte, 26) + for i := 9; i >= 0; i-- { + out[i] = crockford[ms&31] + ms >>= 5 + } + for i := 10; i < 26; i++ { + out[i] = crockford[(int(seed)+i)%32] + } + return string(out) +} + +// message — тело POST /api/messages в личный чат. Шифротекст сервер +// не проверяет: ему важна только форма. +func message(id, to string) map[string]any { + return map[string]any{ + "id": id, + "to": map[string]string{"dm": to}, + "keyId": "dm", + "iv": bytesOf(12, 21), + "ct": bytesOf(48, 23), + } +} + +// queue — очередь устройства как её видит сервер. +func (e *env) queue(device string) []string { + e.t.Helper() + got, err := e.st.Queue(context.Background(), device) + if err != nil { + e.t.Fatalf("очередь %s: %v", device, err) + } + return got +} + +// envelopes разбирает конверты очереди. +func (e *env) envelopes(device string) []envelope { + e.t.Helper() + raw := e.queue(device) + out := make([]envelope, 0, len(raw)) + for _, s := range raw { + var env envelope + if err := json.Unmarshal([]byte(s), &env); err != nil { + e.t.Fatalf("разбор конверта %q: %v", s, err) + } + out = append(out, env) + } + return out +} + +// envelope — конверт в том виде, в каком его видит клиент. +type envelope struct { + ID string `json:"id"` + To struct { + DM string `json:"dm"` + Room string `json:"room"` + } `json:"to"` + From string `json:"from"` + KeyID string `json:"keyId"` + IV string `json:"iv"` + CT string `json:"ct"` + TS int64 `json:"ts"` +} + +// Конверт уходит на все устройства обоих собеседников, кроме отправившего +// (ADR-017): мультидевайс без отдельной логики. +func TestFanout(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + petya, p1 := e.join("petya", 3) + p2 := e.addDevice(petya, deviceOf(4)) + + id := ulid(nowMillis(), 5) + rec := e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)) + expect(t, rec, http.StatusAccepted, "") + var accepted struct { + ID string `json:"id"` + TS int64 `json:"ts"` + } + decodeBody(t, rec, &accepted) + if accepted.ID != id || accepted.TS == 0 { + t.Errorf("ответ: %+v", accepted) + } + + if got := e.queue(m1); len(got) != 0 { + t.Errorf("эхо отправившему устройству: %v", got) + } + for _, device := range []string{m2, p1, p2} { + got := e.envelopes(device) + if len(got) != 1 { + t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got)) + } + env := got[0] + if env.ID != id || env.From != "marta" || env.To.DM != "petya" || env.KeyID != "dm" { + t.Errorf("конверт для %s: %+v", device, env) + } + if env.IV != bytesOf(12, 21) || env.CT != bytesOf(48, 23) { + t.Errorf("шифротекст изменился: %+v", env) + } + if env.TS != accepted.TS { + t.Errorf("ts: получено %d, ожидалось %d", env.TS, accepted.TS) + } + } +} + +// from ставит сервер из сессии; поле from в теле запроса не читается +// вовсе (ADR-017, модель угроз). +func TestFromComesFromSession(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "petya") + body["from"] = "petya" + body["ts"] = 1 + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), http.StatusAccepted, "") + + got := e.envelopes(p1) + if len(got) != 1 { + t.Fatalf("очередь: получено %d конвертов, ожидался 1", len(got)) + } + if got[0].From != "marta" { + t.Errorf("from: получено %q, ожидалось \"marta\"", got[0].From) + } + if got[0].TS == 1 { + t.Errorf("ts взят из тела запроса: %d", got[0].TS) + } +} + +// ACK удаляет строки очереди только своего устройства. +func TestAck(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + + first := ulid(nowMillis(), 4) + second := ulid(nowMillis()+1, 5) + for _, id := range []string{first, second} { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + + ack := map[string]any{"ids": []string{first}} + expect(t, e.do(http.MethodPost, "/api/ack", ack, with(petya), withDevice(p1)), http.StatusNoContent, "") + + left := e.envelopes(p1) + if len(left) != 1 || left[0].ID != second { + t.Errorf("очередь p1 после ack: %+v", left) + } + if got := e.queue(p2); len(got) != 2 { + t.Errorf("очередь p2: получено %d конвертов, ожидалось 2", len(got)) + } + // Чужие идентификаторы и повторный ack ничего не ломают. + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}}, + with(petya), withDevice(p1)), http.StatusNoContent, "") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("очередь p1: получено %d конвертов, ожидалось 0", len(got)) + } + if got := e.queue(p2); len(got) != 2 { + t.Errorf("очередь p2 после ack чужого устройства: получено %d", len(got)) + } +} + +func TestAckLimit(t *testing.T) { + e := newEnv(t) + c, device := e.join("marta", 1) + + ids := make([]string, 500) + for i := range ids { + ids[i] = ulid(nowMillis(), byte(i)) + } + expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(c), withDevice(device)), + http.StatusNoContent, "") + + rec := e.do(http.MethodPost, "/api/ack", map[string]any{"ids": append(ids, ulid(nowMillis(), 9))}, + with(c), withDevice(device)) + expect(t, rec, http.StatusBadRequest, "invalid") + var field struct { + Field string `json:"field"` + } + decodeBody(t, rec, &field) + if field.Field != "ids" { + t.Errorf("field: получено %q, ожидалось \"ids\"", field.Field) + } +} + +// Часы клиента врут в обе стороны одинаково плохо (ADR-017). +func TestClockSkew(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + e.join("petya", 2) + + minute := int64(60 * 1000) + for _, shift := range []int64{-6 * minute, 6 * minute, -24 * 60 * minute, 24 * 60 * minute} { + body := message(ulid(nowMillis()+shift, 3), "petya") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), + http.StatusBadRequest, "clock_skew") + } + // В пределах пяти минут — принимается. + for _, shift := range []int64{-4 * minute, 4 * minute} { + body := message(ulid(nowMillis()+shift, 4), "petya") + expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), + http.StatusAccepted, "") + } +} + +// Строки contacts заводятся в обе стороны при первом сообщении (ADR-019): +// новое устройство видит список чатов без истории. +func TestContactsFromFirstMessage(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + if got := e.contacts(marta); len(got) != 0 { + t.Fatalf("контакты до первого сообщения: %+v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + mine := e.contacts(marta) + if len(mine) != 1 || mine[0].Nick != "petya" || mine[0].CreatedAt == 0 { + t.Errorf("контакты marta: %+v", mine) + } + theirs := e.contacts(petya) + if len(theirs) != 1 || theirs[0].Nick != "marta" { + t.Errorf("контакты petya: %+v", theirs) + } + if len(theirs[0].PublicKey) == 0 { + t.Errorf("в контакте нет публичного ключа: %+v", theirs[0]) + } + + // Второе сообщение ничего не удваивает. + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "petya"), + with(marta), withDevice(m1)), http.StatusAccepted, "") + if got := e.contacts(marta); len(got) != 1 { + t.Errorf("контакты marta после второго сообщения: %+v", got) + } +} + +// Повтор POST с тем же id не ломает запрос: сервер историю идентификаторов +// не хранит, склеивает клиент (ADR-017). +func TestRepeatedMessageID(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + id := ulid(nowMillis(), 3) + for i := 0; i < 3; i++ { + expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)), + http.StatusAccepted, "") + } + if got := e.queue(p1); len(got) != 1 { + t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got)) + } +} + +func TestMessageRejects(t *testing.T) { + cases := []struct { + name string + change func(map[string]any) + status int + code string + field string + }{ + {"id не ulid", func(m map[string]any) { m["id"] = "не ulid" }, http.StatusBadRequest, "invalid", "id"}, + {"строчный ulid", func(m map[string]any) { + m["id"] = "01hqzz0000zzzzzzzzzzzzzzzz" + }, http.StatusBadRequest, "invalid", "id"}, + {"буква вне алфавита", func(m map[string]any) { + m["id"] = "0" + "I" + ulid(nowMillis(), 1)[2:] + }, http.StatusBadRequest, "invalid", "id"}, + {"нет адресата", func(m map[string]any) { delete(m, "to") }, http.StatusBadRequest, "invalid", "to"}, + {"оба адресата", func(m map[string]any) { + m["to"] = map[string]string{"dm": "petya", "room": bytesOf(16, 1)} + }, http.StatusBadRequest, "invalid", "to"}, + {"кривой ник", func(m map[string]any) { + m["to"] = map[string]string{"dm": "МАРТА"} + }, http.StatusBadRequest, "invalid", "to"}, + {"чужой keyId в личном чате", func(m map[string]any) { + m["keyId"] = bytesOf(16, 1) + }, http.StatusBadRequest, "invalid", "keyId"}, + {"iv не 12 байт", func(m map[string]any) { m["iv"] = bytesOf(16, 21) }, http.StatusBadRequest, "invalid", "iv"}, + {"короткий ct", func(m map[string]any) { m["ct"] = bytesOf(8, 23) }, http.StatusBadRequest, "invalid", "ct"}, + {"ct не base64url", func(m map[string]any) { m["ct"] = "!!!" }, http.StatusBadRequest, "invalid", "ct"}, + {"неизвестный ник", func(m map[string]any) { + m["to"] = map[string]string{"dm": "kolya"} + }, http.StatusNotFound, "unknown_user", ""}, + {"себе", func(m map[string]any) { + m["to"] = map[string]string{"dm": "marta"} + }, http.StatusBadRequest, "self", ""}, + // Комнаты — этап 3; членства нет ни у кого. + {"в комнату", func(m map[string]any) { + m["to"] = map[string]string{"room": bytesOf(16, 1)} + m["keyId"] = bytesOf(16, 2) + }, http.StatusForbidden, "not_member", ""}, + {"кривой roomId", func(m map[string]any) { + m["to"] = map[string]string{"room": "нет"} + }, http.StatusBadRequest, "invalid", "to"}, + {"кривой keyId комнаты", func(m map[string]any) { + m["to"] = map[string]string{"room": bytesOf(16, 1)} + m["keyId"] = "dm" + }, http.StatusBadRequest, "invalid", "keyId"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + body := message(ulid(nowMillis(), 3), "petya") + c.change(body) + rec := e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)) + expect(t, rec, c.status, c.code) + if c.field != "" { + var got struct { + Field string `json:"field"` + } + decodeBody(t, rec, &got) + if got.Field != c.field { + t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field) + } + } + if got := e.queue(p1); len(got) != 0 { + t.Errorf("отвергнутое сообщение попало в очередь: %v", got) + } + }) + } +} + +// Лимит сообщений — 30 в минуту на пользователя, пакет 10 (ADR-021). +func TestMessageRateLimit(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, "") + } + rec := e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"), with(marta), withDevice(m1)) + expect(t, rec, http.StatusTooManyRequests, "rate_limited") + // Токен набегает раз в две секунды: пакет кончился, ждать до двух. + after, err := strconv.Atoi(rec.Header().Get("Retry-After")) + if err != nil || after < 1 || after > 2 { + t.Errorf("Retry-After: получено %q", rec.Header().Get("Retry-After")) + } + + // Лимит на пользователе, а не на устройстве. + m2 := e.addDevice(marta, deviceOf(3)) + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 12), "petya"), + with(marta), withDevice(m2)), http.StatusTooManyRequests, "rate_limited") + + // Другому пользователю чужой лимит не мешает. + petya, p1 := e.join("kolya", 4) + expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 13), "marta"), + with(petya), withDevice(p1)), http.StatusAccepted, "") +} diff --git a/internal/api/ulid.go b/internal/api/ulid.go new file mode 100644 index 0000000..14ada55 --- /dev/null +++ b/internal/api/ulid.go @@ -0,0 +1,37 @@ +package api + +import "strings" + +// ULID — 48 бит миллисекунд и 80 бит случайности, Crockford base32, +// 26 символов (docs/crypto.md, «Идентификаторы»). Сервер читает из него +// только время: расхождение с серверными часами больше пяти минут — +// clock_skew (ADR-017). +const ulidLen = 26 + +// crockford — алфавит Crockford base32: без I, L, O и U. Канонический +// ULID записывается заглавными; строчные буквы сервер не принимает — +// идентификатор входит в AAD шифротекста побайтно (docs/crypto.md). +const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ" + +// ulidTime разбирает ULID и отдаёт его метку времени в миллисекундах. +func ulidTime(id string) (int64, bool) { + if len(id) != ulidLen { + return 0, false + } + var ms int64 + for i := 0; i < ulidLen; i++ { + v := strings.IndexByte(crockford, id[i]) + if v < 0 { + return 0, false + } + if i < 10 { + ms = ms<<5 | int64(v) + } + } + // Первые десять символов — 50 бит, времени отведено 48: старшие два + // обязаны быть нулевыми. + if ms > 1<<48-1 { + return 0, false + } + return ms, true +} diff --git a/internal/api/valid.go b/internal/api/valid.go index 0244736..2a2e050 100644 --- a/internal/api/valid.go +++ b/internal/api/valid.go @@ -14,6 +14,7 @@ import ( // base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»). const ( authKeyLen = 32 // байт + idLen = 16 // байт: deviceId, keyId, roomId ivLen = 12 // байт minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md @@ -39,6 +40,13 @@ func decodeExactly(s string, n int) ([]byte, bool) { // authKey разбирает authKey клиента: base64url ровно 32 байта. func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) } +// validID — deviceId, keyId и roomId устроены одинаково: 16 случайных +// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). +func validID(s string) bool { + _, ok := decodeExactly(s, idLen) + return ok +} + // jwkPublic — публичный ключ в том виде, в каком сервер его хранит // и отдаёт: четыре поля и ничего больше. type jwkPublic struct { diff --git a/internal/hub/hub.go b/internal/hub/hub.go new file mode 100644 index 0000000..b39f4f5 --- /dev/null +++ b/internal/hub/hub.go @@ -0,0 +1,123 @@ +// Package hub держит открытые SSE-потоки устройств (ADR-004, ADR-017). +// +// На устройство приходится один поток: новое соединение закрывает +// предыдущее. Потерянное живое событие не теряет сообщения — оно лежит +// в очереди до ACK и выдаётся заново при следующем подключении +// (docs/protocol.md, «События»). +package hub + +import "sync" + +// buffer — сколько событий поток держит, пока обработчик их не разобрал. +const buffer = 32 + +// Event — одно событие SSE: имя и готовый JSON. Данные — строка: её +// нельзя изменить после того, как она ушла в несколько потоков сразу. +type Event struct { + Name string + Data string +} + +// Hub — карта «устройство → открытый поток». Пуст, пока никто не подключён. +type Hub struct { + mu sync.Mutex + streams map[string]*Stream +} + +// New заводит пустой hub. +func New() *Hub { return &Hub{streams: make(map[string]*Stream)} } + +// Stream — поток одного устройства. Обработчик читает Events до тех пор, +// пока не закроется Done или не уйдёт клиент. +type Stream struct { + hub *Hub + device string + events chan Event + done chan struct{} + once sync.Once +} + +// Open открывает поток устройства и закрывает предыдущий, если он был: +// одно соединение на устройство (docs/protocol.md, «События»). +func (h *Hub) Open(device string) *Stream { + s := &Stream{ + hub: h, + device: device, + events: make(chan Event, buffer), + done: make(chan struct{}), + } + h.mu.Lock() + prev := h.streams[device] + h.streams[device] = s + h.mu.Unlock() + if prev != nil { + prev.stop() + } + return s +} + +// Send отдаёт событие подключённому устройству. Устройство не подключено — +// молча ничего: конверт уже лежит в его очереди. +func (h *Hub) Send(device string, ev Event) { + h.mu.Lock() + s := h.streams[device] + h.mu.Unlock() + if s == nil { + return + } + select { + case s.events <- ev: + default: + // Клиент не успевает читать. Закрываем поток: переподключение + // выдаст очередь целиком, а копить события в памяти сервера — + // не его дело (ADR-008). + h.drop(s) + } +} + +// Close закрывает поток устройства: устройство удалили (docs/protocol.md, +// «Устройства»). +func (h *Hub) Close(device string) { + h.mu.Lock() + s := h.streams[device] + delete(h.streams, device) + h.mu.Unlock() + if s != nil { + s.stop() + } +} + +// CloseAll закрывает все потоки: сервер останавливается. Без этого +// остановка ждала бы, пока клиенты уйдут сами. +func (h *Hub) CloseAll() { + h.mu.Lock() + streams := h.streams + h.streams = make(map[string]*Stream) + h.mu.Unlock() + for _, s := range streams { + s.stop() + } +} + +// drop снимает регистрацию именно этого потока и закрывает его. Если +// устройство успело подключиться заново, новый поток остаётся на месте. +func (h *Hub) drop(s *Stream) { + h.mu.Lock() + if h.streams[s.device] == s { + delete(h.streams, s.device) + } + h.mu.Unlock() + s.stop() +} + +// Events — события, пришедшие потоку. +func (s *Stream) Events() <-chan Event { return s.events } + +// Done закрывается, когда поток закрыт: новым соединением того же +// устройства, удалением устройства или остановкой сервера. +func (s *Stream) Done() <-chan struct{} { return s.done } + +// Close закрывает поток — его зовёт обработчик, когда клиент ушёл. +func (s *Stream) Close() { s.hub.drop(s) } + +func (s *Stream) stop() { s.once.Do(func() { close(s.done) }) } diff --git a/internal/hub/hub_test.go b/internal/hub/hub_test.go new file mode 100644 index 0000000..e66adc4 --- /dev/null +++ b/internal/hub/hub_test.go @@ -0,0 +1,170 @@ +package hub + +import ( + "strconv" + "sync" + "testing" + "time" +) + +const wait = 2 * time.Second + +// next ждёт событие потока. +func next(t *testing.T, s *Stream) Event { + t.Helper() + select { + case ev := <-s.Events(): + return ev + case <-time.After(wait): + t.Fatal("событие не пришло") + } + return Event{} +} + +// closed ждёт закрытия потока. +func closed(t *testing.T, s *Stream) { + t.Helper() + select { + case <-s.Done(): + case <-time.After(wait): + t.Fatal("поток не закрылся") + } +} + +func open(t *testing.T, s *Stream) { + t.Helper() + select { + case <-s.Done(): + t.Fatal("поток закрыт") + default: + } +} + +func TestSend(t *testing.T) { + h := New() + s := h.Open("device") + + h.Send("device", Event{Name: "msg", Data: `{"id":"1"}`}) + if ev := next(t, s); ev.Name != "msg" || ev.Data != `{"id":"1"}` { + t.Errorf("событие: %+v", ev) + } + + // Неподключённое устройство — молча ничего: конверт лежит в очереди. + h.Send("другое", Event{Name: "msg"}) + open(t, s) +} + +// Одно соединение на устройство: новое закрывает предыдущее. +func TestOpenClosesPrevious(t *testing.T) { + h := New() + first := h.Open("device") + second := h.Open("device") + + closed(t, first) + open(t, second) + + h.Send("device", Event{Name: "msg"}) + if ev := next(t, second); ev.Name != "msg" { + t.Errorf("событие ушло не в тот поток: %+v", ev) + } +} + +// Close закрывает поток устройства: устройство удалили. +func TestCloseDevice(t *testing.T) { + h := New() + s := h.Open("device") + + h.Close("device") + closed(t, s) + + // Второе закрытие и закрытие неизвестного устройства — не беда. + h.Close("device") + h.Close("другое") +} + +// CloseAll — остановка сервера. +func TestCloseAll(t *testing.T) { + h := New() + first := h.Open("first") + second := h.Open("second") + + h.CloseAll() + closed(t, first) + closed(t, second) +} + +// Клиент, который не читает, теряет поток, а не память сервера: +// переподключение выдаст очередь целиком. +func TestOverflowDropsStream(t *testing.T) { + h := New() + s := h.Open("device") + + for i := 0; i < buffer+1; i++ { + h.Send("device", Event{Name: "msg", Data: strconv.Itoa(i)}) + } + closed(t, s) + + // Место в карте освободилось: следующее подключение начинает с нуля. + fresh := h.Open("device") + h.Send("device", Event{Name: "msg", Data: "снова"}) + if ev := next(t, fresh); ev.Data != "снова" { + t.Errorf("событие: %+v", ev) + } +} + +// Закрытие потока обработчиком не трогает уже открытый новый. +func TestStreamCloseKeepsNewer(t *testing.T) { + h := New() + first := h.Open("device") + second := h.Open("device") + first.Close() + + h.Send("device", Event{Name: "msg"}) + if ev := next(t, second); ev.Name != "msg" { + t.Errorf("событие: %+v", ev) + } +} + +// Доставки идут из разных горутин: hub обязан это переживать. +func TestConcurrent(t *testing.T) { + h := New() + done := make(chan struct{}) + var wg sync.WaitGroup + + for i := 0; i < 4; i++ { + wg.Add(1) + go func(n int) { + defer wg.Done() + device := "device" + strconv.Itoa(n%2) + for { + select { + case <-done: + return + default: + } + h.Send(device, Event{Name: "msg"}) + h.Open(device) + } + }(i) + } + // Читатель, чтобы буфер не переполнялся мгновенно. + wg.Add(1) + go func() { + defer wg.Done() + s := h.Open("device0") + for { + select { + case <-done: + return + case <-s.Events(): + case <-s.Done(): + s = h.Open("device0") + } + } + }() + + time.Sleep(50 * time.Millisecond) + close(done) + wg.Wait() + h.CloseAll() +} diff --git a/internal/store/contacts.go b/internal/store/contacts.go new file mode 100644 index 0000000..45c3e7e --- /dev/null +++ b/internal/store/contacts.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + "fmt" +) + +// Contact — строка списка чатов: собеседник и его публичный ключ. +// Доверие к ключу — TOFU на клиенте (ADR-016). +type Contact struct { + Nick string + PublicKey string + CreatedAt int64 +} + +// Contacts — контакты пользователя в порядке появления. +func (s *Store) Contacts(ctx context.Context, nick string) ([]Contact, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT c.peer, u.public_key, c.created_at + FROM contacts c JOIN users u ON u.nick = c.peer + WHERE c.nick = ? ORDER BY c.created_at, c.peer`, nick) + if err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + defer rows.Close() + + var out []Contact + for rows.Next() { + var c Contact + if err := rows.Scan(&c.Nick, &c.PublicKey, &c.CreatedAt); err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + out = append(out, c) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список контактов: %w", err) + } + return out, nil +} + +// AddContact заводит одну строку списка чатов. Первое значение — была ли +// она создана. Зеркальную строку собеседнику эта операция не заводит: +// обе стороны появляются только при первом сообщении (ADR-019). +func (s *Store) AddContact(ctx context.Context, nick, peer string, now int64) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + INSERT INTO contacts (nick, peer, created_at) VALUES (?, ?, ?) + ON CONFLICT(nick, peer) DO NOTHING`, nick, peer, now) + if err != nil { + return false, fmt.Errorf("store: добавление контакта: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: добавление контакта: %w", err) + } + return n > 0, nil +} + +// DeleteContact убирает чат из списка. Только своя строка: зеркальная +// у собеседника остаётся, блокировок в v1 нет (ADR-019). +func (s *Store) DeleteContact(ctx context.Context, nick, peer string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM contacts WHERE nick = ? AND peer = ?`, nick, peer); err != nil { + return fmt.Errorf("store: удаление контакта: %w", err) + } + return nil +} diff --git a/internal/store/devices.go b/internal/store/devices.go new file mode 100644 index 0000000..5da765e --- /dev/null +++ b/internal/store/devices.go @@ -0,0 +1,129 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// ErrDeviceTaken — идентификатор устройства занят другим пользователем +// (ADR-017): клиент генерирует новый. +var ErrDeviceTaken = errors.New("store: устройство занято") + +// Device — строка devices без самой push-подписки: клиенту отдаётся +// только факт её наличия. +type Device struct { + ID string + CreatedAt int64 + LastSeen int64 + HasPush bool +} + +// RegisterDevice заводит устройство или подтверждает уже заведённое, +// обновляет last_seen и привязывает к устройству текущую сессию (ADR-021). +// Первое значение — было ли устройство создано; занятый чужим id — +// ErrDeviceTaken. +func (s *Store) RegisterDevice(ctx context.Context, id, nick string, tokenHash []byte, now int64) (bool, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + defer tx.Rollback() + + // ON CONFLICT DO NOTHING вместо разбора кода ошибки драйвера: занятый + // id виден по нулю затронутых строк, а чей он — по следующему запросу. + res, err := tx.ExecContext(ctx, ` + INSERT INTO devices (id, nick, created_at, last_seen) VALUES (?, ?, ?, ?) + ON CONFLICT(id) DO NOTHING`, id, nick, now, now) + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + created := n == 1 + if !created { + var owner string + if err := tx.QueryRowContext(ctx, `SELECT nick FROM devices WHERE id = ?`, id).Scan(&owner); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + if owner != nick { + return false, ErrDeviceTaken + } + if _, err := tx.ExecContext(ctx, `UPDATE devices SET last_seen = ? WHERE id = ?`, now, id); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `UPDATE sessions SET device_id = ? WHERE token_hash = ?`, id, tokenHash); err != nil { + return false, fmt.Errorf("store: привязка сессии к устройству: %w", err) + } + if err := tx.Commit(); err != nil { + return false, fmt.Errorf("store: регистрация устройства: %w", err) + } + return created, nil +} + +// DeviceOwned — принадлежит ли устройство этому пользователю. Чужое +// и несуществующее неразличимы: снаружи и то и другое unknown_device. +func (s *Store) DeviceOwned(ctx context.Context, id, nick string) (bool, error) { + var one int + err := s.db.QueryRowContext(ctx, `SELECT 1 FROM devices WHERE id = ? AND nick = ?`, id, nick).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("store: проверка устройства: %w", err) + } + return true, nil +} + +// Devices — устройства пользователя в порядке появления. +func (s *Store) Devices(ctx context.Context, nick string) ([]Device, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, created_at, last_seen, push_subscription IS NOT NULL + FROM devices WHERE nick = ? ORDER BY created_at, id`, nick) + if err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + defer rows.Close() + + var out []Device + for rows.Next() { + var d Device + if err := rows.Scan(&d.ID, &d.CreatedAt, &d.LastSeen, &d.HasPush); err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + out = append(out, d) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список устройств: %w", err) + } + return out, nil +} + +// DeleteDevice удаляет устройство пользователя; очередь, push-подписку +// и сессии устройства уносит каскад (docs/storage.md). Первое значение — +// была ли строка: чужое устройство удалить нельзя. +func (s *Store) DeleteDevice(ctx context.Context, id, nick string) (bool, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM devices WHERE id = ? AND nick = ?`, id, nick) + if err != nil { + return false, fmt.Errorf("store: удаление устройства: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: удаление устройства: %w", err) + } + return n > 0, nil +} + +// TouchDevice — устройство подключилось по SSE: неотработанного пуша +// больше нет (ADR-023), время последнего появления — сейчас. +func (s *Store) TouchDevice(ctx context.Context, id string, now int64) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_pending = 0, last_seen = ? WHERE id = ?`, now, id); err != nil { + return fmt.Errorf("store: подключение устройства: %w", err) + } + return nil +} diff --git a/internal/store/queue.go b/internal/store/queue.go new file mode 100644 index 0000000..d798abe --- /dev/null +++ b/internal/store/queue.go @@ -0,0 +1,133 @@ +package store + +import ( + "context" + "database/sql" + "fmt" + "strings" +) + +// Транзитная очередь недоставленных конвертов, по строке на устройство +// (ADR-008). Доставлено и подтверждено ACK — удалено; не забрано +// за 30 дней — удалено фоновой чисткой. + +// Queue — очередь устройства в порядке выдачи при подключении: +// created_at, msg_id (docs/protocol.md, «События»). Строки — готовые +// конверты, сервер их не разбирает. +func (s *Store) Queue(ctx context.Context, device string) ([]string, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT envelope FROM queue WHERE device_id = ? ORDER BY created_at, msg_id`, device) + if err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var envelope string + if err := rows.Scan(&envelope); err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + out = append(out, envelope) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: очередь устройства: %w", err) + } + return out, nil +} + +// Ack удаляет из очереди устройства перечисленные сообщения: клиент +// записал их в IndexedDB (docs/storage.md). +func (s *Store) Ack(ctx context.Context, device string, ids []string) error { + if len(ids) == 0 { + return nil + } + args := make([]any, 0, len(ids)+1) + args = append(args, device) + for _, id := range ids { + args = append(args, id) + } + query := `DELETE FROM queue WHERE device_id = ? AND msg_id IN (?` + + strings.Repeat(", ?", len(ids)-1) + `)` + if _, err := s.db.ExecContext(ctx, query, args...); err != nil { + return fmt.Errorf("store: подтверждение доставки: %w", err) + } + return nil +} + +// Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы +// разложить его по очередям. Envelope сервер не разбирает, поэтому id +// приходит отдельным полем. +type Delivery struct { + From string // отправитель, он же один из получателей + To string // собеседник + Exclude string // устройство отправителя: эхо ему не нужно (ADR-017) + MsgID string // id конверта, вторая половина ключа очереди + Envelope string // готовый JSON конверта + Now int64 // серверное время, оно же ts конверта +} + +// DeliverDM кладёт конверт личного чата в очередь всех устройств обоих +// собеседников, кроме отправившего, и заводит недостающие строки contacts +// в обе стороны — всё в одной транзакции (docs/protocol.md, «Сообщения»). +// Возвращает устройства, которым конверт надо отдать живьём. +// +// Повторный POST с тем же id — не ошибка: сервер историю идентификаторов +// не хранит, повтор порождает повторную доставку, а склеивает её клиент +// (ADR-017). Поэтому вставка молча пропускает уже лежащую в очереди +// строку, а список устройств от этого не зависит. +func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: доставка: %w", err) + } + defer tx.Rollback() + + for _, pair := range [2][2]string{{d.From, d.To}, {d.To, d.From}} { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO contacts (nick, peer, created_at) VALUES (?, ?, ?) + ON CONFLICT(nick, peer) DO NOTHING`, pair[0], pair[1], d.Now); err != nil { + return nil, fmt.Errorf("store: доставка (контакты): %w", err) + } + } + + devices, err := deviceIDs(ctx, tx, d.From, d.To, d.Exclude) + if err != nil { + return nil, err + } + for _, id := range devices { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT(device_id, msg_id) DO NOTHING`, + id, d.MsgID, d.Envelope, d.Now); err != nil { + return nil, fmt.Errorf("store: доставка (очередь): %w", err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: доставка: %w", err) + } + return devices, nil +} + +// deviceIDs — устройства обоих собеседников, кроме отправившего. +func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT id FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude) + if err != nil { + return nil, fmt.Errorf("store: доставка (устройства): %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: доставка (устройства): %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: доставка (устройства): %w", err) + } + return out, nil +} diff --git a/web/app.css b/web/app.css index 855cc52..550c4a6 100644 --- a/web/app.css +++ b/web/app.css @@ -299,6 +299,56 @@ input[type="password"] { list-style: none; } +.items:last-child { + margin-bottom: 0; +} + +/* строка списка: чат или «+ новый чат» */ + +.item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 8px; + border: 0; + border-radius: 0; + background: none; + color: var(--text2); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} + +.item__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.item .n { + margin-left: auto; + color: var(--mark); +} + +/* активный чат — инверсия (docs/identity/brief.md) */ + +.item.is-active { + background: var(--ink); + color: var(--bone); +} + +.item.is-active .n { + color: var(--bone); +} + +.item--new { + margin-bottom: 18px; + color: var(--mute); +} + .me { display: flex; align-items: center; @@ -394,6 +444,250 @@ input[type="password"] { margin-top: 14px; } +.fp-hint { + margin: 10px 0 0; + font-size: 11px; + color: var(--mute); +} + +/* чат: шапка, лента, ввод — docs/identity/screens.html */ + +.chat-title { + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 15px; + text-align: left; + cursor: pointer; +} + +/* лента прижата к низу: короткая переписка не висит под шапкой */ + +.feed { + flex: 1; + min-height: 0; + padding: 20px; + display: flex; + flex-direction: column; + overflow-y: auto; +} + +.grid { + margin-top: auto; + display: flex; + flex-direction: column; + font-size: 14px; + line-height: 1.5; +} + +/* мобильный: автор над группой сообщений */ + +.line { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 4px; +} + +.line.is-head { + margin-top: 14px; +} + +.grid > :first-child { + margin-top: 0; +} + +.author { + display: flex; + flex-wrap: wrap; + column-gap: 6px; + color: var(--mute); + font-size: 11px; + overflow-wrap: anywhere; +} + +.author--me { + color: var(--mark); +} + +.author .t { + color: var(--stone); +} + +.text { + min-width: 0; +} + +.text__body { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.text--pending { + color: var(--stone); +} + +.text--none { + color: var(--mute); + font-style: italic; +} + +.fail { + display: flex; + align-items: center; + flex-wrap: wrap; + /* пробел вокруг «·» держит gap: у соседних элементов строки он + схлопывается */ + gap: 0 5px; + margin: 2px 0 0; + color: var(--mark); + font-size: 12px; +} + +.link { + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: inherit; + font: inherit; + text-decoration: underline; + cursor: pointer; +} + +/* разделители: дата — линией line, «новые» — линией mark */ + +.divider { + display: flex; + align-items: center; + gap: 14px; + margin: 14px 0; + color: var(--stone); + font-size: 10px; +} + +.divider::before, +.divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--line); +} + +.divider--new { + color: var(--mark); +} + +.divider--new::before, +.divider--new::after { + background: var(--mark); +} + +/* ввод: рамка 1 px ink, слева «>» цветом mark */ + +.compose { + flex: none; + padding: 0 16px 20px; +} + +.input { + display: flex; + align-items: center; + gap: 10px; + padding: 0 14px; + border: 1px solid var(--ink); + font-size: 14px; +} + +.input .p { + flex: none; + color: var(--mark); +} + +/* фокус показывает вся строка ввода: рамка у неё одна, второй рамки + вокруг поля внутри не нужно */ + +.input:focus-within { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +.input .input__field:focus-visible { + outline: none; +} + +.input .input__field { + flex: 1; + min-width: 0; + min-height: 0; + padding: 12px 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 14px; + line-height: 1.5; +} + +/* строка ввода перебивает общее правило для input: рамка здесь одна, + у всей строки */ + +.input textarea.input__field { + resize: none; + overflow-y: auto; + max-height: 7em; + /* растёт под текст там, где браузер это умеет; где нет — прокрутка */ + field-sizing: content; +} + +.input .input__field::placeholder { + color: var(--stone); +} + +.input__send { + flex: none; + align-self: stretch; + min-width: 44px; + padding: 0; + border: 0; + border-radius: 0; + background: none; + color: var(--ink); + font: inherit; + font-size: 14px; + cursor: pointer; +} + +.counter, +.enter { + flex: none; + margin-left: auto; + color: var(--stone); + font-size: 11px; +} + +.counter + .enter { + margin-left: 0; +} + +/* полоса над вводом: нет соединения — stone, отказ отправки — mark */ + +.bar { + margin: 0 0 10px; + font-size: 11px; + line-height: 1.5; + color: var(--stone); +} + +.bar--mark { + color: var(--mark); +} + @media (min-width: 760px) { .shell { display: grid; @@ -416,6 +710,66 @@ input[type="password"] { .body { padding: 28px 32px; } + + /* сайдбар всегда на месте: «назад» из чата некуда */ + + .back--chat { + display: none; + } + + /* сетка «автор 132 px + текст»: строка сообщения раскрывается + в две ячейки сетки, поэтому display: contents */ + + .feed { + padding: 28px 32px; + } + + .grid { + display: grid; + grid-template-columns: 132px 1fr; + column-gap: 20px; + row-gap: 6px; + line-height: 1.55; + } + + .line, + .line.is-head { + display: contents; + margin-top: 0; + } + + .author { + padding-top: 2px; + font-size: 12px; + } + + .divider { + grid-column: 1 / -1; + margin: 0; + padding: 16px 0; + font-size: 11px; + } + + .compose { + padding: 0 32px 28px; + } + + .input { + gap: 12px; + padding: 0 16px; + } + + .input .input__field { + padding: 13px 0; + } + + /* на десктопе отправляет enter — кнопка не нужна ни в чате, ни в + строке `@ник`: в рамке ввода «>» остаётся один, слева + (docs/identity/brief.md, «Компоновка») */ + + .input__send { + display: none; + } } /* мобильный: один экран за раз, цели нажатия не меньше 44 px */ @@ -430,7 +784,24 @@ input[type="password"] { } .tab, - .back { + .back, + .item, + .chat-title, + .link { min-height: 44px; } + + /* подсказку «enter — отправить» видит только десктоп: на мобильном + enter переносит строку (docs/ui.md, «Чат») */ + + .enter { + display: none; + } + + /* автор стоит над группой, поэтому пустая ячейка автора не занимает + места; на десктопе она держит колонку сетки и остаётся */ + + .author:empty { + display: none; + } } diff --git a/web/js/api.js b/web/js/api.js index f296443..16057c4 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -1,6 +1,10 @@ -// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md: -// JSON в обе стороны, cookie сессии, ошибка — {error, message}. -// SSE и ACK появятся на этапе 2. +// Обёртки над fetch и поток событий. Форма запросов и ответов — +// docs/protocol.md: JSON в обе стороны, cookie сессии, ошибка — +// {error, message}. + +// MAX_ACK — сколько идентификаторов принимает один POST /api/ack +// (docs/protocol.md, «Сообщения»). +export const MAX_ACK = 500; // ApiError — ответ сервера с кодом из перечня docs/protocol.md. export class ApiError extends Error { @@ -30,6 +34,9 @@ const TEXT = { invite_required: "нужен инвайт-код", invalid_invite: "инвайт-код не подходит", rate_limited: "слишком часто, попробуйте позже", + unknown_user: "такого ника нет", + self: "нельзя писать себе", + clock_skew: "проверьте часы на устройстве: расхождение больше 5 минут", }; export function errorText(err) { @@ -53,12 +60,21 @@ export function onSessionExpired(handler) { // quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где // «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession). -async function request(method, path, body, { quiet = false } = {}) { +// device: заголовок X-Device — он обязателен там, где важно, с какого +// устройства пришёл запрос (docs/protocol.md, «Общие правила»). +async function request(method, path, body, { quiet = false, device = null } = {}) { const init = { method, credentials: "same-origin", cache: "no-store" }; + const headers = {}; if (body !== undefined) { - init.headers = { "Content-Type": "application/json" }; + headers["Content-Type"] = "application/json"; init.body = JSON.stringify(body); } + if (device) { + headers["X-Device"] = device; + } + if (Object.keys(headers).length > 0) { + init.headers = headers; + } let response; try { response = await fetch(path, init); @@ -128,3 +144,79 @@ export function password(body) { export function deleteMe(authKey) { return request("DELETE", "/api/me", { authKey }); } + +export function user(nick) { + return request("GET", `/api/users/${encodeURIComponent(nick)}`); +} + +// --- устройства -------------------------------------------------------- + +// registerDevice — 201 при создании, 200 если устройство уже наше, +// 409 device_conflict, если идентификатор занят другим (ADR-017). +export function registerDevice(id) { + return request("POST", "/api/devices", { id }); +} + +export function devices() { + return request("GET", "/api/devices"); +} + +export function removeDevice(id) { + return request("DELETE", `/api/devices/${encodeURIComponent(id)}`); +} + +// --- контакты ---------------------------------------------------------- + +export function contacts() { + return request("GET", "/api/contacts"); +} + +// addContact заводит строку списка чатов и отдаёт публичный ключ +// собеседника: 404 unknown_user, 400 self (ADR-019). +export function addContact(nick) { + return request("POST", "/api/contacts", { nick }); +} + +export function removeContact(nick) { + return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`); +} + +// --- сообщения --------------------------------------------------------- + +// sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017). +// Ответ — 202 {id, ts}. +export function sendMessage(device, envelope) { + return request("POST", "/api/messages", envelope, { device }); +} + +// ack подтверждает запись сообщений в IndexedDB: сервер убирает их +// из очереди устройства (docs/storage.md). Не больше MAX_ACK за раз. +export function ack(device, ids) { + return request("POST", "/api/ack", { ids }, { device }); +} + +// --- события ----------------------------------------------------------- + +// stream открывает поток событий устройства (docs/protocol.md, «События»). +// Устройство передаётся в query: EventSource не умеет заголовки. +// +// Переподключение делает браузер сам. Ответ не 200 он считает +// окончательным отказом и больше не подключается — это видно +// по readyState CLOSED и передаётся в handlers.error вторым состоянием. +// +// Отдаёт функцию закрытия потока. +export function stream(device, handlers) { + const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`); + source.addEventListener("msg", (event) => handlers.msg(parse(event.data))); + source.addEventListener("ready", () => handlers.ready()); + source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED)); + return () => source.close(); +} + +function parse(data) { + try { + return JSON.parse(data); + } catch { + return null; + } +} diff --git a/web/js/crypto.js b/web/js/crypto.js index 1e92ae1..f5322b5 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -13,11 +13,21 @@ const SALT_PREFIX = "bare-v1:"; const INFO_AUTH = "bare-auth-v1"; const INFO_KEK = "bare-kek-v1"; const BLOB_AAD = "bare-blob-v1|"; +const DM_SALT = "bare-dm-v1"; +const MSG_AAD = "bare-msg-v1|"; + +// DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного +// идентификатора у него нет (docs/crypto.md, «Сообщение»). +export const DM_KEY_ID = "dm"; // Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM. export const SECRET_LEN = 32; const IV_LEN = 12; +// ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных +// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). +const ID_LEN = 16; + // Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от // сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а // считает по нему клиент, поэтому проверить его может только он. @@ -47,6 +57,11 @@ export function random(length) { return bytes; } +// newId — идентификатор устройства, ключа комнаты или комнаты. +export function newId() { + return b64url(random(ID_LEN)); +} + export function b64url(input) { const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); let binary = ""; @@ -240,3 +255,82 @@ export async function openBlob(blob, kek, nick) { } return { priv: parsed.priv, secret }; } + +// --- чат 1:1 ----------------------------------------------------------- + +// order — ники пары по возрастанию. Сравниваются кодовые единицы, а не +// буквы языка: ник — это [a-z0-9_], и порядок обязан совпасть у обеих +// сторон побайтно (docs/crypto.md, «Чат 1:1»). +export function order(a, b) { + return a < b ? [a, b] : [b, a]; +} + +// dmLabel — метка чата для AAD сообщения: "dm:" + a + ":" + b. +// Это не ключ хранилища chats: там чат зовётся "dm:<собеседник>". +export function dmLabel(a, b) { + const [first, second] = order(a, b); + return `dm:${first}:${second}`; +} + +// dmKey выводит ключ личного чата (docs/crypto.md, «Чат 1:1»). +// Ключ симметричен для обеих сторон и всех их устройств; в IndexedDB +// не пишется — выводится заново из peers. +export async function dmKey(privateKey, peerPublicJwk, me, peer) { + const [a, b] = order(me, peer); + const publicKey = await importPublic(peerPublicJwk); + const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256); + const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); + wipe(new Uint8Array(shared)); + return subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: utf8(DM_SALT), info: utf8(`${a}\0${b}`) }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// --- сообщение --------------------------------------------------------- + +// messageAad привязывает открытые поля конверта к шифротексту: подмена +// любого из них ломает расшифровку (docs/crypto.md, «Сообщение»). +function messageAad({ id, chat, from, keyId }) { + return utf8(`${MSG_AAD}${id}|${chat}|${from}|${keyId}`); +} + +// sealMessage шифрует текст сообщения. plain — JSON {"t": текст}; +// ничего кроме текста внутрь не кладётся. +export async function sealMessage(key, { id, chat, from, keyId, text }) { + const iv = random(IV_LEN); + const plain = utf8(JSON.stringify({ t: text })); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: messageAad({ id, chat, from, keyId }) }, + key, + plain, + ); + wipe(plain); + return { iv: b64url(iv), ct: b64url(ct) }; +} + +// openMessage расшифровывает конверт и отдаёт текст. Бросает при любой +// порче: не тот ключ, изменившиеся открытые поля, битый base64url. +// Для вызывающего это не фатально — сообщение сохраняется нерасшифрованным +// с кодом причины (docs/storage.md). +export async function openMessage(key, { id, chat, from, keyId, iv, ct }) { + const nonce = unb64url(iv); + if (nonce.length !== IV_LEN) { + throw new Error("iv — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv: nonce, additionalData: messageAad({ id, chat, from, keyId }) }, + key, + unb64url(ct), + ); + const bytes = new Uint8Array(plain); + const parsed = JSON.parse(decoder.decode(bytes)); + wipe(bytes); + if (parsed === null || typeof parsed !== "object" || typeof parsed.t !== "string") { + throw new Error("в сообщении нет текста"); + } + return parsed.t; +} diff --git a/web/js/db.js b/web/js/db.js index e226d00..090acc7 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -59,6 +59,20 @@ function value(request) { }); } +// get и put — одна запись одного хранилища. Ключ у chats, messages +// и peers лежит внутри значения (keyPath), поэтому put берёт запись целиком. +async function get(name, key) { + const db = await open(); + return value(db.transaction(name, "readonly").objectStore(name).get(key)); +} + +async function put(name, record) { + const db = await open(); + const tx = db.transaction(name, "readwrite"); + tx.objectStore(name).put(record); + await done(tx); +} + // meta читает несколько ключей одной транзакцией. export async function meta(keys) { const db = await open(); @@ -81,6 +95,245 @@ export async function putMeta(entries) { await done(tx); } +// --- чаты --------------------------------------------------------------- + +// PAGE — страница ленты: 50 сообщений (docs/storage.md). +export const PAGE = 50; + +const DM = "dm:"; +const ROOM = "room:"; + +// Ключ чата — "dm:<собеседник>" или "room:" (docs/storage.md). +// Это не метка чата в AAD сообщения: там у личного чата оба ника. +export function dmChatId(peer) { + return DM + peer; +} + +export function roomChatId(roomId) { + return ROOM + roomId; +} + +// peerOf — с кем личный чат; у комнаты собеседника нет. +export function peerOf(chatId) { + return chatId.startsWith(DM) ? chatId.slice(DM.length) : null; +} + +// blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»: +// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3), +// до этого вместо имени стоит идентификатор. +export function blankChat(id) { + const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false }; + const peer = peerOf(id); + if (peer !== null) { + return { ...base, type: "dm", title: peer, peer }; + } + const roomId = id.slice(ROOM.length); + return { ...base, type: "room", title: roomId, roomId }; +} + +// chats — список чатов в порядке docs/ui.md: по lastId по убыванию. +// Скрытые («убрать из списка») не отдаются, пока их не попросят. +export async function chats({ hidden = false } = {}) { + const db = await open(); + const store = db.transaction("chats", "readonly").objectStore("chats"); + const list = await value(store.getAll()); + return list.filter((c) => hidden || !c.hidden).sort(byLastId); +} + +function byLastId(a, b) { + if (a.lastId !== b.lastId) { + if (!a.lastId) { + return 1; + } + if (!b.lastId) { + return -1; + } + return a.lastId < b.lastId ? 1 : -1; + } + return a.id < b.id ? -1 : 1; +} + +export function chat(id) { + return get("chats", id); +} + +export function putChat(record) { + return put("chats", record); +} + +// markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает +// к последнему сообщению. Обе величины локальные, на сервер не уходят +// (docs/storage.md). +export async function markRead(chatId) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const record = await value(store.get(chatId)); + if (record) { + record.unread = 0; + record.lastReadId = record.lastId; + store.put(record); + } + await done(tx); + return record ?? null; +} + +// hideChat прячет чат из списка или возвращает его туда. История +// не трогается: «убрать из списка» — не удаление (ADR-019). +export async function hideChat(chatId, hidden) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const record = (await value(store.get(chatId))) ?? blankChat(chatId); + record.hidden = hidden; + store.put(record); + await done(tx); + return record; +} + +// --- сообщения ---------------------------------------------------------- + +export function message(id) { + return get("messages", id); +} + +// saveMessages пишет сообщения и обновляет их чаты одной транзакцией. +// ACK серверу уходит только после успешной записи (docs/storage.md), +// поэтому лента и счётчик непрочитанных не должны расходиться. +// +// remove — идентификаторы, которые надо убрать: устаревший ULID +// неотправленного сообщения меняется на свежий, и старая запись уходит +// (ADR-036). +// me — собственный ник: свои сообщения непрочитанными не считаются. +// incoming — сообщения пришли из потока событий: известный id +// игнорируется целиком, перезаписи нет (ADR-034). +// +// Отдаёт ключи затронутых чатов. +export async function saveMessages({ + messages = [], + remove = [], + me = null, + incoming = false, +} = {}) { + if (messages.length === 0 && remove.length === 0) { + return []; + } + const db = await open(); + const tx = db.transaction(["messages", "chats"], "readwrite"); + const store = tx.objectStore("messages"); + const chatStore = tx.objectStore("chats"); + + for (const id of remove) { + store.delete(id); + } + // Оба чтения — запросы этой же транзакции: она живёт, пока их ждут. + const known = await Promise.all(messages.map((m) => value(store.get(m.id)))); + const ids = [...new Set(messages.map((m) => m.chatId))]; + const records = await Promise.all(ids.map((id) => value(chatStore.get(id)))); + + const touched = new Map(); + ids.forEach((id, i) => touched.set(id, records[i] ?? blankChat(id))); + + // Повтор доставки не должен ни дублировать ленту, ни двигать счётчик: + // сервер выдаёт очередь заново при каждом подключении и вправе + // прислать конверт дважды в одной пачке (ADR-017). Дубли внутри пачки + // видны только здесь: known собран до первого put. + const seen = new Set(); + messages.forEach((m, i) => { + const twice = seen.has(m.id); + seen.add(m.id); + // Входящее с уже известным id игнорируется целиком: id открыт + // в конверте, и перезапись отдала бы собеседнику чужую запись + // в истории (ADR-034). Исходящее по своему id пишется всегда — + // это переход pending → sent/failed. + if (twice || (incoming && known[i] !== undefined)) { + return; + } + store.put(m); + const record = touched.get(m.chatId); + if (!record.lastId || record.lastId < m.id) { + record.lastId = m.id; + } + if (known[i] !== undefined) { + return; + } + if (m.from !== me && (!record.lastReadId || record.lastReadId < m.id)) { + record.unread += 1; + } + // Новое сообщение возвращает скрытый чат в список. + record.hidden = false; + }); + + for (const record of touched.values()) { + chatStore.put(record); + } + await done(tx); + return [...touched.keys()]; +} + +// messagesBefore — страница ленты назад от before, не включая его, +// по индексу "chat" (docs/storage.md). Отдаёт по возрастанию id. +export async function messagesBefore(chatId, before = null, limit = PAGE) { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + // Ключ индекса — [chatId, id]. Массив больше любой строки, поэтому + // [chatId, []] — верхняя граница всех сообщений чата, а [chatId] — + // нижняя: короткий массив идёт раньше своих продолжений. + const range = before + ? IDBKeyRange.bound([chatId], [chatId, before], false, true) + : IDBKeyRange.bound([chatId], [chatId, []]); + const out = []; + await cursor(store.index("chat").openCursor(range, "prev"), (record) => { + out.push(record); + return out.length < limit; + }); + out.reverse(); + return out; +} + +// pendingMessages — неотправленное по возрастанию id. Индекса по статусу +// в схеме нет (docs/storage.md), поэтому это проход курсором: он делается +// один раз при старте, дальше отправитель ведёт свой список. +export async function pendingMessages() { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + const out = []; + await cursor(store.openCursor(), (record) => { + if (record.status === "pending") { + out.push(record); + } + return true; + }); + return out; +} + +// cursor обходит курсор, пока step не скажет «хватит». +function cursor(request, step) { + return new Promise((resolve, reject) => { + request.onsuccess = () => { + const current = request.result; + if (!current || !step(current.value)) { + resolve(); + return; + } + current.continue(); + }; + request.onerror = () => reject(request.error); + }); +} + +// --- собеседники -------------------------------------------------------- + +// peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом +// получении ключа; сверка изменившегося ключа и pending — этап 3. +export function peer(nick) { + return get("peers", nick); +} + +export function putPeer(record) { + return put("peers", record); +} + // persist просит браузер не вычищать базу: история на устройстве — // единственная копия (docs/storage.md). export async function persist() { diff --git a/web/js/main.js b/web/js/main.js index 49cacf8..9a38e18 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -6,6 +6,7 @@ import * as api from "./api.js"; import * as db from "./db.js"; +import * as sync from "./sync.js"; import { deriveAccountKeys, exportPrivateJwk, @@ -21,8 +22,11 @@ import { validIterations, wipe, } from "./crypto.js"; -import { clear } from "./ui/dom.js"; +import { DESKTOP, clear, wide } from "./ui/dom.js"; import { renderAuth } from "./ui/auth.js"; +import { renderChat } from "./ui/chat.js"; +import { renderContact } from "./ui/contact.js"; +import { renderNew } from "./ui/new.js"; import { renderSettings } from "./ui/settings.js"; import { frame } from "./ui/shell.js"; @@ -32,7 +36,7 @@ const MIN_PASSWORD = 12; // Ник — ADR-019. Клиент проверяет ту же форму, что и сервер. const NICK = /^[a-z0-9_]{2,32}$/; -const state = { config: null, me: null }; +const state = { config: null, me: null, dispose: null, paint: 0, shown: null }; // AccountError — то, что случилось с ключевым материалом, а не с сетью. // Сообщение уже пригодно для показа человеку (ADR-028). @@ -65,22 +69,83 @@ const ctx = { // --- роутинг ----------------------------------------------------------- -// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»; -// на этом этапе есть только список и настройки, остальные ведут в пустой -// список: чатов, контактов и комнат ещё нет. -function render() { +// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут +// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список. +const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/; + +function route() { + const hash = location.hash || "#/"; + if (hash === "#/settings") { + return { kind: "settings" }; + } + if (hash === "#/new") { + return { kind: "new" }; + } + const nick = NICK_ROUTE.exec(hash); + if (nick) { + return { kind: nick[1], nick: nick[2] }; + } + return { kind: "root" }; +} + +// render рисует экран под текущий hash. Перерисовка гасит подписки +// прежнего экрана: список чатов и лента слушают sync. +async function render() { + const mine = ++state.paint; const app = document.getElementById("app"); - clear(app); if (!state.me) { + release(); + clear(app); renderAuth(app, ctx); return; } - const settings = (location.hash || "#/") === "#/settings"; - const { root, main } = frame(ctx, settings ? "screen" : "list"); - if (settings) { - renderSettings(main, ctx); + const where = route(); + // На десктопе `#/` показывает первый чат — тот, что вверху списка. + let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null; + if (where.kind === "root" && wide()) { + const list = await sync.chats().catch(() => []); + if (mine !== state.paint) { + return; + } + chatId = list.length > 0 ? list[0].id : null; } + + release(); + clear(app); + state.shown = chatId; + const { root, main, dispose } = frame(ctx, where.kind === "root" ? "list" : "screen", chatId); + // Сначала в документ, потом содержимое: экраны ставят фокус и мотают + // ленту, а на неприсоединённом узле это не работает. app.append(root); + const parts = [dispose]; + if (where.kind === "settings") { + renderSettings(main, ctx); + } else if (where.kind === "new") { + renderNew(main, ctx); + } else if (where.kind === "contact") { + renderContact(main, ctx, where.nick); + } else if (chatId !== null) { + parts.push(renderChat(main, ctx, chatId)); + } + state.dispose = () => parts.forEach((off) => off()); +} + +function release() { + if (state.dispose) { + state.dispose(); + state.dispose = null; + } + state.shown = null; +} + +// Первый чат на десктопе показывается и тогда, когда список приехал позже +// экрана: после входа на новом устройстве чаты приходят с контактами, уже +// после первой отрисовки. Открытый чат при этом не трогаем — иначе новое +// сообщение в соседнем чате уводило бы из текущего. +function fill() { + if (state.me && state.shown === null && route().kind === "root" && wide()) { + render(); + } } function go(hash) { @@ -234,6 +299,13 @@ async function adopt(nick, priv, secret) { accountSecret: await importSecret(secret), }); state.me = { nick, publicKey, fingerprint }; + connect(); +} + +// connect поднимает поток событий и синхронизацию. Отказы разбирает сам +// sync: экран входа их уже не касается. +function connect() { + sync.start().catch(() => {}); } // raise — автоматическое повышение итераций сразу после входа, молча @@ -303,6 +375,7 @@ async function signOut() { // forget уносит историю: она на этом устройстве единственная копия // (docs/storage.md, docs/ui.md). async function forget() { + sync.stop(); await db.destroy(); state.me = null; } @@ -318,6 +391,25 @@ function errorText(err) { async function boot() { db.persist(); + // Обработчик ставится раньше первого запроса: 401 unauthenticated + // на любом из них — на экран входа, IndexedDB цела. + api.onSessionExpired(() => { + if (state.me) { + sync.stop(); + state.me = null; + render(); + } + }); + addEventListener("hashchange", render); + // Перелом ширины меняет только выбор маршрута: на десктопе `#/` — первый + // чат, на мобильном — список. Открытый экран не трогаем: в нём набранный + // текст, а всё остальное разбирает CSS. + matchMedia(DESKTOP).addEventListener("change", () => { + if (route().kind === "root") { + render(); + } + }); + sync.on("chats", fill); try { await ensureConfig(); } catch { @@ -325,14 +417,9 @@ async function boot() { } state.me = await restore(); render(); - addEventListener("hashchange", render); - // 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела. - api.onSessionExpired(() => { - if (state.me) { - state.me = null; - render(); - } - }); + if (state.me) { + connect(); + } } boot(); diff --git a/web/js/sync.js b/web/js/sync.js new file mode 100644 index 0000000..a78656f --- /dev/null +++ b/web/js/sync.js @@ -0,0 +1,911 @@ +// Транспорт и данные чата: устройство, поток событий, приём и отправка. +// Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js +// они не ходят — пишет в базу только этот модуль. +// +// Правила — docs/protocol.md («События», «Сообщения») и docs/storage.md: +// ACK уходит только после успешной записи в IndexedDB, исходящее живёт +// в pending до 202 и держится за свой ULID, пока время в нём годится +// серверу; отвергнутый по часам переиспользованный id меняется на свежий +// один раз (ADR-036). + +import * as api from "./api.js"; +import { ApiError, NetworkError } from "./api.js"; +import * as db from "./db.js"; +import { + DM_KEY_ID, + dmKey, + dmLabel, + fingerprintOf, + newId, + openMessage, + sealMessage, +} from "./crypto.js"; +import { ulid, ulidTime, validUlid } from "./ulid.js"; + +// Пауза перед восстановлением закрытого потока: удваивается, пока +// не упрётся в предел. Живой ready сбрасывает её обратно. +const RETRY_MIN = 1000; +const RETRY_MAX = 30000; + +// Владение потоком одно на браузерный профиль: устройство у вкладок общее, +// а соединение на устройство сервер держит одно (ADR-035). +const STREAM_LOCK = "bare-stream"; +const CHANNEL = "bare"; + +// Оба API нужны вместе: замок выбирает владельца потока, канал раздаёт +// его находки остальным вкладкам. Нет хотя бы одного — работаем как +// одна вкладка (ADR-035). +const shared = typeof BroadcastChannel === "function" && !!navigator.locks; + +const state = { + running: false, + nick: null, + privateKey: null, + device: null, + close: null, // закрыть поток событий + online: false, + timer: null, + wait: RETRY_MIN, + // Владение потоком: release отпускает замок, claim отменяет ожидание. + release: null, + claim: null, + channel: null, + // Ключи личных чатов — только в памяти: в IndexedDB они не пишутся, + // а выводятся заново из peers (docs/crypto.md, «Чат 1:1»). + keys: new Map(), + // Неотправленное. Полный проход по messages делается один раз при + // старте: индекса по статусу в схеме нет (docs/storage.md). + pending: new Set(), + // Конверты, пришедшие по SSE и ещё не разобранные. + inbox: [], + scheduled: false, + // Отложенный разбор конвертов, которые сейчас не разобрать. + inboxTimer: null, + hold: RETRY_MIN, +}; + +// --- события для экранов ----------------------------------------------- + +const bus = new EventTarget(); + +// on подписывает обработчик и отдаёт функцию отписки. События три: +// +// "net" {online} — доходят ли запросы до сервера +// "chats" {} — список чатов изменился +// "messages" {chatId, ids, removed} — в чате появились, изменились +// или исчезли сообщения +// +// removed непуст, только когда повтор отправки выдал сообщению новый +// ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним +// идентификатором, removed пуст, и лента не перерисовывается (ADR-036). +export function on(type, handler) { + const wrapped = (event) => handler(event.detail); + bus.addEventListener(type, wrapped); + return () => bus.removeEventListener(type, wrapped); +} + +function emit(type, detail = {}) { + bus.dispatchEvent(new CustomEvent(type, { detail })); +} + +// notify рассылает изменения: экрану чата нужна лента, сайдбару — список. +// Те же изменения уходят соседним вкладкам: поток событий у профиля один, +// а база общая (ADR-035). +function notify(messages, removed = []) { + const byChat = new Map(); + const slot = (chatId) => { + if (!byChat.has(chatId)) { + byChat.set(chatId, { chatId, ids: [], removed: [] }); + } + return byChat.get(chatId); + }; + for (const m of messages) { + slot(m.chatId).ids.push(m.id); + } + for (const r of removed) { + slot(r.chatId).removed.push(r.id); + } + const details = [...byChat.values()]; + for (const detail of details) { + emit("messages", detail); + } + emit("chats"); + share({ + kind: "changed", + details, + // Отправленное и похороненное повтора больше не ждёт. О том, что + // осталось pending, соседям говорит settle: пока попытка идёт, + // повтор из соседней вкладки отправил бы то же сообщение второй + // раз (ADR-035). + settled: messages.filter((m) => m.status !== "pending").map((m) => m.id) + .concat(removed.map((r) => r.id)), + }); +} + +// announceChats — список чатов изменился без сообщений: прочитан чат, +// заведён или скрыт собеседник. +function announceChats() { + emit("chats"); + share({ kind: "chats" }); +} + +// --- соседние вкладки --------------------------------------------------- + +// share отдаёт изменение соседним вкладкам. Канал открыт, только пока +// синхронизация жива: после выхода база стирается, рассылать нечего. +function share(payload) { + state.channel?.postMessage(payload); +} + +function openChannel() { + if (!shared || state.channel) { + return; + } + state.channel = new BroadcastChannel(CHANNEL); + state.channel.addEventListener("message", (event) => receive(event.data)); + // Вкладка, открытая позже владельца, состояния сети ещё не знает. + share({ kind: "hello" }); +} + +function closeChannel() { + state.channel?.close(); + state.channel = null; +} + +// receive применяет чужое изменение: в базу оно уже записано той вкладкой, +// здесь остаётся поднять экраны. Рассылать это дальше нельзя — иначе +// сообщение ходило бы по кругу. +function receive(data) { + if (!state.running || data === null || typeof data !== "object") { + return; + } + switch (data.kind) { + case "hello": + // Отвечает владелец: только он знает, цел ли поток. + if (state.release) { + share({ kind: "net", online: state.online }); + } + return; + case "net": + applyOnline(data.online === true); + return; + case "chats": + emit("chats"); + return; + case "pending": + // Соседняя вкладка не отправила сообщение и повторять его не будет: + // повторяет владелец потока. + for (const id of data.ids ?? []) { + state.pending.add(id); + } + return; + case "changed": + for (const id of data.settled ?? []) { + state.pending.delete(id); + } + for (const detail of data.details ?? []) { + emit("messages", detail); + } + emit("chats"); + return; + default: + } +} + +// --- жизненный цикл ----------------------------------------------------- + +// start поднимает синхронизацию после входа или восстановления сессии. +// Ключи берутся из IndexedDB: наружу они не выходят. +export async function start() { + if (state.running) { + return; + } + let meta; + try { + meta = await db.meta(["nick", "privateKey"]); + } catch { + return; + } + if (!meta.nick || !meta.privateKey) { + return; + } + state.running = true; + state.nick = meta.nick; + state.privateKey = meta.privateKey; + openChannel(); + try { + for (const m of await db.pendingMessages()) { + state.pending.add(m.id); + } + } catch { + // Не прочли — повторим при следующем запуске; отправка не сломана. + } + await connect(); +} + +// stop гасит синхронизацию: выход, удаление аккаунта, истёкшая сессия. +// Базу не трогает — это дело main.js. +export function stop() { + state.running = false; + clearTimer(); + if (state.inboxTimer !== null) { + clearTimeout(state.inboxTimer); + state.inboxTimer = null; + } + if (state.close) { + state.close(); + state.close = null; + } + // Замок отпускается раньше, чем гаснет всё остальное: соседняя вкладка + // ждёт очереди и займёт поток сразу (ADR-035). + yieldStream(); + closeChannel(); + state.nick = null; + state.privateKey = null; + state.device = null; + state.keys.clear(); + state.pending.clear(); + state.inbox.length = 0; + state.wait = RETRY_MIN; + state.hold = RETRY_MIN; + setOnline(false); +} + +export function online() { + return state.online; +} + +export function nick() { + return state.nick; +} + +export function deviceId() { + return state.device; +} + +// --- устройство --------------------------------------------------------- + +// ensureDevice — deviceId устройства: 16 случайных байт base64url, +// заводится при первом входе и живёт в IndexedDB (ADR-017). +// 409 device_conflict означает, что идентификатор занят другим аккаунтом: +// берём новый. +async function ensureDevice() { + let id = (await db.meta(["deviceId"])).deviceId ?? null; + for (let attempt = 0; attempt < 3; attempt += 1) { + if (!id) { + id = newId(); + await db.putMeta({ deviceId: id }); + } + try { + await api.registerDevice(id); + return id; + } catch (err) { + if (err instanceof ApiError && err.code === "device_conflict") { + id = null; + continue; + } + throw err; + } + } + throw new Error("не удалось завести устройство"); +} + +// --- поток событий ------------------------------------------------------ + +async function connect() { + if (!state.running) { + return; + } + clearTimer(); + try { + state.device = await ensureDevice(); + } catch (err) { + // 401 unauthenticated уже увёл на экран входа и остановил нас. + if (err instanceof NetworkError) { + // Запрос не дошёл — это и есть «нет соединения» (ADR-028). + setOnline(false); + } + if (transient(err)) { + retryLater(); + } + return; + } + if (state.release) { + // Поток уже наш: переподключение идёт под тем же замком. + openStream(); + return; + } + claimStream(); +} + +// claimStream берёт владение потоком. Устройство у вкладок одного профиля +// общее (ADR-017), а соединение на устройство сервер держит одно: без +// арбитража вкладки бесконечно отбирали бы поток друг у друга. Замок +// держится, пока жива синхронизация; ожидающие вкладки живут на +// broadcast от владельца (ADR-035). +function claimStream() { + if (state.claim) { + return; + } + if (!shared) { + openStream(); + return; + } + const claim = new AbortController(); + state.claim = claim; + navigator.locks.request(STREAM_LOCK, { signal: claim.signal }, () => new Promise((release) => { + state.claim = null; + if (!state.running) { + release(); + return; + } + state.release = release; + openStream(); + })).catch(() => { + // Ожидание отменено выходом или замок не дался — потока у нас нет. + if (state.claim === claim) { + state.claim = null; + } + }); +} + +// yieldStream отпускает владение: соседняя вкладка займёт поток сразу. +function yieldStream() { + if (state.claim) { + state.claim.abort(); + state.claim = null; + } + if (state.release) { + state.release(); + state.release = null; + } +} + +function openStream() { + if (state.close) { + state.close(); + } + state.close = api.stream(state.device, { + msg: (envelope) => { + if (envelope) { + state.inbox.push(envelope); + schedule(); + } + }, + ready: () => { + state.wait = RETRY_MIN; + setOnline(true); + serial(afterReady); + }, + error: (closed) => { + setOnline(false); + // Браузер переподключается сам, пока поток не закрыт насовсем. + if (closed) { + retryLater(); + } + }, + }); +} + +function retryLater() { + if (state.timer !== null || !state.running) { + return; + } + const delay = state.wait; + state.wait = Math.min(delay * 2, RETRY_MAX); + state.timer = setTimeout(() => { + state.timer = null; + recover(); + }, delay); +} + +function clearTimer() { + if (state.timer !== null) { + clearTimeout(state.timer); + state.timer = null; + } +} + +// recover разбирает окончательно закрытый поток. Причин две: сессии +// больше нет — это увидит GET /api/me и уведёт на экран входа; или +// устройства больше нет — тогда его надо завести заново. +async function recover() { + if (!state.running) { + return; + } + try { + await api.me(); + } catch (err) { + if (err instanceof NetworkError) { + retryLater(); + } + return; + } + await connect(); +} + +// setOnline — состояние сети этой вкладки. Владелец потока рассказывает +// о нём соседям: своего потока у них нет (ADR-035). +function setOnline(value) { + if (state.online === value) { + return; + } + applyOnline(value); + if (state.release) { + share({ kind: "net", online: value }); + } +} + +function applyOnline(value) { + if (state.online === value) { + return; + } + state.online = value; + emit("net", { online: value }); +} + +// --- очередь работ ------------------------------------------------------ + +// serial выстраивает работу с базой в очередь: приём, отправка и повтор +// не должны идти одновременно. +let chain = Promise.resolve(); + +function serial(task) { + const next = chain.then(() => task()); + chain = next.catch(() => {}); + return next; +} + +// schedule откладывает разбор входящих на следующий такт: очередь при +// подключении приходит событием на конверт, а записать её и подтвердить +// лучше пачкой. Разбор забирает всё, что успело накопиться. +function schedule() { + if (state.scheduled) { + return; + } + state.scheduled = true; + setTimeout(() => { + state.scheduled = false; + serial(flush); + }, 0); +} + +// --- приём -------------------------------------------------------------- + +async function flush() { + const batch = state.inbox.splice(0, state.inbox.length); + if (batch.length === 0) { + return; + } + const messages = []; + const acked = []; + const kept = []; + for (const envelope of batch) { + if (!usable(envelope)) { + // Разобрать нечего, но и держать это в очереди сервера незачем. + if (typeof envelope?.id === "string") { + acked.push(envelope.id); + } + continue; + } + const record = await decode(envelope); + if (record === null) { + // Ключа сейчас не добыть по причине, которая пройдёт: конверт + // остаётся у нас и разбирается заново. Ждать переподключения + // нельзя — поток цел и рваться не собирается. + kept.push(envelope); + continue; + } + messages.push(record); + acked.push(record.id); + } + if (messages.length > 0) { + await db.saveMessages({ messages, me: state.nick, incoming: true }); + notify(messages); + } + if (kept.length > 0) { + state.inbox.unshift(...kept); + postpone(); + } else { + state.hold = RETRY_MIN; + } + // ACK — только после успешной записи (docs/storage.md). + await ackAll(acked); +} + +// postpone откладывает повторный разбор: причина, по которой конверт не +// разобрался, проходит сама, но сообщать о себе не умеет. Пауза +// удваивается, удачный разбор возвращает её к минимуму. +function postpone() { + if (state.inboxTimer !== null || !state.running) { + return; + } + const delay = state.hold; + state.hold = Math.min(delay * 2, RETRY_MAX); + state.inboxTimer = setTimeout(() => { + state.inboxTimer = null; + schedule(); + }, delay); +} + +// usable — форма конверта (docs/protocol.md, «Типы»). Сервер её проверяет, +// но запись в базу собирается из этих полей, и мусор до неё не доходит. +function usable(e) { + return e !== null && typeof e === "object" + && typeof e.id === "string" && validUlid(e.id) + && typeof e.from === "string" + && typeof e.keyId === "string" + && typeof e.iv === "string" && typeof e.ct === "string" + && Number.isFinite(e.ts) + && (typeof e.to?.dm === "string") !== (typeof e.to?.room === "string"); +} + +// decode превращает конверт в запись messages. null означает «сейчас +// не разобрать по причине, которая пройдёт»: конверт остаётся и у нас, +// и в очереди сервера — ACK по нему не уходит. Ошибка AEAD +// и неизвестный keyId причиной не являются — +// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»). +async function decode(envelope) { + const me = state.nick; + const peer = envelope.to.dm + ? (envelope.from === me ? envelope.to.dm : envelope.from) + : null; + const base = { + id: envelope.id, + chatId: peer === null ? db.roomChatId(envelope.to.room) : db.dmChatId(peer), + from: envelope.from, + text: null, + ts: envelope.ts, + status: "sent", + }; + // Комнаты — этап 3: ключа комнаты на устройстве ещё нет. + if (peer === null || envelope.keyId !== DM_KEY_ID) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + + let key; + try { + key = await chatKey(peer); + } catch (err) { + if (transient(err)) { + return null; + } + // Ник исчез: публичного ключа не будет и позже, но raw остаётся. + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + try { + const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) }); + return { ...base, text }; + } catch { + // Смену ключа собеседника разбирает TOFU (ADR-016) — этап 3; + // до тех пор любая неудача AEAD выглядит одинаково. + return { ...base, undecryptable: "bad_aead", raw: envelope }; + } +} + +async function ackAll(ids) { + for (let i = 0; i < ids.length; i += api.MAX_ACK) { + try { + await api.ack(state.device, ids.slice(i, i + api.MAX_ACK)); + } catch { + // Не подтвердили — сервер выдаст конверты заново, а put по тому же + // id дублей не создаст (ADR-017). + return; + } + } +} + +// --- после ready -------------------------------------------------------- + +// afterReady — очередь выдана целиком. Клиент перечитывает контакты +// и повторяет неотправленное (docs/ui.md, «Сеть и состояния»). +// Комнаты — этап 3. +async function afterReady() { + await refreshContacts(); + await retryPending(); +} + +async function refreshContacts() { + let list; + try { + list = await api.contacts(); + } catch { + return; + } + let changed = false; + for (const contact of list) { + await rememberPeer(contact.nick, contact.publicKey, contact.createdAt); + const chatId = db.dmChatId(contact.nick); + if (!(await db.chat(chatId))) { + await db.putChat(db.blankChat(chatId)); + changed = true; + } + } + if (changed) { + announceChats(); + } +} + +// retryPending повторяет неотправленное после подключения. Идёт прямо, +// без serial: afterReady уже внутри очереди. +async function retryPending() { + for (const id of [...state.pending]) { + let record; + try { + record = await db.message(id); + } catch { + return; + } + if (!record || record.status !== "pending") { + state.pending.delete(id); + continue; + } + await attempt(record, record.id); + } +} + +// --- собеседники -------------------------------------------------------- + +// chatKey — ключ личного чата из памяти или выведенный заново. +async function chatKey(peer) { + const cached = state.keys.get(peer); + if (cached) { + return cached; + } + const record = await knownPeer(peer); + const key = await dmKey(state.privateKey, record.publicKey, state.nick, peer); + state.keys.set(peer, key); + return key; +} + +// knownPeer — запись TOFU. Ключа нет — берём у сервера и запоминаем +// как есть: сверка изменившегося ключа — этап 3 (ADR-016). +async function knownPeer(nick) { + const known = await db.peer(nick); + if (known) { + return known; + } + const user = await api.user(nick); + return rememberPeer(user.nick, user.publicKey); +} + +// rememberPeer запоминает ключ при первом контакте. Уже знакомый ник +// не трогается: смена ключа — состояние, а не перезапись (ADR-016). +async function rememberPeer(nick, publicKey, firstSeen = Date.now()) { + const known = await db.peer(nick); + if (known) { + return known; + } + const record = { + nick, + publicKey, + fingerprint: await fingerprintOf(publicKey), + firstSeen, + pending: null, + }; + await db.putPeer(record); + return record; +} + +// --- отправка ----------------------------------------------------------- + +// send — новое исходящее сообщение. Пустая строка не отправляется; +// предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»). +// Отдаёт id записи или null, если отправлять нечего. +export function send(chatId, text) { + const body = String(text ?? "").trim(); + if (!state.running || body === "" || db.peerOf(chatId) === null) { + return Promise.resolve(null); + } + return serial(() => attempt({ chatId, text: body }, null)); +} + +// retry — повтор с пометки «не отправлено». +export function retry(id) { + if (!state.running) { + return Promise.resolve(null); + } + return serial(async () => { + const record = await db.message(id); + if (!record || record.status === "sent") { + return null; + } + return attempt(record, record.id); + }); +} + +// REUSE — запас под окно часов сервера: он принимает сообщение, пока время +// в ULID расходится с его часами не больше чем на пять минут (ADR-017). +// Идентификатор переиспользуется, пока до края окна остаётся минута: за неё +// успевают шифрование, очередь работ и сама сеть, так что дошедший запрос +// застаёт окно ещё открытым. +const REUSE = 4 * 60 * 1000; + +// attempt — одна попытка отправки. Прежний ULID сохраняется, пока его время +// годится серверу: ответ на POST мог потеряться после того, как сервер +// сообщение принял, и повтор с тем же идентификатором получатель молча +// пропустит (ADR-034), а повтор с новым лёг бы у него вторым сообщением +// (ADR-036). Идентификатор старше запаса заменяется свежим, и тогда старая +// запись удаляется: время в id должно совпадать с временем фактической +// отправки — иначе после долгого офлайна сервер ответит clock_skew. +// +// fresh требует свежий идентификатор, каким бы годным ни выглядел прежний: +// так возвращается попытка, у которой переиспользованный id сервер отверг +// по часам. +async function attempt(source, previousId, fresh = false) { + const peer = db.peerOf(source.chatId); + if (peer === null) { + state.pending.delete(previousId); + return null; + } + const keep = !fresh && previousId !== null && reusable(previousId); + const message = { + id: keep ? previousId : ulid(), + chatId: source.chatId, + from: state.nick, + text: source.text, + // Время показа идёт за идентификатором: сохранённый id оставляет + // и прежнее ts — до 202, которое принесёт серверное. + ts: keep ? source.ts : Date.now(), + status: "pending", + }; + const stale = previousId !== null && !keep; + if (stale) { + state.pending.delete(previousId); + } + state.pending.add(message.id); + await db.saveMessages({ + messages: [message], + remove: stale ? [previousId] : [], + me: state.nick, + }); + notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []); + const err = await post(message, peer); + if (err === null) { + return message.id; + } + // Возраст переиспользованного id сервер считает по своим часам: к времени, + // проведённому в pending, добавляется расхождение часов. Отставание в пару + // минут выводит за окно идентификатор, который клиенту кажется свежим. + // Это ровно та причина, ради которой id и меняется, — берём свежий и идём + // второй раз. Второго круга нет: fresh снимает переиспользование, и такой + // же отказ на свежем id означает, что часы врут по-настоящему (ADR-036). + if (keep && err instanceof ApiError && err.code === "clock_skew") { + return attempt(message, message.id, true); + } + await settle(message, err); + return message.id; +} + +// reusable — годится ли прежний идентификатор для новой попытки. Часы +// сравниваются со своими же: других у клиента нет, и первый ULID берётся +// из них же. Часы, врущие сверх окна, отсекает сервер: clock_skew на +// переиспользованном id разбирает attempt, на свежем — settle. +function reusable(id) { + const ms = ulidTime(id); + return ms !== null && Math.abs(Date.now() - ms) < REUSE; +} + +// post шифрует и отдаёт конверт серверу. from в AAD — собственный ник: +// сервер проставит то же значение из сессии, и AAD сойдётся у получателя +// (docs/crypto.md, «Сообщение»). +// +// Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt — +// clock_skew на переиспользованном идентификаторе кончается не полосой, +// а второй попыткой. +async function post(message, peer) { + let envelope; + try { + const sealed = await sealMessage(await chatKey(peer), { + id: message.id, + chat: dmLabel(state.nick, peer), + from: state.nick, + keyId: DM_KEY_ID, + text: message.text, + }); + envelope = { + id: message.id, + to: { dm: peer }, + keyId: DM_KEY_ID, + iv: sealed.iv, + ct: sealed.ct, + }; + } catch (err) { + return err; + } + try { + const answer = await api.sendMessage(state.device, envelope); + // Запрос дошёл: сеть есть, что бы ни думал поток событий (ADR-028). + setOnline(true); + state.pending.delete(message.id); + const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts }; + await db.saveMessages({ messages: [sent], me: state.nick }); + notify([sent]); + return null; + } catch (err) { + // Ответ с кодом — то же доказательство, что запрос дошёл, что и 202: + // сеть есть, что бы ни думал поток событий (ADR-028). Ошибка шифрования + // сюда не попадает — она случается до запроса. 401 unauthenticated уже + // увёл на экран входа: состояние сети там ничьё. + if (err instanceof ApiError && state.running) { + setOnline(true); + } + return err; + } +} + +// settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся +// pending и повторится при следующем подключении (ADR-027). Удалённое +// устройство чинится тем же способом — переподключением. Остальные 4xx — +// failed с текстом отказа (ADR-033). +async function settle(message, err) { + if (err instanceof NetworkError) { + // Поток событий молчания сети не замечает: у EventSource нет + // таймаута на тишину. Не дошедший запрос — та же полоса «нет + // соединения» (docs/ui.md, «Сеть и состояния», ADR-028). + setOnline(false); + } + if (transient(err)) { + share({ kind: "pending", ids: [message.id] }); + return; + } + if (err instanceof ApiError && err.code === "unknown_device") { + share({ kind: "pending", ids: [message.id] }); + retryLater(); + return; + } + state.pending.delete(message.id); + const failed = { ...message, status: "failed", error: api.errorText(err) }; + await db.saveMessages({ messages: [failed], me: state.nick }); + notify([failed]); +} + +// transient — отказ, который пройдёт сам: запрос не дошёл или сервер +// не справился. Повтор допустим (ADR-027). +function transient(err) { + return err instanceof NetworkError || (err instanceof ApiError && err.status >= 500); +} + +// --- действия экранов --------------------------------------------------- + +// openDm заводит личный чат с ником и отдаёт chatId. Строку списка +// заводит сервер (ADR-019), публичный ключ приходит тем же ответом. +// Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»). +export async function openDm(peer) { + const answer = await api.addContact(peer); + await rememberPeer(answer.nick, answer.publicKey); + const chatId = db.dmChatId(answer.nick); + const existing = await db.chat(chatId); + if (!existing || existing.hidden) { + // hideChat читает и пишет одной транзакцией и заводит недостающую + // запись: приём сообщений идёт своим чередом и в неё не врезается. + await db.hideChat(chatId, false); + announceChats(); + } + return chatId; +} + +// forgetChat — «убрать из списка» в карточке контакта. Строка на сервере +// уходит, зеркальная у собеседника остаётся: это не блокировка (ADR-019). +// История на устройстве не трогается — чат прячется. +export async function forgetChat(chatId) { + const peer = db.peerOf(chatId); + if (peer !== null) { + await api.removeContact(peer); + } + await db.hideChat(chatId, true); + announceChats(); +} + +// markRead — чат прочитан. Граница «новых» и счётчик локальные, на сервер +// не уходят (docs/storage.md). +export async function markRead(chatId) { + const record = await db.markRead(chatId); + announceChats(); + return record; +} + +// Чтение для экранов. Писать в базу им не нужно: всё, что меняет +// состояние, живёт здесь. dmChatId и peerOf — форма ключа чата +// (docs/storage.md): экраны собирают её из ника маршрута, а не из строки. +export { chats, chat, message, messagesBefore, peer, dmChatId, peerOf, PAGE } from "./db.js"; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js new file mode 100644 index 0000000..ff7ed8a --- /dev/null +++ b/web/js/ui/chat.js @@ -0,0 +1,425 @@ +// Экран чата — docs/ui.md, «Чат»; вид — docs/identity/screens.html. +// +// Данные и действия идут только через sync.js: экран не пишет в базу +// и не ходит в сеть сам. + +import * as sync from "../sync.js"; +import { DESKTOP, clear, el, wide } from "./dom.js"; + +// Предел текста и порог счётчика — docs/ui.md, «Чат». +const LIMIT = 4000; +const COUNTER_AT = 3500; + +// Разделители дат: на десктопе — полная дата, на мобильном — короткая, +// как в эталоне. Время — ЧЧ:ММ в локальной зоне. +const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numeric", month: "long" }); +const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" }); +const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" }); + +// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет — +// это про комнату; всё остальное в личном чате означает чужой ключ. +const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты"; +const KEY_CHANGED = "не удалось расшифровать: ключ изменился"; + +// Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее +// сообщение подматывает ленту только тогда, когда он и так смотрит конец. +const NEAR_BOTTOM = 80; + +// renderChat рисует чат в root и отдаёт отписку. +export function renderChat(root, ctx, chatId) { + const view = { + ctx, + chatId, + me: ctx.me.nick, + peer: sync.peerOf(chatId), + limit: ctx.config?.maxMessageChars ?? LIMIT, + alive: true, + // Лента: записи по возрастанию id и их строки в разметке. + items: [], + nodes: new Map(), + // Граница «новых»: первый непрочитанный на момент открытия. + newId: null, + chain: Promise.resolve(), + }; + + root.append(head(view)); + + view.feed = el("div", "feed"); + view.body = el("div", "grid"); + view.body.setAttribute("aria-live", "polite"); + view.feed.append(view.body); + root.append(view.feed); + + root.append(composer(view)); + + const offMessages = sync.on("messages", (detail) => { + if (detail.chatId === view.chatId) { + run(view, () => apply(view, detail)); + } + }); + const offNet = sync.on("net", () => paintBar(view)); + const media = matchMedia(DESKTOP); + const onMedia = () => paint(view, true); + media.addEventListener("change", onMedia); + + run(view, () => load(view)); + + return () => { + view.alive = false; + offMessages(); + offNet(); + media.removeEventListener("change", onMedia); + }; +} + +// run выстраивает работу экрана в очередь: загрузка и приходящие события +// не должны перемешиваться. +function run(view, task) { + view.chain = view.chain.then(task).catch(() => {}); + return view.chain; +} + +// --- разметка ----------------------------------------------------------- + +// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева +// нужен там, где виден один экран за раз; на десктопе его прячет CSS. +function head(view) { + const bar = el("div", "head"); + const back = el("button", "back back--chat", "назад"); + back.type = "button"; + back.addEventListener("click", () => view.ctx.go("#/")); + const title = el("button", "chat-title", `@${view.peer}`); + title.type = "button"; + title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`)); + bar.append(back, title); + return bar; +} + +// composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>» +// цветом mark. Enter отправляет только на десктопе; на мобильном он делает +// перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»). +function composer(view) { + const form = el("form", "compose"); + form.noValidate = true; + + view.bar = el("p", "bar"); + view.bar.hidden = true; + view.bar.setAttribute("aria-live", "polite"); + + const row = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + + view.field = el("textarea", "input__field"); + view.field.rows = 1; + view.field.placeholder = "сообщение"; + view.field.maxLength = view.limit; + + view.counter = el("span", "counter"); + view.counter.hidden = true; + + const send = el("button", "input__send", ">"); + send.type = "submit"; + + row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send); + form.append(view.bar, row); + + view.field.addEventListener("input", () => count(view)); + view.field.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) { + return; + } + if (!wide()) { + return; + } + event.preventDefault(); + submit(view); + }); + form.addEventListener("submit", (event) => { + event.preventDefault(); + submit(view); + }); + + return form; +} + +// count — счётчик остатка: появляется после порога (docs/ui.md, «Чат»). +function count(view) { + const length = view.field.value.length; + view.counter.textContent = String(view.limit - length); + view.counter.hidden = length <= COUNTER_AT; +} + +function submit(view) { + const text = view.field.value; + if (text.trim() === "") { + return; + } + view.field.value = ""; + count(view); + run(view, () => sync.send(view.chatId, text)); +} + +// --- лента -------------------------------------------------------------- + +async function load(view) { + let record = null; + let list = []; + try { + record = await sync.chat(view.chatId); + list = await sync.messagesBefore(view.chatId); + } catch { + // Базы нет — рисуем пустую ленту: отправка от этого не ломается. + } + if (!view.alive) { + return; + } + view.items = list; + view.newId = firstUnread(record, list, view.me); + paint(view, true); + // Фокус в строку ввода при открытии чата на десктопе (docs/ui.md, + // «Доступность»); на мобильном это подняло бы клавиатуру на весь экран. + if (wide()) { + view.field.focus(); + } + await read(view); +} + +// firstUnread — граница «новых»: первый чужой непрочитанный. Своё +// непрочитанным не бывает, поэтому и границей не становится. +function firstUnread(record, list, me) { + if (!record || record.unread <= 0) { + return null; + } + const bound = record.lastReadId; + const found = list.find((m) => m.from !== me && (!bound || m.id > bound)); + return found ? found.id : null; +} + +// read помечает чат прочитанным — после отрисовки: до этого lastReadId +// и есть граница «новых» (docs/storage.md). +async function read(view) { + try { + await sync.markRead(view.chatId); + } catch { + // Счётчик непрочитанных подождёт до следующего раза. + } +} + +// apply разбирает изменения ленты. Дописать в конец дешевле, чем +// перерисовать: лента — живая область, и перерисовка заставила бы +// экранного диктора зачитать её целиком. +async function apply(view, detail) { + const incoming = []; + for (const id of detail.ids ?? []) { + let record = null; + try { + record = await sync.message(id); + } catch { + return; + } + if (record && record.chatId === view.chatId) { + incoming.push(record); + } + } + if (!view.alive) { + return; + } + const bottom = atBottom(view); + let whole = false; + let added = 0; + + for (const id of detail.removed ?? []) { + const at = view.items.findIndex((m) => m.id === id); + if (at >= 0) { + view.items.splice(at, 1); + whole = true; + } + } + incoming.sort((a, b) => (a.id < b.id ? -1 : 1)); + for (const record of incoming) { + const at = view.items.findIndex((m) => m.id === record.id); + if (at >= 0) { + // Та же запись в новом состоянии: pending стал sent или failed. + view.items[at] = record; + if (!whole) { + redraw(view, record); + } + continue; + } + const last = view.items[view.items.length - 1]; + if (last && last.id > record.id) { + // Из очереди сервера пришло то, что старше уже нарисованного. + view.items.splice(view.items.findIndex((m) => m.id > record.id), 0, record); + whole = true; + continue; + } + view.items.push(record); + if (!whole) { + line(view, record, view.items[view.items.length - 2] ?? null); + } + added += 1; + } + + if (whole) { + paint(view, bottom); + } else { + if (bottom && added > 0) { + down(view); + } + paintBar(view); + } + if (whole || added > 0) { + await read(view); + } +} + +// paint рисует ленту заново. +function paint(view, bottom) { + clear(view.body); + view.nodes.clear(); + let previous = null; + for (const record of view.items) { + line(view, record, previous); + previous = record; + } + paintBar(view); + if (bottom) { + down(view); + } +} + +// line дописывает сообщение в конец ленты вместе с разделителями, +// которые перед ним нужны. +function line(view, record, previous) { + const day = !previous || dayOf(previous.ts) !== dayOf(record.ts); + if (day) { + view.body.append(divider(label(record.ts), false)); + } + const fresh = record.id === view.newId; + if (fresh) { + view.body.append(divider("новые", true)); + } + // Подряд идущие сообщения одного автора — без повтора автора. + const first = day || fresh || !previous || previous.from !== record.from; + const node = el("div", first ? "line is-head" : "line"); + node.append(author(view, record, first), text(view, record)); + view.body.append(node); + view.nodes.set(record.id, node); +} + +// redraw обновляет одну строку на месте: автор и группировка от состояния +// сообщения не зависят. +function redraw(view, record) { + const node = view.nodes.get(record.id); + if (!node) { + return; + } + const first = node.classList.contains("is-head"); + clear(node); + node.append(author(view, record, first), text(view, record)); +} + +function divider(caption, fresh) { + const node = el("div", fresh ? "divider divider--new" : "divider"); + node.append(el("span", null, caption)); + return node; +} + +// author — колонка автора: ник и время. Свой ник — цветом mark. +function author(view, record, first) { + const node = el("div", record.from === view.me ? "author author--me" : "author"); + if (!first) { + return node; + } + node.append(el("span", null, record.from), el("span", "t", TIME.format(record.ts))); + return node; +} + +// text — само сообщение. Нерасшифрованное — курсивом с причиной, pending — +// цветом stone, failed — с пометкой «не отправлено · повторить». +function text(view, record) { + const node = el("div", "text"); + if (record.text === null) { + node.classList.add("text--none"); + node.textContent = view.peer === null && record.undecryptable === "unknown_key" + ? NO_ROOM_KEY + : KEY_CHANGED; + return node; + } + if (record.status === "pending") { + node.classList.add("text--pending"); + } + node.append(el("p", "text__body", record.text)); + if (record.status === "failed") { + const note = el("p", "fail"); + const again = el("button", "link", "повторить"); + again.type = "button"; + again.addEventListener("click", () => run(view, () => sync.retry(record.id))); + note.append(el("span", null, "не отправлено ·"), again); + node.append(note); + } + return node; +} + +// paintBar — полоса над вводом. Причина одна за раз: отказ отправки +// перебивает «нет соединения», потому что он про конкретное сообщение +// и уходит при следующей попытке (ADR-033). +function paintBar(view) { + const failed = lastFailed(view); + if (failed) { + view.bar.className = "bar bar--mark"; + view.bar.textContent = failed.error; + view.bar.hidden = false; + return; + } + if (!sync.online()) { + view.bar.className = "bar"; + view.bar.textContent = "нет соединения"; + view.bar.hidden = false; + return; + } + view.bar.hidden = true; + view.bar.textContent = ""; +} + +// lastFailed — последнее своё неотправленное сообщение с текстом отказа +// (docs/ui.md, «Чат»). Смотреть на состояние последнего своего нельзя: +// лента отсортирована по ULID, а время в нём — часы отправителя. Отставшие +// часы ставят новое сообщение перед его же старыми, и последним своим +// остаётся давно отправленное — ровно в том случае, ради которого текст +// про часы и заведён (ADR-033). +function lastFailed(view) { + for (let i = view.items.length - 1; i >= 0; i -= 1) { + const record = view.items[i]; + if (record.from === view.me && record.status === "failed" && record.error) { + return record; + } + } + return null; +} + +// --- прокрутка и даты --------------------------------------------------- + +function atBottom(view) { + const feed = view.feed; + return feed.scrollHeight - feed.scrollTop - feed.clientHeight < NEAR_BOTTOM; +} + +function down(view) { + view.feed.scrollTop = view.feed.scrollHeight; +} + +function dayOf(ts) { + const date = new Date(ts); + return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`; +} + +// label — дата разделителя. Короткая форма на мобильном без точки +// сокращения: так в эталоне. +function label(ts) { + if (wide()) { + return DAY_LONG.format(ts); + } + return DAY_SHORT.format(ts).replace(/\.$/, ""); +} diff --git a/web/js/ui/chats.js b/web/js/ui/chats.js new file mode 100644 index 0000000..1589403 --- /dev/null +++ b/web/js/ui/chats.js @@ -0,0 +1,73 @@ +// Список чатов в сайдбаре — docs/ui.md, «Список чатов». +// +// Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит +// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет. + +import * as sync from "../sync.js"; +import { clear, el } from "./dom.js"; + +const SECTIONS = [ + ["room", "каналы"], + ["dm", "личные"], +]; + +// mount рисует список в root и держит его в актуальном виде, пока экран +// жив. Отдаёт отписку. +export function mount(root, ctx, active) { + // Событий «chats» приходит больше одного подряд; рисует последнее. + let generation = 0; + const paint = async () => { + const mine = ++generation; + let list; + try { + list = await sync.chats(); + } catch { + return; + } + if (mine !== generation) { + return; + } + clear(root); + for (const [type, title] of SECTIONS) { + const part = list.filter((chat) => chat.type === type); + if (part.length === 0) { + continue; + } + const items = el("ul", "items"); + for (const chat of part) { + items.append(item(ctx, chat, active)); + } + root.append(el("h2", "section", title), items); + } + }; + const off = sync.on("chats", paint); + paint(); + return off; +} + +function item(ctx, chat, active) { + const row = el("li"); + const button = el("button", "item"); + button.type = "button"; + if (chat.id === active) { + // Активный чат — инверсия (docs/identity/brief.md). + button.classList.add("is-active"); + button.setAttribute("aria-current", "true"); + } + button.append(el("span", "item__name", sigil(chat) + chat.title)); + if (chat.unread > 0) { + button.append(el("span", "n", String(chat.unread))); + } + button.addEventListener("click", () => ctx.go(hashOf(chat))); + row.append(button); + return row; +} + +// Сигил ставит экран: в базе чат зовётся без «@» и «#» (docs/storage.md). +function sigil(chat) { + return chat.type === "dm" ? "@" : "#"; +} + +function hashOf(chat) { + return chat.type === "dm" ? `#/dm/${chat.peer}` : `#/room/${chat.roomId}`; +} diff --git a/web/js/ui/contact.js b/web/js/ui/contact.js new file mode 100644 index 0000000..5411c11 --- /dev/null +++ b/web/js/ui/contact.js @@ -0,0 +1,61 @@ +// Карточка контакта — docs/ui.md, «Карточка контакта». +// +// Смена ключа собеседника и «доверять новому ключу» появятся вместе +// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending. + +import * as sync from "../sync.js"; +import { fingerprintGroups } from "../crypto.js"; +import { button, el, message, setError, setNote } from "./dom.js"; + +export function renderContact(root, ctx, nick) { + root.append(head(ctx, nick)); + const body = el("div", "body settings"); + root.append(body); + + const card = el("section", "block block--first"); + body.append(card, remove(ctx, nick)); + + // Отпечаток лежит в записи TOFU; её может ещё не быть, если чат + // открыли до первого ключа. + sync.peer(nick).then((record) => { + if (!record?.fingerprint || !card.isConnected) { + return; + } + const groups = fingerprintGroups(record.fingerprint); + card.append( + el("p", "fp", groups.slice(0, 8).join(" ")), + el("p", "fp", groups.slice(8).join(" ")), + el("p", "fp-hint", "сверьте с собеседником голосом или лично"), + ); + }).catch(() => {}); +} + +function head(ctx, nick) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => ctx.go(`#/dm/${nick}`)); + bar.append(back, el("span", "title", `@${nick}`)); + return bar; +} + +// remove — «убрать из списка»: строка контакта уходит с сервера, история +// на устройстве остаётся (ADR-019). +function remove(ctx, nick) { + const box = el("section", "block"); + const note = message(); + const drop = button("убрать из списка"); + drop.addEventListener("click", async () => { + drop.disabled = true; + setNote(note, ""); + try { + await sync.forgetChat(sync.dmChatId(nick)); + ctx.go("#/"); + } catch (err) { + setError(note, ctx.errorText(err)); + drop.disabled = false; + } + }); + box.append(drop, note); + return box; +} diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index f6271d5..9e98695 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -3,6 +3,15 @@ const SVG = "http://www.w3.org/2000/svg"; +// DESKTOP — порог десктопа: сайдбар и чат рядом, один экран за раз кончается +// (docs/ui.md, «Каркас»). Экраны спрашивают ширину в момент события, а не +// перерисовываются на каждое изменение размера. +export const DESKTOP = "(min-width: 760px)"; + +export function wide() { + return matchMedia(DESKTOP).matches; +} + export function el(tag, className, text) { const node = document.createElement(tag); if (className) { diff --git a/web/js/ui/new.js b/web/js/ui/new.js new file mode 100644 index 0000000..73cca3e --- /dev/null +++ b/web/js/ui/new.js @@ -0,0 +1,68 @@ +// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится +// вместе с комнатами (этап 3): создавать пока нечего. + +import * as sync from "../sync.js"; +import { el, message, setError, setNote } from "./dom.js"; + +export function renderNew(root, ctx) { + root.append(head(ctx)); + + const body = el("div", "body"); + const form = el("form", "form"); + form.noValidate = true; + + const row = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = "@ник"; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + const go = el("button", "input__send", ">"); + go.type = "submit"; + row.append(prompt, field, go); + + const note = message(); + form.append(row, note); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (go.disabled) { + return; + } + setNote(note, ""); + // Ник вводят как в списке: с «@» или без. Регистр не хранится — + // ники строчные (ADR-019). + const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + if (nick === "") { + field.focus(); + return; + } + field.value = nick; + go.disabled = true; + try { + await sync.openDm(nick); + ctx.go(`#/dm/${nick}`); + } catch (err) { + setError(note, ctx.errorText(err)); + field.focus(); + } finally { + go.disabled = false; + } + }); + + body.append(form); + root.append(body); + field.focus(); +} + +function head(ctx) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => ctx.go("#/")); + bar.append(back, el("span", "title", "новый чат")); + return bar; +} diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js index 5b015cf..eb9d2d0 100644 --- a/web/js/ui/shell.js +++ b/web/js/ui/shell.js @@ -1,19 +1,22 @@ // Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» -// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые. +// и «Список чатов». import { el, mark } from "./dom.js"; +import { mount } from "./chats.js"; -// frame отдаёт корень и место под экран. screen — что показывать -// на мобильном, где виден один экран за раз: "list" или "screen". -export function frame(ctx, screen) { +// frame отдаёт корень, место под экран и отписку списка чатов. +// screen — что показывать на мобильном, где виден один экран за раз: +// "list" или "screen". active — чат, который сейчас открыт. +export function frame(ctx, screen, active = null) { const root = el("div", "shell"); root.dataset.screen = screen; const main = el("main", "main"); - root.append(side(ctx), main); - return { root, main }; + const { nav, dispose } = side(ctx, active); + root.append(nav, main); + return { root, main, dispose }; } -function side(ctx) { +function side(ctx, active) { const nav = el("nav", "side"); const brand = el("div", "brand"); @@ -21,9 +24,11 @@ function side(ctx) { nav.append(brand); const list = el("div", "list"); - for (const title of ["каналы", "личные"]) { - list.append(el("h2", "section", title), el("ul", "items")); - } + const add = el("button", "item item--new", "+ новый чат"); + add.type = "button"; + add.addEventListener("click", () => ctx.go("#/new")); + const items = el("div"); + list.append(add, items); nav.append(list); const me = el("button", "me"); @@ -34,5 +39,5 @@ function side(ctx) { me.addEventListener("click", () => ctx.go("#/settings")); nav.append(me); - return nav; + return { nav, dispose: mount(items, ctx, active) }; } diff --git a/web/js/ulid.js b/web/js/ulid.js new file mode 100644 index 0000000..890e2c6 --- /dev/null +++ b/web/js/ulid.js @@ -0,0 +1,104 @@ +// ULID — идентификатор сообщения: 48 бит миллисекунд и 80 бит случайности, +// Crockford base32, 26 символов (docs/crypto.md, «Идентификаторы»). +// +// Заглавные буквы обязательны: идентификатор входит в AAD шифротекста +// побайтно, и сервер строчные не принимает. +// +// Модуль не знает про DOM: его можно импортировать в node и прогнать. + +// crockford — алфавит base32 без I, L, O и U. +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +const TIME_LEN = 10; // 50 бит, старшие два обязаны быть нулевыми +const RANDOM_LEN = 16; // 80 бит +const RANDOM_BYTES = 10; + +export const ULID_LEN = TIME_LEN + RANDOM_LEN; + +// MAX_TIME — предел 48 бит: дальше метка времени в ULID не помещается. +const MAX_TIME = 2 ** 48 - 1; + +// Последняя выданная миллисекунда и её случайная часть. Внутри одной +// миллисекунды случайная часть инкрементируется (docs/crypto.md): +// два сообщения, набранные подряд, не получают одинаковый идентификатор +// и сортируются в порядке отправки. +let lastMs = -1; +const lastRandom = new Uint8Array(RANDOM_BYTES); + +export function ulid(now = Date.now()) { + const ms = Math.floor(now); + if (!Number.isSafeInteger(ms) || ms < 0 || ms > MAX_TIME) { + throw new RangeError("время вне 48 бит"); + } + if (ms === lastMs) { + bump(lastRandom); + } else { + lastMs = ms; + globalThis.crypto.getRandomValues(lastRandom); + } + return encodeTime(ms) + encodeRandom(lastRandom); +} + +// ulidTime — метка времени идентификатора в миллисекундах; null, если +// это не ULID. Сервер считает ту же величину и сравнивает со своими +// часами: расхождение больше пяти минут — clock_skew (ADR-017). +export function ulidTime(id) { + if (typeof id !== "string" || id.length !== ULID_LEN) { + return null; + } + let ms = 0; + for (let i = 0; i < ULID_LEN; i += 1) { + const value = ALPHABET.indexOf(id[i]); + if (value < 0) { + return null; + } + if (i < TIME_LEN) { + ms = ms * 32 + value; + } + } + return ms > MAX_TIME ? null : ms; +} + +export function validUlid(id) { + return ulidTime(id) !== null; +} + +// bump увеличивает случайную часть на единицу. Переполнение всех 80 бит +// внутри одной миллисекунды невозможно на практике; если оно всё же +// случилось, берём новые случайные байты. +function bump(bytes) { + for (let i = bytes.length - 1; i >= 0; i -= 1) { + if (bytes[i] < 255) { + bytes[i] += 1; + return; + } + bytes[i] = 0; + } + globalThis.crypto.getRandomValues(bytes); +} + +function encodeTime(ms) { + const out = new Array(TIME_LEN); + let rest = ms; + for (let i = TIME_LEN - 1; i >= 0; i -= 1) { + out[i] = ALPHABET[rest % 32]; + rest = Math.floor(rest / 32); + } + return out.join(""); +} + +// encodeRandom режет 80 бит на 16 групп по 5: остатка нет. +function encodeRandom(bytes) { + let out = ""; + let acc = 0; + let bits = 0; + for (let i = 0; i < bytes.length; i += 1) { + acc = (acc << 8) | bytes[i]; + bits += 8; + while (bits >= 5) { + bits -= 5; + out += ALPHABET[(acc >>> bits) & 31]; + } + } + return out; +} -- 2.54.0 From 05586218e1ebfb42e311ea3c92db74ba6473a059 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 22:18:13 +0300 Subject: [PATCH 5/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=203:=20TOFU=20?= =?UTF-8?q?=D0=B8=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0=D1=82=D1=8B=20=E2=80=94?= =?UTF-8?q?=20=D0=BA=D0=BB=D1=8E=D1=87=20=D0=BA=D0=BE=D0=BC=D0=BD=D0=B0?= =?UTF-8?q?=D1=82=D1=8B,=20=D0=B0=D1=82=D0=BE=D0=BC=D0=B0=D1=80=D0=BD?= =?UTF-8?q?=D1=8B=D0=B9=20rekey,=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: комнаты, состав и завёрнутые ключи; смена состава и rekey одним атомарным запросом с проверкой keys[].to против итогового состава; обрезка до двух последних ключей; передача владения по joined_at; события room каждому со своим ключом и room_left удалённым; членство и keyId в проверках сообщения; миграция 002. Клиент: TOFU на всех путях, по которым публичный ключ доходит до клиента; предупреждение о смене ключа с блокировкой отправки и повторной расшифровкой сохранённого raw; заворачивание и разворачивание ключей комнаты, включая себе — тем же кодом, без ветвления; расшифровка любым известным keyId; экраны участников, создание комнаты, карточка контакта с двумя отпечатками. ADR-037: roomId генерирует клиент. crypto.md вплетает roomId в заворачивание, а оно происходит до запроса — создатель обязан привязать ключ к идентификатору, которого по прежнему протоколу ещё не существовало. ADR-039: завёрнутый ключ принимается только от участника. Иначе сервер подставляет ключ, завёрнутый посторонним аккаунтом, TOFU молчит — ник незнакомый, первый ключ запоминается молча, — и комната уезжает на ключ сервера. Одно подменённое поле в ответе, без сговора и подмены кода. ADR-041: долг по rekey — состояние комнаты, а не свойство события. Владелец, офлайн в момент выхода участника, не узнавал о долге никогда, и комната навсегда оставалась на ключе, который вышедший знает. ADR-038, 040, 042, 043, 044: тексты экранов комнат, снятие pending при возврате прежнего ключа, порядок ключей, форма раньше прав, экран покинутой комнаты. Приёмка на боевом: комната на троих, добавленный четвёртый читает только новое, вышедший после rekey новых не получает и писать не может, keys_mismatch, key_exists, not_owner, owner, room_conflict, чужой X-Device. Подмена public_key в базе даёт у собеседника предупреждение и блокирует отправку — проверено в Chrome. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- docs/crypto.md | 2 + docs/decisions/037-client-room-id.md | 23 + docs/decisions/038-room-screens-texts.md | 25 + .../039-room-key-sender-is-member.md | 26 + ...040-trusted-key-returned-clears-pending.md | 24 + docs/decisions/041-needs-rekey-is-state.md | 31 + docs/decisions/042-current-room-key-order.md | 23 + docs/decisions/043-form-before-rights.md | 23 + docs/decisions/044-left-room-screen.md | 22 + docs/protocol.md | 17 +- docs/storage.md | 16 +- docs/ui.md | 10 +- internal/api/account.go | 14 +- internal/api/api.go | 6 + internal/api/devices.go | 12 + internal/api/devices_test.go | 30 + internal/api/messages.go | 36 +- internal/api/rooms.go | 385 ++++++ internal/api/rooms_test.go | 1037 +++++++++++++++++ internal/store/cleanup.go | 20 +- internal/store/migrations/001_init.sql | 2 +- .../store/migrations/002_room_needs_rekey.sql | 4 + internal/store/queue.go | 62 +- internal/store/rooms.go | 771 ++++++++++++ internal/store/rooms_test.go | 334 ++++++ internal/store/store_test.go | 15 +- internal/store/users.go | 108 +- web/app.css | 92 +- web/js/api.js | 38 + web/js/crypto.js | 90 ++ web/js/db.js | 110 +- web/js/main.js | 24 +- web/js/sync.js | 831 ++++++++++++- web/js/ui/chat.js | 184 ++- web/js/ui/chats.js | 2 +- web/js/ui/contact.js | 92 +- web/js/ui/members.js | 249 ++++ web/js/ui/new.js | 103 +- 38 files changed, 4692 insertions(+), 201 deletions(-) create mode 100644 docs/decisions/037-client-room-id.md create mode 100644 docs/decisions/038-room-screens-texts.md create mode 100644 docs/decisions/039-room-key-sender-is-member.md create mode 100644 docs/decisions/040-trusted-key-returned-clears-pending.md create mode 100644 docs/decisions/041-needs-rekey-is-state.md create mode 100644 docs/decisions/042-current-room-key-order.md create mode 100644 docs/decisions/043-form-before-rights.md create mode 100644 docs/decisions/044-left-room-screen.md create mode 100644 internal/api/rooms.go create mode 100644 internal/api/rooms_test.go create mode 100644 internal/store/migrations/002_room_needs_rekey.sql create mode 100644 internal/store/rooms.go create mode 100644 internal/store/rooms_test.go create mode 100644 web/js/ui/members.js diff --git a/docs/crypto.md b/docs/crypto.md index 65321cb..63ac956 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -78,6 +78,8 @@ ct = AES-GCM(wrapK, iv, roomKey, AAD = utf8("bare-roomkey-v1|" + roomId + "| Публичный ключ `from` проходит через TOFU как любой другой. Заворачивание самому себе — `ECDH(myPrivate, myPublic)`, без исключений в коде. +Ключ, чей `from` не входит в состав комнаты, пришедший тем же `Room`, отвергается до запроса публичного ключа: TOFU запоминает первый ключ ника молча, поэтому незнакомый распространитель — это подмена, а не первый контакт (ADR-039). + ## Сообщение ``` diff --git a/docs/decisions/037-client-room-id.md b/docs/decisions/037-client-room-id.md new file mode 100644 index 0000000..6a4b60d --- /dev/null +++ b/docs/decisions/037-client-room-id.md @@ -0,0 +1,23 @@ +# ADR-037: Идентификатор комнаты генерирует клиент + +## Контекст + +`docs/crypto.md` вплетает `roomId` в заворачивание ключа комнаты дважды: в `info` вывода `wrapK` и в AAD шифротекста. Ключ заворачивается до запроса — `POST /api/rooms` несёт `keys[]` с готовым шифротекстом. + +`docs/protocol.md` и `docs/storage.md` при этом отдавали выдачу `roomId` серверу. Создатель комнаты обязан привязать ключ к идентификатору, которого ещё не существует. + +Обойти это нечем. Ключ себе при создании — не формальность: ADR-018 требует его ровно для других устройств создателя, и без него второе устройство получает комнату с нечитаемым ключом. Эндпоинта, который дослал бы ключ после ответа, в протоколе нет; `POST /api/rooms/{id}/members` меняет `keyId`, то есть делает rekey сразу после создания — лишний круг и комната без действующего ключа в промежутке. + +## Решение + +- `roomId` генерирует клиент: 16 случайных байт base64url — как `deviceId` (ADR-017) и `keyId`. +- `POST /api/rooms` принимает `id`. Сервер проверяет форму, как у любого идентификатора, и отвергает занятый — `409 room_conflict`. Клиент берёт новый идентификатор и повторяет, как при `device_conflict`. +- Слияния с существующей комнатой нет: повторный `POST` с занятым `id` не присоединяет и не перезаписывает. +- Правятся `docs/protocol.md` (тело запроса и перечень кодов) и `docs/storage.md` (комментарий к `rooms.id`). + +## Следствия + +- Заворачивание себе при создании привязано к настоящему `roomId`, и второе устройство создателя читает комнату. +- Сервер доверяет клиенту не больше прежнего: он принимает форму идентификатора и отказывает занятому. +- `roomId` был и остаётся метаданными — он открыт серверу в любом случае. Угадывание чужого идентификатора ничего не даёт: доступ проверяется по `room_members`, а не по знанию `id`. +- Коллизия 128 случайных бит невозможна на практике; `room_conflict` существует ради целостности, а не ради сценария. diff --git a/docs/decisions/038-room-screens-texts.md b/docs/decisions/038-room-screens-texts.md new file mode 100644 index 0000000..646b2f5 --- /dev/null +++ b/docs/decisions/038-room-screens-texts.md @@ -0,0 +1,25 @@ +# ADR-038: Тексты экранов комнат и порядок полос + +## Контекст + +Этап 3 рисует экраны комнат по `docs/ui.md`, и в трёх местах документ описывает состояние, но слов не даёт. + +- «Участники»: «удалить комнату» — «с подтверждением», а текста подтверждения нет. У подтверждений ADR-028 и ADR-029 свои строки записаны, у этого — нет. +- «Карточка контакта»: у ника с `pending` показываются «оба отпечатка, старый и новый». Два блока по 64 hex подряд без пометок неразличимы, а перепутать их — подтвердить не тот ключ. +- «Участники»: полоса «нужен новый ключ комнаты: подтвердите ключ @x» привязана к `needsRekey`. Тот же тупик даёт добавление участника: rekey не выполняется, если ключ кого-то из итогового состава не подтверждён (ADR-016), и операция обрывается до запроса. Состояние то же самое, а показать его нечем. + +Четвёртое место — про поведение, а не про текст. ADR-033 оставил в чате одну полосу на три причины и не сказал, какая из них главная. + +## Решение + +- Подтверждение удаления комнаты — «комната будет удалена у всех участников.» с кнопками «удалить» и «отмена». Про историю в тексте ничего нет: она на устройствах и не трогается. +- Отпечатки в карточке контакта помечаются «старый» и «новый». +- Текст «нужен новый ключ комнаты: подтвердите ключ @x» показывается и тогда, когда неподтверждённый ключ обрывает добавление или удаление участника, — но строкой состояния формы, а не полосой: у отказа формы место одно, и оно под ней (ADR-028). Полоса остаётся за состоянием комнаты, строка — за неудавшимся действием. Ников бывает несколько, через запятую. +- В чате полоса одна, и предупреждение о смене ключа перебивает отказ отправки и «нет соединения»: только оно блокирует ввод, и пока оно висит, повторять отправку всё равно нечем. +- Строки записаны в `docs/ui.md` — разделы «Чат», «Карточка контакта», «Участники». + +## Следствия + +- Экраны комнат собраны из `docs/ui.md` целиком: слов, которых нет в документе, в них не осталось. +- Владелец, упёршийся в неподтверждённый ключ, видит одну и ту же строку независимо от того, сам он менял состав или участник вышел. Это одно состояние, и выход из него один — подтвердить ключ. +- Порядок полос зафиксирован: три причины не спорят за одно место. diff --git a/docs/decisions/039-room-key-sender-is-member.md b/docs/decisions/039-room-key-sender-is-member.md new file mode 100644 index 0000000..f21ef39 --- /dev/null +++ b/docs/decisions/039-room-key-sender-is-member.md @@ -0,0 +1,26 @@ +# ADR-039: Завёрнутый ключ комнаты принимается только от участника + +Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-016](016-key-trust-tofu.md): у распространителя ключа комнаты появляется проверяемое условие. + +## Контекст + +`docs/crypto.md` говорит про отправителя завёрнутого ключа одно: «Публичный ключ `from` проходит через TOFU как любой другой». Этого мало. + +TOFU защищает от подмены ключа знакомого ника, а не от появления незнакомого. Первый ключ запоминается молча — так и задумано (ADR-016). Значит сервер, подставивший в `Room.key` запись, завёрнутую посторонним аккаунтом, получает молчаливое доверие: клиент спрашивает `GET /api/users/<посторонний>`, впервые видит этот ник, запоминает его ключ без предупреждения, разворачивает ключ комнаты и делает его текущим — последний полученный побеждает. Следующее сообщение уходит ключом, который знает подставивший. + +Стоит это одного подменённого поля в ответе `GET /api/rooms` конкретному участнику. Ни подмены клиентского кода, ни сговора с участником не нужно, а на экране комната не меняется: владелец и состав приходят прежние. `docs/threat-model.md` обещает обратное — «дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка». + +ADR-018 при этом уже называет распространителя: ключ заворачивает клиент-владелец, каждому участнику и себе. Условие есть, просто оно не проверялось. + +## Решение + +- Клиент отвергает завёрнутый ключ комнаты, если `from` не входит в состав, пришедший в том же `Room`. Ключ не разворачивается и не сохраняется. +- Проверка идёт до `GET /api/users/{from}`: подставной ник не попадает и в TOFU, следа от него не остаётся. +- Требовать именно владельца нельзя: владение переходит по ADR-018, и у действующих участников остаётся ключ прежнего владельца. Состав — то условие, которое переживает передачу владения. +- Строка записана в `docs/crypto.md`, «Заворачивание участнику». + +## Следствия + +- Чтобы подсунуть ключ, серверу придётся показать подставной ник в составе комнаты. Это видно на экране участников — то есть подмена перестаёт быть невидимой, ровно как обещает модель угроз. +- Ключ, завёрнутый ником, который успел выйти из комнаты, новое устройство участника не развернёт: комната для него остаётся без ключа до rekey, входящее показывается как «нет ключа комнаты». ADR-018 такой случай уже допускает, а долг по rekey теперь переживает офлайн (ADR-041), поэтому окно короткое. +- Уже сохранённый ключ проверка не трогает: `roomKeys` заполняется один раз на `keyId`. diff --git a/docs/decisions/040-trusted-key-returned-clears-pending.md b/docs/decisions/040-trusted-key-returned-clears-pending.md new file mode 100644 index 0000000..65ef4ab --- /dev/null +++ b/docs/decisions/040-trusted-key-returned-clears-pending.md @@ -0,0 +1,24 @@ +# ADR-040: Возврат к доверенному ключу закрывает состояние pending + +Уточняет [ADR-016](016-key-trust-tofu.md): у состояния «ключ изменился» появляется второй выход. + +## Контекст + +ADR-016 знает одно состояние и один выход из него: ключ ника изменился, отправка блокируется до явного «доверять новому ключу». `docs/ui.md` даёт под это ровно одну кнопку. + +Выход оказался не единственным возможным, а единственным записанным. Сервер, отдавший чужой ключ и вернувший обратно настоящий, оставляет клиент в тупике: в `pending` лежит ключ, которому доверять нельзя, а кнопка «доверять новому ключу» продвинула бы в основные именно его — то есть уже отозванную подмену. Отправка при этом заблокирована, и разблокировать её человеку нечем. + +Клиент этапа 3 снимал `pending` сам, когда сервер снова отдавал доверенный ключ. Поведение верное, но в документах его не было, а `CLAUDE.md` и `docs/plan.md` запрещают дописывать спецификацию молча. + +## Решение + +- Публичный ключ, совпавший с доверенным, закрывает состояние `pending`: запись возвращается к прежнему ключу, полоса в чате и второй отпечаток в карточке контакта исчезают, отправка разблокируется. +- Человеку об этом не сообщается: смены ключа не случилось, а состояние обещало ровно смену. +- Кнопка «доверять новому ключу» остаётся единственным выходом там, где новый ключ никуда не делся. +- Строка записана в `docs/ui.md`, «Карточка контакта». + +## Следствия + +- Тупика нет: из состояния выходит либо человек — подтверждением, либо сам сервер — возвратом к прежнему ключу. +- Подмена, откатившаяся до того, как человек посмотрел на экран, следа в состоянии не оставляет. След остаётся в ленте: сообщение, зашифрованное подменным ключом, так и лежит нерасшифрованным с пометкой «ключ изменился» — расшифровать его нечем, ключа подменщика у нас нет и не будет. +- Отдельного поля «здесь была подмена» не заводится: `peers` держит доверие, а не журнал. Журнал подмен — отдельное решение, если понадобится. diff --git a/docs/decisions/041-needs-rekey-is-state.md b/docs/decisions/041-needs-rekey-is-state.md new file mode 100644 index 0000000..c829a01 --- /dev/null +++ b/docs/decisions/041-needs-rekey-is-state.md @@ -0,0 +1,31 @@ +# ADR-041: Долг по ключу комнаты — состояние, а не событие + +Уточняет [ADR-018](018-rooms-membership-rekey.md): «шлёт остальным событие `room` с `needsRekey: true`» дополняется признаком, который событие переживает. + +## Контекст + +ADR-018 описывает окно без rekey как временное: «Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает». Значит, вернувшись, владелец обязан ключ сменить. + +Вернуть его было нечем. `needsRekey` жил только в живом событии `room`: `GET /api/rooms` этого признака не нёс вовсе, в очередь событие не кладётся, а клиентский долг держался в памяти вкладки и умирал от перезагрузки. Владелец, не подключённый в ту секунду, когда участник вышел, не узнавал о долге никогда, и комната оставалась на ключе, который унёс вышедший, — до следующей смены состава, то есть, возможно, навсегда. Перезагрузка страницы у подключённого владельца давала то же самое, вместе с полосой «нужен новый ключ комнаты», которая исчезала молча. + +`docs/protocol.md` при этом утверждает: «room и room_left в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms`… поэтому пропуск события во время офлайна ничего не ломает». Для `needsRekey` утверждение было ложным. + +Тот же провал на втором пути. `DELETE /api/me` уносит участника из всех его комнат, но не шлёт оставшимся ничего: ни `room` с `needsRekey`, ни уведомления новому владельцу. По составу это тот же выход участника, что и `POST /api/rooms/{id}/leave`, и ADR-018 требует того же rekey. Хуже: `room_keys.sender` внешним ключом не защищён, поэтому текущий ключ комнаты остаётся завёрнутым исчезнувшим ником, и новое устройство оставшегося участника получить ключ уже не может. + +Третье место — собственный выход. `POST /api/rooms/{id}/leave` шлёт `room` оставшимся и ничего — другим устройствам вышедшего. Они держат комнату в списке до следующего `ready`, то есть часами: при живом потоке `ready` не наступает. + +## Решение + +- `rooms` получает колонку `needs_rekey` (миграция 002). Ставится в 1, когда состав уменьшился, а участники остались: выход участника и удаление аккаунта. Снимается в 0 при `POST /api/rooms/{id}/members` — любая смена состава раздаёт новый ключ всему итоговому составу. +- `GET /api/rooms` отдаёт признак полем `needsRekey`. Клиент-владелец поднимает долг из списка комнат так же, как из события, и потому переживает и офлайн, и перезагрузку вкладки. +- `DELETE /api/me` — выход из всех комнат пользователя: оставшимся уходит `event: room` с `needsRekey: true` и их собственным текущим ключом, владение и пустые комнаты обрабатываются как при выходе (ADR-018). +- `POST /api/rooms/{id}/leave` шлёт `event: room_left` устройствам вышедшего, кроме отправившего запрос: их состояние сходится сразу, а не к следующему `ready`. +- Правятся `docs/protocol.md` («Типы», «Комнаты», «Аккаунт», «События») и `docs/storage.md` (миграция 002). + +## Следствия + +- Утверждение протокола про пропуск событий во время офлайна становится верным: всё, что несёт событие `room`, есть и в `GET /api/rooms`. +- Комната не остаётся на ключе вышедшего дольше, чем владелец не заходит. Окно снова временное, как и обещает ADR-018. +- Полоса «нужен новый ключ комнаты: подтвердите ключ @x» переживает перезагрузку: после `ready` владелец снова упирается в тот же неподтверждённый ключ и снова её показывает. Отдельного поля в `chats` для этого не нужно. +- Признак — метаданные комнаты, серверу и так известные: он знает состав и знает, что ключ не менялся. Нового про ключи сервер не узнаёт. +- Клиент по-прежнему решает сам, делать ли rekey: сервер только помнит, что состав уменьшился. diff --git a/docs/decisions/042-current-room-key-order.md b/docs/decisions/042-current-room-key-order.md new file mode 100644 index 0000000..7b06415 --- /dev/null +++ b/docs/decisions/042-current-room-key-order.md @@ -0,0 +1,23 @@ +# ADR-042: Порядок ключей комнаты и «текущий ключ» + +Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера». + +## Контекст + +На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным. + +Код этапа 3 это починил: сервер поднимает время нового ключа до `последний + 1`, а при равенстве доопределяет порядок по `key_id`; клиент делает то же со своим `receivedAt`. Инвариант несущий, а записан был только комментариями в коде. + +Второе: порядка сервера клиент не знает и знать не может. В `Room.key` приходит один текущий ключ без номера и без времени, так что клиент считает текущим тот, который получил последним. В гонке двух rekey с разных устройств владельца эти порядки расходятся: устройство, чей ответ пришёл раньше события соседнего, считает текущим свой ключ, а сервер — чужой. + +## Решение + +- Инвариант записывается в `docs/storage.md`: время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id`. То же — про клиентский `roomKeys.receivedAt`. +- Клиент держит порядок получения, а не порядок сервера. Это осознанный предел: номера ключа в протоколе нет и не заводится. +- Расхождение безвредно, пока оба ключа живы, а живы они, пока комната держит два последних `keyId`. Сообщение, отправленное ключом, который сервер уже обрезал, получает `400 unknown_key` и хоронится как `failed` (ADR-033). + +## Следствия + +- Обрезка до двух `keyId` не выбрасывает ничей текущий ключ: самый свежий `key_id` есть у каждого участника (иначе `keys_mismatch`), и он остаётся всегда. +- `created_at` в `room_keys` перестаёт быть в точности «миллисекундами Unix»: у двух rekey в одну миллисекунду второе время сдвинуто вперёд. Это записано рядом с колонкой. +- Порядковый номер ключа в `Room.key` — возможное расширение отдельным ADR, если расхождение порядков когда-нибудь окажется дорогим. diff --git a/docs/decisions/043-form-before-rights.md b/docs/decisions/043-form-before-rights.md new file mode 100644 index 0000000..309d081 --- /dev/null +++ b/docs/decisions/043-form-before-rights.md @@ -0,0 +1,23 @@ +# ADR-043: Форма запроса проверяется раньше прав + +Уточняет [ADR-026](026-protocol-error-codes.md): перечень кодов исчерпывающий, значит и порядок их выдачи должен быть записан. + +## Контекст + +`docs/protocol.md` перечисляет проверки `POST /api/rooms/{id}/members` в одном порядке — «Только владелец (`403 not_owner`). Проверки: все `add` существуют…», — а сервер отвечает в другом: форму ников и завёрнутых ключей он разбирает до обращения к хранилищу, то есть до проверки владения. Порядок наружу виден: не владелец с кривым ником в `add` получал не `not_owner`. + +Иначе и не сделать: чтобы спросить хранилище о правах, запрос сначала надо разобрать. Утечки в этом нет — проверка чисто синтаксическая и о комнате ничего не сообщает. + +Два кода при этом расходились с документом. Повтор ника в `keys[].to` отвечал `400 invalid`, хотя множество `keys[].to` составу в этом случае не равно и протокол называет `400 keys_mismatch`. Ошибка формы ника в `add` отвечала `404 unknown_user`, а та же ошибка в `remove` — `400 invalid`: один класс входа, два разных ответа. + +## Решение + +- Форма запроса проверяется раньше прав и раньше существования сущностей. Записано строкой в «Общих правилах» `docs/protocol.md`: `400 bad_json`, `413 too_large` и `400 invalid` приходят и на запрос, который отвергли бы и по правам. +- Повтор ника в `keys[].to` — `400 keys_mismatch`, как и любое другое несовпадение с итоговым составом. +- Ник неверной формы в `add` — `400 invalid` с полем `add`, как и в `remove`. Несуществующий ник верной формы остаётся `404 unknown_user`. + +## Следствия + +- Перечень кодов остаётся исчерпывающим, а порядок их выдачи — записанным, а не выведенным из чтения кода. +- Снаружи по ответу видно, что запрос разобран, но не видно ничего о комнате: `403 not_owner` одинаков и для чужой комнаты, и для несуществующей. +- Клиенту разница не важна: свои ники он приводит к форме ADR-019 до запроса. diff --git a/docs/decisions/044-left-room-screen.md b/docs/decisions/044-left-room-screen.md new file mode 100644 index 0000000..20dea39 --- /dev/null +++ b/docs/decisions/044-left-room-screen.md @@ -0,0 +1,22 @@ +# ADR-044: Экран комнаты, которой у нас больше нет + +Дополняет [ADR-038](038-room-screens-texts.md): у чата появляется четвёртая причина для полосы. + +## Контекст + +Комната уходит из списка тремя путями: человек вышел сам, владелец его убрал, владелец удалил комнату. Открытый экран чата при этом оставался рабочим: лента, поле ввода и кнопка `>` на месте. Отправка доходила до сервера, получала `403 not_member` и садилась как `failed` с текстом «сервер не справился, попробуйте позже» — то есть человеку сообщали, что виноват сервер, тогда как он просто больше не участник. «Повторить» в этом состоянии не срабатывает никогда. + +`docs/ui.md` этого состояния не описывает вовсе, хотя приходит оно и без действий человека: событие `room_left` застаёт его в открытом чате. + +## Решение + +- Чат комнаты, из состава которой нас больше нет, показывает полосу над вводом цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован — и поле, и кнопка `>`. +- Полоса перебивает отказ отправки и «нет соединения» на тех же основаниях, что и предупреждение о ключе (ADR-038): она блокирует ввод, и пока она висит, повторять отправку всё равно нечем. +- Лента остаётся на месте и остаётся читаемой: история на устройстве — единственная копия, и она не трогается. +- Строка записана в `docs/ui.md`, «Чат». + +## Следствия + +- Причин у полосы в чате становится четыре, показывается по-прежнему одна. Порядок: не участник, ключ изменился, отказ отправки, нет соединения. +- Отдельного текста для `403 not_member` не заводится: до сервера отправка из такого чата больше не доходит. +- Экран участников покинутой комнаты отдельного состояния не получает: состав там пустеет сам, а «выйти из комнаты» и «удалить комнату» отвечают тем же, чем и раньше. diff --git a/docs/protocol.md b/docs/protocol.md index c2aee13..ab9c577 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,6 +10,7 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043). - Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027). - Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). @@ -31,7 +32,7 @@ Room { members: nick[], // по joined_at createdAt: number, key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего - needsRekey: boolean // только в событии после выхода участника + needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041) } WrappedKey { to: nick, iv: string, ct: string } @@ -55,7 +56,7 @@ WrappedKey { to: nick, iv: string, ct: string } `POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}` → `204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей. -`DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. +`DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. Удаление аккаунта — выход из всех его комнат: оставшимся участникам уходит `event: room` с `needsRekey: true`, каждому со своим ключом (ADR-041). `GET /api/users/{nick}` → `200 {nick, publicKey}` | `404 unknown_user`. @@ -110,25 +111,25 @@ event: room_left data: {id} // получателя удалили ил event: ready data: {} ``` -`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. +`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey`, есть и в `GET /api/rooms` (ADR-041). `id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления. ## Комнаты -`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом. +`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). -`POST /api/rooms {name, keyId, keys: WrappedKey[]}` → `201 Room`. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. +`POST /api/rooms {id, name, keyId, keys: WrappedKey[]}` → `201 Room`. `id` — 16 случайных байт base64url, генерирует клиент (ADR-037): ключ комнаты заворачивается до запроса и привязан к идентификатору. Занятый `id` — `409 room_conflict`, клиент берёт новый. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. -`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`). Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. +`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. -`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`. +`POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041). `DELETE /api/rooms/{id}` → `204`. Только владелец. Всем участникам — `event: room_left`. ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `room_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. ## Статика и служебное diff --git a/docs/storage.md b/docs/storage.md index 51868ba..e0d0d00 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -44,7 +44,7 @@ CREATE TABLE contacts ( ); CREATE TABLE rooms ( - id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент name TEXT NOT NULL, owner TEXT NOT NULL REFERENCES users(nick), created_at INTEGER NOT NULL @@ -79,9 +79,19 @@ CREATE TABLE queue ( CREATE INDEX queue_created ON queue(created_at); ``` +### Миграция 002 + +```sql +ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0; +``` + +Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`. + Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. -Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает комнаты, где он владелец (передача или удаление), остальное — каскад. +Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен. + +Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает его комнаты — убирает членство и ключи, передаёт владение или удаляет опустевшую комнату, ставит `needs_rekey` там, где участники остались (ADR-041), — остальное уносит каскад. ### Фоновая чистка, раз в час @@ -119,6 +129,8 @@ messages key: id (ULID) roomKeys key: [roomId, keyId] {roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt} + // receivedAt строго больше receivedAt всех прежних ключей той же комнаты; + // текущий ключ — последний по нему, то есть в порядке получения (ADR-042) peers key: nick {nick, publicKey: JWK, fingerprint, firstSeen, diff --git a/docs/ui.md b/docs/ui.md index ad289ff..d1716c0 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -36,7 +36,9 @@ Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. -Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. +Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038). + +Комната, из состава которой нас больше нет (вышли сами, убрал владелец, комната удалена), — та же полоса цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован, лента остаётся. Эта полоса перебивает и предупреждение о ключе (ADR-044). Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется. @@ -44,11 +46,13 @@ ## Карточка контакта (`#/contact/`) -`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка, старый и новый, кнопка «доверять новому ключу». Кнопка «убрать из списка». +`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка с пометками «старый» и «новый» и кнопка «доверять новому ключу». Кнопка «убрать из списка». + +`pending` снимает и сервер, снова отдавший доверенный ключ: смены ключа не случилось, состояние закрывается само и молча (ADR-040). ## Участники (`#/room//members`) -Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением. Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». +Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038). ## Настройки (`#/settings`) diff --git a/internal/api/account.go b/internal/api/account.go index 769b509..5ee8fa8 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -256,13 +256,19 @@ func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) { if !s.confirm(w, in.AuthKey, u) { return } - // Устройства, сессии, контакты, членство и очереди уносит каскад. - // Комнаты, где пользователь владелец, требуют передачи владения - // (ADR-018) — это этап 3, до появления комнат случай не наступает. - if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil { + // Устройства, сессии, контакты, членство, ключи комнат и очереди уносит + // каскад; комнаты, где пользователь владелец, меняют владельца или + // удаляются пустыми (ADR-018) — всё в одной транзакции хранилища. + changes, err := s.st.DeleteUser(r.Context(), u.Nick) + if err != nil { s.internal(w, r, err) return } + // Удаление аккаунта — выход из всех его комнат: оставшимся уходит room + // с needsRekey, каждому со своим ключом (ADR-041). + for _, change := range changes { + s.sendRoom(r, change, "") + } auth.ClearCookie(w) noContent(w) } diff --git a/internal/api/api.go b/internal/api/api.go index cdc9cba..aefbccc 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -84,6 +84,12 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact))) + mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms))) + mux.Handle("POST /api/rooms", private(http.HandlerFunc(s.createRoom))) + mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers))) + mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom))) + mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom))) + mux.Handle("GET /api/events", private(http.HandlerFunc(s.events))) mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage))) mux.Handle("POST /api/ack", private(http.HandlerFunc(s.ack))) diff --git a/internal/api/devices.go b/internal/api/devices.go index 7287e75..76e0788 100644 --- a/internal/api/devices.go +++ b/internal/api/devices.go @@ -115,6 +115,18 @@ func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) { return id, true } +// optionalDevice — то же для маршрутов, где заголовок необязателен: +// он всего лишь просит не возвращать эхо отправившему устройству. Пустой +// X-Device — пусто, непустой обязан быть своим устройством: правило +// принадлежности общее для всех маршрутов, где устройство важно +// (docs/protocol.md, «Общие правила»). +func (s *server) optionalDevice(w http.ResponseWriter, r *http.Request) (string, bool) { + if r.Header.Get("X-Device") == "" { + return "", true + } + return s.device(w, r) +} + func unknownDevice(w http.ResponseWriter) { Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше") } diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go index 9cc7911..21cc5b0 100644 --- a/internal/api/devices_test.go +++ b/internal/api/devices_test.go @@ -151,6 +151,36 @@ func TestForeignDevice(t *testing.T) { // Со своим устройством — обычная отправка. expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"), with(petya), withDevice(petyaDevice)), http.StatusAccepted, "") + + // Комнаты: заголовок здесь необязателен — он всего лишь просит не слать + // событие отправившему устройству, — но принадлежность проверяется + // та же (docs/protocol.md, «Общие правила», «Комнаты»). + room := map[string]any{ + "id": roomIDOf(40), + "name": "общая", + "keyId": keyID(40), + "keys": keysFor([]string{"petya"}, 40), + } + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(deviceOf(9))), + http.StatusForbidden, "unknown_device") + // Отказ ничего не создал: идентификатор комнаты свободен. + expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya)), http.StatusCreated, "") + + leave := "/api/rooms/" + roomIDOf(40) + "/leave" + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(martaDevice)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice("мусор")), + http.StatusForbidden, "unknown_device") + // Отказ ничего не изменил: из комнаты никто не вышел. + if got := e.room(petya, roomIDOf(40)); got == nil { + t.Fatal("комната пропала после отказа по устройству") + } + expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(petyaDevice)), + http.StatusNoContent, "") } // Удаление устройства уносит очередь и сессии устройства. diff --git a/internal/api/messages.go b/internal/api/messages.go index ab56c50..929d5c1 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -74,14 +74,23 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { return } - if in.To.Room != "" { - // Комнаты — этап 3. Участников нет ни у одной комнаты, потому что - // нет и самих комнат: единственный возможный ответ — not_member. - Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") - return - } sess, _ := auth.From(r) - if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { + room := in.To.Room != "" + if room { + member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) + if err != nil { + s.internal(w, r, err) + return + } + if !member { + Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") + return + } + if !knownKey { + Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа") + return + } + } else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { return } @@ -92,7 +101,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { env := envelope{ ID: in.ID, - To: target{DM: in.To.DM}, + To: target{DM: in.To.DM, Room: in.To.Room}, From: sess.Nick, KeyID: in.KeyID, IV: in.IV, @@ -104,14 +113,21 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { s.internal(w, r, err) return } - devices, err := s.st.DeliverDM(r.Context(), store.Delivery{ + delivery := store.Delivery{ From: env.From, To: env.To.DM, + Room: env.To.Room, Exclude: device, MsgID: env.ID, Envelope: string(raw), Now: env.TS, - }) + } + var devices []string + if room { + devices, err = s.st.DeliverRoom(r.Context(), delivery) + } else { + devices, err = s.st.DeliverDM(r.Context(), delivery) + } if err != nil { s.internal(w, r, err) return diff --git a/internal/api/rooms.go b/internal/api/rooms.go new file mode 100644 index 0000000..5e884cd --- /dev/null +++ b/internal/api/rooms.go @@ -0,0 +1,385 @@ +package api + +import ( + "encoding/json" + "errors" + "net/http" + "time" + "unicode/utf8" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/store" +) + +// Комнаты (ADR-018): владелец меняет состав, ключи заворачивают клиенты. +// Сервер проверяет форму и права, хранит шифротекст и раздаёт события. + +// maxRoomName — имя комнаты, символов (ADR-021). Имя открыто: это +// метаданные, как и состав. +const maxRoomName = 64 + +// roomOut — тип Room из docs/protocol.md. key присутствует всегда, +// пустой — null; needsRekey — состояние комнаты, а не свойство события, +// поэтому идёт и в списке, и в событии (ADR-041). +type roomOut struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Members []string `json:"members"` + CreatedAt int64 `json:"createdAt"` + Key *keyOut `json:"key"` + NeedsRekey bool `json:"needsRekey"` +} + +// keyOut — завёрнутый ключ комнаты для того, кто его получает. +type keyOut struct { + KeyID string `json:"keyId"` + From string `json:"from"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// keyIn — запись keys[] запроса: WrappedKey из docs/protocol.md. +type keyIn struct { + To string `json:"to"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// GET /api/rooms — комнаты, где пользователь участник, каждая с его +// текущим ключом и признаком needsRekey: владелец, пропустивший событие, +// поднимает долг по ключу отсюда (ADR-041). +func (s *server) rooms(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + list, err := s.st.Rooms(r.Context(), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + out := make([]roomOut, 0, len(list)) + for _, room := range list { + out = append(out, roomJSON(room, room.Key)) + } + writeJSON(w, http.StatusOK, out) +} + +// POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент +// (ADR-037), ключ приходит ровно один и заворачивается создателем себе: +// его другие устройства получают комнату вместе с ключом (ADR-018). +func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { + // X-Device здесь необязателен, но чужой и кривой — 403, как и везде, + // где устройство важно (docs/protocol.md, «Общие правила»). + device, ok := s.optionalDevice(w, r) + if !ok { + return + } + var in struct { + ID string `json:"id"` + Name string `json:"name"` + KeyID string `json:"keyId"` + Keys []keyIn `json:"keys"` + } + if !decode(w, r, &in) { + return + } + // Идентификатор комнаты генерирует клиент: ключ заворачивается до + // запроса и привязан к roomId в info и AAD (ADR-037). + if !validID(in.ID) { + Invalid(w, "id", "id комнаты — не 16 байт base64url") + return + } + if !validRoomName(in.Name) { + Invalid(w, "name", "имя комнаты: 1–64 символа") + return + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return + } + keys, ok := wrappedKeys(w, in.Keys) + if !ok { + return + } + sess, _ := auth.From(r) + // Состав новой комнаты — один создатель, поэтому и ключ ровно один. + // Несовпадение — то же самое, что при rekey: keys не по составу. + if len(keys) != 1 || keys[0].To != sess.Nick { + keysMismatch(w) + return + } + change, err := s.st.CreateRoom(r.Context(), store.NewRoom{ + ID: in.ID, + Name: in.Name, + Owner: sess.Nick, + KeyID: in.KeyID, + Key: keys[0], + Now: time.Now().UnixMilli(), + }) + if errors.Is(err, store.ErrRoomExists) { + // Занятый идентификатор не присоединяет к чужой комнате и не + // перезаписывает свою: клиент берёт новый (ADR-037). + Error(w, http.StatusConflict, "room_conflict", "такая комната уже есть") + return + } + if err != nil { + s.internal(w, r, err) + return + } + // Комната уже записана: остальным устройствам создателя она уходит + // событием, отправившему — ответом на запрос. + s.sendRoom(r, change, device) + writeJSON(w, http.StatusCreated, roomJSON(change.Room, keyFor(change, sess.Nick))) +} + +// POST /api/rooms/{id}/members — смена состава и rekey одним запросом +// (ADR-018). Пустые add и remove — чистый rekey. +func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) { + var in struct { + Add []string `json:"add"` + Remove []string `json:"remove"` + KeyID string `json:"keyId"` + Keys []keyIn `json:"keys"` + } + if !decode(w, r, &in) { + return + } + add, ok := uniqueNicks(in.Add) + if !ok { + // Форма — это форма: несуществующий ник верной формы отвечает + // unknown_user, а ник не по форме — invalid, как и в remove + // (ADR-043). + Invalid(w, "add", "добавить можно только ник a–z, 0–9, _") + return + } + remove, ok := uniqueNicks(in.Remove) + if !ok { + Invalid(w, "remove", "убрать можно только участника комнаты") + return + } + for _, nick := range remove { + for _, other := range add { + if nick == other { + Invalid(w, "remove", "один ник нельзя добавить и убрать одним запросом") + return + } + } + } + if !validID(in.KeyID) { + Invalid(w, "keyId", "keyId — не 16 байт base64url") + return + } + keys, ok := wrappedKeys(w, in.Keys) + if !ok { + return + } + sess, _ := auth.From(r) + change, err := s.st.UpdateMembers(r.Context(), store.MembersChange{ + RoomID: r.PathValue("id"), + Owner: sess.Nick, + Add: add, + Remove: remove, + KeyID: in.KeyID, + Keys: keys, + Now: time.Now().UnixMilli(), + }) + if err != nil { + s.roomError(w, r, err) + return + } + // Событие room уходит и участникам, и — как room_left — убранным; + // каждому участнику со своим ключом (docs/protocol.md, «Комнаты»). + s.sendRoom(r, change, "") + writeJSON(w, http.StatusOK, roomJSON(change.Room, keyFor(change, sess.Nick))) +} + +// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит +// участнику с наименьшим joined_at, опустевшая комната удаляется; +// оставшимся уходит room с needsRekey (ADR-018), другим устройствам +// вышедшего — room_left (ADR-041). +// +// Не участник и несуществующая комната отвечают тем же 204: выходить +// неоткуда, а отдельного кода на этот случай в протоколе нет. +func (s *server) leaveRoom(w http.ResponseWriter, r *http.Request) { + // Заголовок необязателен, но чужой и кривой — 403, как и везде, + // где устройство важно (docs/protocol.md, «Общие правила»). + device, ok := s.optionalDevice(w, r) + if !ok { + return + } + sess, _ := auth.From(r) + change, err := s.st.LeaveRoom(r.Context(), r.PathValue("id"), sess.Nick) + if errors.Is(err, store.ErrNotFound) { + noContent(w) + return + } + if err != nil { + s.internal(w, r, err) + return + } + s.sendRoom(r, change, device) + noContent(w) +} + +// DELETE /api/rooms/{id} — удаление комнаты владельцем. Всем участникам, +// включая его самого, уходит room_left. +func (s *server) deleteRoom(w http.ResponseWriter, r *http.Request) { + sess, _ := auth.From(r) + change, err := s.st.DeleteRoom(r.Context(), r.PathValue("id"), sess.Nick) + if err != nil { + s.roomError(w, r, err) + return + } + s.sendRoom(r, change, "") + noContent(w) +} + +// roomError переводит отказы хранилища в коды протокола +// (docs/protocol.md, «Комнаты»). +func (s *server) roomError(w http.ResponseWriter, r *http.Request, err error) { + switch { + case errors.Is(err, store.ErrNotOwner): + Error(w, http.StatusForbidden, "not_owner", "комнату меняет её владелец") + case errors.Is(err, store.ErrUnknownUser): + unknownUser(w) + case errors.Is(err, store.ErrNotMember): + Invalid(w, "remove", "убрать можно только участника комнаты") + case errors.Is(err, store.ErrOwnerRemoval): + Error(w, http.StatusBadRequest, "owner", "владельца убрать нельзя") + case errors.Is(err, store.ErrKeyExists): + Error(w, http.StatusConflict, "key_exists", "такой ключ у комнаты уже был") + case errors.Is(err, store.ErrKeysMismatch): + keysMismatch(w) + default: + s.internal(w, r, err) + } +} + +func keysMismatch(w http.ResponseWriter) { + Error(w, http.StatusBadRequest, "keys_mismatch", "ключи не совпадают с составом комнаты") +} + +// sendRoom раздаёт события изменившейся комнаты: room участникам, каждому +// с его собственным ключом, и room_left выбывшим. exclude — устройство, +// которому событие не нужно; пусто — нужно всем. +// +// Событие в очередь не кладётся: клиент после каждого ready перечитывает +// GET /api/rooms, а всё, что несёт room, включая needsRekey, есть и там, +// поэтому пропуск во время офлайна ничего не ломает (docs/protocol.md, +// «События», ADR-041). +func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) { + for _, member := range change.Members { + raw, err := json.Marshal(roomJSON(change.Room, member.Key)) + if err != nil { + s.report(r, err) + continue + } + s.send(member.Devices, exclude, hub.Event{Name: "room", Data: string(raw)}) + } + if len(change.Left) == 0 { + return + } + raw, err := json.Marshal(struct { + ID string `json:"id"` + }{change.Room.ID}) + if err != nil { + s.report(r, err) + return + } + for _, gone := range change.Left { + s.send(gone.Devices, exclude, hub.Event{Name: "room_left", Data: string(raw)}) + } +} + +// send отдаёт событие подключённым устройствам, кроме exclude. +func (s *server) send(devices []string, exclude string, ev hub.Event) { + for _, device := range devices { + if device == exclude { + continue + } + s.hub.Send(device, ev) + } +} + +// roomJSON собирает Room протокола: состав всегда список, ключ — null, +// если его нет. +func roomJSON(room store.Room, key *store.RoomKey) roomOut { + out := roomOut{ + ID: room.ID, + Name: room.Name, + Owner: room.Owner, + Members: room.Members, + CreatedAt: room.CreatedAt, + NeedsRekey: room.NeedsRekey, + } + if out.Members == nil { + out.Members = []string{} + } + if key != nil { + out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT} + } + return out +} + +// keyFor — ключ участника в итоге изменения: у каждого он свой. +func keyFor(change store.RoomChange, nick string) *store.RoomKey { + for _, member := range change.Members { + if member.Nick == nick { + return member.Key + } + } + return nil +} + +// wrappedKeys проверяет форму завёрнутых ключей. Содержимое сервер +// не проверяет и проверить не может: это шифротекст (ADR-018). +func wrappedKeys(w http.ResponseWriter, in []keyIn) ([]store.WrappedKey, bool) { + out := make([]store.WrappedKey, 0, len(in)) + seen := make(map[string]bool, len(in)) + for _, k := range in { + if !validNick(k.To) { + Invalid(w, "keys", "keys[].to — не ник") + return nil, false + } + if seen[k.To] { + // Два ключа одному участнику — это множество keys[].to, + // не равное составу, а не отдельный отказ (ADR-043). + keysMismatch(w) + return nil, false + } + seen[k.To] = true + if _, ok := decodeExactly(k.IV, ivLen); !ok { + Invalid(w, "keys", "iv — не 12 байт base64url") + return nil, false + } + if ct, err := b64.DecodeString(k.CT); err != nil || len(ct) < minCTLen { + Invalid(w, "keys", "ct — не base64url или слишком короткий") + return nil, false + } + out = append(out, store.WrappedKey{To: k.To, IV: k.IV, CT: k.CT}) + } + return out, true +} + +// uniqueNicks разбирает список ников запроса: повторы схлопываются, +// порядок сохраняется. Второе значение — прошёл ли список проверку формы. +func uniqueNicks(list []string) ([]string, bool) { + out := make([]string, 0, len(list)) + seen := make(map[string]bool, len(list)) + for _, nick := range list { + if !validNick(nick) { + return nil, false + } + if seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + return out, true +} + +// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021). +func validRoomName(name string) bool { + return name != "" && utf8.RuneCountInString(name) <= maxRoomName +} diff --git a/internal/api/rooms_test.go b/internal/api/rooms_test.go new file mode 100644 index 0000000..ec6379f --- /dev/null +++ b/internal/api/rooms_test.go @@ -0,0 +1,1037 @@ +package api_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "strings" + "testing" + "time" +) + +// roomBody — тип Room из docs/protocol.md, как его видит клиент. +type roomBody struct { + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Members []string `json:"members"` + CreatedAt int64 `json:"createdAt"` + Key *keyBody `json:"key"` + NeedsRekey bool `json:"needsRekey"` +} + +type keyBody struct { + KeyID string `json:"keyId"` + From string `json:"from"` + IV string `json:"iv"` + CT string `json:"ct"` +} + +// keyID — идентификатор ключа комнаты: 16 байт base64url (docs/crypto.md). +func keyID(seed byte) string { return bytesOf(16, seed) } + +// roomIDOf — идентификатор комнаты: его генерирует клиент (ADR-037). +func roomIDOf(seed byte) string { return bytesOf(16, seed+100) } + +// wrapped — завёрнутый ключ участнику. Порядковый номер входит в «шифротекст»: +// по нему видно, что каждому ушёл его собственный ключ. Содержимое сервер +// не проверяет и проверить не может. +func wrapped(to string, seed byte, nth int) map[string]any { + return map[string]any{"to": to, "iv": ivOf(seed, nth), "ct": ctOf(seed, nth)} +} + +func ivOf(seed byte, nth int) string { return bytesOf(12, seed+byte(nth)) } +func ctOf(seed byte, nth int) string { return bytesOf(48, seed+byte(nth)) } +func keysFor(to []string, seed byte) []any { + out := make([]any, 0, len(to)) + for i, nick := range to { + out = append(out, wrapped(nick, seed, i)) + } + return out +} + +// makeRoom заводит комнату: состав — один создатель, ключ ровно один. +func (e *env) makeRoom(c *http.Cookie, owner, name string, seed byte, opts ...func(*http.Request)) roomBody { + e.t.Helper() + body := map[string]any{ + "id": roomIDOf(seed), + "name": name, + "keyId": keyID(seed), + "keys": keysFor([]string{owner}, seed), + } + rec := e.do(http.MethodPost, "/api/rooms", body, append([]func(*http.Request){with(c)}, opts...)...) + expect(e.t, rec, http.StatusCreated, "") + var room roomBody + decodeBody(e.t, rec, &room) + return room +} + +// changeMembers — смена состава и rekey одним запросом: to — итоговый +// состав, которому заворачивается новый ключ. +func (e *env) changeMembers(c *http.Cookie, id string, add, remove, to []string, seed byte) *httptest.ResponseRecorder { + e.t.Helper() + body := map[string]any{ + "add": add, + "remove": remove, + "keyId": keyID(seed), + "keys": keysFor(to, seed), + } + return e.do(http.MethodPost, "/api/rooms/"+id+"/members", body, with(c)) +} + +func (e *env) rooms(c *http.Cookie) []roomBody { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/rooms", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var out []roomBody + decodeBody(e.t, rec, &out) + return out +} + +// room — комната из списка; nil, если её там нет. +func (e *env) room(c *http.Cookie, id string) *roomBody { + e.t.Helper() + for _, got := range e.rooms(c) { + if got.ID == id { + room := got + return &room + } + } + return nil +} + +// roomMessage — тело POST /api/messages в комнату. +func roomMessage(id, room, key string) map[string]any { + return map[string]any{ + "id": id, + "to": map[string]string{"room": room}, + "keyId": key, + "iv": bytesOf(12, 21), + "ct": bytesOf(48, 23), + } +} + +// nicks — состав как множество: joined_at у сервера в миллисекундах, +// и две операции подряд попадают в одну и ту же. Порядок по joined_at +// проверяет TestMembersJoinOrder, где операции разведены во времени. +func nicks(members []string) string { + sorted := append([]string(nil), members...) + sort.Strings(sorted) + return strings.Join(sorted, ",") +} + +// tick разводит операции по разным миллисекундам. +func tick() { time.Sleep(2 * time.Millisecond) } + +// field достаёт поле, на котором остановилась валидация. +func field(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Field string `json:"field"` + } + decodeBody(t, rec, &body) + return body.Field +} + +func TestCreateRoom(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + + room := e.makeRoom(marta, "marta", "общая", 40) + if room.ID != roomIDOf(40) { + t.Errorf("id комнаты: получено %q, ожидалось %q", room.ID, roomIDOf(40)) + } + if room.Name != "общая" || room.Owner != "marta" || room.CreatedAt == 0 { + t.Errorf("комната: %+v", room) + } + if len(room.Members) != 1 || room.Members[0] != "marta" { + t.Errorf("состав: %v", room.Members) + } + if room.NeedsRekey { + t.Error("needsRekey в ответе на создание") + } + 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) { + t.Errorf("ключ: %+v", room.Key) + } + + // Та же комната приходит списком, с тем же ключом. + list := e.rooms(marta) + if len(list) != 1 { + t.Fatalf("комнат: получено %d, ожидалась 1", len(list)) + } + if list[0].ID != room.ID || list[0].Key == nil || list[0].Key.CT != ctOf(40, 0) { + t.Errorf("список комнат: %+v", list[0]) + } + + // Вторая комната — другой идентификатор. + other := e.makeRoom(marta, "marta", "вторая", 60) + if other.ID == room.ID { + t.Error("идентификаторы комнат совпали") + } + if got := e.rooms(marta); len(got) != 2 { + t.Errorf("комнат: получено %d, ожидалось 2", len(got)) + } + // Чужому комната не видна. + petya, _ := e.join("petya", 2) + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("чужие комнаты: %+v", got) + } +} + +// Занятый идентификатор комнаты не присоединяет к чужой и не перезаписывает +// свою: клиент берёт новый (ADR-037). +func TestCreateRoomConflict(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + + taken := func(c *http.Cookie, owner string) { + t.Helper() + body := map[string]any{ + "id": room.ID, + "name": "чужая", + "keyId": keyID(60), + "keys": keysFor([]string{owner}, 60), + } + expect(t, e.do(http.MethodPost, "/api/rooms", body, with(c)), http.StatusConflict, "room_conflict") + } + taken(petya, "petya") + taken(marta, "marta") + + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("занятый id присоединил к чужой комнате: %+v", got) + } + list := e.rooms(marta) + if len(list) != 1 || list[0].Name != "общая" || list[0].Key == nil || list[0].Key.KeyID != keyID(40) { + t.Errorf("занятый id тронул существующую комнату: %+v", list) + } +} + +func TestCreateRoomRejects(t *testing.T) { + cases := []struct { + name string + change func(map[string]any) + status int + code string + field string + }{ + {"нет id", func(m map[string]any) { delete(m, "id") }, http.StatusBadRequest, "invalid", "id"}, + {"кривой id", func(m map[string]any) { m["id"] = "room-1" }, http.StatusBadRequest, "invalid", "id"}, + {"пустое имя", func(m map[string]any) { m["name"] = "" }, http.StatusBadRequest, "invalid", "name"}, + {"имя длиннее 64", func(m map[string]any) { + m["name"] = strings.Repeat("я", 65) + }, http.StatusBadRequest, "invalid", "name"}, + {"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"}, + {"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""}, + {"ключ чужому", func(m map[string]any) { + m["keys"] = keysFor([]string{"petya"}, 40) + }, http.StatusBadRequest, "keys_mismatch", ""}, + {"два ключа", func(m map[string]any) { + m["keys"] = keysFor([]string{"marta", "petya"}, 40) + }, http.StatusBadRequest, "keys_mismatch", ""}, + {"кривой iv", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "marta", "iv": bytesOf(16, 1), "ct": ctOf(40, 0)}} + }, http.StatusBadRequest, "invalid", "keys"}, + {"короткий ct", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "marta", "iv": ivOf(40, 0), "ct": bytesOf(8, 1)}} + }, http.StatusBadRequest, "invalid", "keys"}, + {"кривой ник в ключе", func(m map[string]any) { + m["keys"] = []any{map[string]any{"to": "МАРТА", "iv": ivOf(40, 0), "ct": ctOf(40, 0)}} + }, http.StatusBadRequest, "invalid", "keys"}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + + body := map[string]any{ + "id": roomIDOf(40), + "name": "общая", + "keyId": keyID(40), + "keys": keysFor([]string{"marta"}, 40), + } + c.change(body) + rec := e.do(http.MethodPost, "/api/rooms", body, with(marta)) + expect(t, rec, c.status, c.code) + if c.field != "" { + if got := field(t, rec); got != c.field { + t.Errorf("field: получено %q, ожидалось %q", got, c.field) + } + } + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("отвергнутое создание завело комнату: %+v", got) + } + }) + } +} + +// Смена состава и rekey — один запрос: у каждого участника свой ключ +// (ADR-018). +func TestMembersAdd(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + rec := e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60) + expect(t, rec, http.StatusOK, "") + + var got roomBody + decodeBody(t, rec, &got) + want := []string{"marta", "petya", "kolya"} + if nicks(got.Members) != nicks(want) { + t.Errorf("состав: получено %v, ожидалось %v", got.Members, want) + } + if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) { + t.Errorf("ключ владельца в ответе: %+v", got.Key) + } + + // Каждый видит комнату со своим ключом. + for i, c := range []*http.Cookie{marta, petya, kolya} { + list := e.rooms(c) + if len(list) != 1 { + t.Fatalf("комнат у %d: получено %d, ожидалась 1", i, len(list)) + } + if list[0].Owner != "marta" || nicks(list[0].Members) != nicks(want) { + t.Errorf("комната у %d: %+v", i, list[0]) + } + if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" || + list[0].Key.CT != ctOf(60, i) { + t.Errorf("ключ у %d: %+v", i, list[0].Key) + } + } + + // Повторное добавление участника ничего не меняет. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, want, 80), http.StatusOK, "") + if got := e.room(marta, room.ID); nicks(got.Members) != nicks(want) { + t.Errorf("состав после повторного добавления: %v", got.Members) + } +} + +// Состав идёт по joined_at: кто вступил раньше, тот и раньше в списке +// (docs/protocol.md, «Типы»). Повторное добавление участника его не двигает. +func TestMembersJoinOrder(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya"}, 60), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 80), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "kolya", "petya"}, 100), + http.StatusOK, "") + + want := "marta,kolya,petya" + if got := e.room(marta, room.ID); strings.Join(got.Members, ",") != want { + t.Errorf("состав: получено %v, ожидалось %q", got.Members, want) + } +} + +// Множество keys[].to обязано равняться итоговому составу; отказ не меняет +// ни состава, ни ключей (docs/protocol.md, «Комнаты»). +func TestMembersKeysMismatch(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + + cases := []struct { + name string + add []string + to []string + }{ + {"ключ не всем", []string{"petya"}, []string{"marta"}}, + {"ключ лишнему", []string{"petya"}, []string{"marta", "petya", "kolya"}}, + {"ключ вместо участника", []string{"petya"}, []string{"marta", "kolya"}}, + {"ключей нет вовсе", []string{"petya"}, nil}, + {"чистый rekey без себя", nil, []string{"petya"}}, + } + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + rec := e.changeMembers(marta, room.ID, c.add, nil, c.to, byte(60+i*10)) + expect(t, rec, http.StatusBadRequest, "keys_mismatch") + }) + } + + // Ни один отказ не изменил ни состава, ни ключа. + got := e.room(marta, room.ID) + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав после отказов: %v", got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(40) { + t.Errorf("ключ после отказов: %+v", got.Key) + } + if list := e.rooms(kolya); len(list) != 0 { + t.Errorf("комната у постороннего: %+v", list) + } + // Комната по-прежнему работает на старом ключе. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusAccepted, "") +} + +// keyId обязан быть новым для комнаты: повтор — 409, и запрос не проходит +// целиком (docs/protocol.md, «Комнаты»). +func TestMembersKeyExists(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + + // Тот же keyId, что у ключа при создании. + rec := e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 40) + expect(t, rec, http.StatusConflict, "key_exists") + + // Состав не изменился: проверки идут до записи. + got := e.room(marta, room.ID) + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав после key_exists: %v", got.Members) + } + + // Новый keyId проходит, а повтор уже его — снова 409. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "petya"}, 60), + http.StatusConflict, "key_exists") +} + +func TestMembersRejects(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + // Не владелец — 403 not_owner, хоть участник, хоть посторонний. + expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"marta", "petya"}, 80), + http.StatusForbidden, "not_owner") + kolya, _ := e.join("kolya2", 4) + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"marta", "petya"}, 80), + http.StatusForbidden, "not_owner") + // Несуществующая комната неотличима от чужой. + expect(t, e.changeMembers(marta, bytesOf(16, 9), nil, nil, []string{"marta"}, 80), + http.StatusForbidden, "not_owner") + expect(t, e.changeMembers(marta, "мусор", nil, nil, []string{"marta"}, 80), + http.StatusForbidden, "not_owner") + + // Владельца убрать нельзя. + expect(t, e.changeMembers(marta, room.ID, nil, []string{"marta"}, []string{"petya"}, 80), + http.StatusBadRequest, "owner") + + // Несуществующий ник в add — unknown_user; ник не по форме — invalid + // с полем add, как и в remove (ADR-043). + expect(t, e.changeMembers(marta, room.ID, []string{"nikogo"}, nil, []string{"marta", "petya", "nikogo"}, 80), + http.StatusNotFound, "unknown_user") + rec := e.changeMembers(marta, room.ID, []string{"МАРТА"}, nil, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + if got := field(t, rec); got != "add" { + t.Errorf("field: получено %q, ожидалось \"add\"", got) + } + + // Убрать можно только участника. + rec = e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + if got := field(t, rec); got != "remove" { + t.Errorf("field: получено %q, ожидалось \"remove\"", got) + } + + // Один ник в add и remove сразу — противоречие. + rec = e.changeMembers(marta, room.ID, []string{"kolya"}, []string{"kolya"}, []string{"marta", "petya"}, 80) + expect(t, rec, http.StatusBadRequest, "invalid") + + // Ни один отказ не тронул состав. + got := e.room(marta, room.ID) + if nicks(got.Members) != "marta,petya" { + t.Errorf("состав после отказов: %v", got.Members) + } +} + +// Убранный участник теряет комнату, ключи и доставку. +func TestMembersRemove(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + expect(t, e.changeMembers(marta, room.ID, nil, []string{"petya"}, []string{"marta"}, 80), + http.StatusOK, "") + + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("комната у убранного: %+v", got) + } + if got := e.room(marta, room.ID); len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав: %+v", got) + } + // Убранный не пишет и не получает. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(80)), + with(petya), withDevice(p1)), http.StatusForbidden, "not_member") + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), room.ID, keyID(80)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("убранному пришло сообщение: %v", got) + } +} + +// Выход владельца передаёт владение участнику с наименьшим joined_at +// (ADR-018). +func TestLeaveTransfersOwnership(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + kolya, _ := e.join("kolya", 3) + room := e.makeRoom(marta, "marta", "общая", 40) + // Владение получает участник с наименьшим joined_at, поэтому petya + // и kolya вступают в разные миллисекунды. + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + tick() + expect(t, e.changeMembers(marta, room.ID, []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 80), + http.StatusOK, "") + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комната у вышедшего: %+v", got) + } + got := e.room(petya, room.ID) + if got == nil { + t.Fatal("комната пропала у оставшихся") + } + if got.Owner != "petya" { + t.Errorf("владелец: получено %q, ожидалось \"petya\"", got.Owner) + } + if nicks(got.Members) != "kolya,petya" { + t.Errorf("состав: %v", got.Members) + } + // Ключ оставшихся никуда не делся: rekey делает клиент нового владельца. + if got.Key == nil || got.Key.KeyID != keyID(80) { + t.Errorf("ключ после выхода владельца: %+v", got.Key) + } + // Новый владелец меняет состав, прежний — уже нет. + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 100), + http.StatusForbidden, "not_owner") + expect(t, e.changeMembers(petya, room.ID, nil, nil, []string{"petya", "kolya"}, 100), + http.StatusOK, "") +} + +// Выход последнего участника удаляет комнату (ADR-018). +func TestLeaveDeletesEmptyRoom(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + room := e.makeRoom(marta, "marta", "общая", 40) + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комнаты после выхода: %+v", got) + } + // Комнаты больше нет: писать в неё некому. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + + // Повторный выход и выход не участника — тот же 204. + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 9)+"/leave", nil, with(marta)), http.StatusNoContent, "") + petya, _ := e.join("petya", 2) + other := e.makeRoom(petya, "petya", "вторая", 60) + expect(t, e.do(http.MethodPost, "/api/rooms/"+other.ID+"/leave", nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(petya); len(got) != 1 { + t.Errorf("чужой выход тронул комнату: %+v", got) + } +} + +// DELETE /api/rooms/{id} — только владелец; комната исчезает у всех. +func TestDeleteRoom(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, _ := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(petya)), http.StatusForbidden, "not_owner") + expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 9), nil, with(marta)), http.StatusForbidden, "not_owner") + if got := e.rooms(petya); len(got) != 1 { + t.Fatalf("комната пропала до удаления: %+v", got) + } + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "") + if got := e.rooms(marta); len(got) != 0 { + t.Errorf("комнаты у владельца: %+v", got) + } + if got := e.rooms(petya); len(got) != 0 { + t.Errorf("комнаты у участника: %+v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + // Удалять больше нечего — и это уже чужая комната. + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusForbidden, "not_owner") +} + +// Конверт комнаты уходит на все устройства всех участников, кроме +// отправившего (ADR-017). +func TestRoomMessageFanout(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + petya, p1 := e.join("petya", 3) + p2 := e.addDevice(petya, deviceOf(4)) + kolya, k1 := e.join("kolya", 5) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + id := ulid(nowMillis(), 6) + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(id, room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + if got := e.queue(m1); len(got) != 0 { + t.Errorf("эхо отправившему устройству: %v", got) + } + for _, device := range []string{m2, p1, p2} { + got := e.envelopes(device) + if len(got) != 1 { + t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got)) + } + if got[0].ID != id || got[0].From != "marta" || got[0].To.Room != room.ID || got[0].To.DM != "" { + t.Errorf("конверт для %s: %+v", device, got[0]) + } + if got[0].KeyID != keyID(60) || got[0].TS == 0 { + t.Errorf("конверт для %s: %+v", device, got[0]) + } + } + // Посторонний не получает и не пишет. + if got := e.queue(k1); len(got) != 0 { + t.Errorf("конверт постороннему: %v", got) + } + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 7), room.ID, keyID(60)), + with(kolya), withDevice(k1)), http.StatusForbidden, "not_member") + + // Контактов комната не заводит: список комнат приходит из GET /api/rooms. + if got := e.contacts(marta); len(got) != 0 { + t.Errorf("сообщение в комнату завело контакт: %+v", got) + } +} + +// keyId сообщения обязан быть ключом этой комнаты (docs/protocol.md). +func TestRoomMessageUnknownKey(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + other := e.makeRoom(petya, "petya", "чужая", 60) + + // Ключ другой комнаты — не ключ этой. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, keyID(60)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(99)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + // Свой — принимается. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusAccepted, "") + + // Членство проверяется раньше ключа. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 6), other.ID, keyID(99)), + with(marta), withDevice(m1)), http.StatusForbidden, "not_member") + if got := e.queue(p1); len(got) != 0 { + t.Errorf("отвергнутое сообщение попало в очередь: %v", got) + } +} + +// Прежний ключ комнаты остаётся рабочим, пока его не вытеснила обрезка: +// у комнаты живут два последних keyId (ADR-018). +func TestRoomKeysKeepTwo(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + room := e.makeRoom(marta, "marta", "общая", 40) + + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 60), http.StatusOK, "") + // Два последних — первый ещё жив. + for _, key := range []string{keyID(40), keyID(60)} { + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 3), room.ID, key), + with(marta), withDevice(m1)), http.StatusAccepted, "") + } + + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "") + // Третий rekey вытеснил самый старый ключ. + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 4), room.ID, keyID(40)), + with(marta), withDevice(m1)), http.StatusBadRequest, "unknown_key") + for _, key := range []string{keyID(60), keyID(80)} { + expect(t, e.do(http.MethodPost, "/api/messages", roomMessage(ulid(nowMillis(), 5), room.ID, key), + with(marta), withDevice(m1)), http.StatusAccepted, "") + } + // Текущий ключ участника — последний. + if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) { + t.Errorf("текущий ключ: %+v", got.Key) + } +} + +// Удаление аккаунта передаёт владение по ADR-018, а комнату без участников +// удаляет. +func TestDeleteAccountWithRooms(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + + shared := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, shared.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + alone := e.makeRoom(marta, "marta", "своя", 80) + + expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)), + http.StatusNoContent, "") + + // Комната с оставшимся участником живёт, комната без участников — + // исчезла вместе с владельцем. + list := e.rooms(petya) + if len(list) != 1 || list[0].ID != shared.ID || list[0].ID == alone.ID { + t.Fatalf("комнаты petya: %+v", list) + } + if list[0].Owner != "petya" { + t.Errorf("владелец после удаления аккаунта: получено %q, ожидалось \"petya\"", list[0].Owner) + } + if len(list[0].Members) != 1 || list[0].Members[0] != "petya" { + t.Errorf("состав: %v", list[0].Members) + } + if list[0].Key == nil || list[0].Key.KeyID != keyID(60) { + t.Errorf("ключ оставшегося: %+v", list[0].Key) + } + // Ник свободен, а комната, где не осталось никого, исчезла вместе с ним. + expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "") + fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}) + expect(t, fresh, http.StatusOK, "") + if got := e.rooms(e.cookie(fresh)); len(got) != 0 { + t.Errorf("комнаты нового аккаунта: %+v", got) + } +} + +// Событие room уходит остальным устройствам создателя, но не отправившему +// (docs/protocol.md, «Комнаты»). +func TestRoomEventOnCreate(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + + sender := e.open(m1, marta) + sender.untilReady() + other := e.open(m2, marta) + other.untilReady() + + room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1)) + + ev := other.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if got.ID != room.ID || got.Name != "общая" || got.Owner != "marta" { + t.Errorf("комната в событии: %+v", got) + } + if got.Key == nil || got.Key.CT != ctOf(40, 0) { + t.Errorf("ключ в событии: %+v", got.Key) + } + if got.NeedsRekey { + t.Error("needsRekey при создании") + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Смена состава: room всем участникам — каждому со своим ключом, +// room_left убранным. +func TestRoomEventsOnMembers(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + kolya, k1 := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60), http.StatusOK, "") + + streams := map[string]*stream{ + "marta": e.open(m1, marta), + "petya": e.open(p1, petya), + "kolya": e.open(k1, kolya), + } + for _, s := range streams { + s.untilReady() + } + + expect(t, e.changeMembers(marta, room.ID, nil, []string{"kolya"}, []string{"marta", "petya"}, 80), + http.StatusOK, "") + + for i, nick := range []string{"marta", "petya"} { + ev := streams[nick].next() + if ev.name != "room" { + t.Fatalf("событие у %s: получено %q, ожидалось \"room\"", nick, ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if nicks(got.Members) != "marta,petya" { + t.Errorf("состав в событии у %s: %v", nick, got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) { + t.Errorf("ключ в событии у %s: %+v", nick, got.Key) + } + if got.NeedsRekey { + t.Errorf("needsRekey при смене состава у %s", nick) + } + } + + ev := streams["kolya"].next() + if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие у убранного: %+v", ev) + } +} + +// Выход участника: остальным — room с needsRekey (ADR-018). +func TestRoomEventOnLeave(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + owner := e.open(m1, marta) + owner.untilReady() + leaving := e.open(p1, petya) + leaving.untilReady() + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)), http.StatusNoContent, "") + + ev := owner.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if !got.NeedsRekey { + t.Errorf("needsRekey: получено false, ожидалось true: %s", ev.data) + } + if len(got.Members) != 1 || got.Members[0] != "marta" { + t.Errorf("состав в событии: %v", got.Members) + } + if got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ в событии: %+v", got.Key) + } + // Другим устройствам вышедшего — room_left: комната ушла из списка, + // и ждать следующего ready им незачем (ADR-041). Запрос шёл без + // X-Device, поэтому событие получает и это устройство. + gone := leaving.next() + if gone.name != "room_left" || gone.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие вышедшему: %+v", gone) + } +} + +// Выход с X-Device: room_left уходит другим устройствам вышедшего, +// но не отправившему запрос (ADR-041). +func TestRoomEventOnLeaveExcludesSender(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + sender := e.open(p1, petya) + sender.untilReady() + other := e.open(p2, petya) + other.untilReady() + + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya), withDevice(p1)), + http.StatusNoContent, "") + + ev := other.next() + if ev.name != "room_left" || ev.data != `{"id":"`+room.ID+`"}` { + t.Errorf("событие другому устройству: %+v", ev) + } + select { + case ev := <-sender.events: + t.Errorf("эхо отправившему устройству: %+v", ev) + case <-time.After(200 * time.Millisecond): + } +} + +// Удаление комнаты: room_left всем участникам, включая владельца. +func TestRoomEventOnDelete(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + owner := e.open(m1, marta) + owner.untilReady() + member := e.open(p1, petya) + member.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/rooms/"+room.ID, nil, with(marta)), http.StatusNoContent, "") + + want := `{"id":"` + room.ID + `"}` + for _, s := range []*stream{owner, member} { + ev := s.next() + if ev.name != "room_left" || ev.data != want { + t.Errorf("событие: %+v", ev) + } + } +} + +// Комнаты требуют сессии, как и всё непубличное. +func TestRoomsNeedSession(t *testing.T) { + e := newEnv(t) + expect(t, e.do(http.MethodGet, "/api/rooms", nil), http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodPost, "/api/rooms", map[string]any{"name": "общая"}), + http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodPost, "/api/rooms/"+bytesOf(16, 1)+"/leave", nil), + http.StatusUnauthorized, "unauthenticated") + expect(t, e.do(http.MethodDelete, "/api/rooms/"+bytesOf(16, 1), nil), + http.StatusUnauthorized, "unauthenticated") + // Идентификатор комнаты в журнал не уходит: пишется шаблон маршрута. + if strings.Contains(e.log.String(), bytesOf(16, 1)) { + t.Errorf("идентификатор комнаты в журнале: %q", e.log.String()) + } +} + +// Долг по ключу — состояние комнаты: владелец, пропустивший событие, +// поднимает его из GET /api/rooms, а смена состава долг снимает (ADR-041). +func TestNeedsRekeyOutlivesEvent(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, _ := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + if got := e.room(marta, room.ID); got.NeedsRekey { + t.Error("needsRekey до выхода участника") + } + + // Владелец не подключён: событие room ему уходить некуда. + expect(t, e.do(http.MethodPost, "/api/rooms/"+room.ID+"/leave", nil, with(petya)), + http.StatusNoContent, "") + + got := e.room(marta, room.ID) + if got == nil || !got.NeedsRekey { + t.Fatalf("needsRekey в списке комнат: %+v", got) + } + if got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ в списке: %+v", got.Key) + } + + // Rekey закрывает долг. + expect(t, e.changeMembers(marta, room.ID, nil, nil, []string{"marta"}, 80), http.StatusOK, "") + if got := e.room(marta, room.ID); got.NeedsRekey { + t.Error("needsRekey после rekey") + } +} + +// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room +// с needsRekey и их собственным ключом, владение переходит (ADR-041). +func TestDeleteAccountLeavesRooms(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + kolya, k1 := e.join("kolya", 3) + + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya", "kolya"}, nil, + []string{"marta", "petya", "kolya"}, 60), http.StatusOK, "") + + first := e.open(p1, petya) + first.untilReady() + second := e.open(k1, kolya) + second.untilReady() + + expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(marta)), + http.StatusNoContent, "") + + for i, s := range []*stream{first, second} { + ev := s.next() + if ev.name != "room" { + t.Fatalf("событие: получено %q, ожидалось \"room\"", ev.name) + } + var got roomBody + if err := json.Unmarshal([]byte(ev.data), &got); err != nil { + t.Fatalf("разбор события %q: %v", ev.data, err) + } + if !got.NeedsRekey { + t.Errorf("needsRekey в событии: %s", ev.data) + } + // Владение — участнику с наименьшим joined_at; petya и kolya + // вступили одной операцией, поэтому порядок решает ник. + if got.Owner != "kolya" { + t.Errorf("владелец в событии: %q, ожидался kolya", got.Owner) + } + if nicks(got.Members) != nicks([]string{"petya", "kolya"}) { + t.Errorf("состав в событии: %v", got.Members) + } + // Каждому — его собственный ключ: он различается порядковым + // номером внутри «шифротекста». + if got.Key == nil || got.Key.CT != ctOf(60, i+1) { + t.Errorf("ключ в событии: %+v", got.Key) + } + } + + // Признак пережил и рассылку: новый владелец увидит его после ready. + if got := e.room(kolya, room.ID); got == nil || !got.NeedsRekey || got.Owner != "kolya" { + t.Errorf("комната у нового владельца: %+v", got) + } + // Ключи удалённого аккаунта ушли каскадом, комната жива. + expect(t, e.changeMembers(kolya, room.ID, nil, nil, []string{"petya", "kolya"}, 80), + http.StatusOK, "") + if got := e.room(petya, room.ID); got.NeedsRekey { + t.Error("needsRekey после rekey нового владельца") + } +} + +// Два ключа одному участнику — это множество keys[].to, не равное +// составу, и код у него тот же (ADR-043). +func TestMembersDuplicateKeyTarget(t *testing.T) { + e := newEnv(t) + marta, _ := e.join("marta", 1) + e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 60), + http.StatusOK, "") + + rec := e.changeMembers(marta, room.ID, nil, nil, []string{"marta", "marta"}, 80) + expect(t, rec, http.StatusBadRequest, "keys_mismatch") + // Отказ ничего не изменил: ключ комнаты прежний. + if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) { + t.Errorf("ключ после keys_mismatch: %+v", got.Key) + } +} diff --git a/internal/store/cleanup.go b/internal/store/cleanup.go index 5fa1914..70d9a7f 100644 --- a/internal/store/cleanup.go +++ b/internal/store/cleanup.go @@ -44,26 +44,14 @@ func (s *Store) Cleanup(ctx context.Context, now time.Time) error { {"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}}, {"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}}, {"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}}, - // Ключи комнат: у каждой комнаты остаются два последних key_id. - // Возраст key_id — время его самой поздней записи: ключ раздаётся - // участникам не одной строкой, а по строке на участника. - {"ключи комнат", ` - DELETE FROM room_keys WHERE (room_id, key_id) NOT IN ( - SELECT room_id, key_id FROM ( - SELECT room_id, key_id, - ROW_NUMBER() OVER ( - PARTITION BY room_id - ORDER BY MAX(created_at) DESC, key_id DESC - ) AS rn - FROM room_keys - GROUP BY room_id, key_id - ) WHERE rn <= ? - )`, []any{roomKeysKept}}, } for _, step := range steps { if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil { return fmt.Errorf("store: чистка (%s): %w", step.what, err) } } - return nil + // Ключи комнат: у каждой комнаты остаются два последних key_id. Тем же + // запросом обрезает их rekey (internal/store/rooms.go): порядок один, + // иначе чистка и rekey держали бы разные ключи. + return trimRoomKeys(ctx, s.db, "") } diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql index 0f9fbed..9dbf76d 100644 --- a/internal/store/migrations/001_init.sql +++ b/internal/store/migrations/001_init.sql @@ -38,7 +38,7 @@ CREATE TABLE contacts ( ); CREATE TABLE rooms ( - id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт сервер + id TEXT PRIMARY KEY, -- base64url 16 байт, выдаёт клиент (ADR-037) name TEXT NOT NULL, owner TEXT NOT NULL REFERENCES users(nick), created_at INTEGER NOT NULL diff --git a/internal/store/migrations/002_room_needs_rekey.sql b/internal/store/migrations/002_room_needs_rekey.sql new file mode 100644 index 0000000..3d83316 --- /dev/null +++ b/internal/store/migrations/002_room_needs_rekey.sql @@ -0,0 +1,4 @@ +-- Долг по ключу комнаты — состояние, а не свойство события (ADR-041): +-- состав уменьшился, а нового ключа ещё не было. Ставится при выходе +-- участника и удалении аккаунта, снимается при смене состава и rekey. +ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0; diff --git a/internal/store/queue.go b/internal/store/queue.go index d798abe..c84ae6c 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -57,10 +57,12 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error { // Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы // разложить его по очередям. Envelope сервер не разбирает, поэтому id -// приходит отдельным полем. +// приходит отдельным полем. Заполнено ровно одно из To и Room — адресат +// у конверта один (docs/protocol.md, «Типы»). type Delivery struct { From string // отправитель, он же один из получателей - To string // собеседник + To string // собеседник личного чата + Room string // комната Exclude string // устройство отправителя: эхо ему не нужно (ADR-017) MsgID string // id конверта, вторая половина ключа очереди Envelope string // готовый JSON конверта @@ -109,6 +111,38 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { return devices, nil } +// DeliverRoom кладёт конверт комнаты в очередь всех устройств всех +// участников, кроме отправившего (ADR-018), и возвращает эти устройства. +// Контактов у комнаты нет: список комнат клиент берёт из GET /api/rooms. +// +// Членство и keyId проверены раньше, отдельным запросом: между проверкой +// и этой транзакцией состав мог измениться, поэтому получателей она берёт +// из состава на момент доставки. +func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: доставка в комнату: %w", err) + } + defer tx.Rollback() + + devices, err := roomDeviceIDs(ctx, tx, d.Room, d.Exclude) + if err != nil { + return nil, err + } + for _, id := range devices { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) + ON CONFLICT(device_id, msg_id) DO NOTHING`, + id, d.MsgID, d.Envelope, d.Now); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err) + } + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: доставка в комнату: %w", err) + } + return devices, nil +} + // deviceIDs — устройства обоих собеседников, кроме отправившего. func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) { rows, err := tx.QueryContext(ctx, ` @@ -131,3 +165,27 @@ func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]str } return out, nil } + +// roomDeviceIDs — устройства всех участников комнаты, кроме отправившего. +func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick + WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude) + if err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) + } + return out, nil +} diff --git a/internal/store/rooms.go b/internal/store/rooms.go new file mode 100644 index 0000000..8255d77 --- /dev/null +++ b/internal/store/rooms.go @@ -0,0 +1,771 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "strings" +) + +// Комнаты (ADR-007, ADR-018): владелец меняет состав, ключи заворачивают +// клиенты. Сервер хранит и раздаёт завёрнутые ключи, но прочитать их +// не может — это шифротекст, как и всё остальное в базе. + +// Ошибки комнат, которые обработчику нужно различать. Остальное — +// внутренние сбои. +var ( + // ErrNotOwner — комнату меняет не её владелец. Несуществующая комната + // отвечает тем же: знать о ней постороннему незачем. + ErrNotOwner = errors.New("store: не владелец комнаты") + // ErrUnknownUser — в add ник, которого нет. + ErrUnknownUser = errors.New("store: нет такого ника") + // ErrNotMember — в remove ник, который не участник комнаты. + ErrNotMember = errors.New("store: не участник комнаты") + // ErrOwnerRemoval — владельца из состава убрать нельзя. + ErrOwnerRemoval = errors.New("store: владельца убрать нельзя") + // ErrKeyExists — такой keyId у комнаты уже был. + ErrKeyExists = errors.New("store: ключ комнаты уже есть") + // ErrKeysMismatch — множество keys[].to не равно итоговому составу. + ErrKeysMismatch = errors.New("store: ключи не по составу") + // ErrRoomExists — идентификатор комнаты занят (ADR-037). + ErrRoomExists = errors.New("store: такая комната уже есть") +) + +// RoomKey — завёрнутый ключ комнаты, каким его видит участник +// (docs/protocol.md, «Типы»). Развернуть его может только он. +type RoomKey struct { + KeyID string + From string // кто завернул + IV string + CT string +} + +// WrappedKey — запись keys[] запроса: кому предназначен ключ и что в нём. +// Заворачивал тот, кто прислал запрос. +type WrappedKey struct { + To string + IV string + CT string +} + +// Room — комната и её состав. Key — текущий ключ того, кто спрашивает; +// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился, +// а нового ключа ещё не было (ADR-041). +type Room struct { + ID string + Name string + Owner string + Members []string // по joined_at + CreatedAt int64 + Key *RoomKey + NeedsRekey bool +} + +// Recipient — участник, его устройства и его текущий ключ: событие room +// уходит каждому со своим ключом (docs/protocol.md, «Комнаты»). +type Recipient struct { + Nick string + Devices []string + Key *RoomKey +} + +// RoomChange — итог изменения комнаты: кому уходит room, а кому room_left. +// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient. +type RoomChange struct { + Room Room + Members []Recipient // итоговый состав + Left []Recipient // выбывшие +} + +// Rooms — комнаты, где пользователь участник, каждая с его текущим +// ключом (docs/protocol.md, «Комнаты»). +func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey + FROM rooms r JOIN room_members m ON m.room_id = r.id + WHERE m.nick = ? ORDER BY r.created_at, r.id`, nick) + if err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + defer rows.Close() + + var out []Room + at := make(map[string]int) + for rows.Next() { + var r Room + if err := rows.Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey); err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + r.Members = []string{} + at[r.ID] = len(out) + out = append(out, r) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: список комнат: %w", err) + } + if len(out) == 0 { + return nil, nil + } + + members, err := s.db.QueryContext(ctx, ` + SELECT room_id, nick FROM room_members + WHERE room_id IN (SELECT room_id FROM room_members WHERE nick = ?) + ORDER BY room_id, joined_at, nick`, nick) + if err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + defer members.Close() + + for members.Next() { + var room, member string + if err := members.Scan(&room, &member); err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + if i, ok := at[room]; ok { + out[i].Members = append(out[i].Members, member) + } + } + if err := members.Err(); err != nil { + return nil, fmt.Errorf("store: состав комнат: %w", err) + } + + keys, err := s.db.QueryContext(ctx, currentKeysQuery+` AND nick = ?`, nick) + if err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + defer keys.Close() + + for keys.Next() { + // Второй столбец — ник владельца ключа, здесь он всегда nick. + var room, member string + var k RoomKey + if err := keys.Scan(&room, &member, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + if i, ok := at[room]; ok { + key := k + out[i].Key = &key + } + } + if err := keys.Err(); err != nil { + return nil, fmt.Errorf("store: ключи комнат: %w", err) + } + return out, nil +} + +// NewRoom — что нужно, чтобы завести комнату. Идентификатор выдаёт +// клиент (ADR-037), ключ ровно один — себе (docs/protocol.md, «Комнаты»). +type NewRoom struct { + ID string + Name string + Owner string + KeyID string + Key WrappedKey + Now int64 +} + +// CreateRoom заводит комнату, её единственного участника-владельца и его +// завёрнутый ключ — в одной транзакции. Идентификатор приходит от клиента +// (ADR-037); занятый — ErrRoomExists, без слияния с существующей комнатой. +func (s *Store) CreateRoom(ctx context.Context, n NewRoom) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + defer tx.Rollback() + + // Проверка до вставки, а не разбор ошибки драйвера: она читаема + // и не зависит от текста, который вернёт SQLite. + if _, err := roomRow(ctx, tx, n.ID); err == nil { + return RoomChange{}, ErrRoomExists + } else if !errors.Is(err, ErrNotFound) { + return RoomChange{}, err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO rooms (id, name, owner, created_at) VALUES (?, ?, ?, ?)`, + n.ID, n.Name, n.Owner, n.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?)`, + n.ID, n.Owner, n.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты (участник): %w", err) + } + if err := insertKeys(ctx, tx, n.ID, n.Owner, n.KeyID, []WrappedKey{n.Key}, n.Now); err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, []string{n.Owner}) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: создание комнаты: %w", err) + } + + key := RoomKey{KeyID: n.KeyID, From: n.Owner, IV: n.Key.IV, CT: n.Key.CT} + return RoomChange{ + Room: Room{ + ID: n.ID, + Name: n.Name, + Owner: n.Owner, + Members: []string{n.Owner}, + CreatedAt: n.Now, + }, + Members: []Recipient{{Nick: n.Owner, Devices: devices[n.Owner], Key: &key}}, + }, nil +} + +// MembersChange — смена состава и rekey одним запросом (ADR-018). +// Add и Remove — ники без повторов и без пересечения; пустые — чистый rekey. +type MembersChange struct { + RoomID string + Owner string // от чьего имени идёт запрос: он обязан быть владельцем + Add []string + Remove []string + KeyID string + Keys []WrappedKey + Now int64 +} + +// UpdateMembers меняет состав и раздаёт новый ключ — всё в одной +// транзакции: состав без ключа или ключ без состава невозможны (ADR-018). +// Порядок проверок — docs/protocol.md, «Комнаты»; отказ на любой из них +// не меняет ни строки. +func (s *Store) UpdateMembers(ctx context.Context, c MembersChange) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, c.RoomID) + if errors.Is(err, ErrNotFound) { + return RoomChange{}, ErrNotOwner + } + if err != nil { + return RoomChange{}, err + } + if room.Owner != c.Owner { + return RoomChange{}, ErrNotOwner + } + + current, err := roomMembers(ctx, tx, c.RoomID) + if err != nil { + return RoomChange{}, err + } + for _, nick := range c.Add { + ok, err := userExists(ctx, tx, nick) + if err != nil { + return RoomChange{}, err + } + if !ok { + return RoomChange{}, ErrUnknownUser + } + } + member := make(map[string]bool, len(current)) + for _, nick := range current { + member[nick] = true + } + for _, nick := range c.Remove { + if !member[nick] { + return RoomChange{}, ErrNotMember + } + } + for _, nick := range c.Remove { + if nick == room.Owner { + return RoomChange{}, ErrOwnerRemoval + } + } + used, err := keyUsed(ctx, tx, c.RoomID, c.KeyID) + if err != nil { + return RoomChange{}, err + } + if used { + return RoomChange{}, ErrKeyExists + } + if !sameNicks(afterChange(current, c.Add, c.Remove), keyTargets(c.Keys)) { + return RoomChange{}, ErrKeysMismatch + } + + for _, nick := range c.Remove { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_members WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (убрать): %w", err) + } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, c.RoomID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (ключи убранного): %w", err) + } + } + for _, nick := range c.Add { + // Уже состоящего участника запрос не двигает: joined_at остаётся + // прежним, порядок состава не прыгает. + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_members (room_id, nick, joined_at) VALUES (?, ?, ?) + ON CONFLICT(room_id, nick) DO NOTHING`, c.RoomID, nick, c.Now); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава (добавить): %w", err) + } + } + if err := insertKeys(ctx, tx, c.RoomID, c.Owner, c.KeyID, c.Keys, c.Now); err != nil { + return RoomChange{}, err + } + if err := trimRoomKeys(ctx, tx, c.RoomID); err != nil { + return RoomChange{}, err + } + // Ключ роздан всему итоговому составу — долг закрыт (ADR-041). + if _, err := tx.ExecContext(ctx, ` + UPDATE rooms SET needs_rekey = 0 WHERE id = ?`, c.RoomID); err != nil { + return RoomChange{}, fmt.Errorf("store: снятие долга по ключу: %w", err) + } + room.NeedsRekey = false + + final, err := roomMembers(ctx, tx, c.RoomID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, final) + if err != nil { + return RoomChange{}, err + } + left, err := devicesOf(ctx, tx, c.Remove) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: смена состава: %w", err) + } + + room.Members = final + change := RoomChange{Room: room} + wrapped := make(map[string]WrappedKey, len(c.Keys)) + for _, k := range c.Keys { + wrapped[k.To] = k + } + for _, nick := range final { + k := wrapped[nick] + key := RoomKey{KeyID: c.KeyID, From: c.Owner, IV: k.IV, CT: k.CT} + change.Members = append(change.Members, Recipient{Nick: nick, Devices: devices[nick], Key: &key}) + } + for _, nick := range c.Remove { + change.Left = append(change.Left, Recipient{Nick: nick, Devices: left[nick]}) + } + return change, nil +} + +// LeaveRoom убирает участника и его ключи. Вышел владелец — владение +// получает участник с наименьшим joined_at; не осталось никого — комната +// удаляется (ADR-018). В RoomChange.Members — оставшиеся с их текущими +// ключами: им уходит room с needsRekey. В RoomChange.Left — сам вышедший: +// его другим устройствам уходит room_left, иначе комната висела бы у них +// до следующего ready (ADR-041). Не участник — ErrNotFound. +func (s *Store) LeaveRoom(ctx context.Context, roomID, nick string) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, roomID) + if err != nil { + return RoomChange{}, err + } + var one int + err = tx.QueryRowContext(ctx, ` + SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?`, roomID, nick).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return RoomChange{}, ErrNotFound + } + if err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + gone, err := devicesOf(ctx, tx, []string{nick}) + if err != nil { + return RoomChange{}, err + } + change, err := leaveRoom(ctx, tx, room, nick) + if err != nil { + return RoomChange{}, err + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + change.Left = []Recipient{{Nick: nick, Devices: gone[nick]}} + return change, nil +} + +// leaveRoom убирает участника и его ключи внутри чужой транзакции: это +// общее у POST /api/rooms/{id}/leave и удаления аккаунта — по составу +// комнаты это один и тот же выход участника (ADR-018, ADR-041). +// +// В RoomChange.Members — оставшиеся с их текущими ключами и признаком +// needsRekey; пусто, если комната опустела и удалена. Проверку членства +// и рассылку берут на себя вызывающие. +func leaveRoom(ctx context.Context, tx *sql.Tx, room Room, nick string) (RoomChange, error) { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_members WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + DELETE FROM room_keys WHERE room_id = ? AND nick = ?`, room.ID, nick); err != nil { + return RoomChange{}, fmt.Errorf("store: выход из комнаты (ключи): %w", err) + } + rest, err := roomMembers(ctx, tx, room.ID) + if err != nil { + return RoomChange{}, err + } + if len(rest) == 0 { + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление пустой комнаты: %w", err) + } + // Комнаты больше нет: ключ ей не нужен, и долга за ней не остаётся. + room.Members = nil + room.NeedsRekey = false + return RoomChange{Room: room}, nil + } + if room.Owner == nick { + room.Owner = rest[0] + if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, room.Owner, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: передача владения: %w", err) + } + } + // Состав уменьшился: комнате нужен новый ключ. Признак ждёт владельца + // в базе, а не только в событии, — офлайн его больше не теряет (ADR-041). + if _, err := tx.ExecContext(ctx, ` + UPDATE rooms SET needs_rekey = 1 WHERE id = ?`, room.ID); err != nil { + return RoomChange{}, fmt.Errorf("store: долг по ключу комнаты: %w", err) + } + room.NeedsRekey = true + keys, err := currentKeys(ctx, tx, room.ID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, rest) + if err != nil { + return RoomChange{}, err + } + + room.Members = rest + change := RoomChange{Room: room} + for _, member := range rest { + r := Recipient{Nick: member, Devices: devices[member]} + if k, ok := keys[member]; ok { + key := k + r.Key = &key + } + change.Members = append(change.Members, r) + } + return change, nil +} + +// DeleteRoom удаляет комнату целиком; членство и ключи уносит каскад. +// В RoomChange.Left — все участники: им уходит room_left. Не владелец +// и несуществующая комната — ErrNotOwner. +func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + defer tx.Rollback() + + room, err := roomRow(ctx, tx, roomID) + if errors.Is(err, ErrNotFound) { + return RoomChange{}, ErrNotOwner + } + if err != nil { + return RoomChange{}, err + } + if room.Owner != owner { + return RoomChange{}, ErrNotOwner + } + members, err := roomMembers(ctx, tx, roomID) + if err != nil { + return RoomChange{}, err + } + devices, err := devicesOf(ctx, tx, members) + if err != nil { + return RoomChange{}, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, roomID); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + if err := tx.Commit(); err != nil { + return RoomChange{}, fmt.Errorf("store: удаление комнаты: %w", err) + } + + room.Members = members + change := RoomChange{Room: room} + for _, member := range members { + change.Left = append(change.Left, Recipient{Nick: member, Devices: devices[member]}) + } + return change, nil +} + +// RoomAccess — что сервер проверяет перед отправкой в комнату +// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если +// есть хоть одна строка room_keys с таким key_id (docs/storage.md). +func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) { + err = s.db.QueryRowContext(ctx, ` + SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?), + EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`, + roomID, nick, roomID, keyID).Scan(&member, &knownKey) + if err != nil { + return false, false, fmt.Errorf("store: доступ к комнате: %w", err) + } + return member, knownKey, nil +} + +// currentKeysQuery — текущий ключ участника: строка room_keys с максимальным +// created_at (docs/storage.md). Порядок при совпадении времени тот же, что +// у обрезки, — иначе «текущий» и «оставленный» могли бы разойтись. +const currentKeysQuery = ` + SELECT room_id, nick, key_id, sender, iv, ct FROM ( + SELECT room_id, nick, key_id, sender, iv, ct, + ROW_NUMBER() OVER ( + PARTITION BY room_id, nick + ORDER BY created_at DESC, key_id DESC + ) AS rn + FROM room_keys + ) WHERE rn = 1` + +// currentKeys — текущие ключи всех участников комнаты. +func currentKeys(ctx context.Context, tx *sql.Tx, roomID string) (map[string]RoomKey, error) { + rows, err := tx.QueryContext(ctx, currentKeysQuery+` AND room_id = ?`, roomID) + if err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + defer rows.Close() + + out := make(map[string]RoomKey) + for rows.Next() { + var room, nick string + var k RoomKey + if err := rows.Scan(&room, &nick, &k.KeyID, &k.From, &k.IV, &k.CT); err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + out[nick] = k + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: ключи комнаты: %w", err) + } + return out, nil +} + +// execer — то общее у *sql.DB и *sql.Tx, что нужно обрезке ключей: её +// зовут и транзакция rekey, и фоновая чистка. +type execer interface { + ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) +} + +// trimRoomKeys оставляет у комнаты два последних keyId (ADR-018). Пустой +// room — все комнаты: это фоновая чистка (docs/storage.md). Запрос один +// на оба случая, иначе rekey и чистка держали бы разные ключи. +// +// Текущий ключ участника обрезка не трогает: новый keyId раздаётся всем +// участникам сразу (иначе keys_mismatch), поэтому самый свежий key_id +// комнаты есть у каждого, а он остаётся всегда. Какой из ключей свежий — +// однозначно: время ключа строго растёт (insertKeys). +// +// Возраст key_id — время его самой поздней записи: ключ раздаётся не одной +// строкой, а по строке на участника. +func trimRoomKeys(ctx context.Context, x execer, room string) error { + _, err := x.ExecContext(ctx, ` + DELETE FROM room_keys + WHERE (? = '' OR room_id = ?) + AND (room_id, key_id) NOT IN ( + SELECT room_id, key_id FROM ( + SELECT room_id, key_id, + ROW_NUMBER() OVER ( + PARTITION BY room_id + ORDER BY MAX(created_at) DESC, key_id DESC + ) AS rn + FROM room_keys + GROUP BY room_id, key_id + ) WHERE rn <= ? + )`, room, room, roomKeysKept) + if err != nil { + return fmt.Errorf("store: обрезка ключей комнаты: %w", err) + } + return nil +} + +// roomRow читает комнату без состава и ключей. Нет такой — ErrNotFound. +func roomRow(ctx context.Context, tx *sql.Tx, roomID string) (Room, error) { + var r Room + err := tx.QueryRowContext(ctx, ` + SELECT id, name, owner, created_at, needs_rekey FROM rooms WHERE id = ?`, roomID). + Scan(&r.ID, &r.Name, &r.Owner, &r.CreatedAt, &r.NeedsRekey) + if errors.Is(err, sql.ErrNoRows) { + return Room{}, ErrNotFound + } + if err != nil { + return Room{}, fmt.Errorf("store: чтение комнаты: %w", err) + } + return r, nil +} + +// roomMembers — состав комнаты по joined_at (docs/protocol.md, «Типы»). +func roomMembers(ctx context.Context, tx *sql.Tx, roomID string) ([]string, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT nick FROM room_members WHERE room_id = ? ORDER BY joined_at, nick`, roomID) + if err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var nick string + if err := rows.Scan(&nick); err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + out = append(out, nick) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: состав комнаты: %w", err) + } + return out, nil +} + +// userExists — есть ли такой ник. +func userExists(ctx context.Context, tx *sql.Tx, nick string) (bool, error) { + var one int + err := tx.QueryRowContext(ctx, `SELECT 1 FROM users WHERE nick = ?`, nick).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("store: проверка ника: %w", err) + } + return true, nil +} + +// keyUsed — был ли уже такой keyId у комнаты. +func keyUsed(ctx context.Context, tx *sql.Tx, roomID, keyID string) (bool, error) { + var one int + err := tx.QueryRowContext(ctx, ` + SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ? LIMIT 1`, roomID, keyID).Scan(&one) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("store: проверка ключа комнаты: %w", err) + } + return true, nil +} + +// insertKeys раскладывает завёрнутые ключи по участникам. +// +// Время ключа — строго позже всех прежних ключей комнаты. Текущий ключ +// участника — строка с максимальным created_at (docs/storage.md), а два +// rekey подряд укладываются в одну миллисекунду. Без этого «последним» +// оказался бы прежний ключ, обрезка до двух последних keyId выбросила бы +// свежий, и комната откатилась бы на ключ, которого у новых участников нет. +func insertKeys(ctx context.Context, tx *sql.Tx, roomID, sender, keyID string, keys []WrappedKey, now int64) error { + var last int64 + if err := tx.QueryRowContext(ctx, ` + SELECT COALESCE(MAX(created_at), 0) FROM room_keys WHERE room_id = ?`, roomID).Scan(&last); err != nil { + return fmt.Errorf("store: время ключа комнаты: %w", err) + } + if now <= last { + now = last + 1 + } + for _, k := range keys { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + roomID, k.To, keyID, sender, k.IV, k.CT, now); err != nil { + return fmt.Errorf("store: раздача ключа комнаты: %w", err) + } + } + return nil +} + +// devicesOf — устройства перечисленных пользователей. +func devicesOf(ctx context.Context, tx *sql.Tx, nicks []string) (map[string][]string, error) { + out := make(map[string][]string, len(nicks)) + if len(nicks) == 0 { + return out, nil + } + args := make([]any, 0, len(nicks)) + for _, nick := range nicks { + args = append(args, nick) + } + rows, err := tx.QueryContext(ctx, ` + SELECT nick, id FROM devices WHERE nick IN (?`+ + strings.Repeat(", ?", len(nicks)-1)+`) ORDER BY nick, id`, args...) + if err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + defer rows.Close() + + for rows.Next() { + var nick, id string + if err := rows.Scan(&nick, &id); err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + out[nick] = append(out[nick], id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: устройства участников: %w", err) + } + return out, nil +} + +// afterChange — итоговый состав: текущий без remove плюс add. +func afterChange(current, add, remove []string) []string { + gone := make(map[string]bool, len(remove)) + for _, nick := range remove { + gone[nick] = true + } + out := make([]string, 0, len(current)+len(add)) + seen := make(map[string]bool, len(current)+len(add)) + for _, nick := range current { + if gone[nick] || seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + for _, nick := range add { + if seen[nick] { + continue + } + seen[nick] = true + out = append(out, nick) + } + return out +} + +// keyTargets — кому предназначены завёрнутые ключи. +func keyTargets(keys []WrappedKey) []string { + out := make([]string, 0, len(keys)) + for _, k := range keys { + out = append(out, k.To) + } + return out +} + +// sameNicks — совпадают ли множества ников. Порядок не важен: ключи +// приходят в порядке клиента, состав — по joined_at. +// +// Найденный ник из множества вычёркивается: два ключа одному участнику +// вместо ключа другому — это keys_mismatch, а не совпадение по длине. +// Иначе такой запрос дошёл бы до вставки и упал на ключе room_keys уже +// внутри транзакции, отдав клиенту 500 вместо разбираемого кода. +func sameNicks(a, b []string) bool { + if len(a) != len(b) { + return false + } + set := make(map[string]bool, len(a)) + for _, nick := range a { + set[nick] = true + } + for _, nick := range b { + if !set[nick] { + return false + } + delete(set, nick) + } + return len(set) == 0 +} diff --git a/internal/store/rooms_test.go b/internal/store/rooms_test.go new file mode 100644 index 0000000..866bff8 --- /dev/null +++ b/internal/store/rooms_test.go @@ -0,0 +1,334 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +// room — комната из трёх ников для тестов хранилища. +func newTestStore(t *testing.T, nicks ...string) (*Store, context.Context) { + t.Helper() + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + for i, nick := range nicks { + err := s.CreateUser(ctx, User{ + Nick: nick, + Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"}, + PublicKey: `{"kty":"EC"}`, + KeyBlob: `{"v":1}`, + CreatedAt: int64(i + 1), + }) + if err != nil { + t.Fatalf("CreateUser %s: %v", nick, err) + } + } + return s, ctx +} + +// wrap — завёрнутый ключ участнику: содержимое хранилищу безразлично. +func wrap(nicks ...string) []WrappedKey { + out := make([]WrappedKey, 0, len(nicks)) + for _, nick := range nicks { + out = append(out, WrappedKey{To: nick, IV: "iv-" + nick, CT: "ct-" + nick}) + } + return out +} + +// rekey — смена состава и раздача нового ключа. +func rekey(t *testing.T, s *Store, ctx context.Context, room, owner, keyID string, add, remove, to []string, now int64) RoomChange { + t.Helper() + change, err := s.UpdateMembers(ctx, MembersChange{ + RoomID: room, + Owner: owner, + Add: add, + Remove: remove, + KeyID: keyID, + Keys: wrap(to...), + Now: now, + }) + if err != nil { + t.Fatalf("UpdateMembers %s: %v", keyID, err) + } + return change +} + +// makeRoom — комната с одним владельцем и его ключом. +func makeRoom(t *testing.T, s *Store, ctx context.Context, owner string, now int64) RoomChange { + t.Helper() + change, err := s.CreateRoom(ctx, NewRoom{ + ID: "room-1", + Name: "общая", + Owner: owner, + KeyID: "k1", + Key: wrap(owner)[0], + Now: now, + }) + if err != nil { + t.Fatalf("CreateRoom: %v", err) + } + return change +} + +// Занятый идентификатор комнаты — ErrRoomExists, и ни одной строки +// существующая комната при этом не теряет (ADR-037). +func TestCreateRoomTakenID(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + + _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "чужая", Owner: "petya", + KeyID: "k2", Key: wrap("petya")[0], Now: 2000}) + if !errors.Is(err, ErrRoomExists) { + t.Fatalf("CreateRoom с занятым id: получено %v, ожидалось ErrRoomExists", err) + } + rooms, err := s.Rooms(ctx, "marta") + if err != nil { + t.Fatalf("Rooms: %v", err) + } + if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" || + rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" { + t.Errorf("комната после отказа: %+v", rooms) + } + if got, err := s.Rooms(ctx, "petya"); err != nil || len(got) != 0 { + t.Errorf("занятый id присоединил чужого: %+v, %v", got, err) + } +} + +// У комнаты живут два последних keyId; обрезка при rekey и фоновая чистка +// держат одни и те же ключи и не трогают текущий ключ участника (ADR-018). +func TestRoomKeysTrimmedToTwo(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + rekey(t, s, ctx, "room-1", "marta", "k3", []string{"kolya"}, nil, []string{"marta", "petya", "kolya"}, 3000) + + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи после rekey: получено %v, ожидалось [k2 k3]", got) + } + // Фоновая чистка ничего не добавляет к обрезке: запрос у них один. + if err := s.Cleanup(ctx, time.Now()); err != nil { + t.Fatalf("Cleanup: %v", err) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи после чистки: получено %v, ожидалось [k2 k3]", got) + } + // Текущий ключ есть у каждого участника, и он последний. + for _, nick := range []string{"marta", "petya", "kolya"} { + rooms, err := s.Rooms(ctx, nick) + if err != nil { + t.Fatalf("Rooms %s: %v", nick, err) + } + if len(rooms) != 1 || rooms[0].Key == nil { + t.Fatalf("комнаты %s: %+v", nick, rooms) + } + if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick { + t.Errorf("ключ %s: %+v", nick, rooms[0].Key) + } + } +} + +// Два rekey в одну миллисекунду: текущим остаётся последний розданный ключ, +// и обрезка его не выбрасывает (docs/storage.md, «Текущий ключ комнаты»). +// Идентификаторы ключей случайны, поэтому свежий вполне может оказаться +// меньше прежнего по порядку сортировки — на этом и построен случай. +func TestRoomKeysWithinOneMillisecond(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "marta", + KeyID: "ccc", Key: wrap("marta")[0], Now: 1000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + rekey(t, s, ctx, "room-1", "marta", "bbb", []string{"petya"}, nil, []string{"marta", "petya"}, 1000) + rekey(t, s, ctx, "room-1", "marta", "aaa", nil, nil, []string{"marta", "petya"}, 1000) + + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"aaa", "bbb"}) { + t.Errorf("ключи: получено %v, ожидалось [aaa bbb]", got) + } + for _, nick := range []string{"marta", "petya"} { + rooms, err := s.Rooms(ctx, nick) + if err != nil || len(rooms) != 1 || rooms[0].Key == nil { + t.Fatalf("комнаты %s: %+v, %v", nick, rooms, err) + } + if rooms[0].Key.KeyID != "aaa" { + t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID) + } + } + // Свежим ключом можно писать: он остался ключом комнаты. + member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") + if err != nil || !member || !known { + t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err) + } +} + +// Убранный участник теряет и членство, и все свои ключи. +func TestRoomRemoveDropsKeys(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + change := rekey(t, s, ctx, "room-1", "marta", "k3", nil, []string{"petya"}, []string{"marta"}, 3000) + if len(change.Left) != 1 || change.Left[0].Nick != "petya" { + t.Errorf("выбывшие: %+v", change.Left) + } + if got := ids(t, s, `SELECT nick FROM room_keys ORDER BY nick, key_id`); !equal(got, []string{"marta", "marta"}) { + t.Errorf("ключи после удаления участника: %v", got) + } + rooms, err := s.Rooms(ctx, "petya") + if err != nil || len(rooms) != 0 { + t.Errorf("комнаты убранного: %+v, %v", rooms, err) + } +} + +// Отказ в середине не оставляет следов: состав и ключи те же (docs/protocol.md). +func TestRoomChangeIsAtomic(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + cases := []struct { + name string + c MembersChange + want error + }{ + {"не владелец", MembersChange{RoomID: "room-1", Owner: "petya", KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner}, + {"нет комнаты", MembersChange{RoomID: "нет", Owner: "marta", KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotOwner}, + {"нет ника", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"никого"}, KeyID: "k3", + Keys: wrap("marta", "petya", "никого"), Now: 3000}, ErrUnknownUser}, + {"не участник", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"kolya"}, KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrNotMember}, + {"владелец", MembersChange{RoomID: "room-1", Owner: "marta", Remove: []string{"marta"}, KeyID: "k3", + Keys: wrap("petya"), Now: 3000}, ErrOwnerRemoval}, + {"ключ уже был", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k2", + Keys: wrap("marta", "petya", "kolya"), Now: 3000}, ErrKeyExists}, + {"ключи не по составу", MembersChange{RoomID: "room-1", Owner: "marta", Add: []string{"kolya"}, KeyID: "k3", + Keys: wrap("marta", "petya"), Now: 3000}, ErrKeysMismatch}, + // Два ключа одному вместо ключа другому: длина сходится, состав — нет. + {"два ключа одному", MembersChange{RoomID: "room-1", Owner: "marta", KeyID: "k3", + Keys: wrap("marta", "marta"), Now: 3000}, ErrKeysMismatch}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if _, err := s.UpdateMembers(ctx, c.c); !errors.Is(err, c.want) { + t.Fatalf("получено %v, ожидалось %v", err, c.want) + } + if got := ids(t, s, `SELECT nick FROM room_members WHERE room_id = 'room-1' ORDER BY nick`); !equal(got, []string{"marta", "petya"}) { + t.Errorf("состав: получено %v, ожидалось [marta petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k1", "k2"}) { + t.Errorf("ключи: получено %v, ожидалось [k1 k2]", got) + } + }) + } +} + +// Удаление аккаунта передаёт владение участнику с наименьшим joined_at, +// а комнату без участников удаляет (ADR-018). +func TestDeleteUserRooms(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya", "kolya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"kolya"}, nil, []string{"marta", "kolya"}, 2000) + rekey(t, s, ctx, "room-1", "marta", "k3", []string{"petya"}, nil, []string{"marta", "kolya", "petya"}, 3000) + + // Вторая комната — только владелец. + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-2", Name: "своя", Owner: "marta", + KeyID: "k1", Key: wrap("marta")[0], Now: 4000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + + if _, err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if got := ids(t, s, `SELECT id FROM rooms ORDER BY id`); !equal(got, []string{"room-1"}) { + t.Errorf("комнаты: получено %v, ожидалось [room-1]", got) + } + if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"kolya"}) { + t.Errorf("владелец: получено %v, ожидалось [kolya]", got) + } + if got := ids(t, s, `SELECT nick FROM room_members ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) { + t.Errorf("состав: получено %v, ожидалось [kolya petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys ORDER BY nick`); !equal(got, []string{"kolya", "petya"}) { + t.Errorf("ключи: получено %v, ожидалось [kolya petya]", got) + } +} + +// Удаление аккаунта простого участника чужую комнату не трогает: членство +// и ключи уносит каскад, владелец и остальные на месте. +func TestDeleteUserMember(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + if _, err := s.CreateRoom(ctx, NewRoom{ID: "room-1", Name: "общая", Owner: "petya", + KeyID: "k1", Key: wrap("petya")[0], Now: 1000}); err != nil { + t.Fatalf("CreateRoom: %v", err) + } + rekey(t, s, ctx, "room-1", "petya", "k2", []string{"marta"}, nil, []string{"petya", "marta"}, 2000) + + if _, err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if got := ids(t, s, `SELECT owner FROM rooms`); !equal(got, []string{"petya"}) { + t.Errorf("комнаты: получено %v, ожидалось [petya]", got) + } + if got := ids(t, s, `SELECT nick FROM room_members`); !equal(got, []string{"petya"}) { + t.Errorf("состав: получено %v, ожидалось [petya]", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) { + t.Errorf("ключи: получено %v, ожидалось [petya]", got) + } +} + +// Выход владельца передаёт владение; выход последнего удаляет комнату. +func TestLeaveRoom(t *testing.T) { + s, ctx := newTestStore(t, "marta", "petya") + makeRoom(t, s, ctx, "marta", 1000) + rekey(t, s, ctx, "room-1", "marta", "k2", []string{"petya"}, nil, []string{"marta", "petya"}, 2000) + + // Не участник — ErrNotFound, комната не тронута. + if _, err := s.LeaveRoom(ctx, "нет", "marta"); !errors.Is(err, ErrNotFound) { + t.Errorf("выход из несуществующей комнаты: %v", err) + } + + change, err := s.LeaveRoom(ctx, "room-1", "marta") + if err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + if change.Room.Owner != "petya" { + t.Errorf("владелец: получено %q, ожидалось \"petya\"", change.Room.Owner) + } + if len(change.Members) != 1 || change.Members[0].Nick != "petya" { + t.Fatalf("оставшиеся: %+v", change.Members) + } + if change.Members[0].Key == nil || change.Members[0].Key.KeyID != "k2" { + t.Errorf("ключ оставшегося: %+v", change.Members[0].Key) + } + if !change.Room.NeedsRekey { + t.Error("needsRekey после выхода участника") + } + if len(change.Left) != 1 || change.Left[0].Nick != "marta" { + t.Errorf("room_left вышедшему: %+v", change.Left) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); !equal(got, []string{"petya"}) { + t.Errorf("ключи после выхода: %v", got) + } + + last, err := s.LeaveRoom(ctx, "room-1", "petya") + if err != nil { + t.Fatalf("LeaveRoom: %v", err) + } + // Комнаты больше нет: room слать некому, room_left уходит другим + // устройствам вышедшего (ADR-041). + if len(last.Members) != 0 { + t.Errorf("получатели после выхода последнего: %+v", last.Members) + } + if len(last.Left) != 1 || last.Left[0].Nick != "petya" { + t.Errorf("room_left после выхода последнего: %+v", last.Left) + } + if got := ids(t, s, `SELECT id FROM rooms`); len(got) != 0 { + t.Errorf("пустая комната осталась: %v", got) + } + if got := ids(t, s, `SELECT DISTINCT nick FROM room_keys`); len(got) != 0 { + t.Errorf("ключи удалённой комнаты остались: %v", got) + } +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 7948b84..a1df6f4 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -22,11 +22,12 @@ func TestMigrateAndRestart(t *testing.T) { path := filepath.Join(t.TempDir(), "bare.db") first := open(t, path) - if got := first.Applied(); len(got) != 1 || got[0] != "001_init.sql" { - t.Fatalf("применённые миграции: получено %v, ожидалось [001_init.sql]", got) + want := []string{"001_init.sql", "002_room_needs_rekey.sql"} + if got := first.Applied(); !equal(got, want) { + t.Fatalf("применённые миграции: получено %v, ожидалось %v", got, want) } - if got := version(t, first); got != 1 { - t.Errorf("user_version: получено %d, ожидалась 1", got) + if got := version(t, first); got != len(want) { + t.Errorf("user_version: получено %d, ожидалась %d", got, len(want)) } // Все восемь таблиц из docs/storage.md на месте. for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} { @@ -52,8 +53,8 @@ func TestMigrateAndRestart(t *testing.T) { if got := second.Applied(); len(got) != 0 { t.Errorf("повторный старт применил %v, ожидалось ничего", got) } - if got := version(t, second); got != 1 { - t.Errorf("user_version после перезапуска: получено %d, ожидалась 1", got) + if got := version(t, second); got != len(want) { + t.Errorf("user_version после перезапуска: получено %d, ожидалась %d", got, len(want)) } } @@ -124,7 +125,7 @@ func TestUsersAndSessions(t *testing.T) { } // Удаление пользователя уносит сессии каскадом. - if err := s.DeleteUser(ctx, "marta"); err != nil { + if _, err := s.DeleteUser(ctx, "marta"); err != nil { t.Fatalf("DeleteUser: %v", err) } if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) { diff --git a/internal/store/users.go b/internal/store/users.go index 2e38fe5..21d23b2 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -104,16 +104,104 @@ func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, b return nil } -// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство -// и очереди уносит каскад. +// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство, +// ключи комнат и очереди уносит каскад. // -// Комнаты, где пользователь владелец, каскадом не удаляются: rooms.owner -// ссылается на users(nick) без ON DELETE, и удаление такого пользователя -// упрётся в внешний ключ. Передача владения и удаление пустых комнат — -// ADR-018, этап 3; до появления комнат случай не наступает. -func (s *Store) DeleteUser(ctx context.Context, nick string) error { - if _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { - return fmt.Errorf("store: удаление пользователя: %w", err) +// Удаление аккаунта — выход из всех его комнат (ADR-041): по составу это +// тот же уход участника, что и POST /api/rooms/{id}/leave. Членство и ключи +// убираются до каскада, чтобы собрать оставшихся с их устройствами и +// текущими ключами; владение переходит участнику с наименьшим joined_at, +// опустевшая комната удаляется, у остальных выставляется needs_rekey. +// Всё вместе — одна транзакция; события рассылает обработчик после неё. +func (s *Store) DeleteUser(ctx context.Context, nick string) ([]RoomChange, error) { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) } - return nil + defer tx.Rollback() + + rooms, err := memberRooms(ctx, tx, nick) + if err != nil { + return nil, err + } + var changes []RoomChange + for _, id := range rooms { + room, err := roomRow(ctx, tx, id) + if err != nil { + return nil, err + } + change, err := leaveRoom(ctx, tx, room, nick) + if err != nil { + return nil, err + } + // Комната опустела и удалена — рассылать некому. + if len(change.Members) > 0 { + changes = append(changes, change) + } + } + // Владелец всегда состоит в своей комнате: из состава его не убрать, + // а выход передаёт владение (ADR-018), — так что здесь уже пусто. + // Проверка остаётся ради внешнего ключа: rooms.owner ссылается на + // users(nick) без ON DELETE, и забытая строка заперла бы удаление. + owned, err := ownedRooms(ctx, tx, nick) + if err != nil { + return nil, err + } + for _, room := range owned { + var heir string + err := tx.QueryRowContext(ctx, ` + SELECT nick FROM room_members + WHERE room_id = ? AND nick <> ? ORDER BY joined_at, nick LIMIT 1`, room, nick).Scan(&heir) + if errors.Is(err, sql.ErrNoRows) { + if _, err := tx.ExecContext(ctx, `DELETE FROM rooms WHERE id = ?`, room); err != nil { + return nil, fmt.Errorf("store: удаление пустой комнаты: %w", err) + } + continue + } + if err != nil { + return nil, fmt.Errorf("store: передача владения: %w", err) + } + if _, err := tx.ExecContext(ctx, `UPDATE rooms SET owner = ? WHERE id = ?`, heir, room); err != nil { + return nil, fmt.Errorf("store: передача владения: %w", err) + } + } + if _, err := tx.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) + } + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("store: удаление пользователя: %w", err) + } + return changes, nil +} + +// ownedRooms — комнаты, где пользователь владелец. +func ownedRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) { + return roomIDs(ctx, tx, `SELECT id FROM rooms WHERE owner = ? ORDER BY created_at, id`, nick) +} + +// memberRooms — комнаты, где пользователь участник. +func memberRooms(ctx context.Context, tx *sql.Tx, nick string) ([]string, error) { + return roomIDs(ctx, tx, `SELECT room_id FROM room_members WHERE nick = ? ORDER BY joined_at, room_id`, nick) +} + +// roomIDs — идентификаторы комнат по запросу с одним параметром. +func roomIDs(ctx context.Context, tx *sql.Tx, query, nick string) ([]string, error) { + rows, err := tx.QueryContext(ctx, query, nick) + if err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + defer rows.Close() + + var out []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + out = append(out, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("store: комнаты пользователя: %w", err) + } + return out, nil } diff --git a/web/app.css b/web/app.css index 550c4a6..3bb9a83 100644 --- a/web/app.css +++ b/web/app.css @@ -393,7 +393,20 @@ input[type="password"] { font-size: 15px; } +/* имя комнаты бывает длиной в 64 символа (ADR-018): в шапке оно + обрезается, как и в строке сайдбара, — иначе страница едет вбок */ + +.head .chat-title, +.head .title { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .back { + flex: none; min-height: 32px; padding: 6px 10px; border: 1px solid var(--edge); @@ -444,12 +457,73 @@ input[type="password"] { margin-top: 14px; } +.fp-label { + margin: 12px 0 4px; + font-size: 11px; + color: var(--stone); +} + +.block--first > .fp-label:first-child { + margin-top: 0; +} + .fp-hint { margin: 10px 0 0; font-size: 11px; color: var(--mute); } +/* участники комнаты — docs/ui.md, «Участники» */ + +.members { + margin: 0; + padding: 0; + list-style: none; +} + +.member { + display: flex; + align-items: center; + gap: 10px; + min-height: 28px; + font-size: 13px; + color: var(--text2); +} + +.member__name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.member .tag { + color: var(--stone); + font-size: 11px; +} + +.member .link { + margin-left: auto; + color: var(--mute); + font-size: 12px; +} + +/* строка ввода как отдельная форма: «новый чат» и «добавить участника» */ + +.form--row + .form--row { + margin-top: 12px; +} + +.form--row .button { + margin-top: 10px; +} + +/* две кнопки подряд не слипаются в двойную рамку */ + +.block > .button + .button { + margin-top: 10px; +} + /* чат: шапка, лента, ввод — docs/identity/screens.html */ .chat-title { @@ -663,6 +737,13 @@ input[type="password"] { cursor: pointer; } +/* ввод заблокирован предупреждением о ключе: кнопка «>» тоже гаснет */ + +.input__send[disabled] { + color: var(--stone); + cursor: default; +} + .counter, .enter { flex: none; @@ -675,15 +756,24 @@ input[type="password"] { margin-left: 0; } -/* полоса над вводом: нет соединения — stone, отказ отправки — mark */ +/* полоса над вводом: нет соединения — stone, отказ отправки и смена + ключа — mark. У полосы про ключ есть кнопка, поэтому строка — flex */ .bar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0 8px; margin: 0 0 10px; font-size: 11px; line-height: 1.5; color: var(--stone); } +.bar[hidden] { + display: none; +} + .bar--mark { color: var(--mark); } diff --git a/web/js/api.js b/web/js/api.js index 16057c4..d2f13d2 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -181,6 +181,40 @@ export function removeContact(nick) { return request("DELETE", `/api/contacts/${encodeURIComponent(nick)}`); } +// --- комнаты ----------------------------------------------------------- + +// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым +// ключом (docs/protocol.md, «Комнаты»). +export function rooms() { + return request("GET", "/api/rooms"); +} + +// createRoom заводит комнату. Идентификатор генерирует клиент: ключ +// заворачивается до запроса и привязан к roomId (ADR-037). Занятый +// идентификатор — 409 room_conflict, берётся новый. +// +// X-Device передаётся, чтобы это же устройство не получило комнату ещё +// и событием: она приходит ответом (docs/protocol.md, «Комнаты»). +export function createRoom(device, body) { + return request("POST", "/api/rooms", body, { device }); +} + +// changeMembers — смена состава и rekey одним запросом (ADR-018). +export function changeMembers(id, body) { + return request("POST", `/api/rooms/${encodeURIComponent(id)}/members`, body); +} + +// leaveRoom — выход из комнаты. X-Device передаётся по той же причине, +// что и при создании: комната уходит из списка здесь же, а другим +// устройствам вышедшего сервер шлёт room_left (ADR-041). +export function leaveRoom(device, id) { + return request("POST", `/api/rooms/${encodeURIComponent(id)}/leave`, undefined, { device }); +} + +export function removeRoom(id) { + return request("DELETE", `/api/rooms/${encodeURIComponent(id)}`); +} + // --- сообщения --------------------------------------------------------- // sendMessage отдаёт конверт серверу; from и ts он поставит сам (ADR-017). @@ -208,6 +242,10 @@ export function ack(device, ids) { export function stream(device, handlers) { const source = new EventSource(`/api/events?device=${encodeURIComponent(device)}`); source.addEventListener("msg", (event) => handlers.msg(parse(event.data))); + // room и room_left в очередь не кладутся: пропуск во время офлайна + // чинится перечитыванием GET /api/rooms после ready (docs/protocol.md). + source.addEventListener("room", (event) => handlers.room(parse(event.data))); + source.addEventListener("room_left", (event) => handlers.roomLeft(parse(event.data))); source.addEventListener("ready", () => handlers.ready()); source.addEventListener("error", () => handlers.error(source.readyState === EventSource.CLOSED)); return () => source.close(); diff --git a/web/js/crypto.js b/web/js/crypto.js index f5322b5..3862032 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -14,6 +14,8 @@ const INFO_AUTH = "bare-auth-v1"; const INFO_KEK = "bare-kek-v1"; const BLOB_AAD = "bare-blob-v1|"; const DM_SALT = "bare-dm-v1"; +const WRAP_SALT = "bare-wrap-v1"; +const ROOM_AAD = "bare-roomkey-v1|"; const MSG_AAD = "bare-msg-v1|"; // DM_KEY_ID — keyId личного чата: ключ выводится из ECDH, отдельного @@ -24,6 +26,10 @@ export const DM_KEY_ID = "dm"; export const SECRET_LEN = 32; const IV_LEN = 12; +// ROOM_KEY_LEN — ключ комнаты: 32 случайных байта (docs/crypto.md, +// «Комната»). +export const ROOM_KEY_LEN = 32; + // ID_LEN — deviceId, keyId и roomId устроены одинаково: 16 случайных // байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»). const ID_LEN = 16; @@ -290,6 +296,90 @@ export async function dmKey(privateKey, peerPublicJwk, me, peer) { ); } +// --- комната ----------------------------------------------------------- + +// roomLabel — метка комнаты для AAD сообщения: "room:" + roomId. Совпадает +// с ключом чата в IndexedDB, но собирается здесь: crypto.js не знает о базе. +export function roomLabel(roomId) { + return `room:${roomId}`; +} + +// newRoomKey — новый ключ комнаты: 32 случайных байта и случайный keyId +// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания, +// потом распространитель импортирует их себе non-extractable и затирает. +export function newRoomKey() { + return { keyId: newId(), bytes: random(ROOM_KEY_LEN) }; +} + +// importRoomKey — ключ комнаты как non-extractable AES-GCM-256. Наружу +// он больше не выходит: в IndexedDB кладётся объект CryptoKey. +export function importRoomKey(bytes) { + return subtle.importKey("raw", bytes, { name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]); +} + +// roomAad и wrapKey — ровно то, что написано в docs/crypto.md, +// «Заворачивание участнику». Заворачивание самому себе идёт этим же кодом: +// ECDH(myPrivate, myPublic), без исключений. +function roomAad({ roomId, keyId, from, to }) { + return utf8(`${ROOM_AAD}${roomId}|${keyId}|${from}|${to}`); +} + +async function wrapKey(privateKey, peerPublicJwk, roomId, keyId) { + const publicKey = await importPublic(peerPublicJwk); + const shared = await subtle.deriveBits({ name: "ECDH", public: publicKey }, privateKey, 256); + const material = await subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); + wipe(new Uint8Array(shared)); + return subtle.deriveKey( + { + name: "HKDF", + hash: "SHA-256", + salt: utf8(WRAP_SALT), + info: utf8(`${ROOM_AAD}${roomId}|${keyId}`), + }, + material, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +// wrapRoomKey заворачивает сырые байты ключа комнаты участнику. Отдаёт +// запись keys[] запроса: {to, iv, ct} (docs/protocol.md, «Типы»). +export async function wrapRoomKey(privateKey, memberPublicJwk, { roomId, keyId, from, to }, roomKey) { + const key = await wrapKey(privateKey, memberPublicJwk, roomId, keyId); + const iv = random(IV_LEN); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: roomAad({ roomId, keyId, from, to }) }, + key, + roomKey, + ); + return { to, iv: b64url(iv), ct: b64url(ct) }; +} + +// unwrapRoomKey разворачивает завёрнутый нам ключ той же схемой и сразу +// импортирует его non-extractable: сырые байты дальше не идут. +// Публичный ключ отправителя проходит через TOFU до вызова (ADR-016). +export async function unwrapRoomKey(privateKey, senderPublicJwk, { roomId, keyId, from, to, iv, ct }) { + const key = await wrapKey(privateKey, senderPublicJwk, roomId, keyId); + const nonce = unb64url(iv); + if (nonce.length !== IV_LEN) { + throw new Error("iv — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv: nonce, additionalData: roomAad({ roomId, keyId, from, to }) }, + key, + unb64url(ct), + ); + const bytes = new Uint8Array(plain); + if (bytes.length !== ROOM_KEY_LEN) { + wipe(bytes); + throw new Error("ключ комнаты — не 32 байта"); + } + const imported = await importRoomKey(bytes); + wipe(bytes); + return imported; +} + // --- сообщение --------------------------------------------------------- // messageAad привязывает открытые поля конверта к шифротексту: подмена diff --git a/web/js/db.js b/web/js/db.js index 090acc7..3539801 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -118,9 +118,14 @@ export function peerOf(chatId) { return chatId.startsWith(DM) ? chatId.slice(DM.length) : null; } +// roomIdOf — какая комната; у личного чата комнаты нет. +export function roomIdOf(chatId) { + return chatId.startsWith(ROOM) ? chatId.slice(ROOM.length) : null; +} + // blankChat — пустая запись чата по её ключу. title — имя без «@» и «#»: -// сигил ставит экран. Комнате имя приходит из GET /api/rooms (этап 3), -// до этого вместо имени стоит идентификатор. +// сигил ставит экран. Комнате имя, владелец и состав приходят из +// GET /api/rooms и события room; до этого вместо имени стоит идентификатор. export function blankChat(id) { const base = { id, title: "", lastId: null, lastReadId: null, unread: 0, hidden: false }; const peer = peerOf(id); @@ -161,6 +166,39 @@ export function putChat(record) { return put("chats", record); } +// mergeChat правит поля чата, не трогая ленту и счётчики: чтение и запись +// одной транзакцией, чтобы не разъехаться с параллельным markRead. +// Недостающая запись заводится. Отдаёт, изменилось ли что-нибудь. +export async function mergeChat(id, patch) { + const db = await open(); + const tx = db.transaction("chats", "readwrite"); + const store = tx.objectStore("chats"); + const known = await value(store.get(id)); + const record = known ?? blankChat(id); + // Новую запись пишем всегда: она могла совпасть с пустой по всем полям + // патча и осталась бы ненаписанной. + let changed = known === undefined; + for (const [key, next] of Object.entries(patch)) { + if (same(record[key], next)) { + continue; + } + record[key] = next; + changed = true; + } + if (changed) { + store.put(record); + } + await done(tx); + return changed; +} + +function same(a, b) { + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((item, i) => item === b[i]); + } + return a === b; +} + // markRead — чат прочитан: счётчик обнуляется, граница «новых» уезжает // к последнему сообщению. Обе величины локальные, на сервер не уходят // (docs/storage.md). @@ -307,6 +345,23 @@ export async function pendingMessages() { return out; } +// undecryptable — всё, что не удалось расшифровать и что хранит raw для +// повторной попытки (docs/storage.md). Индекса по этому в схеме нет, значит +// проход курсором; попытка повторяется редко: при подтверждении ключа +// и при появлении недостающего ключа комнаты. +export async function undecryptable() { + const db = await open(); + const store = db.transaction("messages", "readonly").objectStore("messages"); + const out = []; + await cursor(store.openCursor(), (record) => { + if (record.undecryptable && record.raw) { + out.push(record); + } + return true; + }); + return out; +} + // cursor обходит курсор, пока step не скажет «хватит». function cursor(request, step) { return new Promise((resolve, reject) => { @@ -322,10 +377,59 @@ function cursor(request, step) { }); } +// --- ключи комнат ------------------------------------------------------- + +// Ключ хранилища roomKeys — [roomId, keyId]; массив больше любой строки, +// поэтому [roomId, []] — верхняя граница всех ключей комнаты, а [roomId] — +// нижняя (тот же приём, что и в индексе "chat"). +function roomRange(roomId) { + return IDBKeyRange.bound([roomId], [roomId, []]); +} + +export function roomKey(roomId, keyId) { + return get("roomKeys", [roomId, keyId]); +} + +// roomKeysOf — все ключи комнаты по возрастанию receivedAt: последний +// и есть текущий (docs/storage.md, ADR-042). +export async function roomKeysOf(roomId) { + const db = await open(); + const store = db.transaction("roomKeys", "readonly").objectStore("roomKeys"); + const list = await value(store.getAll(roomRange(roomId))); + return list.sort((a, b) => a.receivedAt - b.receivedAt); +} + +// saveRoomKey кладёт ключ комнаты, если такого ещё нет; отдаёт, случилось ли +// это. Клиент держит все ключи комнаты и расшифровывает любым известным +// (ADR-018), поэтому уже сохранённый ключ не перезаписывается. +// +// receivedAt строго больше времени всех прежних ключей комнаты: текущий ключ +// — последний полученный этим устройством, а два rekey подряд укладываются +// в одну миллисекунду (ADR-042, docs/storage.md). +export async function saveRoomKey({ roomId, keyId, key, from }) { + const db = await open(); + const tx = db.transaction("roomKeys", "readwrite"); + const store = tx.objectStore("roomKeys"); + const list = await value(store.getAll(roomRange(roomId))); + let receivedAt = Date.now(); + let known = false; + for (const record of list) { + known = known || record.keyId === keyId; + if (record.receivedAt >= receivedAt) { + receivedAt = record.receivedAt + 1; + } + } + if (!known) { + store.put({ roomId, keyId, key, from, receivedAt }); + } + await done(tx); + return !known; +} + // --- собеседники -------------------------------------------------------- // peers — доверие к ключам, TOFU (ADR-016). Запись заводится при первом -// получении ключа; сверка изменившегося ключа и pending — этап 3. +// получении ключа; изменившийся ключ ложится в pending и ждёт подтверждения. export function peer(nick) { return get("peers", nick); } diff --git a/web/js/main.js b/web/js/main.js index 9a38e18..c8453ee 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -26,6 +26,7 @@ import { DESKTOP, clear, wide } from "./ui/dom.js"; import { renderAuth } from "./ui/auth.js"; import { renderChat } from "./ui/chat.js"; import { renderContact } from "./ui/contact.js"; +import { renderMembers } from "./ui/members.js"; import { renderNew } from "./ui/new.js"; import { renderSettings } from "./ui/settings.js"; import { frame } from "./ui/shell.js"; @@ -69,9 +70,11 @@ const ctx = { // --- роутинг ----------------------------------------------------------- -// route разбирает hash. Маршруты — docs/ui.md, «Каркас»; комнаты придут -// на этапе 3, до тех пор `#/room/…` — неизвестный путь и ведёт в список. +// route разбирает hash. Маршруты — docs/ui.md, «Каркас». Ник — форма +// ADR-019, идентификатор комнаты — 16 случайных байт base64url +// (docs/crypto.md, «Идентификаторы»). Всё, что не разобралось, — список. const NICK_ROUTE = /^#\/(dm|contact)\/([a-z0-9_]{2,32})$/; +const ROOM_ROUTE = /^#\/room\/([A-Za-z0-9_-]{22})(\/members)?$/; function route() { const hash = location.hash || "#/"; @@ -85,6 +88,10 @@ function route() { if (nick) { return { kind: nick[1], nick: nick[2] }; } + const room = ROOM_ROUTE.exec(hash); + if (room) { + return { kind: room[2] ? "members" : "room", roomId: room[1] }; + } return { kind: "root" }; } @@ -101,7 +108,14 @@ async function render() { } const where = route(); // На десктопе `#/` показывает первый чат — тот, что вверху списка. - let chatId = where.kind === "dm" ? sync.dmChatId(where.nick) : null; + // У экранов «карточка контакта» и «участники» открытого чата нет: + // в сайдбаре не выделен никто. + let chatId = null; + if (where.kind === "dm") { + chatId = sync.dmChatId(where.nick); + } else if (where.kind === "room") { + chatId = sync.roomChatId(where.roomId); + } if (where.kind === "root" && wide()) { const list = await sync.chats().catch(() => []); if (mine !== state.paint) { @@ -123,7 +137,9 @@ async function render() { } else if (where.kind === "new") { renderNew(main, ctx); } else if (where.kind === "contact") { - renderContact(main, ctx, where.nick); + parts.push(renderContact(main, ctx, where.nick)); + } else if (where.kind === "members") { + parts.push(renderMembers(main, ctx, where.roomId)); } else if (chatId !== null) { parts.push(renderChat(main, ctx, chatId)); } diff --git a/web/js/sync.js b/web/js/sync.js index a78656f..870207c 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -2,11 +2,17 @@ // Экраны берут отсюда данные и сюда же отдают действия; в db.js и api.js // они не ходят — пишет в базу только этот модуль. // -// Правила — docs/protocol.md («События», «Сообщения») и docs/storage.md: -// ACK уходит только после успешной записи в IndexedDB, исходящее живёт -// в pending до 202 и держится за свой ULID, пока время в нём годится -// серверу; отвергнутый по часам переиспользованный id меняется на свежий -// один раз (ADR-036). +// Правила — docs/protocol.md («События», «Сообщения», «Комнаты») +// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB, +// исходящее живёт в pending до 202 и держится за свой ULID, пока время +// в нём годится серверу; отвергнутый по часам переиспользованный id +// меняется на свежий один раз (ADR-036). +// +// Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший +// от сервера, сверяется с запомненным; изменившийся ложится в pending +// и блокирует отправку до подтверждения. Ключи комнат — docs/crypto.md, +// «Комната»: владелец заворачивает новый ключ каждому участнику, клиент +// держит все ключи комнаты и расшифровывает любым известным. import * as api from "./api.js"; import { ApiError, NetworkError } from "./api.js"; @@ -16,9 +22,15 @@ import { dmKey, dmLabel, fingerprintOf, + importRoomKey, newId, + newRoomKey, openMessage, + roomLabel, sealMessage, + unwrapRoomKey, + wipe, + wrapRoomKey, } from "./crypto.js"; import { ulid, ulidTime, validUlid } from "./ulid.js"; @@ -41,6 +53,9 @@ const state = { running: false, nick: null, privateKey: null, + // Свой публичный ключ: он проверен при входе, и спрашивать его у сервера + // незачем — заворачивание себе идёт по нему (docs/crypto.md, «Комната»). + publicKey: null, device: null, close: null, // закрыть поток событий online: false, @@ -53,6 +68,17 @@ const state = { // Ключи личных чатов — только в памяти: в IndexedDB они не пишутся, // а выводятся заново из peers (docs/crypto.md, «Чат 1:1»). keys: new Map(), + // Ключи комнат: "|" → CryptoKey. Это кэш над хранилищем + // roomKeys, где ключи и живут. + roomKeys: new Map(), + // Комнаты, где rekey упёрся в неподтверждённый ключ: roomId → ники + // (ADR-016). Состояние экрана, в базу не пишется. + blocked: new Map(), + // Комнаты, которым мы должны новый ключ: участник вышел, а rekey + // не прошёл. Долг поднимается и из события room, и из GET /api/rooms: + // это состояние комнаты, и офлайн владельца его не теряет (ADR-041). + // Отдаётся после ready и после подтверждения ключа (ADR-018). + owed: new Set(), // Неотправленное. Полный проход по messages делается один раз при // старте: индекса по статусу в схеме нет (docs/storage.md). pending: new Set(), @@ -68,12 +94,16 @@ const state = { const bus = new EventTarget(); -// on подписывает обработчик и отдаёт функцию отписки. События три: +// on подписывает обработчик и отдаёт функцию отписки. События пять: // // "net" {online} — доходят ли запросы до сервера // "chats" {} — список чатов изменился // "messages" {chatId, ids, removed} — в чате появились, изменились // или исчезли сообщения +// "peers" {nick} — доверие к ключу ника изменилось: +// появился pending или его подтвердили +// "rooms" {id} — комната изменилась: имя, состав, +// ключ, потребность в rekey // // removed непуст, только когда повтор отправки выдал сообщению новый // ULID: старую запись из ленты надо убрать. Обычный повтор идёт с прежним @@ -129,6 +159,21 @@ function announceChats() { share({ kind: "chats" }); } +// announcePeer — доверие к ключу ника изменилось: карточке контакта нужен +// новый отпечаток, чату — полоса про смену ключа (docs/ui.md). +function announcePeer(nick) { + emit("peers", { nick }); + share({ kind: "peers", nick }); +} + +// announceRoom — комната изменилась: экрану участников нужен свежий состав +// и знание, чей ключ мешает rekey. Список ников едет вместе с событием: +// он живёт в памяти вкладки, а в базе его нет. +function announceRoom(id) { + emit("rooms", { id }); + share({ kind: "rooms", id, blocked: needsTrust(id) }); +} + // --- соседние вкладки --------------------------------------------------- // share отдаёт изменение соседним вкладкам. Канал открыт, только пока @@ -172,6 +217,31 @@ function receive(data) { case "chats": emit("chats"); return; + case "peers": + if (typeof data.nick === "string") { + // Ключ личного чата выведен из ключа собеседника и лежит в памяти + // этой вкладки: доверие изменилось — выводим заново из peers + // (docs/crypto.md, «Чат 1:1»). Без этого вкладка продолжила бы + // шифровать ключом, который человек только что отверг. + state.keys.delete(data.nick); + // Сохранённые raw перебирает владелец потока: конверты, разобранные + // устаревшим ключом до сброса, иначе остались бы нерасшифрованными. + if (state.release) { + serial(reopen); + } + emit("peers", { nick: data.nick }); + } + return; + case "rooms": + if (typeof data.id === "string") { + if (Array.isArray(data.blocked) && data.blocked.length > 0) { + state.blocked.set(data.id, data.blocked); + } else { + state.blocked.delete(data.id); + } + emit("rooms", { id: data.id }); + } + return; case "pending": // Соседняя вкладка не отправила сообщение и повторять его не будет: // повторяет владелец потока. @@ -202,16 +272,17 @@ export async function start() { } let meta; try { - meta = await db.meta(["nick", "privateKey"]); + meta = await db.meta(["nick", "privateKey", "publicKey"]); } catch { return; } - if (!meta.nick || !meta.privateKey) { + if (!meta.nick || !meta.privateKey || !meta.publicKey) { return; } state.running = true; state.nick = meta.nick; state.privateKey = meta.privateKey; + state.publicKey = meta.publicKey; openChannel(); try { for (const m of await db.pendingMessages()) { @@ -242,8 +313,12 @@ export function stop() { closeChannel(); state.nick = null; state.privateKey = null; + state.publicKey = null; state.device = null; state.keys.clear(); + state.roomKeys.clear(); + state.blocked.clear(); + state.owed.clear(); state.pending.clear(); state.inbox.length = 0; state.wait = RETRY_MIN; @@ -372,6 +447,18 @@ function openStream() { schedule(); } }, + // Комнаты разбираются в общей очереди работ: приём сообщений и раздача + // ключей не должны перемешиваться. + room: (room) => { + if (room) { + serial(() => applyRoom(room)); + } + }, + roomLeft: (data) => { + if (typeof data?.id === "string") { + serial(() => forgetRoom(data.id)); + } + }, ready: () => { state.wait = RETRY_MIN; setOnline(true); @@ -544,25 +631,46 @@ function usable(e) { // не разобрать по причине, которая пройдёт»: конверт остаётся и у нас, // и в очереди сервера — ACK по нему не уходит. Ошибка AEAD // и неизвестный keyId причиной не являются — -// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение»). +// сообщение сохраняется нерасшифрованным (docs/crypto.md, «Сообщение») +// вместе с raw: по нему попытка повторяется, когда ключ появится +// или когда новый ключ собеседника подтвердят (docs/storage.md). async function decode(envelope) { const me = state.nick; - const peer = envelope.to.dm + const room = typeof envelope.to.room === "string" ? envelope.to.room : null; + const peer = room === null ? (envelope.from === me ? envelope.to.dm : envelope.from) : null; const base = { id: envelope.id, - chatId: peer === null ? db.roomChatId(envelope.to.room) : db.dmChatId(peer), + chatId: room === null ? db.dmChatId(peer) : db.roomChatId(room), from: envelope.from, text: null, ts: envelope.ts, status: "sent", }; - // Комнаты — этап 3: ключа комнаты на устройстве ещё нет. - if (peer === null || envelope.keyId !== DM_KEY_ID) { - return { ...base, undecryptable: "unknown_key", raw: envelope }; + + if (room !== null) { + // Комната расшифровывается любым известным ключом по keyId конверта: + // клиент держит все ключи комнаты (ADR-018). + let key; + try { + key = await roomKeyOf(room, envelope.keyId); + } catch { + return null; + } + if (key === null) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } + try { + return { ...base, text: await openMessage(key, { ...envelope, chat: roomLabel(room) }) }; + } catch { + return { ...base, undecryptable: "bad_aead", raw: envelope }; + } } + if (envelope.keyId !== DM_KEY_ID) { + return { ...base, undecryptable: "unknown_key", raw: envelope }; + } let key; try { key = await chatKey(peer); @@ -577,12 +685,45 @@ async function decode(envelope) { const text = await openMessage(key, { ...envelope, chat: dmLabel(me, peer) }); return { ...base, text }; } catch { - // Смену ключа собеседника разбирает TOFU (ADR-016) — этап 3; - // до тех пор любая неудача AEAD выглядит одинаково. - return { ...base, undecryptable: "bad_aead", raw: envelope }; + // Расшифровка идёт доверенным ключом. Не сошлось, а у ника ждёт + // подтверждения новый, — сообщение зашифровано им (ADR-016). + const known = await db.peer(peer).catch(() => null); + return { + ...base, + undecryptable: known?.pending ? "key_changed" : "bad_aead", + raw: envelope, + }; } } +// reopen — повторная расшифровка сохранённого raw: пришёл недостающий ключ +// комнаты или подтверждён новый ключ собеседника (docs/storage.md). +// Записи свои, а не входящие: перезапись по id здесь законна (ADR-034). +async function reopen() { + let list; + try { + list = await db.undecryptable(); + } catch { + return; + } + const messages = []; + for (const record of list) { + if (!usable(record.raw)) { + continue; + } + const fresh = await decode(record.raw); + if (fresh === null || fresh.text === null) { + continue; + } + messages.push(fresh); + } + if (messages.length === 0) { + return; + } + await db.saveMessages({ messages, me: state.nick }); + notify(messages); +} + async function ackAll(ids) { for (let i = 0; i < ids.length; i += api.MAX_ACK) { try { @@ -598,10 +739,13 @@ async function ackAll(ids) { // --- после ready -------------------------------------------------------- // afterReady — очередь выдана целиком. Клиент перечитывает контакты -// и повторяет неотправленное (docs/ui.md, «Сеть и состояния»). -// Комнаты — этап 3. +// и комнаты и повторяет неотправленное (docs/ui.md, «Сеть и состояния»): +// события room и room_left в очередь не кладутся, и пропущенное во время +// офлайна восстанавливается только этим (docs/protocol.md, «События»). async function afterReady() { await refreshContacts(); + await refreshRooms(); + await payRekeys(); await retryPending(); } @@ -614,7 +758,9 @@ async function refreshContacts() { } let changed = false; for (const contact of list) { - await rememberPeer(contact.nick, contact.publicKey, contact.createdAt); + // Список контактов — главное место сверки TOFU: ключи всех собеседников + // приходят от сервера после каждого ready (ADR-016). + await seePeer(contact.nick, contact.publicKey, contact.createdAt).catch(() => {}); const chatId = db.dmChatId(contact.nick); if (!(await db.chat(chatId))) { await db.putChat(db.blankChat(chatId)); @@ -646,7 +792,8 @@ async function retryPending() { // --- собеседники -------------------------------------------------------- -// chatKey — ключ личного чата из памяти или выведенный заново. +// chatKey — ключ личного чата из памяти или выведенный заново. Выводится +// он из доверенного ключа: ждущий подтверждения в дело не идёт (ADR-016). async function chatKey(peer) { const cached = state.keys.get(peer); if (cached) { @@ -658,43 +805,566 @@ async function chatKey(peer) { return key; } -// knownPeer — запись TOFU. Ключа нет — берём у сервера и запоминаем -// как есть: сверка изменившегося ключа — этап 3 (ADR-016). +// knownPeer — запись TOFU. Ника ещё нет — берём ключ у сервера: первый +// ключ запоминается молча, trust on first use (ADR-016). async function knownPeer(nick) { const known = await db.peer(nick); if (known) { return known; } const user = await api.user(nick); - return rememberPeer(user.nick, user.publicKey); + return seePeer(user.nick, user.publicKey); } -// rememberPeer запоминает ключ при первом контакте. Уже знакомый ник -// не трогается: смена ключа — состояние, а не перезапись (ADR-016). -async function rememberPeer(nick, publicKey, firstSeen = Date.now()) { +// seePeer — сверка TOFU. Зовётся при каждом получении публичного ключа ника, +// откуда бы он ни пришёл: GET /api/users, список контактов, состав комнаты, +// отправитель завёрнутого ключа (ADR-016). +// +// Первый ключ ника запоминается молча. Совпавший — ничего не меняет. +// Изменившийся ложится в pending: отправка этому нику блокируется, входящее +// его ключом остаётся нерасшифрованным, rekey ему не выполняется — всё +// до явного «доверять новому ключу». +// +// Сервер, вернувшийся к доверенному ключу, снимает pending: смены не +// случилось, а подтверждать было бы уже отозванный ключ (ADR-040, +// docs/ui.md, «Карточка контакта»). +async function seePeer(nick, publicKey, firstSeen = Date.now()) { + const fingerprint = await fingerprintOf(publicKey); const known = await db.peer(nick); - if (known) { + if (!known) { + const record = { nick, publicKey, fingerprint, firstSeen, pending: null }; + await db.putPeer(record); + return record; + } + if (known.fingerprint === fingerprint) { + if (!known.pending) { + return known; + } + const record = { ...known, pending: null }; + await db.putPeer(record); + announcePeer(nick); + return record; + } + if (known.pending?.fingerprint === fingerprint) { return known; } - const record = { - nick, - publicKey, - fingerprint: await fingerprintOf(publicKey), - firstSeen, - pending: null, - }; + const record = { ...known, pending: { publicKey, fingerprint, seenAt: Date.now() } }; await db.putPeer(record); + announcePeer(nick); return record; } +// trustKey — «доверять новому ключу» из карточки контакта (docs/ui.md). +// Ключ из pending становится основным, pending чистится, и всё, что +// упиралось в старый ключ, повторяется: завёрнутые ключи комнат от этого +// ника, сохранённые raw и неотправленное. +// +// Отдаёт, было ли что подтверждать. +export function trustKey(nick) { + return serial(async () => { + if (!state.running) { + return false; + } + const known = await db.peer(nick); + if (!known?.pending) { + return false; + } + await db.putPeer({ + nick: known.nick, + publicKey: known.pending.publicKey, + fingerprint: known.pending.fingerprint, + // firstSeen — когда ник встретился впервые, а не когда сменил ключ. + firstSeen: known.firstSeen, + pending: null, + }); + // Ключ личного чата выводится из ключа собеседника — выводим заново. + state.keys.delete(nick); + announcePeer(nick); + await refreshRooms(); + // Владелец, чей rekey упирался в этот ключ, доводит его до конца. + await payRekeys(); + await reopen(); + await retryPending(); + return true; + }); +} + +// --- комнаты ------------------------------------------------------------ + +// TrustNeeded — rekey не выполняется участнику с изменившимся и +// неподтверждённым ключом (ADR-016). nicks — чьи ключи ждут подтверждения; +// владелец повторяет операцию после «доверять новому ключу». +export class TrustNeeded extends Error { + constructor(nicks) { + super("нужно подтвердить ключ"); + this.name = "TrustNeeded"; + this.nicks = nicks; + } +} + +// needsTrust — чьи ключи мешают rekey комнаты (docs/ui.md, «Участники»). +export function needsTrust(roomId) { + return state.blocked.get(roomId) ?? []; +} + +// usableRoom и usableKey — форма Room и завёрнутого ключа +// (docs/protocol.md, «Типы»). Сервер её держит, но записи собираются +// из этих полей, и мусор до базы не доходит. +function usableRoom(r) { + return r !== null && typeof r === "object" + && typeof r.id === "string" && r.id !== "" + && typeof r.name === "string" + && typeof r.owner === "string" + && Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string") + && Number.isFinite(r.createdAt) + && (r.key === null || r.key === undefined || usableKey(r.key)); +} + +function usableKey(k) { + return k !== null && typeof k === "object" + && typeof k.keyId === "string" && k.keyId !== "" + && typeof k.from === "string" + && typeof k.iv === "string" && typeof k.ct === "string"; +} + +// roomKeyOf — ключ комнаты по keyId конверта; null, если такого нет. +async function roomKeyOf(roomId, keyId) { + const at = `${roomId}|${keyId}`; + const cached = state.roomKeys.get(at); + if (cached) { + return cached; + } + const record = await db.roomKey(roomId, keyId); + if (!record) { + return null; + } + state.roomKeys.set(at, record.key); + return record.key; +} + +// currentKeyId — текущий ключ комнаты: последний полученный этим +// устройством. Им шифруется исходящее. Порядок — получения, а не сервера: +// номера ключа протокол не несёт, и в гонке двух rekey эти порядки могут +// разойтись (ADR-042). Оба ключа при этом живы, сервер принимает любой. +async function currentKeyId(roomId) { + const list = await db.roomKeysOf(roomId); + return list.length > 0 ? list[list.length - 1].keyId : null; +} + +// saveRoom кладёт комнату в список чатов. Имя, владелец и состав приходят +// от сервера; лента, счётчик непрочитанных и граница «новых» — местные. +function saveRoom(room) { + return db.mergeChat(db.roomChatId(room.id), { + type: "room", + roomId: room.id, + title: room.name, + owner: room.owner, + members: [...room.members], + // Комната в списке есть, пока мы её участники. + hidden: false, + }); +} + +// senderKey — публичный ключ того, кто завернул ключ комнаты. Свой берётся +// с устройства: он проверен при входе и от сервера не зависит. Чужой +// приходит от сервера и проходит через TOFU; ключ, ждущий подтверждения, — +// null: разворачивать им нельзя (ADR-016). +async function senderKey(nick) { + if (nick === state.nick) { + return state.publicKey; + } + const user = await api.user(nick); + const record = await seePeer(nick, user.publicKey); + return record.pending ? null : record.publicKey; +} + +// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его +// в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»). +// Отдаёт, появился ли новый ключ. +// +// Уже известный keyId не трогается: клиент держит все ключи комнаты. +// Не развернувшийся не теряется — сервер отдаёт его снова с каждым +// GET /api/rooms. +// +// Заворачивает ключ участник комнаты — владелец или тот, кто им был +// до передачи владения (ADR-018). Ключ от постороннего ника отвергается +// до запроса его публичного ключа: TOFU запоминает первый ключ молча, +// поэтому незнакомый распространитель — это подмена, а не первый +// контакт (ADR-039). +async function takeRoomKey(room) { + const wrapped = room.key; + if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) { + return false; + } + if (await db.roomKey(room.id, wrapped.keyId)) { + return false; + } + let publicKey; + try { + publicKey = await senderKey(wrapped.from); + } catch { + // Ключа отправителя сейчас не добыть: попробуем при следующем ready. + return false; + } + if (publicKey === null) { + return false; + } + let key; + try { + key = await unwrapRoomKey(state.privateKey, publicKey, { + roomId: room.id, + keyId: wrapped.keyId, + from: wrapped.from, + to: state.nick, + iv: wrapped.iv, + ct: wrapped.ct, + }); + } catch { + return false; + } + const stored = await db.saveRoomKey({ + roomId: room.id, + keyId: wrapped.keyId, + key, + from: wrapped.from, + }); + if (stored) { + state.roomKeys.set(`${room.id}|${wrapped.keyId}`, key); + } + return stored; +} + +// applyRoom разбирает событие room: создание, смена состава, rekey, выход +// участника (docs/protocol.md, «События»). +async function applyRoom(room) { + if (!state.running || !usableRoom(room)) { + return; + } + const changed = await saveRoom(room); + const fresh = await takeRoomKey(room); + if (changed) { + announceChats(); + } + announceRoom(room.id); + if (fresh) { + await reopen(); + await retryPending(); + } + // Участник вышел — комната осталась на ключе, который он знает. Новый + // раздаёт владелец тем же запросом с пустыми add и remove (ADR-018). + if (room.needsRekey === true && room.owner === state.nick) { + await rekey(room.id); + } +} + +// refreshRooms перечитывает комнаты после каждого ready: события room +// и room_left в очередь не кладутся (docs/protocol.md, «События»). +async function refreshRooms() { + // Список известных комнат читается до запроса: комната, заведённая + // соседней вкладкой, пока ответ летел, в него не попадёт, а прятать + // её нельзя — она есть и на сервере, и в базе (ADR-035). + let known; + try { + known = await db.chats(); + } catch { + known = []; + } + let list; + try { + list = await api.rooms(); + } catch { + return; + } + if (!Array.isArray(list)) { + return; + } + let changed = false; + let fresh = false; + const seen = new Set(); + for (const room of list) { + if (!usableRoom(room)) { + continue; + } + seen.add(room.id); + const moved = await saveRoom(room); + const key = await takeRoomKey(room); + changed = changed || moved; + fresh = fresh || key; + if (moved || key) { + announceRoom(room.id); + } + // Долг по ключу — состояние комнаты, а не свойство события (ADR-041): + // владелец поднимает его и после офлайна, и после перезагрузки вкладки. + // Отдаёт долг payRekeys — он идёт следом за refreshRooms. + if (room.needsRekey === true && room.owner === state.nick) { + state.owed.add(room.id); + } + } + // Комнату, из которой нас убрали, пока мы были офлайн, видно только так: + // события мы не получили, а в списке её больше нет. + for (const chat of known) { + if (chat.type === "room" && !seen.has(chat.roomId)) { + await db.hideChat(chat.id, true); + state.blocked.delete(chat.roomId); + state.owed.delete(chat.roomId); + announceRoom(chat.roomId); + changed = true; + } + } + if (changed) { + announceChats(); + } + // retryPending зовёт afterReady следом — второй раз не нужно. + if (fresh) { + await reopen(); + } +} + +// forgetRoom убирает комнату из списка: нас удалили, комната удалена или +// мы вышли сами. История на устройстве не трогается — она единственная +// копия, а новых сообщений в этой комнате нам уже не доставят. +async function forgetRoom(roomId) { + state.blocked.delete(roomId); + state.owed.delete(roomId); + const chatId = db.roomChatId(roomId); + const record = await db.chat(chatId); + if (!record || record.hidden) { + return; + } + await db.hideChat(chatId, true); + announceChats(); + announceRoom(roomId); +} + +// memberKeys — публичные ключи итогового состава с проверкой TOFU +// (ADR-016, ADR-018). Ник с неподтверждённым ключом останавливает всю +// операцию: rekey ему не выполняется, а состав без ключа невозможен. +async function memberKeys(members) { + const keys = new Map(); + const blocked = []; + for (const nick of members) { + if (nick === state.nick) { + keys.set(nick, state.publicKey); + continue; + } + const user = await api.user(nick); + const record = await seePeer(nick, user.publicKey); + if (record.pending) { + blocked.push(nick); + continue; + } + keys.set(nick, record.publicKey); + } + if (blocked.length > 0) { + throw new TrustNeeded(blocked); + } + return keys; +} + +// distribute генерирует ключ комнаты и заворачивает его каждому участнику, +// включая себя: заворачивание себе — ECDH(myPrivate, myPublic), тем же кодом +// (docs/crypto.md, «Комната»). Сырые байты живут до конца заворачивания, +// потом импортируются non-extractable и затираются. +async function distribute(roomId, members, keys) { + const { keyId, bytes } = newRoomKey(); + try { + const wrapped = []; + for (const nick of members) { + wrapped.push(await wrapRoomKey( + state.privateKey, + keys.get(nick), + { roomId, keyId, from: state.nick, to: nick }, + bytes, + )); + } + return { keyId, wrapped, key: await importRoomKey(bytes) }; + } finally { + wipe(bytes); + } +} + +// keepRoomKey кладёт свой же розданный ключ: у распространителя он +// не разворачивается, а берётся из сырых байт до их затирания. +async function keepRoomKey(roomId, keyId, key) { + const stored = await db.saveRoomKey({ roomId, keyId, key, from: state.nick }); + if (stored) { + state.roomKeys.set(`${roomId}|${keyId}`, key); + } + return stored; +} + +// createRoom заводит комнату. Идентификатор генерирует клиент: ключ +// заворачивается до запроса и привязан к roomId (ADR-037). Отдаёт chatId. +export function createRoom(name) { + return serial(async () => { + const title = String(name ?? "").trim(); + if (!state.running || title === "") { + return null; + } + const keys = await memberKeys([state.nick]); + for (let attempt = 0; attempt < 3; attempt += 1) { + const roomId = newId(); + const { keyId, wrapped, key } = await distribute(roomId, [state.nick], keys); + let room; + try { + room = await api.createRoom(state.device, { + id: roomId, + name: title, + keyId, + keys: wrapped, + }); + } catch (err) { + if (err instanceof ApiError && err.code === "room_conflict") { + continue; + } + throw err; + } + await keepRoomKey(roomId, keyId, key); + // Форма ответа так же непроверена, как форма события. Чужой + // идентификатор в ответе означал бы ключ, привязанный не к той + // комнате: roomId вплетён в info и AAD (ADR-037). + if (usableRoom(room) && room.id === roomId) { + await saveRoom(room); + } + announceChats(); + announceRoom(roomId); + return db.roomChatId(roomId); + } + throw new Error("не удалось завести комнату"); + }); +} + +// changeMembers — смена состава и rekey одним запросом (ADR-018): владелец +// получает публичные ключи итогового состава с проверкой TOFU, генерирует +// ключ, заворачивает каждому и только потом отправляет. +// +// Ники приходят уже приведёнными к форме ADR-019: их проверяет экран. +export function changeMembers(roomId, { add = [], remove = [] } = {}) { + return serial(() => changeRoom(roomId, add, remove)); +} + +// rekey — новый ключ прежнему составу: тот же запрос с пустыми add +// и remove (ADR-018). Зовётся у владельца, получившего room с needsRekey. +// Неудача долг не снимает: попытка повторится после ready. +async function rekey(roomId) { + state.owed.add(roomId); + try { + await changeRoom(roomId, [], []); + } catch (err) { + if (err instanceof TrustNeeded) { + // Владелец видит, чей ключ надо подтвердить, и повторяет операцию + // после подтверждения (ADR-016). + state.blocked.set(roomId, err.nicks); + announceRoom(roomId); + return; + } + // Сеть или отказ сервера: попытка повторится после следующего ready. + } +} + +// payRekeys отдаёт долги по ключам комнат. Комната, которой мы больше +// не владеем или из которой ушли, долг снимает: новый ключ раздаёт +// её владелец. +async function payRekeys() { + for (const roomId of [...state.owed]) { + let record; + try { + record = await db.chat(db.roomChatId(roomId)); + } catch { + return; + } + if (!record || record.type !== "room" || record.hidden || record.owner !== state.nick) { + state.owed.delete(roomId); + if (state.blocked.delete(roomId)) { + announceRoom(roomId); + } + continue; + } + await rekey(roomId); + } +} + +async function changeRoom(roomId, add, remove) { + if (!state.running) { + return null; + } + const record = await db.chat(db.roomChatId(roomId)); + if (!record || record.type !== "room") { + throw new Error("нет такой комнаты"); + } + const members = finalMembers(record.members ?? [], add, remove); + const keys = await memberKeys(members); + const { keyId, wrapped, key } = await distribute(roomId, members, keys); + const room = await api.changeMembers(roomId, { add, remove, keyId, keys: wrapped }); + const stored = await keepRoomKey(roomId, keyId, key); + // Ключ роздан всему составу: долг закрыт, и подтверждать больше нечего. + state.owed.delete(roomId); + state.blocked.delete(roomId); + if (usableRoom(room)) { + await saveRoom(room); + } + announceChats(); + announceRoom(roomId); + if (stored) { + await reopen(); + await retryPending(); + } + return room; +} + +// finalMembers — итоговый состав: текущий без remove плюс add, без повторов +// и с сохранением порядка. +function finalMembers(current, add, remove) { + const gone = new Set(remove); + const seen = new Set(); + const out = []; + for (const nick of [...current.filter((nick) => !gone.has(nick)), ...add]) { + if (seen.has(nick)) { + continue; + } + seen.add(nick); + out.push(nick); + } + return out; +} + +// leaveRoom — «выйти из комнаты». Владение уходит участнику с наименьшим +// joined_at, опустевшая комната удаляется — это дело сервера (ADR-018). +export function leaveRoom(roomId) { + return serial(async () => { + await api.leaveRoom(state.device, roomId); + await forgetRoom(roomId); + }); +} + +// deleteRoom — «удалить комнату», только у владельца. Участникам уходит +// room_left. +export function deleteRoom(roomId) { + return serial(async () => { + await api.removeRoom(roomId); + await forgetRoom(roomId); + }); +} + // --- отправка ----------------------------------------------------------- +// Postponed — отправить сейчас нечем, но причина пройдёт: нет ключа комнаты +// или ключ собеседника изменился и ждёт подтверждения (ADR-016). Сообщение +// остаётся pending и уходит, когда причина уйдёт. +class Postponed extends Error { + constructor() { + super("отправка отложена"); + this.name = "Postponed"; + } +} + // send — новое исходящее сообщение. Пустая строка не отправляется; // предел в maxMessageChars держит строка ввода (docs/ui.md, «Чат»). // Отдаёт id записи или null, если отправлять нечего. export function send(chatId, text) { const body = String(text ?? "").trim(); - if (!state.running || body === "" || db.peerOf(chatId) === null) { + const known = db.peerOf(chatId) !== null || db.roomIdOf(chatId) !== null; + if (!state.running || body === "" || !known) { return Promise.resolve(null); } return serial(() => attempt({ chatId, text: body }, null)); @@ -734,7 +1404,8 @@ const REUSE = 4 * 60 * 1000; // по часам. async function attempt(source, previousId, fresh = false) { const peer = db.peerOf(source.chatId); - if (peer === null) { + const roomId = db.roomIdOf(source.chatId); + if (peer === null && roomId === null) { state.pending.delete(previousId); return null; } @@ -760,7 +1431,7 @@ async function attempt(source, previousId, fresh = false) { me: state.nick, }); notify([message], stale ? [{ chatId: source.chatId, id: previousId }] : []); - const err = await post(message, peer); + const err = await post(message, peer, roomId); if (err === null) { return message.id; } @@ -786,6 +1457,43 @@ function reusable(id) { return ms !== null && Math.abs(Date.now() - ms) < REUSE; } +// dmEnvelope — конверт личного чата. Ключ собеседника изменился и ждёт +// подтверждения — отправка блокируется (ADR-016): сообщение остаётся +// pending и уходит после «доверять новому ключу». +async function dmEnvelope(message, peer) { + const known = await db.peer(peer); + if (known?.pending) { + throw new Postponed(); + } + const sealed = await sealMessage(await chatKey(peer), { + id: message.id, + chat: dmLabel(state.nick, peer), + from: state.nick, + keyId: DM_KEY_ID, + text: message.text, + }); + return { id: message.id, to: { dm: peer }, keyId: DM_KEY_ID, iv: sealed.iv, ct: sealed.ct }; +} + +// roomEnvelope — конверт комнаты: keyId текущего ключа, chat — "room:" +// (docs/crypto.md, «Сообщение»). Ключа ещё нет — отправка откладывается +// до его прихода. +async function roomEnvelope(message, roomId) { + const keyId = await currentKeyId(roomId); + const key = keyId === null ? null : await roomKeyOf(roomId, keyId); + if (key === null) { + throw new Postponed(); + } + const sealed = await sealMessage(key, { + id: message.id, + chat: roomLabel(roomId), + from: state.nick, + keyId, + text: message.text, + }); + return { id: message.id, to: { room: roomId }, keyId, iv: sealed.iv, ct: sealed.ct }; +} + // post шифрует и отдаёт конверт серверу. from в AAD — собственный ник: // сервер проставит то же значение из сессии, и AAD сойдётся у получателя // (docs/crypto.md, «Сообщение»). @@ -793,23 +1501,12 @@ function reusable(id) { // Отдаёт null при 202 и отказ, если он был: судьбу отказа решает attempt — // clock_skew на переиспользованном идентификаторе кончается не полосой, // а второй попыткой. -async function post(message, peer) { +async function post(message, peer, roomId) { let envelope; try { - const sealed = await sealMessage(await chatKey(peer), { - id: message.id, - chat: dmLabel(state.nick, peer), - from: state.nick, - keyId: DM_KEY_ID, - text: message.text, - }); - envelope = { - id: message.id, - to: { dm: peer }, - keyId: DM_KEY_ID, - iv: sealed.iv, - ct: sealed.ct, - }; + envelope = peer !== null + ? await dmEnvelope(message, peer) + : await roomEnvelope(message, roomId); } catch (err) { return err; } @@ -860,10 +1557,12 @@ async function settle(message, err) { notify([failed]); } -// transient — отказ, который пройдёт сам: запрос не дошёл или сервер -// не справился. Повтор допустим (ADR-027). +// transient — отказ, который пройдёт сам: запрос не дошёл, сервер +// не справился или шифровать пока нечем. Повтор допустим (ADR-027). function transient(err) { - return err instanceof NetworkError || (err instanceof ApiError && err.status >= 500); + return err instanceof NetworkError + || err instanceof Postponed + || (err instanceof ApiError && err.status >= 500); } // --- действия экранов --------------------------------------------------- @@ -873,7 +1572,7 @@ function transient(err) { // Ошибки — 404 unknown_user и 400 self (docs/ui.md, «Новый чат»). export async function openDm(peer) { const answer = await api.addContact(peer); - await rememberPeer(answer.nick, answer.publicKey); + await seePeer(answer.nick, answer.publicKey); const chatId = db.dmChatId(answer.nick); const existing = await db.chat(chatId); if (!existing || existing.hidden) { @@ -906,6 +1605,18 @@ export async function markRead(chatId) { } // Чтение для экранов. Писать в базу им не нужно: всё, что меняет -// состояние, живёт здесь. dmChatId и peerOf — форма ключа чата -// (docs/storage.md): экраны собирают её из ника маршрута, а не из строки. -export { chats, chat, message, messagesBefore, peer, dmChatId, peerOf, PAGE } from "./db.js"; +// состояние, живёт здесь. dmChatId, roomChatId, peerOf и roomIdOf — форма +// ключа чата (docs/storage.md): экраны собирают её из ника или +// идентификатора маршрута, а не из строки. +export { + chats, + chat, + message, + messagesBefore, + peer, + dmChatId, + roomChatId, + peerOf, + roomIdOf, + PAGE, +} from "./db.js"; diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index ff7ed8a..813706a 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -16,11 +16,20 @@ const DAY_LONG = new Intl.DateTimeFormat("ru-RU", { weekday: "long", day: "numer const DAY_SHORT = new Intl.DateTimeFormat("ru-RU", { day: "numeric", month: "short" }); const TIME = new Intl.DateTimeFormat("ru-RU", { hour: "2-digit", minute: "2-digit" }); -// Тексты нерасшифрованного — docs/ui.md, «Чат». Ключа комнаты нет — -// это про комнату; всё остальное в личном чате означает чужой ключ. +// Тексты нерасшифрованного — docs/ui.md, «Чат». Строк там две, а причин +// в записи три (docs/storage.md): «нет ключа комнаты» — это unknown_key +// в комнате, всё остальное сводится к «ключ изменился». const NO_ROOM_KEY = "не удалось расшифровать: нет ключа комнаты"; const KEY_CHANGED = "не удалось расшифровать: ключ изменился"; +// Сколько символов имени комнаты попадает в подсказку ввода. Строка ввода +// растёт под placeholder так же, как под текст, и имя в 64 символа (ADR-018) +// занимало бы три строки. Имя укорачивается многоточием, как в шапке, только +// разметкой: text-overflow к placeholder не применяется. Двенадцать — +// столько, чтобы «сообщение в #имя…» умещалось в одну строку на самом +// узком из целевых экранов (360 px). +const NAME_IN_HINT = 12; + // Насколько далеко от низа ленты человек ещё считается «внизу»: пришедшее // сообщение подматывает ленту только тогда, когда он и так смотрит конец. const NEAR_BOTTOM = 80; @@ -32,8 +41,18 @@ export function renderChat(root, ctx, chatId) { chatId, me: ctx.me.nick, peer: sync.peerOf(chatId), + roomId: sync.roomIdOf(chatId), limit: ctx.config?.maxMessageChars ?? LIMIT, alive: true, + // Имя комнаты; до чтения записи чата вместо него идентификатор, + // как в db.blankChat. + name: sync.roomIdOf(chatId), + // Ключ собеседника изменился и ждёт подтверждения: ввод заблокирован + // (ADR-016). + blocked: false, + // Комнаты у нас больше нет: вышли сами, убрал владелец, комната + // удалена. Ввод заблокирован, лента остаётся (ADR-044). + gone: false, // Лента: записи по возрастанию id и их строки в разметке. items: [], nodes: new Map(), @@ -58,6 +77,18 @@ export function renderChat(root, ctx, chatId) { } }); const offNet = sync.on("net", () => paintBar(view)); + // Доверие к ключу собеседника меняет полосу и ввод; имя комнаты приходит + // из GET /api/rooms и события room, иногда позже первой отрисовки. + const offPeers = sync.on("peers", (detail) => { + if (view.peer !== null && detail.nick === view.peer) { + run(view, () => checkPeer(view)); + } + }); + const offRooms = sync.on("rooms", (detail) => { + if (view.roomId !== null && detail.id === view.roomId) { + run(view, () => refreshRoom(view)); + } + }); const media = matchMedia(DESKTOP); const onMedia = () => paint(view, true); media.addEventListener("change", onMedia); @@ -68,6 +99,8 @@ export function renderChat(root, ctx, chatId) { view.alive = false; offMessages(); offNet(); + offPeers(); + offRooms(); media.removeEventListener("change", onMedia); }; } @@ -81,20 +114,65 @@ function run(view, task) { // --- разметка ----------------------------------------------------------- -// head — шапка: имя чата, по нажатию — карточка контакта. «назад» слева -// нужен там, где виден один экран за раз; на десктопе его прячет CSS. +// head — шапка: имя чата, по нажатию — участники комнаты или карточка +// контакта (docs/ui.md, «Чат»). «назад» слева нужен там, где виден один +// экран за раз; на десктопе его прячет CSS. function head(view) { const bar = el("div", "head"); const back = el("button", "back back--chat", "назад"); back.type = "button"; back.addEventListener("click", () => view.ctx.go("#/")); - const title = el("button", "chat-title", `@${view.peer}`); - title.type = "button"; - title.addEventListener("click", () => view.ctx.go(`#/contact/${view.peer}`)); - bar.append(back, title); + view.title = el("button", "chat-title", titleText(view)); + view.title.type = "button"; + view.title.addEventListener("click", () => view.ctx.go(view.roomId !== null + ? `#/room/${view.roomId}/members` + : `#/contact/${view.peer}`)); + bar.append(back, view.title); return bar; } +function titleText(view) { + return view.roomId !== null ? `#${view.name}` : `@${view.peer}`; +} + +// shortName — имя комнаты для подсказки ввода: длинное обрезается +// многоточием. Считается символами, а не единицами utf-16: имя ограничено +// символами (docs/protocol.md), и разрезать пару посередине незачем. +function shortName(name) { + const chars = Array.from(name); + return chars.length > NAME_IN_HINT ? `${chars.slice(0, NAME_IN_HINT).join("")}…` : name; +} + +// refreshRoom обновляет имя комнаты в шапке и placeholder ввода — имя +// приходит от сервера и бывает известно позже первой отрисовки — и состояние +// членства: комната, из состава которой нас больше нет, гасит ввод +// и показывает полосу (ADR-044). +async function refreshRoom(view, known) { + if (view.roomId === null) { + return; + } + let record = known; + if (record === undefined) { + try { + record = await sync.chat(view.chatId); + } catch { + return; + } + } + if (!view.alive) { + return; + } + view.name = record?.title || view.roomId; + view.title.textContent = titleText(view); + view.field.placeholder = `сообщение в #${shortName(view.name)}`; + // Скрытая запись комнаты — это room_left или собственный выход: + // отправлять больше некуда, и сервер ответил бы not_member. + view.gone = record?.hidden === true; + view.field.disabled = view.gone; + view.send.disabled = view.gone; + paintBar(view); +} + // composer — полоса состояния и строка ввода: рамка 1 px ink, слева «>» // цветом mark. Enter отправляет только на десктопе; на мобильном он делает // перенос, а отправляет кнопка «>» справа (docs/ui.md, «Чат»). @@ -118,10 +196,10 @@ function composer(view) { view.counter = el("span", "counter"); view.counter.hidden = true; - const send = el("button", "input__send", ">"); - send.type = "submit"; + view.send = el("button", "input__send", ">"); + view.send.type = "submit"; - row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), send); + row.append(prompt, view.field, view.counter, el("span", "enter", "enter — отправить"), view.send); form.append(view.bar, row); view.field.addEventListener("input", () => count(view)); @@ -152,7 +230,7 @@ function count(view) { function submit(view) { const text = view.field.value; - if (text.trim() === "") { + if (view.blocked || view.gone || text.trim() === "") { return; } view.field.value = ""; @@ -174,6 +252,11 @@ async function load(view) { if (!view.alive) { return; } + await refreshRoom(view, record ?? null); + await checkPeer(view); + if (!view.alive) { + return; + } view.items = list; view.newId = firstUnread(record, list, view.me); paint(view, true); @@ -342,7 +425,7 @@ function text(view, record) { const node = el("div", "text"); if (record.text === null) { node.classList.add("text--none"); - node.textContent = view.peer === null && record.undecryptable === "unknown_key" + node.textContent = view.roomId !== null && record.undecryptable === "unknown_key" ? NO_ROOM_KEY : KEY_CHANGED; return node; @@ -362,25 +445,82 @@ function text(view, record) { return node; } -// paintBar — полоса над вводом. Причина одна за раз: отказ отправки +// checkPeer — состояние доверия к ключу собеседника. Ключ изменился +// и ждёт подтверждения — ввод заблокирован до «доверять новому ключу» +// (docs/ui.md, «Чат»). В комнате блокировать нечего: ключ там симметричный, +// а чьи ключи мешают rekey, показывает экран участников. +async function checkPeer(view) { + if (view.peer === null) { + return; + } + let record = null; + try { + record = await sync.peer(view.peer); + } catch { + // Базы нет — считаем ключ прежним: отправка не блокируется. + } + if (!view.alive) { + return; + } + view.blocked = !!record?.pending; + // Ввод заблокирован целиком: и поле, и кнопка «>» на мобильном. + view.field.disabled = view.blocked; + view.send.disabled = view.blocked; + paintBar(view); +} + +// paintBar — полоса над вводом. Причина одна за раз (ADR-033). Порядок: +// комнаты у нас больше нет — перебивает всё, отправлять некуда (ADR-044); +// предупреждение о ключе — только оно блокирует ввод в личном чате, +// и пока оно висит, повторять отправку нечем (ADR-038); отказ отправки // перебивает «нет соединения», потому что он про конкретное сообщение -// и уходит при следующей попытке (ADR-033). +// и уходит при следующей попытке. function paintBar(view) { + if (view.gone) { + band(view, "bar bar--mark", "вы больше не участник комнаты", false); + return; + } + if (view.blocked) { + band(view, "bar bar--mark", `ключ @${view.peer} изменился. сверьте отпечаток лично.`, true); + return; + } const failed = lastFailed(view); if (failed) { - view.bar.className = "bar bar--mark"; - view.bar.textContent = failed.error; - view.bar.hidden = false; + band(view, "bar bar--mark", failed.error, false); return; } if (!sync.online()) { - view.bar.className = "bar"; - view.bar.textContent = "нет соединения"; - view.bar.hidden = false; + band(view, "bar", "нет соединения", false); return; } + clear(view.bar); view.bar.hidden = true; - view.bar.textContent = ""; +} + +// band — сама полоса. Кнопка «доверять новому ключу» стоит в ней же: +// подтверждение — единственный выход из состояния (ADR-016). +function band(view, className, caption, trust) { + clear(view.bar); + view.bar.className = className; + view.bar.append(el("span", null, caption)); + if (trust) { + const yes = el("button", "link", "доверять новому ключу"); + yes.type = "button"; + yes.addEventListener("click", () => { + yes.disabled = true; + run(view, async () => { + try { + await sync.trustKey(view.peer); + } finally { + // Удалось — полосу перерисует событие «peers»; нет — кнопка + // снова готова к нажатию. + yes.disabled = false; + } + }); + }); + view.bar.append(yes); + } + view.bar.hidden = false; } // lastFailed — последнее своё неотправленное сообщение с текстом отказа diff --git a/web/js/ui/chats.js b/web/js/ui/chats.js index 1589403..74400be 100644 --- a/web/js/ui/chats.js +++ b/web/js/ui/chats.js @@ -1,7 +1,7 @@ // Список чатов в сайдбаре — docs/ui.md, «Список чатов». // // Секции «каналы» и «личные», порядок — по lastId по убыванию (его держит -// sync.chats). Пустая секция не рисуется: комнат до этапа 3 нет. +// sync.chats). Пустая секция не рисуется. import * as sync from "../sync.js"; import { clear, el } from "./dom.js"; diff --git a/web/js/ui/contact.js b/web/js/ui/contact.js index 5411c11..c180a03 100644 --- a/web/js/ui/contact.js +++ b/web/js/ui/contact.js @@ -1,33 +1,38 @@ // Карточка контакта — docs/ui.md, «Карточка контакта». // -// Смена ключа собеседника и «доверять новому ключу» появятся вместе -// с TOFU (этап 3, ADR-016): до тех пор у записи peers нет pending. +// Отпечаток доверенного ключа, а если ключ ника изменился и ждёт +// подтверждения — оба отпечатка и «доверять новому ключу» (ADR-016). import * as sync from "../sync.js"; import { fingerprintGroups } from "../crypto.js"; -import { button, el, message, setError, setNote } from "./dom.js"; +import { button, clear, el, message, setError, setNote } from "./dom.js"; +// renderContact рисует карточку в root и отдаёт отписку. export function renderContact(root, ctx, nick) { + const view = { + ctx, + nick, + alive: true, + // Событий «peers» приходит больше одного подряд; рисует последнее. + generation: 0, + }; root.append(head(ctx, nick)); const body = el("div", "body settings"); + view.card = el("section", "block block--first"); + body.append(view.card, remove(ctx, nick)); root.append(body); - const card = el("section", "block block--first"); - body.append(card, remove(ctx, nick)); - - // Отпечаток лежит в записи TOFU; её может ещё не быть, если чат - // открыли до первого ключа. - sync.peer(nick).then((record) => { - if (!record?.fingerprint || !card.isConnected) { - return; + const off = sync.on("peers", (detail) => { + if (detail.nick === view.nick) { + paint(view); } - const groups = fingerprintGroups(record.fingerprint); - card.append( - el("p", "fp", groups.slice(0, 8).join(" ")), - el("p", "fp", groups.slice(8).join(" ")), - el("p", "fp-hint", "сверьте с собеседником голосом или лично"), - ); - }).catch(() => {}); + }); + paint(view); + + return () => { + view.alive = false; + off(); + }; } function head(ctx, nick) { @@ -39,6 +44,57 @@ function head(ctx, nick) { return bar; } +// paint рисует отпечатки. Записи TOFU может ещё не быть: чат открыли +// раньше, чем пришёл первый ключ. +async function paint(view) { + const mine = ++view.generation; + let record = null; + try { + record = await sync.peer(view.nick); + } catch { + // Базы нет — показывать нечего. + } + if (!view.alive || mine !== view.generation || !record?.fingerprint) { + return; + } + clear(view.card); + if (!record.pending) { + fingerprint(view.card, record.fingerprint, null); + view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично")); + return; + } + // Ключ изменился: показываем оба отпечатка с пометками, чтобы + // подтверждали не наугад (ADR-038). + fingerprint(view.card, record.fingerprint, "старый"); + fingerprint(view.card, record.pending.fingerprint, "новый"); + view.card.append(el("p", "fp-hint", "сверьте с собеседником голосом или лично"), trust(view)); +} + +// fingerprint — 64 hex группами по 4 в две строки (ADR-016). +function fingerprint(box, value, label) { + if (label) { + box.append(el("p", "fp-label", label)); + } + const groups = fingerprintGroups(value); + box.append(el("p", "fp", groups.slice(0, 8).join(" ")), el("p", "fp", groups.slice(8).join(" "))); +} + +// trust — «доверять новому ключу»: ключ из pending становится основным, +// и всё, что в него упиралось, повторяется (ADR-016). +function trust(view) { + const yes = button("доверять новому ключу"); + yes.addEventListener("click", async () => { + yes.disabled = true; + try { + await sync.trustKey(view.nick); + } catch { + // Не вышло — запись цела, экран перерисуется с теми же ключами. + yes.disabled = false; + } + }); + return yes; +} + // remove — «убрать из списка»: строка контакта уходит с сервера, история // на устройстве остаётся (ADR-019). function remove(ctx, nick) { diff --git a/web/js/ui/members.js b/web/js/ui/members.js new file mode 100644 index 0000000..e76a0c7 --- /dev/null +++ b/web/js/ui/members.js @@ -0,0 +1,249 @@ +// Участники комнаты — docs/ui.md, «Участники». +// +// Состав, владелец и то, чей ключ мешает rekey, приходят из sync: экран +// не пишет в базу и не ходит в сеть сам. + +import * as sync from "../sync.js"; +import { button, clear, confirmPanel, el, message, setError, setNote } from "./dom.js"; + +// renderMembers рисует экран в root и отдаёт отписку. +export function renderMembers(root, ctx, roomId) { + const view = { + ctx, + roomId, + chatId: sync.roomChatId(roomId), + me: ctx.me.nick, + alive: true, + // Событий «rooms» приходит больше одного подряд; рисует последнее. + generation: 0, + }; + + view.title = el("span", "title", "#"); + root.append(head(view)); + + const body = el("div", "body settings"); + + // Полоса «нужен новый ключ комнаты» — единственный акцент экрана + // (docs/identity/brief.md). + view.warn = el("p", "bar bar--mark"); + view.warn.hidden = true; + view.warn.setAttribute("aria-live", "polite"); + + const people = el("section", "block block--first"); + view.list = el("ul", "members"); + view.note = message(); + people.append(view.list, add(view), view.note); + + body.append(view.warn, people, exit(view)); + root.append(body); + + const off = sync.on("rooms", (detail) => { + if (detail.id === view.roomId) { + paint(view); + } + }); + paint(view); + + return () => { + view.alive = false; + off(); + }; +} + +function head(view) { + const bar = el("div", "head"); + const back = el("button", "back", "назад"); + back.type = "button"; + back.addEventListener("click", () => view.ctx.go(`#/room/${view.roomId}`)); + bar.append(back, view.title); + return bar; +} + +// --- разметка ----------------------------------------------------------- + +// add — строка ввода `@ник` и «добавить». Видна только владельцу: состав +// меняет он (ADR-018). +function add(view) { + const form = el("form", "form form--row"); + form.noValidate = true; + form.hidden = true; + + const line = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = "@ник"; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + line.append(prompt, field); + + const go = el("button", "button", "добавить"); + go.type = "submit"; + form.append(line, go); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (go.disabled) { + return; + } + setNote(view.note, ""); + // Ник вводят как в списке: с «@» или без. Ники строчные (ADR-019). + const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + if (nick === "") { + field.focus(); + return; + } + field.value = nick; + go.disabled = true; + try { + await sync.changeMembers(view.roomId, { add: [nick] }); + field.value = ""; + } catch (err) { + setError(view.note, failure(view.ctx, err)); + field.focus(); + } finally { + go.disabled = false; + } + }); + + view.add = form; + return form; +} + +// exit — «выйти из комнаты» всем, «удалить комнату» владельцу +// (docs/ui.md, «Участники»). +function exit(view) { + const box = el("section", "block"); + const note = message(); + + const leave = button("выйти из комнаты"); + leave.addEventListener("click", async () => { + leave.disabled = true; + setNote(note, ""); + try { + await sync.leaveRoom(view.roomId); + view.ctx.go("#/"); + } catch (err) { + setError(note, failure(view.ctx, err)); + leave.disabled = false; + } + }); + + const drop = button("удалить комнату"); + drop.hidden = true; + const panel = confirmPanel("комната будет удалена у всех участников.", "удалить"); + + drop.addEventListener("click", () => { + setNote(note, ""); + drop.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + panel.root.hidden = true; + drop.hidden = false; + drop.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + try { + await sync.deleteRoom(view.roomId); + view.ctx.go("#/"); + } catch (err) { + setError(note, failure(view.ctx, err)); + panel.yes.disabled = false; + panel.no.disabled = false; + panel.root.hidden = true; + drop.hidden = false; + } + }); + + view.drop = drop; + view.panel = panel.root; + box.append(leave, drop, panel.root, note); + return box; +} + +// --- состояние ---------------------------------------------------------- + +// paint перечитывает комнату и рисует состав. Владение переходит к участнику +// с наименьшим joined_at (ADR-018), поэтому владельческие части экрана +// появляются и исчезают вместе с составом. +async function paint(view) { + const mine = ++view.generation; + let record = null; + try { + record = await sync.chat(view.chatId); + } catch { + // Базы нет — рисуем пустой состав: выйти из комнаты это не мешает. + } + if (!view.alive || mine !== view.generation) { + return; + } + const members = record?.members ?? []; + const owner = record?.owner ?? null; + const mineRoom = owner === view.me; + + view.title.textContent = `#${record?.title || view.roomId}`; + + clear(view.list); + for (const nick of members) { + view.list.append(person(view, nick, owner, mineRoom)); + } + + view.add.hidden = !mineRoom; + view.drop.hidden = !mineRoom || !view.panel.hidden; + if (!mineRoom) { + view.panel.hidden = true; + } + + const blocked = sync.needsTrust(view.roomId); + view.warn.textContent = blocked.length > 0 ? trustText(blocked) : ""; + view.warn.hidden = blocked.length === 0; +} + +// person — строка участника: ник, пометка «владелец», «убрать» у владельца. +// Владельца убрать нельзя (docs/protocol.md, `400 owner`), поэтому кнопки +// в его строке нет. +function person(view, nick, owner, mineRoom) { + const row = el("li", "member"); + row.append(el("span", "member__name", `@${nick}`)); + if (nick === owner) { + row.append(el("span", "tag", "владелец")); + return row; + } + if (!mineRoom) { + return row; + } + const drop = el("button", "link", "убрать"); + drop.type = "button"; + drop.addEventListener("click", async () => { + drop.disabled = true; + setNote(view.note, ""); + try { + await sync.changeMembers(view.roomId, { remove: [nick] }); + } catch (err) { + setError(view.note, failure(view.ctx, err)); + drop.disabled = false; + } + }); + row.append(drop); + return row; +} + +// failure — текст отказа. Неподтверждённый ключ обрывает смену состава +// до запроса, и состояние у него то же, что у несделанного rekey: +// комнате нужен новый ключ, а раздать его некому (ADR-016, ADR-038). +function failure(ctx, err) { + if (err instanceof sync.TrustNeeded) { + return trustText(err.nicks); + } + return ctx.errorText(err); +} + +function trustText(nicks) { + return `нужен новый ключ комнаты: подтвердите ключ ${nicks.map((nick) => `@${nick}`).join(", ")}`; +} diff --git a/web/js/ui/new.js b/web/js/ui/new.js index 73cca3e..4042ed5 100644 --- a/web/js/ui/new.js +++ b/web/js/ui/new.js @@ -1,61 +1,106 @@ -// Новый чат — docs/ui.md, «Новый чат». Строка `#имя комнаты` появится -// вместе с комнатами (этап 3): создавать пока нечего. +// Новый чат — docs/ui.md, «Новый чат». Две строки ввода: `@ник` открывает +// личный чат, `#имя комнаты` заводит комнату. import * as sync from "../sync.js"; import { el, message, setError, setNote } from "./dom.js"; +// Имя комнаты — до 64 символов (ADR-018, ADR-021). maxLength считает +// единицы UTF-16, а сервер — руны: за предел это не выпустит. +const ROOM_NAME_MAX = 64; + export function renderNew(root, ctx) { root.append(head(ctx)); const body = el("div", "body"); - const form = el("form", "form"); - form.noValidate = true; - - const row = el("div", "input"); - const prompt = el("span", "p", ">"); - prompt.setAttribute("aria-hidden", "true"); - const field = el("input", "input__field"); - field.type = "text"; - field.placeholder = "@ник"; - field.autocapitalize = "off"; - field.autocomplete = "off"; - field.spellcheck = false; - const go = el("button", "input__send", ">"); - go.type = "submit"; - row.append(prompt, field, go); - + // Место под ошибку одно на оба поля: строка состояния у экрана одна + // (ADR-028). const note = message(); - form.append(row, note); - form.addEventListener("submit", async (event) => { + const dm = row("@ник"); + const room = row("#имя комнаты"); + room.field.maxLength = ROOM_NAME_MAX; + + dm.form.addEventListener("submit", async (event) => { event.preventDefault(); - if (go.disabled) { + if (dm.go.disabled) { return; } setNote(note, ""); // Ник вводят как в списке: с «@» или без. Регистр не хранится — // ники строчные (ADR-019). - const nick = field.value.trim().replace(/^@/, "").toLowerCase(); + const nick = dm.field.value.trim().replace(/^@/, "").toLowerCase(); if (nick === "") { - field.focus(); + dm.field.focus(); return; } - field.value = nick; - go.disabled = true; + dm.field.value = nick; + dm.go.disabled = true; try { await sync.openDm(nick); ctx.go(`#/dm/${nick}`); } catch (err) { setError(note, ctx.errorText(err)); - field.focus(); + dm.field.focus(); } finally { - go.disabled = false; + dm.go.disabled = false; } }); - body.append(form); + room.form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (room.go.disabled) { + return; + } + setNote(note, ""); + // Имя вводят как в списке: с «#» или без. Регистр имени комнаты + // сохраняется — это её название, а не идентификатор. + const name = room.field.value.trim().replace(/^#/, "").trim(); + if (name === "") { + room.field.focus(); + return; + } + room.go.disabled = true; + try { + const chatId = await sync.createRoom(name); + if (chatId === null) { + room.field.focus(); + return; + } + ctx.go(`#/room/${sync.roomIdOf(chatId)}`); + } catch (err) { + setError(note, ctx.errorText(err)); + room.field.focus(); + } finally { + room.go.disabled = false; + } + }); + + body.append(dm.form, room.form, note); root.append(body); - field.focus(); + dm.field.focus(); +} + +// row — строка ввода в стиле чата: рамка 1 px ink, слева «>» цветом mark +// (docs/identity/brief.md, «Компоновка»). +function row(placeholder) { + const form = el("form", "form form--row"); + form.noValidate = true; + + const line = el("div", "input"); + const prompt = el("span", "p", ">"); + prompt.setAttribute("aria-hidden", "true"); + const field = el("input", "input__field"); + field.type = "text"; + field.placeholder = placeholder; + field.autocapitalize = "off"; + field.autocomplete = "off"; + field.spellcheck = false; + const go = el("button", "input__send", ">"); + go.type = "submit"; + line.append(prompt, field, go); + + form.append(line); + return { form, field, go }; } function head(ctx) { -- 2.54.0 From 8f67f4aa4d1a42b2be24c4459e54a6b9f1cfc631 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sun, 23 Aug 2026 00:50:58 +0300 Subject: [PATCH 6/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=204:=20PWA=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=83=D1=88=D0=B8=20=E2=80=94=20service=20worke?= =?UTF-8?q?r,=20Web=20Push=20=D1=81=20VAPID,=20=D0=B1=D0=B0=D0=BD=D0=BD?= =?UTF-8?q?=D0=B5=D1=80=20=D1=83=D1=81=D1=82=D0=B0=D0=BD=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сервер: пакет push на webpush-go (третья и последняя прямая зависимость), подписка устройства, правило ADR-023 «пуш только молчащему устройству и только один» через атомарный захват push_pending, удаление подписки на 404 и 410, TTL сутки, urgency normal. В нагрузке только {title, body, chat} — тело собирается из константы, плейнтекст туда не попадает даже по ошибке. Клиент: service worker с версионированным кэшем оболочки и никогда — /api/*, push и notificationclick, подписка на VAPID-ключ сервера, запрос разрешения после первого отправленного сообщения, разделы настроек «уведомления» и «установить приложение», баннер установки на iOS. ADR-045: пуш адресован получателю — по букве ADR-023 он уходил бы и молчащему устройству отправителя с бессмысленным заголовком из собственного ника. ADR-047: сервер ходит на endpoint подписки, который выбирает браузер. Проверка «только https» обходилась редиректом, а имя могло смотреть внутрь сети — теперь запрет редиректов и проверка разрешённого адреса на уровне сокета. ADR-048: пределы отправки — недоступный push-сервис одного аккаунта больше не съедает пуши всего сервера. ADR-049: явно выключенные уведомления сами не включаются обратно. Попутно: webpush-go дописывает набивку в переданный срез, а одна нагрузка уходила всем устройствам сообщения — гонка, пойманная go test -race. Теперь у каждого задания своя копия. Приёмка на боевом: подписки, hasPush, чужое устройство, Origin, оболочка из девяти файлов, Service-Worker-Allowed. Отдельно шесть непубличных адресов и endpoint на 3 КиБ — все отбиты. Чеклист ручной проверки на iPhone и Android — в docs/plan.md. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- cmd/bare/main.go | 4 + docs/decisions/021-sessions-csrf-limits.md | 2 +- docs/decisions/023-push-and-service-worker.md | 4 +- .../045-push-addressed-to-recipient.md | 23 + docs/decisions/046-push-client.md | 29 + docs/decisions/047-push-endpoint.md | 27 + docs/decisions/048-push-limits.md | 27 + docs/decisions/049-notifications-off.md | 25 + docs/deploy.md | 5 +- docs/plan.md | 26 + docs/protocol.md | 6 +- docs/storage.md | 2 +- docs/threat-model.md | 2 + docs/ui.md | 10 +- go.mod | 2 + go.sum | 68 ++ internal/api/api.go | 28 +- internal/api/api_test.go | 14 +- internal/api/devices_test.go | 5 - internal/api/messages.go | 49 +- internal/api/push.go | 163 ++++ internal/api/push_test.go | 806 ++++++++++++++++++ internal/api/rooms.go | 30 +- internal/api/rooms_test.go | 11 + internal/config/config.go | 6 + internal/hub/hub.go | 8 + internal/push/push.go | 467 ++++++++++ internal/push/push_test.go | 145 ++++ internal/store/devices.go | 78 ++ internal/store/queue.go | 68 +- internal/store/rooms.go | 37 +- internal/store/rooms_test.go | 9 +- internal/web/web_test.go | 68 ++ web/app.css | 52 ++ web/js/api.js | 11 + web/js/main.js | 62 +- web/js/pwa.js | 380 +++++++++ web/js/sync.js | 18 + web/js/ui/dom.js | 5 + web/js/ui/settings.js | 114 ++- web/js/ui/shell.js | 28 +- web/sw.js | 215 ++++- 42 files changed, 3047 insertions(+), 92 deletions(-) create mode 100644 docs/decisions/045-push-addressed-to-recipient.md create mode 100644 docs/decisions/046-push-client.md create mode 100644 docs/decisions/047-push-endpoint.md create mode 100644 docs/decisions/048-push-limits.md create mode 100644 docs/decisions/049-notifications-off.md create mode 100644 internal/api/push.go create mode 100644 internal/api/push_test.go create mode 100644 internal/push/push.go create mode 100644 internal/push/push_test.go create mode 100644 internal/web/web_test.go create mode 100644 web/js/pwa.js diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 17f5e7c..05be1fc 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -117,6 +117,10 @@ func serve() error { } }() fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin) + // Молчащие пуши — худший вид поломки: снаружи она не видна вовсе. + if cfg.VAPIDPublic == "" || cfg.VAPIDPrivate == "" || cfg.VAPIDSubject == "" { + fmt.Println("bare: пуши выключены — нужны BARE_VAPID_PUBLIC, BARE_VAPID_PRIVATE и BARE_VAPID_SUBJECT") + } select { case err := <-failed: diff --git a/docs/decisions/021-sessions-csrf-limits.md b/docs/decisions/021-sessions-csrf-limits.md index 45bc2ba..ae4a569 100644 --- a/docs/decisions/021-sessions-csrf-limits.md +++ b/docs/decisions/021-sessions-csrf-limits.md @@ -21,7 +21,7 @@ ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без пар - остальные изменяющие запросы — 60 в минуту на пользователя. Превышение — `429` с `Retry-After`. IP берётся из `X-Real-IP`, только если соединение с `127.0.0.1` (nginx, ADR-022). -**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. +**Размеры.** Тело запроса — до 32 КиБ, текст сообщения — до 4000 символов, имя комнаты — до 64, ник — до 32. Имя комнаты вдобавок не бывает пустым, из одних пробелов, с управляющими символами и с переопределениями направления письма (U+202A…U+202E, U+2066…U+2069): с этапа 4 оно уходит в заголовок системного уведомления (ADR-045), а там перевод строки и разворот текста выдают чужое имя за сообщение системы. ## Следствия diff --git a/docs/decisions/023-push-and-service-worker.md b/docs/decisions/023-push-and-service-worker.md index 6478172..98d1b45 100644 --- a/docs/decisions/023-push-and-service-worker.md +++ b/docs/decisions/023-push-and-service-worker.md @@ -1,5 +1,7 @@ # ADR-023: Правила пушей и service worker +Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md). + ## Контекст ADR-011 задаёт принцип «пуш — сигнал». Не определено, когда именно слать пуш, как он привязан к устройству и что кэширует service worker. @@ -10,7 +12,7 @@ ADR-011 задаёт принцип «пуш — сигнал». Не опред - Пуш отправляется при постановке сообщения в очередь устройства, если выполняются оба условия: устройство не подключено по SSE и у устройства не висит неотработанный пуш (`push_pending = 0`). После отправки `push_pending = 1`; сбрасывается при подключении SSE. Одно молчащее устройство получает один пуш, не ленту. - Полезная нагрузка: `{title, body: "новое сообщение", chat}` — `title` это `@nick` или `#имя комнаты`, `chat` — идентификатор для перехода. `TTL` 24 часа, urgency `normal`. Ответы 404/410 от push-сервиса удаляют подписку. - Service worker: `push` → `showNotification` с `tag = chat` (новое уведомление заменяет старое в том же чате); `notificationclick` → фокус открытого окна или открытие `/#/`. -- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`. +- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/manifest.json`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`. - Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки. ## Следствия diff --git a/docs/decisions/045-push-addressed-to-recipient.md b/docs/decisions/045-push-addressed-to-recipient.md new file mode 100644 index 0000000..45df929 --- /dev/null +++ b/docs/decisions/045-push-addressed-to-recipient.md @@ -0,0 +1,23 @@ +# ADR-045: Пуш адресован получателю + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +ADR-023 задаёт правило отправки: пуш уходит при постановке сообщения в очередь устройства, если устройство не подключено по SSE и неотработанного пуша у него нет. Но конверт кладётся в очередь и другим устройствам отправителя (ADR-017) — по букве правила молчащий второй телефон автора получал бы пуш о собственном сообщении. + +Полезная нагрузка от этого рассыпается. Заголовок — `@nick` отправителя, адрес чата — `dm:`: и то и другое собрано с точки зрения получателя. У отправителя тот же чат называется именем собеседника, а уведомление «@marta: новое сообщение» на телефоне самой marta не значит ничего. + +Тексты уведомления при этом живут в ADR-023, а не в `docs/ui.md`, где место всему, что видит человек. + +## Решение + +- Пуш уходит только устройствам получателей. Устройства отправителя — и то, с которого он писал, и все остальные — пуша не получают; сообщение они забирают очередью, как и раньше. +- Заголовок и адрес чата собираются для получателя: `@nick` отправителя и `dm:` в личном чате, `#имя комнаты` и `room:` в комнате. В комнате адресация одна для всех получателей, в личном чате получатель один — значит, у сообщения одна нагрузка на всех. +- Тексты уведомления записаны в `docs/ui.md`, «Уведомления». + +## Следствия + +- Правило ADR-023 читается как «пуш уходит устройствам получателей, если …». Одно молчащее устройство получателя — один пуш. +- Сервер собирает пуш из того, что и так знает: ник отправителя, имя комнаты, идентификатор чата. Плейнтекста он не знает, шифротекст не пересылает — в пуше нет ни того ни другого. +- Автор, отошедший от одного своего устройства к другому, узнаёт о собственном сообщении не пушем, а очередью при открытии. Осознанно. diff --git a/docs/decisions/046-push-client.md b/docs/decisions/046-push-client.md new file mode 100644 index 0000000..4efcace --- /dev/null +++ b/docs/decisions/046-push-client.md @@ -0,0 +1,29 @@ +# ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +Клиентская половина ADR-023 упирается в четыре места, где документ не договаривает. + +**Адрес перехода.** ADR-023 велит открывать по нажатию на уведомление `/#/`. Идентификатор чата — `dm:` или `room:` (`docs/storage.md`), а маршруты клиента — `#/dm/` и `#/room/` (`docs/ui.md`). Буквальное `/#/dm:marta` не разбирается роутером и открывает список: уведомление ведёт не туда, куда обещало. + +**Состояний больше трёх.** `docs/ui.md` знает три: `включены`, `выключены`, `запрещены в браузере`. Кроме них бывает браузер без `Notification` и `PushManager` (iOS вне установленного приложения — как раз такой) и сервер без VAPID-ключа: включить нельзя, а сказать про это нечем. + +**У кнопки установки нет надписи.** «Кнопка, если есть `beforeinstallprompt`» — а что на ней написано, не сказано. + +**Подписка переживает то, к чему привязана.** Подписка принадлежит устройству (ADR-023), но живёт в браузерном профиле и не знает ни про `deviceId`, ни про аккаунт. `deviceId` меняется при конфликте идентификаторов и при чистке IndexedDB (ADR-017), аккаунт на устройстве меняется при выходе. Что делать с подпиской в эти моменты, не записано нигде. + +## Решение + +- **Переход.** Service worker переводит идентификатор чата в маршрут: `dm:` → `/#/dm/`, `room:` → `/#/room/`. Идентификатор не той формы открывает `/#/`. Формулировка ADR-023 читается так. +- **Состояния.** Их по-прежнему три. `запрещены в браузере` — это отклонённое разрешение, отсутствие `Notification` или `PushManager` и пустой `vapidPublicKey`: включить нельзя, кнопки в этом состоянии нет. `выключены` — всё, что включается кнопкой. На iOS вне установленного приложения раздел показывает `выключены` и вместо кнопки текст про установку — тот же, что в баннере. +- **Надписи.** Кнопка установки — «установить». Крестик баннера — «×» с подписью «закрыть» для экранного диктора. Тексты записаны в `docs/ui.md`. +- **Жизнь подписки.** При каждом запуске синхронизации клиент переставляет имеющуюся подписку на текущее устройство (`PUT /api/devices/{id}/push`): запрос идемпотентен, и смена `deviceId` этим и лечится. Выход из аккаунта снимает подписку и у сервера, и у браузера — сначала `DELETE /api/devices/{id}/push`, пока сессия жива, потом `pushManager.unsubscribe` (ADR-049). Удаление аккаунта отписывается только у браузера: строку устройства вместе с подпиской уносит каскад. + +## Следствия + +- Уведомление открывает тот чат, о котором оно: `chat` в нагрузке остаётся идентификатором из ADR-023, разбирает его клиент. +- Пользователь, у которого пушей не бывает вовсе, видит `запрещены в браузере` и не видит кнопки, которая ничего не даст. +- Подписка, поставленная не тому устройству, чинится следующим запуском приложения, а не остаётся молчащей навсегда. +- Устройство, с которого вышли, пушей прежнего аккаунта не получает: адреса подписки у сервера больше нет, даже если отозвать её у push-сервиса не удалось. diff --git a/docs/decisions/047-push-endpoint.md b/docs/decisions/047-push-endpoint.md new file mode 100644 index 0000000..94e6db2 --- /dev/null +++ b/docs/decisions/047-push-endpoint.md @@ -0,0 +1,27 @@ +# ADR-047: Исходящий запрос к push-сервису + +Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md). + +## Контекст + +Адрес push-сервиса выбирает браузер получателя: клиент присылает `endpoint` из `PushSubscription`, сервер хранит его и на каждое сообщение сам открывает к нему соединение. Это единственное место, где сервер ходит наружу по адресу, который назвал пользователь. Свойство появилось на этапе 4, и в модели угроз его не было. + +Проверки «endpoint — абсолютный https-url» для него мало. `http.Client` по умолчанию идёт за редиректами: один ответ `307` с настоящего https-хоста уводит запрос на plain http и на любой внутренний адрес — вместе с заголовком `Authorization: vapid`. Адрес может указывать внутрь и сразу: `https://127.0.0.1:…`, `https://169.254.169.254/…`, `https://10.0.0.1/`. Ответ наружу не пересылается, но `404` и `410` снимают подписку, а это видно в `GET /api/devices` полем `hasPush`: получается побитовое сканирование внутренней сети двумя своими аккаунтами. + +Рядом — две недопроверки формы. Длина `endpoint` не ограничена ничем, кроме общего предела тела: адрес на 20 КиБ ложился в базу. `p256dh` проверялся только по длине, хотя 65 случайных байт точкой кривой не являются: отправка на такую подписку падает при каждом сообщении, а устройство остаётся с ней навсегда. + +И журнал: адрес подписки уходил в строку отказа. Развернуть `*url.Error` мало — host и DNS-имя остаются внутри `*net.OpError` и ошибки резолвера, а `docs/deploy.md` обещает, что данных пользователя в журнале нет. + +## Решение + +- Редиректы не выполняются: `CheckRedirect` возвращает `http.ErrUseLastResponse`. Push-сервисы редиректов не шлют, а без этого требование https не значит ничего. +- Соединение возможно только с публичным адресом. Проверка стоит на `Control` диалера, то есть на уже разрешённом адресе: имя, указывающее внутрь, не помогает. Непубличные — loopback, приватные сети (RFC 1918 и RFC 4193), link-local, multicast и неопределённый адрес. +- `PUT /api/devices/{id}/push` отвергает `400 invalid` литеральный непубличный адрес и `endpoint` длиннее 2 КиБ, а `p256dh` разбирает как точку P-256. Это ранний отсев формы; решает всё равно проверка при соединении. +- Отказ отправки пишется в журнал классом: «таймаут», «имя не разрешилось», «адрес подписки не публичный», «отправка не удалась». Текст ошибки транспорта не печатается вовсе — внутри него адрес подписки. +- Разрешение ходить на непубличные адреса есть в конфигурации, но из окружения не читается и в работе всегда выключено. Оно нужно тестам, где push-сервис вендора подменён сервером на `127.0.0.1`. + +## Следствия + +- Сервер остаётся отправителем пушей и не становится инструментом запросов внутрь периметра: оракула `hasPush` по внутренним адресам больше нет. +- Свой push-сервис на внутреннем адресе работать не будет. Для v1 это верно: подписку выдаёт браузер, а вендоры живут в интернете. +- Остаток риска записан в `docs/threat-model.md`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет. diff --git a/docs/decisions/048-push-limits.md b/docs/decisions/048-push-limits.md new file mode 100644 index 0000000..38d3ba6 --- /dev/null +++ b/docs/decisions/048-push-limits.md @@ -0,0 +1,27 @@ +# ADR-048: Пределы отправки пушей + +Уточняет [ADR-023](023-push-and-service-worker.md). + +## Контекст + +ADR-023 говорит, кому и когда уходит пуш, но про пределы отправки не говорит ничего. Этап 4 сделал общую очередь на 256 заданий и четыре отправщика с таймаутом 10 секунд, без изоляции между аккаунтами. Прогон показал цену: аккаунт с сотней устройств на не отвечающем эндпоинте занимает всех отправщиков на минуты, и пуши посторонних пользователей в это время отбрасываются. Того же эффекта добивается не злой умысел, а медленный вендор. + +Рядом две лишние работы. В очередь ставились и устройства без подписки — отправить им нечего, а место они занимали. И на каждое отброшенное задание писалась строка в журнал, прямо из обработчика `POST /api/messages`: одно сообщение давало сотню строк — готовый усилитель для заливки журнала. + +Отдельно — само правило «пуш только молчащему устройству». Подключение проверялось в обработчике запроса, а право на пуш забиралось позже, в отправщике. Между этими моментами устройство успевает подключиться: подключение сбрасывает `push_pending`, отправщик тут же забирает его снова и шлёт пуш подключённому. Хуже последствие: право висит всю SSE-сессию и съедает первый пуш после ухода в офлайн. + +## Решение + +- Пуш ставится в очередь только устройству с подпиской: признак берётся тем же запросом, что и список устройств доставки. +- Заданий одного аккаунта в очереди и в работе — не больше четырёх. Лишние отбрасываются сразу, не занимая отправщика. +- Отправщиков восемь, таймаут запроса — 5 секунд, соединения — 3: вендоры отвечают за секунды, а таймаут задаёт потолок пропускной способности. +- Отброшенные пуши считаются, а не пишутся строкой каждый: в журнал уходит счётчик, не чаще раза в минуту. +- Подключение устройства проверяется в отправщике: до захвата права, сразу после захвата и после успешной отправки. Подключённому устройству право возвращается. +- Потолка на число устройств у аккаунта не вводим. Доля в отправке ограничена, устройства без подписки в очередь не попадают, а экран «устройства» — этап 5. + +## Следствия + +- Аккаунт с сотней молчащих устройств занимает не больше половины отправщиков: пуш постороннего уходит сразу. +- Отброшенный пуш не теряется навсегда: право на него не забиралось, `push_pending` устройства остался нулём, и следующее сообщение попробует снова. +- Подключённое устройство пуша не получает, а его право не остаётся висеть до конца сессии. +- Пропускная способность отправки — восемь заданий на пять секунд в худшем случае. Для маленького сервера это приемлемо; понадобится больше — менять числа, а не устройство. diff --git a/docs/decisions/049-notifications-off.md b/docs/decisions/049-notifications-off.md new file mode 100644 index 0000000..b2ee658 --- /dev/null +++ b/docs/decisions/049-notifications-off.md @@ -0,0 +1,25 @@ +# ADR-049: Выключенные уведомления остаются выключенными + +Уточняет [ADR-046](046-push-client.md). + +## Контекст + +Кнопка «выключить» снимала подписку у push-сервиса и у сервера, но следа о решении человека не оставляла. Дальше подписку возвращали два автоматических пути: `askOnce` после первого отправленного сообщения (разрешение уже дано — значит, ставим подписку) и `refresh` при каждом запуске приложения (подписка в браузере уцелела — переставим её на сервер). Человек нажимал «выключить», а уведомления включались обратно сами и молча. + +Рядом состояние «включены», которое считалось по одному факту наличия подписки. Подписка под прежней парой VAPID-ключей не работает: push-сервис отвечает на неё `403`, а это не `404` и не `410`, и сервер её не снимет. В настройках при этом написано «включены», а уведомлений нет. + +И выход из аккаунта. ADR-046 велел снимать подписку только у браузера, «сервер не спрашивая: сессии к этому моменту уже нет». Сессия на момент нажатия «выйти» ещё жива, а отписка у push-сервиса может не пройти — сети нет, вендор недоступен. Тогда строка `devices.push_subscription` остаётся живой, и пуши прежнего аккаунта рисуются на экране блокировки устройства, где уже вошёл другой человек. + +## Решение + +- В `meta` появляется `notificationsOff` — явный отказ. Его ставит «выключить», снимает «включить». При взведённом флаге `askOnce` и `refresh` не делают ничего, а раздел настроек показывает «выключены». +- «Включить» закрывает и вопрос о разрешении: `notificationsAsked` ставится здесь же — человек уже решил всё сам. +- Состояние «включены» требует подписки под текущим `vapidPublicKey`. Подписка под прежним ключом — «выключены», и кнопка «включить» переподпишет устройство. По той же причине `refresh` не переставляет на сервер подписку под чужим ключом. +- Выход из аккаунта сначала снимает подписку на сервере (`DELETE /api/devices/{id}/push`, пока сессия жива), потом закрывает сессию, потом отписывается у push-сервиса. Удаление аккаунта в этом не нуждается: строка устройства уходит каскадом вместе с подпиской. + +## Следствия + +- Выключенные уведомления включаются только кнопкой. +- Смена пары VAPID-ключей на сервере видна человеку как «выключены», а не как молчание при надписи «включены». +- Устройство, с которого вышли, пушей прежнего аккаунта не получает, даже если отписаться у push-сервиса не удалось: адреса подписки у сервера больше нет. +- В `meta` на один ключ больше — он записан в `docs/storage.md`. diff --git a/docs/deploy.md b/docs/deploy.md index ca226a1..e33d9cf 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -115,6 +115,8 @@ sudo systemctl daemon-reload && sudo systemctl enable --now bare ## Обновление — `scripts/deploy.sh` +Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag. + ```sh #!/bin/sh set -eu @@ -129,6 +131,7 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud - `curl -I https://bare.xmatic.team/` — 200, заголовки CSP и nosniff. - `curl -N https://bare.xmatic.team/api/events` — 401 (без cookie), без буферизации. +- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории. - `journalctl -u bare -f` — старт, применённые миграции, нет ошибок. ## Бэкап @@ -137,4 +140,4 @@ 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) пишутся отдельной строкой, без данных запроса. journald хранит по своим правилам. +Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам. diff --git a/docs/plan.md b/docs/plan.md index 718627f..bc73ee3 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -32,6 +32,8 @@ web/ js/api.js fetch-обёртки, SSE, ACK js/crypto.js всё из crypto.md js/db.js IndexedDB из storage.md + js/sync.js устройство, поток событий, приём и отправка + js/pwa.js service worker, подписка на пуши, установка js/ulid.js ULID js/ui/*.js экраны из ui.md js/export.js .bare @@ -84,6 +86,30 @@ Go — последняя стабильная версия, маршрутиз Готово, когда закрытое PWA на iPhone и Android получает пуш и открывается на нужном чате; повторные сообщения до открытия пуш не порождают. +### Чеклист ручной проверки на устройствах + +Автоматически проверено всё, что проверяется без настоящих устройств: правило «одно +молчащее устройство — один пуш», сброс `push_pending` при подключении SSE, удаление +подписки на 404/410, расшифровка пуша по RFC 8291 в тесте, отсутствие плейнтекста +в нагрузке, кэш оболочки без `/api/*`, отказ ходить на непубличные адреса. Осталось +то, что требует рук и телефона: + +- [ ] **iPhone, установленное на «Домой» приложение**: пуш приходит при закрытом + приложении, нажатие открывает нужный чат. +- [ ] **Android Chrome, закрытое приложение**: то же самое. +- [ ] **Клик по системному уведомлению** в обоих случаях: в уже открытое окно + (фокус и переход) и при закрытом приложении (`/#/dm/`, `/#/room/`). +- [ ] **Повторные сообщения до открытия**: второе и третье пуша не порождают. +- [ ] **iOS вне PWA**: баннер установки над списком чатов, текст, крестик и то, + что он больше не появляется; в настройках «уведомления» — текст про установку + вместо кнопки. +- [ ] **`beforeinstallprompt`** в обычном Chrome: раздел «установить приложение» + появляется, после нажатия исчезает целиком. +- [ ] **Системный запрос разрешения** после первого отправленного сообщения: + headless-Chrome отвечает `denied` сам, живой диалог не проверялся. +- [ ] **Прогон сценариев «готово, когда»** этапов 1–5 в Safari (iOS и десктоп) + и Firefox — автоматика гоняла только Chrome. + ## Этап 5 — история - Экспорт и импорт `.bare` по `crypto.md` и `storage.md`; идемпотентность; «архив создан другим аккаунтом». diff --git a/docs/protocol.md b/docs/protocol.md index ab9c577..4934173 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -68,9 +68,9 @@ WrappedKey { to: nick, iv: string, ct: string } `DELETE /api/devices/{id}` → `204`. Удаляет очередь, подписку и сессии, привязанные к устройству. Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`. -`PUT /api/devices/{id}/push {subscription}` → `204`. `subscription` — объект `PushSubscription.toJSON()`. Сбрасывает `push_pending`. +`PUT /api/devices/{id}/push {subscription}` → `204`. `subscription` — объект `PushSubscription.toJSON()`: `endpoint` — абсолютный `https`-адрес до 2 КиБ на публичный адрес (литеральные loopback, link-local и приватные адреса — `400 invalid`, ADR-047), `keys.p256dh` — точка кривой P-256 в 65 байтах base64url, `keys.auth` — 16 байт base64url; прочие поля, включая `expirationTime`, сервер не хранит. Сбрасывает `push_pending`. Устройство в пути, как и `X-Device`, обязано принадлежать пользователю сессии. -`DELETE /api/devices/{id}/push` → `204`. +`DELETE /api/devices/{id}/push` → `204`; подписки не было — тот же `204`, чужое устройство — `403 unknown_device`. ## Контакты @@ -86,7 +86,7 @@ WrappedKey { to: nick, iv: string, ct: string } Проверки по порядку: формат полей (`400 invalid`); время ULID в пределах ±5 минут от серверного (`400 clock_skew`); для `dm` — существование ника (`404 unknown_user`), не себе (`400 self`); для `room` — членство (`403 not_member`), `keyId` среди ключей комнаты (`400 unknown_key`); лимит (`429`). -Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши по правилам ADR-023. +Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши устройствам получателей по правилам ADR-023 и ADR-045. `POST /api/ack {ids: string[]}` → `204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. diff --git a/docs/storage.md b/docs/storage.md index e0d0d00..30e31aa 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -115,7 +115,7 @@ meta key: string → value deviceId, nick, publicKey (JWK), fingerprint, privateKey (CryptoKey ECDH, non-extractable), accountSecret (CryptoKey HKDF, non-extractable), - notificationsAsked (bool), installBannerDismissed (bool) + notificationsAsked (bool), notificationsOff (bool), installBannerDismissed (bool) chats key: id // "dm:" | "room:" {id, type: "dm"|"room", title, peer?, roomId?, owner?, members?: nick[], diff --git a/docs/threat-model.md b/docs/threat-model.md index 7973e2a..f476a52 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -41,3 +41,5 @@ **Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером. **Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша. + +**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов. diff --git a/docs/ui.md b/docs/ui.md index d1716c0..4a1f4fe 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -57,8 +57,8 @@ ## Настройки (`#/settings`) - «ты: @nick», свой отпечаток. -- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка включить/выключить. На iOS вне PWA — текст про установку. -- «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»». +- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере. +- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет. - «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». - «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». - «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён». @@ -67,12 +67,16 @@ ## Баннер установки (iOS) -Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — `installBannerDismissed`, повтор не показывается. +Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — «×» с подписью «закрыть» для экранного диктора — ставит `installBannerDismissed`, повтор не показывается. ## Уведомления Запрос разрешения — после первого успешно отправленного сообщения, один раз (`notificationsAsked`). После `granted` — `pushManager.subscribe` с `vapidPublicKey` и `PUT /api/devices/{id}/push`. Отказ — молча; включить можно в настройках. +Выключенные кнопкой уведомления сами не включаются: ни первым сообщением, ни запуском приложения. Обратно их включает только кнопка (ADR-049). + +Уведомление: заголовок — `@nick` отправителя или `#имя комнаты`, текст — «новое сообщение», нажатие открывает этот чат. Содержимого сообщения в уведомлении нет: сервер его не знает (ADR-011). Пуш о собственном сообщении не приходит (ADR-045). + ## Сеть и состояния - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. diff --git a/go.mod b/go.mod index 6459dbb..2041e9b 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/xmatic-squad/bare go 1.27.0 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.57.0 ) require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect diff --git a/go.sum b/go.sum index e2c6978..70cbd37 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,10 @@ +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -12,16 +17,79 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/api/api.go b/internal/api/api.go index aefbccc..7bb0c72 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -16,6 +16,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/config" "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/push" "github.com/xmatic-squad/bare/internal/store" ) @@ -34,27 +35,39 @@ type server struct { cfg *config.Config st *store.Store hub *hub.Hub + push *push.Sender msgs *buckets logw io.Writer } -// Handler — обработчик всех маршрутов и живые SSE-потоки за ним. +// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей +// за ним. type Handler struct { http.Handler - hub *hub.Hub + hub *hub.Hub + push *push.Sender } -// Close закрывает открытые потоки событий. Без него остановка сервера -// ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004). -func (h *Handler) Close() { h.hub.CloseAll() } +// Close закрывает открытые потоки событий и останавливает отправку +// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами: +// у потока нет конца (ADR-004). +func (h *Handler) Close() { + h.hub.CloseAll() + h.push.Close() +} // New собирает обработчик: /api/, /healthz, всё остальное — статика. // logw — куда писать строки запросов и причины отказов; nil отключает лог. func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler { + // Отправитель пушей спрашивает у hub, подключено ли устройство: + // решение «пуш только молчащему» принимается в момент захвата права + // на него, а не при постановке в очередь (ADR-023). + live := hub.New() s := &server{ cfg: cfg, st: st, - hub: hub.New(), + hub: live, + push: push.New(cfg, st, live.Connected, logw), msgs: newBuckets(messagesPerMinute, messagesBurst), logw: logw, } @@ -79,6 +92,8 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write mux.Handle("POST /api/devices", private(http.HandlerFunc(s.createDevice))) mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices))) mux.Handle("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice))) + mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush))) + mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush))) mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts))) mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) @@ -103,6 +118,7 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write return &Handler{ Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))), hub: s.hub, + push: s.push, } } diff --git a/internal/api/api_test.go b/internal/api/api_test.go index f3df3e3..5542e3d 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -58,6 +58,12 @@ func newEnv(t *testing.T) *env { return invited(t, "") } // invited — сервер на временной базе; непустой code включает инвайты. func invited(t *testing.T, code string) *env { + t.Helper() + return envWith(t, func(cfg *config.Config) { cfg.InviteCode = code }) +} + +// envWith — сервер на временной базе; tweak правит конфигурацию до старта. +func envWith(t *testing.T, tweak func(*config.Config)) *env { t.Helper() static, err := web.New() if err != nil { @@ -74,10 +80,14 @@ func invited(t *testing.T, code string) *env { DB: "bare.db", Origin: origin, VAPIDPublic: "vapid", - InviteCode: code, } + tweak(cfg) e := &env{t: t, st: st, log: &syncLog{}} - e.h = api.New(cfg, st, static, e.log) + h := api.New(cfg, st, static, e.log) + // Обработчик закрывается раньше базы: отправщики пушей дописывают + // начатое, а база им ещё нужна. + t.Cleanup(h.Close) + e.h = h return e } diff --git a/internal/api/devices_test.go b/internal/api/devices_test.go index 21cc5b0..6c31768 100644 --- a/internal/api/devices_test.go +++ b/internal/api/devices_test.go @@ -80,11 +80,6 @@ func TestDevices(t *testing.T) { if list[0].Current || !list[1].Current { t.Errorf("текущее устройство второй сессии: %+v", list) } - - // Push-подписка — этап 4: пути ещё нет, а неизвестный путь отвечает - // 404 not_found (ADR-026). - expect(t, e.do(http.MethodPut, "/api/devices/"+id+"/push", map[string]any{}, with(c)), - http.StatusNotFound, "not_found") } // Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017). diff --git a/internal/api/messages.go b/internal/api/messages.go index 929d5c1..ec6ab0f 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -8,6 +8,7 @@ import ( "github.com/xmatic-squad/bare/internal/auth" "github.com/xmatic-squad/bare/internal/hub" + "github.com/xmatic-squad/bare/internal/push" "github.com/xmatic-squad/bare/internal/store" ) @@ -76,22 +77,29 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { sess, _ := auth.From(r) room := in.To.Room != "" + // Заголовок и адрес чата для пуша: сервер собирает их из того, что + // и так знает, — из ников и имени комнаты (ADR-023). + var signal push.Payload if room { - member, knownKey, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) + access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID) if err != nil { s.internal(w, r, err) return } - if !member { + if !access.Member { Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты") return } - if !knownKey { + if !access.KnownKey { Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа") return } - } else if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { - return + signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room} + } else { + if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok { + return + } + signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick} } if wait, ok := s.msgs.take(sess.Nick, now); !ok { @@ -122,7 +130,7 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { Envelope: string(raw), Now: env.TS, } - var devices []string + var devices []store.Target if room { devices, err = s.st.DeliverRoom(r.Context(), delivery) } else { @@ -133,16 +141,39 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { return } // Очередь уже записана: подключённое устройство получает конверт - // сразу, остальные — при подключении. Пуши — этап 4. - for _, id := range devices { - s.hub.Send(id, hub.Event{Name: "msg", Data: string(raw)}) + // сразу, остальные — при подключении. + for _, target := range devices { + s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)}) } + // Пуш — побочный эффект доставки, а не её часть: конверт уже + // в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023). + s.push.Send(s.silent(devices, env.From), signal) writeJSON(w, http.StatusAccepted, struct { ID string `json:"id"` TS int64 `json:"ts"` }{env.ID, env.TS}) } +// silent — устройства, которым нужен пуш: чужие (устройства отправителя +// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат +// поток событий (ADR-023). +// +// Устройство без подписки отсеивается здесь: отправить ему нечего, +// а место в очереди отправки оно заняло бы (ADR-048). Проверка на +// подключение — ранний отсев: решает её повтор в момент захвата права +// на пуш, потому что между этой строкой и отправкой устройство успевает +// подключиться (ADR-023). +func (s *server) silent(targets []store.Target, from string) []push.Target { + var out []push.Target + for _, target := range targets { + if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) { + continue + } + out = append(out, push.Target{Device: target.ID, Owner: target.Nick}) + } + return out +} + // checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер // проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан, // если вернулось false. diff --git a/internal/api/push.go b/internal/api/push.go new file mode 100644 index 0000000..0e8be40 --- /dev/null +++ b/internal/api/push.go @@ -0,0 +1,163 @@ +package api + +import ( + "crypto/ecdh" + "encoding/base64" + "encoding/json" + "net/http" + "net/netip" + "net/url" + + "github.com/xmatic-squad/bare/internal/auth" + "github.com/xmatic-squad/bare/internal/push" +) + +// Push-подписка принадлежит устройству (ADR-023): её ставит и снимает +// само устройство. Сервер хранит подписку как непрозрачный JSON и лезет +// в неё только при отправке. + +// Длины ключей подписки (RFC 8291): p256dh — несжатая точка P-256, +// auth — общий секрет. +const ( + p256dhLen = 65 + authLen = 16 + // maxEndpoint — предел длины адреса подписки. Адреса вендоров — + // две-три сотни символов; всё остальное push-сервисом не является, + // а прочие поля протокола ограничены явно (docs/protocol.md). + maxEndpoint = 2 << 10 +) + +// subscriptionIn — объект PushSubscription.toJSON(). Поле expirationTime +// браузеры кладут рядом; сервер его не читает и не хранит — хранится +// ровно то, что нужно для отправки. +type subscriptionIn struct { + Endpoint string `json:"endpoint"` + Keys struct { + P256dh string `json:"p256dh"` + Auth string `json:"auth"` + } `json:"keys"` +} + +// PUT /api/devices/{id}/push — подписка устройства на пуши. Сбрасывает +// неотработанный пуш: устройство снова готово его принять (ADR-023). +// +// X-Device на этом маршруте обязателен, и устройство в пути тоже обязано +// быть своим: чужому устройству подписку не поставить (docs/protocol.md, +// «Общие правила»). +func (s *server) setPush(w http.ResponseWriter, r *http.Request) { + var in struct { + Subscription subscriptionIn `json:"subscription"` + } + if !decode(w, r, &in) { + return + } + // Форма проверяется раньше прав (ADR-043). + subscription, ok := checkSubscription(w, in.Subscription) + if !ok { + return + } + if _, ok := s.device(w, r); !ok { + return + } + sess, _ := auth.From(r) + set, err := s.st.SetPush(r.Context(), r.PathValue("id"), sess.Nick, subscription) + if err != nil { + s.internal(w, r, err) + return + } + if !set { + unknownDevice(w) + return + } + noContent(w) +} + +// DELETE /api/devices/{id}/push — снять подписку. Подписки не было — +// тот же 204: снимать нечего. Чужое устройство — 403, как и на PUT. +func (s *server) deletePush(w http.ResponseWriter, r *http.Request) { + if _, ok := s.device(w, r); !ok { + return + } + sess, _ := auth.From(r) + cleared, err := s.st.ClearPush(r.Context(), r.PathValue("id"), sess.Nick) + if err != nil { + s.internal(w, r, err) + return + } + if !cleared { + unknownDevice(w) + return + } + noContent(w) +} + +// checkSubscription проверяет форму подписки и отдаёт её канонический +// JSON: три поля и ничего больше. Ответ об ошибке уже написан, если +// вернулось false. +func checkSubscription(w http.ResponseWriter, in subscriptionIn) (string, bool) { + if !validEndpoint(in.Endpoint) { + Invalid(w, "subscription", "endpoint — не публичный https-url до 2 КиБ") + return "", false + } + if !pushPoint(in.Keys.P256dh) { + Invalid(w, "subscription", "keys.p256dh — не точка p-256 в 65 байтах base64url") + return "", false + } + if _, ok := pushKey(in.Keys.Auth, authLen); !ok { + Invalid(w, "subscription", "keys.auth — не 16 байт base64url") + return "", false + } + out, err := json.Marshal(in) + if err != nil { + return "", false + } + return string(out), true +} + +// validEndpoint — адрес push-сервиса. Выбирает его браузер, сервер знает +// о нём только то, что это абсолютный https-url разумной длины: без TLS +// пуш ушёл бы открытым текстом мимо всех обещаний. +// +// Литеральный непубличный адрес отвергается сразу: push-сервиса по нему +// не бывает, а внутренняя служба бывает (ADR-047). Имя здесь не +// разрешается — за именем всё равно может стоять внутренний адрес, +// поэтому решающая проверка идёт при соединении, в отправщике. +func validEndpoint(raw string) bool { + if raw == "" || len(raw) > maxEndpoint { + return false + } + u, err := url.Parse(raw) + if err != nil || u.Scheme != "https" || u.Host == "" { + return false + } + ip, err := netip.ParseAddr(u.Hostname()) + if err != nil { + // Не литерал, а имя: его разберёт отправщик. + return true + } + return push.Public(ip) +} + +// pushPoint — p256dh: несжатая точка кривой P-256. Одной длины мало: +// случайные 65 байт точкой не являются, отправка на них падает при +// каждом сообщении, а устройство остаётся с подпиской, которая никогда +// не заработает (docs/protocol.md, «Устройства»). +func pushPoint(s string) bool { + raw, ok := pushKey(s, p256dhLen) + if !ok { + return false + } + _, err := ecdh.P256().NewPublicKey(raw) + return err == nil +} + +// pushKey — ключ подписки: ровно n байт base64url. Push API задаёт форму +// без паддинга, но браузер, добавивший паддинг, не должен остаться без +// уведомлений: webpush-go разбирает обе формы, и сервер принимает обе. +func pushKey(s string, n int) ([]byte, bool) { + if raw, err := b64.DecodeString(s); err == nil { + return raw, len(raw) == n + } + raw, err := base64.URLEncoding.DecodeString(s) + return raw, err == nil && len(raw) == n +} diff --git a/internal/api/push_test.go b/internal/api/push_test.go new file mode 100644 index 0000000..5ab79b1 --- /dev/null +++ b/internal/api/push_test.go @@ -0,0 +1,806 @@ +package api_test + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/xmatic-squad/bare/internal/config" +) + +// quiet — сколько ждём, чтобы убедиться, что пуша нет. Всё локально, +// задержек быть не должно. +const quiet = 300 * time.Millisecond + +// pushEnv — сервер с настоящей парой VAPID-ключей: без неё пуши выключены +// (docs/deploy.md). +func pushEnv(t *testing.T) *env { return pushEnvWith(t, true) } + +// pushEnvWith — то же; local разрешает отправку на 127.0.0.1, где живёт +// подменный push-сервис. Настоящий сервер ходит только по публичным +// адресам (ADR-047), и это проверяется отдельно. +func pushEnvWith(t *testing.T, local bool) *env { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("vapid: %v", err) + } + return envWith(t, func(cfg *config.Config) { + cfg.VAPIDPublic = raw64(key.PublicKey().Bytes()) + cfg.VAPIDPrivate = raw64(key.Bytes()) + cfg.VAPIDSubject = "mailto:bare@bare.test" + // Push-сервис вендора подменён сервером на 127.0.0.1: в работе + // отправщик ходит только по публичным адресам (ADR-047). + cfg.PushLocal = local + }) +} + +func raw64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +// padded64 — то же, что bytesOf, но с паддингом: браузер вправе прислать +// ключи подписки и в такой форме. +func padded64(n int, seed byte) string { + raw := make([]byte, n) + for i := range raw { + raw[i] = seed + byte(i) + } + return base64.URLEncoding.EncodeToString(raw) +} + +// point65 — p256dh настоящей подписки: несжатая точка P-256 в 65 байтах. +// Случайные байты той же длины точкой не являются, и сервер их не примет +// (docs/protocol.md, «Устройства»). +func point65(t *testing.T) []byte { + t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ключ подписки: %v", err) + } + return key.PublicKey().Bytes() +} + +// pushService — push-сервис вендора в тесте. Настоящий FCM тестам не нужен +// и не годится: проверяется, что уходит и что сервер делает с ответом. +type pushService struct { + t *testing.T + url string + got chan delivered + status atomic.Int32 +} + +// delivered — то, что увидел push-сервис. +type delivered struct { + device string // хвост endpoint: по нему видно, чей это пуш + ttl string + urgency string + encoding string + auth string + record []byte +} + +func newPushService(t *testing.T) *pushService { + t.Helper() + open := make(chan struct{}) + close(open) + return pushServiceWith(t, open) +} + +// newSlowPushService — push-сервис, который принимает запрос и молчит, +// пока тест не отпустит его. Так видно, что делает сервер, пока отправка +// ещё идёт. Отпускать обязательно: иначе остановка сервера ждёт таймаута. +func newSlowPushService(t *testing.T) (*pushService, func()) { + t.Helper() + gate := make(chan struct{}) + var once sync.Once + return pushServiceWith(t, gate), func() { once.Do(func() { close(gate) }) } +} + +// pushServiceWith — push-сервис, отвечающий не раньше, чем закроется gate. +func pushServiceWith(t *testing.T, gate <-chan struct{}) *pushService { + t.Helper() + p := &pushService{t: t, got: make(chan delivered, 512)} + p.status.Store(http.StatusCreated) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + record, _ := io.ReadAll(r.Body) + got := delivered{ + device: strings.TrimPrefix(r.URL.Path, "/push/"), + ttl: r.Header.Get("TTL"), + urgency: r.Header.Get("Urgency"), + encoding: r.Header.Get("Content-Encoding"), + auth: r.Header.Get("Authorization"), + record: record, + } + select { + case p.got <- got: + default: + } + select { + case <-gate: + case <-r.Context().Done(): + return + } + w.WriteHeader(int(p.status.Load())) + })) + t.Cleanup(srv.Close) + p.url = srv.URL + return p +} + +// next — следующий пуш; его отсутствие — ошибка теста. +func (p *pushService) next() delivered { + p.t.Helper() + select { + case got := <-p.got: + return got + case <-time.After(wait): + p.t.Fatal("пуш не пришёл") + } + return delivered{} +} + +// silent требует, чтобы других пушей не было. +func (p *pushService) silent() { + p.t.Helper() + select { + case got := <-p.got: + p.t.Fatalf("лишний пуш устройству %s", got.device) + case <-time.After(quiet): + } +} + +// subscriber — устройство с push-подпиской. Ключи настоящие: тест +// расшифровывает пуш ровно так, как это сделал бы браузер (RFC 8291), +// и потому видит, что в нём лежит. +type subscriber struct { + device string + key *ecdh.PrivateKey + auth []byte +} + +// subscribe кладёт подписку устройства прямо в базу. Через PUT её сюда +// не поставить: тестовый push-сервис живёт на http, а эндпоинт принимает +// только https. Форму подписки проверяют TestPushSubscription +// и TestPushSubscriptionForm, правила отправки от неё не зависят. +func (e *env) subscribe(nick, device string, p *pushService) *subscriber { + e.t.Helper() + key, err := ecdh.P256().GenerateKey(rand.Reader) + if err != nil { + e.t.Fatalf("ключ подписки: %v", err) + } + auth := make([]byte, 16) + if _, err := rand.Read(auth); err != nil { + e.t.Fatalf("секрет подписки: %v", err) + } + raw, err := json.Marshal(map[string]any{ + "endpoint": p.url + "/push/" + device, + "keys": map[string]string{ + "p256dh": raw64(key.PublicKey().Bytes()), + "auth": raw64(auth), + }, + }) + if err != nil { + e.t.Fatalf("подписка: %v", err) + } + set, err := e.st.SetPush(context.Background(), device, nick, string(raw)) + if err != nil || !set { + e.t.Fatalf("SetPush: %v (поставлена: %v)", err, set) + } + return &subscriber{device: device, key: key, auth: auth} +} + +// open расшифровывает пуш: aes128gcm по RFC 8291, как это делает браузер. +// Без расшифровки нельзя утверждать, что в пуше нет ничего лишнего. +func (s *subscriber) open(t *testing.T, record []byte) map[string]string { + t.Helper() + check := func(what string, err error) { + t.Helper() + if err != nil { + t.Fatalf("%s: %v", what, err) + } + } + // Заголовок записи: соль, размер записи, длина открытого ключа. + const header = 16 + 4 + 1 + if len(record) < header { + t.Fatalf("запись короче заголовка: %d байт", len(record)) + } + salt := record[:16] + keyLen := int(record[20]) + if len(record) < header+keyLen { + t.Fatalf("запись короче ключа отправителя: %d байт", len(record)) + } + sender, ct := record[header:header+keyLen], record[header+keyLen:] + + remote, err := ecdh.P256().NewPublicKey(sender) + check("ключ отправителя", err) + shared, err := s.key.ECDH(remote) + check("ecdh", err) + + info := append([]byte("WebPush: info\x00"), s.key.PublicKey().Bytes()...) + info = append(info, sender...) + ikm, err := hkdf.Key(sha256.New, shared, s.auth, string(info), 32) + check("ikm", err) + cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16) + check("ключ записи", err) + nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12) + check("nonce", err) + + block, err := aes.NewCipher(cek) + check("aes", err) + gcm, err := cipher.NewGCM(block) + check("gcm", err) + plain, err := gcm.Open(nil, nonce, ct, nil) + check("расшифровка", err) + + // Хвост записи — набивка: нули после разделителя 0x02. + plain = bytes.TrimSuffix(bytes.TrimRight(plain, "\x00"), []byte{2}) + var out map[string]string + if err := json.Unmarshal(plain, &out); err != nil { + t.Fatalf("нагрузка %q: %v", plain, err) + } + return out +} + +// hasPush — что о подписке устройства говорит GET /api/devices. +func (e *env) hasPush(c *http.Cookie, device string) bool { + e.t.Helper() + rec := e.do(http.MethodGet, "/api/devices", nil, with(c)) + expect(e.t, rec, http.StatusOK, "") + var list []struct { + ID string `json:"id"` + HasPush bool `json:"hasPush"` + } + decodeBody(e.t, rec, &list) + for _, got := range list { + if got.ID == device { + return got.HasPush + } + } + e.t.Fatalf("устройства %s нет в списке", device) + return false +} + +// waitPushGone ждёт, пока подписка исчезнет: снимает её отправщик, уже +// после того, как push-сервис ответил. +func (e *env) waitPushGone(c *http.Cookie, device string) { + e.t.Helper() + for deadline := time.Now().Add(wait); time.Now().Before(deadline); { + if !e.hasPush(c, device) { + return + } + time.Sleep(5 * time.Millisecond) + } + e.t.Fatalf("подписка устройства %s не снята", device) +} + +// send — обычная отправка личного сообщения. +func (e *env) send(c *http.Cookie, device, to string, seed byte) { + e.t.Helper() + expect(e.t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), seed), to), + with(c), withDevice(device)), http.StatusAccepted, "") +} + +// pushEventually шлёт сообщения, пока не придёт пуш. И разрыв потока, +// и возврат права на пуш случаются после ответа на запрос: момент их +// наступления не назначить, поэтому попытка повторяется. +func (e *env) pushEventually(p *pushService, c *http.Cookie, device, to string) delivered { + e.t.Helper() + for i := 0; i < 8; i++ { + e.send(c, device, to, byte(50+i)) + select { + case got := <-p.got: + return got + case <-time.After(200 * time.Millisecond): + } + } + e.t.Fatal("пуш так и не пришёл") + return delivered{} +} + +// subscription — тело PUT /api/devices/{id}/push в форме +// PushSubscription.toJSON(). +func subscription(t *testing.T) map[string]any { + t.Helper() + return map[string]any{ + "endpoint": "https://push.example/one", + "expirationTime": nil, + "keys": map[string]string{"p256dh": raw64(point65(t)), "auth": bytesOf(16, 7)}, + } +} + +// Подписка ставится и снимается, hasPush честный, чужое устройство — 403 +// (docs/protocol.md, «Устройства», «Общие правила»). +func TestPushSubscription(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + body := map[string]any{"subscription": subscription(t)} + + if e.hasPush(marta, m1) { + t.Error("hasPush до подписки: true") + } + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, with(marta), withDevice(m1)), + http.StatusNoContent, "") + if !e.hasPush(marta, m1) { + t.Error("hasPush после подписки: false") + } + // Подписка принадлежит устройству: у соседа её не появилось. + if e.hasPush(petya, p1) { + t.Error("подписка досталась чужому устройству") + } + + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)), + http.StatusNoContent, "") + if e.hasPush(marta, m1) { + t.Error("hasPush после снятия: true") + } + // Снимать нечего — тот же 204. + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)), + http.StatusNoContent, "") + + // Чужое устройство в пути — 403, и подписки у него не появилось. + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", body, with(marta), withDevice(m1)), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodDelete, "/api/devices/"+p1+"/push", nil, with(marta), withDevice(m1)), + http.StatusForbidden, "unknown_device") + if e.hasPush(petya, p1) { + t.Error("подписка поставлена чужому устройству") + } + + // X-Device обязателен и обязан быть своим. + for _, opts := range [][]func(*http.Request){ + {with(marta)}, + {with(marta), withDevice(p1)}, + {with(marta), withDevice("мусор")}, + {with(marta), withDevice(deviceOf(9))}, + } { + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, opts...), + http.StatusForbidden, "unknown_device") + expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, opts...), + http.StatusForbidden, "unknown_device") + } + if e.hasPush(marta, m1) { + t.Error("подписка появилась после отказа") + } +} + +// Форма подписки: абсолютный https-адрес и два ключа нужной длины. +func TestPushSubscriptionForm(t *testing.T) { + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + + cases := []struct { + name string + change func(map[string]any) + }{ + {"нет endpoint", func(s map[string]any) { delete(s, "endpoint") }}, + {"endpoint без tls", func(s map[string]any) { s["endpoint"] = "http://push.example/one" }}, + {"endpoint без хоста", func(s map[string]any) { s["endpoint"] = "https:///one" }}, + {"endpoint не url", func(s map[string]any) { s["endpoint"] = "какой же это url" }}, + {"нет ключей", func(s map[string]any) { delete(s, "keys") }}, + {"endpoint на loopback", func(s map[string]any) { s["endpoint"] = "https://127.0.0.1:9/push" }}, + {"endpoint на link-local", func(s map[string]any) { + s["endpoint"] = "https://169.254.169.254/latest/meta-data/" + }}, + {"endpoint в приватной сети", func(s map[string]any) { s["endpoint"] = "https://10.0.0.1/push" }}, + {"endpoint на ::1", func(s map[string]any) { s["endpoint"] = "https://[::1]:8411/api/me" }}, + {"endpoint длиннее 2 КиБ", func(s map[string]any) { + s["endpoint"] = "https://push.example/" + strings.Repeat("a", 2048) + }}, + {"p256dh не 65 байт", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(32, 5), "auth": bytesOf(16, 7)} + }}, + {"p256dh не точка на кривой", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(16, 7)} + }}, + {"auth не 16 байт", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(32, 7)} + }}, + {"ключ не base64url", func(s map[string]any) { + s["keys"] = map[string]string{"p256dh": strings.Repeat("!", 87), "auth": bytesOf(16, 7)} + }}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + sub := subscription(t) + c.change(sub) + rec := e.do(http.MethodPut, "/api/devices/"+m1+"/push", + map[string]any{"subscription": sub}, with(marta), withDevice(m1)) + expect(t, rec, http.StatusBadRequest, "invalid") + var field struct { + Field string `json:"field"` + } + decodeBody(t, rec, &field) + if field.Field != "subscription" { + t.Errorf("field: получено %q, ожидалось \"subscription\"", field.Field) + } + }) + } + if e.hasPush(marta, m1) { + t.Error("подписка не по форме поставилась") + } + + // Паддинг в base64url тоже принимается: браузер вправе его добавить. + padded := subscription(t) + padded["keys"] = map[string]string{ + "p256dh": base64.URLEncoding.EncodeToString(point65(t)), + "auth": padded64(16, 7), + } + expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", + map[string]any{"subscription": padded}, with(marta), withDevice(m1)), http.StatusNoContent, "") + + // Форма проверяется раньше прав: на запрос к чужому устройству + // приходит отказ по форме, а не по правам (ADR-043). + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", "не json", with(marta), withDevice(m1)), + http.StatusBadRequest, "bad_json") + broken := subscription(t) + broken["endpoint"] = "http://push.example/one" + expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", + map[string]any{"subscription": broken}, with(marta), withDevice(m1)), http.StatusBadRequest, "invalid") +} + +// Пуш уходит только отключённому устройству с подпиской (ADR-023). +// Он несёт заголовок, «новое сообщение» и адрес чата — и ничего больше. +func TestPushToSilentDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + e.subscribe("petya", p1, svc) // подключено по SSE + silent := e.subscribe("petya", p2, svc) // молчит + + stream := e.open(p1, petya) + stream.untilReady() + e.send(marta, m1, "petya", 4) + + got := svc.next() + if got.device != p2 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p2) + } + // TTL сутки, urgency normal (ADR-023). + if got.ttl != "86400" { + t.Errorf("TTL: получено %q, ожидалось \"86400\"", got.ttl) + } + if got.urgency != "normal" { + t.Errorf("Urgency: получено %q, ожидалось \"normal\"", got.urgency) + } + if got.encoding != "aes128gcm" { + t.Errorf("Content-Encoding: получено %q, ожидалось \"aes128gcm\"", got.encoding) + } + if !strings.HasPrefix(got.auth, "vapid t=") { + t.Errorf("Authorization: получено %q, ожидался vapid", got.auth) + } + + payload := silent.open(t, got.record) + if len(payload) != 3 { + t.Errorf("поля нагрузки: %v", payload) + } + if payload["title"] != "@marta" { + t.Errorf("title: получено %q, ожидалось \"@marta\"", payload["title"]) + } + if payload["body"] != "новое сообщение" { + t.Errorf("body: получено %q, ожидалось \"новое сообщение\"", payload["body"]) + } + if payload["chat"] != "dm:marta" { + t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"]) + } + // Шифротекста сообщения в пуше нет ни в каком виде: сервер его + // не пересылает, а плейнтекста он и не знает (ADR-011). + if bytes.Contains(got.record, []byte(bytesOf(48, 23))) { + t.Error("шифротекст сообщения попал в пуш") + } + svc.silent() +} + +// Одно молчащее устройство получает один пуш, а не ленту (ADR-023). +func TestPushOncePerSilentDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + if got := svc.next(); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + // Второе и третье сообщение подряд пуша не порождают. + e.send(marta, m1, "petya", 4) + e.send(marta, m1, "petya", 5) + svc.silent() +} + +// Подключение по SSE сбрасывает неотработанный пуш: следующее сообщение +// молчащему устройству снова даёт пуш (ADR-023). +func TestPushAgainAfterStream(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + e.send(marta, m1, "petya", 4) + svc.silent() + + stream := e.open(p1, petya) + stream.untilReady() + stream.close() + + if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } +} + +// 404 и 410 от push-сервиса означают, что подписки больше нет (ADR-011). +func TestPushDeadSubscription(t *testing.T) { + for _, status := range []int{http.StatusNotFound, http.StatusGone} { + t.Run(http.StatusText(status), func(t *testing.T) { + svc := newPushService(t) + svc.status.Store(int32(status)) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + e.waitPushGone(petya, p1) + }) + } +} + +// Прочие отказы push-сервиса подписку не трогают и доставку сообщения +// не роняют. Право на пуш при этом возвращается: иначе одна ошибка +// затыкала бы уведомления устройства до самого подключения. +func TestPushServiceError(t *testing.T) { + svc := newPushService(t) + svc.status.Store(http.StatusInternalServerError) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.next() + if !e.hasPush(petya, p1) { + t.Error("подписка снята по ответу 500") + } + + svc.status.Store(http.StatusCreated) + if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + if !e.hasPush(petya, p1) { + t.Error("подписка снята после успешного пуша") + } +} + +// Отправитель пуша о собственном сообщении не получает — ни на то +// устройство, с которого писал, ни на остальные свои (ADR-045). +func TestPushNotToSender(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + m2 := e.addDevice(marta, deviceOf(2)) + _, p1 := e.join("petya", 3) + e.subscribe("marta", m1, svc) + e.subscribe("marta", m2, svc) + to := e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 4) + + got := svc.next() + if got.device != p1 { + t.Fatalf("пуш ушёл устройству отправителя %s", got.device) + } + svc.silent() + if payload := to.open(t, got.record); payload["chat"] != "dm:marta" { + t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"]) + } +} + +// Пуш из комнаты: заголовок — имя комнаты, адрес чата — её идентификатор +// (ADR-023). +func TestPushFromRoom(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1)) + expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 41), + http.StatusOK, "") + member := e.subscribe("petya", p1, svc) + + expect(t, e.do(http.MethodPost, "/api/messages", + roomMessage(ulid(nowMillis(), 5), room.ID, keyID(41)), with(marta), withDevice(m1)), + http.StatusAccepted, "") + + got := svc.next() + if got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + payload := member.open(t, got.record) + if payload["title"] != "#общая" { + t.Errorf("title: получено %q, ожидалось \"#общая\"", payload["title"]) + } + if payload["chat"] != "room:"+room.ID { + t.Errorf("chat: получено %q, ожидалось %q", payload["chat"], "room:"+room.ID) + } + if payload["body"] != "новое сообщение" { + t.Errorf("body: получено %q", payload["body"]) + } + svc.silent() +} + +// Без VAPID-ключей пуши выключены: подписка ставится, отправки нет. +func TestPushOffWithoutKeys(t *testing.T) { + svc := newPushService(t) + e := newEnv(t) + marta, m1 := e.join("marta", 1) + _, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.silent() +} + +// Аккаунт с молчащим push-сервисом не отбирает отправку у остальных: +// доля одного аккаунта в отправщиках ограничена (ADR-048). +func TestPushShareBetweenAccounts(t *testing.T) { + e := pushEnv(t) + stuck, release := newSlowPushService(t) + defer release() + live := newPushService(t) + + marta, m1 := e.join("marta", 1) + greedy, g1 := e.join("greedy", 2) + e.subscribe("greedy", g1, stuck) + for seed := byte(10); seed < 30; seed++ { + e.subscribe("greedy", e.addDevice(greedy, deviceOf(seed)), stuck) + } + _, c1 := e.join("carol", 3) + e.subscribe("carol", c1, live) + + // Двадцать одно молчащее устройство одного аккаунта: часть заданий + // отбрасывается сразу, остальные занимают не больше своей доли. + e.send(marta, m1, "greedy", 40) + e.send(marta, m1, "carol", 41) + + select { + case got := <-live.got: + if got.device != c1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, c1) + } + case <-time.After(wait): + t.Fatal("пуш постороннему аккаунту не ушёл: отправщики заняты чужим") + } +} + +// Устройство, подключившееся по SSE во время отправки, не остаётся +// с неотработанным пушем: право возвращается, и следующее сообщение +// после ухода в офлайн снова даёт пуш (ADR-023). +func TestPushReleasedWhenDeviceConnects(t *testing.T) { + e := pushEnv(t) + svc, release := newSlowPushService(t) + defer release() + + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + // Отправка уже началась: push-сервис получил запрос и держит его. + if got := svc.next(); got.device != p1 { + t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1) + } + // Пока пуш в пути, устройство подключилось: подключение сбрасывает + // неотработанный пуш, а отправщик поставил его позже. + stream := e.open(p1, petya) + stream.untilReady() + release() + + // Право на пуш свободно: захват удаётся. + for deadline := time.Now().Add(wait); ; { + claimed, ok, err := e.st.ClaimPush(context.Background(), p1) + if err != nil { + t.Fatalf("ClaimPush: %v", err) + } + if ok { + if claimed == "" { + t.Error("подписка пуста") + } + return + } + if time.Now().After(deadline) { + t.Fatal("неотработанный пуш остался висеть на подключённом устройстве") + } + time.Sleep(5 * time.Millisecond) + } +} + +// Пуш на непубличный адрес не уходит вовсе: соединения не случается, +// право на пуш возвращается, а адрес подписки в журнал не попадает +// (ADR-047, docs/deploy.md, «Логи»). +func TestPushSkipsLocalEndpoint(t *testing.T) { + svc := newPushService(t) + e := pushEnvWith(t, false) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + e.subscribe("petya", p1, svc) + + e.send(marta, m1, "petya", 3) + svc.silent() + + if !e.hasPush(petya, p1) { + t.Error("подписка снята, хотя push-сервис не отвечал") + } + // Право на пуш вернулось: следующее сообщение попробует снова. + claimed, ok, err := e.st.ClaimPush(context.Background(), p1) + if err != nil { + t.Fatalf("ClaimPush: %v", err) + } + if !ok || claimed == "" { + t.Error("право на пуш осталось захваченным") + } + log := e.log.String() + if !strings.Contains(log, "адрес подписки не публичный") { + t.Errorf("в журнале нет причины отказа: %q", log) + } + if strings.Contains(log, "127.0.0.1") || strings.Contains(log, strings.TrimPrefix(svc.url, "http://")) { + t.Errorf("адрес подписки попал в журнал: %q", log) + } +} + +// Одно сообщение — несколько молчащих устройств: каждое получает свою +// расшифровываемую нагрузку. Нагрузка на всех одна (ADR-045), но +// шифруется она для каждой подписки отдельно. +func TestPushPayloadPerDevice(t *testing.T) { + svc := newPushService(t) + e := pushEnv(t) + marta, m1 := e.join("marta", 1) + petya, p1 := e.join("petya", 2) + p2 := e.addDevice(petya, deviceOf(3)) + p3 := e.addDevice(petya, deviceOf(4)) + subs := map[string]*subscriber{ + p1: e.subscribe("petya", p1, svc), + p2: e.subscribe("petya", p2, svc), + p3: e.subscribe("petya", p3, svc), + } + + e.send(marta, m1, "petya", 5) + seen := make(map[string]bool) + for i := 0; i < len(subs); i++ { + got := svc.next() + to, ok := subs[got.device] + if !ok { + t.Fatalf("пуш ушёл неизвестному устройству %s", got.device) + } + if seen[got.device] { + t.Fatalf("устройство %s получило второй пуш", got.device) + } + seen[got.device] = true + payload := to.open(t, got.record) + if payload["title"] != "@marta" || payload["chat"] != "dm:marta" || payload["body"] != "новое сообщение" { + t.Errorf("нагрузка устройства %s: %v", got.device, payload) + } + } + svc.silent() +} diff --git a/internal/api/rooms.go b/internal/api/rooms.go index 5e884cd..eb91ea3 100644 --- a/internal/api/rooms.go +++ b/internal/api/rooms.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "time" + "unicode" "unicode/utf8" "github.com/xmatic-squad/bare/internal/auth" @@ -379,7 +380,32 @@ func uniqueNicks(list []string) ([]string, bool) { return out, true } -// validRoomName — имя комнаты: непустое, до 64 символов (ADR-021). +// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих +// символов, без переопределений направления письма и не из одних +// пробелов (ADR-021). +// +// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит +// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод +// строки, ни разворот текста — на экране блокировки такое имя выглядит +// не строкой списка, а сообщением от системы. func validRoomName(name string) bool { - return name != "" && utf8.RuneCountInString(name) <= maxRoomName + if name == "" || utf8.RuneCountInString(name) > maxRoomName { + return false + } + blank := true + for _, r := range name { + if unicode.IsControl(r) || bidi(r) { + return false + } + if !unicode.IsSpace(r) { + blank = false + } + } + return !blank +} + +// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069. +// Они переставляют текст на экране местами, оставаясь невидимыми. +func bidi(r rune) bool { + return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069) } diff --git a/internal/api/rooms_test.go b/internal/api/rooms_test.go index ec6379f..a731c46 100644 --- a/internal/api/rooms_test.go +++ b/internal/api/rooms_test.go @@ -225,6 +225,17 @@ func TestCreateRoomRejects(t *testing.T) { {"имя длиннее 64", func(m map[string]any) { m["name"] = strings.Repeat("я", 65) }, http.StatusBadRequest, "invalid", "name"}, + // Имя уходит в заголовок системного уведомления (ADR-045): + // ни перевода строки, ни разворота текста в нём быть не должно. + {"имя с переводом строки", func(m map[string]any) { + m["name"] = "общая\nсрочно: перезагрузите телефон" + }, http.StatusBadRequest, "invalid", "name"}, + {"имя с bidi", func(m map[string]any) { + m["name"] = "общая\u202eяандекс" + }, http.StatusBadRequest, "invalid", "name"}, + {"имя из пробелов", func(m map[string]any) { + m["name"] = " " + }, http.StatusBadRequest, "invalid", "name"}, {"кривой keyId", func(m map[string]any) { m["keyId"] = "dm" }, http.StatusBadRequest, "invalid", "keyId"}, {"нет ключа", func(m map[string]any) { m["keys"] = []any{} }, http.StatusBadRequest, "keys_mismatch", ""}, {"ключ чужому", func(m map[string]any) { diff --git a/internal/config/config.go b/internal/config/config.go index 411e370..67579ad 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -16,6 +16,12 @@ type Config struct { VAPIDPrivate string // BARE_VAPID_PRIVATE VAPIDSubject string // BARE_VAPID_SUBJECT InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию + + // PushLocal разрешает отправку пушей на непубличные адреса. Из + // окружения не читается и в работе всегда false: сервер ходит + // только по публичным адресам (ADR-047). Поле существует ради + // тестов, где push-сервис вендора подменён сервером на 127.0.0.1. + PushLocal bool } // Значения по умолчанию — локальный запуск без окружения. diff --git a/internal/hub/hub.go b/internal/hub/hub.go index b39f4f5..23780e9 100644 --- a/internal/hub/hub.go +++ b/internal/hub/hub.go @@ -75,6 +75,14 @@ func (h *Hub) Send(device string, ev Event) { } } +// Connected — держит ли устройство открытый поток. Пуш уходит только +// молчащему устройству (ADR-023). +func (h *Hub) Connected(device string) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.streams[device] != nil +} + // Close закрывает поток устройства: устройство удалили (docs/protocol.md, // «Устройства»). func (h *Hub) Close(device string) { diff --git a/internal/push/push.go b/internal/push/push.go new file mode 100644 index 0000000..c758adb --- /dev/null +++ b/internal/push/push.go @@ -0,0 +1,467 @@ +// Package push отправляет веб-пуши устройствам (ADR-011, ADR-023). +// +// Пуш — сигнал, а не транспорт: он говорит, что для устройства что-то +// есть, а содержимое устройство забирает очередью при подключении. +// Плейнтекста сервер не знает, поэтому текст пуша — константа, а не поле. +package push + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/netip" + "sync" + "syscall" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/xmatic-squad/bare/internal/config" +) + +// Параметры отправки из ADR-023. +const ( + ttl = 24 * time.Hour + urgency = webpush.UrgencyNormal +) + +// body — текст пуша. Константа, а не поле полезной нагрузки: сервер +// не знает плейнтекста сообщения и не может положить его в пуш даже +// по ошибке (ADR-011, docs/ui.md, «Уведомления»). +const body = "новое сообщение" + +// Пределы отправки (ADR-048). Пуш — побочный эффект доставки, ответа на +// POST /api/messages он не ждёт, но и «выстрелил и забыл» без границ +// не годится: недоступный push-сервис держит соединение до таймаута, +// и без предела такие отправки копились бы горутинами и сокетами, +// пока хватает памяти. Поэтому фиксированная очередь, фиксированное +// число отправщиков и доля одного аккаунта в них. +const ( + workers = 8 + // queueSize — сколько пушей ждут отправщика. Переполнение означает, + // что push-сервисы не справляются; лишний пуш отбрасывается, а не + // копится. Потери в этом нет: право на пуш забирается перед самой + // отправкой, поэтому у отброшенного устройства push_pending остаётся + // нулём и следующее сообщение попробует снова. + queueSize = 256 + // perAccount — сколько заданий одного аккаунта бывает в очереди и в + // работе одновременно. Без этой доли аккаунт с сотней устройств на + // молчащем эндпоинте занимал бы всех отправщиков, и пуши остальных + // пользователей отбрасывались бы (ADR-048). + perAccount = 4 + // requestTimeout — сколько ждём push-сервис. Вендоры отвечают за + // секунды; всё, что дольше, — уже недоступный сервис, а таймаут + // на задание задаёт пропускную способность отправки. + requestTimeout = 5 * time.Second + // dialTimeout — сколько ждём соединения с push-сервисом. + dialTimeout = 3 * time.Second + // storeTimeout — сколько ждём базу, когда правим подписку по итогам + // отправки. + storeTimeout = 5 * time.Second + // dropEvery — как часто в журнал уходит счётчик отброшенных пушей. + // Строка на каждый отброшенный пуш была бы усилителем заливки + // журнала: одно сообщение аккаунту с сотней устройств давало бы + // сотню строк (ADR-048). + dropEvery = time.Minute +) + +// Devices — что отправителю нужно от хранилища. Правило «одно молчащее +// устройство — один пуш» держится на атомарном захвате (ADR-023). +type Devices interface { + ClaimPush(ctx context.Context, device string) (subscription string, claimed bool, err error) + ReleasePush(ctx context.Context, device string) error + DropPush(ctx context.Context, device string) error +} + +// Payload — полезная нагрузка пуша (ADR-023). Заголовок — «@nick» +// отправителя или «#имя комнаты», chat — идентификатор чата для +// перехода: «dm:» или «room:». Текста сообщения здесь нет +// и быть не может. +type Payload struct { + Title string + Chat string +} + +// Target — кому нужен пуш: устройство и аккаунт, которому оно +// принадлежит. Аккаунт нужен, чтобы отмерить его долю в отправке +// (ADR-048). +type Target struct { + Device string + Owner string +} + +// wire — полезная нагрузка на проводе. +type wire struct { + Title string `json:"title"` + Body string `json:"body"` + Chat string `json:"chat"` +} + +// Sender — очередь отправки и отправщики за ней. +type Sender struct { + devices Devices + // connected — держит ли устройство поток событий. Спрашивается + // в момент захвата права на пуш, а не при постановке в очередь + // (ADR-023). + connected func(device string) bool + public string + private string + subject string + client *http.Client + logw io.Writer + + jobs chan job + done chan struct{} + stop sync.Once + wg sync.WaitGroup + + mu sync.Mutex + // share — сколько заданий аккаунта в очереди и в работе. + share map[string]int + // dropped — сколько пушей отброшено с прошлой строки в журнале. + dropped int + reported time.Time +} + +// job — один пуш: кому, от чьего имени доля и что. +type job struct { + device string + owner string + payload []byte +} + +// New собирает отправителя. Без полной пары VAPID-ключей и subject пуши +// выключены: отправлять их всё равно нечем (ADR-022, docs/deploy.md). +// Выключенный отправитель не заводит горутин и молча ничего не делает. +// +// connected отвечает, подключено ли устройство по SSE; nil означает +// «никто не подключён». +func New(cfg *config.Config, devices Devices, connected func(device string) bool, logw io.Writer) *Sender { + if connected == nil { + connected = func(string) bool { return false } + } + s := &Sender{ + devices: devices, + connected: connected, + public: cfg.VAPIDPublic, + private: cfg.VAPIDPrivate, + subject: cfg.VAPIDSubject, + client: &http.Client{ + Timeout: requestTimeout, + // Push-сервисы редиректов не шлют. Следование за ними + // означало бы, что проверка «endpoint — https» ничего + // не значит: один 307 уводит запрос вместе с VAPID-заголовком + // куда угодно, в том числе на plain http внутрь периметра + // (ADR-047). + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + Transport: transport(cfg.PushLocal), + }, + logw: logw, + share: make(map[string]int), + } + if !s.on() { + return s + } + s.jobs = make(chan job, queueSize) + s.done = make(chan struct{}) + s.wg.Add(workers) + for i := 0; i < workers; i++ { + go s.work() + } + return s +} + +// on — есть ли чем подписывать пуши. +func (s *Sender) on() bool { + return s.public != "" && s.private != "" && s.subject != "" +} + +// Send ставит пуш каждому из устройств в очередь отправки и возвращается +// сразу: конверт уже в очереди устройства, ответ на POST /api/messages +// пуша не ждёт (ADR-023). +// +// Заданий одного аккаунта в работе не больше perAccount: лишние +// отбрасываются здесь же, не занимая отправщика (ADR-048). +func (s *Sender) Send(targets []Target, p Payload) { + if !s.on() || len(targets) == 0 { + return + } + raw, err := json.Marshal(wire{Title: p.Title, Body: body, Chat: p.Chat}) + if err != nil { + s.report("сборка нагрузки: %v", err) + return + } + for _, t := range targets { + if !s.reserve(t.Owner) { + continue + } + // У каждого задания своя копия нагрузки: webpush-go дописывает + // набивку прямо в переданный срез, а одно сообщение уходит сразу + // нескольким устройствам и в разных отправщиках. + select { + case s.jobs <- job{device: t.Device, owner: t.Owner, payload: bytes.Clone(raw)}: + default: + s.free(t.Owner) + s.countDrop() + } + } + s.reportDrops(dropEvery) +} + +// Close останавливает отправщиков и дожидается начатых отправок. +func (s *Sender) Close() { + if !s.on() { + return + } + s.stop.Do(func() { close(s.done) }) + s.wg.Wait() + s.reportDrops(0) +} + +func (s *Sender) work() { + defer s.wg.Done() + for { + select { + case <-s.done: + return + case j := <-s.jobs: + s.deliver(j) + s.free(j.owner) + } + } +} + +// deliver забирает право на пуш и отправляет его. Контекст здесь свой: +// запрос, породивший пуш, к этому моменту давно отвечен. +func (s *Sender) deliver(j job) { + ctx, cancel := context.WithTimeout(context.Background(), requestTimeout) + defer cancel() + + // Подключённому устройству пуш не нужен, и права на пуш ему брать + // нельзя: захваченное право сбрасывается только подключением, и на + // подключённом устройстве оно провисело бы всю сессию, съев пуш + // после ухода в офлайн. Поэтому проверка идёт здесь, рядом + // с захватом, а не при постановке в очередь (ADR-023). + if s.connected(j.device) { + return + } + subscription, claimed, err := s.devices.ClaimPush(ctx, j.device) + if err != nil { + s.report("захват: %v", err) + return + } + // Права нет: устройство без подписки или с неотработанным пушем. + // Одно молчащее устройство получает один пуш, не ленту (ADR-023). + if !claimed { + return + } + // Между проверкой и захватом устройство успевает подключиться: + // подключение сбрасывает право, а мы забрали его следом. + if s.connected(j.device) { + s.release(j.device) + return + } + + var to webpush.Subscription + if err := json.Unmarshal([]byte(subscription), &to); err != nil { + // Подписку в таком виде мог записать только сервер, и всё же: + // неразбираемая подписка не заработает никогда, снимаем. + s.report("подписка не разобрана") + s.drop(j.device) + return + } + + resp, err := webpush.SendNotificationWithContext(ctx, j.payload, &to, &webpush.Options{ + HTTPClient: s.client, + Subscriber: s.subject, + VAPIDPublicKey: s.public, + VAPIDPrivateKey: s.private, + TTL: int(ttl.Seconds()), + Urgency: urgency, + }) + if err != nil { + s.report("отправка: %s", reason(err)) + s.release(j.device) + return + } + defer resp.Body.Close() + // Тело ответа push-сервиса нам не нужно, но дочитать его стоит: + // иначе соединение не переиспользуется. + io.Copy(io.Discard, resp.Body) + + switch { + case resp.StatusCode < 300: + // Пуш принят: у устройства висит неотработанный пуш. Если оно + // успело подключиться, пока шла отправка, право возвращается: + // подключение сбрасывает его раньше, чем мы поставили. + if s.connected(j.device) { + s.release(j.device) + } + case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone: + // Подписки больше нет — чистим мёртвую (ADR-011). + s.drop(j.device) + default: + s.report("push-сервис ответил %d", resp.StatusCode) + s.release(j.device) + } +} + +// reserve занимает долю аккаунта в отправке. Доля израсходована — пуш +// отбрасывается: устройству от этого ничего не грозит, право на пуш +// ещё не забрано (ADR-048). +func (s *Sender) reserve(owner string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.share[owner] >= perAccount { + s.dropped++ + return false + } + s.share[owner]++ + return true +} + +// free возвращает долю аккаунта. +func (s *Sender) free(owner string) { + s.mu.Lock() + defer s.mu.Unlock() + if n := s.share[owner]; n > 1 { + s.share[owner] = n - 1 + } else { + delete(s.share, owner) + } +} + +// countDrop считает отброшенный пуш. +func (s *Sender) countDrop() { + s.mu.Lock() + defer s.mu.Unlock() + s.dropped++ +} + +// reportDrops пишет счётчик отброшенных пушей, но не чаще чем раз +// в every (ADR-048). +func (s *Sender) reportDrops(every time.Duration) { + s.mu.Lock() + n := s.dropped + if n == 0 || time.Since(s.reported) < every { + s.mu.Unlock() + return + } + s.dropped = 0 + s.reported = time.Now() + s.mu.Unlock() + s.report("отброшено пушей: %d", n) +} + +// release возвращает право на пуш: отправка не состоялась, ждать +// устройству нечего. Контекст здесь свой: отправка могла кончиться +// именно таймаутом, а на просроченном контексте запись не прошла бы +// и push_pending остался бы висеть. +func (s *Sender) release(device string) { + ctx, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + if err := s.devices.ReleasePush(ctx, device); err != nil { + s.report("возврат: %v", err) + } +} + +// drop снимает подписку по той же причине со своим контекстом. +func (s *Sender) drop(device string) { + ctx, cancel := context.WithTimeout(context.Background(), storeTimeout) + defer cancel() + if err := s.devices.DropPush(ctx, device); err != nil { + s.report("снятие подписки: %v", err) + } +} + +// report пишет строку в журнал. Ни идентификатора устройства, ни адреса +// подписки в ней нет: и то и другое — данные пользователя +// (docs/deploy.md, «Логи»). +func (s *Sender) report(format string, args ...any) { + if s.logw == nil { + return + } + fmt.Fprintf(s.logw, "%s пуш: %s\n", time.Now().Format(time.RFC3339), fmt.Sprintf(format, args...)) +} + +// errLocalAddress — попытка соединиться с непубличным адресом (ADR-047). +var errLocalAddress = errors.New("push: адрес не публичный") + +// reason сводит отказ отправки к классу. Текст ошибки транспорта +// в журнал не идёт вовсе: внутри него лежит адрес подписки — host, порт +// или имя, — а это данные пользователя (docs/deploy.md, «Логи»). Класс +// отвечает на вопрос «что чинить», адрес для этого не нужен. +func reason(err error) string { + if errors.Is(err, errLocalAddress) { + return "адрес подписки не публичный" + } + if errors.Is(err, context.DeadlineExceeded) { + return "таймаут" + } + var dns *net.DNSError + if errors.As(err, &dns) { + return "имя не разрешилось" + } + var ne net.Error + if errors.As(err, &ne) && ne.Timeout() { + return "таймаут сети" + } + return "отправка не удалась" +} + +// transport — транспорт отправщика. Адрес push-сервиса выбирает браузер +// получателя, а сервер стоит во внутренней сети за nginx (ADR-022): +// без проверки любой вошедший пользователь заставил бы его стучаться +// внутрь периметра. Проверяется адрес соединения, то есть уже +// разрешённое имя, — подмена DNS не помогает (ADR-047). +// +// local снимает проверку и включается только в тестах: настоящий +// push-сервис в них подменён сервером на 127.0.0.1. Из окружения этот +// флаг не читается. +func transport(local bool) http.RoundTripper { + dialer := &net.Dialer{Timeout: dialTimeout, KeepAlive: 30 * time.Second} + if !local { + dialer.Control = onlyPublic + } + t := http.DefaultTransport.(*http.Transport).Clone() + t.DialContext = dialer.DialContext + return t +} + +// onlyPublic отказывает в соединении с непубличным адресом. +func onlyPublic(network, address string, _ syscall.RawConn) error { + host, _, err := net.SplitHostPort(address) + if err != nil { + return errLocalAddress + } + ip, err := netip.ParseAddr(host) + if err != nil { + return errLocalAddress + } + if !Public(ip) { + return errLocalAddress + } + return nil +} + +// Public — публичный ли адрес. Непубличными считаются loopback, +// link-local, приватные сети (RFC 1918 и RFC 4193), multicast +// и неопределённый адрес: push-сервиса по таким адресам не бывает, +// а внутренние службы бывают (ADR-047). +func Public(ip netip.Addr) bool { + ip = ip.Unmap() + if !ip.IsValid() { + return false + } + return !ip.IsLoopback() && + !ip.IsPrivate() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsInterfaceLocalMulticast() && + !ip.IsMulticast() && + !ip.IsUnspecified() +} diff --git a/internal/push/push_test.go b/internal/push/push_test.go new file mode 100644 index 0000000..1a0e9cf --- /dev/null +++ b/internal/push/push_test.go @@ -0,0 +1,145 @@ +package push + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/netip" + "net/url" + "strings" + "testing" + + "github.com/xmatic-squad/bare/internal/config" +) + +// sender без VAPID-ключей: отправщиков он не заводит, а клиент собирает — +// именно клиент здесь и проверяется. +func client(t *testing.T, local bool) *http.Client { + t.Helper() + return New(&config.Config{PushLocal: local}, nil, nil, nil).client +} + +// Публичный адрес отличается от того, по которому push-сервиса не бывает +// (ADR-047). +func TestPublicAddress(t *testing.T) { + cases := []struct { + addr string + public bool + }{ + {"93.184.216.34", true}, + {"2606:2800:220:1:248:1893:25c8:1946", true}, + {"127.0.0.1", false}, + {"::1", false}, + {"10.0.0.1", false}, + {"172.16.5.4", false}, + {"192.168.1.1", false}, + {"169.254.169.254", false}, + {"fe80::1", false}, + {"fc00::1", false}, + {"0.0.0.0", false}, + {"::", false}, + {"224.0.0.1", false}, + {"::ffff:127.0.0.1", false}, + {"::ffff:10.0.0.1", false}, + } + for _, c := range cases { + ip, err := netip.ParseAddr(c.addr) + if err != nil { + t.Fatalf("%s: %v", c.addr, err) + } + if got := Public(ip); got != c.public { + t.Errorf("Public(%s): получено %v, ожидалось %v", c.addr, got, c.public) + } + } +} + +// Соединения с непубличным адресом не случается: проверка стоит на самом +// dial, поэтому её не обойти ни именем, ни редиректом (ADR-047). +func TestClientRefusesLocalAddress(t *testing.T) { + got := make(chan struct{}, 1) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got <- struct{}{} + })) + defer srv.Close() + + resp, err := client(t, false).Get(srv.URL) + if err == nil { + resp.Body.Close() + t.Fatal("соединение с 127.0.0.1 состоялось") + } + if !errors.Is(err, errLocalAddress) { + t.Errorf("ошибка: %v, ожидался отказ по адресу", err) + } + if reason(err) != "адрес подписки не публичный" { + t.Errorf("класс отказа: %q", reason(err)) + } + select { + case <-got: + t.Error("внутренняя служба получила запрос") + default: + } +} + +// Редиректы push-сервиса не выполняются: иначе один 307 уводил бы запрос +// вместе с VAPID-заголовком куда угодно, и проверка «endpoint — https» +// не значила бы ничего (ADR-047). +func TestClientDoesNotFollowRedirect(t *testing.T) { + inside := make(chan struct{}, 1) + internal := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + inside <- struct{}{} + w.WriteHeader(http.StatusGone) + })) + defer internal.Close() + + vendor := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, internal.URL+"/latest/meta-data/", http.StatusTemporaryRedirect) + })) + defer vendor.Close() + + // local: сами тестовые серверы живут на 127.0.0.1, проверяется здесь + // именно политика редиректов. + resp, err := client(t, true).Get(vendor.URL + "/push") + if err != nil { + t.Fatalf("запрос: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusTemporaryRedirect { + t.Errorf("статус: получено %d, ожидалось 307", resp.StatusCode) + } + select { + case <-inside: + t.Error("запрос ушёл по редиректу на внутренний адрес") + default: + } +} + +// Отказ отправки сводится к классу: адреса подписки в журнале нет +// (docs/deploy.md, «Логи»). +func TestReasonWithoutEndpoint(t *testing.T) { + const endpoint = "secret-host.push.example" + cases := []struct { + err error + want string + }{ + {fmt.Errorf("dial: %w", errLocalAddress), "адрес подписки не публичный"}, + {fmt.Errorf("post: %w", context.DeadlineExceeded), "таймаут"}, + {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", + Err: &net.DNSError{Err: "no such host", Name: endpoint}}, "имя не разрешилось"}, + {&url.Error{Op: "Post", URL: "https://" + endpoint + "/x", + Err: &net.OpError{Op: "read", Net: "tcp", + Addr: &net.TCPAddr{IP: net.IPv4(10, 1, 2, 3), Port: 443}, + Err: errors.New("connection reset by peer")}}, "отправка не удалась"}, + } + for _, c := range cases { + got := reason(c.err) + if got != c.want { + t.Errorf("reason(%v): получено %q, ожидалось %q", c.err, got, c.want) + } + if strings.Contains(got, endpoint) || strings.Contains(got, "10.1.2.3") { + t.Errorf("адрес подписки попал в журнал: %q", got) + } + } +} diff --git a/internal/store/devices.go b/internal/store/devices.go index 5da765e..7cd83eb 100644 --- a/internal/store/devices.go +++ b/internal/store/devices.go @@ -127,3 +127,81 @@ func (s *Store) TouchDevice(ctx context.Context, id string, now int64) error { } return nil } + +// Push-подписка принадлежит устройству (ADR-023). Сервер хранит её как +// непрозрачный JSON: разбирает его только отправитель пушей. + +// SetPush ставит подписку устройства и сбрасывает неотработанный пуш: +// устройство снова готово его принять (ADR-023). Первое значение — было +// ли такое устройство у этого пользователя. +func (s *Store) SetPush(ctx context.Context, id, nick, subscription string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = ?, push_pending = 0 + WHERE id = ? AND nick = ?`, subscription, id, nick) + if err != nil { + return false, fmt.Errorf("store: push-подписка: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: push-подписка: %w", err) + } + return n > 0, nil +} + +// ClearPush снимает подписку устройства. Подписки не было — это не +// ошибка: снимать нечего. +func (s *Store) ClearPush(ctx context.Context, id, nick string) (bool, error) { + res, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = NULL WHERE id = ? AND nick = ?`, id, nick) + if err != nil { + return false, fmt.Errorf("store: снятие push-подписки: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return false, fmt.Errorf("store: снятие push-подписки: %w", err) + } + return n > 0, nil +} + +// ClaimPush забирает право на пуш: устройству с подпиской и без +// неотработанного пуша ставит push_pending = 1 и отдаёт подписку. +// Второе значение — досталось ли право. +// +// Захват и проверка — один запрос: два сообщения подряд приходят +// в разных горутинах, а молчащее устройство получает один пуш, не ленту +// (ADR-023). Проигравший запрос уходит ни с чем. +func (s *Store) ClaimPush(ctx context.Context, id string) (string, bool, error) { + var subscription string + err := s.db.QueryRowContext(ctx, ` + UPDATE devices SET push_pending = 1 + WHERE id = ? AND push_pending = 0 AND push_subscription IS NOT NULL + RETURNING push_subscription`, id).Scan(&subscription) + if errors.Is(err, sql.ErrNoRows) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("store: захват пуша: %w", err) + } + return subscription, true, nil +} + +// ReleasePush возвращает право на пуш: отправка не состоялась, значит +// и неотработанного пуша у устройства нет. Иначе одна ошибка push-сервиса +// затыкала бы уведомления устройства до следующего подключения по SSE. +func (s *Store) ReleasePush(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_pending = 0 WHERE id = ?`, id); err != nil { + return fmt.Errorf("store: возврат пуша: %w", err) + } + return nil +} + +// DropPush снимает мёртвую подписку: push-сервис ответил 404 или 410 +// (ADR-011). Неотработанного пуша заодно не остаётся — он никуда не ушёл. +func (s *Store) DropPush(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, ` + UPDATE devices SET push_subscription = NULL, push_pending = 0 WHERE id = ?`, id); err != nil { + return fmt.Errorf("store: снятие мёртвой push-подписки: %w", err) + } + return nil +} diff --git a/internal/store/queue.go b/internal/store/queue.go index c84ae6c..e65f9af 100644 --- a/internal/store/queue.go +++ b/internal/store/queue.go @@ -55,6 +55,17 @@ func (s *Store) Ack(ctx context.Context, device string, ids []string) error { return nil } +// Target — устройство, которому конверт лёг в очередь. Ник рядом +// с идентификатором нужен пушу: устройства отправителя пуша не получают +// (ADR-045), а доля аккаунта в отправке ограничена (ADR-048). Признак +// подписки — оттуда же: устройству без неё пуш не отправить, и место +// в очереди отправки на него не тратится. +type Target struct { + ID string + Nick string + HasPush bool +} + // Delivery — одна доставка: готовый конверт и всё, что нужно, чтобы // разложить его по очередям. Envelope сервер не разбирает, поэтому id // приходит отдельным полем. Заполнено ровно одно из To и Room — адресат @@ -78,7 +89,7 @@ type Delivery struct { // не хранит, повтор порождает повторную доставку, а склеивает её клиент // (ADR-017). Поэтому вставка молча пропускает уже лежащую в очереди // строку, а список устройств от этого не зависит. -func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { +func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]Target, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("store: доставка: %w", err) @@ -97,11 +108,11 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { if err != nil { return nil, err } - for _, id := range devices { + for _, device := range devices { if _, err := tx.ExecContext(ctx, ` INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(device_id, msg_id) DO NOTHING`, - id, d.MsgID, d.Envelope, d.Now); err != nil { + device.ID, d.MsgID, d.Envelope, d.Now); err != nil { return nil, fmt.Errorf("store: доставка (очередь): %w", err) } } @@ -118,7 +129,7 @@ func (s *Store) DeliverDM(ctx context.Context, d Delivery) ([]string, error) { // Членство и keyId проверены раньше, отдельным запросом: между проверкой // и этой транзакцией состав мог измениться, поэтому получателей она берёт // из состава на момент доставки. -func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { +func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]Target, error) { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return nil, fmt.Errorf("store: доставка в комнату: %w", err) @@ -129,11 +140,11 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { if err != nil { return nil, err } - for _, id := range devices { + for _, device := range devices { if _, err := tx.ExecContext(ctx, ` INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES (?, ?, ?, ?) ON CONFLICT(device_id, msg_id) DO NOTHING`, - id, d.MsgID, d.Envelope, d.Now); err != nil { + device.ID, d.MsgID, d.Envelope, d.Now); err != nil { return nil, fmt.Errorf("store: доставка в комнату (очередь): %w", err) } } @@ -144,48 +155,49 @@ func (s *Store) DeliverRoom(ctx context.Context, d Delivery) ([]string, error) { } // deviceIDs — устройства обоих собеседников, кроме отправившего. -func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]string, error) { +func deviceIDs(ctx context.Context, tx *sql.Tx, from, to, exclude string) ([]Target, error) { rows, err := tx.QueryContext(ctx, ` - SELECT id FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude) + SELECT id, nick, push_subscription IS NOT NULL + FROM devices WHERE nick IN (?, ?) AND id <> ? ORDER BY id`, from, to, exclude) if err != nil { return nil, fmt.Errorf("store: доставка (устройства): %w", err) } defer rows.Close() - var out []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("store: доставка (устройства): %w", err) - } - out = append(out, id) - } - if err := rows.Err(); err != nil { + out, err := targets(rows) + if err != nil { return nil, fmt.Errorf("store: доставка (устройства): %w", err) } return out, nil } // roomDeviceIDs — устройства всех участников комнаты, кроме отправившего. -func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]string, error) { +func roomDeviceIDs(ctx context.Context, tx *sql.Tx, room, exclude string) ([]Target, error) { rows, err := tx.QueryContext(ctx, ` - SELECT d.id FROM devices d JOIN room_members m ON m.nick = d.nick + SELECT d.id, d.nick, d.push_subscription IS NOT NULL + FROM devices d JOIN room_members m ON m.nick = d.nick WHERE m.room_id = ? AND d.id <> ? ORDER BY d.id`, room, exclude) if err != nil { return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) } defer rows.Close() - var out []string - for rows.Next() { - var id string - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) - } - out = append(out, id) - } - if err := rows.Err(); err != nil { + out, err := targets(rows) + if err != nil { return nil, fmt.Errorf("store: доставка в комнату (устройства): %w", err) } return out, nil } + +// targets собирает устройства получателей из выборки «id, nick, подписка». +func targets(rows *sql.Rows) ([]Target, error) { + var out []Target + for rows.Next() { + var t Target + if err := rows.Scan(&t.ID, &t.Nick, &t.HasPush); err != nil { + return nil, err + } + out = append(out, t) + } + return out, rows.Err() +} diff --git a/internal/store/rooms.go b/internal/store/rooms.go index 8255d77..7393a56 100644 --- a/internal/store/rooms.go +++ b/internal/store/rooms.go @@ -501,18 +501,33 @@ func (s *Store) DeleteRoom(ctx context.Context, roomID, owner string) (RoomChang return change, nil } -// RoomAccess — что сервер проверяет перед отправкой в комнату -// (docs/protocol.md, «Сообщения»). keyId считается ключом комнаты, если -// есть хоть одна строка room_keys с таким key_id (docs/storage.md). -func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (member, knownKey bool, err error) { - err = s.db.QueryRowContext(ctx, ` - SELECT EXISTS(SELECT 1 FROM room_members WHERE room_id = ? AND nick = ?), - EXISTS(SELECT 1 FROM room_keys WHERE room_id = ? AND key_id = ?)`, - roomID, nick, roomID, keyID).Scan(&member, &knownKey) - if err != nil { - return false, false, fmt.Errorf("store: доступ к комнате: %w", err) +// Access — что сервер знает о комнате перед отправкой в неё +// (docs/protocol.md, «Сообщения»). Имя нужно заголовку пуша: «#имя +// комнаты» (ADR-023). +type Access struct { + Member bool + KnownKey bool + Name string +} + +// RoomAccess — что сервер проверяет перед отправкой в комнату. keyId +// считается ключом комнаты, если есть хоть одна строка room_keys с таким +// key_id (docs/storage.md). Несуществующая комната отвечает пустым +// Access: снаружи она неотличима от чужой. +func (s *Store) RoomAccess(ctx context.Context, roomID, nick, keyID string) (Access, error) { + var a Access + err := s.db.QueryRowContext(ctx, ` + SELECT r.name, + EXISTS(SELECT 1 FROM room_members WHERE room_id = r.id AND nick = ?), + EXISTS(SELECT 1 FROM room_keys WHERE room_id = r.id AND key_id = ?) + FROM rooms r WHERE r.id = ?`, nick, keyID, roomID).Scan(&a.Name, &a.Member, &a.KnownKey) + if errors.Is(err, sql.ErrNoRows) { + return Access{}, nil } - return member, knownKey, nil + if err != nil { + return Access{}, fmt.Errorf("store: доступ к комнате: %w", err) + } + return a, nil } // currentKeysQuery — текущий ключ участника: строка room_keys с максимальным diff --git a/internal/store/rooms_test.go b/internal/store/rooms_test.go index 866bff8..5923891 100644 --- a/internal/store/rooms_test.go +++ b/internal/store/rooms_test.go @@ -155,9 +155,12 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) { } } // Свежим ключом можно писать: он остался ключом комнаты. - member, known, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") - if err != nil || !member || !known { - t.Errorf("доступ по свежему ключу: member=%v known=%v err=%v", member, known, err) + access, err := s.RoomAccess(ctx, "room-1", "marta", "aaa") + if err != nil || !access.Member || !access.KnownKey { + t.Errorf("доступ по свежему ключу: %+v, %v", access, err) + } + if access.Name != "общая" { + t.Errorf("имя комнаты: получено %q, ожидалось \"общая\"", access.Name) } } diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..af42cfa --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,68 @@ +package web_test + +import ( + "io/fs" + "regexp" + "testing" + + bare "github.com/xmatic-squad/bare" +) + +// shellRe — массив SHELL из web/sw.js: перечень оболочки списком строк. +var shellRe = regexp.MustCompile(`(?s)const SHELL = \[(.*?)\];`) + +var pathRe = regexp.MustCompile(`"([^"]+)"`) + +// Оболочка в sw.js перечислена вручную (у Cache API нет масок), и забытый +// в ней файл ломает только офлайн — молча. Поэтому список сверяется +// с содержимым embed: оболочка — всё из web/, кроме самого sw.js; +// index.html лежит в кэше под адресом «/» (ADR-023). +func TestShellCoversStatic(t *testing.T) { + root, err := fs.Sub(bare.Web, "web") + if err != nil { + t.Fatalf("embed: %v", err) + } + worker, err := fs.ReadFile(root, "sw.js") + if err != nil { + t.Fatalf("sw.js: %v", err) + } + block := shellRe.FindSubmatch(worker) + if block == nil { + t.Fatal("в sw.js нет массива SHELL") + } + shell := make(map[string]bool) + for _, m := range pathRe.FindAllSubmatch(block[1], -1) { + shell[string(m[1])] = true + } + + want := make(map[string]bool) + err = fs.WalkDir(root, ".", func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + switch { + case d.IsDir(), p == "sw.js": + // Обновление воркера ведёт браузер, в кэш он не кладётся. + return nil + case p == "index.html": + want["/"] = true + default: + want["/"+p] = true + } + return nil + }) + if err != nil { + t.Fatalf("обход embed: %v", err) + } + + for p := range want { + if !shell[p] { + t.Errorf("%s есть в web/, но не в SHELL: офлайн он не откроется", p) + } + } + for p := range shell { + if !want[p] { + t.Errorf("%s есть в SHELL, но не в web/: install воркера упадёт целиком", p) + } + } +} diff --git a/web/app.css b/web/app.css index 3bb9a83..322a781 100644 --- a/web/app.css +++ b/web/app.css @@ -473,6 +473,58 @@ input[type="password"] { color: var(--mute); } +/* уведомления и установка — docs/ui.md, «Настройки» */ + +.state { + margin: 0 0 12px; + font-size: 13px; + color: var(--text2); +} + +.install { + margin: 0; + font-size: 12px; + line-height: 1.5; + color: var(--mute); +} + +/* баннер установки — docs/ui.md, «Баннер установки (iOS)». + Цель нажатия у крестика — 44 px, отрицательные поля не дают ей + растянуть сам баннер */ + +.banner-slot { + flex: none; +} + +.banner { + display: flex; + align-items: flex-start; + padding: 12px 20px; + border-bottom: 1px solid var(--line); +} + +.banner__text { + margin: 0; + font-size: 11px; + line-height: 1.5; + color: var(--mute); +} + +.banner__close { + flex: none; + width: 44px; + height: 44px; + margin: -12px -14px -12px auto; + padding: 0; + border: 0; + background: none; + color: var(--stone); + font: inherit; + font-size: 15px; + line-height: 1; + cursor: pointer; +} + /* участники комнаты — docs/ui.md, «Участники» */ .members { diff --git a/web/js/api.js b/web/js/api.js index d2f13d2..46060a0 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -165,6 +165,17 @@ export function removeDevice(id) { return request("DELETE", `/api/devices/${encodeURIComponent(id)}`); } +// setPush и clearPush — push-подписка устройства (ADR-023). Подписка +// принадлежит устройству, поэтому устройство идёт и в пути, и в заголовке: +// чужому подписку не поставить (docs/protocol.md, «Устройства»). +export function setPush(device, subscription) { + return request("PUT", `/api/devices/${encodeURIComponent(device)}/push`, { subscription }, { device }); +} + +export function clearPush(device) { + return request("DELETE", `/api/devices/${encodeURIComponent(device)}/push`, undefined, { device }); +} + // --- контакты ---------------------------------------------------------- export function contacts() { diff --git a/web/js/main.js b/web/js/main.js index c8453ee..821e693 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -5,7 +5,9 @@ // и отдаётся экранам через ctx. import * as api from "./api.js"; +import { NetworkError } from "./api.js"; import * as db from "./db.js"; +import * as pwa from "./pwa.js"; import * as sync from "./sync.js"; import { deriveAccountKeys, @@ -183,12 +185,20 @@ async function ensureConfig() { // restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи // на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт. +// +// Запрос, который не дошёл, — это «нет соединения», а не «мы не вошли» +// (docs/ui.md, «Сеть и состояния»): офлайн-старт установленного +// приложения поднимается из кэша с ключами и историей устройства, +// а полосу «нет соединения» рисует sync. Если сессии и правда нет, +// первый дошедший запрос ответит 401 unauthenticated и уведёт на вход. async function restore() { - let who; + let who = null; try { who = await api.me(); - } catch { - return null; + } catch (err) { + if (!(err instanceof NetworkError)) { + return null; + } } let meta; try { @@ -196,7 +206,10 @@ async function restore() { } catch { return null; } - if (!meta.privateKey || meta.nick !== who.nick) { + if (!meta.privateKey || !meta.nick) { + return null; + } + if (who !== null && meta.nick !== who.nick) { return null; } return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint }; @@ -320,8 +333,12 @@ async function adopt(nick, priv, secret) { // connect поднимает поток событий и синхронизацию. Отказы разбирает сам // sync: экран входа их уже не касается. +// +// Подписка на пуши переставляется на текущее устройство сразу после +// того, как оно завелось: она живёт в браузерном профиле и про смену +// deviceId сама не узнаёт (ADR-046). function connect() { - sync.start().catch(() => {}); + sync.start().then(() => pwa.refresh(state.config?.vapidPublicKey)).catch(() => {}); } // raise — автоматическое повышение итераций сразу после входа, молча @@ -380,6 +397,11 @@ async function deleteAccount(password) { } async function signOut() { + // Подписка снимается на сервере, пока сессия ещё жива: устройство + // остаётся у аккаунта, и живая строка в базе слала бы пуши прежнего + // аккаунта человеку, который вошёл на этом устройстве под другим + // (ADR-046). + await dropPush(); try { await api.dropSession(); } catch { @@ -388,10 +410,29 @@ async function signOut() { await forget(); } +// dropPush снимает подписку у сервера. Отказ ничего не меняет: 401 +// означает, что сессии и так нет, а всё прочее чинится отпиской +// у push-сервиса и правилом 404/410 (ADR-011). +async function dropPush() { + const device = sync.deviceId(); + if (!device) { + return; + } + try { + await api.clearPush(device); + } catch { + // Не сняли — снимет push-сервис и правило мёртвых подписок. + } +} + // forget уносит историю: она на этом устройстве единственная копия -// (docs/storage.md, docs/ui.md). +// (docs/storage.md, docs/ui.md). Подписка на пуши уходит вместе с ней: +// она принадлежала устройству этого аккаунта (ADR-046). async function forget() { sync.stop(); + // Подписка снимается своим ходом: выход ждёт стирания истории, + // а не push-сервиса. + pwa.detach().catch(() => {}); await db.destroy(); state.me = null; } @@ -407,6 +448,15 @@ function errorText(err) { async function boot() { db.persist(); + // Service worker ставится с первой секунды: кэш оболочки нужен и до + // входа, а пуши приходят в него же (ADR-023). Отказ ничего не ломает. + pwa.register(); + // Первое успешно отправленное сообщение за всю историю устройства — + // единственный повод спросить разрешение на уведомления (ADR-011); + // «один раз» считает pwa.js. + sync.onSent(() => { + pwa.askOnce(state.config?.vapidPublicKey).catch(() => {}); + }); // Обработчик ставится раньше первого запроса: 401 unauthenticated // на любом из них — на экран входа, IndexedDB цела. api.onSessionExpired(() => { diff --git a/web/js/pwa.js b/web/js/pwa.js new file mode 100644 index 0000000..b91c723 --- /dev/null +++ b/web/js/pwa.js @@ -0,0 +1,380 @@ +// PWA: service worker, подписка на пуши и установка приложения +// (ADR-011, ADR-023, ADR-046). +// +// Экраны спрашивают отсюда состояние и сюда же отдают действия; в +// pushManager, IndexedDB и сеть они не ходят — как и с чатом, это делает +// один модуль. +// +// Пуш — сигнал: он говорит, что для устройства что-то есть, а содержимое +// приезжает очередью при подключении (ADR-011). Поэтому здесь нет ни +// сообщений, ни ключей — только подписка и разрешение. + +import * as api from "./api.js"; +import { NetworkError } from "./api.js"; +import * as db from "./db.js"; +import { b64url, unb64url } from "./crypto.js"; +import { deviceId } from "./sync.js"; + +const WORKER = "/sw.js"; + +// iOS: пуши работают только у приложения, установленного на экран «Домой» +// (ADR-011). Признак — docs/ui.md, «Баннер установки»: iPhone|iPad +// и navigator.standalone !== true. +const IOS = /iPhone|iPad/; + +const state = { + // Регистрация service worker: одна на страницу, ждут её все. + registering: null, + // beforeinstallprompt приходит один раз и ждёт кнопки в настройках + // (docs/ui.md, «Настройки»). + prompt: null, + // Разрешение спрашивается один раз за всю историю устройства + // (docs/ui.md, «Уведомления»); флаг в памяти закрывает вкладку от + // повторного вопроса, флаг в meta — устройство. + asked: false, + // Вопрос идёт прямо сейчас: два сообщения подряд не должны дать + // два запроса разрешения. + asking: false, +}; + +// Приглашение установки ловится с первой секунды: браузер показывает его +// сам и только раз. Предотвращённое событие оживает кнопкой в настройках. +addEventListener("beforeinstallprompt", (event) => { + event.preventDefault(); + state.prompt = event; +}); + +// --- service worker ----------------------------------------------------- + +// register ставит service worker. Отдаёт регистрацию или null: браузер +// без service worker — это просто клиент без кэша оболочки и пушей, +// а не сломанный клиент. +export function register() { + if (!("serviceWorker" in navigator)) { + return Promise.resolve(null); + } + if (state.registering === null) { + state.registering = navigator.serviceWorker.register(WORKER).catch(() => null); + } + return state.registering; +} + +// ready — регистрация с работающим service worker: подписка ставится +// только на неё. navigator.serviceWorker.ready ждёт вечно, если +// регистрации нет, — поэтому сначала register. +async function ready() { + const registration = await register(); + if (registration === null) { + return null; + } + try { + return await navigator.serviceWorker.ready; + } catch { + return null; + } +} + +// --- уведомления -------------------------------------------------------- + +// supported — есть ли в браузере то, из чего складывается пуш. iOS вне +// установленного приложения сюда не проходит: там нет ни Notification, +// ни PushManager. +function supported() { + return "serviceWorker" in navigator + && "PushManager" in self + && "Notification" in self; +} + +// notifications — состояние раздела «уведомления» (docs/ui.md): +// "on" — «включены», "off" — «выключены», "denied" — «запрещены +// в браузере». В "denied" сходится всё, чего кнопкой не включить: +// отклонённое разрешение, браузер без уведомлений, сервер без +// VAPID-ключа (ADR-046). +export async function notifications(key) { + if (!supported() || !key || Notification.permission === "denied") { + return "denied"; + } + if (await turnedOff()) { + return "off"; + } + const subscription = await current(); + if (subscription === null) { + return "off"; + } + // Подписка под прежней парой VAPID-ключей не работает и не починится + // сама: push-сервис отвечает на неё 403, а это не 404 и не 410, и + // сервер её не снимет. Для человека это «выключены», а кнопка + // «включить» подпишет заново под текущим ключом (ADR-049). + return sameKey(subscription, key) ? "on" : "off"; +} + +// turnedOff — уведомления выключены кнопкой в настройках. Явный отказ +// сильнее любой оставшейся подписки: сама она больше не включается +// (ADR-049). +async function turnedOff() { + try { + return (await db.meta(["notificationsOff"])).notificationsOff === true; + } catch { + return false; + } +} + +// enable — «включить». Разрешение спрашивается по нажатию, подписка +// ставится после granted (docs/ui.md, «Уведомления»). Отдаёт новое +// состояние. +export async function enable(key) { + if (!supported() || !key) { + return "denied"; + } + let permission; + try { + permission = await Notification.requestPermission(); + } catch { + return "denied"; + } + if (permission !== "granted") { + return permission === "denied" ? "denied" : "off"; + } + // Человек решил всё сам: отказа больше нет, и спрашивать после первого + // сообщения не о чем (ADR-049). + state.asked = true; + await db.putMeta({ notificationsAsked: true, notificationsOff: false }).catch(() => {}); + return (await attach(key)) ? "on" : "denied"; +} + +// disable — «выключить». Подписка снимается у push-сервиса и у сервера: +// первое действует сразу, второе убирает мёртвую строку из базы. +// +// Отказ запоминается раньше всего остального: без него первое же +// отправленное сообщение вернуло бы подписку через askOnce, а запуск +// приложения — через refresh (ADR-049). +export async function disable() { + await db.putMeta({ notificationsOff: true }).catch(() => {}); + const subscription = await current(); + if (subscription !== null) { + try { + await subscription.unsubscribe(); + } catch { + // Отписаться не дали: сервер уберёт подписку по 404/410 (ADR-011). + } + } + const device = deviceId(); + if (device) { + await api.clearPush(device); + } +} + +// askOnce — запрос разрешения после первого успешно отправленного +// сообщения за всю историю устройства, один раз (ADR-011, docs/ui.md, +// «Уведомления»). Отказ — молча: включить можно в настройках. +// +// На iOS вне установленного приложения вопроса нет вовсе: там вместо +// него баннер установки (ADR-023), а флаг не ставится — установят, +// спросим после следующего сообщения. +export async function askOnce(key) { + if (state.asked || state.asking) { + return; + } + state.asking = true; + try { + let meta; + try { + meta = await db.meta(["notificationsAsked", "notificationsOff"]); + } catch { + return; + } + // Выключенные в настройках уведомления сами не включаются: вопрос + // закрыт человеком, а не нами (ADR-049). + if (meta.notificationsOff === true) { + return; + } + if (meta.notificationsAsked === true) { + state.asked = true; + return; + } + if (iosBrowser() || !supported() || !key) { + return; + } + // Спрашивать нечего: разрешение уже дано или уже отклонено. Данное — + // повод поставить подписку, если её нет. + if (Notification.permission !== "default") { + await remember(); + if (Notification.permission === "granted") { + await attach(key).catch(() => {}); + } + return; + } + let permission; + try { + permission = await Notification.requestPermission(); + } catch { + // Браузер требует нажатия, а между отправкой и ответом сервера оно + // истекло. Вопроса не было — значит, «один раз» ещё не потрачено: + // попробуем после следующего сообщения. + return; + } + await remember(); + if (permission === "granted") { + await attach(key).catch(() => {}); + } + } finally { + state.asking = false; + } +} + +// remember — вопрос задан, второй раз не спрашиваем ни в этой вкладке, +// ни на этом устройстве (docs/ui.md, «Уведомления»). +async function remember() { + state.asked = true; + await db.putMeta({ notificationsAsked: true }).catch(() => {}); +} + +// refresh переставляет подписку на текущее устройство. Подписка живёт +// в браузерном профиле, а принадлежит устройству (ADR-023): deviceId +// меняется при конфликте идентификаторов и после чистки IndexedDB, +// и запуск приложения это чинит (ADR-046). +// +// Выключенные уведомления запуск не включает, а подписку под прежним +// ключом сервера не переставляет: она всё равно не работает, и место ей +// не в базе, а в кнопке «включить» (ADR-049). +export async function refresh(key) { + if (await turnedOff()) { + return; + } + const subscription = await current(); + if (subscription === null) { + return; + } + if (key && !sameKey(subscription, key)) { + return; + } + try { + await put(subscription); + } catch { + // Не переставили — переставим при следующем запуске. + } +} + +// detach снимает подписку у push-сервиса при выходе из аккаунта +// и при его удалении: подписка принадлежит устройству, а устройство — +// аккаунту (ADR-046). Сервер не спрашиваем: сессии к этому моменту +// уже нет, а мёртвую подписку он уберёт сам по 404/410. +export async function detach() { + const subscription = await current(); + if (subscription === null) { + return; + } + try { + await subscription.unsubscribe(); + } catch { + // Не отписались — пуши всё равно некуда доставлять: истории на + // устройстве больше нет. + } +} + +// current — подписка этого браузера или null. +async function current() { + const registration = await ready(); + if (registration === null || !registration.pushManager) { + return null; + } + try { + return await registration.pushManager.getSubscription(); + } catch { + return null; + } +} + +// attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён +// в подписку: сменился ключ — прежняя подписка не годится, push-сервис +// подпишет заново. +async function attach(key) { + const registration = await ready(); + if (registration === null || !registration.pushManager) { + return false; + } + let subscription = await registration.pushManager.getSubscription(); + if (subscription !== null && !sameKey(subscription, key)) { + try { + await subscription.unsubscribe(); + } catch { + // Старая подписка останется у push-сервиса; сервер её не знает. + } + subscription = null; + } + if (subscription === null) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: unb64url(key), + }); + } + await put(subscription); + return true; +} + +// put отдаёт подписку серверу. Без устройства запрос невозможен: +// подписка принадлежит устройству, а его заводит подключение +// (ADR-017). Это то же состояние, что и не дошедший запрос. +async function put(subscription) { + const device = deviceId(); + if (!device) { + throw new NetworkError(); + } + await api.setPush(device, subscription.toJSON()); +} + +// sameKey — та ли пара VAPID-ключей, под которую выдана подписка. +function sameKey(subscription, key) { + const applied = subscription.options?.applicationServerKey; + if (!applied) { + return false; + } + try { + return b64url(new Uint8Array(applied)) === key; + } catch { + return false; + } +} + +// --- установка ---------------------------------------------------------- + +// iosBrowser — iPhone или iPad вне установленного приложения. Ровно этот +// признак показывает баннер установки (docs/ui.md). +export function iosBrowser() { + return IOS.test(navigator.userAgent) && navigator.standalone !== true; +} + +// installable — поймано ли приглашение установки. +export function installable() { + return state.prompt !== null; +} + +// install показывает приглашение установки. Оно одноразовое: показали — +// кнопки больше нет. +export async function install() { + const prompt = state.prompt; + if (prompt === null) { + return; + } + state.prompt = null; + try { + await prompt.prompt(); + await prompt.userChoice; + } catch { + // Приглашение протухло: браузер покажет своё, когда сочтёт нужным. + } +} + +// bannerHidden — баннер установки уже закрывали (docs/ui.md). +export async function bannerHidden() { + try { + return (await db.meta(["installBannerDismissed"])).installBannerDismissed === true; + } catch { + return true; + } +} + +// hideBanner — крестик: повтор не показывается. +export async function hideBanner() { + await db.putMeta({ installBannerDismissed: true }).catch(() => {}); +} diff --git a/web/js/sync.js b/web/js/sync.js index 870207c..b18fbf2 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -338,6 +338,17 @@ export function deviceId() { return state.device; } +// onSent ставит обработчик успешной отправки: по первой из них клиент +// один раз просит разрешение на уведомления (docs/ui.md, «Уведомления»). +// «Первой за всю историю устройства» это делает не здесь: транспорт +// не знает ни про разрешения, ни про то, о чём уже спрашивали. Ставит +// обработчик main.js. +let sentHandler = () => {}; + +export function onSent(handler) { + sentHandler = handler; +} + // --- устройство --------------------------------------------------------- // ensureDevice — deviceId устройства: 16 случайных байт base64url, @@ -1518,6 +1529,13 @@ async function post(message, peer, roomId) { const sent = { ...message, status: "sent", ts: answer?.ts ?? message.ts }; await db.saveMessages({ messages: [sent], me: state.nick }); notify([sent]); + // Сообщение ушло: обработчик решает, спрашивать ли разрешение + // на уведомления. Отправку он не задерживает и сорвать не может. + try { + sentHandler(); + } catch { + // Дело обработчика; отправка состоялась. + } return null; } catch (err) { // Ответ с кодом — то же доказательство, что запрос дошёл, что и 202: diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index 9e98695..57e8468 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -12,6 +12,11 @@ export function wide() { return matchMedia(DESKTOP).matches; } +// INSTALL_IOS — текст про установку на iOS. Он один и тот же в баннере +// над списком чатов и в настройках (docs/ui.md), поэтому и живёт в одном +// месте. +export const INSTALL_IOS = "уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»"; + export function el(tag, className, text) { const node = document.createElement(tag); if (className) { diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js index 4130793..d69da30 100644 --- a/web/js/ui/settings.js +++ b/web/js/ui/settings.js @@ -1,15 +1,30 @@ // Настройки — docs/ui.md, «Настройки». На этом этапе только разделы, -// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта. -// Уведомления, устройства, история и установка приложения — дальше по плану. +// которые уже работают: кто ты, уведомления, установка приложения, смена +// пароля, выход, удаление аккаунта. Устройства и история — дальше по плану. import { ApiError } from "../api.js"; import { fingerprintGroups } from "../crypto.js"; -import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; +import * as pwa from "../pwa.js"; +import { INSTALL_IOS, button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; + +// Состояния уведомлений и инструкция установки — docs/ui.md, «Настройки». +const NOTIFICATIONS = { + on: "включены", + off: "выключены", + denied: "запрещены в браузере", +}; + +const INSTALL_HINT = "поделиться → на экран «домой»"; export function renderSettings(root, ctx) { root.append(head(ctx)); const body = el("div", "body settings"); - body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); + body.append(identity(ctx), notificationsBlock(ctx)); + const install = installBlock(); + if (install !== null) { + body.append(install); + } + body.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); root.append(body); } @@ -31,6 +46,97 @@ function identity(ctx) { return box; } +// notificationsBlock — «уведомления»: состояние и одна кнопка +// (docs/ui.md, «Настройки»). Состояний три; «запрещены в браузере» — +// это и отклонённое разрешение, и браузер без уведомлений, и сервер без +// VAPID-ключа: включать нечем, кнопки нет (ADR-046). +function notificationsBlock(ctx) { + const box = block("уведомления"); + const status = el("p", "state", ""); + // На iOS вне установленного приложения кнопки нет: там пуши работают + // только у приложения на экране «Домой» (ADR-011). + const ios = pwa.iosBrowser(); + const action = button("включить"); + action.hidden = true; + const note = message(); + if (ios) { + box.append(status, el("p", "install", INSTALL_IOS), note); + } else { + box.append(status, action, note); + } + + let mode = "denied"; + const paint = async () => { + if (ios) { + status.textContent = NOTIFICATIONS.off; + return; + } + mode = await pwa.notifications(await vapidKey(ctx)); + status.textContent = NOTIFICATIONS[mode]; + action.textContent = mode === "on" ? "выключить" : "включить"; + action.hidden = mode === "denied"; + }; + + action.addEventListener("click", async () => { + if (action.disabled) { + return; + } + action.disabled = true; + setNote(note, ""); + try { + if (mode === "on") { + await pwa.disable(); + } else { + await pwa.enable(await vapidKey(ctx)); + } + } catch (err) { + setError(note, ctx.errorText(err)); + } finally { + action.disabled = false; + await paint(); + } + }); + + paint(); + return box; +} + +// vapidKey — публичный ключ сервера для подписки (docs/protocol.md, +// «Публичные»). Конфигурации нет — подписаться нечем. +async function vapidKey(ctx) { + try { + return (await ctx.ensureConfig()).vapidPublicKey ?? ""; + } catch { + return ""; + } +} + +// installBlock — «установить приложение» (docs/ui.md, «Настройки»). +// Кнопка есть, если поймано beforeinstallprompt; на iOS вместо неё +// инструкция. Устанавливать нечего — раздела нет. +function installBlock() { + const ios = pwa.iosBrowser(); + if (!ios && !pwa.installable()) { + return null; + } + const box = block("установить приложение"); + if (ios) { + box.append(el("p", "install", INSTALL_HINT)); + return box; + } + const action = button("установить"); + action.addEventListener("click", () => { + // Приглашение одноразовое: показали — устанавливать этим разделом + // больше нечего, и раздела нет (docs/ui.md, «Настройки»). Раздел + // уходит сразу: дальше человек отвечает браузеру, а не нам, и ждать + // его ответа кнопке незачем. + pwa.install(); + box.remove(); + }); + box.append(action); + return box; +} + function passwordBlock(ctx) { const box = block("сменить пароль"); const form = el("form", "form"); diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js index eb9d2d0..10a9652 100644 --- a/web/js/ui/shell.js +++ b/web/js/ui/shell.js @@ -1,7 +1,8 @@ // Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» // и «Список чатов». -import { el, mark } from "./dom.js"; +import * as pwa from "../pwa.js"; +import { INSTALL_IOS, clear, el, mark } from "./dom.js"; import { mount } from "./chats.js"; // frame отдаёт корень, место под экран и отписку списка чатов. @@ -23,6 +24,12 @@ function side(ctx, active) { brand.append(mark(), el("span", null, "bare")); nav.append(brand); + // Баннер установки — над списком чатов (docs/ui.md, «Баннер установки»). + // Место под него занимается сразу, содержимое приезжает из meta. + const place = el("div", "banner-slot"); + nav.append(place); + banner(place); + const list = el("div", "list"); const add = el("button", "item item--new", "+ новый чат"); add.type = "button"; @@ -41,3 +48,22 @@ function side(ctx, active) { return { nav, dispose: mount(items, ctx, active) }; } + +// banner — баннер установки на iOS: пуши там работают только +// у установленного приложения (ADR-011). Крестик закрывает его насовсем. +async function banner(place) { + if (!pwa.iosBrowser() || await pwa.bannerHidden()) { + return; + } + const box = el("div", "banner"); + box.append(el("p", "banner__text", INSTALL_IOS)); + const close = el("button", "banner__close", "×"); + close.type = "button"; + close.setAttribute("aria-label", "закрыть"); + close.addEventListener("click", () => { + clear(place); + pwa.hideBanner(); + }); + box.append(close); + place.append(box); +} diff --git a/web/sw.js b/web/sw.js index 9f2a8df..7e59f29 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,8 +1,213 @@ -// service worker. пока пустой: кэш оболочки и пуши — этап 4 (ADR-023). -// версия кэша меняется при релизе. +// Service worker: кэш оболочки, пуши и переход по уведомлению (ADR-023). +// +// Оболочка отдаётся stale-while-revalidate: сначала из кэша, следом — +// проверка у сервера. Всё под /api/ не кэшируется никогда: ни ответы, +// ни ошибки — это чужая почта, а не оболочка. +// +// Имя кэша содержит версию; версия — константа, она меняется при релизе, +// и старые кэши уходят в activate. -const CACHE = "bare-v1"; +const VERSION = "v2"; +const CACHE = `bare-${VERSION}`; -self.addEventListener("install", () => {}); +// Оболочка — всё, из чего клиент поднимается без сети. Список явный: +// у Cache API нет масок, а угадывать нечего — файлов немного и они +// перечислены в docs/plan.md (ADR-001). +const SHELL = [ + "/", + "/app.css", + "/manifest.json", + "/js/api.js", + "/js/crypto.js", + "/js/db.js", + "/js/main.js", + "/js/pwa.js", + "/js/sync.js", + "/js/ulid.js", + "/js/ui/auth.js", + "/js/ui/chat.js", + "/js/ui/chats.js", + "/js/ui/contact.js", + "/js/ui/dom.js", + "/js/ui/members.js", + "/js/ui/new.js", + "/js/ui/settings.js", + "/js/ui/shell.js", + "/icons/icon.svg", + "/icons/mark.svg", + "/icons/icon-180.png", + "/icons/icon-192.png", + "/icons/icon-512.png", +]; -self.addEventListener("activate", () => {}); +// Иконка уведомления — знак из /icons/ (ADR-023). +const ICON = "/icons/icon-192.png"; + +// Идентификатор чата из нагрузки пуша (ADR-023) и маршруты клиента +// (docs/ui.md, «Каркас») — разные формы одного и того же; перевод +// одной в другую — ADR-046. +const DM = /^dm:([a-z0-9_]{2,32})$/; +const ROOM = /^room:([A-Za-z0-9_-]{22})$/; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); +}); + +// activate уносит кэши прежних версий: имя кэша содержит версию, и всё, +// что названо иначе, — прошлый релиз. clients.claim берёт под контроль +// уже открытую страницу: без этого первый запуск остался бы без кэша +// и без перехода по уведомлению. +self.addEventListener("activate", (event) => { + event.waitUntil((async () => { + for (const name of await caches.keys()) { + if (name !== CACHE) { + await caches.delete(name); + } + } + await self.clients.claim(); + })()); +}); + +self.addEventListener("fetch", (event) => { + const request = event.request; + if (request.method !== "GET") { + return; + } + const url = new URL(request.url); + if (url.origin !== self.location.origin || !shell(url.pathname)) { + return; + } + event.respondWith(revalidate(event, request, url.origin + url.pathname + url.search)); +}); + +// shell — что относится к оболочке. Всё прочее идёт в сеть мимо кэша: +// /api/ — потому что это данные (ADR-023), /sw.js — потому что его +// обновление ведёт браузер, /healthz — потому что он про сервер. +function shell(path) { + return path === "/" + || path === "/app.css" + || path === "/manifest.json" + || path.startsWith("/js/") + || path.startsWith("/icons/"); +} + +// revalidate — stale-while-revalidate. Ответ из кэша уходит сразу, запрос +// к серверу идёт своим ходом и обновляет кэш. Сервер отдаёт статику +// с ETag и Cache-Control: no-cache (docs/protocol.md), поэтому обычный +// fetch — это условный запрос: неизменившийся файл стоит одного 304. +// +// Ключ кэша — адрес без фрагмента: у навигационного запроса в url лежит +// маршрут (`/#/dm/marta`), и по самому запросу запись `/` подменялась бы +// адресом последней перезагрузки. Сопоставление фрагмент и так +// игнорирует, а вот caches.keys() должен говорить правду о том, что +// лежит в оболочке (ADR-001). +function revalidate(event, request, key) { + return caches.open(CACHE).then(async (cache) => { + const cached = await cache.match(key); + const network = fetch(request).then((response) => { + // Кладём только цельный свой ответ: чужие и частичные в оболочке + // не бывают. + if (response.ok && response.type === "basic") { + cache.put(key, response.clone()); + } + return response; + }); + if (cached) { + // Проверка переживёт ответ: без waitUntil браузер вправе усыпить + // service worker сразу после отдачи страницы. + event.waitUntil(network.catch(() => {})); + return cached; + } + return network; + }); +} + +// push → уведомление. Содержимого сообщения в нагрузке нет и быть +// не может: сервер его не знает (ADR-011). tag — идентификатор чата: +// новое уведомление заменяет старое в том же чате (ADR-023). +self.addEventListener("push", (event) => { + const data = payload(event); + if (data === null) { + return; + } + event.waitUntil(self.registration.showNotification(data.title, { + body: data.body, + tag: data.chat, + data: { chat: data.chat }, + icon: ICON, + lang: "ru", + })); +}); + +// payload разбирает нагрузку пуша: {title, body, chat} (ADR-023). +// Чужого здесь не бывает — пуш подписан ключом сервера, — но показывать +// неразобранное всё равно нечем. +function payload(event) { + let data = null; + try { + data = event.data ? event.data.json() : null; + } catch { + return null; + } + if (data === null || typeof data !== "object") { + return null; + } + const { title, body, chat } = data; + if (typeof title !== "string" || typeof body !== "string" || typeof chat !== "string") { + return null; + } + if (title === "" || body === "" || chat === "") { + return null; + } + return { title, body, chat }; +} + +// notificationclick — фокус уже открытого окна с переходом на нужный чат +// либо открытие нового (ADR-023). +self.addEventListener("notificationclick", (event) => { + event.notification.close(); + event.waitUntil(open(route(event.notification.data?.chat))); +}); + +// route переводит идентификатор чата в маршрут клиента (ADR-046). +// Идентификатор не той формы открывает список. +function route(chat) { + if (typeof chat === "string") { + const dm = DM.exec(chat); + if (dm) { + return `/#/dm/${dm[1]}`; + } + const room = ROOM.exec(chat); + if (room) { + return `/#/room/${room[1]}`; + } + } + return "/#/"; +} + +async function open(path) { + const target = new URL(path, self.location.origin); + const windows = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); + for (const client of windows) { + if (new URL(client.url).origin !== target.origin) { + continue; + } + try { + await client.focus(); + } catch { + // Поднять окно не дали. Чат всё равно откроем: человек вернётся + // в приложение сам и увидит его открытым там, где нужно. + } + try { + // Маршрут живёт в hash: переход к нему — не перезагрузка, + // а событие hashchange, которое разбирает роутер клиента. + if (new URL(client.url).hash !== target.hash && typeof client.navigate === "function") { + await client.navigate(target.href); + } + } catch { + // Окно не наше или им уже распоряжаются: останется, где было. + } + return; + } + await self.clients.openWindow(target.href); +} -- 2.54.0 From 0c878477d2ed3695cd424175e9a55352ef010f53 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sun, 23 Aug 2026 02:57:51 +0300 Subject: [PATCH 7/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=205:=20=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=80=D0=B8=D1=8F=20=E2=80=94=20=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=B8=20=D0=B8=D0=BC=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=82=20.bare,=20=D1=83=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=B9=D1=81=D1=82=D0=B2=D0=B0,=20=D0=BC=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=BE,=20=D0=BF=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Экспорт: ключ архива из секрета аккаунта через HKDF, заголовок ровно 65 байт (magic, версия, соль, 32 сырых байта отпечатка владельца, iv) и он же целиком AAD шифротекста. Ника владельца в файле нет. Импорт сверяет отпечаток до расшифровки, сливает идемпотентно по id, а записи peers берёт только для ников, которых в локальном TOFU ещё нет: архивом доверие к ключу не перебить. Настройки: устройства с датой и пометкой «это устройство», «занято N МБ», кнопка «экспортировать» в подтверждении выхода и в подтверждении входа под другим ником — долг этапа 1 и обещание ADR-029 закрыты. Лента: страницы по 50 с подгрузкой вверх без прыжка прокрутки; новая страница вставляется, а не пересобирает ленту. ADR-050: импорт не перезаписывает лежащую запись — у своей есть состояние отправки, которого в архиве нет. ADR-054: архив — недоверенный ввод. Ревью собрало архивы с ts вне диапазона Date, мусорным lastId, ником с bidi-переопределением и roomId с обходом пути: каждый из них навсегда ломал ленту или счётчик. Теперь форма ника, roomId, id, ts и автора проверяется, а lastId из файла не читается вовсе. ADR-051, 052, 053: тексты и кнопки, устройства и место, страницы ленты без виртуализации. Приёмка: формат сверен побайтно на модулях, скачанных с боевого сервера, — смещения заголовка, отпечаток сырыми байтами, новые соль и iv на каждый экспорт, подмена любого байта заголовка ломает расшифровку, чужой секрет не открывает, семь видов битых файлов отвергнуты. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- docs/architecture.md | 2 +- docs/crypto.md | 2 + docs/decisions/009-local-history.md | 2 + .../050-import-keeps-local-record.md | 31 ++ docs/decisions/051-export-button-and-texts.md | 24 ++ .../052-settings-devices-and-space.md | 25 ++ .../053-feed-pages-without-virtualization.md | 27 ++ .../054-archive-is-untrusted-input.md | 26 ++ docs/storage.md | 6 +- docs/ui.md | 12 +- web/app.css | 49 +++ web/js/crypto.js | 138 ++++++- web/js/db.js | 110 ++++++ web/js/export.js | 342 ++++++++++++++++++ web/js/main.js | 30 ++ web/js/sync.js | 21 +- web/js/ui/auth.js | 29 +- web/js/ui/chat.js | 180 +++++++-- web/js/ui/dom.js | 26 +- web/js/ui/settings.js | 242 ++++++++++++- web/sw.js | 3 +- 21 files changed, 1274 insertions(+), 53 deletions(-) create mode 100644 docs/decisions/050-import-keeps-local-record.md create mode 100644 docs/decisions/051-export-button-and-texts.md create mode 100644 docs/decisions/052-settings-devices-and-space.md create mode 100644 docs/decisions/053-feed-pages-without-virtualization.md create mode 100644 docs/decisions/054-archive-is-untrusted-input.md create mode 100644 web/js/export.js diff --git a/docs/architecture.md b/docs/architecture.md index 9f6d7e6..364a94e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -38,7 +38,7 @@ Forward secrecy — осознанный non-goal v1. Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов (включая завёрнутые ключи комнат), транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: устройство — случайный идентификатор, который клиент создаёт при первом входе; доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. Схема — `docs/storage.md`, протокол — `docs/protocol.md`. -Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM. +Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений; загруженное держится в DOM целиком, виртуализации нет (ADR-053). При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`. diff --git a/docs/crypto.md b/docs/crypto.md index 63ac956..44d890e 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -106,6 +106,8 @@ header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) || iv Импорт: проверить magic и версию, сравнить `fingerprint` со своим — при несовпадении показать «архив создан другим аккаунтом» и остановиться, иначе вывести ключ и расшифровать. Слияние — идемпотентное по `id` сообщений и `id` чатов; записи `peers` из архива добавляются только для ников, которых в локальном TOFU ещё нет. +Чужая магия и незнакомая версия — файла в заголовке или нагрузки в поле `v` — показываются тем же текстом, что и порча: «файл повреждён». Третьего текста нет (ADR-054). Форму записей внутри нагрузки клиент проверяет сам — `docs/storage.md`, «Экспорт `.bare`». + ## Идентификаторы - ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется. diff --git a/docs/decisions/009-local-history.md b/docs/decisions/009-local-history.md index 56cddea..1df398d 100644 --- a/docs/decisions/009-local-history.md +++ b/docs/decisions/009-local-history.md @@ -1,5 +1,7 @@ # ADR-009: История — только на устройстве, в IndexedDB +Уточнён [ADR-053](053-feed-pages-without-virtualization.md): виртуализация списка в DOM снята, пагинация курсором по 50 в силе. + ## Контекст История, живущая на сервере, делает сервер архивом и целью атак. У Bare история — собственность устройства. diff --git a/docs/decisions/050-import-keeps-local-record.md b/docs/decisions/050-import-keeps-local-record.md new file mode 100644 index 0000000..18590f1 --- /dev/null +++ b/docs/decisions/050-import-keeps-local-record.md @@ -0,0 +1,31 @@ +# ADR-050: Импорт архива не перезаписывает то, что уже лежит + +## Контекст + +`docs/crypto.md` описывает слияние одной строкой: «идемпотентное по `id` сообщений и `id` чатов». Что делать с записью, которая на устройстве уже есть, там не сказано, а вариантов два, и они дают разную историю. + +ADR-034 такой же вопрос уже решал — для входящего из сети. Его довод к архиву не относится: `id` открыт собеседнику, и потому конверт с известным `id` игнорируется, а архив зашифрован секретом аккаунта, чужой его не соберёт. Значит правило нужно выбирать заново, а не наследовать. + +Молчат и три соседних места. Счётчик непрочитанных и граница «новых» в архив не пишутся (`docs/storage.md`) — но что происходит с местными, когда приходит история за прошлый год, не сказано. `pending` — сообщение, набранное на другом устройстве и туда же не ушедшее, — по букве документа в архив попадает: текст у него есть. И `lastId` чата в архив попадает тоже, хотя указывает на последнюю строку чата, а ею бывает как раз то, чего в архиве нет. + +## Решение + +- Импорт не перезаписывает существующую запись сообщения. Своя запись знает то, чего в архиве нет: состояние отправки у каждого устройства своё — на одном сообщение `failed`, на другом то же самое доставлено, — а нерасшифрованная хранит `raw`. +- Чат с известным `id` не перезаписывается. Меняется одно: `lastId` уезжает вперёд под самое новое из добавленного, иначе чат не встанет на своё место в списке. +- `lastId` в архив не пишется. Устройство, принявшее архив, считает его само — по тому, что действительно добавило. Взятый из файла, он указывал бы на строку, которой в архиве нет: последней в чате бывает и неотправленная, и нерасшифрованная. Такой `lastId` ставит пустой чат в начало списка, а после открытия чата уезжает в `lastReadId` — и настоящее сообщение с тем же `id`, приехав позже, не поднимет счётчик. +- Счётчик непрочитанных импорт не трогает: архив приносит переписку, а не отметки о прочтении. Граница «новых» едет за лентой: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет; у чата с непрочитанным граница уже показывает на него и остаётся на месте. Счётчик и граница считаются от одной точки — иначе счётчик говорит «1», а линия отчёркивает всю привезённую переписку. +- `hidden` в архив не пишется. «Убрать из списка» — решение устройства, а не история: перенесённое, оно спрятало бы привезённую переписку на новом устройстве, и показать её было бы нечем. +- В архив уносится только отправленное — `sent`. `pending` и `failed` привязаны к устройству и к своему ULID (ADR-036): на другом устройстве «повторить» у такой записи отправит собеседнику второе сообщение, а сама запись, ушедшая после повтора под свежим `id`, вернётся из того же файла дублем. Нерасшифрованное не уносится тоже: без текста от записи остаётся один заголовок, а `raw` — служебное поле. +- Ответ импорта — число добавленных сообщений: «добавлено N сообщений». Повторный импорт того же файла добавляет ноль. +- Лента открытого чата после импорта перечитывается целиком: добавленное ложится в середину пачками по несколько тысяч, и перечня в событии нет. +- Правила записаны в `docs/storage.md`, раздел «Экспорт `.bare`». + +## Следствия + +- Повторный импорт и склейка истории с двух устройств не создают дублей и не двигают ни одной прежней строки. Это верно и после «повторить»: отвергнутого в архиве нет. +- Нерасшифрованное остаётся нерасшифрованным, даже когда в архиве есть его текст. Чинит это `raw` и появившийся ключ, а не файл. Цена принята: правило одно и без исключений, а исключение стоило бы разбора, чья запись новее. +- Импорт истории годовой давности не превращает список чатов в стену непрочитанных и не отчёркивает её линией «новые». +- Архив, собранный устройством, у которого что-то не ушло, не заставляет второе устройство отправлять это за него. +- Неотправленное и отвергнутое живут ровно на одном устройстве. Потеря устройства без экспорта уносит их — как и всё, что не успело стать историей. +- Чат, убранный из списка на одном устройстве, на другом виден: вместе с ним видна и привезённая переписка. Комната, из которой мы вышли, прячется обратно при следующем `ready` — состав комнаты знает сервер (ADR-044). +- Чат, у которого в архиве нет ни одной строки истории, приезжает пустым и встаёт в конец списка: `lastId` у него пуст. diff --git a/docs/decisions/051-export-button-and-texts.md b/docs/decisions/051-export-button-and-texts.md new file mode 100644 index 0000000..a328835 --- /dev/null +++ b/docs/decisions/051-export-button-and-texts.md @@ -0,0 +1,24 @@ +# ADR-051: Кнопка «экспортировать» в подтверждениях и тексты архива + +## Контекст + +История на устройстве — единственная копия, и стирают её два экрана: «выйти» в настройках и вход под другим ником (ADR-029). `docs/ui.md` держит кнопку «экспортировать» только в первом из них, потому что второго ADR-029 коснулся тогда, когда экспорта не существовало вовсе, и прямо пообещал: «когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`». Экспорт появился. + +Ко второму экрану вопросов нет: секрет прежнего аккаунта лежит на устройстве, а сессия экспорту не нужна — архив собирается из IndexedDB (ADR-014). + +Тексты раздела «история» перечислены, но двух вещей в них нет. «добавлено N сообщений» не сходится с числом: при одном сообщении получается «добавлено 1 сообщений». И отказ, который не про файл: истории не прочитать, места на устройстве нет, ключей аккаунта нет — в перечне ответов такого нет, а показывать его надо: молчащая кнопка «экспортировать» перед стиранием истории — худший из возможных исходов. + +## Решение + +- Подтверждение входа под другим ником получает третью кнопку: «экспортировать», «удалить», «отмена». Порядок и место — как у подтверждения выхода: «экспортировать», «выйти», «отмена». +- Экспорт подтверждение не закрывает: архив скачался, а стирать историю или нет — отдельное решение того же человека. +- Начальный фокус в обоих подтверждениях — на «экспортировать»: с неё безопасно начинать. +- «добавлено N сообщений» согласуется с числом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений». +- «Тексты состояний» получают две строки: «экспорт не удался» — архив не собрался; «импорт не удался» — разобранный архив не дошёл до базы. Порча самого файла и чужой архив говорят о себе своими словами, они уже в перечне. +- Всё перечисленное записано в `docs/ui.md`: «Вход и регистрация», «Настройки», «Тексты состояний». + +## Следствия + +- Единственная копия истории не исчезает без предложения сохранить её ни на одном экране. +- Кнопок в подтверждении три, и в один ряд они помещаются не всегда: «экспортировать» в моноширинном шрифте шире трети колонки настроек, а насколько — решает системный шрифт платформы. Панель переносит их сама, цель нажатия остаётся 44 px. +- Обещание ADR-029 закрыто. diff --git a/docs/decisions/052-settings-devices-and-space.md b/docs/decisions/052-settings-devices-and-space.md new file mode 100644 index 0000000..140c944 --- /dev/null +++ b/docs/decisions/052-settings-devices-and-space.md @@ -0,0 +1,25 @@ +# ADR-052: Своё устройство из настроек не удаляется; занятое место — оценка браузера + +## Контекст + +`docs/ui.md` описывает раздел «устройства» одной строкой: список `id` (первые 8 символов), дата, «это устройство», «удалить». Три вещи в ней не решены, а решить их надо в коде. + +Первая — что делает «удалить» у своей строки. `DELETE /api/devices/{id}` уносит очередь, подписку и сессии устройства (`docs/protocol.md`). На своём это означает: следующий же запрос получает `401 unauthenticated` и по `docs/ui.md` («Сеть и состояния») уводит на экран входа с целой IndexedDB. Выходом это не является: «выйти» стирает историю и сначала предлагает её сохранить (ADR-051). Получается третье состояние, которого в документе нет, — выход без вопроса и без стирания, с прежним `deviceId` в `meta`, который при следующем входе заведёт устройство заново. + +Вторая — какая дата. Сервер отдаёт две: `createdAt` и `lastSeen`. + +Третья — что такое `N` в «занято N МБ». Байты `storage.estimate()` в мегабайтах дают дробь с десятком знаков, а браузер, который `estimate()` не умеет, не даёт и её. + +## Решение + +- Своё устройство из раздела не удаляется. У своей строки вместо кнопки стоит пометка «это устройство». Отцепляет текущее устройство «выйти»: там и вопрос про историю, и стирание базы. +- Сервер не меняется: `DELETE /api/devices/{id}` принимает любое своё устройство, включая текущее. Запрет — правило экрана, а не протокола: устройство, потерявшее сессию с чужой руки, обязано оставаться рабочим сценарием. +- Дата в строке — дата появления устройства (`createdAt`), в местной зоне, цифрами: `22.08.2026`. `lastSeen` не показывается: список нужен, чтобы узнать своё среди чужих и отцепить лишнее. +- «занято N МБ» — `usage` из `navigator.storage.estimate()`, МБ равен 1024×1024 байтам. Число человеческое: до десятых, пока меньше десяти, дальше целое; десятые округляются вверх, потому что пара сотен килобайт — это не «0 МБ». Браузер без `estimate()` строки не получает: писать в неё нечего. +- Записано в `docs/ui.md`, «Настройки». + +## Следствия + +- Потерянное устройство отцепляется с любого другого; текущее — выходом. +- Кнопки «удалить» у своей строки нет никогда, даже когда устройство одно. +- Занятое место — оценка происхождения целиком, а не сумма длин записей: индексы и служебные страницы IndexedDB тоже место. Она же намеренно грубая у самого браузера, и точнее показывать нечего. diff --git a/docs/decisions/053-feed-pages-without-virtualization.md b/docs/decisions/053-feed-pages-without-virtualization.md new file mode 100644 index 0000000..cdf630f --- /dev/null +++ b/docs/decisions/053-feed-pages-without-virtualization.md @@ -0,0 +1,27 @@ +# ADR-053: Лента страницами по 50, без виртуализации списка + +Уточняет [ADR-009](009-local-history.md): пагинация курсором остаётся, виртуализация снимается. + +## Контекст + +ADR-009 задаёт одной строкой две разные вещи: «пагинация курсором по ~50 сообщений, виртуализация списка в DOM». Первая — про данные и решает настоящую задачу: чат в десять тысяч сообщений не должен читаться из IndexedDB целиком при открытии. Вторая — про разметку, и её цена выяснилась только на этапе 5. + +Виртуализация требует знать высоту строки до отрисовки. В ленте её нет: текст переносится, на десктопе строка — две ячейки грида через `display: contents` (`docs/identity/brief.md`), сообщение бывает в одну строку и в тридцать. Значит нужны измерение каждой строки, распорки сверху и снизу и пересчёт при смене ширины окна. Платят за это не только кодом: `aria-live` на ленте (`docs/ui.md`, «Доступность») зачитывает появление и исчезновение строк, а поиск по странице и выделение текста перестают видеть то, что убрано из разметки. + +Выгоды при этом нет. В DOM попадает не вся история, а только то, что человек домотал прокруткой: открытие чата — 50 строк независимо от размера переписки. + +## Решение + +- Виртуализации в v1 нет. В разметке живёт всё загруженное. +- Лента открывается последней страницей в 50 сообщений и стоит в конце. +- Прокрутка к верхнему краю берёт следующие 50 назад по индексу `chat` (`docs/storage.md`). Страница короче полной означает, что выше ничего нет. +- Расстояние до низа при подгрузке сохраняется: то, что человек читает, не двигается. +- Страница короче окна прокрутки события `scroll` не порождает, поэтому следующая берётся сразу — пока лента не заполнит окно или сообщения не кончатся. +- Разделители дат и «новые» считаются по всему загруженному, а не по последней странице: граница «новых» уезжает вверх вместе с подгруженным. +- Записано в `docs/ui.md` («Чат») и `docs/architecture.md`. + +## Следствия + +- Домотавший до начала переписки в десять тысяч сообщений держит их все в разметке. Это его прокрутка и его выбор; обычное открытие чата — 50 строк. +- Экранный диктор, поиск по странице и выделение работают как в обычном документе. +- Возврат виртуализации — отдельный ADR, если появится жалоба, а не предположение. diff --git a/docs/decisions/054-archive-is-untrusted-input.md b/docs/decisions/054-archive-is-untrusted-input.md new file mode 100644 index 0000000..cdd8561 --- /dev/null +++ b/docs/decisions/054-archive-is-untrusted-input.md @@ -0,0 +1,26 @@ +# ADR-054: Архив — недоверенный ввод: форму записей проверяет клиент + +## Контекст + +Архив собрал владелец аккаунта: ключ выводится из секрета аккаунта, а заголовок целиком лежит под тегом AEAD (ADR-014). Отсюда легко сделать неверный вывод — что содержимому файла можно верить. + +Разбирается он на устройстве и ложится в базу рядом с настоящей историей. На сетевом пути форму держит сервер (`internal/api/valid.go`): ник — `[a-z0-9_]{2,32}` (ADR-019), идентификаторы — 22 символа base64url (`docs/crypto.md`), `ts` сервер ставит сам (ADR-017). Поэтому `sync.js` и обходится проверкой типа. У архива такой опоры нет: тег AEAD ловит порчу, но всё, что лежит под тегом, написал клиент — своей же прошлой или будущей версии. Архив живёт дольше версии, которая его собрала, и его разбор — единственное место, где клиент ест данные, которых больше никто не проверял. + +Цена видна на двух примерах. `ts` вне диапазона `Date` роняет отрисовку ленты на своей строке: `Intl` бросает `RangeError`, лента обрывается, чат не открывается больше никогда. Чат с ником не по форме нельзя ни открыть маршрутом (`docs/ui.md`, «Каркас»), ни убрать из списка — карточка контакта до такого ника не доходит. Убрать негодную запись из базы нечем: экрана для этого нет и не будет. + +Отдельный вопрос — незнакомая версия. Клиент отвечает на неё тем же текстом, что и на порчу, а `docs/ui.md` этого не говорит. + +## Решение + +- Форма проверяется при разборе, до записи в базу. Ник — `[a-z0-9_]{2,32}` (ADR-019); `roomId` — 22 символа base64url (`docs/crypto.md`, «Идентификаторы»); `id` сообщения — ULID; `ts` — целое от нуля до 8 640 000 000 000 000 (предел `Date`). Автор сообщения в личном чате — свой ник или ник собеседника: третьего в переписке двоих не бывает. В комнате автором бывает и вышедший участник, поэтому там сверяется только форма ника. +- Что не по форме, до базы не доходит: пропускается запись целиком, а не поле. +- Ничего, что устройство может посчитать само, из архива не читается: `lastId` чата считается по добавленному (ADR-050). +- Незнакомая версия — файла в заголовке или нагрузки в поле `v` — показывается как «файл повреждён». Третьего текста нет: разобрать такой архив это устройство всё равно не может, а строку под формат, которого ещё нет, пришлось бы придумывать. +- Записано в `docs/storage.md` и `docs/crypto.md`. + +## Следствия + +- Запись, которую нельзя ни открыть, ни убрать, в базу не попадает. +- Правила формы живут в двух местах: на сервере — для сети, в клиенте — для архива. Это цена того, что архив приходит с диска, а не из протокола. +- Архив будущей версии старый клиент назовёт повреждённым. Цена принята: версия формата пока одна, а вторая заведёт свой текст тем же порядком — сначала `docs/ui.md`. +- Проверка не защищает от оператора и не претендует на это: подделать архив без секрета аккаунта нельзя, а порчу ловит тег AEAD. Она защищает от собственных ошибок — от того, что записал клиент другой версии, и от того, что запишет он же завтра. diff --git a/docs/storage.md b/docs/storage.md index 30e31aa..3b405c0 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -149,4 +149,8 @@ peers key: nick ## Экспорт `.bare` -Полезная нагрузка — `chats` (без `unread`, `lastReadId`), `messages` (без `raw` и `error`, только с `text`), `peers` (без `pending`). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. +Полезная нагрузка — `chats` (без `lastId`, `unread`, `lastReadId`, `hidden`), `messages` (без `raw` и `error`), `peers` (без `pending`). Уносится только отправленное — `sent`. Неотправленное и отвергнутое остаются устройству: `pending` и `failed` — незаконченная и отвергнутая попытки, привязанные к своему ULID (ADR-036), а не история. Нерасшифрованное не уносится: без текста от записи остаётся один заголовок, а `raw` — служебное поле. Показания устройства — место чата в списке, счётчики и «убрано из списка» — не уносятся тоже (ADR-050). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare--.bare`. + +Архив — недоверенный ввод: форму каждой записи клиент проверяет сам, до записи в базу (ADR-054). Ник — `[a-z0-9_]{2,32}`, `roomId` — 22 символа base64url, `id` сообщения — ULID, `ts` — целое в пределах `Date`; автор сообщения в личном чате — свой ник или ник собеседника. Что не по форме, до базы не доходит: пропускается запись целиком, а не поле. + +Импорт вливает архив одной транзакцией и не трогает то, что уже лежит (ADR-050): сообщение и чат с известным `id` остаются как есть, `unread` и `hidden` не меняются, запись `peers` добавляется только для ника, которого в TOFU ещё нет. У чата двигается `lastId` — под самое новое из добавленного, — и вместе с ним граница «новых»: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет. Ответ — число добавленных сообщений; повторный импорт того же файла добавляет ноль. diff --git a/docs/ui.md b/docs/ui.md index 4a1f4fe..5b85526 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -18,7 +18,7 @@ Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний». -Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает. +Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «экспортировать», «удалить» и «отмена» (ADR-029, ADR-051). «экспортировать» скачивает `.bare` прежнего аккаунта и подтверждение не закрывает; сессия для этого не нужна. База стирается после успешного входа или регистрации; отказ сервера её не трогает. ## Список чатов (сайдбар) @@ -34,6 +34,8 @@ Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`. +Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053). + Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038). @@ -59,10 +61,10 @@ - «ты: @nick», свой отпечаток. - «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере. - «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет. -- «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». -- «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». +- «устройства»: список `id` (первые 8 символов), дата появления, «удалить». У своей строки кнопки нет: вместо неё пометка «это устройство». Своё устройство отсюда не отцепляется — это делает «выйти», где спрашивают про историю (ADR-052). +- «история»: «занято N МБ» — оценка браузера, до десятых, пока меньше десяти, дальше целые; браузер, который её не даёт, строки не показывает (ADR-052). «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». Число согласуется со словом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений» (ADR-051). - «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён». -- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». +- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». «экспортировать» — то же скачивание, что и в разделе «история»; подтверждение оно не закрывает (ADR-051). - «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена». ## Баннер установки (iOS) @@ -99,6 +101,8 @@ | ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» | | `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» | | `401 invalid_credentials` в настройках | «неверный пароль» | +| архив не собрался: истории не прочитать или ключей аккаунта на устройстве нет | «экспорт не удался» | +| разобранный архив не дошёл до базы: нет места или ключей аккаунта | «импорт не удался» | ## Доступность diff --git a/web/app.css b/web/app.css index 322a781..32dcdf3 100644 --- a/web/app.css +++ b/web/app.css @@ -165,6 +165,17 @@ input[type="password"] { width: auto; } +/* три кнопки подтверждения в один ряд помещаются не всегда: переносятся + и делят место поровну (ADR-051) */ + +.row--wrap { + flex-wrap: wrap; +} + +.row--wrap .button { + flex: 1 1 120px; +} + /* подписи и строки состояния: акцент mark — только ошибка */ .hint { @@ -488,6 +499,44 @@ input[type="password"] { color: var(--mute); } +/* устройства — docs/ui.md, «Настройки». Строка: восемь символов + идентификатора, дата появления и «удалить»; у своей строки вместо + кнопки пометка (ADR-052) */ + +.devices { + margin: 0 0 12px; + padding: 0; + list-style: none; +} + +.device { + display: flex; + align-items: center; + gap: 10px; + min-height: 28px; + font-size: 13px; + color: var(--text2); +} + +.device__id { + flex: none; +} + +.device .tag { + color: var(--stone); + font-size: 11px; +} + +.device .tag:last-child { + margin-left: auto; +} + +.device .link { + margin-left: auto; + color: var(--mute); + font-size: 12px; +} + /* баннер установки — docs/ui.md, «Баннер установки (iOS)». Цель нажатия у крестика — 44 px, отрицательные поля не дают ей растянуть сам баннер */ diff --git a/web/js/crypto.js b/web/js/crypto.js index 3862032..cbd74a0 100644 --- a/web/js/crypto.js +++ b/web/js/crypto.js @@ -105,6 +105,20 @@ export function fingerprintGroups(fingerprint) { return fingerprint.match(/.{1,4}/g) ?? []; } +// sameBytes — побайтное сравнение. Постоянного времени здесь не нужно: +// сравниваются отпечатки публичных ключей, а они не секрет. +export function sameBytes(a, b) { + if (a.length !== b.length) { + return false; + } + for (let i = 0; i < a.length; i += 1) { + if (a[i] !== b[i]) { + return false; + } + } + return true; +} + // wipe затирает сырые байты, когда они больше не нужны. export function wipe(bytes) { if (bytes instanceof Uint8Array) { @@ -193,10 +207,17 @@ export async function importSecret(bytes) { return subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey", "deriveBits"]); } -// fingerprint — SHA-256 несжатой точки публичного ключа, 64 hex строчными. -export async function fingerprint(publicKey) { +// fingerprintBytes — отпечаток сырыми байтами: SHA-256 несжатой точки +// публичного ключа, 32 байта. В заголовке архива лежат именно они, +// а не hex-строка (ADR-014). +export async function fingerprintBytes(publicKey) { const raw = await subtle.exportKey("raw", publicKey); - return hex(await subtle.digest("SHA-256", raw)); + return new Uint8Array(await subtle.digest("SHA-256", raw)); +} + +// fingerprint — тот же отпечаток для человека: 64 hex строчными. +export async function fingerprint(publicKey) { + return hex(await fingerprintBytes(publicKey)); } export async function fingerprintOf(jwk) { @@ -424,3 +445,114 @@ export async function openMessage(key, { id, chat, from, keyId, iv, ct }) { } return parsed.t; } + +// --- архив .bare ------------------------------------------------------- + +// Формат файла — docs/crypto.md, «Экспорт .bare»: +// +// header = "BARE" (4) || version u8 = 1 || salt (16) || fingerprint (32) +// || iv (12) // 65 байт +// file = header || AES-GCM(exportKey, iv, payload, AAD = header) +// +// Заголовок открыт и целиком входит в AAD: подмена любого его байта ломает +// расшифровку. Ника владельца в нём нет — это лишняя утечка (ADR-014). + +const EXPORT_INFO = "bare-export-v1"; +const MAGIC = "BARE"; + +// ARCHIVE_VERSION — версия формата файла. Не версия полезной нагрузки: +// та лежит внутри, полем v, и считается отдельно. +const ARCHIVE_VERSION = 1; + +const SALT_LEN = 16; +const FP_LEN = 32; +const MAGIC_AT = 0; +const VERSION_AT = 4; +const SALT_AT = 5; +const FP_AT = SALT_AT + SALT_LEN; +const IV_AT = FP_AT + FP_LEN; + +// HEADER_LEN — 65 байт, ровно как в docs/crypto.md. +const HEADER_LEN = IV_AT + IV_LEN; + +// Тег AES-GCM — 16 байт: короче шифротекста не бывает даже у пустого архива. +const TAG_LEN = 16; + +// archiveKey — ключ одного экспорта: HKDF из секрета аккаунта со случайной +// солью (ADR-014). Секрет — non-extractable CryptoKey типа HKDF; сырых байт +// у клиента нет и быть не должно. +function archiveKey(secret, salt) { + return subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt, info: utf8(EXPORT_INFO) }, + secret, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); +} + +function archiveHeader(salt, fingerprint, iv) { + const header = new Uint8Array(HEADER_LEN); + header.set(utf8(MAGIC), MAGIC_AT); + header[VERSION_AT] = ARCHIVE_VERSION; + header.set(salt, SALT_AT); + header.set(fingerprint, FP_AT); + header.set(iv, IV_AT); + return header; +} + +// sealArchive шифрует полезную нагрузку и собирает файл целиком. +// fingerprint — 32 сырых байта отпечатка владельца. +export async function sealArchive(secret, fingerprint, payload) { + if (fingerprint.length !== FP_LEN) { + throw new Error("отпечаток — не 32 байта"); + } + const salt = random(SALT_LEN); + const iv = random(IV_LEN); + const header = archiveHeader(salt, fingerprint, iv); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: header }, + await archiveKey(secret, salt), + payload, + ); + const file = new Uint8Array(HEADER_LEN + ct.byteLength); + file.set(header); + file.set(new Uint8Array(ct), HEADER_LEN); + return file; +} + +// parseArchive читает заголовок, ничего не расшифровывая: отпечаток +// владельца сверяется до вывода ключа (ADR-014). Чужая магия, чужая версия +// и файл короче заголовка с тегом — null. +export function parseArchive(bytes) { + if (!(bytes instanceof Uint8Array) || bytes.length < HEADER_LEN + TAG_LEN) { + return null; + } + const magic = utf8(MAGIC); + for (let i = 0; i < magic.length; i += 1) { + if (bytes[MAGIC_AT + i] !== magic[i]) { + return null; + } + } + if (bytes[VERSION_AT] !== ARCHIVE_VERSION) { + return null; + } + return { + header: bytes.subarray(0, HEADER_LEN), + salt: bytes.subarray(SALT_AT, FP_AT), + fingerprint: bytes.subarray(FP_AT, IV_AT), + iv: bytes.subarray(IV_AT, HEADER_LEN), + ct: bytes.subarray(HEADER_LEN), + }; +} + +// openArchive расшифровывает разобранный файл. Ошибка AEAD — единственный +// признак порчи: заголовок целиком в AAD, а всё остальное под тегом. +export async function openArchive(secret, archive) { + const plain = await subtle.decrypt( + { name: "AES-GCM", iv: archive.iv, additionalData: archive.header }, + await archiveKey(secret, archive.salt), + archive.ct, + ); + return new Uint8Array(plain); +} diff --git a/web/js/db.js b/web/js/db.js index 3539801..1e74149 100644 --- a/web/js/db.js +++ b/web/js/db.js @@ -438,6 +438,116 @@ export function putPeer(record) { return put("peers", record); } +// --- архив -------------------------------------------------------------- + +// allMessages и allPeers отдают хранилище целиком: архив .bare уносит всю +// историю устройства. Какие поля в него попадают, решает export.js — база +// отдаёт записи как есть (docs/storage.md, «Экспорт .bare»). +export async function allMessages() { + const db = await open(); + return value(db.transaction("messages", "readonly").objectStore("messages").getAll()); +} + +export async function allPeers() { + const db = await open(); + return value(db.transaction("peers", "readonly").objectStore("peers").getAll()); +} + +// mergeArchive вливает разобранный архив одной транзакцией: половина +// импорта хуже, чем ничего. +// +// Слияние идемпотентное по id сообщений и id чатов (docs/crypto.md): +// известная запись не трогается, а запись peers добавляется только для +// ника, которого в TOFU ещё нет. Своя запись всегда права — у неё есть +// состояние отправки, которого в архиве нет (ADR-050). +// +// Счётчик непрочитанных и «убрано из списка» — местные: импорт приносит +// историю, а не показания счётчиков. Место чата в списке при этом меняется: +// lastId растёт под самое новое из добавленного, и вместе с ним уезжает +// граница «новых» — пока непрочитанного у чата нет, ей нечего отчёркивать, +// а оставшись позади, она отчеркнула бы всю привезённую переписку при +// первом же входящем (ADR-050). +// +// Отдаёт число добавленных сообщений и ключи затронутых чатов. +export async function mergeArchive({ chats: list = [], messages = [], peers = [] } = {}) { + const db = await open(); + const tx = db.transaction(["chats", "messages", "peers"], "readwrite"); + const chatStore = tx.objectStore("chats"); + const messageStore = tx.objectStore("messages"); + const peerStore = tx.objectStore("peers"); + + // Все чтения — одним заходом до первой записи: что уже лежит в базе, + // надо знать целиком, а запросы этой же транзакции держат её живой. + const [ids, nicks, known] = await Promise.all([ + value(messageStore.getAllKeys()), + value(peerStore.getAllKeys()), + value(chatStore.getAll()), + ]); + const seen = new Set(ids); + const trusted = new Set(nicks); + const records = new Map(known.map((record) => [record.id, record])); + + const touched = new Set(); + for (const chat of list) { + if (records.has(chat.id)) { + continue; + } + // Показания устройства в архив не пишутся (docs/storage.md) — у новой + // записи они с чистого листа: место в списке считается по добавленному, + // счётчик пуст, чат в списке виден. + records.set(chat.id, { + ...blankChat(chat.id), + ...chat, + lastId: null, + lastReadId: null, + unread: 0, + hidden: false, + }); + touched.add(chat.id); + } + + let added = 0; + for (const record of messages) { + if (seen.has(record.id)) { + continue; + } + seen.add(record.id); + messageStore.put(record); + added += 1; + let chat = records.get(record.chatId); + if (!chat) { + chat = blankChat(record.chatId); + records.set(record.chatId, chat); + } + if (!chat.lastId || chat.lastId < record.id) { + chat.lastId = record.id; + } + touched.add(record.chatId); + } + + for (const record of peers) { + if (trusted.has(record.nick)) { + continue; + } + trusted.add(record.nick); + peerStore.put(record); + } + + for (const id of touched) { + const record = records.get(id); + // Граница «новых» едет за лентой, пока непрочитанного нет: счётчик + // и граница считаются от одной точки, иначе первое же входящее + // отчеркнёт «новыми» всю привезённую переписку. У чата с непрочитанным + // граница уже показывает на него и остаётся на месте (ADR-050). + if (record.unread === 0) { + record.lastReadId = record.lastId; + } + chatStore.put(record); + } + await done(tx); + return { added, chats: [...touched] }; +} + // persist просит браузер не вычищать базу: история на устройстве — // единственная копия (docs/storage.md). export async function persist() { diff --git a/web/js/export.js b/web/js/export.js new file mode 100644 index 0000000..739bd4f --- /dev/null +++ b/web/js/export.js @@ -0,0 +1,342 @@ +// Архив `.bare` — экспорт и импорт истории. +// +// История живёт только на устройстве (ADR-009), и архив — единственный +// способ перенести её на другое (ADR-010). Файл привязан к аккаунту +// криптографически: ключ выводится из секрета аккаунта, и у чужого клиента +// его нет (ADR-014). Формат — docs/crypto.md, «Экспорт .bare», состав +// полезной нагрузки — docs/storage.md. +// +// Модуль работает и без сессии: и история, и секрет аккаунта лежат +// на устройстве. Это и есть смысл кнопки «экспортировать» в подтверждении +// выхода и в подтверждении входа под другим ником (docs/ui.md). + +import * as db from "./db.js"; +import * as sync from "./sync.js"; +import { + fingerprintBytes, + fingerprintOf, + importPublic, + openArchive, + parseArchive, + publicJwk, + sameBytes, + sealArchive, + utf8, + wipe, +} from "./crypto.js"; +import { validUlid } from "./ulid.js"; + +// Версия полезной нагрузки — поле v внутри шифротекста (docs/crypto.md). +// Версия самого файла живёт в заголовке и считается отдельно. +const PAYLOAD_VERSION = 1; + +// Тексты отказа — docs/ui.md, «Настройки», раздел «история». +const BROKEN = "файл повреждён"; +const FOREIGN = "архив создан другим аккаунтом"; + +// Файл отдаётся как двоичный: своего типа у .bare нет и заводить его +// незачем. +const MIME = "application/octet-stream"; + +// ARCHIVE_EXT — расширение файла (docs/storage.md). Оно же уходит в accept +// выбора файла: предлагать человеку всё подряд незачем. +export const ARCHIVE_EXT = ".bare"; + +// Временный адрес живёт до конца скачивания: браузер читает Blob по нему +// уже после click. Минута — с запасом на медленный диск. +const REVOKE_AFTER = 60_000; + +const decoder = new TextDecoder(); + +// ArchiveError — отказ импорта. Сообщение уже пригодно для показа +// человеку (ADR-028): причин у отказа ровно две, и обе — в docs/ui.md. +export class ArchiveError extends Error { + constructor(text) { + super(text); + this.name = "ArchiveError"; + } +} + +// --- экспорт ------------------------------------------------------------ + +// exportHistory собирает архив и отдаёт его браузеру на скачивание. +export async function exportHistory() { + const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]); + if (!nick || !publicKey || !accountSecret) { + throw new Error("на устройстве нет ключей аккаунта"); + } + const fingerprint = await fingerprintBytes(await importPublic(publicKey)); + const payload = utf8(JSON.stringify(await collect())); + let file; + try { + file = await sealArchive(accountSecret, fingerprint, payload); + } finally { + // Плейнтекст истории в памяти дальше не нужен. + wipe(payload); + } + save(file, fileName(nick)); +} + +// collect — полезная нагрузка (docs/storage.md, «Экспорт .bare»). +async function collect() { + const [chats, messages, peers] = await Promise.all([ + // Скрытые чаты — тоже история: «убрать из списка» не удаление (ADR-019). + db.chats({ hidden: true }), + db.allMessages(), + db.allPeers(), + ]); + return { + v: PAYLOAD_VERSION, + exportedAt: Date.now(), + chats: chats.map(chatRecord), + messages: messages.filter(archivable).map(messageRecord), + peers: peers.map(peerRecord), + }; +} + +// archivable — что из ленты попадает в архив: только отправленное. +// Нерасшифрованное не уносится: без текста в архиве от него остался бы один +// заголовок, а raw — служебное поле. Незаконченная и отвергнутая попытки +// не уносятся тоже: pending и failed привязаны к устройству и к своему +// ULID (ADR-036) — на другом устройстве «повторить» отправило бы то же +// сообщение вторым, а запись, ушедшая после повтора под свежим id, +// вернулась бы из архива дублем (ADR-050). +function archivable(record) { + return typeof record?.text === "string" && record.status === "sent"; +} + +// fileName — bare--.bare (docs/storage.md). Дата местная: +// это день человека, а не UTC. +function fileName(nick) { + const now = new Date(); + const day = [ + String(now.getFullYear()).padStart(4, "0"), + String(now.getMonth() + 1).padStart(2, "0"), + String(now.getDate()).padStart(2, "0"), + ].join("-"); + return `bare-${nick}-${day}${ARCHIVE_EXT}`; +} + +// save отдаёт файл браузеру: Blob, временный адрес и . Ни +// inline-скриптов, ни атрибутов-обработчиков это не требует, а CSP +// default-src 'self' скачиванию не мешает: сохранение файла — не подгрузка +// ресурса страницы (ADR-021). +function save(bytes, name) { + const url = URL.createObjectURL(new Blob([bytes], { type: MIME })); + const link = document.createElement("a"); + link.href = url; + link.download = name; + link.hidden = true; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), REVOKE_AFTER); +} + +// --- импорт ------------------------------------------------------------- + +// importHistory разбирает выбранный файл и вливает его в базу. Порядок — +// docs/crypto.md: магия и версия, потом отпечаток владельца, и только потом +// ключ. Чужой архив не расшифровывается вовсе: сверка отпечатка — вежливость, +// настоящая защита в том, что секрета аккаунта у чужого клиента нет (ADR-014). +// +// Отдаёт число добавленных сообщений. +export async function importHistory(file) { + const { nick, publicKey, accountSecret } = await db.meta(["nick", "publicKey", "accountSecret"]); + if (!nick || !publicKey || !accountSecret) { + throw new Error("на устройстве нет ключей аккаунта"); + } + let bytes; + try { + bytes = new Uint8Array(await file.arrayBuffer()); + } catch { + // Файл не прочитался: для человека это то же самое, что порча. + throw new ArchiveError(BROKEN); + } + const archive = parseArchive(bytes); + if (archive === null) { + throw new ArchiveError(BROKEN); + } + const mine = await fingerprintBytes(await importPublic(publicKey)); + if (!sameBytes(archive.fingerprint, mine)) { + throw new ArchiveError(FOREIGN); + } + const payload = await unpack(accountSecret, archive); + const { added, chats } = await db.mergeArchive({ + chats: list(payload.chats).filter(usableChat).map(chatRecord), + messages: list(payload.messages).filter((record) => usableMessage(record, nick)).map(messageRecord), + peers: await peersOf(payload.peers), + }); + if (chats.length > 0) { + sync.imported(chats); + } + return added; +} + +// unpack расшифровывает и разбирает нагрузку. Порча заголовка, порча +// шифротекста и мусор внутри — одно и то же для человека: файл повреждён. +async function unpack(secret, archive) { + let payload; + try { + payload = JSON.parse(decoder.decode(await openArchive(secret, archive))); + } catch { + throw new ArchiveError(BROKEN); + } + if (payload === null || typeof payload !== "object" || payload.v !== PAYLOAD_VERSION) { + throw new ArchiveError(BROKEN); + } + return payload; +} + +function list(value) { + return Array.isArray(value) ? value : []; +} + +// --- записи ------------------------------------------------------------- +// +// Один и тот же отбор полей работает в обе стороны: что уходит в архив, +// то и приходит из него. Всё, чего в этих функциях нет, до базы не доходит. +// +// Место чата в списке, счётчик непрочитанных, граница «новых» и «убрано +// из списка» — показания устройства, а не история: lastId, unread, +// lastReadId и hidden в архив не пишутся (ADR-050). У lastId причина +// вторая: он указывает на последнюю строку чата, а ею бывает и та, +// которой в архиве нет, — неотправленная или нерасшифрованная. Устройство, +// принявшее архив, считает его само — по тому, что действительно добавило. + +function chatRecord(chat) { + const record = { + id: chat.id, + type: chat.type, + title: chat.title, + }; + if (chat.type === "dm") { + record.peer = chat.peer; + } else { + record.roomId = chat.roomId; + } + // Владелец и состав есть только у комнаты и приходят от сервера: пока + // комната не перечитана, их может не быть вовсе. + if (typeof chat.owner === "string") { + record.owner = chat.owner; + } + if (Array.isArray(chat.members)) { + record.members = chat.members.filter((nick) => typeof nick === "string"); + } + return record; +} + +function messageRecord(record) { + return { + id: record.id, + chatId: record.chatId, + from: record.from, + text: record.text, + ts: record.ts, + status: record.status, + }; +} + +function peerRecord(record) { + return { + nick: record.nick, + publicKey: publicJwk(record.publicKey), + fingerprint: record.fingerprint, + firstSeen: record.firstSeen, + }; +} + +// peersOf — записи TOFU из архива. Отпечаток считается заново из ключа: +// человек сверяет голосом именно его, и брать его на веру из файла рядом +// с ключом нельзя (ADR-016). Ключ, из которого отпечаток не считается, — +// не ключ, такая запись пропускается. +async function peersOf(peers) { + const out = []; + for (const record of list(peers)) { + if (!usablePeer(record)) { + continue; + } + try { + out.push({ + ...peerRecord(record), + fingerprint: await fingerprintOf(record.publicKey), + // Ждущий подтверждения ключ в архив не пишется (docs/storage.md). + pending: null, + }); + } catch { + // Не ключ. + } + } + return out; +} + +// --- разбор архива ------------------------------------------------------ +// +// Архив собрал владелец аккаунта — чужой его не соберёт (ADR-014), — но +// разбирается он на устройстве и ложится в базу рядом с настоящей историей. +// На сетевом пути форму держит сервер (internal/api/valid.go), поэтому +// sync.js обходится проверкой типа; у файла с диска такой опоры нет, и +// форму проверяет клиент, до записи (ADR-054). Запись, которую потом +// нельзя ни открыть, ни убрать, лежала бы в базе навсегда. + +// Ник — форма ADR-019; roomId — 16 случайных байт base64url, 22 символа +// (docs/crypto.md, «Идентификаторы»). +const NICK = /^[a-z0-9_]{2,32}$/; +const ROOM_ID = /^[A-Za-z0-9_-]{22}$/; + +// MAX_TS — предел Date: дальше `new Date(ts)` не дата вовсе, а лента +// падает на такой строке целиком (ADR-054). +const MAX_TS = 8.64e15; + +// known — ключ чата по форме docs/storage.md: «dm:<ник>» или «room:». +function known(chatId) { + if (typeof chatId !== "string") { + return false; + } + const peer = db.peerOf(chatId); + if (peer !== null) { + return NICK.test(peer); + } + const roomId = db.roomIdOf(chatId); + return roomId !== null && ROOM_ID.test(roomId); +} + +function usableChat(chat) { + if (chat === null || typeof chat !== "object" || !known(chat.id)) { + return false; + } + const peer = db.peerOf(chat.id); + // Вид чата задаёт его ключ: «dm:<ник>» или «room:» (docs/storage.md). + if (chat.type !== (peer !== null ? "dm" : "room")) { + return false; + } + if (peer !== null ? chat.peer !== peer : chat.roomId !== db.roomIdOf(chat.id)) { + return false; + } + return typeof chat.title === "string"; +} + +// usableMessage — строка истории. Автор в личном чате — свой ник или ник +// собеседника: третьего в переписке двоих не бывает. В комнате автором +// бывает и вышедший участник, поэтому там сверяется только форма ника. +function usableMessage(record, me) { + if (record === null || typeof record !== "object" || !known(record.chatId)) { + return false; + } + const peer = db.peerOf(record.chatId); + if (peer !== null && record.from !== peer && record.from !== me) { + return false; + } + return typeof record.id === "string" && validUlid(record.id) + && typeof record.from === "string" && NICK.test(record.from) + && typeof record.text === "string" + && Number.isSafeInteger(record.ts) && record.ts >= 0 && record.ts <= MAX_TS + && record.status === "sent"; +} + +function usablePeer(record) { + return record !== null && typeof record === "object" + && typeof record.nick === "string" && NICK.test(record.nick) + && record.publicKey !== null && typeof record.publicKey === "object" + && Number.isFinite(record.firstSeen); +} diff --git a/web/js/main.js b/web/js/main.js index 821e693..e16112e 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -62,6 +62,8 @@ const ctx = { }, errorText, ensureConfig, + devices, + removeDevice, signUp, signIn, changePassword, @@ -226,6 +228,34 @@ async function storedNick() { } } +// --- устройства --------------------------------------------------------- + +// devices — устройства аккаунта для настроек (docs/protocol.md, +// «Устройства»). Своё сервер помечает по сессии; заодно сверяем +// с устройством этой вкладки: сессия привязывается к устройству +// в POST /api/devices, и до него current не проставлен (ADR-017). +async function devices() { + const list = await api.devices(); + const mine = sync.deviceId(); + if (!Array.isArray(list)) { + return []; + } + return list + .filter((item) => item !== null && typeof item === "object" && typeof item.id === "string") + .map((item) => ({ + id: item.id, + createdAt: item.createdAt, + current: item.current === true || (mine !== null && item.id === mine), + })); +} + +// removeDevice — «удалить» в настройках: очередь, подписка и сессии +// устройства уходят вместе с ним. Своё устройство сюда не приходит — +// его отцепляет «выйти» (ADR-052). +function removeDevice(id) { + return api.removeDevice(id); +} + // --- аккаунт ----------------------------------------------------------- // derive — вывод ключей по числу итераций, пришедшему от сервера. Границы diff --git a/web/js/sync.js b/web/js/sync.js index b18fbf2..034d04a 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -98,8 +98,11 @@ const bus = new EventTarget(); // // "net" {online} — доходят ли запросы до сервера // "chats" {} — список чатов изменился -// "messages" {chatId, ids, removed} — в чате появились, изменились -// или исчезли сообщения +// "messages" {chatId, ids, removed, whole} +// — в чате появились, изменились +// или исчезли сообщения; whole +// означает «перечитай ленту +// целиком», без перечня (ADR-050) // "peers" {nick} — доверие к ключу ника изменилось: // появился pending или его подтвердили // "rooms" {id} — комната изменилась: имя, состав, @@ -174,6 +177,20 @@ function announceRoom(id) { share({ kind: "rooms", id, blocked: needsTrust(id) }); } +// imported — импорт архива влил историю в базу (ADR-050). Перечня +// добавленного в событии нет: сообщений бывает несколько тысяч и они +// старые, поэтому лента перечитывается целиком, а не строка за строкой. +// Запись сделал export.js, здесь остаётся поднять экраны — свои +// и соседних вкладок (ADR-035). +export function imported(chatIds) { + const details = chatIds.map((chatId) => ({ chatId, ids: [], removed: [], whole: true })); + for (const detail of details) { + emit("messages", detail); + } + emit("chats"); + share({ kind: "changed", details, settled: [] }); +} + // --- соседние вкладки --------------------------------------------------- // share отдаёт изменение соседним вкладкам. Канал открыт, только пока diff --git a/web/js/ui/auth.js b/web/js/ui/auth.js index 4d5043d..a0d14fc 100644 --- a/web/js/ui/auth.js +++ b/web/js/ui/auth.js @@ -1,6 +1,7 @@ // Экран входа и регистрации — docs/ui.md, «Вход и регистрация». -import { clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js"; +import { exportHistory } from "../export.js"; +import { EXPORT_FAILED, clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js"; const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. " + "не короче 12 символов; лучше — фраза из нескольких слов."; @@ -58,8 +59,11 @@ function screen(ctx, view, paint) { form.append(submit); // На устройстве могут лежать ключи другого ника: вход под этим сотрёт - // историю прежнего, поэтому сначала подтверждение (ADR-029). - const wipe = confirmPanel("", "удалить"); + // историю прежнего, поэтому сначала подтверждение (ADR-029). История + // на устройстве — единственная копия, и подтверждение предлагает сначала + // сохранить её: секрет прежнего аккаунта ещё здесь, экспорту сессия + // не нужна (ADR-014). + const wipe = confirmPanel("", "удалить", "экспортировать"); form.append(wipe.root); const note = message(); @@ -101,6 +105,22 @@ function screen(ctx, view, paint) { wipe.root.hidden = true; submit.hidden = false; }; + // Экспорт подтверждение не закрывает: архив скачался, а входить или нет — + // отдельное решение. + wipe.extra.addEventListener("click", async () => { + if (wipe.extra.disabled) { + return; + } + wipe.extra.disabled = true; + fail(""); + try { + await exportHistory(); + } catch { + fail(EXPORT_FAILED); + } finally { + wipe.extra.disabled = false; + } + }); wipe.no.addEventListener("click", () => { hideWipe(); submit.focus(); @@ -151,7 +171,8 @@ function screen(ctx, view, paint) { wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`; wipe.root.hidden = false; submit.hidden = true; - wipe.yes.focus(); + // Фокус — на «экспортировать»: с него безопасно начинать. + wipe.extra.focus(); return; } await run(); diff --git a/web/js/ui/chat.js b/web/js/ui/chat.js index 813706a..48aae63 100644 --- a/web/js/ui/chat.js +++ b/web/js/ui/chat.js @@ -34,6 +34,11 @@ const NAME_IN_HINT = 12; // сообщение подматывает ленту только тогда, когда он и так смотрит конец. const NEAR_BOTTOM = 80; +// Насколько близко к верхнему краю берётся следующая страница. Запас +// в экран: страница успевает приехать до того, как человек упрётся +// в край (ADR-053). +const NEAR_TOP = 200; + // renderChat рисует чат в root и отдаёт отписку. export function renderChat(root, ctx, chatId) { const view = { @@ -53,9 +58,19 @@ export function renderChat(root, ctx, chatId) { // Комнаты у нас больше нет: вышли сами, убрал владелец, комната // удалена. Ввод заблокирован, лента остаётся (ADR-044). gone: false, - // Лента: записи по возрастанию id и их строки в разметке. + // Лента: записи по возрастанию id и их разметка — строка и её + // разделители, id → {node, marks}. items: [], nodes: new Map(), + // Выше загруженного есть ещё сообщения: последняя страница пришла + // целой (ADR-053). + more: false, + // Страница уже едет: событий scroll приходит много подряд. + loading: false, + // Граница «новых» на момент открытия: было ли непрочитанное и докуда + // читали. Сама граница считается по всему загруженному — подгрузка + // вверх двигает её выше (ADR-053). + mark: { unread: false, bound: null }, // Граница «новых»: первый непрочитанный на момент открытия. newId: null, chain: Promise.resolve(), @@ -67,6 +82,12 @@ export function renderChat(root, ctx, chatId) { view.body = el("div", "grid"); view.body.setAttribute("aria-live", "polite"); view.feed.append(view.body); + // Прокрутка к верхнему краю берёт следующую страницу (ADR-053). + view.feed.addEventListener("scroll", () => { + if (view.feed.scrollTop <= NEAR_TOP) { + pull(view); + } + }); root.append(view.feed); root.append(composer(view)); @@ -258,27 +279,133 @@ async function load(view) { return; } view.items = list; - view.newId = firstUnread(record, list, view.me); + // Страница пришла целой — выше есть ещё (ADR-053). + view.more = list.length >= sync.PAGE; + view.mark = { unread: (record?.unread ?? 0) > 0, bound: record?.lastReadId ?? null }; + view.newId = firstUnread(view.mark, list, view.me); paint(view, true); // Фокус в строку ввода при открытии чата на десктопе (docs/ui.md, // «Доступность»); на мобильном это подняло бы клавиатуру на весь экран. if (wide()) { view.field.focus(); } + reach(view); await read(view); } // firstUnread — граница «новых»: первый чужой непрочитанный. Своё // непрочитанным не бывает, поэтому и границей не становится. -function firstUnread(record, list, me) { - if (!record || record.unread <= 0) { +// +// Считается по всему загруженному: чат с сотней непрочитанных открывается +// последней страницей, и первый из них лежит выше — граница находится, +// когда до него домотают (ADR-053). +function firstUnread(mark, list, me) { + if (!mark.unread) { return null; } - const bound = record.lastReadId; - const found = list.find((m) => m.from !== me && (!bound || m.id > bound)); + const found = list.find((m) => m.from !== me && (!mark.bound || m.id > mark.bound)); return found ? found.id : null; } +// --- страницы ----------------------------------------------------------- + +// pull просит следующую страницу. Событий scroll приходит много подряд, +// поэтому вход закрывается до постановки в очередь. +function pull(view) { + if (!view.more || view.loading || !view.alive) { + return; + } + view.loading = true; + run(view, () => older(view)); +} + +// older дописывает страницу сверху: курсор по индексу «chat» назад +// от самого старого загруженного, по 50 (docs/storage.md, ADR-053). +// Страница короче полной означает, что выше ничего нет. +async function older(view) { + const first = view.items[0] ?? null; + try { + const list = await sync.messagesBefore(view.chatId, first ? first.id : null); + if (!view.alive) { + return; + } + if (list.length < sync.PAGE) { + view.more = false; + } + if (list.length === 0) { + return; + } + view.items = [...list, ...view.items]; + keep(view, () => grow(view, list, first)); + } catch { + // Базы нет — оставляем то, что уже загружено. + } finally { + view.loading = false; + reach(view); + } +} + +// grow дописывает страницу сверху, не пересобирая ленту: нарисованное +// переживает подгрузку — выделение текста не пропадает, а живая область +// не зачитывается экранным диктором заново (ADR-053). Заново считаются две +// строки: та, что держала линию «новые», если граница уехала выше, и бывшая +// первая — у неё появился сосед сверху, а от соседа зависят разделитель +// даты и повтор автора. +function grow(view, list, head) { + const was = view.newId; + view.newId = firstUnread(view.mark, view.items, view.me); + const page = document.createDocumentFragment(); + let previous = null; + for (const record of list) { + line(view, record, previous, page); + previous = record; + } + view.body.insertBefore(page, view.body.firstChild); + if (was !== null && was !== view.newId && (head === null || was !== head.id)) { + const at = view.items.findIndex((m) => m.id === was); + if (at > 0) { + reline(view, view.items[at], view.items[at - 1]); + } + } + if (head !== null) { + reline(view, head, previous); + } +} + +// reline перерисовывает одну строку вместе с её разделителями: у неё +// сменился сосед сверху или уехала линия «новые». +function reline(view, record, previous) { + const old = view.nodes.get(record.id); + if (!old) { + return; + } + const next = old.node.nextSibling; + for (const node of old.marks) { + node.remove(); + } + old.node.remove(); + const box = document.createDocumentFragment(); + line(view, record, previous, box); + view.body.insertBefore(box, next); +} + +// keep сохраняет расстояние до низа ленты: подгрузка вверх не должна +// двигать то, что человек читает. +function keep(view, draw) { + const feed = view.feed; + const bottom = feed.scrollHeight - feed.scrollTop; + draw(); + feed.scrollTop = feed.scrollHeight - bottom; +} + +// reach берёт следующую страницу, когда прокручивать нечего: лента короче +// окна, события scroll не будет, а сообщения выше есть. +function reach(view) { + if (view.feed.scrollHeight <= view.feed.clientHeight) { + pull(view); + } +} + // read помечает чат прочитанным — после отрисовки: до этого lastReadId // и есть граница «новых» (docs/storage.md). async function read(view) { @@ -293,6 +420,13 @@ async function read(view) { // перерисовать: лента — живая область, и перерисовка заставила бы // экранного диктора зачитать её целиком. async function apply(view, detail) { + // Импорт архива приносит недостающую историю пачкой и в середину ленты: + // перечитать её целиком дешевле, чем вставлять сообщение за сообщением + // (ADR-050). + if (detail.whole === true) { + await load(view); + return; + } const incoming = []; for (const id of detail.ids ?? []) { let record = null; @@ -372,35 +506,35 @@ function paint(view, bottom) { } } -// line дописывает сообщение в конец ленты вместе с разделителями, -// которые перед ним нужны. -function line(view, record, previous) { - const day = !previous || dayOf(previous.ts) !== dayOf(record.ts); - if (day) { - view.body.append(divider(label(record.ts), false)); +// line дописывает сообщение в конец parent вместе с разделителями, которые +// перед ним нужны. Разделители принадлежат строке: подгрузка страницы +// сверху перерисовывает строку вместе с ними, а не всю ленту. +function line(view, record, previous, parent = view.body) { + const marks = []; + if (!previous || dayOf(previous.ts) !== dayOf(record.ts)) { + marks.push(divider(label(record.ts), false)); } - const fresh = record.id === view.newId; - if (fresh) { - view.body.append(divider("новые", true)); + if (record.id === view.newId) { + marks.push(divider("новые", true)); } // Подряд идущие сообщения одного автора — без повтора автора. - const first = day || fresh || !previous || previous.from !== record.from; + const first = marks.length > 0 || !previous || previous.from !== record.from; const node = el("div", first ? "line is-head" : "line"); node.append(author(view, record, first), text(view, record)); - view.body.append(node); - view.nodes.set(record.id, node); + parent.append(...marks, node); + view.nodes.set(record.id, { node, marks }); } // redraw обновляет одну строку на месте: автор и группировка от состояния // сообщения не зависят. function redraw(view, record) { - const node = view.nodes.get(record.id); - if (!node) { + const known = view.nodes.get(record.id); + if (!known) { return; } - const first = node.classList.contains("is-head"); - clear(node); - node.append(author(view, record, first), text(view, record)); + const first = known.node.classList.contains("is-head"); + clear(known.node); + known.node.append(author(view, record, first), text(view, record)); } function divider(caption, fresh) { diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js index 57e8468..ae9a5f3 100644 --- a/web/js/ui/dom.js +++ b/web/js/ui/dom.js @@ -76,20 +76,36 @@ export function button(text, className = "button") { return node; } -// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили. -// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа. -export function confirmPanel(question, yesLabel) { +// confirmPanel — вопрос и кнопки; спрятан, пока не спросили. Вопрос +// отдаётся наружу: его текст бывает известен только к моменту показа. +// +// extraLabel — необязательная третья кнопка перед «да». Там, где +// подтверждение уносит историю, это «экспортировать»: единственная копия +// не должна исчезать без предложения сохранить её (docs/ui.md, ADR-029). +// Отдаётся отдельным полем; без неё оно null. +export function confirmPanel(question, yesLabel, extraLabel = null) { const root = el("div"); root.hidden = true; const text = el("p", "confirm", question); const yes = button(yesLabel); const no = button("отмена"); - const row = el("div", "row"); + const extra = extraLabel === null ? null : button(extraLabel); + // Три кнопки в один ряд помещаются не всегда: «экспортировать» шире + // трети колонки, и ряд переносится (ADR-051). + const row = el("div", extra === null ? "row" : "row row--wrap"); + if (extra !== null) { + row.append(extra); + } row.append(yes, no); root.append(text, row); - return { root, text, yes, no }; + return { root, text, yes, no, extra }; } +// EXPORT_FAILED — экспорт не собрался: истории не прочитать или ключей +// аккаунта на устройстве нет (docs/ui.md, «Тексты состояний»). Текст один +// на все три места, где стоит кнопка «экспортировать». +export const EXPORT_FAILED = "экспорт не удался"; + // message — строка состояния под формой: ошибка цветом mark, ответ — mute. export function message() { const node = el("p", "message"); diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js index d69da30..3cc1d08 100644 --- a/web/js/ui/settings.js +++ b/web/js/ui/settings.js @@ -1,11 +1,22 @@ -// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы, -// которые уже работают: кто ты, уведомления, установка приложения, смена -// пароля, выход, удаление аккаунта. Устройства и история — дальше по плану. +// Настройки — docs/ui.md, «Настройки»: кто ты, уведомления, установка +// приложения, устройства, история, смена пароля, выход, удаление аккаунта. import { ApiError } from "../api.js"; import { fingerprintGroups } from "../crypto.js"; +import { ARCHIVE_EXT, ArchiveError, exportHistory, importHistory } from "../export.js"; import * as pwa from "../pwa.js"; -import { INSTALL_IOS, button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; +import { + EXPORT_FAILED, + INSTALL_IOS, + button, + clear, + confirmPanel, + el, + field, + message, + setError, + setNote, +} from "./dom.js"; // Состояния уведомлений и инструкция установки — docs/ui.md, «Настройки». const NOTIFICATIONS = { @@ -16,6 +27,27 @@ const NOTIFICATIONS = { const INSTALL_HINT = "поделиться → на экран «домой»"; +// Сколько символов идентификатора устройства видно в списке (docs/ui.md, +// «Настройки»). Восьми хватает, чтобы отличить одно устройство от другого: +// идентификатор случайный. +const ID_SHOWN = 8; + +// Дата появления устройства — местная, цифрами: она стоит в строке рядом +// с идентификатором, и длинная форма её бы утопила (ADR-052). +const DEVICE_DAY = new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", +}); + +// МБ занятого места — 1024×1024 байта (ADR-052). +const MB = 1024 * 1024; + +// Импорт не дошёл до базы: места на устройстве нет или ключей аккаунта +// на нём нет (docs/ui.md, «Тексты состояний»). Порча самого файла говорит +// о себе своими словами. +const IMPORT_FAILED = "импорт не удался"; + export function renderSettings(root, ctx) { root.append(head(ctx)); const body = el("div", "body settings"); @@ -24,7 +56,7 @@ export function renderSettings(root, ctx) { if (install !== null) { body.append(install); } - body.append(passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); + body.append(devicesBlock(ctx), historyBlock(), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); root.append(body); } @@ -137,6 +169,187 @@ function installBlock() { return box; } +// devicesBlock — «устройства»: список идентификаторов, дата появления +// и «удалить» (docs/ui.md, «Настройки»). +// +// У своей строки кнопки нет: там пометка «это устройство». Удаление уносит +// сессии устройства, и на своём это оставило бы человека на экране входа +// с целой историей и без объяснения; отцепляет своё устройство «выйти» +// (ADR-052). +function devicesBlock(ctx) { + const box = block("устройства"); + const list = el("ul", "devices"); + const note = message(); + box.append(list, note); + + const row = (item) => { + const line = el("li", "device"); + line.append(el("span", "device__id", item.id.slice(0, ID_SHOWN))); + if (Number.isFinite(item.createdAt)) { + line.append(el("span", "tag", DEVICE_DAY.format(item.createdAt))); + } + if (item.current) { + line.append(el("span", "tag", "это устройство")); + return line; + } + const drop = el("button", "link", "удалить"); + drop.type = "button"; + drop.addEventListener("click", async () => { + drop.disabled = true; + setNote(note, ""); + try { + await ctx.removeDevice(item.id); + await paint(); + } catch (err) { + setError(note, ctx.errorText(err)); + drop.disabled = false; + } + }); + line.append(drop); + return line; + }; + + // paint перечитывает список: он же и есть ответ на удаление. + async function paint() { + let items; + try { + items = await ctx.devices(); + } catch (err) { + setError(note, ctx.errorText(err)); + return; + } + setNote(note, ""); + clear(list); + for (const item of items) { + list.append(row(item)); + } + } + + paint(); + return box; +} + +// historyBlock — «история»: занятое место, экспорт и импорт архива +// (docs/ui.md, «Настройки»). +function historyBlock() { + const box = block("история"); + // Место занято не только сообщениями: считает его браузер, а не мы + // (ADR-009). Браузер, который считать не умеет, строки не получает — + // писать в неё нечего (ADR-052). + const used = el("p", "state", ""); + used.hidden = true; + // Строка перечитывается и после импорта: «добавлено N сообщений» рядом + // с прежней цифрой — две строки об одном действии, говорящие разное. + const showSpace = async () => { + const bytes = await space(); + if (bytes === null) { + return; + } + used.textContent = `занято ${megabytes(bytes)} МБ`; + used.hidden = false; + }; + showSpace(); + const out = button("экспорт"); + const take = button("импорт"); + // Выбор файла — обычный , спрятанный за кнопкой: + // системный вид ему тут не к месту, а поведение нужно родное. + const picker = el("input"); + picker.type = "file"; + picker.accept = ARCHIVE_EXT; + picker.hidden = true; + const note = message(); + box.append(used, out, take, picker, note); + + out.addEventListener("click", () => runExport(out, note)); + take.addEventListener("click", () => { + if (take.disabled) { + return; + } + setNote(note, ""); + picker.click(); + }); + picker.addEventListener("change", async () => { + const file = picker.files?.[0] ?? null; + // Тот же файл, выбранный второй раз подряд, обязан считаться выбором: + // без сброса change не приходит. + picker.value = ""; + if (file === null) { + return; + } + take.disabled = true; + try { + const added = await importHistory(file); + setNote(note, `добавлено ${plural(added)}`); + await showSpace(); + } catch (err) { + setError(note, err instanceof ArchiveError ? err.message : IMPORT_FAILED); + } finally { + take.disabled = false; + } + }); + return box; +} + +// runExport — «экспорт» в разделе истории и «экспортировать» в подтверждении +// выхода: действие одно, кнопки две. Удача говорит сама за себя — браузер +// сохраняет файл, писать об этом нечего. +async function runExport(action, note) { + if (action.disabled) { + return; + } + action.disabled = true; + setNote(note, ""); + try { + await exportHistory(); + } catch { + setError(note, EXPORT_FAILED); + } finally { + action.disabled = false; + } +} + +// space — занятое место в байтах или null, если браузер его не считает +// (docs/storage.md, ADR-009). Это оценка происхождения целиком: индексы +// и служебные страницы IndexedDB тоже занимают место. +async function space() { + if (!navigator.storage?.estimate) { + return null; + } + try { + const { usage } = await navigator.storage.estimate(); + return Number.isFinite(usage) ? usage : null; + } catch { + return null; + } +} + +// megabytes — «занято N МБ» человеческим числом: до десятых, пока меньше +// десяти, дальше целые (ADR-052). Десятые округляются вверх: пара сотен +// килобайт — это не «0 МБ». +function megabytes(bytes) { + const value = bytes / MB; + if (value >= 10) { + return String(Math.round(value)); + } + return (Math.ceil(value * 10) / 10).toLocaleString("ru-RU"); +} + +// plural склоняет «сообщение» с числом: «добавлено 1 сообщение», +// «добавлено 2 сообщения», «добавлено 5 сообщений» (docs/ui.md). +function plural(count) { + const tail = count % 100; + const last = count % 10; + if (tail < 11 || tail > 14) { + if (last === 1) { + return `${count} сообщение`; + } + if (last >= 2 && last <= 4) { + return `${count} сообщения`; + } + } + return `${count} сообщений`; +} + function passwordBlock(ctx) { const box = block("сменить пароль"); const form = el("form", "form"); @@ -198,27 +411,38 @@ function passwordBlock(ctx) { function exitBlock(ctx) { const box = block("выйти"); const start = button("выйти"); - // Кнопки «экспортировать» пока нет: экспорт — этап 5 (docs/plan.md). - const panel = confirmPanel("история на этом устройстве будет удалена. экспортировать сначала?", "выйти"); + // История на этом устройстве — единственная копия, поэтому подтверждение + // предлагает сначала сохранить её (docs/ui.md, ADR-029). + const panel = confirmPanel( + "история на этом устройстве будет удалена. экспортировать сначала?", + "выйти", + "экспортировать", + ); + const note = message(); start.addEventListener("click", () => { start.hidden = true; panel.root.hidden = false; - panel.yes.focus(); + panel.extra.focus(); }); + // Экспорт подтверждение не закрывает: человек скачивает архив и решает + // дальше сам. + panel.extra.addEventListener("click", () => runExport(panel.extra, note)); panel.no.addEventListener("click", () => { panel.root.hidden = true; + setNote(note, ""); start.hidden = false; start.focus(); }); panel.yes.addEventListener("click", async () => { panel.yes.disabled = true; panel.no.disabled = true; + panel.extra.disabled = true; await ctx.signOut(); ctx.go("#/"); }); - box.append(start, panel.root); + box.append(start, panel.root, note); return box; } diff --git a/web/sw.js b/web/sw.js index 7e59f29..cdc2eb4 100644 --- a/web/sw.js +++ b/web/sw.js @@ -7,7 +7,7 @@ // Имя кэша содержит версию; версия — константа, она меняется при релизе, // и старые кэши уходят в activate. -const VERSION = "v2"; +const VERSION = "v3"; const CACHE = `bare-${VERSION}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: @@ -20,6 +20,7 @@ const SHELL = [ "/js/api.js", "/js/crypto.js", "/js/db.js", + "/js/export.js", "/js/main.js", "/js/pwa.js", "/js/sync.js", -- 2.54.0 From db45978b1640a8a54f42e6ebc1413e1b5a023cd3 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sun, 23 Aug 2026 06:22:52 +0300 Subject: [PATCH 8/8] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF=206:=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BA=D0=B0=D0=BB=D0=BA=D0=B0=20=E2=80=94=20=D0=BB=D0=B8?= =?UTF-8?q?=D0=BC=D0=B8=D1=82=D1=8B=20ADR-021,=20=D0=B0=D1=83=D0=B4=D0=B8?= =?UTF-8?q?=D1=82=20=D0=BC=D0=BE=D0=B4=D0=B5=D0=BB=D0=B8=20=D1=83=D0=B3?= =?UTF-8?q?=D1=80=D0=BE=D0=B7,=20=D1=81=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After; X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок отменял бы лимит на IP; карты вёдер ограничены поколениями. Аудит нашёл то, что пропустили пять раундов ревью: ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md. Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare. ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная сессия продолжала получать сообщения. ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа — вопреки обещанию storage.md о повторной попытке после получения keyId. ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих запросах, включая выход из комнаты: 116 отказов за прогон стало нулём. ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version, 403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода, kdf как оракул после повышения цели KDF. Модель угроз пополнена тем, что действительно видит оператор: push-подписки лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это произвольное уведомление на экране блокировки. README приведён к v1. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ --- README.md | 4 +- cmd/bare/main.go | 31 +- docs/architecture.md | 2 +- .../015-password-never-leaves-client.md | 2 + docs/decisions/018-rooms-membership-rekey.md | 2 + docs/decisions/021-sessions-csrf-limits.md | 2 + docs/decisions/022-deploy-nginx-systemd.md | 2 + docs/decisions/033-failed-message-reason.md | 2 + docs/decisions/042-current-room-key-order.md | 2 + docs/decisions/055-limit-keys-and-bounds.md | 39 +++ docs/decisions/056-nginx-access-log-off.md | 21 ++ .../decisions/057-version-marks-dirty-tree.md | 21 ++ docs/decisions/058-logout-closes-stream.md | 24 ++ .../059-room-keys-kept-are-handed-out.md | 26 ++ .../060-unknown-device-keeps-pending.md | 23 ++ docs/decisions/061-room-name-in-input-hint.md | 23 ++ .../062-kdf-answer-and-nick-existence.md | 23 ++ docs/decisions/063-ack-goes-in-batches.md | 31 ++ docs/deploy.md | 10 +- docs/protocol.md | 23 +- docs/storage.md | 5 +- docs/threat-model.md | 6 +- docs/ui.md | 2 +- internal/api/account.go | 52 ++- internal/api/account_test.go | 14 +- internal/api/api.go | 73 +++-- internal/api/api_test.go | 32 +- internal/api/events_test.go | 39 +++ internal/api/limit.go | 168 ++++++++-- internal/api/limit_test.go | 177 +++++++--- internal/api/messages.go | 28 +- internal/api/push_test.go | 36 +++ internal/api/ratelimit_test.go | 305 ++++++++++++++++++ internal/api/rooms.go | 60 ++-- internal/api/rooms_test.go | 127 +++++--- internal/store/rooms.go | 33 +- internal/store/rooms_test.go | 41 ++- internal/store/store_test.go | 16 +- internal/store/users.go | 49 ++- web/app.css | 4 + web/js/api.js | 23 +- web/js/main.js | 4 + web/js/pwa.js | 26 +- web/js/sync.js | 169 ++++++++-- web/sw.js | 2 +- 45 files changed, 1522 insertions(+), 282 deletions(-) create mode 100644 docs/decisions/055-limit-keys-and-bounds.md create mode 100644 docs/decisions/056-nginx-access-log-off.md create mode 100644 docs/decisions/057-version-marks-dirty-tree.md create mode 100644 docs/decisions/058-logout-closes-stream.md create mode 100644 docs/decisions/059-room-keys-kept-are-handed-out.md create mode 100644 docs/decisions/060-unknown-device-keeps-pending.md create mode 100644 docs/decisions/061-room-name-in-input-hint.md create mode 100644 docs/decisions/062-kdf-answer-and-nick-existence.md create mode 100644 docs/decisions/063-ack-goes-in-batches.md create mode 100644 internal/api/ratelimit_test.go diff --git a/README.md b/README.md index 2c75743..81a0884 100644 --- a/README.md +++ b/README.md @@ -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. ## Лицензия diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 05be1fc..b165156 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -81,6 +81,11 @@ func serve() error { } h := api.New(cfg, st, static, os.Stdout) + // Отправщики пушей дописывают начатое и пишут результат в базу, поэтому + // остановить их надо раньше, чем закроется st. defer выстроен на это: + // h.Close отложен позже st.Close и выполнится раньше него. + defer h.Close() + srv := &http.Server{ Handler: h, ReadHeaderTimeout: 10 * time.Second, @@ -93,7 +98,11 @@ func serve() error { // Потоки событий не заканчиваются сами: без этого Shutdown ждал бы, // пока подключённые клиенты уйдут, до самого таймаута (ADR-004). - srv.RegisterOnShutdown(h.Close) + // Здесь только закрытие потоков: колбэк крутится в своей горутине, + // и Shutdown его не дожидается — дождаться отправки пушей отсюда + // нельзя. Их останавливает h.Close, когда Shutdown уже вернулся + // и обработчики отработали. + srv.RegisterOnShutdown(h.CloseStreams) // Сначала bind, потом сообщение: строка в журнале означает, что порт занят // нами, а не то, что мы собирались его занять. @@ -154,15 +163,29 @@ func version() { fmt.Println(revision()) } +// revision — ревизия сборки. У бинаря из изменённого рабочего дерева +// к ней дописывается «+dirty»: сверка хеша со сборкой из тега — единственное +// смягчение против подмены клиента (docs/threat-model.md), и чистый хеш +// коммита у бинаря с чужими правками сводил бы её на нет (ADR-057). func revision() string { info, ok := debug.ReadBuildInfo() if !ok { return "unknown" } + var vcs, modified string for _, s := range info.Settings { - if s.Key == "vcs.revision" { - return s.Value + switch s.Key { + case "vcs.revision": + vcs = s.Value + case "vcs.modified": + modified = s.Value } } - return "unknown" + if vcs == "" { + return "unknown" + } + if modified == "true" { + return vcs + "+dirty" + } + return vcs } diff --git a/docs/architecture.md b/docs/architecture.md index 364a94e..69842c3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -28,7 +28,7 @@ Bare — это PWA-клиент на ванильных веб-технолог Чаты 1:1: ECDH shared secret → HKDF → AES-GCM. -Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст), чтобы новое устройство участника получило текущий ключ. Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников. +Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст) и отдаёт участнику все, что держит, — два последних: новое устройство получает действующий ключ, а вернувшееся из офлайна — ещё и пропущенный, которым зашифровано лежащее в его очереди (ADR-059). Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников. Все процедуры побайтно — `docs/crypto.md`. diff --git a/docs/decisions/015-password-never-leaves-client.md b/docs/decisions/015-password-never-leaves-client.md index d7f3483..b7acacd 100644 --- a/docs/decisions/015-password-never-leaves-client.md +++ b/docs/decisions/015-password-never-leaves-client.md @@ -1,5 +1,7 @@ # ADR-015: Пароль не покидает клиент — два ключа из одного мастера +Уточнён [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает и потоки событий отозванных сессий; [ADR-062](062-kdf-answer-and-nick-existence.md): `GET /api/kdf` не обещает скрывать существование ника. + ## Контекст ADR-005 и ADR-006 используют один пароль и для серверной аутентификации (Argon2id), и как материал ключа шифрования блоба. Если клиент отправляет пароль на сервер в открытом виде, оператор, логирующий тела запросов, получает материал ключа — и обещание «оператор не читает сообщения» рушится на первом же входе. Кроме того, ADR-013 требует повышать число итераций KDF без миграции всех аккаунтов разом, а смена пароля числится открытым вопросом. diff --git a/docs/decisions/018-rooms-membership-rekey.md b/docs/decisions/018-rooms-membership-rekey.md index 35afefe..9efa5bd 100644 --- a/docs/decisions/018-rooms-membership-rekey.md +++ b/docs/decisions/018-rooms-membership-rekey.md @@ -1,5 +1,7 @@ # ADR-018: Комнаты — владелец, состав и атомарный rekey +Уточнён [ADR-039](039-room-key-sender-is-member.md) (кто вправе раздавать ключ комнаты), [ADR-041](041-needs-rekey-is-state.md) (`needsRekey` — состояние комнаты, а не свойство события), [ADR-042](042-current-room-key-order.md) (какой ключ считается текущим) и [ADR-059](059-room-keys-kept-are-handed-out.md) (участник получает все удерживаемые ключи, а не только текущий). + ## Контекст ADR-007 задаёт принцип: симметричный ключ комнаты, раздача на публичные ключи, rekey при смене состава. Не определено: кто меняет состав, как ключ попадает на новое устройство участника, что происходит при гонке двух rekey и при выходе участника. diff --git a/docs/decisions/021-sessions-csrf-limits.md b/docs/decisions/021-sessions-csrf-limits.md index ae4a569..1c63b4f 100644 --- a/docs/decisions/021-sessions-csrf-limits.md +++ b/docs/decisions/021-sessions-csrf-limits.md @@ -1,5 +1,7 @@ # ADR-021: Сессии, CSRF, Argon2id и лимиты +Уточнён [ADR-055](055-limit-keys-and-bounds.md): пакеты правил, состав «остальных изменяющих», место лимита в порядке проверок, границы карт и доверие к `X-Real-IP`; [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает потоки событий отозванных сессий, а не только удаляет их строки. + ## Контекст ADR-005 задаёт «Argon2id, сессия в httpOnly cookie» без параметров. Cookie плюс `fetch POST` — классическая поверхность для CSRF. Лимиты и защита от перебора — открытый вопрос. diff --git a/docs/decisions/022-deploy-nginx-systemd.md b/docs/decisions/022-deploy-nginx-systemd.md index 36fca2e..66eb5a6 100644 --- a/docs/decisions/022-deploy-nginx-systemd.md +++ b/docs/decisions/022-deploy-nginx-systemd.md @@ -1,5 +1,7 @@ # ADR-022: Деплой — nginx, systemd, кросс-сборка +Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева). + ## Контекст Целевой сервер (`ssh xmatic`, Ubuntu 22.04) уже держит nginx на 80/443 с десятком сайтов и certbot. Go на сервере нет. HTTPS обязателен (ADR-002), но TLS в самом бинаре означал бы либо `autocert` — четвёртую зависимость, — либо конфликт за 443 с nginx. diff --git a/docs/decisions/033-failed-message-reason.md b/docs/decisions/033-failed-message-reason.md index db98880..40b3cd6 100644 --- a/docs/decisions/033-failed-message-reason.md +++ b/docs/decisions/033-failed-message-reason.md @@ -1,5 +1,7 @@ # ADR-033: Текст отказа у неотправленного сообщения +Уточнён [ADR-036](036-resend-keeps-ulid.md) (новая попытка заводит запись без поля `error`) и [ADR-060](060-unknown-device-keeps-pending.md) (`403 unknown_device` — не отказ сообщению). + ## Контекст `docs/storage.md` задаёт судьбу исходящего: `202` → `sent`, сетевая ошибка → остаётся `pending`, `4xx` → `failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`. diff --git a/docs/decisions/042-current-room-key-order.md b/docs/decisions/042-current-room-key-order.md index 7b06415..6badadf 100644 --- a/docs/decisions/042-current-room-key-order.md +++ b/docs/decisions/042-current-room-key-order.md @@ -2,6 +2,8 @@ Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера». +Уточнён [ADR-059](059-room-keys-kept-are-handed-out.md): порядок относится ко всем удерживаемым ключам, а не только к последнему. + ## Контекст На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным. diff --git a/docs/decisions/055-limit-keys-and-bounds.md b/docs/decisions/055-limit-keys-and-bounds.md new file mode 100644 index 0000000..0bb17ee --- /dev/null +++ b/docs/decisions/055-limit-keys-and-bounds.md @@ -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). Новых текстов интерфейса решение не заводит. diff --git a/docs/decisions/056-nginx-access-log-off.md b/docs/decisions/056-nginx-access-log-off.md new file mode 100644 index 0000000..9cb9d3e --- /dev/null +++ b/docs/decisions/056-nginx-access-log-off.md @@ -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` для этого домена пуст по конфигурации, а не по случайности. diff --git a/docs/decisions/057-version-marks-dirty-tree.md b/docs/decisions/057-version-marks-dirty-tree.md new file mode 100644 index 0000000..2ca91e4 --- /dev/null +++ b/docs/decisions/057-version-marks-dirty-tree.md @@ -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`. diff --git a/docs/decisions/058-logout-closes-stream.md b/docs/decisions/058-logout-closes-stream.md new file mode 100644 index 0000000..2f5d810 --- /dev/null +++ b/docs/decisions/058-logout-closes-stream.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`) закрывать нечего: потока у неё и нет. diff --git a/docs/decisions/059-room-keys-kept-are-handed-out.md b/docs/decisions/059-room-keys-kept-are-handed-out.md new file mode 100644 index 0000000..bcdd1e0 --- /dev/null +++ b/docs/decisions/059-room-keys-kept-are-handed-out.md @@ -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` вырастает на один завёрнутый ключ на комнату. Это десятки байт. diff --git a/docs/decisions/060-unknown-device-keeps-pending.md b/docs/decisions/060-unknown-device-keeps-pending.md new file mode 100644 index 0000000..4c1edbc --- /dev/null +++ b/docs/decisions/060-unknown-device-keeps-pending.md @@ -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`, отложенная отправка (нет ключа комнаты, ключ собеседника ждёт подтверждения) и потерянное устройство. +- Бесконечного круга нет: пока устройства нет, сообщение просто лежит; полоса про отказ отправки в чате не появляется, потому что отказа сообщению не было. diff --git a/docs/decisions/061-room-name-in-input-hint.md b/docs/decisions/061-room-name-in-input-hint.md new file mode 100644 index 0000000..b818830 --- /dev/null +++ b/docs/decisions/061-room-name-in-input-hint.md @@ -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`, «Чат». + +## Следствия + +- Строка ввода остаётся в одну строку при любом имени комнаты. +- Две комнаты с одинаковым началом длинного имени дают одинаковую подсказку. Это подсказка, а не заголовок: имя целиком видно в шапке над лентой. diff --git a/docs/decisions/062-kdf-answer-and-nick-existence.md b/docs/decisions/062-kdf-answer-and-nick-existence.md new file mode 100644 index 0000000..2f06c47 --- /dev/null +++ b/docs/decisions/062-kdf-answer-and-nick-existence.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 такой задачи нет. diff --git a/docs/decisions/063-ack-goes-in-batches.md b/docs/decisions/063-ack-goes-in-batches.md new file mode 100644 index 0000000..d5d7b58 --- /dev/null +++ b/docs/decisions/063-ack-goes-in-batches.md @@ -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), и на экране это не видно. +- Закрытая вкладка уносит с собой до двух секунд неподтверждённого — те же конверты придут очередью в следующий раз. +- Новых текстов интерфейса решение не заводит. diff --git a/docs/deploy.md b/docs/deploy.md index e33d9cf..16b7331 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -8,7 +8,7 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bare ./cmd/bare ``` -Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`. `/healthz` отвечает только `ok`. +Версия бинаря — `vcs.revision` из `debug.ReadBuildInfo()`, печатается по `bare version`; у сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057). `/healthz` отвечает только `ok`. ## Первичная настройка сервера (один раз) @@ -70,6 +70,7 @@ server { listen 80; listen [::]:80; server_name bare.xmatic.team; + access_log off; return 301 https://$host$request_uri; } @@ -82,6 +83,9 @@ server { ssl_certificate_key /etc/letsencrypt/live/bare.xmatic.team/privkey.pem; add_header Strict-Transport-Security "max-age=31536000" always; + # журнал запросов ведёт только bare, и ведёт без ника, IP и query (ADR-056) + access_log off; + client_max_body_size 64k; location /api/events { @@ -140,4 +144,6 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud ## Логи -Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам. +Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам. + +nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента. diff --git a/docs/protocol.md b/docs/protocol.md index 4934173..95652a1 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -6,9 +6,9 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Аутентификация — cookie `bare_session` (ADR-021). Без неё — `401 unauthenticated`. Публичные: `GET /api/config`, `GET /api/kdf`, `POST /api/register`, `POST /api/login`. - На всех запросах кроме `GET`/`HEAD` заголовок `Origin` обязан равняться `BARE_ORIGIN`, иначе `403 bad_origin`. -- Заголовок `X-Device: ` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. +- Заголовок `X-Device: ` обязателен на `/api/ack`, `/api/messages`, `/api/devices/{id}/push`; для `/api/events` устройство передаётся в query (`EventSource` не умеет заголовки). Устройство должно принадлежать пользователю сессии, иначе `403 unknown_device`. Принадлежность — право, поэтому проверяется после разбора тела и его формы (ADR-043). - Тело запроса — до 32 КиБ, иначе `413 too_large`. -- Rate limiting — `429` с `Retry-After` (секунды). +- Rate limiting — `429 rate_limited` с `Retry-After` в целых секундах, не меньше одной. Правила (ADR-021): регистрация — 5 в час на IP; вход — 10 за 10 минут на пару IP+ник; сообщения — 30 в минуту на пользователя, пакет 10; остальные изменяющие запросы — 60 в минуту на пользователя, одним ведром на все маршруты. Чтения не ограничиваются. Общий лимит отвечает раньше разбора тела; у регистрации, входа и сообщений он стоит на своём месте в порядке проверок эндпоинта (ADR-055). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. - Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043). - Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027). @@ -31,18 +31,20 @@ Room { id: roomId, name: string, owner: nick, members: nick[], // по joined_at createdAt: number, - key: {keyId, from, iv, ct} | null, // текущий завёрнутый ключ для запрашивающего + keys: {keyId, from, iv, ct}[], // завёрнутые ключи для запрашивающего, от старого к новому needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041) } WrappedKey { to: nick, iv: string, ct: string } ``` +`keys` — все ключи запрашивающего, которые сервер ещё держит, в `GET /api/rooms`; ровно один, только что розданный, — в событии `room` и в ответах `POST /api/rooms` и `POST /api/rooms/{id}/members` (ADR-059). Пусто, если ключа у него нет. + ## Публичные `GET /api/config` → `200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}` -`GET /api/kdf?nick=` → `200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. +`GET /api/kdf?nick=` → `200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. Скрытием существования ника ответ не занимается: у аккаунта, не входившего после повышения цели, число итераций своё (ADR-062), а сам факт, что ник существует, публичен (ADR-019). `POST /api/register {nick, authKey, publicKey: JWK, blob: string, invite?: string}` → `201 {nick}` + cookie. Ошибки: `400 invalid_nick`, `409 nick_taken`, `403 invite_required`, `403 invalid_invite`. `authKey` — base64url 32 байт, `publicKey` — JWK `kty=EC, crv=P-256` с `x`, `y` без `d`; `blob` — до 8 КиБ. @@ -54,7 +56,7 @@ WrappedKey { to: nick, iv: string, ct: string } `POST /api/logout` → `204`, cookie стирается. -`POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}` → `204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей. +`POST /api/password {authKey, newAuthKey, blob, logoutOthers: bool}` → `204`. `401 invalid_credentials`, если `authKey` не подходит. Хеш и блоб меняются в одной транзакции; при `logoutOthers` удаляются все сессии кроме текущей, а устройствам удалённых сессий поток событий закрывается — их следующий запрос получает `401` (ADR-058). `DELETE /api/me {authKey}` → `204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. Удаление аккаунта — выход из всех его комнат: оставшимся участникам уходит `event: room` с `needsRekey: true`, каждому со своим ключом (ADR-041). @@ -88,7 +90,7 @@ WrappedKey { to: nick, iv: string, ct: string } Сервер в одной транзакции: для `dm` создаёт недостающие строки `contacts` в обе стороны; вычисляет получателей (оба ника или все участники); для каждого устройства получателей, кроме `X-Device`, вставляет строку в `queue`; после коммита отдаёт конверт подключённым устройствам и шлёт пуши устройствам получателей по правилам ADR-023 и ADR-045. -`POST /api/ack {ids: string[]}` → `204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. +`POST /api/ack {ids: string[]}` → `204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. Клиент копит подтверждения и шлёт их пачкой, не чаще раза в две секунды: маршрут живёт в общем ведре изменяющих запросов, и запрос на конверт съедал бы его целиком (ADR-063). ## События @@ -106,22 +108,23 @@ WrappedKey { to: nick, iv: string, ct: string } ``` event: msg data: Envelope -event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey) +event: room data: Room // создание, смена состава, rekey, выход участника (needsRekey); + // keys — один новый ключ получателя либо пусто (ADR-059) event: room_left data: {id} // получателя удалили или комната удалена event: ready data: {} ``` -`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey`, есть и в `GET /api/rooms` (ADR-041). +`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey` и ключ, есть и в `GET /api/rooms` (ADR-041, ADR-059). `id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления. ## Комнаты -`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, с его текущим ключом и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). +`GET /api/rooms` → `200 Room[]` — комнаты, где пользователь участник, со всеми его ключами, которые сервер ещё держит (до двух, ADR-018), и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). Ключи идут от старого к новому; последний — текущий. Участник, пропустивший rekey в офлайне, добирает пропущенный `keyId` только отсюда: события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059). `POST /api/rooms {id, name, keyId, keys: WrappedKey[]}` → `201 Room`. `id` — 16 случайных байт base64url, генерирует клиент (ADR-037): ключ комнаты заворачивается до запроса и привязан к идентификатору. Занятый `id` — `409 room_conflict`, клиент берёт новый. `keys` — ровно одна запись, `to` равен нику создателя. Всем устройствам создателя кроме `X-Device` (если передан) уходит `event: room`. -`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `event: room` всем участникам (каждому — с его ключом), `event: room_left` удалённым. +`POST /api/rooms/{id}/members {add: nick[], remove: nick[], keyId, keys: WrappedKey[]}` → `200 Room`. Только владелец (`403 not_owner`). Проверки: все `add` существуют (`404 unknown_user`), `remove` — участники, владельца удалить нельзя (`400 owner`), `keyId` новый для комнаты (`409 key_exists`), множество `keys[].to` равно итоговому составу (`400 keys_mismatch`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `event: room` всем участникам (каждому — с его новым ключом), `event: room_left` удалённым. `POST /api/rooms/{id}/leave` → `204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041). diff --git a/docs/storage.md b/docs/storage.md index 3b405c0..cee9154 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -87,7 +87,7 @@ ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0; Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`. -Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. +Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. Участнику отдаются все его удерживаемые ключи, а не только текущий: пропущенный в офлайне `keyId` иначе не добыть ничем — события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059). Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен. @@ -139,9 +139,10 @@ peers key: nick Правила: -- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. +- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Подтверждения копятся и уходят пачкой, не чаще раза в две секунды: в накопитель идёт только записанное, а запрос на конверт тратил бы общее ведро лимита одними подтверждениями (ADR-063). - Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`. - Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202` → `sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx` → `failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id` — `failed` с текстом про часы (ADR-036). +- Исключение среди `4xx` одно: `403 unknown_device` — не отказ сообщению, а потерянное устройство. Запись остаётся `pending`, клиент заводит устройство заново при переподключении и повторяет её после `ready` (ADR-060). - `unread` и `lastReadId` — локальные, на сервер не уходят. - Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`. - Пагинация — курсор по индексу `chat` назад от последнего, по 50. diff --git a/docs/threat-model.md b/docs/threat-model.md index f476a52..29dfade 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -8,7 +8,7 @@ ## От кого защищаем -**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. +**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов, push-подписки устройств — адрес push-сервиса и ключи подписки `p256dh` и `auth`. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. База вместе с VAPID-ключом, который лежит на той же машине в `/etc/bare/env`, позволяет показать устройству произвольное уведомление от имени bare — вплоть до фишингового текста на экране блокировки; содержимого сообщений это не раскрывает. **Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое. @@ -24,7 +24,7 @@ **Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета. -**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств и когда они появлялись — серверу видно. Скрытие метаданных — не задача Bare. +**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств, когда они появлялись и куда им слать пуши, — серверу видно. Скрытие метаданных — не задача Bare. **Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`. @@ -42,4 +42,4 @@ **Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша. -**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — один POST на сообщение, в пределах общих лимитов. +**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — до четырёх POST на аккаунт-получателя (доля аккаунта в отправке, ADR-048), то есть до 4×N на сообщение в комнату из N участников, в пределах общих лимитов и восьми отправщиков. diff --git a/docs/ui.md b/docs/ui.md index 5b85526..e8f7e15 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -36,7 +36,7 @@ Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053). -Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение». Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. +Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500. Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038). diff --git a/internal/api/account.go b/internal/api/account.go index 5ee8fa8..3220a78 100644 --- a/internal/api/account.go +++ b/internal/api/account.go @@ -31,7 +31,10 @@ func (s *server) config(w http.ResponseWriter, r *http.Request) { // // Значение лежит открытым полем iter в ключевом блобе: другого места // у него нет (docs/crypto.md). Неизвестный ник получает целевое значение -// тем же статусом 200 — ответ не раскрывает, существует ли ник (ADR-015). +// тем же статусом 200. Скрытием существования ника этот ответ +// не занимается: после повышения цели у аккаунта, который с тех пор +// не входил, iter свой, и по числу его видно (ADR-062). Существование +// ника публично и так (ADR-019). func (s *server) kdf(w http.ResponseWriter, r *http.Request) { iterations := config.KDFIterations 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) { 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 - } - } + // Форма — раньше инвайт-кода: он даёт право регистрироваться, а права + // идут после формы (ADR-043). Занятость ника этим не выдаётся: nick_taken + // живёт дальше по тексту, за инвайтом. if !validNick(in.Nick) { Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _") return @@ -94,6 +90,22 @@ func (s *server) register(w http.ResponseWriter, r *http.Request) { Invalid(w, "blob", err.Error()) 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) if err != nil { @@ -139,6 +151,14 @@ func (s *server) login(w http.ResponseWriter, r *http.Request) { invalidCredentials(w) 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) if errors.Is(err, store.ErrNotFound) { // Считаем впустую: вход с несуществующим ником не должен @@ -234,10 +254,18 @@ func (s *server) password(w http.ResponseWriter, r *http.Request) { return } 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) return } + // Сессия проверяется при подключении к потоку, а не в его цикле, + // поэтому отозванная продолжала бы получать конверты до обрыва + // соединения. Отзыв доступа закрывает поток сам — тем же способом, + // что и удаление устройства (ADR-058). + for _, device := range revoked { + s.hub.Close(device) + } noContent(w) } diff --git a/internal/api/account_test.go b/internal/api/account_test.go index 40c77c2..55ae91d 100644 --- a/internal/api/account_test.go +++ b/internal/api/account_test.go @@ -1,6 +1,7 @@ package api_test import ( + "crypto/sha256" "encoding/base64" "encoding/json" "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 { 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, "") 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 { e.t.Helper() for _, c := range rec.Result().Cookies() { diff --git a/internal/api/api.go b/internal/api/api.go index 7bb0c72..ebd5124 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -36,8 +36,12 @@ type server struct { st *store.Store hub *hub.Hub push *push.Sender - msgs *buckets - logw io.Writer + // Лимиты ADR-021: у каждого правила своё ведро и свой ключ. + regs *buckets // регистрация — по адресу + logins *buckets // вход — по паре адрес+ник + msgs *buckets // сообщения — по нику + writes *buckets // остальные изменяющие запросы — по нику + logw io.Writer } // Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей @@ -48,11 +52,18 @@ type Handler struct { push *push.Sender } -// Close закрывает открытые потоки событий и останавливает отправку -// пушей. Без него остановка сервера ждала бы, пока клиенты уйдут сами: -// у потока нет конца (ADR-004). -func (h *Handler) Close() { +// CloseStreams закрывает открытые потоки событий. Без этого остановка +// сервера ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004). +// Ничего не ждёт сама и потому годится в http.Server.RegisterOnShutdown. +func (h *Handler) CloseStreams() { h.hub.CloseAll() +} + +// Close останавливает всё, что живёт за обработчиком: потоки событий +// и отправку пушей, — и дожидается начатых отправок. Отправщики пишут +// в базу, поэтому Close обязан случиться до её закрытия. +func (h *Handler) Close() { + h.CloseStreams() h.push.Close() } @@ -64,16 +75,23 @@ func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Write // на него, а не при постановке в очередь (ADR-023). live := hub.New() s := &server{ - cfg: cfg, - st: st, - hub: live, - push: push.New(cfg, st, live.Connected, logw), - msgs: newBuckets(messagesPerMinute, messagesBurst), - logw: logw, + cfg: cfg, + st: st, + hub: live, + push: push.New(cfg, st, live.Connected, logw), + regs: newBuckets(registerRule), + logins: newBuckets(loginRule), + msgs: newBuckets(messagesRule), + writes: newBuckets(writesRule), + logw: logw, } fail := auth.Fail{Error: Error, Internal: s.internal} // Сессия проверяется на всех непубличных маршрутах (docs/protocol.md). private := auth.Require(st, fail) + // write — сессия плюс общий лимит изменяющих запросов (ADR-021). + // Под него идут все непубличные маршруты кроме чтений и отправки + // сообщений: у сообщений своё правило. + write := func(h http.HandlerFunc) http.Handler { return private(s.limitWrites(h)) } mux := http.NewServeMux() 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.Handle("GET /api/me", private(http.HandlerFunc(s.me))) - mux.Handle("DELETE /api/me", private(http.HandlerFunc(s.deleteMe))) - mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout))) - mux.Handle("POST /api/password", private(http.HandlerFunc(s.password))) + mux.Handle("DELETE /api/me", write(s.deleteMe)) + mux.Handle("POST /api/logout", write(s.logout)) + mux.Handle("POST /api/password", write(s.password)) 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("DELETE /api/devices/{id}", private(http.HandlerFunc(s.deleteDevice))) - mux.Handle("PUT /api/devices/{id}/push", private(http.HandlerFunc(s.setPush))) - mux.Handle("DELETE /api/devices/{id}/push", private(http.HandlerFunc(s.deletePush))) + mux.Handle("DELETE /api/devices/{id}", write(s.deleteDevice)) + mux.Handle("PUT /api/devices/{id}/push", write(s.setPush)) + mux.Handle("DELETE /api/devices/{id}/push", write(s.deletePush)) mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts))) - mux.Handle("POST /api/contacts", private(http.HandlerFunc(s.addContact))) - mux.Handle("DELETE /api/contacts/{nick}", private(http.HandlerFunc(s.deleteContact))) + mux.Handle("POST /api/contacts", write(s.addContact)) + mux.Handle("DELETE /api/contacts/{nick}", write(s.deleteContact)) mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms))) - mux.Handle("POST /api/rooms", private(http.HandlerFunc(s.createRoom))) - mux.Handle("POST /api/rooms/{id}/members", private(http.HandlerFunc(s.updateMembers))) - mux.Handle("POST /api/rooms/{id}/leave", private(http.HandlerFunc(s.leaveRoom))) - mux.Handle("DELETE /api/rooms/{id}", private(http.HandlerFunc(s.deleteRoom))) + mux.Handle("POST /api/rooms", write(s.createRoom)) + mux.Handle("POST /api/rooms/{id}/members", write(s.updateMembers)) + mux.Handle("POST /api/rooms/{id}/leave", write(s.leaveRoom)) + mux.Handle("DELETE /api/rooms/{id}", write(s.deleteRoom)) 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/ack", private(http.HandlerFunc(s.ack))) + mux.Handle("POST /api/ack", write(s.ack)) // Всё прочее под /api/ — 404, включая неподдерживаемый метод известного // пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 5542e3d..1ff034b 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -20,9 +21,11 @@ import ( const origin = "https://bare.test" // env — сервер на временной базе плюс журнал, в который он пишет. +// Обработчик хранится своим типом: тестам нужен не только ServeHTTP, +// но и остановка — Close и CloseStreams. type env struct { t *testing.T - h http.Handler + h *api.Handler st *store.Store log *syncLog 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) } } +// 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) { return func(r *http.Request) { if value == "" { diff --git a/internal/api/events_test.go b/internal/api/events_test.go index b08b37d..6a2b64b 100644 --- a/internal/api/events_test.go +++ b/internal/api/events_test.go @@ -9,6 +9,8 @@ import ( "strings" "testing" "time" + + "github.com/xmatic-squad/bare/internal/config" ) // wait — сколько тест ждёт события. Всё локально, задержек быть не должно. @@ -269,6 +271,43 @@ func TestEventsClosedOnDeviceDelete(t *testing.T) { 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, поток не открывается. func TestEventsUnknownDevice(t *testing.T) { e := newEnv(t) diff --git a/internal/api/limit.go b/internal/api/limit.go index b9eeacf..896ddd6 100644 --- a/internal/api/limit.go +++ b/internal/api/limit.go @@ -2,27 +2,65 @@ package api import ( "math" + "net" + "net/http" + "net/netip" + "strconv" + "strings" "sync" "time" + + "github.com/xmatic-squad/bare/internal/auth" ) -// Лимит сообщений (ADR-021): 30 в минуту на пользователя, пакет 10. -// Остальные лимиты — этап 6. -const ( - messagesPerMinute = 30 - messagesBurst = 10 +// Лимиты ADR-021, все четыре правила. Token bucket в памяти сервера: +// рестарт их обнуляет — для маленького сервера это принято. +// +// Пакет отдельным числом задан только у сообщений. У остальных правил он +// равен самому лимиту: «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 — с какого размера карты имеет смысл выкидывать полные вёдра. -const sweepAt = 1024 +// rule — правило лимита: count запросов за window, пакетом не больше burst. +type rule struct { + count int + window time.Duration + burst int +} -// buckets — token bucket в памяти сервера, по ведру на ключ (ник). -// Рестарт обнуляет лимиты: для маленького сервера это принято (ADR-021). +// generation — сколько ключей карта лимита держит до смены поколения. +// +// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой +// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком +// новых ключей полных не бывает вовсе — каждое только что потратило токен. +// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до +// generation, она становится прежней, а прежняя выбрасывается целиком. +// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену +// переживает; забывается только то, к чему не обращались целое поколение, +// а забытое ведро — то же самое, что новое. +// +// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер, +// то есть около мегабайта на правило. Миллион разных адресов памяти +// не съедает — он протачивает поколения насквозь. +const generation = 4096 + +// buckets — token bucket в памяти сервера, по ведру на ключ. type buckets struct { mu sync.Mutex rate float64 // токенов в секунду burst float64 - seen map[string]*bucket + cur map[string]*bucket // нынешнее поколение + old map[string]*bucket // прежнее, пока к его ключам ещё обращаются } type bucket struct { @@ -30,11 +68,11 @@ type bucket struct { at time.Time } -func newBuckets(perMinute, burst int) *buckets { +func newBuckets(r rule) *buckets { return &buckets{ - rate: float64(perMinute) / 60, - burst: float64(burst), - seen: make(map[string]*bucket), + rate: float64(r.count) / r.window.Seconds(), + burst: float64(r.burst), + cur: make(map[string]*bucket), } } @@ -44,14 +82,7 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) { b.mu.Lock() defer b.mu.Unlock() - e, ok := b.seen[key] - if !ok { - if len(b.seen) >= sweepAt { - b.sweep(now) - } - e = &bucket{tokens: b.burst, at: now} - b.seen[key] = e - } + e := b.bucket(key, now) e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now)) e.at = now if e.tokens < 1 { @@ -61,6 +92,27 @@ func (b *buckets) take(key string, now time.Time) (time.Duration, bool) { 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 — сколько токенов набежало. Время назад не идёт: часы могли // прыгнуть, но долг за это выставлять некому. 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 } -// 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 — значение заголовка в секундах, не меньше одной: нулевое // ожидание после отказа сбивало бы клиента с толку. func retryAfter(wait time.Duration) int { @@ -89,3 +131,65 @@ func retryAfter(wait time.Duration) int { } 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("") +} diff --git a/internal/api/limit_test.go b/internal/api/limit_test.go index 90366e0..9d10cae 100644 --- a/internal/api/limit_test.go +++ b/internal/api/limit_test.go @@ -1,62 +1,80 @@ package api import ( + "net/http" + "net/http/httptest" + "strconv" "testing" "time" ) -// Token bucket из ADR-021: 30 в минуту, пакет 10. -func TestBuckets(t *testing.T) { - b := newBuckets(messagesPerMinute, messagesBurst) - now := time.Now() +// Все четыре правила ADR-021: пакет расходуется целиком, следующий токен +// набегает ровно через window/count, ведро не переполняется. +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() - for i := 0; i < messagesBurst; i++ { - if _, ok := b.take("marta", now); !ok { - t.Fatalf("запрос %d из пакета отклонён", i+1) - } - } - wait, ok := b.take("marta", now) - if ok { - t.Fatal("пакет не кончился") - } - // Тридцать в минуту — токен раз в две секунды. - if wait != 2*time.Second { - t.Errorf("ожидание: получено %v, ожидалось 2s", wait) - } - if got := retryAfter(wait); got != 2 { - t.Errorf("Retry-After: получено %d, ожидалось 2", got) - } + for i := 0; i < c.rule.burst; i++ { + if _, ok := b.take("ключ", now); !ok { + t.Fatalf("запрос %d из пакета %d отклонён", i+1, c.rule.burst) + } + } + wait, ok := b.take("ключ", now) + if ok { + t.Fatal("пакет не кончился") + } + if wait != c.token { + t.Errorf("ожидание: получено %v, ожидалось %v", wait, c.token) + } + if got, want := retryAfter(wait), int(c.token.Seconds()); got != want { + t.Errorf("Retry-After: получено %d, ожидалось %d", got, want) + } - // Через две секунды набегает ровно один токен. - if _, ok := b.take("marta", now.Add(2*time.Second)); !ok { - t.Error("токен не набежал") - } - if _, ok := b.take("marta", now.Add(2*time.Second)); ok { - t.Error("набежало больше одного токена") - } + // Восстановление: ровно через это время набегает ровно один токен. + if _, ok := b.take("ключ", now.Add(c.token)); !ok { + t.Error("токен не набежал") + } + if _, ok := b.take("ключ", now.Add(c.token)); ok { + t.Error("набежало больше одного токена") + } - // Ведро не переполняется: за час копится пакет, не тридцать в минуту. - for i := 0; i < messagesBurst; i++ { - if _, ok := b.take("marta", now.Add(time.Hour)); !ok { - t.Fatalf("запрос %d после долгой паузы отклонён", i+1) - } - } - if _, ok := b.take("marta", now.Add(time.Hour)); ok { - t.Error("ведро больше пакета") - } + // За долгую паузу копится пакет, а не весь пропущенный поток. + for i := 0; i < c.rule.burst; i++ { + if _, ok := b.take("ключ", now.Add(24*time.Hour)); !ok { + t.Fatalf("запрос %d после долгой паузы отклонён", i+1) + } + } + if _, ok := b.take("ключ", now.Add(24*time.Hour)); ok { + t.Error("ведро больше пакета") + } - // Лимит на ключ: чужое ведро полное. - if _, ok := b.take("petya", now); !ok { - t.Error("лимит одного пользователя задел другого") + // Ведро на ключ: чужое полное. + if _, ok := b.take("другой ключ", now); !ok { + t.Error("лимит одного ключа задел другой") + } + }) } } // Часы могут прыгнуть назад; долг за это никому не выставляется. func TestBucketsClockBack(t *testing.T) { - b := newBuckets(messagesPerMinute, messagesBurst) + b := newBuckets(messagesRule) now := time.Now() - for i := 0; i < messagesBurst; i++ { + for i := 0; i < messagesRule.burst; i++ { b.take("marta", now) } 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() - for i := 0; i < sweepAt; i++ { - b.take(string(rune(i)), now) + for i := 0; i < 1_000_000; i++ { + b.take(strconv.Itoa(i), now) } - if len(b.seen) != sweepAt { - t.Fatalf("вёдер: получено %d, ожидалось %d", len(b.seen), sweepAt) + if got := b.size(); got > 2*generation { + t.Errorf("вёдер: получено %d, ожидалось не больше %d", got, 2*generation) } - // Все вёдра успели наполниться заново — чистка их и уносит. - b.take("marta", now.Add(time.Hour)) - if len(b.seen) != 1 { - t.Errorf("вёдер после чистки: получено %d, ожидалось 1", len(b.seen)) + + // Ключ, по которому ходят, смену поколения переживает: его ведро + // переезжает в нынешнюю карту, а не заводится заново. + 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) + } } } // Ждать меньше секунды бессмысленно: Retry-After в секундах. func TestRetryAfter(t *testing.T) { cases := map[time.Duration]int{ + -time.Second: 1, 0: 1, 100 * time.Millisecond: 1, time.Second: 1, 1500 * time.Millisecond: 2, 2 * time.Second: 2, + 12 * time.Minute: 720, } for wait, want := range cases { 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, время в первых десяти. func TestULIDTime(t *testing.T) { // 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) +} diff --git a/internal/api/messages.go b/internal/api/messages.go index ec6ab0f..4bfe92d 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -3,7 +3,6 @@ package api import ( "encoding/json" "net/http" - "strconv" "time" "github.com/xmatic-squad/bare/internal/auth" @@ -55,10 +54,6 @@ type messageIn struct { // он проверяет форму и раскладывает конверт по очередям (ADR-008). // Порядок проверок — docs/protocol.md, «Сообщения». func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { - device, ok := s.device(w, r) - if !ok { - return - } var in messageIn if !decode(w, r, &in) { return @@ -75,6 +70,14 @@ func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) { return } + // Принадлежность устройства — право, а не форма, поэтому проверяется + // после разбора тела: кривое тело отвечает bad_json и invalid даже + // с чужим X-Device (ADR-043). + device, ok := s.device(w, r) + if !ok { + return + } + sess, _ := auth.From(r) room := in.To.Room != "" // Заголовок и адрес чата для пуша: сервер собирает их из того, что @@ -220,10 +223,6 @@ func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) { // POST /api/ack — клиент записал сообщения в IndexedDB: из очереди // устройства их можно убрать (ADR-008). func (s *server) ack(w http.ResponseWriter, r *http.Request) { - device, ok := s.device(w, r) - if !ok { - return - } var in struct { IDs []string `json:"ids"` } @@ -234,15 +233,14 @@ func (s *server) ack(w http.ResponseWriter, r *http.Request) { Invalid(w, "ids", "не больше 500 идентификаторов") return } + // Устройство — право: после формы тела (ADR-043). + device, ok := s.device(w, r) + if !ok { + return + } if err := s.st.Ack(r.Context(), device, in.IDs); err != nil { s.internal(w, r, err) return } 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", "слишком часто, попробуйте позже") -} diff --git a/internal/api/push_test.go b/internal/api/push_test.go index 5ab79b1..a55618a 100644 --- a/internal/api/push_test.go +++ b/internal/api/push_test.go @@ -804,3 +804,39 @@ func TestPushPayloadPerDevice(t *testing.T) { } 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("остановка не закончилась после отправки") + } +} diff --git a/internal/api/ratelimit_test.go b/internal/api/ratelimit_test.go new file mode 100644 index 0000000..65e9eb8 --- /dev/null +++ b/internal/api/ratelimit_test.go @@ -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") +} diff --git a/internal/api/rooms.go b/internal/api/rooms.go index eb91ea3..09352f6 100644 --- a/internal/api/rooms.go +++ b/internal/api/rooms.go @@ -20,8 +20,10 @@ import ( // метаданные, как и состав. const maxRoomName = 64 -// roomOut — тип Room из docs/protocol.md. key присутствует всегда, -// пустой — null; needsRekey — состояние комнаты, а не свойство события, +// roomOut — тип Room из docs/protocol.md. keys присутствует всегда, +// пустой — []; в GET /api/rooms это все удерживаемые сервером ключи +// запрашивающего, от старого к новому, в событии room — только новый +// (ADR-059). needsRekey — состояние комнаты, а не свойство события, // поэтому идёт и в списке, и в событии (ADR-041). type roomOut struct { ID string `json:"id"` @@ -29,7 +31,7 @@ type roomOut struct { Owner string `json:"owner"` Members []string `json:"members"` CreatedAt int64 `json:"createdAt"` - Key *keyOut `json:"key"` + Keys []keyOut `json:"keys"` NeedsRekey bool `json:"needsRekey"` } @@ -49,8 +51,9 @@ type keyIn struct { } // GET /api/rooms — комнаты, где пользователь участник, каждая с его -// текущим ключом и признаком needsRekey: владелец, пропустивший событие, -// поднимает долг по ключу отсюда (ADR-041). +// ключами и признаком needsRekey: владелец, пропустивший событие, +// поднимает долг по ключу отсюда (ADR-041), а участник, пропустивший +// rekey в офлайне, — недостающий ключ (ADR-059). func (s *server) rooms(w http.ResponseWriter, r *http.Request) { sess, _ := auth.From(r) 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)) for _, room := range list { - out = append(out, roomJSON(room, room.Key)) + out = append(out, roomJSON(room, room.Keys)) } writeJSON(w, http.StatusOK, out) } @@ -69,12 +72,6 @@ func (s *server) rooms(w http.ResponseWriter, r *http.Request) { // (ADR-037), ключ приходит ровно один и заворачивается создателем себе: // его другие устройства получают комнату вместе с ключом (ADR-018). func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { - // X-Device здесь необязателен, но чужой и кривой — 403, как и везде, - // где устройство важно (docs/protocol.md, «Общие правила»). - device, ok := s.optionalDevice(w, r) - if !ok { - return - } var in struct { ID string `json:"id"` Name string `json:"name"` @@ -109,6 +106,13 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { keysMismatch(w) 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{ ID: in.ID, Name: in.Name, @@ -130,7 +134,7 @@ func (s *server) createRoom(w http.ResponseWriter, r *http.Request) { // Комната уже записана: остальным устройствам создателя она уходит // событием, отправившему — ответом на запрос. 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 одним запросом @@ -191,7 +195,7 @@ func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) { // Событие room уходит и участникам, и — как room_left — убранным; // каждому участнику со своим ключом (docs/protocol.md, «Комнаты»). 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 — выход из комнаты. Владение переходит @@ -270,7 +274,7 @@ func keysMismatch(w http.ResponseWriter) { // «События», ADR-041). func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) { for _, member := range change.Members { - raw, err := json.Marshal(roomJSON(change.Room, member.Key)) + raw, err := json.Marshal(roomJSON(change.Room, keyList(member.Key))) if err != nil { s.report(r, err) continue @@ -302,31 +306,41 @@ func (s *server) send(devices []string, exclude string, ev hub.Event) { } } -// roomJSON собирает Room протокола: состав всегда список, ключ — null, -// если его нет. -func roomJSON(room store.Room, key *store.RoomKey) roomOut { +// roomJSON собирает Room протокола: состав и ключи всегда списки, +// пустые — []. +func roomJSON(room store.Room, keys []store.RoomKey) roomOut { out := roomOut{ ID: room.ID, Name: room.Name, Owner: room.Owner, Members: room.Members, CreatedAt: room.CreatedAt, + Keys: make([]keyOut, 0, len(keys)), NeedsRekey: room.NeedsRekey, } if out.Members == nil { out.Members = []string{} } - if key != nil { - out.Key = &keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT} + for _, key := range keys { + out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT}) } return out } -// keyFor — ключ участника в итоге изменения: у каждого он свой. -func keyFor(change store.RoomChange, nick string) *store.RoomKey { +// keyList — ключ события: он один, новый (ADR-059). Остальные свои ключи +// получатель уже видел, а отключённый доберёт их из 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 { if member.Nick == nick { - return member.Key + return keyList(member.Key) } } return nil diff --git a/internal/api/rooms_test.go b/internal/api/rooms_test.go index a731c46..9082fcb 100644 --- a/internal/api/rooms_test.go +++ b/internal/api/rooms_test.go @@ -12,13 +12,22 @@ import ( // roomBody — тип Room из docs/protocol.md, как его видит клиент. type roomBody struct { - ID string `json:"id"` - Name string `json:"name"` - Owner string `json:"owner"` - Members []string `json:"members"` - CreatedAt int64 `json:"createdAt"` - Key *keyBody `json:"key"` - NeedsRekey bool `json:"needsRekey"` + ID string `json:"id"` + Name string `json:"name"` + Owner string `json:"owner"` + Members []string `json:"members"` + CreatedAt int64 `json:"createdAt"` + Keys []keyBody `json:"keys"` + 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 { @@ -151,9 +160,9 @@ func TestCreateRoom(t *testing.T) { if room.NeedsRekey { t.Error("needsRekey в ответе на создание") } - 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) { - t.Errorf("ключ: %+v", room.Key) + 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) { + t.Errorf("ключ: %+v", room.key()) } // Та же комната приходит списком, с тем же ключом. @@ -161,7 +170,7 @@ func TestCreateRoom(t *testing.T) { if len(list) != 1 { 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]) } @@ -206,7 +215,7 @@ func TestCreateRoomConflict(t *testing.T) { t.Errorf("занятый id присоединил к чужой комнате: %+v", got) } 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) } } @@ -301,8 +310,8 @@ func TestMembersAdd(t *testing.T) { if nicks(got.Members) != nicks(want) { t.Errorf("состав: получено %v, ожидалось %v", got.Members, want) } - if got.Key == nil || got.Key.KeyID != keyID(60) || got.Key.CT != ctOf(60, 0) { - t.Errorf("ключ владельца в ответе: %+v", got.Key) + if got.key() == nil || got.key().KeyID != keyID(60) || got.key().CT != ctOf(60, 0) { + 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) { t.Errorf("комната у %d: %+v", i, list[0]) } - if list[0].Key == nil || list[0].Key.KeyID != keyID(60) || list[0].Key.From != "marta" || - list[0].Key.CT != ctOf(60, i) { - t.Errorf("ключ у %d: %+v", i, list[0].Key) + if list[0].key() == nil || list[0].key().KeyID != keyID(60) || list[0].key().From != "marta" || + list[0].key().CT != ctOf(60, i) { + 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" { t.Errorf("состав после отказов: %v", got.Members) } - if got.Key == nil || got.Key.KeyID != keyID(40) { - t.Errorf("ключ после отказов: %+v", got.Key) + if got.key() == nil || got.key().KeyID != keyID(40) { + t.Errorf("ключ после отказов: %+v", got.key()) } if list := e.rooms(kolya); len(list) != 0 { t.Errorf("комната у постороннего: %+v", list) @@ -533,8 +542,8 @@ func TestLeaveTransfersOwnership(t *testing.T) { t.Errorf("состав: %v", got.Members) } // Ключ оставшихся никуда не делся: rekey делает клиент нового владельца. - if got.Key == nil || got.Key.KeyID != keyID(80) { - t.Errorf("ключ после выхода владельца: %+v", got.Key) + if got.key() == nil || got.key().KeyID != keyID(80) { + t.Errorf("ключ после выхода владельца: %+v", got.key()) } // Новый владелец меняет состав, прежний — уже нет. 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, "") } // Текущий ключ участника — последний. - if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(80) { - t.Errorf("текущий ключ: %+v", got.Key) + if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(80) { + 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" { t.Errorf("состав: %v", list[0].Members) } - if list[0].Key == nil || list[0].Key.KeyID != keyID(60) { - t.Errorf("ключ оставшегося: %+v", list[0].Key) + if list[0].key() == nil || list[0].key().KeyID != keyID(60) { + t.Errorf("ключ оставшегося: %+v", list[0].key()) } // Ник свободен, а комната, где не осталось никого, исчезла вместе с ним. 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" { t.Errorf("комната в событии: %+v", got) } - if got.Key == nil || got.Key.CT != ctOf(40, 0) { - t.Errorf("ключ в событии: %+v", got.Key) + if got.key() == nil || got.key().CT != ctOf(40, 0) { + t.Errorf("ключ в событии: %+v", got.key()) } if got.NeedsRekey { t.Error("needsRekey при создании") @@ -808,8 +817,8 @@ func TestRoomEventsOnMembers(t *testing.T) { if nicks(got.Members) != "marta,petya" { t.Errorf("состав в событии у %s: %v", nick, got.Members) } - if got.Key == nil || got.Key.KeyID != keyID(80) || got.Key.CT != ctOf(80, i) { - t.Errorf("ключ в событии у %s: %+v", nick, got.Key) + if got.key() == nil || got.key().KeyID != keyID(80) || got.key().CT != ctOf(80, i) { + t.Errorf("ключ в событии у %s: %+v", nick, got.key()) } if got.NeedsRekey { t.Errorf("needsRekey при смене состава у %s", nick) @@ -853,8 +862,8 @@ func TestRoomEventOnLeave(t *testing.T) { if len(got.Members) != 1 || got.Members[0] != "marta" { t.Errorf("состав в событии: %v", got.Members) } - if got.Key == nil || got.Key.KeyID != keyID(60) { - t.Errorf("ключ в событии: %+v", got.Key) + if got.key() == nil || got.key().KeyID != keyID(60) { + t.Errorf("ключ в событии: %+v", got.key()) } // Другим устройствам вышедшего — room_left: комната ушла из списка, // и ждать следующего ready им незачем (ADR-041). Запрос шёл без @@ -959,8 +968,8 @@ func TestNeedsRekeyOutlivesEvent(t *testing.T) { if got == nil || !got.NeedsRekey { t.Fatalf("needsRekey в списке комнат: %+v", got) } - if got.Key == nil || got.Key.KeyID != keyID(60) { - t.Errorf("ключ в списке: %+v", got.Key) + if got.key() == nil || got.key().KeyID != keyID(60) { + t.Errorf("ключ в списке: %+v", got.key()) } // 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 // с needsRekey и их собственным ключом, владение переходит (ADR-041). 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) { - t.Errorf("ключ в событии: %+v", got.Key) + if got.key() == nil || got.key().CT != ctOf(60, i+1) { + 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) expect(t, rec, http.StatusBadRequest, "keys_mismatch") // Отказ ничего не изменил: ключ комнаты прежний. - if got := e.room(marta, room.ID); got.Key == nil || got.Key.KeyID != keyID(60) { - t.Errorf("ключ после keys_mismatch: %+v", got.Key) + if got := e.room(marta, room.ID); got.key() == nil || got.key().KeyID != keyID(60) { + t.Errorf("ключ после keys_mismatch: %+v", got.key()) } } diff --git a/internal/store/rooms.go b/internal/store/rooms.go index 7393a56..4ae6d63 100644 --- a/internal/store/rooms.go +++ b/internal/store/rooms.go @@ -49,16 +49,18 @@ type WrappedKey struct { CT string } -// Room — комната и её состав. Key — текущий ключ того, кто спрашивает; -// nil означает, что ключа у него нет. NeedsRekey — состав уменьшился, -// а нового ключа ещё не было (ADR-041). +// Room — комната и её состав. Keys — завёрнутые ключи того, кто +// спрашивает, от старого к новому: сервер держит два последних keyId +// (ADR-018) и отдаёт участнику все, иначе пропущенный в офлайне ключ +// не добыть ничем (ADR-059). Пусто — ключей у него нет. NeedsRekey — +// состав уменьшился, а нового ключа ещё не было (ADR-041). type Room struct { ID string Name string Owner string Members []string // по joined_at CreatedAt int64 - Key *RoomKey + Keys []RoomKey NeedsRekey bool } @@ -71,15 +73,17 @@ type Recipient struct { } // RoomChange — итог изменения комнаты: кому уходит room, а кому room_left. -// Room.Key всегда nil — ключ у каждого получателя свой, он в Recipient. +// Room.Keys всегда пусты — ключ у каждого получателя свой, он в Recipient. type RoomChange struct { Room Room Members []Recipient // итоговый состав Left []Recipient // выбывшие } -// Rooms — комнаты, где пользователь участник, каждая с его текущим -// ключом (docs/protocol.md, «Комнаты»). +// Rooms — комнаты, где пользователь участник, каждая со всеми его +// завёрнутыми ключами: сервер держит два последних keyId, и участник, +// пропустивший rekey в офлайне, добирает пропущенный отсюда +// (ADR-059, docs/protocol.md, «Комнаты»). func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) { rows, err := s.db.QueryContext(ctx, ` SELECT r.id, r.name, r.owner, r.created_at, r.needs_rekey @@ -130,22 +134,25 @@ func (s *Store) Rooms(ctx context.Context, nick string) ([]Room, error) { 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 { return nil, fmt.Errorf("store: ключи комнат: %w", err) } defer keys.Close() for keys.Next() { - // Второй столбец — ник владельца ключа, здесь он всегда nick. - var room, member string + var room string 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) } if i, ok := at[room]; ok { - key := k - out[i].Key = &key + out[i].Keys = append(out[i].Keys, k) } } if err := keys.Err(); err != nil { diff --git a/internal/store/rooms_test.go b/internal/store/rooms_test.go index 5923891..f6ba3c5 100644 --- a/internal/store/rooms_test.go +++ b/internal/store/rooms_test.go @@ -88,7 +88,7 @@ func TestCreateRoomTakenID(t *testing.T) { t.Fatalf("Rooms: %v", err) } if len(rooms) != 1 || rooms[0].Name != "общая" || rooms[0].Owner != "marta" || - rooms[0].Key == nil || rooms[0].Key.KeyID != "k1" { + current(rooms[0]) == nil || current(rooms[0]).KeyID != "k1" { t.Errorf("комната после отказа: %+v", rooms) } 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 и фоновая чистка // держат одни и те же ключи и не трогают текущий ключ участника (ADR-018). func TestRoomKeysTrimmedToTwo(t *testing.T) { @@ -120,13 +129,31 @@ func TestRoomKeysTrimmedToTwo(t *testing.T) { if err != nil { 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) } - if rooms[0].Key.KeyID != "k3" || rooms[0].Key.CT != "ct-"+nick { - t.Errorf("ключ %s: %+v", nick, rooms[0].Key) + if current(rooms[0]).KeyID != "k3" || current(rooms[0]).CT != "ct-"+nick { + 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 в одну миллисекунду: текущим остаётся последний розданный ключ, @@ -147,11 +174,11 @@ func TestRoomKeysWithinOneMillisecond(t *testing.T) { } for _, nick := range []string{"marta", "petya"} { 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) } - if rooms[0].Key.KeyID != "aaa" { - t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, rooms[0].Key.KeyID) + if current(rooms[0]).KeyID != "aaa" { + t.Errorf("текущий ключ %s: получено %q, ожидалось \"aaa\"", nick, current(rooms[0]).KeyID) } } // Свежим ключом можно писать: он остался ключом комнаты. diff --git a/internal/store/store_test.go b/internal/store/store_test.go index a1df6f4..dcfa01f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -105,11 +105,23 @@ func TestUsersAndSessions(t *testing.T) { 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"} - 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) } + 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) { t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err) } diff --git a/internal/store/users.go b/internal/store/users.go index 21d23b2..81e35b5 100644 --- a/internal/store/users.go +++ b/internal/store/users.go @@ -80,28 +80,65 @@ func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error // разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти // или ключ которого не расшифровать. При logoutOthers в той же транзакции // удаляются все сессии пользователя, кроме 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) if err != nil { - return fmt.Errorf("store: смена пароля: %w", err) + return nil, fmt.Errorf("store: смена пароля: %w", err) } defer tx.Rollback() if _, err := tx.ExecContext(ctx, ` UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`, 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 { + revoked, err = revokedDevices(ctx, tx, nick, keep) + if err != nil { + return nil, err + } if _, err := tx.ExecContext(ctx, ` 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 { - 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 удаляет пользователя; устройства, сессии, контакты, членство, diff --git a/web/app.css b/web/app.css index 32dcdf3..48ca5d0 100644 --- a/web/app.css +++ b/web/app.css @@ -980,6 +980,10 @@ input[type="password"] { .chat-title, .link { min-height: 44px; + /* цель нажатия не меньше 44 px в обе стороны (docs/ui.md, + «Доступность»): у коротких надписей вроде «убрать» ширины + по тексту не хватает */ + min-width: 44px; } /* подсказку «enter — отправить» видит только десктоп: на мобильном diff --git a/web/js/api.js b/web/js/api.js index 46060a0..06c995a 100644 --- a/web/js/api.js +++ b/web/js/api.js @@ -7,13 +7,15 @@ export const MAX_ACK = 500; // ApiError — ответ сервера с кодом из перечня docs/protocol.md. +// retryAfter — сколько секунд просит ждать 429; у остальных ответов null. export class ApiError extends Error { - constructor(code, message, status, field) { + constructor(code, message, status, field, retryAfter = null) { super(message || code); this.name = "ApiError"; this.code = code; this.status = status; this.field = field; + this.retryAfter = retryAfter; } } @@ -96,7 +98,19 @@ async function request(method, path, body, { quiet = false, device = null } = {} if (code === "unauthenticated" && !quiet) { 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() { @@ -194,8 +208,9 @@ export function removeContact(nick) { // --- комнаты ----------------------------------------------------------- -// rooms — комнаты, где мы участники, каждая с нашим текущим завёрнутым -// ключом (docs/protocol.md, «Комнаты»). +// rooms — комнаты, где мы участники, каждая с нашими завёрнутыми ключами: +// сервер отдаёт все, которые ещё держит, от старого к новому (ADR-059, +// docs/protocol.md, «Комнаты»). export function rooms() { return request("GET", "/api/rooms"); } diff --git a/web/js/main.js b/web/js/main.js index e16112e..669f1d7 100644 --- a/web/js/main.js +++ b/web/js/main.js @@ -410,6 +410,10 @@ async function changePassword(current, next, logoutOthers) { } finally { wipe(secret); } + // Вход завёл новую сессию, а сессия заводится без устройства: привязку + // делает POST /api/devices. Без неё удаление этого устройства с другого + // не завершит здешнюю сессию (docs/protocol.md, «Устройства»). + await sync.rebindDevice(); } // deleteAccount входит заново тем же порядком, что и смена пароля: сессии diff --git a/web/js/pwa.js b/web/js/pwa.js index b91c723..633dc3a 100644 --- a/web/js/pwa.js +++ b/web/js/pwa.js @@ -35,6 +35,11 @@ const state = { // Вопрос идёт прямо сейчас: два сообщения подряд не должны дать // два запроса разрешения. asking: false, + // Браузер отказал в самой подписке: приватное окно, политика, + // недоступный push-сервис. Кнопкой это не включить, поэтому раздел + // показывает «запрещены в браузере» (ADR-046). Флаг живёт во вкладке: + // перезагрузка пробует снова — причина могла уйти. + refused: false, }; // Приглашение установки ловится с первой секунды: браузер показывает его @@ -91,7 +96,7 @@ function supported() { // отклонённое разрешение, браузер без уведомлений, сервер без // VAPID-ключа (ADR-046). export async function notifications(key) { - if (!supported() || !key || Notification.permission === "denied") { + if (!supported() || !key || Notification.permission === "denied" || state.refused) { return "denied"; } if (await turnedOff()) { @@ -288,6 +293,11 @@ async function current() { // attach ставит подписку и отдаёт её серверу. Ключ сервера вплетён // в подписку: сменился ключ — прежняя подписка не годится, push-сервис // подпишет заново. +// +// Отказ самой подписки — не сбой сервера, а браузер, который её не даёт: +// приватное окно, политика, недоступный push-сервис. Кнопкой это +// не включить, поэтому false, а раздел настроек скажет «запрещены +// в браузере» и уберёт кнопку (ADR-046). async function attach(key) { const registration = await ready(); if (registration === null || !registration.pushManager) { @@ -303,11 +313,17 @@ async function attach(key) { subscription = null; } if (subscription === null) { - subscription = await registration.pushManager.subscribe({ - userVisibleOnly: true, - applicationServerKey: unb64url(key), - }); + try { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: unb64url(key), + }); + } catch { + state.refused = true; + return false; + } } + state.refused = false; await put(subscription); return true; } diff --git a/web/js/sync.js b/web/js/sync.js index 034d04a..86e097b 100644 --- a/web/js/sync.js +++ b/web/js/sync.js @@ -3,10 +3,10 @@ // они не ходят — пишет в базу только этот модуль. // // Правила — docs/protocol.md («События», «Сообщения», «Комнаты») -// и docs/storage.md: ACK уходит только после успешной записи в IndexedDB, -// исходящее живёт в pending до 202 и держится за свой ULID, пока время -// в нём годится серверу; отвергнутый по часам переиспользованный id -// меняется на свежий один раз (ADR-036). +// и docs/storage.md: ACK уходит пачкой и только после успешной записи +// в IndexedDB (ADR-063), исходящее живёт в pending до 202 и держится +// за свой ULID, пока время в нём годится серверу; отвергнутый по часам +// переиспользованный id меняется на свежий один раз (ADR-036). // // Доверие к ключам — TOFU (ADR-016): каждый публичный ключ, пришедший // от сервера, сверяется с запомненным; изменившийся ложится в pending @@ -39,6 +39,11 @@ import { ulid, ulidTime, validUlid } from "./ulid.js"; const RETRY_MIN = 1000; const RETRY_MAX = 30000; +// Пауза перед отправкой подтверждений: идентификаторы копятся и уходят +// одним POST /api/ack, не чаще раза в две секунды (ADR-063). Подтверждение +// — учёт очереди сервера, а не доставка человеку: сообщение уже на экране. +const ACK_DELAY = 2000; + // Владение потоком одно на браузерный профиль: устройство у вкладок общее, // а соединение на устройство сервер держит одно (ADR-035). const STREAM_LOCK = "bare-stream"; @@ -88,6 +93,10 @@ const state = { // Отложенный разбор конвертов, которые сейчас не разобрать. inboxTimer: null, hold: RETRY_MIN, + // Записанное в базу и ещё не подтверждённое серверу. Множество: + // конверт, выданный очередью повторно, подтверждается один раз (ADR-063). + acks: new Set(), + ackTimer: null, }; // --- события для экранов ----------------------------------------------- @@ -320,6 +329,10 @@ export function stop() { clearTimeout(state.inboxTimer); state.inboxTimer = null; } + if (state.ackTimer !== null) { + clearTimeout(state.ackTimer); + state.ackTimer = null; + } if (state.close) { state.close(); state.close = null; @@ -338,6 +351,9 @@ export function stop() { state.owed.clear(); state.pending.clear(); state.inbox.length = 0; + // Неподтверждённое не досылается: сервер выдаст эти конверты очередью + // при следующем подключении (ADR-063). + state.acks.clear(); state.wait = RETRY_MIN; state.hold = RETRY_MIN; setOnline(false); @@ -393,6 +409,25 @@ async function ensureDevice() { 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() { @@ -408,6 +443,15 @@ async function connect() { // Запрос не дошёл — это и есть «нет соединения» (ADR-028). setOnline(false); } + if (rateLimited(err)) { + // Ведро изменяющих запросов общее на пользователя (ADR-055): + // устройство могло не завестись из-за соседнего устройства или + // прежней работы этой же вкладки. Это задержка, а не отказ — + // без устройства нет ни потока, ни отправки, и сама вкладка + // не ожила бы до перезагрузки. + retryLater(pause(err)); + return; + } if (transient(err)) { retryLater(); } @@ -452,6 +496,13 @@ function claimStream() { }); } +// owner — держит ли эта вкладка поток событий. Без BroadcastChannel или +// navigator.locks арбитража нет вовсе, и вкладка работает как единственная +// (ADR-035), поэтому владельцем считается и она. +function owner() { + return !shared || state.release !== null; +} + // yieldStream отпускает владение: соседняя вкладка займёт поток сразу. function yieldStream() { 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) { return; } - const delay = state.wait; - state.wait = Math.min(delay * 2, RETRY_MAX); + let delay = state.wait; + if (after === null) { + state.wait = Math.min(delay * 2, RETRY_MAX); + } else { + delay = Math.min(after, RETRY_MAX); + } state.timer = setTimeout(() => { state.timer = null; recover(); @@ -625,7 +684,7 @@ async function flush() { state.hold = RETRY_MIN; } // ACK — только после успешной записи (docs/storage.md). - await ackAll(acked); + ackLater(acked); } // postpone откладывает повторный разбор: причина, по которой конверт не @@ -752,7 +811,32 @@ async function reopen() { 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) { try { 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 повторяет неотправленное после подключения. Идёт прямо, // без serial: afterReady уже внутри очереди. async function retryPending() { @@ -912,7 +1008,9 @@ export function trustKey(nick) { // Владелец, чей rekey упирался в этот ключ, доводит его до конца. await payRekeys(); await reopen(); - await retryPending(); + // «Доверять новому ключу» нажимают в любой вкладке, а повторяет + // неотправленное владелец потока (ADR-035). + await resumePending(); return true; }); } @@ -945,7 +1043,7 @@ function usableRoom(r) { && typeof r.owner === "string" && Array.isArray(r.members) && r.members.every((nick) => typeof nick === "string") && Number.isFinite(r.createdAt) - && (r.key === null || r.key === undefined || usableKey(r.key)); + && Array.isArray(r.keys) && r.keys.every(usableKey); } function usableKey(k) { @@ -1006,10 +1104,15 @@ async function senderKey(nick) { return record.pending ? null : record.publicKey; } -// takeRoomKey разворачивает завёрнутый нам ключ комнаты и кладёт его +// takeRoomKeys разворачивает завёрнутые нам ключи комнаты и кладёт их // в roomKeys вместе с from и receivedAt (docs/crypto.md, «Комната»). // Отдаёт, появился ли новый ключ. // +// Ключей бывает несколько: `GET /api/rooms` отдаёт все, которые сервер +// ещё держит, — участник, пропустивший rekey в офлайне, добирает отсюда +// недостающий keyId и читает конверт, пришедший с ним (ADR-059). +// Событие room несёт один ключ, новый. +// // Уже известный keyId не трогается: клиент держит все ключи комнаты. // Не развернувшийся не теряется — сервер отдаёт его снова с каждым // GET /api/rooms. @@ -1019,9 +1122,20 @@ async function senderKey(nick) { // до запроса его публичного ключа: TOFU запоминает первый ключ молча, // поэтому незнакомый распространитель — это подмена, а не первый // контакт (ADR-039). -async function takeRoomKey(room) { - const wrapped = room.key; - if (!usableKey(wrapped) || !room.members.includes(wrapped.from)) { +async function takeRoomKeys(room) { + let fresh = false; + // Порядок — от старого ключа к новому: текущим у нас становится + // последний сохранённый (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; } if (await db.roomKey(room.id, wrapped.keyId)) { @@ -1069,7 +1183,7 @@ async function applyRoom(room) { return; } const changed = await saveRoom(room); - const fresh = await takeRoomKey(room); + const fresh = await takeRoomKeys(room); if (changed) { announceChats(); } @@ -1115,7 +1229,7 @@ async function refreshRooms() { } seen.add(room.id); const moved = await saveRoom(room); - const key = await takeRoomKey(room); + const key = await takeRoomKeys(room); changed = changed || moved; fresh = fresh || key; if (moved || key) { @@ -1335,7 +1449,9 @@ async function changeRoom(roomId, add, remove) { announceRoom(roomId); if (stored) { await reopen(); - await retryPending(); + // Состав меняют из любой вкладки; повтор неотправленного — дело + // владельца потока (ADR-035). + await resumePending(); } return room; } @@ -1567,8 +1683,9 @@ async function post(message, peer, roomId) { } // settle разбирает отказ. Сеть и 500 сообщение не хоронят: оно остаётся -// pending и повторится при следующем подключении (ADR-027). Удалённое -// устройство чинится тем же способом — переподключением. Остальные 4xx — +// pending и повторится при следующем подключении (ADR-027). Потерянное +// устройство — то же самое: это не отказ сообщению, и чинится он +// переподключением, а не текстом в ленте (ADR-060). Остальные 4xx — // failed с текстом отказа (ADR-033). async function settle(message, err) { if (err instanceof NetworkError) { @@ -1600,6 +1717,18 @@ function transient(err) { || (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. Строку списка diff --git a/web/sw.js b/web/sw.js index cdc2eb4..6b98fa8 100644 --- a/web/sw.js +++ b/web/sw.js @@ -7,7 +7,7 @@ // Имя кэша содержит версию; версия — константа, она меняется при релизе, // и старые кэши уходят в activate. -const VERSION = "v3"; +const VERSION = "v4"; const CACHE = `bare-${VERSION}`; // Оболочка — всё, из чего клиент поднимается без сети. Список явный: -- 2.54.0