Files
bare/docs/architecture.md
T
mayatnikovandClaude Fable 5 a898347005 Закрыть открытые вопросы: ADR-015…022, бриф айдентики
Регистрация открытая (015), контакты и приглашения по нику с
закреплением ключа и блокировкой (016), лимиты (017), идентификация
устройства через случайный deviceId в IndexedDB и серверный fan-out
(018), отказ от серверного перца (019), смена пароля не в v1 (020),
интерфейс ru/en (021), айдентика «Скобы» по turn 2 канваса (022).

Архитектура, модель угроз и README обновлены. В открытых вопросах
остались два: моногарнитура с кириллицей (Fragment Mono её не
содержит) и удаление аккаунта.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R2pCJkWyG2aYu48u1Vp1A1
2026-08-20 22:42:20 +03:00

90 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура
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 и пуши.
## Аккаунты
Регистрация открытая — ник и пароль, инвайтов нет. Ник уникален и является идентификатором пользователя: 3–32 символа, латиница в нижнем регистре, цифры, `_`, `-`. Без email, телефона, OAuth и интеграций. Восстановления пароля нет. Смены пароля в v1 тоже нет — появится позже: формат блоба это уже позволяет.
Серверная аутентификация: Argon2id, сессия в httpOnly cookie, привязанная к устройству.
Пароль — не короче 12 символов; правил про регистры и спецсимволы нет: длина важнее состава. UI рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет.
## E2EE
Вся клиентская криптография — WebCrypto, без крипто-библиотек.
Идентичность пользователя — ECDH-пара (P-256). Приватный ключ шифруется ключом, выведенным из пароля (PBKDF2-HMAC-SHA256, не менее 600 000 итераций, целевое значение — 1 000 000), и хранится на сервере как блоб — сервер видит только шифротекст. Параметры KDF лежат рядом с блобом и читаются клиентом при входе: их можно повышать без миграции всех аккаунтов разом. Рядом с приватным ключом в блобе живёт случайный 32-байтовый секрет аккаунта — из него выводятся ключи экспорта истории. Отсюда два следствия. Сброс пароля невозможен by design. Мультидевайс-вход прост: новый девайс вводит пароль, скачивает блоб, расшифровывает ключ.
Чаты 1:1: ECDH shared secret → AES-GCM.
Комнаты: у комнаты симметричный ключ, он раздаётся участникам зашифрованным на их публичные ключи. При изменении состава — rekey. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
Forward secrecy — осознанный non-goal v1.
## Контакты и комнаты
Контакт добавляется по точному нику: сервер отдаёт публичный ключ и создаёт запрос, адресат принимает или отклоняет. Сообщения 1:1 ходят только между взаимными контактами. Поиска и каталога нет; ссылок и QR — тоже.
Публичный ключ контакта закрепляется на устройстве при первом получении. Если сервер вернёт для ника другой ключ — предупреждение, шифрование на новый ключ только после явного подтверждения. Отпечаток виден в карточке контакта.
В комнату приглашают только своих контактов, добавлением в состав без отдельного согласия. Создатель комнаты исключает участников; выйти может любой. Каждое изменение состава — rekey.
Отклонить запрос контакта можно с блокировкой: сервер не доставляет от заблокированного ни сообщений, ни новых запросов.
## Устройства
Устройство — 128-битный случайный `deviceId`, сгенерированный клиентом при первом запуске и хранящийся в IndexedDB рядом с историей. Стёрли данные сайта — новое устройство. `deviceId` — адрес очереди, не секрет; аутентифицирует сессия.
Доставка — серверный fan-out: сообщение раскладывается в очереди всех устройств всех участников, кроме отправившего. Все устройства аккаунта владеют одним приватным ключом, шифротекст один для всех. Отправитель устройств адресата не знает.
Устройство без связи 30 дней удаляется вместе с очередью и push-подпиской. Список устройств с метками из user-agent — в настройках; любое можно удалить.
## Хранение
Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб, устройства), метаданные комнат, контактов и блокировок, транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено; больше 10 000 сообщений — старейшие удаляются. Серверного «перца» для ключевого блоба нет: развёртывание — бинарь плюс один файл базы, ничего, что нельзя потерять, кроме самой базы.
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM.
При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`.
## Экспорт и импорт истории
Экспорт: вся локальная история сериализуется, шифруется и сохраняется одним файлом `.bare`. Ключ экспорта выводится из секрета аккаунта: HKDF(секрет, случайный salt, info="bare-export-v1") → AES-GCM, всё на WebCrypto. Отдельной парольной фразы нет: архив криптографически привязан к аккаунту и вне его бесполезен — у чужого клиента нет секрета аккаунта, расшифровка невозможна в принципе.
Заголовок файла открытый: magic, версия формата, salt, отпечаток публичного ключа владельца. Ника в заголовке нет — лишняя утечка. Импорт: клиент сверяет отпечаток со своим (при несовпадении — «архив создан другим аккаунтом», сверка — UX-вежливость, не защита) и делает идемпотентный merge в IndexedDB по messageId — повторный импорт и склейка истории с двух устройств не создают дублей.
Это единственный механизм переноса истории между устройствами. Осознанно.
## Пуши
Web Push + VAPID. Одна пара ключей, никаких регистраций и оплат у вендоров, никакого Firebase SDK.
Пуш — сигнал, не транспорт: содержимое всегда догоняется через очередь при открытии. Текст пуша generic («имя: новое сообщение») — сервер не знает плейнтекста. Declarative Web Push не используем: несовместим с E2EE.
iOS: пуши работают только у PWA, установленного на экран «Домой», поэтому онбординг-баннер установки — обязательная часть продукта. Разрешение на уведомления запрашивается после осмысленного действия (первое отправленное сообщение), не при входе.
Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки.
## Лимиты
Сообщение — до 4 096 символов плейнтекста, проверяет клиент. Тело запроса — до 32 КиБ, проверяет сервер. Частота — token bucket в памяти сервера, превышение — `429` с `Retry-After`: 60 сообщений в минуту, 20 запросов контакта в сутки, 5 регистраций в час с IP, 10 попыток входа за 15 минут на ник. Контактов — до 500, участников комнаты — до 100, устройств — до 10. Все числа — константы в одном файле сервера, конфигурации нет.
Антиспам структурный: писать можно только взаимным контактам, блокировка ника, лимиты. Модерации нет — серверу нечего модерировать.
## Интерфейс
Два языка, русский и английский: два плоских словаря в одном ES-модуле, выбор по `navigator.language` с переключателем в настройках. Склонения — `Intl.PluralRules`, даты — `Intl.DateTimeFormat`.
Айдентика — «Скобы»: знак из четырёх углов, моноширинная гарнитура для всего, сообщения строками без баблов, палитра bone/ink/stone с одним красным акцентом. Бриф — `docs/identity/brief.md`. Шрифт не грузится с внешних хостов.
## Scope v1
Чаты 1:1 и комнаты. Только текст и эмодзи (эмодзи — юникод, отдельной фичи нет). Экспорт/импорт истории. Пуши на всех платформах. Всё остальное — за пределами v1.