Лимиты: все четыре правила ADR-021 — регистрация 5/час на IP, вход 10/10 мин на IP и ник, сообщения 30/мин, прочие изменяющие 60/мин; 429 с Retry-After; X-Real-IP читается только с loopback, иначе адрес соединения — иначе заголовок отменял бы лимит на IP; карты вёдер ограничены поколениями. Аудит нашёл то, что пропустили пять раундов ревью: ADR-056: nginx вёл access_log с IP и полными путями вопреки обещанию deploy.md. Ники и социальный граф ложились в /var/log/nginx рядом с чистым журналом bare. ADR-058: «выйти на других устройствах» не обрывал уже открытый SSE — отозванная сессия продолжала получать сообщения. ADR-059: промежуточный ключ комнаты был невосстановим. Участник, пропустивший офлайн два rekey подряд, навсегда не расшифровал бы сообщения среднего ключа — вопреки обещанию storage.md о повторной попытке после получения keyId. ADR-063: ACK уходил по одному на конверт, а не пачкой. Получатель в оживлённой комнате выедал общее ведро подтверждениями и упирался в 429 на всех изменяющих запросах, включая выход из комнаты: 116 отказов за прогон стало нулём. ADR-055, 057, 060, 061, 062: ключи вёдер и границы, +dirty у bare version, 403 unknown_device не хоронит сообщение, усечение имени в подсказке ввода, kdf как оракул после повышения цели KDF. Модель угроз пополнена тем, что действительно видит оператор: push-подписки лежат в базе открытым текстом, и вместе с VAPID-ключом с той же машины это произвольное уведомление на экране блокировки. README приведён к v1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
78 lines
11 KiB
Markdown
78 lines
11 KiB
Markdown
# Архитектура
|
||
|
||
Bare — это PWA-клиент на ванильных веб-технологиях и Go-сервер одним бинарём. Между ними — голый HTTPS: приём сообщений через SSE, отправка обычным `fetch POST`. Чат поверх голого HTTP.
|
||
|
||
## Стек
|
||
|
||
Клиент: HTML + CSS + vanilla JS, ES-модули без сборки, один service worker, manifest.json. PWA. Ноль npm-зависимостей.
|
||
|
||
Сервер: Go, стандартная библиотека плюс ровно три внешних пакета — webpush-go, драйвер SQLite, argon2. База — SQLite. HTTPS обязателен: без него не работают service worker и пуши.
|
||
|
||
## Аккаунты
|
||
|
||
Регистрация — ник и пароль. Ник уникален и является идентификатором пользователя. Без email, телефона, OAuth и интеграций. Восстановления пароля нет.
|
||
|
||
Пароль не покидает клиент. Из него выводится мастер-ключ, из мастера — два независимых ключа: `authKey` для входа и `kek` для ключевого блоба. Сервер хранит Argon2id от `authKey`; сессия — в httpOnly cookie с `SameSite=Strict`, плюс проверка `Origin`. Смена пароля и повышение итераций KDF — одна операция, есть в v1. Удаление аккаунта есть.
|
||
|
||
Ники — `[a-z0-9_]{2,32}`. Регистрация открыта; оператор может включить общий инвайт-код. Чат 1:1 начинается с ввода ника, согласия не требуется. Блокировок в v1 нет.
|
||
|
||
Пароль — не короче 12 символов; правил про регистры и спецсимволы нет: длина важнее состава. UI рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет.
|
||
|
||
## E2EE
|
||
|
||
Вся клиентская криптография — WebCrypto, без крипто-библиотек.
|
||
|
||
Идентичность пользователя — ECDH-пара (P-256). Приватный ключ шифруется ключом, выведенным из пароля (PBKDF2-HMAC-SHA256, не менее 600 000 итераций, целевое значение — 1 000 000), и хранится на сервере как блоб — сервер видит только шифротекст. Параметры KDF лежат рядом с блобом и читаются клиентом при входе: их можно повышать без миграции всех аккаунтов разом. Рядом с приватным ключом в блобе живёт случайный 32-байтовый секрет аккаунта — из него выводятся ключи экспорта истории. Отсюда два следствия. Сброс пароля невозможен by design. Мультидевайс-вход прост: новый девайс вводит пароль, скачивает блоб, расшифровывает ключ.
|
||
|
||
Публичные ключи раздаёт сервер, доверие — TOFU: клиент запоминает ключ при первом контакте, смена ключа блокирует отправку до явного подтверждения по отпечатку. Подписей сообщений нет; отправителя проставляет сервер из сессии.
|
||
|
||
Чаты 1:1: ECDH shared secret → HKDF → AES-GCM.
|
||
|
||
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст) и отдаёт участнику все, что держит, — два последних: новое устройство получает действующий ключ, а вернувшееся из офлайна — ещё и пропущенный, которым зашифровано лежащее в его очереди (ADR-059). Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
|
||
|
||
Все процедуры побайтно — `docs/crypto.md`.
|
||
|
||
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 целиком, виртуализации нет (ADR-053).
|
||
|
||
При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`.
|
||
|
||
## Экспорт и импорт истории
|
||
|
||
Экспорт: вся локальная история сериализуется, шифруется и сохраняется одним файлом `.bare`. Ключ экспорта выводится из секрета аккаунта: HKDF(секрет, случайный salt, info="bare-export-v1") → AES-GCM, всё на WebCrypto. Отдельной парольной фразы нет: архив криптографически привязан к аккаунту и вне его бесполезен — у чужого клиента нет секрета аккаунта, расшифровка невозможна в принципе.
|
||
|
||
Заголовок файла открытый: magic, версия формата, salt, отпечаток публичного ключа владельца. Ника в заголовке нет — лишняя утечка. Импорт: клиент сверяет отпечаток со своим (при несовпадении — «архив создан другим аккаунтом», сверка — UX-вежливость, не защита) и делает идемпотентный merge в IndexedDB по messageId — повторный импорт и склейка истории с двух устройств не создают дублей.
|
||
|
||
Это единственный механизм переноса истории между устройствами. Осознанно.
|
||
|
||
## Транспорт
|
||
|
||
Конверт сообщения: открытые `id` (ULID клиента, расхождение с часами сервера не больше 5 минут), адресат, отправитель (ставит сервер), `keyId`, `iv`, `ct`, серверное время. Открытые поля привязаны к шифротексту через AAD. Приём — SSE с воспроизведением очереди при каждом подключении и ACK после записи в IndexedDB; отправка — `fetch POST`.
|
||
|
||
## Пуши
|
||
|
||
Web Push + VAPID. Одна пара ключей, никаких регистраций и оплат у вендоров, никакого Firebase SDK.
|
||
|
||
Пуш — сигнал, не транспорт: содержимое всегда догоняется через очередь при открытии. Текст пуша generic («имя: новое сообщение») — сервер не знает плейнтекста. Declarative Web Push не используем: несовместим с E2EE.
|
||
|
||
iOS: пуши работают только у PWA, установленного на экран «Домой», поэтому онбординг-баннер установки — обязательная часть продукта. Разрешение на уведомления запрашивается после осмысленного действия (первое отправленное сообщение), не при входе.
|
||
|
||
Подписка принадлежит устройству. Пуш уходит, только если устройство не подключено по SSE и у него нет неотработанного пуша: одно молчащее устройство — один пуш. Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки.
|
||
|
||
## Развёртывание
|
||
|
||
TLS терминирует nginx на том же сервере, Bare слушает `127.0.0.1:8411` под systemd. Статика встроена в бинарь. Сборка — кросс-компиляция без cgo. Подробности — `docs/deploy.md`.
|
||
|
||
## Интерфейс
|
||
|
||
Айдентика «Скобы», системный моноширинный шрифт, одна светлая тема, русский язык без i18n. Экраны — `docs/ui.md`.
|
||
|
||
## Scope v1
|
||
|
||
Чаты 1:1 и комнаты. Только текст и эмодзи (эмодзи — юникод, отдельной фичи нет). Экспорт/импорт истории. Пуши на всех платформах. Всё остальное — за пределами v1.
|