From ae55c846aafabd1e456f515fbb35aa5a94753742 Mon Sep 17 00:00:00 2001 From: Yuriy Mayatnikov Date: Sat, 22 Aug 2026 10:53:24 +0300 Subject: [PATCH] =?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 @@ +