ADR-015…024 и спецификации v1: криптография, протокол, хранение, UI, деплой, план
Закрыты все открытые вопросы проектирования. Пароль не покидает клиент (два ключа из мастера), TOFU для публичных ключей, устройства и конверт, атомарный rekey комнат, регистрация и контакты, схема SQLite и драйвер без cgo, сессии и CSRF, деплой через nginx+systemd, правила пушей, айдентика «Скобы» на системном mono. Иконки PWA в web/icons. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
@@ -4,3 +4,6 @@
|
|||||||
# локальная база
|
# локальная база
|
||||||
*.db
|
*.db
|
||||||
*.db-*
|
*.db-*
|
||||||
|
|
||||||
|
# локальные секреты деплоя
|
||||||
|
/env
|
||||||
|
|||||||
@@ -5,8 +5,14 @@
|
|||||||
## Жёсткие ограничения
|
## Жёсткие ограничения
|
||||||
|
|
||||||
- Клиент: HTML, CSS, vanilla JS, нативные браузерные API. Никаких фреймворков, npm-зависимостей и сборки — ES-модули как есть.
|
- Клиент: HTML, CSS, vanilla JS, нативные браузерные API. Никаких фреймворков, npm-зависимостей и сборки — ES-модули как есть.
|
||||||
- Сервер: Go, стандартная библиотека. Допущены ровно три внешних пакета: webpush-go, драйвер SQLite, argon2. Ничего сверх — без обсуждения.
|
- Сервер: Go, стандартная библиотека. Допущены ровно три прямые зависимости: `github.com/SherClockHolmes/webpush-go`, `modernc.org/sqlite`, `golang.org/x/crypto` (argon2). Транзитивные — допускаются. Ничего сверх — без обсуждения.
|
||||||
- Криптография на клиенте: только WebCrypto.
|
- Криптография на клиенте: только 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, не молча.
|
||||||
|
|
||||||
## Процесс
|
## Процесс
|
||||||
|
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ Bare — маленький независимый инструмент, а не
|
|||||||
- [Архитектура](docs/architecture.md) — обзор системы
|
- [Архитектура](docs/architecture.md) — обзор системы
|
||||||
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
|
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
|
||||||
- [Решения](docs/decisions/) — ADR по ключевым решениям
|
- [Решения](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/open-questions.md)
|
||||||
|
|
||||||
## Статус
|
## Статус
|
||||||
|
|
||||||
Проектирование. Кода ещё нет — сначала документы.
|
Спецификации завершены, код — по `docs/plan.md`, этап 0. Иконки PWA уже в `web/icons/`.
|
||||||
|
|
||||||
## Лицензия
|
## Лицензия
|
||||||
|
|
||||||
|
|||||||
+23
-5
@@ -12,7 +12,9 @@ Bare — это PWA-клиент на ванильных веб-технолог
|
|||||||
|
|
||||||
Регистрация — ник и пароль. Ник уникален и является идентификатором пользователя. Без email, телефона, OAuth и интеграций. Восстановления пароля нет.
|
Регистрация — ник и пароль. Ник уникален и является идентификатором пользователя. Без 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 рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет.
|
Пароль — не короче 12 символов; правил про регистры и спецсимволы нет: длина важнее состава. UI рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет.
|
||||||
|
|
||||||
@@ -22,15 +24,19 @@ Bare — это PWA-клиент на ванильных веб-технолог
|
|||||||
|
|
||||||
Идентичность пользователя — ECDH-пара (P-256). Приватный ключ шифруется ключом, выведенным из пароля (PBKDF2-HMAC-SHA256, не менее 600 000 итераций, целевое значение — 1 000 000), и хранится на сервере как блоб — сервер видит только шифротекст. Параметры KDF лежат рядом с блобом и читаются клиентом при входе: их можно повышать без миграции всех аккаунтов разом. Рядом с приватным ключом в блобе живёт случайный 32-байтовый секрет аккаунта — из него выводятся ключи экспорта истории. Отсюда два следствия. Сброс пароля невозможен by design. Мультидевайс-вход прост: новый девайс вводит пароль, скачивает блоб, расшифровывает ключ.
|
Идентичность пользователя — 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.
|
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.
|
Клиент хранит историю в 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.
|
Web Push + VAPID. Одна пара ключей, никаких регистраций и оплат у вендоров, никакого Firebase SDK.
|
||||||
@@ -52,7 +62,15 @@ Web Push + VAPID. Одна пара ключей, никаких регистр
|
|||||||
|
|
||||||
iOS: пуши работают только у PWA, установленного на экран «Домой», поэтому онбординг-баннер установки — обязательная часть продукта. Разрешение на уведомления запрашивается после осмысленного действия (первое отправленное сообщение), не при входе.
|
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
|
## Scope v1
|
||||||
|
|
||||||
|
|||||||
+115
@@ -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": <JWK приватного ключа>, "secret": <base64url 32 байта>}
|
||||||
|
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 и его время.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-002: Сервер на Go, один бинарь
|
# 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. Простота развёртывания и аудита важнее богатства экосистемы.
|
Серверу Bare нужно немного: HTTP, SSE, SQLite, хеширование паролей, Web Push. Простота развёртывания и аудита важнее богатства экосистемы.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-005: Аккаунт — ник и пароль
|
# ADR-005: Аккаунт — ник и пароль
|
||||||
|
|
||||||
|
Уточнён [ADR-015](015-password-never-leaves-client.md): на сервер уходит не пароль, а выведенный из него `authKey`. Открытость регистрации закрыта [ADR-019](019-registration-and-contacts.md).
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Email, телефон и OAuth тянут за собой внешние сервисы, интеграции и утечку идентичности. Bare — независимый инструмент без внешних завязок.
|
Email, телефон и OAuth тянут за собой внешние сервисы, интеграции и утечку идентичности. Bare — независимый инструмент без внешних завязок.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-006: E2EE на WebCrypto, ключ за паролем
|
# ADR-006: E2EE на WebCrypto, ключ за паролем
|
||||||
|
|
||||||
|
Уточнён [ADR-015](015-password-never-leaves-client.md) (два ключа из мастера) и [ADR-016](016-key-trust-tofu.md) (доверие к публичным ключам).
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Оператор не должен уметь читать сообщения. Крипто-библиотеки на клиенте противоречат нулю зависимостей и аудируемости — вся криптография должна быть нативной.
|
Оператор не должен уметь читать сообщения. Крипто-библиотеки на клиенте противоречат нулю зависимостей и аудируемости — вся криптография должна быть нативной.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-007: Симметричный ключ комнаты и rekey
|
# ADR-007: Симметричный ключ комнаты и rekey
|
||||||
|
|
||||||
|
Конкретизирован [ADR-018](018-rooms-membership-rekey.md): владелец, случайный `keyId`, атомарный rekey, постоянное хранение завёрнутых ключей.
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Сообщение в комнате должны читать все участники, но не сервер. Шифровать каждое сообщение отдельно каждому участнику — квадратичный объём работы и трафика.
|
Сообщение в комнате должны читать все участники, но не сервер. Шифровать каждое сообщение отдельно каждому участнику — квадратичный объём работы и трафика.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-008: Сервер — реле с per-device очередью
|
# ADR-008: Сервер — реле с per-device очередью
|
||||||
|
|
||||||
|
Уточнён [ADR-017](017-devices-and-envelope.md) (идентификация устройства, конверт, ACK) и [ADR-018](018-rooms-membership-rekey.md): к метаданным комнат относятся завёрнутые ключи.
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Сервер никогда не является местом, где живёт история (философия, п. 2). Но получатель бывает офлайн — сообщение надо где-то подержать до доставки.
|
Сервер никогда не является местом, где живёт история (философия, п. 2). Но получатель бывает офлайн — сообщение надо где-то подержать до доставки.
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
# ADR-011: Web Push + VAPID, пуш — сигнал
|
# ADR-011: Web Push + VAPID, пуш — сигнал
|
||||||
|
|
||||||
|
Правила отправки и service worker — [ADR-023](023-push-and-service-worker.md).
|
||||||
|
|
||||||
## Контекст
|
## Контекст
|
||||||
|
|
||||||
Без уведомлений чат бесполезен. Firebase SDK и вендорские кабинеты — зависимость и завязка, несовместимые с философией.
|
Без уведомлений чат бесполезен. Firebase SDK и вендорские кабинеты — зависимость и завязка, несовместимые с философией.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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, если появится требование защиты от сговора участника с сервером.
|
||||||
@@ -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`; следующий вход создаёт новое устройство, старое отомрёт по сроку.
|
||||||
@@ -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. В модели угроз сервер и участник по отдельности не защищаемые стороны; их сговор — тем более.
|
||||||
@@ -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, если понадобятся.
|
||||||
@@ -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-файл без основного файла бесполезен.
|
||||||
@@ -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-дневный вход без продления — раз в квартал пароль вводится заново на каждом устройстве.
|
||||||
@@ -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 в одном месте.
|
||||||
@@ -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` → фокус открытого окна или открытие `/#/<chat>`.
|
||||||
|
- Кэш: stale-while-revalidate для оболочки (`/`, `/app.css`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`.
|
||||||
|
- Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки.
|
||||||
|
|
||||||
|
## Следствия
|
||||||
|
|
||||||
|
- Сервер знает только, что у устройства есть что забрать; содержимое в пуше не появляется.
|
||||||
|
- Пользователь с пятью непрочитанными чатами получает один пуш про первый. Остальное — при открытии. Осознанно.
|
||||||
|
- Релиз без смены версии в `sw.js` обновит статику только по ETag при следующем revalidate, не мгновенно.
|
||||||
@@ -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 — растеризованный знак, лежат в репозитории как бинарные файлы; это ассеты, не сборка.
|
||||||
+135
@@ -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 хранит по своим правилам.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="#1B1917" stroke-width="7"><path d="M10 26V10h16"/><path d="M38 10h16v16"/><path d="M54 38v16H38"/><path d="M26 54H10V38"/></svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
@@ -0,0 +1,140 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Bare — эталонные экраны</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bone:#F7F5F0; --ink:#1B1917; --text2:#3C3B38; --mute:#6E6D68; --stone:#A9A59D;
|
||||||
|
--line:#E7E3DA; --edge:#DEDCD6; --mark:#C82D40; --mark:oklch(55% 0.19 20);
|
||||||
|
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing:border-box; }
|
||||||
|
body { margin:0; padding:56px; background:#E9E8E3; color:var(--ink); font-family:var(--mono); }
|
||||||
|
h1 { font-size:32px; font-weight:400; letter-spacing:-0.03em; margin:0 0 8px; }
|
||||||
|
.note { font-size:12px; letter-spacing:0.12em; text-transform:uppercase; color:var(--mute); margin:0 0 32px; }
|
||||||
|
.row { display:flex; flex-wrap:wrap; gap:32px; align-items:flex-start; }
|
||||||
|
.label { display:flex; align-items:center; gap:10px; margin-bottom:10px; font-size:12px; color:var(--mute); }
|
||||||
|
.label b { font-weight:400; font-size:11px; background:var(--ink); color:var(--bone); padding:2px 7px; }
|
||||||
|
.screen { background:var(--bone); border:1px solid var(--edge); color:var(--ink); }
|
||||||
|
.mark { width:18px; height:18px; fill:none; stroke:var(--ink); stroke-width:7; }
|
||||||
|
.section { font-size:10px; letter-spacing:0.14em; text-transform:uppercase; color:var(--stone); padding:0 8px 10px; }
|
||||||
|
.item { font-size:13px; padding:6px 8px; color:var(--text2); display:flex; }
|
||||||
|
.item.active { background:var(--ink); color:var(--bone); }
|
||||||
|
.item .n { margin-left:auto; color:var(--mark); }
|
||||||
|
.divider { display:flex; align-items:center; gap:14px; font-size:11px; color:var(--stone); }
|
||||||
|
.divider::before, .divider::after { content:""; flex:1; height:1px; background:var(--line); }
|
||||||
|
.divider.new { color:var(--mark); }
|
||||||
|
.divider.new::before, .divider.new::after { background:var(--mark); }
|
||||||
|
.author { color:var(--mute); font-size:12px; padding-top:2px; }
|
||||||
|
.author.me { color:var(--mark); }
|
||||||
|
.author .t { color:var(--stone); }
|
||||||
|
.input { display:flex; align-items:center; gap:12px; border:1px solid var(--ink); padding:14px 16px; font-size:14px; }
|
||||||
|
.input .p { color:var(--mark); }
|
||||||
|
.input .caret { width:8px; height:17px; background:var(--ink); }
|
||||||
|
.input .hint { margin-left:auto; font-size:11px; color:var(--stone); }
|
||||||
|
.input .ph { color:var(--stone); }
|
||||||
|
|
||||||
|
/* 2a — десктоп */
|
||||||
|
.desktop { width:1120px; height:700px; display:grid; grid-template-columns:224px 1fr; }
|
||||||
|
.side { border-right:1px solid var(--line); display:flex; flex-direction:column; }
|
||||||
|
.brand { height:64px; display:flex; align-items:center; gap:10px; padding:0 20px; border-bottom:1px solid var(--line); font-size:15px; letter-spacing:-0.02em; }
|
||||||
|
.list { padding:20px; display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.list .section + .section { padding-top:22px; }
|
||||||
|
.me { margin-top:auto; padding:16px 20px; border-top:1px solid var(--line); font-size:12px; color:var(--mute); display:flex; align-items:center; gap:8px; }
|
||||||
|
.me i { width:6px; height:6px; background:var(--ink); }
|
||||||
|
.main { display:flex; flex-direction:column; min-width:0; }
|
||||||
|
.head { height:64px; display:flex; align-items:center; gap:14px; padding:0 32px; border-bottom:1px solid var(--line); font-size:15px; }
|
||||||
|
.feed { flex:1; padding:28px 32px; display:flex; flex-direction:column; justify-content:flex-end; overflow:hidden; }
|
||||||
|
.feed .divider { padding:10px 0 22px; }
|
||||||
|
.grid { display:grid; grid-template-columns:132px 1fr; column-gap:20px; row-gap:6px; font-size:14px; line-height:1.55; }
|
||||||
|
.grid .divider { grid-column:1 / -1; padding:16px 0; }
|
||||||
|
.compose { padding:0 32px 28px; }
|
||||||
|
|
||||||
|
/* 2b — мобильный */
|
||||||
|
.mobile { width:390px; height:700px; display:flex; flex-direction:column; }
|
||||||
|
.mobile .head { height:56px; padding:0 20px; gap:12px; font-size:14px; }
|
||||||
|
.mobile .mark { width:16px; height:16px; stroke-width:8; }
|
||||||
|
.mobile .feed { padding:20px; gap:14px; }
|
||||||
|
.mobile .feed .divider { padding:0; font-size:10px; }
|
||||||
|
.msg { display:flex; flex-direction:column; gap:4px; }
|
||||||
|
.msg .author { font-size:11px; }
|
||||||
|
.msg p { margin:0; font-size:14px; line-height:1.5; }
|
||||||
|
.mobile .compose { padding:0 16px 20px; }
|
||||||
|
.mobile .input { padding:13px 14px; min-height:44px; gap:10px; }
|
||||||
|
.mobile .input .caret { width:7px; height:16px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Bare — эталонные экраны</h1>
|
||||||
|
<p class="note">вариант 1h «скобы» · системный mono · без баблов · только scope v1</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div>
|
||||||
|
<div class="label"><b>2a</b> десктоп · 1120</div>
|
||||||
|
<div class="screen desktop">
|
||||||
|
<nav class="side">
|
||||||
|
<div class="brand"><svg class="mark" viewBox="0 0 64 64"><path d="M10 26V10h16"/><path d="M38 10h16v16"/><path d="M54 38v16H38"/><path d="M26 54H10V38"/></svg>bare</div>
|
||||||
|
<div class="list">
|
||||||
|
<div class="section">каналы</div>
|
||||||
|
<div class="item active">#general</div>
|
||||||
|
<div class="item">#dev</div>
|
||||||
|
<div class="item"><span>#design</span><span class="n">2</span></div>
|
||||||
|
<div class="item">#random</div>
|
||||||
|
<div class="section">личные</div>
|
||||||
|
<div class="item">@marta</div>
|
||||||
|
<div class="item">@lev</div>
|
||||||
|
</div>
|
||||||
|
<div class="me"><i></i>ты: @kir</div>
|
||||||
|
</nav>
|
||||||
|
<main class="main">
|
||||||
|
<div class="head">#general</div>
|
||||||
|
<div class="feed">
|
||||||
|
<div class="divider">вторник, 18 августа</div>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="author">marta <span class="t">11:52</span></div>
|
||||||
|
<div>выкатила статику на bare.xmatic.team, кэш чистится сам</div>
|
||||||
|
<div></div>
|
||||||
|
<div>вес страницы — 14 кб. без шрифтов было бы 9, но mono того стоит</div>
|
||||||
|
<div class="author">lev <span class="t">11:58</span></div>
|
||||||
|
<div>смотрю network: один html, один css, ноль js до первого сообщения. красиво</div>
|
||||||
|
<div class="author me">kir <span class="t">12:03</span></div>
|
||||||
|
<div>это и есть план. если фича требует бандлер — фича не нужна</div>
|
||||||
|
<div></div>
|
||||||
|
<div>доки пишу прямо в readme, отдельного сайта не будет</div>
|
||||||
|
<div class="divider new">новые</div>
|
||||||
|
<div class="author">marta <span class="t">12:41</span></div>
|
||||||
|
<div>кто-то с hn спрашивает, где мобильное приложение</div>
|
||||||
|
<div class="author">lev <span class="t">12:42</span></div>
|
||||||
|
<div>ответил: браузер и есть приложение</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="compose">
|
||||||
|
<div class="input"><span class="p">></span><span>сообщение в #general</span><span class="caret"></span><span class="hint">enter — отправить</span></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="label"><b>2b</b> мобильный · 390</div>
|
||||||
|
<div class="screen mobile">
|
||||||
|
<div class="head"><svg class="mark" viewBox="0 0 64 64"><path d="M10 26V10h16"/><path d="M38 10h16v16"/><path d="M54 38v16H38"/><path d="M26 54H10V38"/></svg>#general</div>
|
||||||
|
<div class="feed">
|
||||||
|
<div class="divider">18 авг</div>
|
||||||
|
<div class="msg"><div class="author">marta <span class="t">11:52</span></div><p>выкатила статику на bare.xmatic.team, кэш чистится сам</p><p>вес страницы — 14 кб</p></div>
|
||||||
|
<div class="msg"><div class="author">lev <span class="t">11:58</span></div><p>один html, один css, ноль js до первого сообщения. красиво</p></div>
|
||||||
|
<div class="msg"><div class="author me">kir <span class="t">12:03</span></div><p>это и есть план. если фича требует бандлер — фича не нужна</p></div>
|
||||||
|
<div class="divider new">новые</div>
|
||||||
|
<div class="msg"><div class="author">marta <span class="t">12:41</span></div><p>кто-то с hn спрашивает, где мобильное приложение</p></div>
|
||||||
|
<div class="msg"><div class="author">lev <span class="t">12:42</span></div><p>ответил: браузер и есть приложение</p></div>
|
||||||
|
</div>
|
||||||
|
<div class="compose">
|
||||||
|
<div class="input"><span class="p">></span><span class="ph">сообщение</span><span class="caret"></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -2,11 +2,8 @@
|
|||||||
|
|
||||||
Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда.
|
Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда.
|
||||||
|
|
||||||
- Регистрация: открытая или по инвайтам?
|
|
||||||
- Механика добавления контакта и приглашения в комнату: по нику? по ссылке?
|
|
||||||
- Лимиты: длина сообщения, rate limiting, антиспам.
|
|
||||||
- Смена пароля (= перешифровка ключевого блоба): в v1 или позже?
|
|
||||||
- Идентификация устройства для per-device очередей.
|
|
||||||
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
|
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
|
||||||
- Язык интерфейса (ru/en); нужна ли i18n.
|
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
|
||||||
- Визуальная айдентика: отдельный бриф будет добавлен в `docs/identity/`.
|
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
|
||||||
|
|
||||||
|
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
|
||||||
|
|||||||
+110
@@ -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`: ни тем, ни аватаров, ни «печатает», ни статусов прочтения.
|
||||||
@@ -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: <deviceId>` обязателен на `/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=<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=<deviceId>` → `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`, без аутентификации, для проверок после деплоя.
|
||||||
+138
@@ -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:<peer>" | "room:<roomId>"
|
||||||
|
{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-<nick>-<YYYY-MM-DD>.bare`.
|
||||||
+11
-5
@@ -8,7 +8,7 @@
|
|||||||
|
|
||||||
## От кого защищаем
|
## От кого защищаем
|
||||||
|
|
||||||
**Пассивный оператор сервера.** Админ с полным доступом к базе и диску видит: ники, argon2-хеши, зашифрованные ключевые блобы, метаданные комнат и контактов, транзитную очередь шифротекстов. Плейнтекста у него нет.
|
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет.
|
||||||
|
|
||||||
**Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое.
|
**Сетевой наблюдатель.** 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.
|
**Слабый пароль.** Пароль — материал ключа. Ключевой блоб хранится на сервере, и его стойкость к оффлайн-перебору равна стойкости пароля. Гарантия «оператор не читает сообщения» действует в пределах стойкости пароля пользователя: слабый пароль — слабое E2EE. Это осознанная цена парольного мультидевайса. Контрмеры (ADR-013): PBKDF2-HMAC-SHA256 с не менее чем 600 000 итераций, пароль от 12 символов, рекомендация парольной фразы в UI.
|
||||||
|
|
||||||
@@ -32,6 +36,8 @@
|
|||||||
|
|
||||||
## Осознанные пределы v1
|
## Осознанные пределы v1
|
||||||
|
|
||||||
**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1. Осознанный non-goal v1.
|
**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1 и завёрнутые ключи комнат. Осознанный non-goal v1.
|
||||||
|
|
||||||
|
**Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером.
|
||||||
|
|
||||||
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
|
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
# Интерфейс
|
||||||
|
|
||||||
|
Визуальная система — `docs/identity/brief.md`, эталон экрана чата — `docs/identity/screens.html`. Здесь — состав экранов, поведение и тексты. Все тексты — русские, строчными, как в моке; заглавная только в начале предложений из нескольких слов.
|
||||||
|
|
||||||
|
## Каркас
|
||||||
|
|
||||||
|
Одна страница `index.html`, роутинг по hash: `#/` — список (на десктопе — первый чат), `#/dm/<nick>`, `#/room/<id>`, `#/room/<id>/members`, `#/contact/<nick>`, `#/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>`)
|
||||||
|
|
||||||
|
`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка, старый и новый, кнопка «доверять новому ключу». Кнопка «убрать из списка».
|
||||||
|
|
||||||
|
## Участники (`#/room/<id>/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.
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.3 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><rect width="512" height="512" fill="#1B1917"/><g fill="none" stroke="#F7F5F0" stroke-width="7" transform="translate(96 96) scale(5)"><path d="M10 26V10h16"/><path d="M38 10h16v16"/><path d="M54 38v16H38"/><path d="M26 54H10V38"/></g></svg>
|
||||||
|
After Width: | Height: | Size: 303 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none" stroke="#1B1917" stroke-width="9"><path d="M10 26V10h16"/><path d="M38 10h16v16"/><path d="M54 38v16H38"/><path d="M26 54H10V38"/></svg>
|
||||||
|
After Width: | Height: | Size: 209 B |
Reference in New Issue
Block a user