Регистрация открытая (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
12 KiB
Архитектура
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.