Merge pull request #2 from xmatic-squad/release/v1

Реализация v1: шесть этапов плана, 39 новых ADR, задеплоено на bare.xmatic.team.
This commit was merged in pull request #2.
This commit is contained in:
Yuriy Mayatnikov
2026-08-23 07:17:41 +03:00
committed by GitHub
145 changed files with 21081 additions and 21 deletions
+3
View File
@@ -4,3 +4,6 @@
# локальная база
*.db
*.db-*
# локальные секреты деплоя
/env
+8 -2
View File
@@ -5,8 +5,14 @@
## Жёсткие ограничения
- Клиент: HTML, CSS, vanilla JS, нативные браузерные API. Никаких фреймворков, npm-зависимостей и сборки — ES-модули как есть.
- Сервер: Go, стандартная библиотека. Допущены ровно три внешних пакета: webpush-go, драйвер SQLite, argon2. Ничего сверх — без обсуждения.
- Криптография на клиенте: только WebCrypto.
- Сервер: Go, стандартная библиотека. Допущены ровно три прямые зависимости: `github.com/SherClockHolmes/webpush-go`, `modernc.org/sqlite`, `golang.org/x/crypto` (argon2). Транзитивные — допускаются. Ничего сверх — без обсуждения.
- Криптография на клиенте: только WebCrypto, строго по `docs/crypto.md` — константы и порядок операций не менять.
- Клиент без inline-стилей, inline-скриптов и `innerHTML`: CSP `default-src 'self'` без исключений.
- Деплой: `ssh xmatic`, `bare.xmatic.team`, `127.0.0.1:8411`, по `docs/deploy.md`.
## Спецификации
`docs/crypto.md`, `docs/protocol.md`, `docs/storage.md`, `docs/ui.md` — обязательны к исполнению наравне с ADR. Порядок работ — `docs/plan.md`. Расхождение кода и документа чинится через ADR, не молча.
## Процесс
+6 -1
View File
@@ -12,11 +12,16 @@ Bare — маленький независимый инструмент, а не
- [Архитектура](docs/architecture.md) — обзор системы
- [Модель угроз](docs/threat-model.md) — от чего защищаемся и от чего нет
- [Решения](docs/decisions/) — ADR по ключевым решениям
- [Криптография](docs/crypto.md), [протокол](docs/protocol.md), [хранение](docs/storage.md) — спецификации для кода
- [Интерфейс](docs/ui.md) и [айдентика](docs/identity/brief.md)
- [Деплой](docs/deploy.md) и [план реализации](docs/plan.md)
- [Открытые вопросы](docs/open-questions.md)
## Статус
Проектирование. Кода ещё нет — сначала документы.
Все шесть этапов `docs/plan.md` сделаны и работают на [bare.xmatic.team](https://bare.xmatic.team): аккаунты, чат 1:1, комнаты с ключом на комнату, TOFU и отпечатки, PWA и пуши, экспорт и импорт истории, лимиты и закалка.
Осталось ручное: прогон сценариев на iOS Safari (установленное на «Домой» приложение), Android Chrome, десктопных Firefox и Safari. Автоматика гоняла только Chrome. Чеклист — в `docs/plan.md`, этап 4.
## Лицензия
+191
View File
@@ -0,0 +1,191 @@
// Команда bare: сервер чата одним бинарём.
//
// bare serve запустить http-сервер
// bare vapid напечатать пару vapid-ключей
// bare version напечатать ревизию сборки
package main
import (
"context"
"crypto/ecdh"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"runtime/debug"
"syscall"
"time"
"github.com/xmatic-squad/bare/internal/api"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/store"
"github.com/xmatic-squad/bare/internal/web"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "serve":
err = serve()
case "vapid":
err = vapid()
case "version":
version()
default:
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "bare:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `bare — сервер чата
использование:
bare serve запустить http-сервер
bare vapid напечатать пару vapid-ключей
bare version напечатать ревизию сборки
настройка — переменные окружения BARE_*, см. docs/deploy.md
`)
}
func serve() error {
cfg, err := config.Load()
if err != nil {
return err
}
static, err := web.New()
if err != nil {
return err
}
st, err := store.Open(cfg.DB)
if err != nil {
return err
}
defer st.Close()
for _, name := range st.Applied() {
fmt.Printf("bare применил миграцию %s\n", name)
}
h := api.New(cfg, st, static, os.Stdout)
// Отправщики пушей дописывают начатое и пишут результат в базу, поэтому
// остановить их надо раньше, чем закроется st. defer выстроен на это:
// h.Close отложен позже st.Close и выполнится раньше него.
defer h.Close()
srv := &http.Server{
Handler: h,
ReadHeaderTimeout: 10 * time.Second,
IdleTimeout: 120 * time.Second,
// OPTIONS * иначе обслуживает net/http сам, в обход middleware:
// ответ уходил бы без заголовков безопасности (ADR-021).
DisableGeneralOptionsHandler: true,
// WriteTimeout не задаётся: впереди SSE с долгими ответами (ADR-004).
}
// Потоки событий не заканчиваются сами: без этого Shutdown ждал бы,
// пока подключённые клиенты уйдут, до самого таймаута (ADR-004).
// Здесь только закрытие потоков: колбэк крутится в своей горутине,
// и Shutdown его не дожидается — дождаться отправки пушей отсюда
// нельзя. Их останавливает h.Close, когда Shutdown уже вернулся
// и обработчики отработали.
srv.RegisterOnShutdown(h.CloseStreams)
// Сначала bind, потом сообщение: строка в журнале означает, что порт занят
// нами, а не то, что мы собирались его занять.
ln, err := net.Listen("tcp", cfg.Addr)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
// Фоновая чистка живёт столько же, сколько сервер (docs/storage.md).
go st.RunCleanup(ctx, func(err error) {
fmt.Fprintln(os.Stderr, "bare:", err)
})
failed := make(chan error, 1)
go func() {
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
failed <- err
}
}()
fmt.Printf("bare слушает %s, origin %s\n", ln.Addr(), cfg.Origin)
// Молчащие пуши — худший вид поломки: снаружи она не видна вовсе.
if cfg.VAPIDPublic == "" || cfg.VAPIDPrivate == "" || cfg.VAPIDSubject == "" {
fmt.Println("bare: пуши выключены — нужны BARE_VAPID_PUBLIC, BARE_VAPID_PRIVATE и BARE_VAPID_SUBJECT")
}
select {
case err := <-failed:
return err
case <-ctx.Done():
}
// Второй сигнал больше не перехватываем: он завершает процесс сразу.
stop()
fmt.Println("bare завершается")
shutdown, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
// vapid печатает пару ключей P-256 в формате, который ждёт webpush-go:
// приватный — 32 байта скаляра, публичный — 65 байт несжатой точки,
// оба base64url без паддинга.
func vapid() error {
priv, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
return err
}
b64 := base64.RawURLEncoding
fmt.Printf("BARE_VAPID_PUBLIC=%s\n", b64.EncodeToString(priv.PublicKey().Bytes()))
fmt.Printf("BARE_VAPID_PRIVATE=%s\n", b64.EncodeToString(priv.Bytes()))
return nil
}
func version() {
fmt.Println(revision())
}
// revision — ревизия сборки. У бинаря из изменённого рабочего дерева
// к ней дописывается «+dirty»: сверка хеша со сборкой из тега — единственное
// смягчение против подмены клиента (docs/threat-model.md), и чистый хеш
// коммита у бинаря с чужими правками сводил бы её на нет (ADR-057).
func revision() string {
info, ok := debug.ReadBuildInfo()
if !ok {
return "unknown"
}
var vcs, modified string
for _, s := range info.Settings {
switch s.Key {
case "vcs.revision":
vcs = s.Value
case "vcs.modified":
modified = s.Value
}
}
if vcs == "" {
return "unknown"
}
if modified == "true" {
return vcs + "+dirty"
}
return vcs
}
+24 -6
View File
@@ -12,7 +12,9 @@ Bare — это PWA-клиент на ванильных веб-технолог
Регистрация — ник и пароль. Ник уникален и является идентификатором пользователя. Без email, телефона, OAuth и интеграций. Восстановления пароля нет.
Серверная аутентификация: Argon2id, сессия в httpOnly cookie.
Пароль не покидает клиент. Из него выводится мастер-ключ, из мастера — два независимых ключа: `authKey` для входа и `kek` для ключевого блоба. Сервер хранит Argon2id от `authKey`; сессия в httpOnly cookie с `SameSite=Strict`, плюс проверка `Origin`. Смена пароля и повышение итераций KDF — одна операция, есть в v1. Удаление аккаунта есть.
Ники — `[a-z0-9_]{2,32}`. Регистрация открыта; оператор может включить общий инвайт-код. Чат 1:1 начинается с ввода ника, согласия не требуется. Блокировок в v1 нет.
Пароль — не короче 12 символов; правил про регистры и спецсимволы нет: длина важнее состава. UI рекомендует парольную фразу из нескольких слов и при регистрации прямо говорит: пароль — это ключ шифрования, а не запись в базе; восстановления нет.
@@ -22,17 +24,21 @@ Bare — это PWA-клиент на ванильных веб-технолог
Идентичность пользователя — ECDH-пара (P-256). Приватный ключ шифруется ключом, выведенным из пароля (PBKDF2-HMAC-SHA256, не менее 600 000 итераций, целевое значение — 1 000 000), и хранится на сервере как блоб — сервер видит только шифротекст. Параметры KDF лежат рядом с блобом и читаются клиентом при входе: их можно повышать без миграции всех аккаунтов разом. Рядом с приватным ключом в блобе живёт случайный 32-байтовый секрет аккаунта — из него выводятся ключи экспорта истории. Отсюда два следствия. Сброс пароля невозможен by design. Мультидевайс-вход прост: новый девайс вводит пароль, скачивает блоб, расшифровывает ключ.
Чаты 1:1: ECDH shared secret → AES-GCM.
Публичные ключи раздаёт сервер, доверие — TOFU: клиент запоминает ключ при первом контакте, смена ключа блокирует отправку до явного подтверждения по отпечатку. Подписей сообщений нет; отправителя проставляет сервер из сессии.
Комнаты: у комнаты симметричный ключ, он раздаётся участникам зашифрованным на их публичные ключи. При изменении состава — rekey. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
Чаты 1:1: ECDH shared secret → HKDF → AES-GCM.
Комнаты: у комнаты симметричный ключ со случайным `keyId`, завёрнутый каждому участнику на ECDH. Завёрнутые ключи сервер хранит постоянно (шифротекст) и отдаёт участнику все, что держит, — два последних: новое устройство получает действующий ключ, а вернувшееся из офлайна — ещё и пропущенный, которым зашифровано лежащее в его очереди (ADR-059). Состав меняет владелец; смена состава и rekey — один атомарный запрос. Новый участник не видит сообщений до своего вступления — их и не существует нигде, кроме устройств участников.
Все процедуры побайтно — `docs/crypto.md`.
Forward secrecy — осознанный non-goal v1.
## Хранение
Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов, транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено.
Сервер хранит только три вещи: аккаунты (ник, argon2-хеш, зашифрованный ключевой блоб), метаданные комнат и контактов (включая завёрнутые ключи комнат), транзитную очередь зашифрованных недоставленных сообщений. Очередь per-device: устройство — случайный идентификатор, который клиент создаёт при первом входе; доставлено и подтверждено ACK — удалено с сервера; не забрано за 30 дней — удалено. Схема — `docs/storage.md`, протокол — `docs/protocol.md`.
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений, виртуализация списка в DOM.
Клиент хранит историю в IndexedDB. messageId — ULID/UUIDv7: хронологическая сортировка и идемпотентный merge. Составной индекс (chatId, messageId). Пагинация курсором по ~50 сообщений; загруженное держится в DOM целиком, виртуализации нет (ADR-053).
При старте клиент запрашивает `navigator.storage.persist()` и показывает занятое место через `storage.estimate()`.
@@ -44,6 +50,10 @@ Forward secrecy — осознанный non-goal v1.
Это единственный механизм переноса истории между устройствами. Осознанно.
## Транспорт
Конверт сообщения: открытые `id` (ULID клиента, расхождение с часами сервера не больше 5 минут), адресат, отправитель (ставит сервер), `keyId`, `iv`, `ct`, серверное время. Открытые поля привязаны к шифротексту через AAD. Приём — SSE с воспроизведением очереди при каждом подключении и ACK после записи в IndexedDB; отправка — `fetch POST`.
## Пуши
Web Push + VAPID. Одна пара ключей, никаких регистраций и оплат у вендоров, никакого Firebase SDK.
@@ -52,7 +62,15 @@ Web Push + VAPID. Одна пара ключей, никаких регистр
iOS: пуши работают только у PWA, установленного на экран «Домой», поэтому онбординг-баннер установки — обязательная часть продукта. Разрешение на уведомления запрашивается после осмысленного действия (первое отправленное сообщение), не при входе.
Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки.
Подписка принадлежит устройству. Пуш уходит, только если устройство не подключено по SSE и у него нет неотработанного пуша: одно молчащее устройство — один пуш. Сервер обрабатывает 404/410 от push-сервисов и чистит мёртвые подписки.
## Развёртывание
TLS терминирует nginx на том же сервере, Bare слушает `127.0.0.1:8411` под systemd. Статика встроена в бинарь. Сборка — кросс-компиляция без cgo. Подробности — `docs/deploy.md`.
## Интерфейс
Айдентика «Скобы», системный моноширинный шрифт, одна светлая тема, русский язык без i18n. Экраны — `docs/ui.md`.
## Scope v1
+119
View File
@@ -0,0 +1,119 @@
# Криптография
Все операции — 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 до 10 000 000 (ADR-013, ADR-030). Границы держат обе стороны: сервер не принимает блоб с `iter` вне них, клиент проверяет пришедшее число до `deriveBits` и не считает по нему ничего. 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)`, без исключений в коде.
Ключ, чей `from` не входит в состав комнаты, пришедший тем же `Room`, отвергается до запроса публичного ключа: TOFU запоминает первый ключ ника молча, поэтому незнакомый распространитель — это подмена, а не первый контакт (ADR-039).
## Сообщение
```
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 ещё нет.
Чужая магия и незнакомая версия — файла в заголовке или нагрузки в поле `v` — показываются тем же текстом, что и порча: «файл повреждён». Третьего текста нет (ADR-054). Форму записей внутри нагрузки клиент проверяет сам — `docs/storage.md`, «Экспорт `.bare`».
## Идентификаторы
- ULID: 48 бит миллисекунд + 80 бит случайности, Crockford base32, 26 символов. Внутри одной миллисекунды на одном клиенте случайная часть инкрементируется.
- `deviceId`, `keyId`, `roomId` — 16 случайных байт base64url (22 символа).
- Сессионный токен — 32 случайных байта, на сервере хранится `SHA-256`.
## Что сервер проверяет, а что нет
Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму: base64url, длины (`iv` = 12 байт, `ct` не короче 16), существование `keyId` для комнаты, формат ULID и его время.
+2
View File
@@ -1,5 +1,7 @@
# ADR-002: Сервер на Go, один бинарь
Уточнён [ADR-020](020-storage-schema-and-driver.md): три прямые зависимости, транзитивные допускаются; драйвер — `modernc.org/sqlite`. Развёртывание — [ADR-022](022-deploy-nginx-systemd.md).
## Контекст
Серверу Bare нужно немного: HTTP, SSE, SQLite, хеширование паролей, Web Push. Простота развёртывания и аудита важнее богатства экосистемы.
+2
View File
@@ -1,5 +1,7 @@
# ADR-005: Аккаунт — ник и пароль
Уточнён [ADR-015](015-password-never-leaves-client.md): на сервер уходит не пароль, а выведенный из него `authKey`. Открытость регистрации закрыта [ADR-019](019-registration-and-contacts.md).
## Контекст
Email, телефон и OAuth тянут за собой внешние сервисы, интеграции и утечку идентичности. Bare — независимый инструмент без внешних завязок.
+2
View File
@@ -1,5 +1,7 @@
# ADR-006: E2EE на WebCrypto, ключ за паролем
Уточнён [ADR-015](015-password-never-leaves-client.md) (два ключа из мастера) и [ADR-016](016-key-trust-tofu.md) (доверие к публичным ключам).
## Контекст
Оператор не должен уметь читать сообщения. Крипто-библиотеки на клиенте противоречат нулю зависимостей и аудируемости — вся криптография должна быть нативной.
+2
View File
@@ -1,5 +1,7 @@
# ADR-007: Симметричный ключ комнаты и rekey
Конкретизирован [ADR-018](018-rooms-membership-rekey.md): владелец, случайный `keyId`, атомарный rekey, постоянное хранение завёрнутых ключей.
## Контекст
Сообщение в комнате должны читать все участники, но не сервер. Шифровать каждое сообщение отдельно каждому участнику — квадратичный объём работы и трафика.
+2
View File
@@ -1,5 +1,7 @@
# ADR-008: Сервер — реле с per-device очередью
Уточнён [ADR-017](017-devices-and-envelope.md) (идентификация устройства, конверт, ACK) и [ADR-018](018-rooms-membership-rekey.md): к метаданным комнат относятся завёрнутые ключи.
## Контекст
Сервер никогда не является местом, где живёт история (философия, п. 2). Но получатель бывает офлайн — сообщение надо где-то подержать до доставки.
+2
View File
@@ -1,5 +1,7 @@
# ADR-009: История — только на устройстве, в IndexedDB
Уточнён [ADR-053](053-feed-pages-without-virtualization.md): виртуализация списка в DOM снята, пагинация курсором по 50 в силе.
## Контекст
История, живущая на сервере, делает сервер архивом и целью атак. У Bare история — собственность устройства.
+2
View File
@@ -1,5 +1,7 @@
# ADR-011: Web Push + VAPID, пуш — сигнал
Правила отправки и service worker — [ADR-023](023-push-and-service-worker.md).
## Контекст
Без уведомлений чат бесполезен. Firebase SDK и вендорские кабинеты — зависимость и завязка, несовместимые с философией.
@@ -0,0 +1,23 @@
# ADR-015: Пароль не покидает клиент — два ключа из одного мастера
Уточнён [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает и потоки событий отозванных сессий; [ADR-062](062-kdf-answer-and-nick-existence.md): `GET /api/kdf` не обещает скрывать существование ника.
## Контекст
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.
+20
View File
@@ -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,24 @@
# ADR-018: Комнаты — владелец, состав и атомарный rekey
Уточнён [ADR-039](039-room-key-sender-is-member.md) (кто вправе раздавать ключ комнаты), [ADR-041](041-needs-rekey-is-state.md) (`needsRekey` — состояние комнаты, а не свойство события), [ADR-042](042-current-room-key-order.md) (какой ключ считается текущим) и [ADR-059](059-room-keys-kept-are-handed-out.md) (участник получает все удерживаемые ключи, а не только текущий).
## Контекст
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,32 @@
# ADR-021: Сессии, CSRF, Argon2id и лимиты
Уточнён [ADR-055](055-limit-keys-and-bounds.md): пакеты правил, состав «остальных изменяющих», место лимита в порядке проверок, границы карт и доверие к `X-Real-IP`; [ADR-058](058-logout-closes-stream.md): смена пароля с `logoutOthers` закрывает потоки событий отозванных сессий, а не только удаляет их строки.
## Контекст
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. Имя комнаты вдобавок не бывает пустым, из одних пробелов, с управляющими символами и с переопределениями направления письма (U+202A…U+202E, U+2066…U+2069): с этапа 4 оно уходит в заголовок системного уведомления (ADR-045), а там перевод строки и разворот текста выдают чужое имя за сообщение системы.
## Следствия
- Сторонний сайт не может ни отправить сообщение, ни выйти из аккаунта от имени пользователя.
- Лимиты живут в памяти: рестарт их обнуляет. Для маленького сервера это приемлемо.
- 90-дневный вход без продления — раз в квартал пароль вводится заново на каждом устройстве.
@@ -0,0 +1,22 @@
# ADR-022: Деплой — nginx, systemd, кросс-сборка
Уточнён [ADR-032](032-state-permissions.md) (`StateDirectoryMode` и `UMask` в юните), [ADR-056](056-nginx-access-log-off.md) (`access_log off`) и [ADR-057](057-version-marks-dirty-tree.md) (`bare version` помечает сборку из изменённого дерева).
## Контекст
Целевой сервер (`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,22 @@
# ADR-023: Правила пушей и service worker
Кому уходит пуш и тексты уведомления — [ADR-045](045-push-addressed-to-recipient.md). Адрес перехода, состояния настроек и жизнь подписки на клиенте — [ADR-046](046-push-client.md).
## Контекст
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`, `/manifest.json`, `/js/*`, `/icons/*`), никогда — для `/api/*`. Имя кэша содержит версию, версия задаётся константой в `sw.js` и меняется при релизе. Сервер отдаёт статику с `ETag` и `Cache-Control: no-cache`.
- Разрешение на уведомления запрашивается после первого отправленного сообщения (ADR-011). На iOS вне установленного PWA вместо запроса показывается баннер установки.
## Следствия
- Сервер знает только, что у устройства есть что забрать; содержимое в пуше не появляется.
- Пользователь с пятью непрочитанными чатами получает один пуш про первый. Остальное — при открытии. Осознанно.
- Релиз без смены версии в `sw.js` обновит статику только по ETag при следующем revalidate, не мгновенно.
+21
View File
@@ -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 — растеризованный знак, лежат в репозитории как бинарные файлы; это ассеты, не сборка.
+17
View File
@@ -0,0 +1,17 @@
# ADR-025: Встраивание статики объявляется в корне модуля
## Контекст
ADR-022 требует один артефакт деплоя: клиентская статика вкомпилирована в бинарь. Раскладка в `docs/plan.md` кладёт отдачу статики в `internal/web/`, а сам клиент — в `web/` в корне. Директива `//go:embed` встраивает только файлы каталога своего пакета и ниже: из `internal/web/` до корневого `web/` не дотянуться. Варианты — перенести клиент внутрь `internal/web/`, продублировать файлы или объявить встраивание в корне.
## Решение
Клиент остаётся в `web/` в корне: путь в репозитории совпадает с путём в URL, и его видно первым в дереве. Встраивание объявляется рядом — файл `embed.go` в корне модуля, `package bare`, `//go:embed web` и `var Web embed.FS`. Логики в пакете нет, только объявление.
`internal/web/` получает подкаталог через `fs.Sub(bare.Web, "web")` и отвечает за отдачу: ETag, `Cache-Control`, `Content-Type`, 304, `Service-Worker-Allowed`.
## Следствия
- В корне модуля появляется пакет `bare` из одного файла — он не растёт: всё, что не объявление `embed.FS`, идёт в `internal/`.
- `internal/web/` импортирует корневой пакет; обратной зависимости нет и не будет.
- Раскладка в `docs/plan.md` дополнена строкой `embed.go`; в остальном не меняется.
@@ -0,0 +1,17 @@
# ADR-026: Код `too_large` и 404 на неподдерживаемый метод
## Контекст
Этап 0 обнажил два места, где код знает больше протокола. Первое: `413` описан в общих правилах (`ADR-021`, «тело запроса — до 32 КиБ»), но кода ошибки для него в перечне `protocol.md` нет, а сервер уже отдаёт `{"error": "too_large"}`. Второе: `POST` к известному пути статики отвечает `404 not_found`; в перечне правил есть только «неизвестный путь — `404 not_found`», решение про метод жило комментарием в коде.
## Решение
- `413` отдаётся с кодом `too_large`. Код добавлен в перечень «Коды ошибок» `protocol.md`, строка про лимит тела уточнена до `413 too_large`.
- Неподдерживаемый метод на известном пути — тоже `404 not_found`. Кода `405` в протоколе нет и не появится: клиент ходит по фиксированному набору маршрутов, а лишний код — лишняя ветка у обеих сторон.
- Тело ошибки в форме `{"error", "message"}` собирает `internal/api`; остальные пакеты пользуются его хелпером, чтобы коды не расходились между пакетами.
## Следствия
- Клиент разбирает `error` по перечню из `protocol.md`, и перечень исчерпывающий.
- Отсутствие `405` означает, что перебор методов не отличается от перебора путей — снаружи виден только `404`.
- `internal/web` импортирует `internal/api`; обратной зависимости нет.
+18
View File
@@ -0,0 +1,18 @@
# ADR-027: Код `internal` для сбоя на стороне сервера
## Контекст
[ADR-026](026-protocol-error-codes.md) сделал перечень кодов ошибок в `protocol.md` исчерпывающим: клиент разбирает поле `error`, а не статус. Кода для `500` в перечне нет, а сбои существуют — недоступная база, ошибка записи. Этап 1 упёрся в это на первом же запросе к хранилищу: отвечать телом без кода нельзя, придумывать код в коде молча — тоже.
## Решение
- Сбой на стороне сервера — `500` с кодом `internal`. Сообщение общее и не зависит от причины.
- Причина уходит только в журнал сервера: ни текст ошибки базы, ни имена таблиц, ни ник клиенту не показываются. Наружу — код и статус.
- Код добавлен в перечень «Коды ошибок» `protocol.md` и в общие правила.
- `internal` — не ветка протокола, а признак поломки: ни один сценарий клиента на него не рассчитывает, повтор запроса допустим.
## Следствия
- Перечень кодов снова исчерпывающий: у любого ответа сервера есть разбираемый код.
- Оператор видит причину в `journalctl`, клиент — нет.
- `500` в журнале означает ошибку в коде или в окружении и разбирается, а не считается нормой.
+34
View File
@@ -0,0 +1,34 @@
# ADR-028: Тексты состояний клиента
## Контекст
`docs/ui.md` задаёт пять ошибок формы входа и тексты экранов. Этап 1 упёрся в состояния, которых в этих перечнях нет, а показать их надо:
- запрос не дошёл (сети нет, сервер молчит) и ответ с кодом, на который у клиента нет сценария, — `500 internal` (ADR-027), `429`, `too_large`;
- ключевой блоб не разбирается или не расшифровывается;
- `iter` в блобе расходится с ответом `GET /api/kdf``docs/crypto.md` прямо требует показать это ошибкой;
- пароль короче 12 символов: проверить длину может только клиент, сервер пароля не видит (ADR-013, ADR-015);
- в настройках — несовпадение нового пароля с повтором, подтверждение опасной операции не тем паролем, ответ об успешной смене пароля.
Придумывать эти строки в коде молча нельзя: тексты — часть интерфейса, а не деталь реализации.
## Решение
Перечни `docs/ui.md` дополняются разделом «Тексты состояний». Правила прежние: строчные, коротко, говорят, что случилось. Ошибка — строкой цветом `mark`, ответ об успехе — той же строкой цветом `mute`.
- «нет соединения» — запрос не дошёл. Тот же текст, что у полосы в чате: состояние одно.
- «сервер не справился, попробуйте позже» — код ответа, на который у клиента нет сценария.
- «слишком часто, попробуйте позже» — `429 rate_limited`.
- «пароль: не короче 12 символов» — проверка клиента при регистрации и смене пароля.
- «пароли не совпадают» — новый пароль и повтор различаются.
- «ключ аккаунта повреждён» — блоб не разобран, не расшифрован или не соответствует публичному ключу аккаунта.
- «параметры ключа не совпали» — `iter` блоба не равен ответу `GET /api/kdf`.
- «неверный пароль» — `401 invalid_credentials` в настройках, где ник заведомо свой.
- «пароль изменён» — ответ на успешную смену.
- «аккаунт и вся история будут удалены навсегда.» — подтверждение удаления аккаунта.
## Следствия
- `docs/ui.md` остаётся единственным местом, где живут тексты интерфейса.
- Клиент разбирает `error` по перечню `docs/protocol.md`; всё, чего в перечне нет, и всё, что случилось до ответа, сводится к двум строкам — «нет соединения» и «сервер не справился, попробуйте позже».
- Новый экран приносит свои тексты в `docs/ui.md` тем же порядком: сначала документ, потом код.
@@ -0,0 +1,21 @@
# ADR-029: Вход под другим ником стирает историю только после подтверждения
## Контекст
`docs/storage.md` держит правило «один аккаунт на браузерный профиль»: база стирается целиком, потому что история на устройстве — единственная копия. Стирание там разрешено только после подтверждения, и `docs/ui.md` описывает это подтверждение ровно в одном месте — у кнопки «выйти» в настройках.
Экран входа в это правило не попал, а достижим с целой базой: по `docs/ui.md` («Сеть и состояния») `401` выбрасывает на экран входа и намеренно оставляет IndexedDB нетронутой. Ввод другого ника в форму входа или регистрации уносил всю историю прежнего аккаунта молча, до единого вопроса.
Просто отказать во входе под другим ником нельзя: с экрана входа выйти из прежнего аккаунта нечем — сессии уже нет, настройки недоступны. Отказ запер бы устройство.
## Решение
- Если на устройстве лежат ключи другого ника, экран входа спрашивает подтверждение до вычисления ключа: «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена». Текст — в `docs/ui.md`, «Вход и регистрация».
- Подтверждение — согласие, а не стирание: база уносится там же, где и раньше, — после успешного входа или регистрации. Отказ сервера ничего не удаляет.
- Правило «один аккаунт на браузерный профиль» остаётся. Меняется одно: молчаливого стирания нет ни на одном экране.
## Следствия
- Единственная копия истории не исчезает без вопроса ни в одном сценарии.
- Кнопки «экспортировать» в этом подтверждении нет: экспорта нет вовсе до этапа 5. Когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`.
- Сравнивается ник, а не отпечаток: перерегистрация под тем же ником вопроса не вызовет. Смена ключа у знакомого ника — предмет TOFU (ADR-016), этап 3.
@@ -0,0 +1,22 @@
# ADR-030: Верхняя граница итераций KDF и проверка границ на клиенте
Уточняет [ADR-013](013-password-policy-kdf.md): нижняя граница остаётся, к ней добавляется верхняя.
## Контекст
ADR-013 задаёт целевое число итераций PBKDF2 и нижнюю границу, верхней нет. `iter` — единственное поле ключевого блоба, которое сервер разбирает сам и потом сам же раздаёт клиентам через `GET /api/kdf`, то есть отвечает за его вменяемость. Регистрация одним запросом с `iter = 10^12` принималась: аккаунт после этого нельзя ни открыть, ни удалить — обе операции начинаются с PBKDF2, который не заканчивается.
С другой стороны, клиент брал число итераций из `GET /api/kdf` и `GET /api/config` как есть и считал по нему `authKey`, который тут же уходит на сервер. Нижнюю границу не проверял никто, кроме сервера, и только у блоба — а PBKDF2 считает клиент, и проверить параметр перед вычислением может только он.
## Решение
- Границы числа итераций — от 600 000 до 10 000 000. Верхняя — порядок над целевым значением 1 000 000: запас на повышение и предел, за которым вход перестаёт заканчиваться.
- Сервер отвергает ключевой блоб с `iter` вне границ: `400 invalid`, `field: blob`.
- Клиент проверяет границы до `deriveBits`: и число из `GET /api/kdf` и `GET /api/config`, и `iter` при разборе блоба. Число от сервера вне границ — «параметры ключа не совпали»; `iter` блоба вне границ — «ключ аккаунта повреждён», как любой другой дефект его формы (ADR-028).
- Границы записаны в `docs/crypto.md` рядом с целевым значением.
## Следствия
- Аккаунт с неоткрываемым `iter` завести нельзя.
- Ослабить KDF ответом `/api/kdf` тоже нельзя: границу держат обе стороны, и клиентская стоит раньше вычисления. Активно-злонамеренный оператор остаётся вне модели угроз — он подменит и сам клиент.
- Поднять целевое значение выше верхней границы без правки границы не выйдет. Это и требуется: такое повышение — решение, а не настройка.
@@ -0,0 +1,22 @@
# ADR-031: Служебный выход перед повторным входом
## Контекст
Ключевой блоб отдаёт только `POST /api/login`: `GET /api/me` его не возвращает, отдельного эндпоинта в `docs/protocol.md` нет. Поэтому смена пароля проходит через вход. Вход заводит новую сессию и перезаписывает cookie, а cookie — `HttpOnly`: прежний токен после этого недостижим, закрыть ту сессию клиенту уже нечем. Оставлять её живой нельзя — украденная cookie пережила бы смену пароля, ради которой всё и затевалось. Значит, выход идёт первым, до входа.
Но `POST /api/logout` — обычный непубличный запрос, и `401 unauthenticated` на нём по `docs/ui.md` («Сеть и состояния») выбрасывает на экран входа. Отсюда отказ. Смена пароля с неверным старым паролем закрывает сессию и падает на входе; клиент остаётся на настройках и показывает «неверный пароль». Вторая попытка, уже с верным паролем, начинается с того же служебного выхода, получает `401` — и уходит на экран входа молча: ошибка пишется в узел, которого в документе уже нет. Удаление аккаунта после такой попытки получает `401` на `DELETE /api/me` и тоже уезжает на экран входа, ничего не удалив.
## Решение
- Смена пароля и удаление аккаунта начинаются со служебного выхода, потом входят заново. Порядок «выход → вход» не оставляет на сервере сессию, токена от которой нет ни у кого.
- Служебный выход не заканчивает сеанс для пользователя. `401 unauthenticated` на нём означает «сессии и так нет» и считается успехом: следующий шаг открывает новую. Обработчик истёкшей сессии на таком ответе не зовётся, ошибка не бросается. Прочие отказы — нет сети, `500` — поднимаются наверх и показываются как есть: при живой сессии входить заново нельзя.
- Мимо обработчика идёт ровно этот вызов. `401 unauthenticated` на любом другом запросе по-прежнему ведёт на экран входа с сохранением IndexedDB.
- Удаление аккаунта входит прямым `POST /api/login`, без разбора блоба: аккаунт с испорченным блобом обязан удаляться.
- Кнопка «выйти» пользуется тем же вызовом: сеанс там заканчивает сам клиент — стирает базу и рисует экран входа, — а не ответ сервера.
## Следствия
- Сорвавшаяся смена пароля оставляет клиент без сессии, но на своём экране и со своей строкой: «неверный пароль», «нет соединения». Следующая попытка — смена пароля или удаление аккаунта — начинается с того же служебного выхода и проходит целиком.
- Перезагрузка страницы в этом состоянии показывает экран входа: `GET /api/me` отвечает `401`, IndexedDB цела. Это обычный сценарий истёкшей сессии, отдельного обхождения не требует.
- Сервер не меняется: `POST /api/logout` и `POST /api/login` работают как записано в `docs/protocol.md`.
- В `docs/ui.md` правило уточняется до `401 unauthenticated`: `401 invalid_credentials` — ошибка формы, на экран входа она не выбрасывала и раньше.
+22
View File
@@ -0,0 +1,22 @@
# ADR-032: Права на каталог состояния и файлы базы
Уточняет [ADR-022](022-deploy-nginx-systemd.md): к юниту добавлены `StateDirectoryMode` и `UMask`.
## Контекст
`docs/deploy.md` задавал владельца `/var/lib/bare`, но не режим. `StateDirectory=bare` создаёт каталог с режимом 0755, SQLite кладёт базу с 0644 — на целевой машине, где живут ещё десяток сайтов и чужие сервисы, файл базы читал любой локальный пользователь.
В базе нет плейнтекста, но есть `argon2id(authKey)` и ключевые блобы. Модель угроз прямо называет стойкость блоба к оффлайн-перебору равной стойкости пароля: раздавать этот материал соседям по машине незачем. Пункт «кража базы или бэкапа» подразумевает злоумышленника, а не любого пользователя системы.
## Решение
- Каталог `/var/lib/bare` — режим 0700, владелец `bare`. В юните `StateDirectoryMode=0700`, чтобы это переживало рестарт.
- Файлы базы — 0600. В юните `UMask=0077`: `bare.db`, `-wal` и `-shm` создаются закрытыми.
- `/etc/bare` — 0700, `/etc/bare/env` — 0600, владелец `root`: там VAPID-ключи.
- Бэкап наследует те же права; `VACUUM INTO` пишет в тот же каталог.
## Следствия
- Локальный пользователь без root не читает ни базу, ни секреты окружения.
- От оператора машины это не защищает и не должно: он остаётся вне модели угроз.
- Восстановление из бэкапа требует восстановить и права; строка про это есть в `docs/deploy.md`.
@@ -0,0 +1,24 @@
# ADR-033: Текст отказа у неотправленного сообщения
Уточнён [ADR-036](036-resend-keeps-ulid.md) (новая попытка заводит запись без поля `error`) и [ADR-060](060-unknown-device-keeps-pending.md) (`403 unknown_device` — не отказ сообщению).
## Контекст
`docs/storage.md` задаёт судьбу исходящего: `202``sent`, сетевая ошибка → остаётся `pending`, `4xx``failed` «с текстом ошибки». Поля для этого текста в записи `messages` нет — есть только `status`.
`docs/ui.md` описывает вторую половину так же наполовину. У сообщения в ленте есть пометка «не отправлено · повторить», одна на все причины. `clock_skew` записан в «Сеть и состояния» с текстом «проверьте часы на устройстве: расхождение больше 5 минут», но где он показывается — не сказано, а показать его негде: пометка у сообщения фиксирована, строка состояния формы (ADR-028) в чате не живёт.
Этап 2 упёрся в это на первой же отправке. Причина отказа известна ровно в момент ответа сервера, а сообщение живёт дальше и переживает перезагрузку страницы.
## Решение
- Запись `messages` получает необязательное поле `error: string` — текст отказа, из-за которого сообщение стало `failed`. Тексты берутся из тех же перечней, что и у форм: коды `docs/protocol.md` и строки ADR-028. Новых строк интерфейса это решение не заводит.
- Поле живёт только у `failed`. Новая попытка отправки заводит запись с новым ULID и без него.
- Текст показывается полосой над вводом цветом `mark` — там же, где «нет соединения» и предупреждение о ключе. Место одно, как требует ADR-028; пометка «не отправлено · повторить» у самого сообщения не меняется.
- Поле служебное: на сервер не уходит и в архив `.bare` не пишется, как и `raw`.
## Следствия
- `clock_skew` наконец видно: расхождение часов объясняется словами, а не молчаливым «не отправлено».
- Текст переживает перезагрузку вместе с сообщением: он часть записи, а не состояние экрана.
- Причин у полосы над вводом становится три — нет соединения, ключ изменился, отказ отправки. Больше одной сразу не показывается: полоса одна.
+22
View File
@@ -0,0 +1,22 @@
# ADR-034: Входящее сообщение с известным id не перезаписывает запись
## Контекст
`docs/storage.md` описывал повтор доставки одной строкой: «`put` с тем же `id`, без дублей». Подразумевался тот же самый конверт — сервер выдаёт очередь заново при каждом подключении и вправе прислать конверт дважды (ADR-017). Клиент так и делал: писал `put` по `id` безусловно.
`id` — открытое поле конверта, собеседник видит его сразу, как получает сообщение. Истории идентификаторов сервер не хранит: это противоречило бы ADR-008, — поэтому `POST /api/messages` с чужим `id` он принимает и раскладывает по очередям как любой другой. AAD сходится: в него входят `id`, `chat`, `from` и `keyId`, а `from` сервер ставит из сессии — конверт собеседника валиден и расшифровывается. Дальше `put` перезаписывал мою строку: в ленте вместо моего сообщения оказывался чужой текст, исходное исчезало, и фан-аут разносил подмену на остальные мои устройства. Тем же приёмом собеседник стирал и то, что прислал сам, — это прямо противоречит модели угроз: «„Удалить у всех“ после доставки не существует».
Вторая половина того же места — счётчик. Все чтения `messages` выпускались до первого `put`, поэтому две копии одного конверта в одной пачке обе считались новыми и `unread` рос дважды. Такая пачка — не гипотеза: конверт, попавший в очередь в момент подключения, приходит и выдачей очереди, и живым событием.
## Решение
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных. Строку, которая уже лежит на устройстве, входящий конверт не трогает.
- Повторный `id` внутри одной пачки учитывается один раз.
- Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`, где `id` свой и запись своя.
- Правило записано строкой в `docs/storage.md`.
## Следствия
- Знание `id` не даёт собеседнику ничего: подменяющий конверт у получателя молча пропадает.
- Нерасшифрованное чинится не повторной доставкой, а полем `raw` — оно для этого и хранится (`docs/storage.md`).
- Дубль доставки, разрешённый ADR-017, больше не двигает счётчик непрочитанных.
@@ -0,0 +1,22 @@
# ADR-035: Один поток событий на браузерный профиль
## Контекст
Устройство определяется браузерным профилем (ADR-017), а `docs/protocol.md` держит одно соединение на устройство: новое закрывает предыдущее. Про несколько вкладок одного профиля не сказано нигде, и клиент открывал `EventSource` в каждой.
Две вкладки одного аккаунта отбирали поток друг у друга бесконечно: сервер закрывал предыдущее соединение, браузер переподключался через три секунды и закрывал соседнее. Замер — семь соединений за двадцать секунд, каждое ровно по три секунды, и после каждого `ready` ещё `GET /api/contacts` и повтор `pending`. Живой доставки при этом нет ни у одной вкладки: сообщения приходят только выдачей очереди, полоса состояния мигает, сервер получает два десятка запросов в минуту на пользователя — и так, пока открыты обе вкладки.
## Решение
- Поток открывает одна вкладка профиля — та, что держит замок `navigator.locks` с именем `bare-stream`. Замок берётся на всё время работы синхронизации и отпускается при выходе и при закрытии вкладки; следующая вкладка получает его сразу и открывает поток.
- Остальные вкладки потока не открывают. Экраны, чтение базы и отправка у них работают как прежде: `POST` потока не требует.
- Вкладки рассказывают друг другу об изменениях через `BroadcastChannel`: то же, что вкладка раздаёт своим экранам, — изменения лент, список чатов, состояние сети. Пишет в базу каждая сама, поэтому рассылают все, а не только владелец.
- Неотправленное повторяет только владелец потока: иначе одно сообщение ушло бы дважды, с разными ULID.
- Оба API нужны вместе: замок выбирает владельца, канал раздаёт его находки. Нет хотя бы одного — вкладка работает как единственная, то есть как до этого решения.
## Следствия
- Соединений к серверу столько, сколько браузерных профилей, а не открытых вкладок.
- Вкладка-наблюдатель показывает то же, что владелец, с задержкой в один `postMessage`.
- Новых текстов интерфейса решение не заводит: наблюдатель видит те же полосы, что владелец.
- Зависимостей не прибавляется: `navigator.locks` и `BroadcastChannel` — нативные браузерные API (ADR-001).
+33
View File
@@ -0,0 +1,33 @@
# ADR-036: Повтор отправки сохраняет ULID
## Контекст
ADR-017 присваивает ULID в момент попытки отправки, а не набора: сервер принимает сообщение, только если время в идентификаторе расходится с его часами не больше чем на пять минут. `docs/storage.md` довёл это до правила «при каждой попытке отправки `pending` получает новый ULID»: старая запись удалялась, новая писалась.
У правила есть цена. `POST /api/messages` кладёт конверт в очередь и только потом отвечает `202`. Ответ теряется: обрыв на мобильной сети, закрытая вкладка, `502` от прокси. Сервер сообщение принял и разослал, клиент считает его неотправленным, оставляет `pending` и после следующего `ready` шлёт заново — уже с другим идентификатором. Собеседник видит один и тот же текст дважды, двумя разными записями, и склеить их нечем: `id` у них разные. Обрыв сразу после отправки — обычное дело на телефоне, а дубль остаётся в истории навсегда.
Второй половины проблемы больше нет. ADR-034 заставил получателя игнорировать входящее с уже известным `id` целиком: ни записи, ни счётчика. Значит повтор с тем же идентификатором безвреден — сервер положит конверт в очередь (`ON CONFLICT DO NOTHING` либо новая строка, если прежнюю уже подтвердили), получатель его молча пропустит и подтвердит. Менять `id` нужно ровно тогда, когда прежний перестал годиться серверу.
У переиспользования есть своя цена. Возраст идентификатора клиент считает по своим часам, сервер — по своим. Клиент отстаёт на две минуты, сообщение пролежало `pending` три с половиной: клиент видит запас нетронутым, сервер видит пять с половиной и отвечает `400 clock_skew` — тогда как прежнее правило дало бы свежий `id` с расхождением в две минуты и `202`. Кнопка «повторить» при этом бесполезна первые минуты: она берёт тот же `id` и получает тот же отказ, пока возраст не перевалит за запас. Оставить это пользователю нельзя: часы отстают на пару минут у любого устройства, которое давно не сверялось со временем, а сообщение при этом не уходит вовсе.
## Решение
- Повтор отправки идёт с прежним ULID. Запись не удаляется и не заводится заново: меняется только её состояние.
- Новый ULID берётся, когда время прежнего разошлось с текущим больше чем на четыре минуты. Тогда работает прежний порядок: старая запись удаляется, новая пишется.
- Запас — минута под серверным окном ±5 минут: за неё успевают шифрование, очередь работ клиента и сама сеть, так что дошедший запрос застаёт окно ещё открытым.
- Первая отправка ULID генерирует, как и раньше.
- Клиент сравнивает время идентификатора со своими часами: других у него нет, и первый ULID берётся из них же.
- `clock_skew` на переиспользованном идентификаторе отменяет переиспользование: прежний `id` снимается, попытка идёт второй раз со свежим. Ровно один раз — это та самая ситуация, ради которой `id` и меняется. Отказ на свежем `id` означает, что часы врут по-настоящему: сообщение становится `failed` с текстом про часы (ADR-033), второго круга нет.
- Сохранённый `id` оставляет и прежнее `ts`: время показа идёт за идентификатором, пока `202` не принесёт серверное.
- Правило записано строкой в `docs/storage.md` вместо прежнего.
## Следствия
- Потерянный ответ на `POST` больше не оборачивается дублем: повтор приходит собеседнику с тем же `id` и молча пропадает у него по ADR-034.
- Остаточный случай остаётся. Если ответ потерялся, а повтор случился позже окна — вкладку закрыли на час, устройство ушло в офлайн, — идентификатор сменится, и дубль появится. Иначе нельзя: сервер такое сообщение не примет вовсе. Вероятность теперь ничтожна, а раньше дубль давал любой обрыв.
- Экран не мигает. `removed` в уведомлении пуст, лента находит сообщение по прежнему `id` и перерисовывает одну строку вместо всей ленты.
- Умеренно врущие часы пользователь не разбирает. Расхождение, которое сервер видит только из-за переиспользования, снимает вторая попытка со свежим `id`; полоса про часы остаётся за настоящим расхождением — тем, что больше пяти минут и от идентификатора не зависит.
- Уточняется ADR-017: «ULID присваивается в момент попытки отправки» верно для идентификатора старше запаса. Более свежий переживает попытку, и время в нём — время первой из них.
- Уточняется ADR-033: новая попытка заводит запись без поля `error`, но не обязательно с новым ULID.
- Уточняется ADR-035. Правило «неотправленное повторяет только владелец потока» остаётся, но причина мельчает: две вкладки послали бы одно и то же сообщение дважды с одним `id`, а не два разных.
- Сортировка исходящего перестаёт зависеть от числа попыток: сообщение остаётся на своём месте в ленте, а не переезжает в конец при каждом повторе.
+23
View File
@@ -0,0 +1,23 @@
# ADR-037: Идентификатор комнаты генерирует клиент
## Контекст
`docs/crypto.md` вплетает `roomId` в заворачивание ключа комнаты дважды: в `info` вывода `wrapK` и в AAD шифротекста. Ключ заворачивается до запроса — `POST /api/rooms` несёт `keys[]` с готовым шифротекстом.
`docs/protocol.md` и `docs/storage.md` при этом отдавали выдачу `roomId` серверу. Создатель комнаты обязан привязать ключ к идентификатору, которого ещё не существует.
Обойти это нечем. Ключ себе при создании — не формальность: ADR-018 требует его ровно для других устройств создателя, и без него второе устройство получает комнату с нечитаемым ключом. Эндпоинта, который дослал бы ключ после ответа, в протоколе нет; `POST /api/rooms/{id}/members` меняет `keyId`, то есть делает rekey сразу после создания — лишний круг и комната без действующего ключа в промежутке.
## Решение
- `roomId` генерирует клиент: 16 случайных байт base64url — как `deviceId` (ADR-017) и `keyId`.
- `POST /api/rooms` принимает `id`. Сервер проверяет форму, как у любого идентификатора, и отвергает занятый — `409 room_conflict`. Клиент берёт новый идентификатор и повторяет, как при `device_conflict`.
- Слияния с существующей комнатой нет: повторный `POST` с занятым `id` не присоединяет и не перезаписывает.
- Правятся `docs/protocol.md` (тело запроса и перечень кодов) и `docs/storage.md` (комментарий к `rooms.id`).
## Следствия
- Заворачивание себе при создании привязано к настоящему `roomId`, и второе устройство создателя читает комнату.
- Сервер доверяет клиенту не больше прежнего: он принимает форму идентификатора и отказывает занятому.
- `roomId` был и остаётся метаданными — он открыт серверу в любом случае. Угадывание чужого идентификатора ничего не даёт: доступ проверяется по `room_members`, а не по знанию `id`.
- Коллизия 128 случайных бит невозможна на практике; `room_conflict` существует ради целостности, а не ради сценария.
+25
View File
@@ -0,0 +1,25 @@
# ADR-038: Тексты экранов комнат и порядок полос
## Контекст
Этап 3 рисует экраны комнат по `docs/ui.md`, и в трёх местах документ описывает состояние, но слов не даёт.
- «Участники»: «удалить комнату» — «с подтверждением», а текста подтверждения нет. У подтверждений ADR-028 и ADR-029 свои строки записаны, у этого — нет.
- «Карточка контакта»: у ника с `pending` показываются «оба отпечатка, старый и новый». Два блока по 64 hex подряд без пометок неразличимы, а перепутать их — подтвердить не тот ключ.
- «Участники»: полоса «нужен новый ключ комнаты: подтвердите ключ @x» привязана к `needsRekey`. Тот же тупик даёт добавление участника: rekey не выполняется, если ключ кого-то из итогового состава не подтверждён (ADR-016), и операция обрывается до запроса. Состояние то же самое, а показать его нечем.
Четвёртое место — про поведение, а не про текст. ADR-033 оставил в чате одну полосу на три причины и не сказал, какая из них главная.
## Решение
- Подтверждение удаления комнаты — «комната будет удалена у всех участников.» с кнопками «удалить» и «отмена». Про историю в тексте ничего нет: она на устройствах и не трогается.
- Отпечатки в карточке контакта помечаются «старый» и «новый».
- Текст «нужен новый ключ комнаты: подтвердите ключ @x» показывается и тогда, когда неподтверждённый ключ обрывает добавление или удаление участника, — но строкой состояния формы, а не полосой: у отказа формы место одно, и оно под ней (ADR-028). Полоса остаётся за состоянием комнаты, строка — за неудавшимся действием. Ников бывает несколько, через запятую.
- В чате полоса одна, и предупреждение о смене ключа перебивает отказ отправки и «нет соединения»: только оно блокирует ввод, и пока оно висит, повторять отправку всё равно нечем.
- Строки записаны в `docs/ui.md` — разделы «Чат», «Карточка контакта», «Участники».
## Следствия
- Экраны комнат собраны из `docs/ui.md` целиком: слов, которых нет в документе, в них не осталось.
- Владелец, упёршийся в неподтверждённый ключ, видит одну и ту же строку независимо от того, сам он менял состав или участник вышел. Это одно состояние, и выход из него один — подтвердить ключ.
- Порядок полос зафиксирован: три причины не спорят за одно место.
@@ -0,0 +1,26 @@
# ADR-039: Завёрнутый ключ комнаты принимается только от участника
Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-016](016-key-trust-tofu.md): у распространителя ключа комнаты появляется проверяемое условие.
## Контекст
`docs/crypto.md` говорит про отправителя завёрнутого ключа одно: «Публичный ключ `from` проходит через TOFU как любой другой». Этого мало.
TOFU защищает от подмены ключа знакомого ника, а не от появления незнакомого. Первый ключ запоминается молча — так и задумано (ADR-016). Значит сервер, подставивший в `Room.key` запись, завёрнутую посторонним аккаунтом, получает молчаливое доверие: клиент спрашивает `GET /api/users/<посторонний>`, впервые видит этот ник, запоминает его ключ без предупреждения, разворачивает ключ комнаты и делает его текущим — последний полученный побеждает. Следующее сообщение уходит ключом, который знает подставивший.
Стоит это одного подменённого поля в ответе `GET /api/rooms` конкретному участнику. Ни подмены клиентского кода, ни сговора с участником не нужно, а на экране комната не меняется: владелец и состав приходят прежние. `docs/threat-model.md` обещает обратное — «дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка».
ADR-018 при этом уже называет распространителя: ключ заворачивает клиент-владелец, каждому участнику и себе. Условие есть, просто оно не проверялось.
## Решение
- Клиент отвергает завёрнутый ключ комнаты, если `from` не входит в состав, пришедший в том же `Room`. Ключ не разворачивается и не сохраняется.
- Проверка идёт до `GET /api/users/{from}`: подставной ник не попадает и в TOFU, следа от него не остаётся.
- Требовать именно владельца нельзя: владение переходит по ADR-018, и у действующих участников остаётся ключ прежнего владельца. Состав — то условие, которое переживает передачу владения.
- Строка записана в `docs/crypto.md`, «Заворачивание участнику».
## Следствия
- Чтобы подсунуть ключ, серверу придётся показать подставной ник в составе комнаты. Это видно на экране участников — то есть подмена перестаёт быть невидимой, ровно как обещает модель угроз.
- Ключ, завёрнутый ником, который успел выйти из комнаты, новое устройство участника не развернёт: комната для него остаётся без ключа до rekey, входящее показывается как «нет ключа комнаты». ADR-018 такой случай уже допускает, а долг по rekey теперь переживает офлайн (ADR-041), поэтому окно короткое.
- Уже сохранённый ключ проверка не трогает: `roomKeys` заполняется один раз на `keyId`.
@@ -0,0 +1,24 @@
# ADR-040: Возврат к доверенному ключу закрывает состояние pending
Уточняет [ADR-016](016-key-trust-tofu.md): у состояния «ключ изменился» появляется второй выход.
## Контекст
ADR-016 знает одно состояние и один выход из него: ключ ника изменился, отправка блокируется до явного «доверять новому ключу». `docs/ui.md` даёт под это ровно одну кнопку.
Выход оказался не единственным возможным, а единственным записанным. Сервер, отдавший чужой ключ и вернувший обратно настоящий, оставляет клиент в тупике: в `pending` лежит ключ, которому доверять нельзя, а кнопка «доверять новому ключу» продвинула бы в основные именно его — то есть уже отозванную подмену. Отправка при этом заблокирована, и разблокировать её человеку нечем.
Клиент этапа 3 снимал `pending` сам, когда сервер снова отдавал доверенный ключ. Поведение верное, но в документах его не было, а `CLAUDE.md` и `docs/plan.md` запрещают дописывать спецификацию молча.
## Решение
- Публичный ключ, совпавший с доверенным, закрывает состояние `pending`: запись возвращается к прежнему ключу, полоса в чате и второй отпечаток в карточке контакта исчезают, отправка разблокируется.
- Человеку об этом не сообщается: смены ключа не случилось, а состояние обещало ровно смену.
- Кнопка «доверять новому ключу» остаётся единственным выходом там, где новый ключ никуда не делся.
- Строка записана в `docs/ui.md`, «Карточка контакта».
## Следствия
- Тупика нет: из состояния выходит либо человек — подтверждением, либо сам сервер — возвратом к прежнему ключу.
- Подмена, откатившаяся до того, как человек посмотрел на экран, следа в состоянии не оставляет. След остаётся в ленте: сообщение, зашифрованное подменным ключом, так и лежит нерасшифрованным с пометкой «ключ изменился» — расшифровать его нечем, ключа подменщика у нас нет и не будет.
- Отдельного поля «здесь была подмена» не заводится: `peers` держит доверие, а не журнал. Журнал подмен — отдельное решение, если понадобится.
@@ -0,0 +1,31 @@
# ADR-041: Долг по ключу комнаты — состояние, а не событие
Уточняет [ADR-018](018-rooms-membership-rekey.md): «шлёт остальным событие `room` с `needsRekey: true`» дополняется признаком, который событие переживает.
## Контекст
ADR-018 описывает окно без rekey как временное: «Пока владелец офлайн, комната живёт на старом ключе — вышедший его и так знает». Значит, вернувшись, владелец обязан ключ сменить.
Вернуть его было нечем. `needsRekey` жил только в живом событии `room`: `GET /api/rooms` этого признака не нёс вовсе, в очередь событие не кладётся, а клиентский долг держался в памяти вкладки и умирал от перезагрузки. Владелец, не подключённый в ту секунду, когда участник вышел, не узнавал о долге никогда, и комната оставалась на ключе, который унёс вышедший, — до следующей смены состава, то есть, возможно, навсегда. Перезагрузка страницы у подключённого владельца давала то же самое, вместе с полосой «нужен новый ключ комнаты», которая исчезала молча.
`docs/protocol.md` при этом утверждает: «room и room_left в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms`… поэтому пропуск события во время офлайна ничего не ломает». Для `needsRekey` утверждение было ложным.
Тот же провал на втором пути. `DELETE /api/me` уносит участника из всех его комнат, но не шлёт оставшимся ничего: ни `room` с `needsRekey`, ни уведомления новому владельцу. По составу это тот же выход участника, что и `POST /api/rooms/{id}/leave`, и ADR-018 требует того же rekey. Хуже: `room_keys.sender` внешним ключом не защищён, поэтому текущий ключ комнаты остаётся завёрнутым исчезнувшим ником, и новое устройство оставшегося участника получить ключ уже не может.
Третье место — собственный выход. `POST /api/rooms/{id}/leave` шлёт `room` оставшимся и ничего — другим устройствам вышедшего. Они держат комнату в списке до следующего `ready`, то есть часами: при живом потоке `ready` не наступает.
## Решение
- `rooms` получает колонку `needs_rekey` (миграция 002). Ставится в 1, когда состав уменьшился, а участники остались: выход участника и удаление аккаунта. Снимается в 0 при `POST /api/rooms/{id}/members` — любая смена состава раздаёт новый ключ всему итоговому составу.
- `GET /api/rooms` отдаёт признак полем `needsRekey`. Клиент-владелец поднимает долг из списка комнат так же, как из события, и потому переживает и офлайн, и перезагрузку вкладки.
- `DELETE /api/me` — выход из всех комнат пользователя: оставшимся уходит `event: room` с `needsRekey: true` и их собственным текущим ключом, владение и пустые комнаты обрабатываются как при выходе (ADR-018).
- `POST /api/rooms/{id}/leave` шлёт `event: room_left` устройствам вышедшего, кроме отправившего запрос: их состояние сходится сразу, а не к следующему `ready`.
- Правятся `docs/protocol.md` («Типы», «Комнаты», «Аккаунт», «События») и `docs/storage.md` (миграция 002).
## Следствия
- Утверждение протокола про пропуск событий во время офлайна становится верным: всё, что несёт событие `room`, есть и в `GET /api/rooms`.
- Комната не остаётся на ключе вышедшего дольше, чем владелец не заходит. Окно снова временное, как и обещает ADR-018.
- Полоса «нужен новый ключ комнаты: подтвердите ключ @x» переживает перезагрузку: после `ready` владелец снова упирается в тот же неподтверждённый ключ и снова её показывает. Отдельного поля в `chats` для этого не нужно.
- Признак — метаданные комнаты, серверу и так известные: он знает состав и знает, что ключ не менялся. Нового про ключи сервер не узнаёт.
- Клиент по-прежнему решает сам, делать ли rekey: сервер только помнит, что состав уменьшился.
@@ -0,0 +1,25 @@
# ADR-042: Порядок ключей комнаты и «текущий ключ»
Уточняет [ADR-018](018-rooms-membership-rekey.md): «текущий ключ — последний полученный в порядке сервера».
Уточнён [ADR-059](059-room-keys-kept-are-handed-out.md): порядок относится ко всем удерживаемым ключам, а не только к последнему.
## Контекст
На однозначности «последнего» держится обрезка: сервер хранит два последних `keyId` комнаты (ADR-018) и обязан не выбросить ничей действующий ключ. `docs/storage.md` определял его одной строкой — «строка `room_keys` с максимальным `created_at`», — а два rekey подряд укладываются в одну миллисекунду, и максимум становится неоднозначным.
Код этапа 3 это починил: сервер поднимает время нового ключа до `последний + 1`, а при равенстве доопределяет порядок по `key_id`; клиент делает то же со своим `receivedAt`. Инвариант несущий, а записан был только комментариями в коде.
Второе: порядка сервера клиент не знает и знать не может. В `Room.key` приходит один текущий ключ без номера и без времени, так что клиент считает текущим тот, который получил последним. В гонке двух rekey с разных устройств владельца эти порядки расходятся: устройство, чей ответ пришёл раньше события соседнего, считает текущим свой ключ, а сервер — чужой.
## Решение
- Инвариант записывается в `docs/storage.md`: время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id`. То же — про клиентский `roomKeys.receivedAt`.
- Клиент держит порядок получения, а не порядок сервера. Это осознанный предел: номера ключа в протоколе нет и не заводится.
- Расхождение безвредно, пока оба ключа живы, а живы они, пока комната держит два последних `keyId`. Сообщение, отправленное ключом, который сервер уже обрезал, получает `400 unknown_key` и хоронится как `failed` (ADR-033).
## Следствия
- Обрезка до двух `keyId` не выбрасывает ничей текущий ключ: самый свежий `key_id` есть у каждого участника (иначе `keys_mismatch`), и он остаётся всегда.
- `created_at` в `room_keys` перестаёт быть в точности «миллисекундами Unix»: у двух rekey в одну миллисекунду второе время сдвинуто вперёд. Это записано рядом с колонкой.
- Порядковый номер ключа в `Room.key` — возможное расширение отдельным ADR, если расхождение порядков когда-нибудь окажется дорогим.
+23
View File
@@ -0,0 +1,23 @@
# ADR-043: Форма запроса проверяется раньше прав
Уточняет [ADR-026](026-protocol-error-codes.md): перечень кодов исчерпывающий, значит и порядок их выдачи должен быть записан.
## Контекст
`docs/protocol.md` перечисляет проверки `POST /api/rooms/{id}/members` в одном порядке — «Только владелец (`403 not_owner`). Проверки: все `add` существуют…», — а сервер отвечает в другом: форму ников и завёрнутых ключей он разбирает до обращения к хранилищу, то есть до проверки владения. Порядок наружу виден: не владелец с кривым ником в `add` получал не `not_owner`.
Иначе и не сделать: чтобы спросить хранилище о правах, запрос сначала надо разобрать. Утечки в этом нет — проверка чисто синтаксическая и о комнате ничего не сообщает.
Два кода при этом расходились с документом. Повтор ника в `keys[].to` отвечал `400 invalid`, хотя множество `keys[].to` составу в этом случае не равно и протокол называет `400 keys_mismatch`. Ошибка формы ника в `add` отвечала `404 unknown_user`, а та же ошибка в `remove``400 invalid`: один класс входа, два разных ответа.
## Решение
- Форма запроса проверяется раньше прав и раньше существования сущностей. Записано строкой в «Общих правилах» `docs/protocol.md`: `400 bad_json`, `413 too_large` и `400 invalid` приходят и на запрос, который отвергли бы и по правам.
- Повтор ника в `keys[].to``400 keys_mismatch`, как и любое другое несовпадение с итоговым составом.
- Ник неверной формы в `add``400 invalid` с полем `add`, как и в `remove`. Несуществующий ник верной формы остаётся `404 unknown_user`.
## Следствия
- Перечень кодов остаётся исчерпывающим, а порядок их выдачи — записанным, а не выведенным из чтения кода.
- Снаружи по ответу видно, что запрос разобран, но не видно ничего о комнате: `403 not_owner` одинаков и для чужой комнаты, и для несуществующей.
- Клиенту разница не важна: свои ники он приводит к форме ADR-019 до запроса.
+22
View File
@@ -0,0 +1,22 @@
# ADR-044: Экран комнаты, которой у нас больше нет
Дополняет [ADR-038](038-room-screens-texts.md): у чата появляется четвёртая причина для полосы.
## Контекст
Комната уходит из списка тремя путями: человек вышел сам, владелец его убрал, владелец удалил комнату. Открытый экран чата при этом оставался рабочим: лента, поле ввода и кнопка `>` на месте. Отправка доходила до сервера, получала `403 not_member` и садилась как `failed` с текстом «сервер не справился, попробуйте позже» — то есть человеку сообщали, что виноват сервер, тогда как он просто больше не участник. «Повторить» в этом состоянии не срабатывает никогда.
`docs/ui.md` этого состояния не описывает вовсе, хотя приходит оно и без действий человека: событие `room_left` застаёт его в открытом чате.
## Решение
- Чат комнаты, из состава которой нас больше нет, показывает полосу над вводом цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован — и поле, и кнопка `>`.
- Полоса перебивает отказ отправки и «нет соединения» на тех же основаниях, что и предупреждение о ключе (ADR-038): она блокирует ввод, и пока она висит, повторять отправку всё равно нечем.
- Лента остаётся на месте и остаётся читаемой: история на устройстве — единственная копия, и она не трогается.
- Строка записана в `docs/ui.md`, «Чат».
## Следствия
- Причин у полосы в чате становится четыре, показывается по-прежнему одна. Порядок: не участник, ключ изменился, отказ отправки, нет соединения.
- Отдельного текста для `403 not_member` не заводится: до сервера отправка из такого чата больше не доходит.
- Экран участников покинутой комнаты отдельного состояния не получает: состав там пустеет сам, а «выйти из комнаты» и «удалить комнату» отвечают тем же, чем и раньше.
@@ -0,0 +1,23 @@
# ADR-045: Пуш адресован получателю
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
ADR-023 задаёт правило отправки: пуш уходит при постановке сообщения в очередь устройства, если устройство не подключено по SSE и неотработанного пуша у него нет. Но конверт кладётся в очередь и другим устройствам отправителя (ADR-017) — по букве правила молчащий второй телефон автора получал бы пуш о собственном сообщении.
Полезная нагрузка от этого рассыпается. Заголовок — `@nick` отправителя, адрес чата — `dm:<nick>`: и то и другое собрано с точки зрения получателя. У отправителя тот же чат называется именем собеседника, а уведомление «@marta: новое сообщение» на телефоне самой marta не значит ничего.
Тексты уведомления при этом живут в ADR-023, а не в `docs/ui.md`, где место всему, что видит человек.
## Решение
- Пуш уходит только устройствам получателей. Устройства отправителя — и то, с которого он писал, и все остальные — пуша не получают; сообщение они забирают очередью, как и раньше.
- Заголовок и адрес чата собираются для получателя: `@nick` отправителя и `dm:<nick>` в личном чате, `#имя комнаты` и `room:<id>` в комнате. В комнате адресация одна для всех получателей, в личном чате получатель один — значит, у сообщения одна нагрузка на всех.
- Тексты уведомления записаны в `docs/ui.md`, «Уведомления».
## Следствия
- Правило ADR-023 читается как «пуш уходит устройствам получателей, если …». Одно молчащее устройство получателя — один пуш.
- Сервер собирает пуш из того, что и так знает: ник отправителя, имя комнаты, идентификатор чата. Плейнтекста он не знает, шифротекст не пересылает — в пуше нет ни того ни другого.
- Автор, отошедший от одного своего устройства к другому, узнаёт о собственном сообщении не пушем, а очередью при открытии. Осознанно.
+29
View File
@@ -0,0 +1,29 @@
# ADR-046: Клиент пушей — адрес перехода, состояния и жизнь подписки
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
Клиентская половина ADR-023 упирается в четыре места, где документ не договаривает.
**Адрес перехода.** ADR-023 велит открывать по нажатию на уведомление `/#/<chat>`. Идентификатор чата — `dm:<nick>` или `room:<id>` (`docs/storage.md`), а маршруты клиента — `#/dm/<nick>` и `#/room/<id>` (`docs/ui.md`). Буквальное `/#/dm:marta` не разбирается роутером и открывает список: уведомление ведёт не туда, куда обещало.
**Состояний больше трёх.** `docs/ui.md` знает три: `включены`, `выключены`, `запрещены в браузере`. Кроме них бывает браузер без `Notification` и `PushManager` (iOS вне установленного приложения — как раз такой) и сервер без VAPID-ключа: включить нельзя, а сказать про это нечем.
**У кнопки установки нет надписи.** «Кнопка, если есть `beforeinstallprompt`» — а что на ней написано, не сказано.
**Подписка переживает то, к чему привязана.** Подписка принадлежит устройству (ADR-023), но живёт в браузерном профиле и не знает ни про `deviceId`, ни про аккаунт. `deviceId` меняется при конфликте идентификаторов и при чистке IndexedDB (ADR-017), аккаунт на устройстве меняется при выходе. Что делать с подпиской в эти моменты, не записано нигде.
## Решение
- **Переход.** Service worker переводит идентификатор чата в маршрут: `dm:<nick>``/#/dm/<nick>`, `room:<id>``/#/room/<id>`. Идентификатор не той формы открывает `/#/`. Формулировка ADR-023 читается так.
- **Состояния.** Их по-прежнему три. `запрещены в браузере` — это отклонённое разрешение, отсутствие `Notification` или `PushManager` и пустой `vapidPublicKey`: включить нельзя, кнопки в этом состоянии нет. `выключены` — всё, что включается кнопкой. На iOS вне установленного приложения раздел показывает `выключены` и вместо кнопки текст про установку — тот же, что в баннере.
- **Надписи.** Кнопка установки — «установить». Крестик баннера — «×» с подписью «закрыть» для экранного диктора. Тексты записаны в `docs/ui.md`.
- **Жизнь подписки.** При каждом запуске синхронизации клиент переставляет имеющуюся подписку на текущее устройство (`PUT /api/devices/{id}/push`): запрос идемпотентен, и смена `deviceId` этим и лечится. Выход из аккаунта снимает подписку и у сервера, и у браузера — сначала `DELETE /api/devices/{id}/push`, пока сессия жива, потом `pushManager.unsubscribe` (ADR-049). Удаление аккаунта отписывается только у браузера: строку устройства вместе с подпиской уносит каскад.
## Следствия
- Уведомление открывает тот чат, о котором оно: `chat` в нагрузке остаётся идентификатором из ADR-023, разбирает его клиент.
- Пользователь, у которого пушей не бывает вовсе, видит `запрещены в браузере` и не видит кнопки, которая ничего не даст.
- Подписка, поставленная не тому устройству, чинится следующим запуском приложения, а не остаётся молчащей навсегда.
- Устройство, с которого вышли, пушей прежнего аккаунта не получает: адреса подписки у сервера больше нет, даже если отозвать её у push-сервиса не удалось.
+27
View File
@@ -0,0 +1,27 @@
# ADR-047: Исходящий запрос к push-сервису
Уточняет [ADR-011](011-web-push.md) и [ADR-023](023-push-and-service-worker.md).
## Контекст
Адрес push-сервиса выбирает браузер получателя: клиент присылает `endpoint` из `PushSubscription`, сервер хранит его и на каждое сообщение сам открывает к нему соединение. Это единственное место, где сервер ходит наружу по адресу, который назвал пользователь. Свойство появилось на этапе 4, и в модели угроз его не было.
Проверки «endpoint — абсолютный https-url» для него мало. `http.Client` по умолчанию идёт за редиректами: один ответ `307` с настоящего https-хоста уводит запрос на plain http и на любой внутренний адрес — вместе с заголовком `Authorization: vapid`. Адрес может указывать внутрь и сразу: `https://127.0.0.1:…`, `https://169.254.169.254/…`, `https://10.0.0.1/`. Ответ наружу не пересылается, но `404` и `410` снимают подписку, а это видно в `GET /api/devices` полем `hasPush`: получается побитовое сканирование внутренней сети двумя своими аккаунтами.
Рядом — две недопроверки формы. Длина `endpoint` не ограничена ничем, кроме общего предела тела: адрес на 20 КиБ ложился в базу. `p256dh` проверялся только по длине, хотя 65 случайных байт точкой кривой не являются: отправка на такую подписку падает при каждом сообщении, а устройство остаётся с ней навсегда.
И журнал: адрес подписки уходил в строку отказа. Развернуть `*url.Error` мало — host и DNS-имя остаются внутри `*net.OpError` и ошибки резолвера, а `docs/deploy.md` обещает, что данных пользователя в журнале нет.
## Решение
- Редиректы не выполняются: `CheckRedirect` возвращает `http.ErrUseLastResponse`. Push-сервисы редиректов не шлют, а без этого требование https не значит ничего.
- Соединение возможно только с публичным адресом. Проверка стоит на `Control` диалера, то есть на уже разрешённом адресе: имя, указывающее внутрь, не помогает. Непубличные — loopback, приватные сети (RFC 1918 и RFC 4193), link-local, multicast и неопределённый адрес.
- `PUT /api/devices/{id}/push` отвергает `400 invalid` литеральный непубличный адрес и `endpoint` длиннее 2 КиБ, а `p256dh` разбирает как точку P-256. Это ранний отсев формы; решает всё равно проверка при соединении.
- Отказ отправки пишется в журнал классом: «таймаут», «имя не разрешилось», «адрес подписки не публичный», «отправка не удалась». Текст ошибки транспорта не печатается вовсе — внутри него адрес подписки.
- Разрешение ходить на непубличные адреса есть в конфигурации, но из окружения не читается и в работе всегда выключено. Оно нужно тестам, где push-сервис вендора подменён сервером на `127.0.0.1`.
## Следствия
- Сервер остаётся отправителем пушей и не становится инструментом запросов внутрь периметра: оракула `hasPush` по внутренним адресам больше нет.
- Свой push-сервис на внутреннем адресе работать не будет. Для v1 это верно: подписку выдаёт браузер, а вендоры живут в интернете.
- Остаток риска записан в `docs/threat-model.md`: сервер по-прежнему открывает соединение к адресу, который назвал браузер получателя, и белого списка вендоров у нас нет.
+27
View File
@@ -0,0 +1,27 @@
# ADR-048: Пределы отправки пушей
Уточняет [ADR-023](023-push-and-service-worker.md).
## Контекст
ADR-023 говорит, кому и когда уходит пуш, но про пределы отправки не говорит ничего. Этап 4 сделал общую очередь на 256 заданий и четыре отправщика с таймаутом 10 секунд, без изоляции между аккаунтами. Прогон показал цену: аккаунт с сотней устройств на не отвечающем эндпоинте занимает всех отправщиков на минуты, и пуши посторонних пользователей в это время отбрасываются. Того же эффекта добивается не злой умысел, а медленный вендор.
Рядом две лишние работы. В очередь ставились и устройства без подписки — отправить им нечего, а место они занимали. И на каждое отброшенное задание писалась строка в журнал, прямо из обработчика `POST /api/messages`: одно сообщение давало сотню строк — готовый усилитель для заливки журнала.
Отдельно — само правило «пуш только молчащему устройству». Подключение проверялось в обработчике запроса, а право на пуш забиралось позже, в отправщике. Между этими моментами устройство успевает подключиться: подключение сбрасывает `push_pending`, отправщик тут же забирает его снова и шлёт пуш подключённому. Хуже последствие: право висит всю SSE-сессию и съедает первый пуш после ухода в офлайн.
## Решение
- Пуш ставится в очередь только устройству с подпиской: признак берётся тем же запросом, что и список устройств доставки.
- Заданий одного аккаунта в очереди и в работе — не больше четырёх. Лишние отбрасываются сразу, не занимая отправщика.
- Отправщиков восемь, таймаут запроса — 5 секунд, соединения — 3: вендоры отвечают за секунды, а таймаут задаёт потолок пропускной способности.
- Отброшенные пуши считаются, а не пишутся строкой каждый: в журнал уходит счётчик, не чаще раза в минуту.
- Подключение устройства проверяется в отправщике: до захвата права, сразу после захвата и после успешной отправки. Подключённому устройству право возвращается.
- Потолка на число устройств у аккаунта не вводим. Доля в отправке ограничена, устройства без подписки в очередь не попадают, а экран «устройства» — этап 5.
## Следствия
- Аккаунт с сотней молчащих устройств занимает не больше половины отправщиков: пуш постороннего уходит сразу.
- Отброшенный пуш не теряется навсегда: право на него не забиралось, `push_pending` устройства остался нулём, и следующее сообщение попробует снова.
- Подключённое устройство пуша не получает, а его право не остаётся висеть до конца сессии.
- Пропускная способность отправки — восемь заданий на пять секунд в худшем случае. Для маленького сервера это приемлемо; понадобится больше — менять числа, а не устройство.
+25
View File
@@ -0,0 +1,25 @@
# ADR-049: Выключенные уведомления остаются выключенными
Уточняет [ADR-046](046-push-client.md).
## Контекст
Кнопка «выключить» снимала подписку у push-сервиса и у сервера, но следа о решении человека не оставляла. Дальше подписку возвращали два автоматических пути: `askOnce` после первого отправленного сообщения (разрешение уже дано — значит, ставим подписку) и `refresh` при каждом запуске приложения (подписка в браузере уцелела — переставим её на сервер). Человек нажимал «выключить», а уведомления включались обратно сами и молча.
Рядом состояние «включены», которое считалось по одному факту наличия подписки. Подписка под прежней парой VAPID-ключей не работает: push-сервис отвечает на неё `403`, а это не `404` и не `410`, и сервер её не снимет. В настройках при этом написано «включены», а уведомлений нет.
И выход из аккаунта. ADR-046 велел снимать подписку только у браузера, «сервер не спрашивая: сессии к этому моменту уже нет». Сессия на момент нажатия «выйти» ещё жива, а отписка у push-сервиса может не пройти — сети нет, вендор недоступен. Тогда строка `devices.push_subscription` остаётся живой, и пуши прежнего аккаунта рисуются на экране блокировки устройства, где уже вошёл другой человек.
## Решение
- В `meta` появляется `notificationsOff` — явный отказ. Его ставит «выключить», снимает «включить». При взведённом флаге `askOnce` и `refresh` не делают ничего, а раздел настроек показывает «выключены».
- «Включить» закрывает и вопрос о разрешении: `notificationsAsked` ставится здесь же — человек уже решил всё сам.
- Состояние «включены» требует подписки под текущим `vapidPublicKey`. Подписка под прежним ключом — «выключены», и кнопка «включить» переподпишет устройство. По той же причине `refresh` не переставляет на сервер подписку под чужим ключом.
- Выход из аккаунта сначала снимает подписку на сервере (`DELETE /api/devices/{id}/push`, пока сессия жива), потом закрывает сессию, потом отписывается у push-сервиса. Удаление аккаунта в этом не нуждается: строка устройства уходит каскадом вместе с подпиской.
## Следствия
- Выключенные уведомления включаются только кнопкой.
- Смена пары VAPID-ключей на сервере видна человеку как «выключены», а не как молчание при надписи «включены».
- Устройство, с которого вышли, пушей прежнего аккаунта не получает, даже если отписаться у push-сервиса не удалось: адреса подписки у сервера больше нет.
- В `meta` на один ключ больше — он записан в `docs/storage.md`.
@@ -0,0 +1,31 @@
# ADR-050: Импорт архива не перезаписывает то, что уже лежит
## Контекст
`docs/crypto.md` описывает слияние одной строкой: «идемпотентное по `id` сообщений и `id` чатов». Что делать с записью, которая на устройстве уже есть, там не сказано, а вариантов два, и они дают разную историю.
ADR-034 такой же вопрос уже решал — для входящего из сети. Его довод к архиву не относится: `id` открыт собеседнику, и потому конверт с известным `id` игнорируется, а архив зашифрован секретом аккаунта, чужой его не соберёт. Значит правило нужно выбирать заново, а не наследовать.
Молчат и три соседних места. Счётчик непрочитанных и граница «новых» в архив не пишутся (`docs/storage.md`) — но что происходит с местными, когда приходит история за прошлый год, не сказано. `pending` — сообщение, набранное на другом устройстве и туда же не ушедшее, — по букве документа в архив попадает: текст у него есть. И `lastId` чата в архив попадает тоже, хотя указывает на последнюю строку чата, а ею бывает как раз то, чего в архиве нет.
## Решение
- Импорт не перезаписывает существующую запись сообщения. Своя запись знает то, чего в архиве нет: состояние отправки у каждого устройства своё — на одном сообщение `failed`, на другом то же самое доставлено, — а нерасшифрованная хранит `raw`.
- Чат с известным `id` не перезаписывается. Меняется одно: `lastId` уезжает вперёд под самое новое из добавленного, иначе чат не встанет на своё место в списке.
- `lastId` в архив не пишется. Устройство, принявшее архив, считает его само — по тому, что действительно добавило. Взятый из файла, он указывал бы на строку, которой в архиве нет: последней в чате бывает и неотправленная, и нерасшифрованная. Такой `lastId` ставит пустой чат в начало списка, а после открытия чата уезжает в `lastReadId` — и настоящее сообщение с тем же `id`, приехав позже, не поднимет счётчик.
- Счётчик непрочитанных импорт не трогает: архив приносит переписку, а не отметки о прочтении. Граница «новых» едет за лентой: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет; у чата с непрочитанным граница уже показывает на него и остаётся на месте. Счётчик и граница считаются от одной точки — иначе счётчик говорит «1», а линия отчёркивает всю привезённую переписку.
- `hidden` в архив не пишется. «Убрать из списка» — решение устройства, а не история: перенесённое, оно спрятало бы привезённую переписку на новом устройстве, и показать её было бы нечем.
- В архив уносится только отправленное — `sent`. `pending` и `failed` привязаны к устройству и к своему ULID (ADR-036): на другом устройстве «повторить» у такой записи отправит собеседнику второе сообщение, а сама запись, ушедшая после повтора под свежим `id`, вернётся из того же файла дублем. Нерасшифрованное не уносится тоже: без текста от записи остаётся один заголовок, а `raw` — служебное поле.
- Ответ импорта — число добавленных сообщений: «добавлено N сообщений». Повторный импорт того же файла добавляет ноль.
- Лента открытого чата после импорта перечитывается целиком: добавленное ложится в середину пачками по несколько тысяч, и перечня в событии нет.
- Правила записаны в `docs/storage.md`, раздел «Экспорт `.bare`».
## Следствия
- Повторный импорт и склейка истории с двух устройств не создают дублей и не двигают ни одной прежней строки. Это верно и после «повторить»: отвергнутого в архиве нет.
- Нерасшифрованное остаётся нерасшифрованным, даже когда в архиве есть его текст. Чинит это `raw` и появившийся ключ, а не файл. Цена принята: правило одно и без исключений, а исключение стоило бы разбора, чья запись новее.
- Импорт истории годовой давности не превращает список чатов в стену непрочитанных и не отчёркивает её линией «новые».
- Архив, собранный устройством, у которого что-то не ушло, не заставляет второе устройство отправлять это за него.
- Неотправленное и отвергнутое живут ровно на одном устройстве. Потеря устройства без экспорта уносит их — как и всё, что не успело стать историей.
- Чат, убранный из списка на одном устройстве, на другом виден: вместе с ним видна и привезённая переписка. Комната, из которой мы вышли, прячется обратно при следующем `ready` — состав комнаты знает сервер (ADR-044).
- Чат, у которого в архиве нет ни одной строки истории, приезжает пустым и встаёт в конец списка: `lastId` у него пуст.
@@ -0,0 +1,24 @@
# ADR-051: Кнопка «экспортировать» в подтверждениях и тексты архива
## Контекст
История на устройстве — единственная копия, и стирают её два экрана: «выйти» в настройках и вход под другим ником (ADR-029). `docs/ui.md` держит кнопку «экспортировать» только в первом из них, потому что второго ADR-029 коснулся тогда, когда экспорта не существовало вовсе, и прямо пообещал: «когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`». Экспорт появился.
Ко второму экрану вопросов нет: секрет прежнего аккаунта лежит на устройстве, а сессия экспорту не нужна — архив собирается из IndexedDB (ADR-014).
Тексты раздела «история» перечислены, но двух вещей в них нет. «добавлено N сообщений» не сходится с числом: при одном сообщении получается «добавлено 1 сообщений». И отказ, который не про файл: истории не прочитать, места на устройстве нет, ключей аккаунта нет — в перечне ответов такого нет, а показывать его надо: молчащая кнопка «экспортировать» перед стиранием истории — худший из возможных исходов.
## Решение
- Подтверждение входа под другим ником получает третью кнопку: «экспортировать», «удалить», «отмена». Порядок и место — как у подтверждения выхода: «экспортировать», «выйти», «отмена».
- Экспорт подтверждение не закрывает: архив скачался, а стирать историю или нет — отдельное решение того же человека.
- Начальный фокус в обоих подтверждениях — на «экспортировать»: с неё безопасно начинать.
- «добавлено N сообщений» согласуется с числом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений».
- «Тексты состояний» получают две строки: «экспорт не удался» — архив не собрался; «импорт не удался» — разобранный архив не дошёл до базы. Порча самого файла и чужой архив говорят о себе своими словами, они уже в перечне.
- Всё перечисленное записано в `docs/ui.md`: «Вход и регистрация», «Настройки», «Тексты состояний».
## Следствия
- Единственная копия истории не исчезает без предложения сохранить её ни на одном экране.
- Кнопок в подтверждении три, и в один ряд они помещаются не всегда: «экспортировать» в моноширинном шрифте шире трети колонки настроек, а насколько — решает системный шрифт платформы. Панель переносит их сама, цель нажатия остаётся 44 px.
- Обещание ADR-029 закрыто.
@@ -0,0 +1,25 @@
# ADR-052: Своё устройство из настроек не удаляется; занятое место — оценка браузера
## Контекст
`docs/ui.md` описывает раздел «устройства» одной строкой: список `id` (первые 8 символов), дата, «это устройство», «удалить». Три вещи в ней не решены, а решить их надо в коде.
Первая — что делает «удалить» у своей строки. `DELETE /api/devices/{id}` уносит очередь, подписку и сессии устройства (`docs/protocol.md`). На своём это означает: следующий же запрос получает `401 unauthenticated` и по `docs/ui.md` («Сеть и состояния») уводит на экран входа с целой IndexedDB. Выходом это не является: «выйти» стирает историю и сначала предлагает её сохранить (ADR-051). Получается третье состояние, которого в документе нет, — выход без вопроса и без стирания, с прежним `deviceId` в `meta`, который при следующем входе заведёт устройство заново.
Вторая — какая дата. Сервер отдаёт две: `createdAt` и `lastSeen`.
Третья — что такое `N` в «занято N МБ». Байты `storage.estimate()` в мегабайтах дают дробь с десятком знаков, а браузер, который `estimate()` не умеет, не даёт и её.
## Решение
- Своё устройство из раздела не удаляется. У своей строки вместо кнопки стоит пометка «это устройство». Отцепляет текущее устройство «выйти»: там и вопрос про историю, и стирание базы.
- Сервер не меняется: `DELETE /api/devices/{id}` принимает любое своё устройство, включая текущее. Запрет — правило экрана, а не протокола: устройство, потерявшее сессию с чужой руки, обязано оставаться рабочим сценарием.
- Дата в строке — дата появления устройства (`createdAt`), в местной зоне, цифрами: `22.08.2026`. `lastSeen` не показывается: список нужен, чтобы узнать своё среди чужих и отцепить лишнее.
- «занято N МБ» — `usage` из `navigator.storage.estimate()`, МБ равен 1024×1024 байтам. Число человеческое: до десятых, пока меньше десяти, дальше целое; десятые округляются вверх, потому что пара сотен килобайт — это не «0 МБ». Браузер без `estimate()` строки не получает: писать в неё нечего.
- Записано в `docs/ui.md`, «Настройки».
## Следствия
- Потерянное устройство отцепляется с любого другого; текущее — выходом.
- Кнопки «удалить» у своей строки нет никогда, даже когда устройство одно.
- Занятое место — оценка происхождения целиком, а не сумма длин записей: индексы и служебные страницы IndexedDB тоже место. Она же намеренно грубая у самого браузера, и точнее показывать нечего.
@@ -0,0 +1,27 @@
# ADR-053: Лента страницами по 50, без виртуализации списка
Уточняет [ADR-009](009-local-history.md): пагинация курсором остаётся, виртуализация снимается.
## Контекст
ADR-009 задаёт одной строкой две разные вещи: «пагинация курсором по ~50 сообщений, виртуализация списка в DOM». Первая — про данные и решает настоящую задачу: чат в десять тысяч сообщений не должен читаться из IndexedDB целиком при открытии. Вторая — про разметку, и её цена выяснилась только на этапе 5.
Виртуализация требует знать высоту строки до отрисовки. В ленте её нет: текст переносится, на десктопе строка — две ячейки грида через `display: contents` (`docs/identity/brief.md`), сообщение бывает в одну строку и в тридцать. Значит нужны измерение каждой строки, распорки сверху и снизу и пересчёт при смене ширины окна. Платят за это не только кодом: `aria-live` на ленте (`docs/ui.md`, «Доступность») зачитывает появление и исчезновение строк, а поиск по странице и выделение текста перестают видеть то, что убрано из разметки.
Выгоды при этом нет. В DOM попадает не вся история, а только то, что человек домотал прокруткой: открытие чата — 50 строк независимо от размера переписки.
## Решение
- Виртуализации в v1 нет. В разметке живёт всё загруженное.
- Лента открывается последней страницей в 50 сообщений и стоит в конце.
- Прокрутка к верхнему краю берёт следующие 50 назад по индексу `chat` (`docs/storage.md`). Страница короче полной означает, что выше ничего нет.
- Расстояние до низа при подгрузке сохраняется: то, что человек читает, не двигается.
- Страница короче окна прокрутки события `scroll` не порождает, поэтому следующая берётся сразу — пока лента не заполнит окно или сообщения не кончатся.
- Разделители дат и «новые» считаются по всему загруженному, а не по последней странице: граница «новых» уезжает вверх вместе с подгруженным.
- Записано в `docs/ui.md` («Чат») и `docs/architecture.md`.
## Следствия
- Домотавший до начала переписки в десять тысяч сообщений держит их все в разметке. Это его прокрутка и его выбор; обычное открытие чата — 50 строк.
- Экранный диктор, поиск по странице и выделение работают как в обычном документе.
- Возврат виртуализации — отдельный ADR, если появится жалоба, а не предположение.
@@ -0,0 +1,26 @@
# ADR-054: Архив — недоверенный ввод: форму записей проверяет клиент
## Контекст
Архив собрал владелец аккаунта: ключ выводится из секрета аккаунта, а заголовок целиком лежит под тегом AEAD (ADR-014). Отсюда легко сделать неверный вывод — что содержимому файла можно верить.
Разбирается он на устройстве и ложится в базу рядом с настоящей историей. На сетевом пути форму держит сервер (`internal/api/valid.go`): ник — `[a-z0-9_]{2,32}` (ADR-019), идентификаторы — 22 символа base64url (`docs/crypto.md`), `ts` сервер ставит сам (ADR-017). Поэтому `sync.js` и обходится проверкой типа. У архива такой опоры нет: тег AEAD ловит порчу, но всё, что лежит под тегом, написал клиент — своей же прошлой или будущей версии. Архив живёт дольше версии, которая его собрала, и его разбор — единственное место, где клиент ест данные, которых больше никто не проверял.
Цена видна на двух примерах. `ts` вне диапазона `Date` роняет отрисовку ленты на своей строке: `Intl` бросает `RangeError`, лента обрывается, чат не открывается больше никогда. Чат с ником не по форме нельзя ни открыть маршрутом (`docs/ui.md`, «Каркас»), ни убрать из списка — карточка контакта до такого ника не доходит. Убрать негодную запись из базы нечем: экрана для этого нет и не будет.
Отдельный вопрос — незнакомая версия. Клиент отвечает на неё тем же текстом, что и на порчу, а `docs/ui.md` этого не говорит.
## Решение
- Форма проверяется при разборе, до записи в базу. Ник — `[a-z0-9_]{2,32}` (ADR-019); `roomId` — 22 символа base64url (`docs/crypto.md`, «Идентификаторы»); `id` сообщения — ULID; `ts` — целое от нуля до 8 640 000 000 000 000 (предел `Date`). Автор сообщения в личном чате — свой ник или ник собеседника: третьего в переписке двоих не бывает. В комнате автором бывает и вышедший участник, поэтому там сверяется только форма ника.
- Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
- Ничего, что устройство может посчитать само, из архива не читается: `lastId` чата считается по добавленному (ADR-050).
- Незнакомая версия — файла в заголовке или нагрузки в поле `v` — показывается как «файл повреждён». Третьего текста нет: разобрать такой архив это устройство всё равно не может, а строку под формат, которого ещё нет, пришлось бы придумывать.
- Записано в `docs/storage.md` и `docs/crypto.md`.
## Следствия
- Запись, которую нельзя ни открыть, ни убрать, в базу не попадает.
- Правила формы живут в двух местах: на сервере — для сети, в клиенте — для архива. Это цена того, что архив приходит с диска, а не из протокола.
- Архив будущей версии старый клиент назовёт повреждённым. Цена принята: версия формата пока одна, а вторая заведёт свой текст тем же порядком — сначала `docs/ui.md`.
- Проверка не защищает от оператора и не претендует на это: подделать архив без секрета аккаунта нельзя, а порчу ловит тег AEAD. Она защищает от собственных ошибок — от того, что записал клиент другой версии, и от того, что запишет он же завтра.
@@ -0,0 +1,39 @@
# ADR-055: Ключи лимитов, границы карт и доверие к X-Real-IP
Уточняет [ADR-021](021-sessions-csrf-limits.md): четыре правила названы там, всё остальное про них — здесь.
Уточнён [ADR-063](063-ack-goes-in-batches.md): `POST /api/ack` приходит пачками не только при подключении — клиент копит подтверждения и шлёт их не чаще раза в две секунды.
## Контекст
ADR-021 задаёт лимиты одним списком: регистрация — 5 в час на IP, вход — 10 за 10 минут на пару IP+ник, сообщения — 30 в минуту на пользователя пакетом 10, остальные изменяющие запросы — 60 в минуту на пользователя. Этап 6 доводит список до кода, и пять вещей списком не решены.
**Пакет.** Он назван только у сообщений. У остальных правил его нет, а token bucket без него не собрать.
**«Остальные изменяющие».** Какие именно и одним ли ведром — не сказано. `POST /api/ack` изменяет очередь, но приходит пачками после каждого подключения; `GET` не изменяет ничего.
**Место в порядке проверок.** У сообщений оно записано (`docs/protocol.md`, «Сообщения»): лимит последний, после формы и прав. Для общего лимита такого места нет: чтобы спросить ведро, нужен только ник сессии, а разбор тела — уже та работа, ради отказа от которой лимит и заводится.
**Размер карт.** Ведро заводится на каждый новый ключ, а ключ — чужой адрес: их бывает сколько угодно. Выбрасывать полные вёдра, как делал этап 2, под потоком новых ключей бесполезно — полных не бывает, каждое только что потратило токен. Миллион адресов давал миллион вёдер и рост памяти без предела.
**X-Real-IP.** ADR-021 говорит «только если соединение с `127.0.0.1`». Соединение с `::1` приходит с той же машины и заслуживает того же доверия, а по букве оно его не получает: тогда все клиенты за таким nginx складываются в одно ведро, и лимит на IP превращается в лимит на сервер.
Рядом — расхождение в `docs/deploy.md`: «Логи» разрешают писать ник «для ошибок аутентификации по лимитам». Такой строки в коде нет и не заводится: ник — данные пользователя, а отказ и так видно по статусу.
## Решение
- **Пакет равен лимиту**, где ADR-021 его не назвал: 5 в час — пакет 5, 10 за 10 минут — 10, 60 в минуту — 60. За окно набегает ровно лимит, и потратить его можно разом. Отдельный пакет остаётся у сообщений: 30 в минуту, пакет 10.
- **«Остальные изменяющие»** — все непубличные маршруты, кроме `GET`, одним ведром на пользователя, включая `POST /api/ack`. Чтения не ограничиваются: ADR-021 ограничивает изменяющие, и большего v1 не вводит. `POST /api/messages` в это ведро не входит — у него своё правило.
- **Общий лимит стоит на маршруте**, сразу за проверкой сессии, и отвечает раньше разбора тела. ADR-043 это не нарушает: `429` говорит не о правах и не о существовании сущностей, а о частоте; `401 unauthenticated` стоит там же и раньше.
- **У регистрации, входа и сообщений** лимит стоит в обработчике: после проверки формы и до работы. У сообщений это записанное место в порядке проверок. У входа — раньше обращения к хранилищу и argon2: перебор не должен заказывать серверу работу. У регистрации — раньше проверки инвайт-кода, иначе код подбирается запросами без счёта.
- **Форма регистрации проверяется раньше инвайт-кода** (ADR-043). Занятость ника по-прежнему за ним: `409 nick_taken` живёт после проверки кода, и без кода ники не перебрать.
- **Карты вёдер ограничены сменой поколения.** Карт две: нынешняя и прежняя. Как только нынешняя дорастает до 4096 ключей, она становится прежней, а прежняя выбрасывается целиком. Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену переживает. Обе карты вместе — не больше 8192 вёдер на правило.
- **X-Real-IP читается с любого loopback-адреса** — `127.0.0.0/8` и `::1`. Соединение не с loopback — заголовок не читается вовсе, ключом становится адрес соединения.
- **Ник в журнал не пишется никогда**, включая отказы по лимитам; строка про это убрана из `docs/deploy.md`.
## Следствия
- Лимит на IP держится ровно до тех пор, пока nginx — единственный, кто ходит на порт. Прямой доступ к `8411` снаружи снял бы его целиком, поэтому порт слушается на `127.0.0.1` (ADR-022).
- Миллион разных адресов стоит около мегабайта на правило, а не гигабайта. Цена — поток чужих ключей протирает ведро того, кого лимит держал: забытое ведро равно новому. ADR-021 уже принял, что рестарт обнуляет лимиты; это то же самое, только чаще.
- Промахнувшийся инвайт-кодом пять раз ждёт час. Числа ADR-021 не меняются: барьер от ботов дороже удобства опечатки.
- Клиент отличает `429` от прочих отказов по коду `rate_limited` и показывает «слишком часто, попробуйте позже» (ADR-028). Новых текстов интерфейса решение не заводит.
@@ -0,0 +1,21 @@
# ADR-056: nginx не ведёт журнал запросов
Уточняет [ADR-022](022-deploy-nginx-systemd.md): к конфигу nginx добавляется `access_log off`.
## Контекст
`docs/deploy.md` обещает в разделе «Логи», что данных пользователя в журнале нет: ника не пишет даже отказ по лимитам, IP не пишется вовсе. На это обещание опирается [ADR-047](047-push-endpoint.md) — ради него отправщик пушей не печатает текст ошибки транспорта, потому что внутри него адрес подписки.
Обещание держал только сам bare. Блок nginx в том же документе не задавал ни `access_log`, ни `log_format`, а на Ubuntu 22.04 `/etc/nginx/nginx.conf` включает `access_log /var/log/nginx/access.log` формата `combined` в http-блоке, и оба server-блока его наследуют. То есть на целевой машине рядом с чистым журналом bare лежал журнал nginx с `$remote_addr` и полным URI каждого запроса: `/api/users/marta`, `DELETE /api/contacts/marta`, `/api/kdf?nick=marta`, `/api/events?device=…`. Это и IP, и социальный граф с временными метками — ровно то, что из журнала bare убирали руками.
## Решение
- Оба server-блока `bare.xmatic.team` содержат `access_log off`. Журнал запросов ведёт только bare, и ведёт по своим правилам: шаблон маршрута вместо пути, без ника, без IP, без query.
- `error_log` остаётся: это журнал сбоев, а не запросов. Он пишется при отказах nginx и содержит адрес клиента; строка про это есть в `docs/deploy.md`.
- Обещание раздела «Логи» распространяется на всё развёртывание, а не только на бинарь.
## Следствия
- Отладка «кто и когда пришёл» средствами nginx исчезает. Для маленького сервера это приемлемо: статус и длительность есть в журнале bare, а разбирать поведение конкретного человека — не задача оператора.
- Счётчики трафика и аналитика по журналу тоже исчезают. Их и не было: сбора статистики Bare не ведёт.
- Обещание модели угроз становится проверяемым целиком: `/var/log/nginx/access.log` для этого домена пуст по конфигурации, а не по случайности.
@@ -0,0 +1,21 @@
# ADR-057: `bare version` помечает сборку из изменённого дерева
Уточняет [ADR-022](022-deploy-nginx-systemd.md): проверка подлинности бинаря опирается на ревизию, значит ревизия обязана быть честной.
## Контекст
`docs/threat-model.md` называет единственное смягчение против активно-злонамеренного оператора: «статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить». `docs/deploy.md` доводит это до двух проверок после деплоя — `sha256sum` на сервере и `bare version`.
`revision()` брала из `debug.ReadBuildInfo()` первое значение `vcs.revision` и печатала его как есть. Рядом лежит `vcs.modified`, и его никто не читал: бинарь, собранный из дерева с правками, печатал чистый хеш коммита, к которому его содержимое отношения не имеет. При этом `scripts/deploy.sh` собирает именно рабочее дерево — штатный путь деплоя такие бинари и порождает.
## Решение
- `revision()` читает `vcs.modified` вместе с `vcs.revision`. При `vcs.modified = true` к хешу дописывается `+dirty`.
- Ревизии нет вовсе — прежнее `unknown`.
- Строка про версию бинаря в `docs/deploy.md` говорит то же.
## Следствия
- Сверка «хеш файла на сервере против сборки из тега» перестаёт молча проходить для бинаря из грязного дерева: `bare version` называет его грязным раньше, чем сойдётся или не сойдётся `sha256sum`.
- Релиз, собранный из чистого тега, печатает прежнюю строку — привычка не ломается.
- Проверять это в тесте нечем: `vcs.*` появляется только у собранного бинаря, а `go test` их не проставляет. Проверка ручная, она в `docs/deploy.md`.
@@ -0,0 +1,24 @@
# ADR-058: отозванная сессия теряет и свой поток событий
Уточняет [ADR-021](021-sessions-csrf-limits.md) и [ADR-015](015-password-never-leaves-client.md): «смена пароля по желанию завершает остальные сессии» — значит завершает, а не помечает.
## Контекст
`docs/protocol.md` обещает у `POST /api/password`: «при `logoutOthers` удаляются все сессии кроме текущей». Строки действительно удалялись, и следующий запрос отозванной сессии получал `401`. Но поток событий сессию проверяет один раз, при подключении: `GET /api/events` открывает поток и дальше читает только очередь и живые события. Открытый поток удаление строки переживал — и продолжал получать `event: msg` с конвертами.
Практический смысл сценария — угнанное устройство. Человек меняет пароль с галочкой «выйти на других устройствах» ровно затем, чтобы отцепить чужую руку; отцеплялась она только от запросов, а живую доставку продолжала получать до обрыва соединения.
Рядом стоит `DELETE /api/devices/{id}`: он закрывает поток явно (`hub.Close`), и `docs/protocol.md` это обещает — «Подключённому по SSE устройству поток закрывается; его следующий запрос получает `401`». Два способа отобрать доступ вели себя по-разному.
## Решение
- `Store.SetPassword` при `logoutOthers` отдаёт устройства, к которым были привязаны удалённые сессии. Обработчик `POST /api/password` закрывает их потоки через `hub.Close` — тем же способом, что и удаление устройства.
- Устройство текущей сессии не трогается: она и есть та, которую оставляют.
- Периодической перепроверки сессии в цикле SSE не заводится: поток закрывает тот, кто отзывает доступ, а не таймер. Сессия, отозванная иначе (истёк срок), доживает до обрыва потока — как и раньше, новых прав это не даёт: очередь и события идут устройству, а устройство остаётся своим.
- Строка записана в `docs/protocol.md`, «Аккаунт».
## Следствия
- Отзыв доступа выглядит одинаково с обеих сторон: и удаление устройства, и смена пароля с галочкой закрывают поток и оставляют следующему запросу `401`.
- Отозванное устройство переподключается сразу и получает `401 unauthenticated` — то есть уходит на экран входа, а не молчит до перезагрузки.
- Сессия без устройства (её ещё не привязали `POST /api/devices`) закрывать нечего: потока у неё и нет.
@@ -0,0 +1,26 @@
# ADR-059: участник получает все удерживаемые ключи комнаты, а не только текущий
Уточняет [ADR-018](018-rooms-membership-rekey.md) и [ADR-042](042-current-room-key-order.md): сервер держит два последних `keyId` — значит, и раздаёт два.
## Контекст
`GET /api/rooms` и событие `room` отдавали участнику ровно один ключ — текущий. Других источников ключа у клиента нет: запросить конкретный `keyId` протокол не умеет.
Этого хватает на одну смену ключа и не хватает на две. Комната `{владелец, D, E}`, устройство D офлайн. Владелец добавляет участника — rekey `K1`; кто-то пишет сообщение ключом `K1`; владелец убирает участника — rekey `K2`. События `room` в очередь не кладутся (`docs/protocol.md`, «События»), поэтому `K1` до D не дошёл, а конверт с `keyId = K1` лежит в его очереди и дождётся подключения. D возвращается, забирает конверт и спрашивает `GET /api/rooms` — там `K2`. `K1` на сервере есть (обрезка держит два последних), но не отдаётся никому и никогда.
Сообщение остаётся `undecryptable: "unknown_key"` навсегда, а `docs/storage.md` обещает обратное: «Нерасшифрованное сообщение хранит `raw` для повторной попытки после … получения недостающего `keyId`». Получить его было нечем. Конфиденциальность цела — это потеря читаемости у законного участника.
## Решение
- Поле `key` типа `Room` заменяется на `keys` — список завёрнутых для запрашивающего ключей комнаты, от старого к новому. Порядок — время записи и `key_id` при равенстве, тот же, что у обрезки (ADR-042).
- `GET /api/rooms` отдаёт все ключи, которые сервер ещё держит (до двух, ADR-018). Событие `room` и ответы `POST /api/rooms` и `POST /api/rooms/{id}/members` несут один — только что розданный: подключённому устройству остальные уже приходили, а отключённое доберёт их из `GET /api/rooms` после `ready`.
- Клиент сохраняет ключи по порядку: текущим у него остаётся последний полученный (ADR-042), поэтому исходящее по-прежнему шифруется свежим ключом.
- Отдельного эндпоинта «ключ по (roomId, keyId)» не заводится: он был бы четвёртым способом получить то же самое.
- Правятся `docs/protocol.md` («Типы», «Комнаты», «События») и `docs/storage.md`.
## Следствия
- Сообщение, отправленное между двумя rekey, читается участником, который в это время был офлайн. Ради этого сервер и держал два ключа.
- Три смены ключа за время офлайна по-прежнему теряют средний: сервер держит два последних `keyId`, а не всю историю. Это прежняя цена ADR-018, и она записана.
- Сервер не узнаёт о ключах ничего нового: он и раньше хранил обе записи и раздавал одну из них.
- Ответ `GET /api/rooms` вырастает на один завёрнутый ключ на комнату. Это десятки байт.
@@ -0,0 +1,23 @@
# ADR-060: `403 unknown_device` не хоронит сообщение
Уточняет [ADR-033](033-failed-message-reason.md) и правило `docs/storage.md` про судьбу исходящего.
## Контекст
`docs/storage.md` делит отказы на два класса: сетевая ошибка и `500` оставляют сообщение `pending` и повторяются при следующем подключении, прочие `4xx``failed` с текстом отказа.
`403 unknown_device` в этот раздел не укладывается. Он означает не «сообщение не годится», а «устройства, от имени которого мы пишем, у сервера больше нет»: его удалили с другого устройства, либо оно отмерло по сроку (ADR-017). Текст у сообщения при этом появился бы посторонний — про сервер, который не справился, — а «повторить» не сработало бы ни разу: тот же `X-Device` получит тот же отказ.
Чинится это не сообщением, а устройством: клиент переподключается, `POST /api/devices` заводит устройство заново, и неотправленное уходит после `ready`. Клиент так и делал — оставлял запись `pending` и заводил повтор, — но в документах исключения не было, а `CLAUDE.md` запрещает дописывать спецификацию молча.
## Решение
- `403 unknown_device` — не отказ сообщению, а потерянное устройство: запись остаётся `pending`, клиент переподключается и повторяет её после `ready`.
- Правило записано строкой в `docs/storage.md` рядом с прежним делением отказов.
- Остальные `4xx` не меняются: `failed` с текстом отказа.
## Следствия
- Удаление устройства с другого устройства не превращает набранное в отвергнутое: сообщение уходит, как только устройство завелось заново.
- Перечень причин, по которым сообщение остаётся `pending`, становится закрытым: сеть, `500`, отложенная отправка (нет ключа комнаты, ключ собеседника ждёт подтверждения) и потерянное устройство.
- Бесконечного круга нет: пока устройства нет, сообщение просто лежит; полоса про отказ отправки в чате не появляется, потому что отказа сообщению не было.
@@ -0,0 +1,23 @@
# ADR-061: имя комнаты в подсказке ввода обрезается
Уточняет `docs/ui.md`, «Чат»: у placeholder появляется предел длины.
## Контекст
`docs/ui.md` задаёт подсказку строки ввода: «сообщение в #general» / «сообщение». Имя комнаты бывает до 64 символов (ADR-021), а строка ввода растёт под placeholder так же, как под набранный текст: имя в 64 символа занимает в ней три строки на десктопе и больше на телефоне. Подсказка при этом не текст, а приглашение — раздувать под неё поле ввода нечем оправдать.
Клиент этапа 2 обрезал имя до двенадцати символов многоточием. Поведение верное — «сообщение в #длинноеимя…» умещается в одну строку на самом узком из целевых экранов (360 px), — но в документе его не было.
Обрезать разметкой нельзя: `text-overflow` к placeholder не применяется.
## Решение
- Имя комнаты в подсказке ввода обрезается до двенадцати символов и заканчивается многоточием: «сообщение в #длинноеимя…».
- Считаются символы, а не единицы utf-16: имя ограничено символами, и разрезать пару посередине незачем.
- В шапке чата имя остаётся полным: там его обрезает разметка, и место у него своё.
- Строка записана в `docs/ui.md`, «Чат».
## Следствия
- Строка ввода остаётся в одну строку при любом имени комнаты.
- Две комнаты с одинаковым началом длинного имени дают одинаковую подсказку. Это подсказка, а не заголовок: имя целиком видно в шапке над лентой.
@@ -0,0 +1,23 @@
# ADR-062: `GET /api/kdf` не обещает скрывать существование ника
Уточняет [ADR-015](015-password-never-leaves-client.md): «ответ не раскрывает существование ника» верно не всегда.
## Контекст
`GET /api/kdf?nick=` отдаёт число итераций PBKDF2: для известного ника — `iter` из его ключевого блоба, для неизвестного — целевое значение сервера. ADR-015 и `docs/protocol.md` называли это свойство прямо: ответ не раскрывает, существует ли ник.
Утверждение держится ровно до первого повышения цели. ADR-013 и ADR-030 предусматривают повышение с автоматической перешифровкой блоба при следующем входе: у аккаунтов, заведённых раньше и с тех пор не входивших, в блобе остаётся прежнее число. Тогда известный ник отвечает старым значением, неизвестный — новым, и разница видна снаружи. Сейчас цель не менялась, поэтому оракул спящий, — но он следует прямо из документированного пути обновления, а не из ошибки.
Прятать существование ника Bare и не обещал в остальном: ADR-019 говорит про ник открытым текстом — «что он существует, узнать можно. Это не считается утечкой», а `POST /api/register` отвечает `409 nick_taken`. Ради согласованности одной строки нет смысла ни отдавать всем целевое значение (клиенту нужно настоящее — иначе не расшифровать блоб), ни заводить второй запрос за `iter` после входа.
## Решение
- Формулировка правится: `GET /api/kdf` отвечает `200` и неизвестному нику, поэтому по статусу существование ника не видно; но число итераций у существующего аккаунта — его собственное, и после повышения цели оно может отличаться от целевого. Скрытием существования ника этот ответ не занимается.
- Существование ника остаётся публичным фактом (ADR-019), и это записано там же, где раньше стояло обещание: `docs/protocol.md`, «Публичные».
- Код не меняется: разное число итераций у разных аккаунтов — свойство постепенного повышения (ADR-013), а не дефект.
## Следствия
- Перечень того, что видно снаружи без сессии, становится честным: существование ника видно и через регистрацию, и через `kdf`.
- Повышение цели KDF остаётся возможным без миграции всех аккаунтов разом — ровно ради этого `iter` и лежит рядом с блобом.
- Если скрывать существование ника когда-нибудь понадобится, это отдельное решение и другой протокол входа; в v1 такой задачи нет.
+31
View File
@@ -0,0 +1,31 @@
# ADR-063: подтверждения копятся и уходят пачкой
Уточняет [ADR-055](055-limit-keys-and-bounds.md): «`POST /api/ack` приходит пачками» — теперь это правда и в живой доставке, а не только при подключении.
## Контекст
ADR-055 положил `POST /api/ack` в общее ведро «60 изменяющих запросов в минуту на пользователя» и обосновал это тем, что ACK приходит пачками после каждого подключения. Для воспроизведения очереди посылка верна: конверты приходят подряд, разбираются одним заходом и подтверждаются одним запросом.
В живой доставке она неверна. Разбор входящих откладывается на следующий такт цикла событий, поэтому конверты, пришедшие в разных тактах, разбираются по одному, и каждый разбор заканчивался своим подтверждением — один `POST /api/ack` на конверт.
Следствие достаётся получателю, и оно измерено. Четверо пишут одному по своему пределу в 30 сообщений в минуту, три минуты: 365 конвертов, 346 подтверждений в журнале сервера, из них 336 — ровно с одним идентификатором. Через 59 секунд ведро кончилось: 103 подтверждения получили `429`, десять пробных изменяющих запросов получателя — все десять `429`, три попытки выйти из комнаты — все три `429`. В очереди сервера осталось 103 неподтверждённых конверта: записаны у получателя, но сервер их не забудет.
Человек, которому пишут часто, теряет возможность уйти: `429` получает всё изменяющее — выход из комнаты, смена пароля, удаление аккаунта.
Второй путь — вынести `/api/ack` из общего ведра пятым правилом ADR-021 — отвергнут: лимит на подтверждения либо не существует вовсе, либо это ещё одно правило, ещё одно ведро и ещё одна карта. Пачка дешевле и не расширяет ADR-021.
## Решение
- Идентификаторы записанного копятся, `POST /api/ack` уходит не чаще раза в две секунды и несёт всё, что накопилось. Задержка ничего не стоит: подтверждение — учёт очереди сервера, а не доставка человеку, сообщение к этому моменту уже на экране.
- Правило `docs/storage.md` остаётся дословным: в накопитель попадает только то, что уже записано в IndexedDB. Неудачная запись не подтверждается ничем.
- Накопитель — множество: конверт, выданный очередью повторно, подтверждается один раз.
- Неудачная отправка бросает накопленное: сервер выдаст эти конверты заново, а `put` по тому же `id` дублей не создаёт (ADR-017).
- Выход гасит таймер и очищает накопитель. Неподтверждённое вернётся очередью при следующем подключении.
- Место лимита не меняется: `/api/ack` остаётся в общем ведре (ADR-055).
## Следствия
- Поток сообщений тратит на подтверждения не больше половины общего ведра — 30 запросов в минуту в худшем случае. Выйти из комнаты, сменить пароль и удалить аккаунт получатель может в любой момент. Тот же прогон после правки: 354 конверта, 83 подтверждения (одно раз в две секунды, по четыре идентификатора в каждом), ни одного `429`, десять пробных изменяющих запросов прошли, выход из комнаты прошёл с первой попытки, очередь сервера пуста.
- Конверт живёт в очереди сервера на пару секунд дольше. Реконнект в этот промежуток выдаёт его заново; запись по тому же `id` не даёт ни дубля в ленте, ни второго непрочитанного (ADR-034), и на экране это не видно.
- Закрытая вкладка уносит с собой до двух секунд неподтверждённого — те же конверты придут очередью в следующий раз.
- Новых текстов интерфейса решение не заводит.
+149
View File
@@ -0,0 +1,149 @@
# Деплой
Цель — `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`; у сборки из изменённого рабочего дерева (`vcs.modified`) к ревизии дописывается `+dirty` — сверка со сборкой из тега не должна проходить молча (ADR-057). `/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
sudo chmod 0700 /var/lib/bare /etc/bare
```
Права закрыты намеренно (ADR-032): в базе лежат `argon2id(authKey)` и ключевые блобы, машина общая.
`/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
StateDirectoryMode=0700
UMask=0077
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;
access_log off;
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;
# журнал запросов ведёт только bare, и ведёт без ника, IP и query (ADR-056)
access_log off;
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`
Перед сборкой: если менялись `index.html`, `app.css`, `js/*`, `manifest.json` или иконки — сменить `VERSION` в `web/sw.js` (ADR-023). Без этого установленные приложения получат новую оболочку только вторым открытием, по ETag.
```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), без буферизации.
- `curl -s https://bare.xmatic.team/sw.js | grep VERSION` — версия та, что в репозитории.
- `journalctl -u bare -f` — старт, применённые миграции, нет ошибок.
## Бэкап
`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. Копия наследует режим 0600 (ADR-032); при восстановлении в другое место права надо выставить руками.
## Логи
Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ника в журнале нет вовсе, включая отказы по лимитам (ADR-055); IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. Отправитель пушей пишет класс отказа — «таймаут», «имя не разрешилось», «отправка не удалась» — без адреса подписки и идентификатора устройства (ADR-047). journald хранит по своим правилам.
nginx журнал запросов не ведёт: `access_log off` в обоих server-блоках (ADR-056). Без этой строки он унаследовал бы `access.log` формата `combined` из `/etc/nginx/nginx.conf` — с адресом клиента и полным URI, то есть с ником и социальным графом. `error_log` остаётся: это журнал сбоев, а не запросов, и при отказе он записывает адрес клиента.
+50
View File
@@ -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`.
+1
View File
@@ -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

+140
View File
@@ -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">&gt;</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">&gt;</span><span class="ph">сообщение</span><span class="caret"></span></div>
</div>
</div>
</div>
</div>
</body>
</html>
+4 -7
View File
@@ -2,11 +2,8 @@
Решения по этим пунктам ещё не приняты. Каждое принятое решение уходит в ADR и вычёркивается отсюда.
- Регистрация: открытая или по инвайтам?
- Механика добавления контакта и приглашения в комнату: по нику? по ссылке?
- Лимиты: длина сообщения, rate limiting, антиспам.
- Смена пароля (= перешифровка ключевого блоба): в v1 или позже?
- Идентификация устройства для per-device очередей.
- Серверный «перец» для ключевого блоба: дополнительное шифрование блоба серверным ключом, хранящимся вне базы. Плюс: дамп базы сам по себе перестаёт быть материалом для оффлайн-перебора. Минус: не защищает от оператора; потеря серверного ключа — невозможность входа с новых устройств для всех. Решение отложено.
- Язык интерфейса (ru/en); нужна ли i18n.
- Визуальная айдентика: отдельный бриф будет добавлен в `docs/identity/`.
- Блокировка собеседника и персональные инвайты — если общего инвайт-кода и лимитов (ADR-019, ADR-021) окажется мало.
- Подписи сообщений вторым ключом — если потребуется защита от сговора участника комнаты с сервером (ADR-016).
Закрыто ADR-015…024: регистрация, контакты, лимиты, смена пароля, идентификация устройств, язык интерфейса, айдентика, доверие к ключам, протокол, схема базы, деплой, правила пушей.
+137
View File
@@ -0,0 +1,137 @@
# План реализации 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` |
## Раскладка репозитория
```
embed.go //go:embed web в корне модуля (ADR-025)
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/sync.js устройство, поток событий, приём и отправка
js/pwa.js service worker, подписка на пуши, установка
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 получает пуш и открывается на нужном чате; повторные сообщения до открытия пуш не порождают.
### Чеклист ручной проверки на устройствах
Автоматически проверено всё, что проверяется без настоящих устройств: правило «одно
молчащее устройство — один пуш», сброс `push_pending` при подключении SSE, удаление
подписки на 404/410, расшифровка пуша по RFC 8291 в тесте, отсутствие плейнтекста
в нагрузке, кэш оболочки без `/api/*`, отказ ходить на непубличные адреса. Осталось
то, что требует рук и телефона:
- [ ] **iPhone, установленное на «Домой» приложение**: пуш приходит при закрытом
приложении, нажатие открывает нужный чат.
- [ ] **Android Chrome, закрытое приложение**: то же самое.
- [ ] **Клик по системному уведомлению** в обоих случаях: в уже открытое окно
(фокус и переход) и при закрытом приложении (`/#/dm/<nick>`, `/#/room/<id>`).
- [ ] **Повторные сообщения до открытия**: второе и третье пуша не порождают.
- [ ] **iOS вне PWA**: баннер установки над списком чатов, текст, крестик и то,
что он больше не появляется; в настройках «уведомления» — текст про установку
вместо кнопки.
- [ ] **`beforeinstallprompt`** в обычном Chrome: раздел «установить приложение»
появляется, после нажатия исчезает целиком.
- [ ] **Системный запрос разрешения** после первого отправленного сообщения:
headless-Chrome отвечает `denied` сам, живой диалог не проверялся.
- [ ] **Прогон сценариев «готово, когда»** этапов 15 в Safari (iOS и десктоп)
и Firefox — автоматика гоняла только Chrome.
## Этап 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`: ни тем, ни аватаров, ни «печатает», ни статусов прочтения.
+141
View File
@@ -0,0 +1,141 @@
# Протокол
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`. Принадлежность — право, поэтому проверяется после разбора тела и его формы (ADR-043).
- Тело запроса — до 32 КиБ, иначе `413 too_large`.
- Rate limiting — `429 rate_limited` с `Retry-After` в целых секундах, не меньше одной. Правила (ADR-021): регистрация — 5 в час на IP; вход — 10 за 10 минут на пару IP+ник; сообщения — 30 в минуту на пользователя, пакет 10; остальные изменяющие запросы — 60 в минуту на пользователя, одним ведром на все маршруты. Чтения не ограничиваются. Общий лимит отвечает раньше разбора тела; у регистрации, входа и сообщений он стоит на своём месте в порядке проверок эндпоинта (ADR-055).
- Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`.
- Форма запроса проверяется раньше прав и раньше существования сущностей: `bad_json`, `too_large` и `invalid` приходят и на запрос, который отвергли бы и по правам (ADR-043).
- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027).
- Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026).
## Типы
```
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,
keys: {keyId, from, iv, ct}[], // завёрнутые ключи для запрашивающего, от старого к новому
needsRekey: boolean // состав уменьшился, а нового ключа ещё не было (ADR-041)
}
WrappedKey { to: nick, iv: string, ct: string }
```
`keys` — все ключи запрашивающего, которые сервер ещё держит, в `GET /api/rooms`; ровно один, только что розданный, — в событии `room` и в ответах `POST /api/rooms` и `POST /api/rooms/{id}/members` (ADR-059). Пусто, если ключа у него нет.
## Публичные
`GET /api/config``200 {inviteRequired: bool, vapidPublicKey: string, kdfIterations: number, maxMessageChars: 4000}`
`GET /api/kdf?nick=<nick>``200 {iterations}`. Для неизвестного ника — `kdfIterations` из конфигурации, тем же статусом. Скрытием существования ника ответ не занимается: у аккаунта, не входившего после повышения цели, число итераций своё (ADR-062), а сам факт, что ник существует, публичен (ADR-019).
`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` удаляются все сессии кроме текущей, а устройствам удалённых сессий поток событий закрывается — их следующий запрос получает `401` (ADR-058).
`DELETE /api/me {authKey}``204`. Удаляет пользователя каскадом; владение комнатами передаётся по ADR-018; пустые комнаты удаляются. Удаление аккаунта — выход из всех его комнат: оставшимся участникам уходит `event: room` с `needsRekey: true`, каждому со своим ключом (ADR-041).
`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()`: `endpoint` — абсолютный `https`-адрес до 2 КиБ на публичный адрес (литеральные loopback, link-local и приватные адреса — `400 invalid`, ADR-047), `keys.p256dh` — точка кривой P-256 в 65 байтах base64url, `keys.auth` — 16 байт base64url; прочие поля, включая `expirationTime`, сервер не хранит. Сбрасывает `push_pending`. Устройство в пути, как и `X-Device`, обязано принадлежать пользователю сессии.
`DELETE /api/devices/{id}/push``204`; подписки не было — тот же `204`, чужое устройство — `403 unknown_device`.
## Контакты
`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 и ADR-045.
`POST /api/ack {ids: string[]}``204`. До 500 идентификаторов. Удаляет из `queue` строки устройства `X-Device`. Клиент копит подтверждения и шлёт их пачкой, не чаще раза в две секунды: маршрут живёт в общем ведре изменяющих запросов, и запрос на конверт съедал бы его целиком (ADR-063).
## События
`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);
// keys — один новый ключ получателя либо пусто (ADR-059)
event: room_left data: {id} // получателя удалили или комната удалена
event: ready data: {}
```
`msg` идёт через очередь и требует ACK. `room` и `room_left` в очередь не кладутся: клиент после каждого `ready` перечитывает `GET /api/rooms` и `GET /api/contacts`, поэтому пропуск события во время офлайна ничего не ломает. Всё, что несёт событие `room`, включая `needsRekey` и ключ, есть и в `GET /api/rooms` (ADR-041, ADR-059).
`id` в SSE не используется; `Last-Event-ID` игнорируется — повторная выдача очереди после реконнекта и есть механизм восстановления.
## Комнаты
`GET /api/rooms``200 Room[]` — комнаты, где пользователь участник, со всеми его ключами, которые сервер ещё держит (до двух, ADR-018), и признаком `needsRekey`: он состояние комнаты, а не свойство события, и переживает офлайн владельца (ADR-041). Ключи идут от старого к новому; последний — текущий. Участник, пропустивший rekey в офлайне, добирает пропущенный `keyId` только отсюда: события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059).
`POST /api/rooms {id, name, keyId, keys: WrappedKey[]}``201 Room`. `id` — 16 случайных байт base64url, генерирует клиент (ADR-037): ключ комнаты заворачивается до запроса и привязан к идентификатору. Занятый `id``409 room_conflict`, клиент берёт новый. `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`; повтор ника в `keys[].to` — тот же код). Форма `add` и `remove` проверяется раньше прав: ник не по форме — `400 invalid` с этим полем. Пустые `add` и `remove` — чистый rekey. В одной транзакции: состав, `room_keys` для каждого участника, удаление ключей и членства удалённых, обрезка до двух последних `keyId`, снятие `needsRekey`. После коммита: `event: room` всем участникам (каждому — с его новым ключом), `event: room_left` удалённым.
`POST /api/rooms/{id}/leave``204`. Удаляет членство и ключи вышедшего. Если вышел владелец — владение получает участник с наименьшим `joined_at`; если никого не осталось — комната удаляется. Остальным — `event: room` с `needsRekey: true`; другим устройствам вышедшего, кроме отправившего запрос, — `event: room_left` (ADR-041).
`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`, `room_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`.
## Статика и служебное
- `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`, без аутентификации, для проверок после деплоя.
+157
View File
@@ -0,0 +1,157 @@
# Хранение
## Сервер — 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);
```
### Миграция 002
```sql
ALTER TABLE rooms ADD COLUMN needs_rekey INTEGER NOT NULL DEFAULT 0;
```
Признак «состав уменьшился, нового ключа ещё не было» (ADR-041): ставится при выходе участника и удалении аккаунта, снимается при `POST /api/rooms/{id}/members`, отдаётся полем `needsRekey`.
Текущий ключ комнаты для участника — строка `room_keys` с максимальным `created_at`; `keyId` считается ключом комнаты, если есть хоть одна строка с таким `key_id` для `room_id`. Участнику отдаются все его удерживаемые ключи, а не только текущий: пропущенный в офлайне `keyId` иначе не добыть ничем — события в очередь не кладутся, а запроса ключа по идентификатору в протоколе нет (ADR-059).
Время записи `room_keys` строго больше времени всех прежних ключей той же комнаты; при равенстве порядок доопределяется по `key_id` (ADR-042). Два rekey подряд укладываются в одну миллисекунду, поэтому `created_at` ключа — не в точности миллисекунды Unix, а миллисекунды, сдвинутые вперёд ровно настолько, чтобы «последний» был однозначен.
Удаление пользователя: перед `DELETE FROM users` сервер обрабатывает его комнаты — убирает членство и ключи, передаёт владение или удаляет опустевшую комнату, ставит `needs_rekey` там, где участники остались (ADR-041), — остальное уносит каскад.
### Фоновая чистка, раз в час
```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. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029).
```
meta key: string → value
deviceId, nick, publicKey (JWK), fingerprint,
privateKey (CryptoKey ECDH, non-extractable),
accountSecret (CryptoKey HKDF, non-extractable),
notificationsAsked (bool), notificationsOff (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",
error?: string, // текст отказа у failed
undecryptable?: "unknown_key"|"bad_aead"|"key_changed", raw?: Envelope}
roomKeys key: [roomId, keyId]
{roomId, keyId, key: CryptoKey AES-GCM non-extractable, from, receivedAt}
// receivedAt строго больше receivedAt всех прежних ключей той же комнаты;
// текущий ключ — последний по нему, то есть в порядке получения (ADR-042)
peers key: nick
{nick, publicKey: JWK, fingerprint, firstSeen,
pending: {publicKey, fingerprint, seenAt} | null} // новый ключ, ждущий подтверждения
```
Правила:
- Сообщение пишется в `messages` до ACK серверу: сначала `put`, потом `POST /api/ack`. Подтверждения копятся и уходят пачкой, не чаще раза в две секунды: в накопитель идёт только записанное, а запрос на конверт тратил бы общее ведро лимита одними подтверждениями (ADR-063).
- Входящее сообщение с уже известным `id` игнорируется целиком: ни записи, ни счётчика непрочитанных (ADR-034). Повтор доставки не даёт ни дубля в ленте, ни второго непрочитанного; `id` открыт в конверте, и перезапись отдала бы собеседнику чужую строку истории. Перезапись по `id` остаётся у исходящего: переход `pending → sent/failed`.
- Исходящее пишется со `status: "pending"` и локальным `id`, затем `POST /api/messages`; `202``sent`, сетевая ошибка и `500` → остаётся `pending` и повторяется при следующем подключении; прочие `4xx``failed` с текстом отказа в поле `error` (ADR-033). Повтор отправки идёт с прежним ULID, пока время в нём разошлось с текущим меньше чем на четыре минуты: ответ на `POST` мог потеряться уже после того, как сервер сообщение принял, а повтор с тем же `id` получатель игнорирует (ADR-034). Идентификатор старше запаса заменяется свежим — старая запись удаляется, новая пишется: время в `id` должно совпадать с временем фактической отправки, иначе после долгого офлайна сервер ответит `clock_skew`. `clock_skew` на переиспользованном `id` отменяет переиспользование: попытка идёт второй раз со свежим `id`, ровно один раз; такой же отказ на свежем `id``failed` с текстом про часы (ADR-036).
- Исключение среди `4xx` одно: `403 unknown_device` — не отказ сообщению, а потерянное устройство. Запись остаётся `pending`, клиент заводит устройство заново при переподключении и повторяет её после `ready` (ADR-060).
- `unread` и `lastReadId` — локальные, на сервер не уходят.
- Нерасшифрованное сообщение хранит `raw` для повторной попытки после подтверждения нового ключа или получения недостающего `keyId`.
- Пагинация — курсор по индексу `chat` назад от последнего, по 50.
- При старте: `navigator.storage.persist()`; в настройках — `storage.estimate()`.
## Экспорт `.bare`
Полезная нагрузка — `chats` (без `lastId`, `unread`, `lastReadId`, `hidden`), `messages` (без `raw` и `error`), `peers` (без `pending`). Уносится только отправленное — `sent`. Неотправленное и отвергнутое остаются устройству: `pending` и `failed` — незаконченная и отвергнутая попытки, привязанные к своему ULID (ADR-036), а не история. Нерасшифрованное не уносится: без текста от записи остаётся один заголовок, а `raw` — служебное поле. Показания устройства — место чата в списке, счётчики и «убрано из списка» — не уносятся тоже (ADR-050). Формат файла и шифрование — `docs/crypto.md`. Имя файла — `bare-<nick>-<YYYY-MM-DD>.bare`.
Архив — недоверенный ввод: форму каждой записи клиент проверяет сам, до записи в базу (ADR-054). Ник — `[a-z0-9_]{2,32}`, `roomId` — 22 символа base64url, `id` сообщения — ULID, `ts` — целое в пределах `Date`; автор сообщения в личном чате — свой ник или ник собеседника. Что не по форме, до базы не доходит: пропускается запись целиком, а не поле.
Импорт вливает архив одной транзакцией и не трогает то, что уже лежит (ADR-050): сообщение и чат с известным `id` остаются как есть, `unread` и `hidden` не меняются, запись `peers` добавляется только для ника, которого в TOFU ещё нет. У чата двигается `lastId` — под самое новое из добавленного, — и вместе с ним граница «новых»: `lastReadId` уезжает под новый `lastId`, пока непрочитанных у чата нет. Ответ — число добавленных сообщений; повторный импорт того же файла добавляет ноль.
+13 -5
View File
@@ -8,7 +8,7 @@
## От кого защищаем
**Пассивный оператор сервера.** Админ с полным доступом к базе и диску видит: ники, argon2-хеши, зашифрованные ключевые блобы, метаданные комнат и контактов, транзитную очередь шифротекстов. Плейнтекста у него нет.
**Пассивный оператор сервера.** Админ с полным доступом к базе, диску и логам запросов видит: ники, argon2-хеши от `authKey`, зашифрованные ключевые блобы, имена комнат и составы, завёрнутые ключи комнат, транзитную очередь шифротекстов, push-подписки устройств — адрес push-сервиса и ключи подписки `p256dh` и `auth`. Пароль на сервер не приходит (ADR-015) — в логах запросов материала ключа нет. Плейнтекста у него нет. База вместе с VAPID-ключом, который лежит на той же машине в `/etc/bare/env`, позволяет показать устройству произвольное уведомление от имени bare — вплоть до фишингового текста на экране блокировки; содержимого сообщений это не раскрывает.
**Сетевой наблюдатель.** HTTPS обязателен. Наблюдатель видит факт и объём трафика к серверу, не содержимое.
@@ -18,11 +18,15 @@
## От кого не защищаем
**Активно-злонамеренный оператор.** Оператор, способный подменить клиентский код, может украсть ключи и плейнтекст. Это фундаментальный предел web-E2EE: клиент каждый раз загружается с сервера. Смягчение — открытый код и клиент из нескольких читаемых файлов без сборки: подмену можно заметить глазами. Гарантии нет.
**Активно-злонамеренный оператор.** Оператор, способный подменить клиентский код, может украсть ключи и плейнтекст. Это фундаментальный предел web-E2EE: клиент каждый раз загружается с сервера. Смягчение — открытый код, клиент из нескольких читаемых файлов без сборки, статика внутри бинаря, хеш которого сверяется со сборкой из тега: подмену можно заметить. Гарантии нет.
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается — серверу видно. Скрытие метаданных — не задача Bare.
**Подмена публичного ключа.** Ключи раздаёт сервер. Защита — TOFU (ADR-016): подмена возможна только при первом контакте, дальше клиент видит смену ключа и блокирует отправку до подтверждения отпечатка. Защита работает ровно настолько, насколько люди сверяют отпечатки; если не сверяют — первый контакт остаётся на доверии к серверу.
**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB). Доступ к устройству — доступ к истории. Защита устройства — зона ответственности пользователя и ОС.
**Подделка отправителя в комнате.** Подписей нет; `from` ставит сервер. Участник комнаты может создать валидный шифротекст, но приписать его другому — только в сговоре с сервером. В 1:1 подделка невозможна без общего секрета.
**Метаданные.** Кто, с кем, когда и сообщениями какого размера обменивается, имена комнат и их составы, список устройств, когда они появлялись и куда им слать пуши, — серверу видно. Скрытие метаданных — не задача Bare.
**Компрометация устройства.** История лежит на устройстве в открытом виде (IndexedDB), там же — приватный ключ и секрет аккаунта как non-extractable `CryptoKey`. Доступ к устройству — доступ к истории и возможность писать от имени владельца. Защита устройства — зона ответственности пользователя и ОС. XSS в клиенте — отдельный риск того же класса; смягчение — CSP без исключений и запрет `innerHTML`.
**Слабый пароль.** Пароль — материал ключа. Ключевой блоб хранится на сервере, и его стойкость к оффлайн-перебору равна стойкости пароля. Гарантия «оператор не читает сообщения» действует в пределах стойкости пароля пользователя: слабый пароль — слабое E2EE. Это осознанная цена парольного мультидевайса. Контрмеры (ADR-013): PBKDF2-HMAC-SHA256 с не менее чем 600 000 итераций, пароль от 12 символов, рекомендация парольной фразы в UI.
@@ -32,6 +36,10 @@
## Осознанные пределы v1
**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1. Осознанный non-goal v1.
**Forward secrecy отсутствует.** Компрометация приватного ключа пользователя раскрывает ранее записанные атакующим шифротексты его чатов 1:1 и завёрнутые ключи комнат. Осознанный non-goal v1.
**Вышедший участник до rekey.** После выхода участника сервер перестаёт доставлять ему сообщения, а новый ключ комнаты создаёт владелец при следующем появлении. В промежутке вышедший участник знает действующий ключ; прочитать новые сообщения он может только в сговоре с сервером.
**Push-транспорт идёт через инфраструктуру вендоров браузеров** (FCM, APNs, Mozilla). Это свойство стандарта Web Push, а не наша зависимость. Вендоры видят факт и время доставки пуша.
**Сервер сам ходит по адресу, который выбрал браузер получателя.** Адрес push-сервиса приходит в подписке от клиента, и на каждое сообщение сервер открывает к нему исходящее соединение. Белого списка вендоров нет и не будет: адреса вендоров меняются, а подписку выдаёт браузер. Ограничения — ADR-047: только `https`, только публичные адреса (проверяется уже разрешённый адрес соединения), без следования за редиректами, адрес подписки в журнал не пишется. Остаток риска принят: аутентифицированный пользователь может заставить сервер обратиться к произвольному публичному адресу — до четырёх POST на аккаунт-получателя (доля аккаунта в отправке, ADR-048), то есть до 4×N на сообщение в комнату из N участников, в пределах общих лимитов и восьми отправщиков.
+109
View File
@@ -0,0 +1,109 @@
# Интерфейс
Визуальная система — `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, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний».
Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «экспортировать», «удалить» и «отмена» (ADR-029, ADR-051). «экспортировать» скачивает `.bare` прежнего аккаунта и подтверждение не закрывает; сессия для этого не нужна. База стирается после успешного входа или регистрации; отказ сервера её не трогает.
## Список чатов (сайдбар)
Секции «каналы» и «личные», как в моке. Активный чат — инверсия (ink на bone). Непрочитанные — число цветом `mark` справа. Порядок — по `lastId` по убыванию. Внизу — «ты: @nick», по нажатию — настройки. Над секциями — строка `+ новый чат`.
## Новый чат (`#/new`)
Две строки ввода: `@ник` → открыть личный чат; `#имя комнаты` → создать комнату. Ошибки: «такого ника нет», «нельзя писать себе».
## Чат
Шапка: имя (`#general` / `@marta`), по нажатию — участники или карточка контакта. Без темы и «N онлайн».
Лента: десктоп — сетка «автор 132 px + текст», подряд идущие сообщения одного автора — без повтора автора; мобильный — автор над группой. Свой ник в колонке автора — цветом `mark`. Разделители дат — линия с датой; «новые» — линия цветом `mark` перед первым непрочитанным, исчезает при следующем открытии чата. Pending — текст цветом `stone`; failed — с пометкой «не отправлено · повторить». Нерасшифрованное — курсивом: «не удалось расшифровать: ключ изменился» / «…: нет ключа комнаты». Время — `ts` в локальной зоне, `ЧЧ:ММ`.
Лента открывается последними 50 сообщениями и стоит в конце. Прокрутка к верхнему краю подгружает следующие 50; то, что человек читает, при этом не двигается. Загруженное остаётся в разметке целиком — виртуализации нет (ADR-053).
Ввод: рамка 1 px ink, слева `>` цветом `mark`, placeholder «сообщение в #general» / «сообщение»; имя комнаты в подсказке обрезается до 12 символов многоточием — «сообщение в #длинноеимя…» (ADR-061), в шапке оно остаётся полным. Enter — отправить, Shift+Enter — перенос; на мобильном Enter — перенос, отправка — кнопка `>` справа. Подсказка «enter — отправить» только на десктопе. Лимит 4000 — счётчик появляется после 3500.
Предупреждение о ключе — полоса над вводом цветом `mark`: «ключ @marta изменился. сверьте отпечаток лично. [доверять новому ключу]». Ввод заблокирован до подтверждения. Полоса одна: предупреждение о ключе перебивает отказ отправки и «нет соединения» (ADR-038).
Комната, из состава которой нас больше нет (вышли сами, убрал владелец, комната удалена), — та же полоса цветом `mark`: «вы больше не участник комнаты». Ввод заблокирован, лента остаётся. Эта полоса перебивает и предупреждение о ключе (ADR-044).
Отказ отправки — та же полоса над вводом цветом `mark` с текстом из поля `error` последнего неотправленного сообщения (ADR-033): «проверьте часы на устройстве: расхождение больше 5 минут», «слишком часто, попробуйте позже», «сервер не справился, попробуйте позже». Полоса исчезает при следующей попытке. Ввод не блокируется.
Первое отправленное сообщение за всю историю устройства → запрос разрешения на уведомления (см. «Уведомления»).
## Карточка контакта (`#/contact/<nick>`)
`@nick`, отпечаток 64 hex группами по 4 в две строки, строка «сверьте с собеседником голосом или лично». Если есть `pending` — оба отпечатка с пометками «старый» и «новый» и кнопка «доверять новому ключу». Кнопка «убрать из списка».
`pending` снимает и сервер, снова отдавший доверенный ключ: смены ключа не случилось, состояние закрывается само и молча (ADR-040).
## Участники (`#/room/<id>/members`)
Список ников; у владельца — пометка «владелец». Владельцу: строка ввода `@ник` + «добавить», у каждого участника «убрать». Всем: «выйти из комнаты»; владельцу — «удалить комнату» с подтверждением «комната будет удалена у всех участников.» и кнопками «удалить» и «отмена». Если клиент-владелец получил `needsRekey` и не может выполнить rekey из-за неподтверждённого ключа — полоса: «нужен новый ключ комнаты: подтвердите ключ @x». Тот же текст — строкой состояния формы, когда неподтверждённый ключ обрывает добавление или удаление участника; ников в нём бывает несколько, через запятую (ADR-038).
## Настройки (`#/settings`)
- «ты: @nick», свой отпечаток.
- «уведомления»: состояние (`включены` / `выключены` / `запрещены в браузере`), кнопка «включить» или «выключить». `запрещены в браузере` — разрешение отклонено или уведомлений в браузере нет вовсе; кнопки в этом состоянии нет (ADR-046). На iOS вне PWA — состояние `выключены` и вместо кнопки текст про установку, тот же, что в баннере.
- «установить приложение»: кнопка «установить», если есть `beforeinstallprompt`; на iOS вне PWA — инструкция «поделиться → на экран «домой»». Устанавливать нечего — раздела нет.
- «устройства»: список `id` (первые 8 символов), дата появления, «удалить». У своей строки кнопки нет: вместо неё пометка «это устройство». Своё устройство отсюда не отцепляется — это делает «выйти», где спрашивают про историю (ADR-052).
- «история»: «занято N МБ» — оценка браузера, до десятых, пока меньше десяти, дальше целые; браузер, который её не даёт, строки не показывает (ADR-052). «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». Число согласуется со словом: «добавлено 1 сообщение», «добавлено 2 сообщения», «добавлено 5 сообщений» (ADR-051).
- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён».
- «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». «экспортировать» — то же скачивание, что и в разделе «история»; подтверждение оно не закрывает (ADR-051).
- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена».
## Баннер установки (iOS)
Показывается при `iPhone|iPad` и `navigator.standalone !== true`, над списком чатов: «уведомления на iOS работают только у установленного приложения: поделиться → на экран «домой»». Крестик — «×» с подписью «закрыть» для экранного диктора — ставит `installBannerDismissed`, повтор не показывается.
## Уведомления
Запрос разрешения — после первого успешно отправленного сообщения, один раз (`notificationsAsked`). После `granted``pushManager.subscribe` с `vapidPublicKey` и `PUT /api/devices/{id}/push`. Отказ — молча; включить можно в настройках.
Выключенные кнопкой уведомления сами не включаются: ни первым сообщением, ни запуском приложения. Обратно их включает только кнопка (ADR-049).
Уведомление: заголовок — `@nick` отправителя или `#имя комнаты`, текст — «новое сообщение», нажатие открывает этот чат. Содержимого сообщения в уведомлении нет: сервер его не знает (ADR-011). Пуш о собственном сообщении не приходит (ADR-045).
## Сеть и состояния
- SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`.
- Вкладок одного профиля бывает несколько; поток событий держит одна из них, остальные получают изменения от неё и выглядят так же (ADR-035).
- Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`.
- `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут».
- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет.
## Тексты состояний
Общие для всех форм строки (ADR-028). Ошибка — цветом `mark`, ответ об успехе — цветом `mute`, место одно.
| состояние | текст |
|---|---|
| запрос не дошёл | «нет соединения» |
| код ответа, на который нет сценария (`internal`, `too_large`, прочее) | «сервер не справился, попробуйте позже» |
| `429 rate_limited` | «слишком часто, попробуйте позже» |
| пароль короче 12 символов | «пароль: не короче 12 символов» |
| новый пароль и повтор различаются | «пароли не совпадают» |
| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» |
| `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» |
| `401 invalid_credentials` в настройках | «неверный пароль» |
| архив не собрался: истории не прочитать или ключей аккаунта на устройстве нет | «экспорт не удался» |
| разобранный архив не дошёл до базы: нет места или ключей аккаунта | «импорт не удался» |
## Доступность
Семантика: `nav`, `main`, `form`, `button`, `ul/li` для списков; `aria-live="polite"` на ленте; фокус в строку ввода при открытии чата на десктопе; контраст ink/bone и mark/bone не ниже 4.5:1; цели нажатия на мобильном не меньше 44 px.
+12
View File
@@ -0,0 +1,12 @@
// Package bare встраивает клиентскую статику в бинарь.
//
// Директива go:embed видит только каталог своего пакета и ниже, поэтому
// объявление живёт в корне модуля, а не в internal/web (ADR-025).
package bare
import "embed"
// Web — каталог web/ как есть: index.html, app.css, manifest.json, sw.js, icons/.
//
//go:embed web
var Web embed.FS
+22
View File
@@ -0,0 +1,22 @@
module github.com/xmatic-squad/bare
go 1.27.0
require (
github.com/SherClockHolmes/webpush-go v1.4.0
golang.org/x/crypto v0.55.0
modernc.org/sqlite v1.57.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.47.0 // indirect
modernc.org/libc v1.74.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+120
View File
@@ -0,0 +1,120 @@
github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s=
github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI=
modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.74.4 h1:fX1Omw4o2/1C2iRkkIsrQTasJQldLhRmuPreXLoWs9k=
modernc.org/libc v1.74.4/go.mod h1:eeQAS9W3sZeKYMFubydxJpII9ybHWshk+7or7bLG9co=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.57.0 h1:qNQP6xnx5M0ISNtlnxoOX0+cD5bJ0/gr9aMmndFczzg=
modernc.org/sqlite v1.57.0/go.mod h1:yCJ2cmAaIkHQ25oXWrF8H4O1lIfPYPR26yCEDj2P3pQ=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+378
View File
@@ -0,0 +1,378 @@
package api
import (
"crypto/subtle"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/store"
)
// GET /api/config — то, что клиенту нужно знать до входа.
func (s *server) config(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, struct {
InviteRequired bool `json:"inviteRequired"`
VAPIDPublicKey string `json:"vapidPublicKey"`
KDFIterations int `json:"kdfIterations"`
MaxMessageChars int `json:"maxMessageChars"`
}{
InviteRequired: s.cfg.InviteCode != "",
VAPIDPublicKey: s.cfg.VAPIDPublic,
KDFIterations: config.KDFIterations,
MaxMessageChars: config.MaxMessageChars,
})
}
// GET /api/kdf?nick= — сколько итераций PBKDF2 брать для этого ника.
//
// Значение лежит открытым полем iter в ключевом блобе: другого места
// у него нет (docs/crypto.md). Неизвестный ник получает целевое значение
// тем же статусом 200. Скрытием существования ника этот ответ
// не занимается: после повышения цели у аккаунта, который с тех пор
// не входил, iter свой, и по числу его видно (ADR-062). Существование
// ника публично и так (ADR-019).
func (s *server) kdf(w http.ResponseWriter, r *http.Request) {
iterations := config.KDFIterations
if nick := r.URL.Query().Get("nick"); validNick(nick) {
u, err := s.st.User(r.Context(), nick)
switch {
case err == nil:
if iter, err := blobIterations(u.KeyBlob); err == nil {
iterations = iter
}
case errors.Is(err, store.ErrNotFound):
// молча: целевое значение
default:
s.internal(w, r, err)
return
}
}
writeJSON(w, http.StatusOK, struct {
Iterations int `json:"iterations"`
}{iterations})
}
// POST /api/register — регистрация. Сервер проверяет только форму:
// содержимое блоба и стойкость пароля ему недоступны by design.
func (s *server) register(w http.ResponseWriter, r *http.Request) {
var in struct {
Nick string `json:"nick"`
AuthKey string `json:"authKey"`
PublicKey json.RawMessage `json:"publicKey"`
Blob string `json:"blob"`
Invite string `json:"invite"`
}
if !decode(w, r, &in) {
return
}
// Форма — раньше инвайт-кода: он даёт право регистрироваться, а права
// идут после формы (ADR-043). Занятость ника этим не выдаётся: nick_taken
// живёт дальше по тексту, за инвайтом.
if !validNick(in.Nick) {
Error(w, http.StatusBadRequest, "invalid_nick", "ник: 232 символа, az, 09, _")
return
}
key, ok := authKey(in.AuthKey)
if !ok {
Invalid(w, "authKey", "authKey — не 32 байта base64url")
return
}
public, err := publicKeyJSON(in.PublicKey)
if err != nil {
Invalid(w, "publicKey", err.Error())
return
}
if _, err := blobIterations(in.Blob); err != nil {
Invalid(w, "blob", err.Error())
return
}
// Лимит — 5 в час на IP (ADR-021) — стоит раньше проверки инвайт-кода:
// иначе код подбирался бы запросами без счёта.
if wait, ok := s.regs.take(clientIP(r), time.Now()); !ok {
s.rateLimited(w, wait)
return
}
if code := s.cfg.InviteCode; code != "" {
if in.Invite == "" {
Error(w, http.StatusForbidden, "invite_required", "нужен инвайт-код")
return
}
if subtle.ConstantTimeCompare([]byte(code), []byte(in.Invite)) != 1 {
Error(w, http.StatusForbidden, "invalid_invite", "инвайт-код не подходит")
return
}
}
cred, err := auth.Hash(key)
if err != nil {
s.internal(w, r, err)
return
}
err = s.st.CreateUser(r.Context(), store.User{
Nick: in.Nick,
Cred: cred,
PublicKey: public,
KeyBlob: in.Blob,
CreatedAt: time.Now().UnixMilli(),
})
if errors.Is(err, store.ErrNickTaken) {
Error(w, http.StatusConflict, "nick_taken", "ник занят")
return
}
if err != nil {
s.internal(w, r, err)
return
}
if err := s.startSession(w, r, in.Nick); err != nil {
s.internal(w, r, err)
return
}
writeJSON(w, http.StatusCreated, struct {
Nick string `json:"nick"`
}{in.Nick})
}
// POST /api/login — вход. Ошибка одна на все случаи: неверный ник,
// неверный authKey и кривая форма неразличимы снаружи.
func (s *server) login(w http.ResponseWriter, r *http.Request) {
var in struct {
Nick string `json:"nick"`
AuthKey string `json:"authKey"`
}
if !decode(w, r, &in) {
return
}
key, ok := authKey(in.AuthKey)
if !ok || !validNick(in.Nick) {
invalidCredentials(w)
return
}
// Лимит — 10 за 10 минут на пару IP+ник (ADR-021) — стоит раньше
// хранилища и argon2: перебор не должен заказывать серверу работу.
// Ключ ведра собирается из адреса и ника через байт, которого нет
// ни в том ни в другом.
if wait, ok := s.logins.take(clientIP(r)+"\x00"+in.Nick, time.Now()); !ok {
s.rateLimited(w, wait)
return
}
u, err := s.st.User(r.Context(), in.Nick)
if errors.Is(err, store.ErrNotFound) {
// Считаем впустую: вход с несуществующим ником не должен
// отвечать заметно быстрее входа с неверным authKey.
auth.Waste(key)
invalidCredentials(w)
return
}
if err != nil {
s.internal(w, r, err)
return
}
valid, rehash := auth.Verify(key, u.Cred)
if !valid {
invalidCredentials(w)
return
}
if rehash {
// Параметры отстали от текущих (ADR-021). Не удалось перехешировать —
// не повод отказывать во входе: старый хеш остаётся рабочим.
if cred, err := auth.Hash(key); err != nil {
s.report(r, err)
} else if err := s.st.SetAuth(r.Context(), u.Nick, cred); err != nil {
s.report(r, err)
}
}
if err := s.startSession(w, r, u.Nick); err != nil {
s.internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
Blob string `json:"blob"`
}{u.Nick, json.RawMessage(u.PublicKey), u.KeyBlob})
}
// GET /api/me — кто вошёл.
func (s *server) me(w http.ResponseWriter, r *http.Request) {
u, ok := s.self(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
CreatedAt int64 `json:"createdAt"`
}{u.Nick, json.RawMessage(u.PublicKey), u.CreatedAt})
}
// POST /api/logout — выход на этом устройстве.
func (s *server) logout(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
if err := s.st.DeleteSession(r.Context(), sess.TokenHash); err != nil {
s.internal(w, r, err)
return
}
auth.ClearCookie(w)
noContent(w)
}
// POST /api/password — смена пароля и повышение итераций: одна операция
// (ADR-015). Хеш и блоб меняются в одной транзакции.
func (s *server) password(w http.ResponseWriter, r *http.Request) {
var in struct {
AuthKey string `json:"authKey"`
NewAuthKey string `json:"newAuthKey"`
Blob string `json:"blob"`
LogoutOthers bool `json:"logoutOthers"`
}
if !decode(w, r, &in) {
return
}
u, ok := s.self(w, r)
if !ok {
return
}
if !s.confirm(w, in.AuthKey, u) {
return
}
newKey, ok := authKey(in.NewAuthKey)
if !ok {
Invalid(w, "newAuthKey", "newAuthKey — не 32 байта base64url")
return
}
if _, err := blobIterations(in.Blob); err != nil {
Invalid(w, "blob", err.Error())
return
}
cred, err := auth.Hash(newKey)
if err != nil {
s.internal(w, r, err)
return
}
sess, _ := auth.From(r)
revoked, err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash)
if err != nil {
s.internal(w, r, err)
return
}
// Сессия проверяется при подключении к потоку, а не в его цикле,
// поэтому отозванная продолжала бы получать конверты до обрыва
// соединения. Отзыв доступа закрывает поток сам — тем же способом,
// что и удаление устройства (ADR-058).
for _, device := range revoked {
s.hub.Close(device)
}
noContent(w)
}
// DELETE /api/me — удаление аккаунта, подтверждённое authKey.
func (s *server) deleteMe(w http.ResponseWriter, r *http.Request) {
var in struct {
AuthKey string `json:"authKey"`
}
if !decode(w, r, &in) {
return
}
u, ok := s.self(w, r)
if !ok {
return
}
if !s.confirm(w, in.AuthKey, u) {
return
}
// Устройства, сессии, контакты, членство, ключи комнат и очереди уносит
// каскад; комнаты, где пользователь владелец, меняют владельца или
// удаляются пустыми (ADR-018) — всё в одной транзакции хранилища.
changes, err := s.st.DeleteUser(r.Context(), u.Nick)
if err != nil {
s.internal(w, r, err)
return
}
// Удаление аккаунта — выход из всех его комнат: оставшимся уходит room
// с needsRekey, каждому со своим ключом (ADR-041).
for _, change := range changes {
s.sendRoom(r, change, "")
}
auth.ClearCookie(w)
noContent(w)
}
// GET /api/users/{nick} — публичный ключ собеседника. Доверие к нему —
// TOFU на клиенте (ADR-016).
func (s *server) user(w http.ResponseWriter, r *http.Request) {
nick := r.PathValue("nick")
if !validNick(nick) {
unknownUser(w)
return
}
u, err := s.st.User(r.Context(), nick)
if errors.Is(err, store.ErrNotFound) {
unknownUser(w)
return
}
if err != nil {
s.internal(w, r, err)
return
}
writeJSON(w, http.StatusOK, struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
}{u.Nick, json.RawMessage(u.PublicKey)})
}
// self читает пользователя сессии. Строки нет — сессия недействительна:
// аккаунт удалён на другом устройстве.
func (s *server) self(w http.ResponseWriter, r *http.Request) (store.User, bool) {
sess, _ := auth.From(r)
u, err := s.st.User(r.Context(), sess.Nick)
if errors.Is(err, store.ErrNotFound) {
Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход")
return store.User{}, false
}
if err != nil {
s.internal(w, r, err)
return store.User{}, false
}
return u, true
}
// confirm проверяет authKey — подтверждение опасной операции.
func (s *server) confirm(w http.ResponseWriter, given string, u store.User) bool {
key, ok := authKey(given)
if !ok {
invalidCredentials(w)
return false
}
if valid, _ := auth.Verify(key, u.Cred); !valid {
invalidCredentials(w)
return false
}
return true
}
// startSession выдаёт сессию и ставит cookie.
func (s *server) startSession(w http.ResponseWriter, r *http.Request, nick string) error {
token, hash, err := auth.NewToken()
if err != nil {
return err
}
now := time.Now()
expires := now.Add(auth.TTL)
if err := s.st.CreateSession(r.Context(), hash, nick, now.UnixMilli(), expires.UnixMilli()); err != nil {
return err
}
auth.SetCookie(w, token, expires)
return nil
}
func invalidCredentials(w http.ResponseWriter) {
Error(w, http.StatusUnauthorized, "invalid_credentials", "неверный ник или пароль")
}
func unknownUser(w http.ResponseWriter) {
Error(w, http.StatusNotFound, "unknown_user", "такого ника нет")
}
+474
View File
@@ -0,0 +1,474 @@
package api_test
import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/xmatic-squad/bare/internal/api"
"github.com/xmatic-squad/bare/internal/config"
)
// bytesOf — детерминированные «случайные» байты: содержимое сервер
// не проверяет, ему важна только форма.
func bytesOf(n int, seed byte) string {
raw := make([]byte, n)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.RawURLEncoding.EncodeToString(raw)
}
// blobOf — ключевой блоб в форме docs/crypto.md.
func blobOf(iter int) string {
return fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`, iter, bytesOf(12, 7), bytesOf(48, 11))
}
func jwk() map[string]string {
return map[string]string{"kty": "EC", "crv": "P-256", "x": bytesOf(32, 3), "y": bytesOf(32, 5)}
}
func account(nick string) map[string]any {
return map[string]any{
"nick": nick,
"authKey": bytesOf(32, 1),
"publicKey": jwk(),
"blob": blobOf(config.KDFIterations),
}
}
// signUp регистрирует аккаунт и отдаёт cookie сессии. Каждый ник приходит
// со своего адреса: регистрация ограничена пятью в час на IP (ADR-021),
// и общий адрес упирался бы в лимит на шестом аккаунте теста.
func (e *env) signUp(nick string) *http.Cookie {
e.t.Helper()
rec := e.do(http.MethodPost, "/api/register", account(nick), fromNick(nick))
expect(e.t, rec, http.StatusCreated, "")
return e.cookie(rec)
}
// fromNick — свой адрес соединения на каждый ник, лишь бы разный
// и не loopback.
func fromNick(nick string) func(*http.Request) {
sum := sha256.Sum256([]byte(nick))
return withRemote(fmt.Sprintf("198.51.%d.%d:41000", sum[0], sum[1]))
}
func (e *env) cookie(rec *httptest.ResponseRecorder) *http.Cookie {
e.t.Helper()
for _, c := range rec.Result().Cookies() {
if c.Name == "bare_session" {
return c
}
}
e.t.Fatal("в ответе нет cookie bare_session")
return nil
}
func TestConfig(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodGet, "/api/config", nil)
expect(t, rec, http.StatusOK, "")
var got struct {
InviteRequired bool `json:"inviteRequired"`
VAPIDPublicKey string `json:"vapidPublicKey"`
KDFIterations int `json:"kdfIterations"`
MaxMessageChars int `json:"maxMessageChars"`
}
decodeBody(t, rec, &got)
if got.InviteRequired {
t.Error("inviteRequired: получено true, ожидалось false")
}
if got.VAPIDPublicKey != "vapid" {
t.Errorf("vapidPublicKey: получено %q", got.VAPIDPublicKey)
}
if got.KDFIterations != 1_000_000 {
t.Errorf("kdfIterations: получено %d, ожидалось 1000000", got.KDFIterations)
}
if got.MaxMessageChars != 4000 {
t.Errorf("maxMessageChars: получено %d, ожидалось 4000", got.MaxMessageChars)
}
}
func TestRegisterAndLogin(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodPost, "/api/register", account("marta"))
expect(t, rec, http.StatusCreated, "")
var created struct {
Nick string `json:"nick"`
}
decodeBody(t, rec, &created)
if created.Nick != "marta" {
t.Errorf("ник в ответе: получено %q", created.Nick)
}
c := e.cookie(rec)
if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteStrictMode || c.Path != "/" {
t.Errorf("флаги cookie: %+v", c)
}
if c.MaxAge < 89*24*3600 || c.MaxAge > 90*24*3600 {
t.Errorf("срок cookie: получено %d секунд, ожидалось около 90 суток", c.MaxAge)
}
// Занятый ник.
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusConflict, "nick_taken")
// Сессия из регистрации работает.
me := e.do(http.MethodGet, "/api/me", nil, with(c))
expect(t, me, http.StatusOK, "")
var self struct {
Nick string `json:"nick"`
PublicKey map[string]string `json:"publicKey"`
CreatedAt int64 `json:"createdAt"`
}
decodeBody(t, me, &self)
if self.Nick != "marta" || self.PublicKey["crv"] != "P-256" || self.CreatedAt == 0 {
t.Errorf("GET /api/me: %+v", self)
}
// Вход тем же authKey отдаёт публичный ключ и блоб.
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
var in struct {
Nick string `json:"nick"`
PublicKey map[string]string `json:"publicKey"`
Blob string `json:"blob"`
}
decodeBody(t, login, &in)
if in.Nick != "marta" || in.Blob != blobOf(config.KDFIterations) || in.PublicKey["x"] != bytesOf(32, 3) {
t.Errorf("вход: %+v", in)
}
if _, ok := in.PublicKey["d"]; ok {
t.Error("в публичном ключе есть d")
}
e.cookie(login)
// Неверный authKey и несуществующий ник неразличимы.
bad := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
expect(t, bad, http.StatusUnauthorized, "invalid_credentials")
none := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "никого", "authKey": bytesOf(32, 1)})
expect(t, none, http.StatusUnauthorized, "invalid_credentials")
unknown := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 1)})
expect(t, unknown, http.StatusUnauthorized, "invalid_credentials")
}
func TestRegisterRejects(t *testing.T) {
private := jwk()
private["d"] = bytesOf(32, 13)
cases := []struct {
name string
change func(map[string]any)
status int
code string
field string
}{
{"кривой ник", func(m map[string]any) { m["nick"] = "Марта" }, http.StatusBadRequest, "invalid_nick", ""},
{"короткий ник", func(m map[string]any) { m["nick"] = "m" }, http.StatusBadRequest, "invalid_nick", ""},
{"ник с заглавной", func(m map[string]any) { m["nick"] = "Marta" }, http.StatusBadRequest, "invalid_nick", ""},
{"короткий authKey", func(m map[string]any) { m["authKey"] = bytesOf(16, 1) }, http.StatusBadRequest, "invalid", "authKey"},
{"authKey не base64url", func(m map[string]any) { m["authKey"] = strings.Repeat("=", 44) }, http.StatusBadRequest, "invalid", "authKey"},
{"приватный ключ в jwk", func(m map[string]any) { m["publicKey"] = private }, http.StatusBadRequest, "invalid", "publicKey"},
{"чужая кривая", func(m map[string]any) {
k := jwk()
k["crv"] = "P-384"
m["publicKey"] = k
}, http.StatusBadRequest, "invalid", "publicKey"},
{"нет публичного ключа", func(m map[string]any) { delete(m, "publicKey") }, http.StatusBadRequest, "invalid", "publicKey"},
{"слабый iter", func(m map[string]any) { m["blob"] = blobOf(599_999) }, http.StatusBadRequest, "invalid", "blob"},
// Неподъёмный iter сервер отдал бы клиентам из GET /api/kdf (ADR-030).
{"неподъёмный iter", func(m map[string]any) {
m["blob"] = blobOf(config.KDFMaxIterations + 1)
}, http.StatusBadRequest, "invalid", "blob"},
{"iter в триллион", func(m map[string]any) { m["blob"] = blobOf(1_000_000_000_000) }, http.StatusBadRequest, "invalid", "blob"},
{"дробный iter", func(m map[string]any) {
m["blob"] = `{"v":1,"iter":1e6,"iv":"` + bytesOf(12, 7) + `","ct":"` + bytesOf(48, 11) + `"}`
}, http.StatusBadRequest, "invalid", "blob"},
{"версия блоба", func(m map[string]any) {
m["blob"] = strings.Replace(blobOf(config.KDFIterations), `"v":1`, `"v":2`, 1)
}, http.StatusBadRequest, "invalid", "blob"},
{"блоб больше 8 КиБ", func(m map[string]any) {
m["blob"] = fmt.Sprintf(`{"v":1,"iter":%d,"iv":"%s","ct":"%s"}`,
config.KDFIterations, bytesOf(12, 7), strings.Repeat("a", 8<<10))
}, http.StatusBadRequest, "invalid", "blob"},
{"блоб не json", func(m map[string]any) { m["blob"] = "не json" }, http.StatusBadRequest, "invalid", "blob"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
body := account("marta")
c.change(body)
rec := e.do(http.MethodPost, "/api/register", body)
expect(t, rec, c.status, c.code)
if c.field != "" {
var got struct {
Field string `json:"field"`
}
decodeBody(t, rec, &got)
if got.Field != c.field {
t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field)
}
}
// Ни одна из этих регистраций не должна была создать аккаунт.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
})
}
}
func TestRegisterBadJSON(t *testing.T) {
e := newEnv(t)
expect(t, e.do(http.MethodPost, "/api/register", "{"), http.StatusBadRequest, "bad_json")
}
func TestInvite(t *testing.T) {
e := invited(t, "секрет")
rec := e.do(http.MethodGet, "/api/config", nil)
var cfg struct {
InviteRequired bool `json:"inviteRequired"`
}
decodeBody(t, rec, &cfg)
if !cfg.InviteRequired {
t.Error("inviteRequired: получено false, ожидалось true")
}
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusForbidden, "invite_required")
wrong := account("marta")
wrong["invite"] = "не секрет"
expect(t, e.do(http.MethodPost, "/api/register", wrong), http.StatusForbidden, "invalid_invite")
right := account("marta")
right["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", right), http.StatusCreated, "")
}
func TestKDF(t *testing.T) {
e := newEnv(t)
// Неизвестный ник — целевое значение, тем же статусом.
for _, nick := range []string{"marta", "", "МАРТА", strings.Repeat("x", 40)} {
rec := e.do(http.MethodGet, "/api/kdf?nick="+nick, nil)
expect(t, rec, http.StatusOK, "")
var got struct {
Iterations int `json:"iterations"`
}
decodeBody(t, rec, &got)
if got.Iterations != config.KDFIterations {
t.Errorf("iterations для %q: получено %d, ожидалось %d", nick, got.Iterations, config.KDFIterations)
}
}
// Известный ник — iter из его блоба.
body := account("marta")
body["blob"] = blobOf(700_000)
expect(t, e.do(http.MethodPost, "/api/register", body), http.StatusCreated, "")
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
expect(t, rec, http.StatusOK, "")
var got struct {
Iterations int `json:"iterations"`
}
decodeBody(t, rec, &got)
if got.Iterations != 700_000 {
t.Errorf("iterations: получено %d, ожидалось 700000", got.Iterations)
}
}
func TestOrigin(t *testing.T) {
e := newEnv(t)
body := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("")), http.StatusForbidden, "bad_origin")
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("https://зло.example")), http.StatusForbidden, "bad_origin")
expect(t, e.do(http.MethodPost, "/api/login", body, withOrigin("null")), http.StatusForbidden, "bad_origin")
// GET без Origin работает.
expect(t, e.do(http.MethodGet, "/api/config", nil), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/", nil), http.StatusOK, "")
// Свой Origin проходит: дальше — обычная ошибка входа, не 403.
expect(t, e.do(http.MethodPost, "/api/login", body), http.StatusUnauthorized, "invalid_credentials")
}
func TestPasswordChange(t *testing.T) {
e := newEnv(t)
first := e.signUp("marta")
// Второе устройство: свой вход, своя сессия.
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
second := e.cookie(login)
newBlob := blobOf(config.KDFIterations)
change := map[string]any{
"authKey": bytesOf(32, 1),
"newAuthKey": bytesOf(32, 9),
"blob": newBlob,
"logoutOthers": true,
}
// Без сессии — 401 unauthenticated, а не invalid_credentials.
expect(t, e.do(http.MethodPost, "/api/password", change), http.StatusUnauthorized, "unauthenticated")
// Неверный старый authKey.
wrong := map[string]any{"authKey": bytesOf(32, 42), "newAuthKey": bytesOf(32, 9), "blob": newBlob}
expect(t, e.do(http.MethodPost, "/api/password", wrong, with(second)), http.StatusUnauthorized, "invalid_credentials")
// Слабый новый блоб не принимается.
weak := map[string]any{"authKey": bytesOf(32, 1), "newAuthKey": bytesOf(32, 9), "blob": blobOf(599_999)}
expect(t, e.do(http.MethodPost, "/api/password", weak, with(second)), http.StatusBadRequest, "invalid")
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
// Текущая сессия жива, остальные — нет.
expect(t, e.do(http.MethodGet, "/api/me", nil, with(second)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusUnauthorized, "unauthenticated")
// Старый authKey больше не подходит, новый отдаёт новый блоб.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
fresh := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)})
expect(t, fresh, http.StatusOK, "")
var got struct {
Blob string `json:"blob"`
}
decodeBody(t, fresh, &got)
if got.Blob != newBlob {
t.Errorf("блоб после смены пароля: получено %q", got.Blob)
}
}
// Повышение итераций — та же операция без выхода на других устройствах.
func TestPasswordKeepsOtherSessions(t *testing.T) {
e := newEnv(t)
first := e.signUp("marta")
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
second := e.cookie(login)
change := map[string]any{
"authKey": bytesOf(32, 1),
"newAuthKey": bytesOf(32, 9),
"blob": blobOf(config.KDFIterations),
"logoutOthers": false,
}
expect(t, e.do(http.MethodPost, "/api/password", change, with(second)), http.StatusNoContent, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(first)), http.StatusOK, "")
}
func TestSessionRequired(t *testing.T) {
e := newEnv(t)
e.signUp("marta")
for _, target := range []string{"/api/me", "/api/users/marta"} {
expect(t, e.do(http.MethodGet, target, nil), http.StatusUnauthorized, "unauthenticated")
}
expect(t, e.do(http.MethodPost, "/api/logout", nil), http.StatusUnauthorized, "unauthenticated")
garbage := &http.Cookie{Name: "bare_session", Value: "not-a-token"}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(garbage)), http.StatusUnauthorized, "unauthenticated")
stranger := &http.Cookie{Name: "bare_session", Value: bytesOf(32, 77)}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(stranger)), http.StatusUnauthorized, "unauthenticated")
}
func TestLogout(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
rec := e.do(http.MethodPost, "/api/logout", nil, with(c))
expect(t, rec, http.StatusNoContent, "")
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
t.Errorf("cookie не стёрта: %+v", cleared)
}
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
}
func TestUsers(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
rec := e.do(http.MethodGet, "/api/users/marta", nil, with(c))
expect(t, rec, http.StatusOK, "")
var got struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
}
decodeBody(t, rec, &got)
if got.Nick != "marta" || !strings.Contains(string(got.PublicKey), `"P-256"`) {
t.Errorf("ответ: %s", rec.Body.String())
}
expect(t, e.do(http.MethodGet, "/api/users/petya", nil, with(c)), http.StatusNotFound, "unknown_user")
expect(t, e.do(http.MethodGet, "/api/users/МАРТА", nil, with(c)), http.StatusNotFound, "unknown_user")
}
func TestDeleteMe(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
expect(t, e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 42)}, with(c)),
http.StatusUnauthorized, "invalid_credentials")
rec := e.do(http.MethodDelete, "/api/me", map[string]any{"authKey": bytesOf(32, 1)}, with(c))
expect(t, rec, http.StatusNoContent, "")
if cleared := e.cookie(rec); cleared.Value != "" || cleared.MaxAge >= 0 {
t.Errorf("cookie не стёрта: %+v", cleared)
}
// Сессия ушла каскадом, ник свободен.
expect(t, e.do(http.MethodGet, "/api/me", nil, with(c)), http.StatusUnauthorized, "unauthenticated")
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
expect(t, e.do(http.MethodPost, "/api/register", account("marta")), http.StatusCreated, "")
}
// Ник не должен попадать в журнал (docs/deploy.md, «Логи»).
func TestNickStaysOutOfLog(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
e.log.Reset()
e.do(http.MethodGet, "/api/users/marta", nil, with(c))
e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
if strings.Contains(e.log.String(), "marta") {
t.Errorf("ник в журнале: %q", e.log.String())
}
if !strings.Contains(e.log.String(), "/api/users/{nick}") {
t.Errorf("шаблон маршрута не в журнале: %q", e.log.String())
}
}
// Отказ до маршрутизации — шаблона ещё нет, а путь с ником в журнал
// попадать не должен всё равно (docs/deploy.md, «Логи»).
func TestNickStaysOutOfLogBeforeRouting(t *testing.T) {
e := newEnv(t)
// 403 bad_origin: любой не-GET со стороннего сайта.
e.do(http.MethodPost, "/api/users/marta", nil, withOrigin("https://зло.example"))
e.do(http.MethodDelete, "/api/contacts/marta", nil, withOrigin(""))
// 413 too_large: тело больше предела, ответ до маршрутизации.
e.do(http.MethodGet, "/api/users/marta", strings.Repeat("a", api.MaxBody+1))
line := e.log.String()
if strings.Count(line, "\n") != 3 {
t.Fatalf("строк в журнале: %q", line)
}
if strings.Contains(line, "marta") {
t.Errorf("ник в журнале: %q", line)
}
if strings.Count(line, "/api/ ") != 3 {
t.Errorf("вместо пути ожидалось \"/api/\": %q", line)
}
}
+318
View File
@@ -0,0 +1,318 @@
// Package api собирает маршруты и общие для всех ответов правила:
// заголовки безопасности (ADR-021), проверку Origin, лимит тела запроса,
// лог в stdout.
package api
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/push"
"github.com/xmatic-squad/bare/internal/store"
)
// MaxBody — предел тела запроса, 32 КиБ (ADR-021).
const MaxBody = 32 << 10
// maxLogPath — сколько байт пути попадает в строку лога.
const maxLogPath = 256
// csp — политика из ADR-021. HSTS ставит nginx, здесь его нет.
const csp = "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
// server — общее для обработчиков: настройки, база, открытые потоки
// событий, лимиты, куда писать журнал.
type server struct {
cfg *config.Config
st *store.Store
hub *hub.Hub
push *push.Sender
// Лимиты ADR-021: у каждого правила своё ведро и свой ключ.
regs *buckets // регистрация — по адресу
logins *buckets // вход — по паре адрес+ник
msgs *buckets // сообщения — по нику
writes *buckets // остальные изменяющие запросы — по нику
logw io.Writer
}
// Handler — обработчик всех маршрутов, живые SSE-потоки и очередь пушей
// за ним.
type Handler struct {
http.Handler
hub *hub.Hub
push *push.Sender
}
// CloseStreams закрывает открытые потоки событий. Без этого остановка
// сервера ждала бы, пока клиенты уйдут сами: у потока нет конца (ADR-004).
// Ничего не ждёт сама и потому годится в http.Server.RegisterOnShutdown.
func (h *Handler) CloseStreams() {
h.hub.CloseAll()
}
// Close останавливает всё, что живёт за обработчиком: потоки событий
// и отправку пушей, — и дожидается начатых отправок. Отправщики пишут
// в базу, поэтому Close обязан случиться до её закрытия.
func (h *Handler) Close() {
h.CloseStreams()
h.push.Close()
}
// New собирает обработчик: /api/, /healthz, всё остальное — статика.
// logw — куда писать строки запросов и причины отказов; nil отключает лог.
func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) *Handler {
// Отправитель пушей спрашивает у hub, подключено ли устройство:
// решение «пуш только молчащему» принимается в момент захвата права
// на него, а не при постановке в очередь (ADR-023).
live := hub.New()
s := &server{
cfg: cfg,
st: st,
hub: live,
push: push.New(cfg, st, live.Connected, logw),
regs: newBuckets(registerRule),
logins: newBuckets(loginRule),
msgs: newBuckets(messagesRule),
writes: newBuckets(writesRule),
logw: logw,
}
fail := auth.Fail{Error: Error, Internal: s.internal}
// Сессия проверяется на всех непубличных маршрутах (docs/protocol.md).
private := auth.Require(st, fail)
// write — сессия плюс общий лимит изменяющих запросов (ADR-021).
// Под него идут все непубличные маршруты кроме чтений и отправки
// сообщений: у сообщений своё правило.
write := func(h http.HandlerFunc) http.Handler { return private(s.limitWrites(h)) }
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", healthz)
mux.HandleFunc("GET /api/config", s.config)
mux.HandleFunc("GET /api/kdf", s.kdf)
mux.HandleFunc("POST /api/register", s.register)
mux.HandleFunc("POST /api/login", s.login)
mux.Handle("GET /api/me", private(http.HandlerFunc(s.me)))
mux.Handle("DELETE /api/me", write(s.deleteMe))
mux.Handle("POST /api/logout", write(s.logout))
mux.Handle("POST /api/password", write(s.password))
mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user)))
mux.Handle("POST /api/devices", write(s.createDevice))
mux.Handle("GET /api/devices", private(http.HandlerFunc(s.devices)))
mux.Handle("DELETE /api/devices/{id}", write(s.deleteDevice))
mux.Handle("PUT /api/devices/{id}/push", write(s.setPush))
mux.Handle("DELETE /api/devices/{id}/push", write(s.deletePush))
mux.Handle("GET /api/contacts", private(http.HandlerFunc(s.contacts)))
mux.Handle("POST /api/contacts", write(s.addContact))
mux.Handle("DELETE /api/contacts/{nick}", write(s.deleteContact))
mux.Handle("GET /api/rooms", private(http.HandlerFunc(s.rooms)))
mux.Handle("POST /api/rooms", write(s.createRoom))
mux.Handle("POST /api/rooms/{id}/members", write(s.updateMembers))
mux.Handle("POST /api/rooms/{id}/leave", write(s.leaveRoom))
mux.Handle("DELETE /api/rooms/{id}", write(s.deleteRoom))
mux.Handle("GET /api/events", private(http.HandlerFunc(s.events)))
// Сообщения считаются своим правилом, поэтому мимо write: лимит стоит
// в самом обработчике, там, где его место в порядке проверок
// (docs/protocol.md, «Сообщения»).
mux.Handle("POST /api/messages", private(http.HandlerFunc(s.sendMessage)))
mux.Handle("POST /api/ack", write(s.ack))
// Всё прочее под /api/ — 404, включая неподдерживаемый метод известного
// пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт
// запросам к /api/ уходить в обработчик статики.
mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) })
mux.Handle("/", static)
return &Handler{
Handler: logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))),
hub: s.hub,
push: s.push,
}
}
func healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
io.WriteString(w, "ok")
}
// errorBody — единственная форма ошибки в протоколе.
type errorBody struct {
Error string `json:"error"`
Field string `json:"field,omitempty"`
Message string `json:"message"`
}
// Error пишет ошибку в форме протокола: {"error": код, "message": текст}.
// Единственное место, где эта форма собирается, — коды берутся из
// перечня в docs/protocol.md.
func Error(w http.ResponseWriter, status int, code, message string) {
writeJSON(w, status, errorBody{Error: code, Message: message})
}
// Invalid — 400 invalid с полем, на котором остановилась валидация.
func Invalid(w http.ResponseWriter, field, message string) {
writeJSON(w, http.StatusBadRequest, errorBody{Error: "invalid", Field: field, Message: message})
}
// NotFound — ответ на неизвестный путь и на неподдерживаемый метод
// известного пути (ADR-026).
func NotFound(w http.ResponseWriter) {
Error(w, http.StatusNotFound, "not_found", "такого пути нет")
}
// internal — 500: сбой на нашей стороне. Клиенту уходит только код,
// причина — в журнал сервера (ADR-027).
func (s *server) internal(w http.ResponseWriter, r *http.Request, err error) {
s.report(r, err)
Error(w, http.StatusInternalServerError, "internal", "сервер не справился, попробуйте позже")
}
// report кладёт причину в журнал. Ник в строку не попадает: пишется
// шаблон маршрута (docs/deploy.md, «Логи»).
func (s *server) report(r *http.Request, err error) {
if s.logw == nil {
return
}
fmt.Fprintf(s.logw, "%s %s %s ошибка: %v\n",
time.Now().Format(time.RFC3339), r.Method, logTarget(r), err)
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
func noContent(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
// decode разбирает тело запроса в v. Ответ об ошибке уже написан,
// если вернулось false.
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
if err := json.NewDecoder(r.Body).Decode(v); err != nil {
var large *http.MaxBytesError
if errors.As(err, &large) {
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
return false
}
Error(w, http.StatusBadRequest, "bad_json", "тело запроса — не json")
return false
}
return true
}
// headers ставит заголовки безопасности на каждый ответ, включая ошибки.
func headers(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
h := w.Header()
h.Set("Content-Security-Policy", csp)
h.Set("Referrer-Policy", "no-referrer")
h.Set("X-Content-Type-Options", "nosniff")
next.ServeHTTP(w, r)
})
}
// limitBody отрезает тело на 32 КиБ. Заявленный размер сверх лимита
// отклоняется сразу, незаявленный — обрывается при чтении.
func limitBody(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.ContentLength > MaxBody {
Error(w, http.StatusRequestEntityTooLarge, "too_large", "тело запроса больше 32 КиБ")
return
}
if r.Body != nil {
r.Body = http.MaxBytesReader(w, r.Body, MaxBody)
}
next.ServeHTTP(w, r)
})
}
// logging пишет время, метод, путь, статус и длительность.
// Ни IP, ни ник, ни query в лог не попадают.
func logging(out io.Writer, next http.Handler) http.Handler {
if out == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &recorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
fmt.Fprintf(out, "%s %s %s %d %s\n",
start.Format(time.RFC3339),
r.Method,
logTarget(r),
rec.status,
time.Since(start).Round(time.Microsecond))
})
}
// logTarget — что пишется в журнал вместо пути. Для маршрутов /api/ —
// шаблон, а не путь: ник из GET /api/users/{nick} в журнал попадать
// не должен (docs/deploy.md, «Логи»). Для статики — сам путь: там
// пользовательских данных нет, а знать, какой файл не нашёлся, полезно.
// Шаблон известен после маршрутизации, поэтому вызывается после ответа.
//
// Шаблона может не быть вовсе: проверка Origin и предел тела отвечают
// раньше маршрутизации. Тогда для /api/ пишется голое "/api/" — путь
// с ником в журнал не уходит и в этом случае.
func logTarget(r *http.Request) string {
if p := patternPath(r.Pattern); strings.HasPrefix(p, "/api/") {
return p
}
if strings.HasPrefix(r.URL.Path, "/api/") {
return "/api/"
}
return logPath(r.URL)
}
// patternPath отрезает от шаблона метод: "GET /api/users/{nick}" → путь.
func patternPath(pattern string) string {
if i := strings.LastIndexByte(pattern, ' '); i >= 0 {
return pattern[i+1:]
}
return pattern
}
// logPath даёт путь в percent-форме: перевод строки, escape-последовательности
// и прочие управляющие байты в журнал не попадают — иначе любой запрос
// подделывал бы строки в journald. Длинный путь обрезается.
func logPath(u *url.URL) string {
p := u.EscapedPath()
if len(p) > maxLogPath {
return p[:maxLogPath] + "…"
}
return p
}
type recorder struct {
http.ResponseWriter
status int
}
func (r *recorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
// Unwrap отдаёт исходный ResponseWriter: через него http.ResponseController
// добирается до Flush и Hijack. Без этого SSE (docs/protocol.md, «События»)
// буферизовался бы — лог стоит самым внешним слоем и виден всем маршрутам.
func (r *recorder) Unwrap() http.ResponseWriter { return r.ResponseWriter }
+356
View File
@@ -0,0 +1,356 @@
package api_test
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"github.com/xmatic-squad/bare/internal/api"
"github.com/xmatic-squad/bare/internal/config"
"github.com/xmatic-squad/bare/internal/store"
"github.com/xmatic-squad/bare/internal/web"
)
const origin = "https://bare.test"
// env — сервер на временной базе плюс журнал, в который он пишет.
// Обработчик хранится своим типом: тестам нужен не только ServeHTTP,
// но и остановка — Close и CloseStreams.
type env struct {
t *testing.T
h *api.Handler
st *store.Store
log *syncLog
srv *httptest.Server
}
// syncLog — журнал сервера в памяти. Под замком, потому что пишут в него
// и обработчики, вызванные напрямую, и обработчики настоящего сервера
// из live: у них разные горутины.
type syncLog struct {
mu sync.Mutex
buf bytes.Buffer
}
func (l *syncLog) Write(p []byte) (int, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.Write(p)
}
func (l *syncLog) String() string {
l.mu.Lock()
defer l.mu.Unlock()
return l.buf.String()
}
func (l *syncLog) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.buf.Reset()
}
func newEnv(t *testing.T) *env { return invited(t, "") }
// invited — сервер на временной базе; непустой code включает инвайты.
func invited(t *testing.T, code string) *env {
t.Helper()
return envWith(t, func(cfg *config.Config) { cfg.InviteCode = code })
}
// envWith — сервер на временной базе; tweak правит конфигурацию до старта.
func envWith(t *testing.T, tweak func(*config.Config)) *env {
t.Helper()
static, err := web.New()
if err != nil {
t.Fatalf("web.New: %v", err)
}
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { st.Close() })
cfg := &config.Config{
Addr: "127.0.0.1:0",
DB: "bare.db",
Origin: origin,
VAPIDPublic: "vapid",
}
tweak(cfg)
e := &env{t: t, st: st, log: &syncLog{}}
h := api.New(cfg, st, static, e.log)
// Обработчик закрывается раньше базы: отправщики пушей дописывают
// начатое, а база им ещё нужна.
t.Cleanup(h.Close)
e.h = h
return e
}
// do отправляет запрос. Origin для методов кроме GET и HEAD ставится сам —
// без него любой такой запрос получил бы 403 (ADR-021).
func (e *env) do(method, target string, body any, opts ...func(*http.Request)) *httptest.ResponseRecorder {
e.t.Helper()
var reader io.Reader
switch v := body.(type) {
case nil:
case string:
reader = strings.NewReader(v)
default:
raw, err := json.Marshal(v)
if err != nil {
e.t.Fatalf("сборка тела: %v", err)
}
reader = bytes.NewReader(raw)
}
r := httptest.NewRequest(method, target, reader)
if method != http.MethodGet && method != http.MethodHead {
r.Header.Set("Origin", origin)
}
for _, opt := range opts {
opt(r)
}
rec := httptest.NewRecorder()
e.h.ServeHTTP(rec, r)
return rec
}
// live поднимает настоящий сервер на том же обработчике. Нужен потоку
// событий: httptest.ResponseRecorder не отдаёт тело, пока обработчик
// не вернулся, а поток не возвращается никогда.
func (e *env) live() *httptest.Server {
e.t.Helper()
if e.srv == nil {
e.srv = httptest.NewServer(e.h)
e.t.Cleanup(e.srv.Close)
}
return e.srv
}
func with(c *http.Cookie) func(*http.Request) {
return func(r *http.Request) {
if c != nil {
r.AddCookie(c)
}
}
}
func withDevice(id string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("X-Device", id) }
}
// withRemote — адрес, с которого пришло соединение. От него зависят лимиты
// на IP (ADR-021); httptest ставит всем один и тот же.
func withRemote(addr string) func(*http.Request) {
return func(r *http.Request) { r.RemoteAddr = addr }
}
// withRealIP — заголовок, который ставит nginx. Читается, только если
// соединение пришло с loopback (ADR-055).
func withRealIP(ip string) func(*http.Request) {
return func(r *http.Request) { r.Header.Set("X-Real-IP", ip) }
}
// retryAfterOf — Retry-After ответа: целые секунды, не меньше одной
// (docs/protocol.md, «Общие правила»).
func retryAfterOf(t *testing.T, rec *httptest.ResponseRecorder) int {
t.Helper()
raw := rec.Header().Get("Retry-After")
seconds, err := strconv.Atoi(raw)
if err != nil {
t.Fatalf("Retry-After: получено %q, ожидались целые секунды", raw)
}
if seconds < 1 {
t.Errorf("Retry-After: получено %d, ожидалось не меньше 1", seconds)
}
return seconds
}
func withOrigin(value string) func(*http.Request) {
return func(r *http.Request) {
if value == "" {
r.Header.Del("Origin")
return
}
r.Header.Set("Origin", value)
}
}
// code достаёт код ошибки из тела ответа.
func code(t *testing.T, rec *httptest.ResponseRecorder) string {
t.Helper()
var body struct {
Error string `json:"error"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
}
return body.Error
}
// expect проверяет статус и код ошибки; код "" — ответ без ошибки.
func expect(t *testing.T, rec *httptest.ResponseRecorder, status int, errCode string) {
t.Helper()
if rec.Code != status {
t.Fatalf("статус: получено %d (%s), ожидалось %d", rec.Code, rec.Body.String(), status)
}
if errCode != "" {
if got := code(t, rec); got != errCode {
t.Errorf("код ошибки: получено %q, ожидалось %q", got, errCode)
}
}
}
func decodeBody(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("разбор тела %q: %v", rec.Body.String(), err)
}
}
func TestHealthz(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodGet, "/healthz", nil)
if rec.Code != http.StatusOK {
t.Errorf("статус: получено %d, ожидалось 200", rec.Code)
}
if rec.Body.String() != "ok" {
t.Errorf("тело: получено %q, ожидалось \"ok\"", rec.Body.String())
}
want := map[string]string{
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
"Referrer-Policy": "no-referrer",
"X-Content-Type-Options": "nosniff",
}
for header, value := range want {
if got := rec.Header().Get(header); got != value {
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
}
}
}
func TestStaticNotModified(t *testing.T) {
e := newEnv(t)
first := e.do(http.MethodGet, "/app.css", nil)
if first.Code != http.StatusOK {
t.Fatalf("статус: получено %d, ожидалось 200", first.Code)
}
etag := first.Header().Get("ETag")
if etag == "" {
t.Fatal("нет ETag")
}
if got := first.Header().Get("Cache-Control"); got != "no-cache" {
t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got)
}
second := e.do(http.MethodGet, "/app.css", nil, func(r *http.Request) {
r.Header.Set("If-None-Match", etag)
})
if second.Code != http.StatusNotModified {
t.Errorf("статус: получено %d, ожидалось 304", second.Code)
}
if second.Body.Len() != 0 {
t.Errorf("тело 304 не пустое: %q", second.Body.String())
}
if got := second.Header().Get("ETag"); got != etag {
t.Errorf("ETag на 304: получено %q, ожидалось %q", got, etag)
}
}
func TestBodyTooLarge(t *testing.T) {
e := newEnv(t)
rec := e.do(http.MethodPost, "/api/login", strings.Repeat("a", api.MaxBody+1))
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
// Тело без заявленной длины обрывается при чтении — тем же кодом.
func TestBodyTooLargeUnannounced(t *testing.T) {
e := newEnv(t)
// Тело — валидный json, чтобы разбор дошёл до предела чтения, а не
// споткнулся о первый же байт.
body := `{"nick":"` + strings.Repeat("a", api.MaxBody) + `"}`
rec := e.do(http.MethodPost, "/api/login", nil, func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(body))
r.ContentLength = -1
})
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
// Неподдерживаемый метод на известном пути — 404 not_found (ADR-026).
func TestStaticRejectsWrite(t *testing.T) {
e := newEnv(t)
expect(t, e.do(http.MethodPost, "/app.css", nil), http.StatusNotFound, "not_found")
}
func TestMethodOnKnownAPIPathIs404(t *testing.T) {
e := newEnv(t)
expect(t, e.do(http.MethodPost, "/api/me", nil), http.StatusNotFound, "not_found")
expect(t, e.do(http.MethodGet, "/api/nope", nil), http.StatusNotFound, "not_found")
}
// Путь из запроса не должен уметь дописать строку в журнал.
func TestLogPathEscaped(t *testing.T) {
e := newEnv(t)
target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms"
e.do(http.MethodGet, target, nil)
line := e.log.String()
if n := strings.Count(line, "\n"); n != 1 {
t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line)
}
if !strings.Contains(line, "%0a") {
t.Errorf("путь не в percent-форме: %q", line)
}
e.log.Reset()
e.do(http.MethodGet, "/"+strings.Repeat("z", 4096), nil)
if len(e.log.String()) > 512 {
t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(e.log.String()))
}
}
// SSE (docs/protocol.md, «События») флашит каждое событие: обёртка логгера
// не должна прятать Flush от http.ResponseController.
func TestFlushThroughMiddleware(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "bare.db"))
if err != nil {
t.Fatalf("store.Open: %v", err)
}
defer st.Close()
var flushErr error
cfg := &config.Config{Addr: "127.0.0.1:0", DB: "bare.db", Origin: origin}
h := api.New(cfg, st, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
flushErr = http.NewResponseController(w).Flush()
}), io.Discard)
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil))
if flushErr != nil {
t.Errorf("Flush: %v", flushErr)
}
}
func TestInternalErrorHasCode(t *testing.T) {
e := newEnv(t)
// Закрытая база — единственный простой способ получить сбой хранилища.
e.st.Close()
rec := e.do(http.MethodGet, "/api/kdf?nick=marta", nil)
expect(t, rec, http.StatusInternalServerError, "internal")
if !strings.Contains(e.log.String(), "ошибка:") {
t.Errorf("причина не попала в журнал: %q", e.log.String())
}
if strings.Contains(rec.Body.String(), "sql") {
t.Errorf("причина уехала клиенту: %q", rec.Body.String())
}
}
+97
View File
@@ -0,0 +1,97 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/store"
)
// Контакт — строка в списке чатов, не разрешение на переписку: писать
// можно любому нику, согласия не требуется (ADR-019).
// GET /api/contacts — список чатов 1:1 с публичными ключами собеседников.
func (s *server) contacts(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
list, err := s.st.Contacts(r.Context(), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
type contactOut struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
CreatedAt int64 `json:"createdAt"`
}
out := make([]contactOut, 0, len(list))
for _, c := range list {
out = append(out, contactOut{c.Nick, json.RawMessage(c.PublicKey), c.CreatedAt})
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/contacts — завести чат с ником вручную, до первого сообщения.
func (s *server) addContact(w http.ResponseWriter, r *http.Request) {
var in struct {
Nick string `json:"nick"`
}
if !decode(w, r, &in) {
return
}
sess, _ := auth.From(r)
peer, ok := s.peer(w, r, in.Nick, sess.Nick)
if !ok {
return
}
created, err := s.st.AddContact(r.Context(), sess.Nick, peer.Nick, time.Now().UnixMilli())
if err != nil {
s.internal(w, r, err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
}{peer.Nick, json.RawMessage(peer.PublicKey)})
}
// DELETE /api/contacts/{nick} — убрать чат из списка. Зеркальная строка
// у собеседника остаётся: это не блокировка (ADR-019). Строки не было —
// тот же 204, удалять нечего.
func (s *server) deleteContact(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
if err := s.st.DeleteContact(r.Context(), sess.Nick, r.PathValue("nick")); err != nil {
s.internal(w, r, err)
return
}
noContent(w)
}
// peer читает собеседника по нику. Порядок отказов — docs/protocol.md:
// сначала существование ника, потом запрет писать себе.
func (s *server) peer(w http.ResponseWriter, r *http.Request, nick, me string) (store.User, bool) {
if !validNick(nick) {
unknownUser(w)
return store.User{}, false
}
u, err := s.st.User(r.Context(), nick)
if errors.Is(err, store.ErrNotFound) {
unknownUser(w)
return store.User{}, false
}
if err != nil {
s.internal(w, r, err)
return store.User{}, false
}
if u.Nick == me {
Error(w, http.StatusBadRequest, "self", "нельзя писать себе")
return store.User{}, false
}
return u, true
}
+85
View File
@@ -0,0 +1,85 @@
package api_test
import (
"encoding/json"
"net/http"
"testing"
)
// contactOut — строка ответа GET /api/contacts.
type contactOut struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
CreatedAt int64 `json:"createdAt"`
}
func (e *env) contacts(c *http.Cookie) []contactOut {
e.t.Helper()
rec := e.do(http.MethodGet, "/api/contacts", nil, with(c))
expect(e.t, rec, http.StatusOK, "")
var out []contactOut
decodeBody(e.t, rec, &out)
return out
}
func TestContacts(t *testing.T) {
e := newEnv(t)
marta := e.signUp("marta")
petya := e.signUp("petya")
if got := e.contacts(marta); len(got) != 0 {
t.Fatalf("контакты нового аккаунта: %+v", got)
}
rec := e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta))
expect(t, rec, http.StatusCreated, "")
var added struct {
Nick string `json:"nick"`
PublicKey json.RawMessage `json:"publicKey"`
}
decodeBody(t, rec, &added)
if added.Nick != "petya" || len(added.PublicKey) == 0 {
t.Errorf("ответ: %s", rec.Body.String())
}
// Повтор — 200 и та же строка.
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)), http.StatusOK, "")
if got := e.contacts(marta); len(got) != 1 || got[0].Nick != "petya" {
t.Errorf("контакты marta: %+v", got)
}
// Зеркальной строки POST не заводит: она появляется при первом
// сообщении (ADR-019).
if got := e.contacts(petya); len(got) != 0 {
t.Errorf("контакты petya: %+v", got)
}
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(marta)),
http.StatusBadRequest, "self")
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "kolya"}, with(marta)),
http.StatusNotFound, "unknown_user")
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "МАРТА"}, with(marta)),
http.StatusNotFound, "unknown_user")
}
// Удаляется только своя строка: зеркальная у собеседника остаётся,
// это не блокировка (ADR-019).
func TestDeleteContact(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, _ := e.join("petya", 2)
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "")
if got := e.contacts(marta); len(got) != 0 {
t.Errorf("контакты marta: %+v", got)
}
if got := e.contacts(petya); len(got) != 1 || got[0].Nick != "marta" {
t.Errorf("контакты petya: %+v", got)
}
// Удалять нечего — тот же ответ.
expect(t, e.do(http.MethodDelete, "/api/contacts/petya", nil, with(marta)), http.StatusNoContent, "")
expect(t, e.do(http.MethodDelete, "/api/contacts/kolya", nil, with(marta)), http.StatusNoContent, "")
}
+132
View File
@@ -0,0 +1,132 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/store"
)
// deviceID — тело и ответ POST /api/devices: идентификатор выдаёт клиент
// (ADR-017), сервер только проверяет форму и принадлежность.
type deviceID struct {
ID string `json:"id"`
}
// POST /api/devices — регистрация устройства. Уже заведённое своё —
// 200 и обновлённый last_seen; занятое чужим — 409, клиент берёт новый id.
func (s *server) createDevice(w http.ResponseWriter, r *http.Request) {
var in deviceID
if !decode(w, r, &in) {
return
}
if !validID(in.ID) {
Invalid(w, "id", "id — не 16 байт base64url")
return
}
sess, _ := auth.From(r)
created, err := s.st.RegisterDevice(r.Context(), in.ID, sess.Nick, sess.TokenHash, time.Now().UnixMilli())
if errors.Is(err, store.ErrDeviceTaken) {
Error(w, http.StatusConflict, "device_conflict", "такое устройство уже есть")
return
}
if err != nil {
s.internal(w, r, err)
return
}
status := http.StatusOK
if created {
status = http.StatusCreated
}
writeJSON(w, status, deviceID{in.ID})
}
// deviceOut — строка ответа GET /api/devices.
type deviceOut struct {
ID string `json:"id"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
HasPush bool `json:"hasPush"`
Current bool `json:"current"`
}
// GET /api/devices — устройства аккаунта. Самой push-подписки в ответе
// нет, только факт её наличия.
func (s *server) devices(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
list, err := s.st.Devices(r.Context(), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
out := make([]deviceOut, 0, len(list))
for _, d := range list {
out = append(out, deviceOut{
ID: d.ID,
CreatedAt: d.CreatedAt,
LastSeen: d.LastSeen,
HasPush: d.HasPush,
Current: d.ID == sess.DeviceID,
})
}
writeJSON(w, http.StatusOK, out)
}
// DELETE /api/devices/{id} — удаление устройства: очередь, подписка
// и сессии уходят каскадом, открытый поток событий закрывается.
//
// Чужое и несуществующее устройство отвечают тем же 204: удалять нечего,
// а отдельного кода на этот случай в протоколе нет (docs/protocol.md).
func (s *server) deleteDevice(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
id := r.PathValue("id")
deleted, err := s.st.DeleteDevice(r.Context(), id, sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
if deleted {
s.hub.Close(id)
}
noContent(w)
}
// device читает X-Device и проверяет, что устройство принадлежит
// пользователю сессии. Заголовка нет, форма кривая, устройство чужое —
// всё это 403 unknown_device (docs/protocol.md, «Общие правила»).
func (s *server) device(w http.ResponseWriter, r *http.Request) (string, bool) {
sess, _ := auth.From(r)
id := r.Header.Get("X-Device")
if !validID(id) {
unknownDevice(w)
return "", false
}
owned, err := s.st.DeviceOwned(r.Context(), id, sess.Nick)
if err != nil {
s.internal(w, r, err)
return "", false
}
if !owned {
unknownDevice(w)
return "", false
}
return id, true
}
// optionalDevice — то же для маршрутов, где заголовок необязателен:
// он всего лишь просит не возвращать эхо отправившему устройству. Пустой
// X-Device — пусто, непустой обязан быть своим устройством: правило
// принадлежности общее для всех маршрутов, где устройство важно
// (docs/protocol.md, «Общие правила»).
func (s *server) optionalDevice(w http.ResponseWriter, r *http.Request) (string, bool) {
if r.Header.Get("X-Device") == "" {
return "", true
}
return s.device(w, r)
}
func unknownDevice(w http.ResponseWriter) {
Error(w, http.StatusForbidden, "unknown_device", "это устройство не ваше")
}
+205
View File
@@ -0,0 +1,205 @@
package api_test
import (
"net/http"
"testing"
)
// deviceOf — идентификатор устройства: 16 байт base64url (docs/crypto.md).
func deviceOf(seed byte) string { return bytesOf(16, seed) }
// join регистрирует аккаунт и его устройство, отдаёт cookie и id.
func (e *env) join(nick string, seed byte) (*http.Cookie, string) {
e.t.Helper()
c := e.signUp(nick)
return c, e.addDevice(c, deviceOf(seed))
}
// addDevice регистрирует устройство под уже открытой сессией.
func (e *env) addDevice(c *http.Cookie, id string) string {
e.t.Helper()
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c))
if rec.Code != http.StatusCreated && rec.Code != http.StatusOK {
e.t.Fatalf("регистрация устройства: %d (%s)", rec.Code, rec.Body.String())
}
return id
}
func TestDevices(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
id := deviceOf(1)
first := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c))
expect(t, first, http.StatusCreated, "")
var created struct {
ID string `json:"id"`
}
decodeBody(t, first, &created)
if created.ID != id {
t.Errorf("id в ответе: получено %q, ожидалось %q", created.ID, id)
}
// Повтор — то же устройство того же пользователя.
expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c)), http.StatusOK, "")
rec := e.do(http.MethodGet, "/api/devices", nil, with(c))
expect(t, rec, http.StatusOK, "")
var list []struct {
ID string `json:"id"`
CreatedAt int64 `json:"createdAt"`
LastSeen int64 `json:"lastSeen"`
HasPush bool `json:"hasPush"`
Current bool `json:"current"`
}
decodeBody(t, rec, &list)
if len(list) != 1 {
t.Fatalf("устройств: получено %d, ожидалось 1", len(list))
}
if list[0].ID != id || list[0].CreatedAt == 0 || list[0].LastSeen == 0 || list[0].HasPush || !list[0].Current {
t.Errorf("устройство: %+v", list[0])
}
// Второе устройство — своя сессия, свой вход. current у каждой сессии
// своё: сессия привязана к устройству (ADR-021).
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
second := e.cookie(login)
e.addDevice(second, deviceOf(2))
rec = e.do(http.MethodGet, "/api/devices", nil, with(c))
decodeBody(t, rec, &list)
if len(list) != 2 {
t.Fatalf("устройств: получено %d, ожидалось 2", len(list))
}
if !list[0].Current || list[1].Current {
t.Errorf("текущее устройство первой сессии: %+v", list)
}
rec = e.do(http.MethodGet, "/api/devices", nil, with(second))
decodeBody(t, rec, &list)
if list[0].Current || !list[1].Current {
t.Errorf("текущее устройство второй сессии: %+v", list)
}
}
// Занятый чужим идентификатор — 409: клиент берёт новый (ADR-017).
func TestDeviceConflict(t *testing.T) {
e := newEnv(t)
marta, id := e.join("marta", 1)
petya := e.signUp("petya")
expect(t, e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(petya)),
http.StatusConflict, "device_conflict")
// Устройство осталось за прежним владельцем.
rec := e.do(http.MethodGet, "/api/devices", nil, with(marta))
var mine []struct {
ID string `json:"id"`
}
decodeBody(t, rec, &mine)
if len(mine) != 1 || mine[0].ID != id {
t.Errorf("устройства marta: %+v", mine)
}
rec = e.do(http.MethodGet, "/api/devices", nil, with(petya))
var theirs []struct {
ID string `json:"id"`
}
decodeBody(t, rec, &theirs)
if len(theirs) != 0 {
t.Errorf("устройства petya: %+v", theirs)
}
}
func TestDeviceIDForm(t *testing.T) {
e := newEnv(t)
c := e.signUp("marta")
for _, id := range []any{"", "короткий", bytesOf(8, 1), bytesOf(32, 1), 42} {
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(c))
if rec.Code == http.StatusCreated || rec.Code == http.StatusOK {
t.Errorf("id %v принят: %d", id, rec.Code)
}
}
}
// X-Device чужого пользователя — 403 unknown_device на всех маршрутах,
// где устройство важно (docs/protocol.md, «Общие правила»).
func TestForeignDevice(t *testing.T) {
e := newEnv(t)
_, martaDevice := e.join("marta", 1)
petya, petyaDevice := e.join("petya", 2)
body := message(ulid(nowMillis(), 3), "marta")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(martaDevice)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(petya), withDevice(martaDevice)),
http.StatusForbidden, "unknown_device")
// Заголовка нет вовсе или в нём мусор — тот же ответ.
expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice("мусор")),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(petya), withDevice(deviceOf(9))),
http.StatusForbidden, "unknown_device")
// Со своим устройством — обычная отправка.
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
with(petya), withDevice(petyaDevice)), http.StatusAccepted, "")
// Комнаты: заголовок здесь необязателен — он всего лишь просит не слать
// событие отправившему устройству, — но принадлежность проверяется
// та же (docs/protocol.md, «Общие правила», «Комнаты»).
room := map[string]any{
"id": roomIDOf(40),
"name": "общая",
"keyId": keyID(40),
"keys": keysFor([]string{"petya"}, 40),
}
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice("мусор")),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(deviceOf(9))),
http.StatusForbidden, "unknown_device")
// Отказ ничего не создал: идентификатор комнаты свободен.
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya)), http.StatusCreated, "")
leave := "/api/rooms/" + roomIDOf(40) + "/leave"
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(martaDevice)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice("мусор")),
http.StatusForbidden, "unknown_device")
// Отказ ничего не изменил: из комнаты никто не вышел.
if got := e.room(petya, roomIDOf(40)); got == nil {
t.Fatal("комната пропала после отказа по устройству")
}
expect(t, e.do(http.MethodPost, leave, nil, with(petya), withDevice(petyaDevice)),
http.StatusNoContent, "")
}
// Удаление устройства уносит очередь и сессии устройства.
func TestDeleteDevice(t *testing.T) {
e := newEnv(t)
marta, martaDevice := e.join("marta", 1)
petya, petyaDevice := e.join("petya", 2)
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(martaDevice)), http.StatusAccepted, "")
if got := e.queue(petyaDevice); len(got) != 1 {
t.Fatalf("очередь petya: получено %d конвертов, ожидался 1", len(got))
}
// Чужое устройство удалить нельзя — и это не ошибка.
expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(marta)), http.StatusNoContent, "")
if got := e.queue(petyaDevice); len(got) != 1 {
t.Errorf("очередь petya после чужого удаления: получено %d конвертов", len(got))
}
expect(t, e.do(http.MethodDelete, "/api/devices/"+petyaDevice, nil, with(petya)), http.StatusNoContent, "")
if got := e.queue(petyaDevice); len(got) != 0 {
t.Errorf("очередь после удаления устройства: получено %d конвертов, ожидалось 0", len(got))
}
// Сессия, привязанная к устройству, ушла каскадом.
expect(t, e.do(http.MethodGet, "/api/me", nil, with(petya)), http.StatusUnauthorized, "unauthenticated")
}
+118
View File
@@ -0,0 +1,118 @@
package api
import (
"fmt"
"io"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
)
// pingEvery — период комментария-пинга: он держит соединение живым
// через прокси и показывает клиенту, что поток цел (docs/protocol.md).
const pingEvery = 20 * time.Second
// GET /api/events?device= — поток событий устройства (ADR-004).
// Устройство передаётся в query: EventSource не умеет заголовки.
//
// Last-Event-ID игнорируется: механизм восстановления — не докрутка
// по идентификатору, а повторная выдача очереди при каждом подключении.
func (s *server) events(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
device := r.URL.Query().Get("device")
if !validID(device) {
unknownDevice(w)
return
}
owned, err := s.st.DeviceOwned(r.Context(), device, sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
if !owned {
unknownDevice(w)
return
}
// Порядок — docs/protocol.md, «События»: сначала push_pending и
// last_seen, потом поток, потом очередь. Сорвавшаяся запись last_seen
// не должна рвать исправный поток устройства, а он был бы уже закрыт
// открытием нового.
now := time.Now().UnixMilli()
if err := s.st.TouchDevice(r.Context(), device, now); err != nil {
s.internal(w, r, err)
return
}
// Поток открывается до чтения очереди: конверт, попавший в очередь
// между выборкой и подпиской, иначе пролежал бы там до следующего
// подключения. Обратная крайность — дубль, а его клиент сливает по id
// (ADR-017). Открытие закрывает прежний поток этого устройства.
stream := s.hub.Open(device)
defer stream.Close()
queued, err := s.st.Queue(r.Context(), device)
if err != nil {
s.internal(w, r, err)
return
}
head := w.Header()
head.Set("Content-Type", "text/event-stream")
head.Set("Cache-Control", "no-cache")
// nginx буферизует ответы проксируемых приложений; для потока это
// означало бы, что события копятся и не уходят (docs/deploy.md).
head.Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
send := sender(w)
for _, envelope := range queued {
if !send("msg", envelope) {
return
}
}
if !send("ready", "{}") {
return
}
ping := time.NewTicker(pingEvery)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
// Клиент ушёл.
return
case <-stream.Done():
// Поток закрыли: новое соединение того же устройства,
// удаление устройства или остановка сервера.
return
case ev := <-stream.Events():
if !send(ev.Name, ev.Data) {
return
}
case <-ping.C:
if !write(w, ": ping\n\n") {
return
}
}
}
}
// sender собирает функцию записи события. Данные — компактный JSON
// без переводов строки, поэтому кадр SSE собирается одной строкой data.
// Ответ false означает, что писать больше некуда: соединение оборвалось.
func sender(w http.ResponseWriter) func(name, data string) bool {
return func(name, data string) bool {
return write(w, fmt.Sprintf("event: %s\ndata: %s\n\n", name, data))
}
}
func write(w http.ResponseWriter, frame string) bool {
if _, err := io.WriteString(w, frame); err != nil {
return false
}
// Без Flush кадр остался бы в буфере net/http до конца ответа,
// а конца у потока нет.
return http.NewResponseController(w).Flush() == nil
}
+323
View File
@@ -0,0 +1,323 @@
package api_test
import (
"bufio"
"context"
"io"
"net/http"
"net/url"
"strings"
"testing"
"time"
"github.com/xmatic-squad/bare/internal/config"
)
// wait — сколько тест ждёт события. Всё локально, задержек быть не должно.
const wait = 2 * time.Second
// sseEvent — одно событие потока.
type sseEvent struct {
name string
data string
}
// stream — открытый GET /api/events. Идёт через настоящий сервер:
// httptest.ResponseRecorder не отдаёт тело, пока обработчик не вернулся.
type stream struct {
t *testing.T
ctx context.Context
cancel context.CancelFunc
events chan sseEvent
head http.Header
}
// open подключается к потоку событий устройства.
func (e *env) open(device string, c *http.Cookie) *stream {
e.t.Helper()
srv := e.live()
ctx, cancel := context.WithCancel(context.Background())
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
srv.URL+"/api/events?device="+url.QueryEscape(device), nil)
if err != nil {
cancel()
e.t.Fatalf("запрос: %v", err)
}
req.AddCookie(c)
resp, err := srv.Client().Do(req)
if err != nil {
cancel()
e.t.Fatalf("подключение: %v", err)
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
cancel()
e.t.Fatalf("статус потока: получено %d, ожидалось 200", resp.StatusCode)
}
s := &stream{t: e.t, ctx: ctx, cancel: cancel, events: make(chan sseEvent, 64), head: resp.Header}
go s.read(resp.Body)
e.t.Cleanup(s.close)
return s
}
// read разбирает кадры SSE: строки event и data, пустая строка — конец
// события, строка с двоеточия — комментарий-пинг.
func (s *stream) read(body io.ReadCloser) {
defer body.Close()
defer close(s.events)
sc := bufio.NewScanner(body)
var ev sseEvent
for sc.Scan() {
line := sc.Text()
switch {
case line == "":
if ev.name == "" {
continue
}
select {
case s.events <- ev:
case <-s.ctx.Done():
return
}
ev = sseEvent{}
case strings.HasPrefix(line, ":"):
case strings.HasPrefix(line, "event: "):
ev.name = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
ev.data = strings.TrimPrefix(line, "data: ")
}
}
}
// next ждёт следующее событие.
func (s *stream) next() sseEvent {
s.t.Helper()
select {
case ev, ok := <-s.events:
if !ok {
s.t.Fatal("поток закрылся, события нет")
}
return ev
case <-time.After(wait):
s.t.Fatal("событие не пришло")
}
return sseEvent{}
}
// untilReady собирает события до ready — то, что лежало в очереди.
func (s *stream) untilReady() []sseEvent {
s.t.Helper()
var out []sseEvent
for {
ev := s.next()
if ev.name == "ready" {
if ev.data != "{}" {
s.t.Errorf("данные ready: получено %q, ожидалось \"{}\"", ev.data)
}
return out
}
out = append(out, ev)
}
}
// ended ждёт, что поток закроет сервер.
func (s *stream) ended() {
s.t.Helper()
select {
case ev, ok := <-s.events:
if ok {
s.t.Fatalf("вместо закрытия пришло событие %q", ev.name)
}
case <-time.After(wait):
s.t.Fatal("поток не закрылся")
}
}
func (s *stream) close() { s.cancel() }
// Порядок после подключения: очередь, ready, живые события
// (docs/protocol.md, «События»).
func TestEventsQueueThenReady(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
first := ulid(nowMillis(), 3)
second := ulid(nowMillis()+1, 4)
for _, id := range []string{first, second} {
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
s := e.open(p1, petya)
for header, value := range map[string]string{
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
} {
if got := s.head.Get(header); got != value {
t.Errorf("%s: получено %q, ожидалось %q", header, got, value)
}
}
queued := s.untilReady()
if len(queued) != 2 {
t.Fatalf("событий из очереди: получено %d, ожидалось 2", len(queued))
}
for i, ev := range queued {
if ev.name != "msg" {
t.Errorf("событие %d: получено %q, ожидалось \"msg\"", i, ev.name)
}
if !strings.Contains(ev.data, `"from":"marta"`) {
t.Errorf("конверт %d: %s", i, ev.data)
}
}
if !strings.Contains(queued[0].data, first) || !strings.Contains(queued[1].data, second) {
t.Errorf("порядок очереди: %q, %q", queued[0].data, queued[1].data)
}
// Реконнект без ACK повторяет очередь целиком: Last-Event-ID сервер
// не смотрит (docs/protocol.md, «События»).
s.close()
again := e.open(p1, petya)
if got := again.untilReady(); len(got) != 2 {
t.Fatalf("после реконнекта: получено %d событий, ожидалось 2", len(got))
}
// После ACK очередь пуста, остаётся только ready.
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}},
with(petya), withDevice(p1)), http.StatusNoContent, "")
again.close()
third := e.open(p1, petya)
if got := third.untilReady(); len(got) != 0 {
t.Errorf("после ack: получено %d событий, ожидалось 0", len(got))
}
}
// Подключённое устройство получает конверт сразу после коммита.
func TestEventsLive(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
s := e.open(p1, petya)
if got := s.untilReady(); len(got) != 0 {
t.Fatalf("очередь нового устройства: %+v", got)
}
id := ulid(nowMillis(), 3)
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
ev := s.next()
if ev.name != "msg" || !strings.Contains(ev.data, id) {
t.Errorf("живое событие: %+v", ev)
}
// Живая доставка не отменяет ACK: конверт лежит в очереди до него.
if got := e.queue(p1); len(got) != 1 {
t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got))
}
}
// Отправитель эха не получает даже живьём, другие его устройства — да.
func TestEventsNoEchoToSender(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
e.join("petya", 3)
sender := e.open(m1, marta)
sender.untilReady()
other := e.open(m2, marta)
other.untilReady()
id := ulid(nowMillis(), 4)
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
if ev := other.next(); ev.name != "msg" || !strings.Contains(ev.data, id) {
t.Errorf("второе устройство отправителя: %+v", ev)
}
select {
case ev := <-sender.events:
t.Errorf("эхо отправившему устройству: %+v", ev)
case <-time.After(200 * time.Millisecond):
}
}
// Одно соединение на устройство: новое закрывает предыдущее.
func TestEventsSingleConnection(t *testing.T) {
e := newEnv(t)
petya, p1 := e.join("petya", 1)
first := e.open(p1, petya)
first.untilReady()
second := e.open(p1, petya)
second.untilReady()
first.ended()
}
// Удаление устройства закрывает его поток (docs/protocol.md, «Устройства»).
func TestEventsClosedOnDeviceDelete(t *testing.T) {
e := newEnv(t)
petya, p1 := e.join("petya", 1)
s := e.open(p1, petya)
s.untilReady()
expect(t, e.do(http.MethodDelete, "/api/devices/"+p1, nil, with(petya)), http.StatusNoContent, "")
s.ended()
}
// Смена пароля с logoutOthers закрывает потоки отозванных сессий:
// поток проверяет сессию только при подключении, и без этого отозванное
// устройство продолжало бы получать конверты (ADR-058).
func TestEventsClosedOnLogoutOthers(t *testing.T) {
e := newEnv(t)
first, d1 := e.join("marta", 1)
login := e.do(http.MethodPost, "/api/login", map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)})
expect(t, login, http.StatusOK, "")
second := e.cookie(login)
d2 := e.addDevice(second, deviceOf(2))
revoked := e.open(d1, first)
revoked.untilReady()
kept := e.open(d2, second)
kept.untilReady()
expect(t, e.do(http.MethodPost, "/api/password", map[string]any{
"authKey": bytesOf(32, 1),
"newAuthKey": bytesOf(32, 9),
"blob": blobOf(config.KDFIterations),
"logoutOthers": true,
}, with(second)), http.StatusNoContent, "")
revoked.ended()
// Поток той сессии, ради которой всё затевалось, остаётся живым.
select {
case ev, ok := <-kept.events:
if !ok {
t.Fatal("закрылся поток текущей сессии")
}
t.Fatalf("лишнее событие текущей сессии: %+v", ev)
case <-time.After(200 * time.Millisecond):
}
}
// Чужое устройство в query — 403 unknown_device, поток не открывается.
func TestEventsUnknownDevice(t *testing.T) {
e := newEnv(t)
_, martaDevice := e.join("marta", 1)
petya, _ := e.join("petya", 2)
for _, device := range []string{martaDevice, deviceOf(9), "мусор", ""} {
rec := e.do(http.MethodGet, "/api/events?device="+url.QueryEscape(device), nil, with(petya))
expect(t, rec, http.StatusForbidden, "unknown_device")
}
// Без сессии — обычный 401.
expect(t, e.do(http.MethodGet, "/api/events?device="+martaDevice, nil), http.StatusUnauthorized, "unauthenticated")
}
+195
View File
@@ -0,0 +1,195 @@
package api
import (
"math"
"net"
"net/http"
"net/netip"
"strconv"
"strings"
"sync"
"time"
"github.com/xmatic-squad/bare/internal/auth"
)
// Лимиты ADR-021, все четыре правила. Token bucket в памяти сервера:
// рестарт их обнуляет — для маленького сервера это принято.
//
// Пакет отдельным числом задан только у сообщений. У остальных правил он
// равен самому лимиту: «5 в час» означает, что за час набегает пять
// попыток и потратить их можно разом (ADR-055).
var (
// registerRule — регистрация: 5 в час на IP.
registerRule = rule{count: 5, window: time.Hour, burst: 5}
// loginRule — вход: 10 за 10 минут на пару IP+ник.
loginRule = rule{count: 10, window: 10 * time.Minute, burst: 10}
// messagesRule — сообщения: 30 в минуту на пользователя, пакет 10.
messagesRule = rule{count: 30, window: time.Minute, burst: 10}
// writesRule — остальные изменяющие запросы: 60 в минуту
// на пользователя.
writesRule = rule{count: 60, window: time.Minute, burst: 60}
)
// rule — правило лимита: count запросов за window, пакетом не больше burst.
type rule struct {
count int
window time.Duration
burst int
}
// generation — сколько ключей карта лимита держит до смены поколения.
//
// Ведро заводится на каждый новый ключ, а ключ — это чужой адрес или чужой
// ник: их бывает сколько угодно. Выбрасывать полные вёдра мало: под потоком
// новых ключей полных не бывает вовсе — каждое только что потратило токен.
// Поэтому карты две, нынешняя и прежняя. Как только нынешняя дорастает до
// generation, она становится прежней, а прежняя выбрасывается целиком.
// Ключ, по которому продолжают ходить, переезжает в нынешнюю и смену
// переживает; забывается только то, к чему не обращались целое поколение,
// а забытое ведро — то же самое, что новое.
//
// Отсюда предел: обе карты вместе держат не больше 2×generation вёдер,
// то есть около мегабайта на правило. Миллион разных адресов памяти
// не съедает — он протачивает поколения насквозь.
const generation = 4096
// buckets — token bucket в памяти сервера, по ведру на ключ.
type buckets struct {
mu sync.Mutex
rate float64 // токенов в секунду
burst float64
cur map[string]*bucket // нынешнее поколение
old map[string]*bucket // прежнее, пока к его ключам ещё обращаются
}
type bucket struct {
tokens float64
at time.Time
}
func newBuckets(r rule) *buckets {
return &buckets{
rate: float64(r.count) / r.window.Seconds(),
burst: float64(r.burst),
cur: make(map[string]*bucket),
}
}
// take забирает токен. Второе значение — можно ли; если нет, первое —
// сколько ждать до следующего токена.
func (b *buckets) take(key string, now time.Time) (time.Duration, bool) {
b.mu.Lock()
defer b.mu.Unlock()
e := b.bucket(key, now)
e.tokens = math.Min(b.burst, e.tokens+b.refill(e.at, now))
e.at = now
if e.tokens < 1 {
return time.Duration((1 - e.tokens) / b.rate * float64(time.Second)), false
}
e.tokens--
return 0, true
}
// bucket находит ведро ключа или заводит новое. Смена поколения идёт
// до поиска: так в нынешней карте никогда не больше generation ключей,
// а в обеих вместе — не больше двух таких карт.
func (b *buckets) bucket(key string, now time.Time) *bucket {
if len(b.cur) >= generation {
b.old = b.cur
b.cur = make(map[string]*bucket, generation)
}
if e, ok := b.cur[key]; ok {
return e
}
if e, ok := b.old[key]; ok {
delete(b.old, key)
b.cur[key] = e
return e
}
e := &bucket{tokens: b.burst, at: now}
b.cur[key] = e
return e
}
// refill — сколько токенов набежало. Время назад не идёт: часы могли
// прыгнуть, но долг за это выставлять некому.
func (b *buckets) refill(since, now time.Time) float64 {
d := now.Sub(since)
if d <= 0 {
return 0
}
return d.Seconds() * b.rate
}
// retryAfter — значение заголовка в секундах, не меньше одной: нулевое
// ожидание после отказа сбивало бы клиента с толку.
func retryAfter(wait time.Duration) int {
if wait < time.Second {
return 1
}
return int(math.Ceil(wait.Seconds()))
}
// rateLimited — 429 с Retry-After в целых секундах (ADR-021).
func (s *server) rateLimited(w http.ResponseWriter, wait time.Duration) {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter(wait)))
Error(w, http.StatusTooManyRequests, "rate_limited", "слишком часто, попробуйте позже")
}
// limitWrites — общий лимит изменяющих запросов: 60 в минуту
// на пользователя (ADR-021). Стоит на маршруте, а не в обработчике,
// поэтому отвечает раньше разбора тела: смысл лимита в том, чтобы сервер
// не брался за работу, а разбор тела — уже работа. Форму это не обгоняет
// в смысле ADR-043: 429 говорит не о правах и не о существовании
// сущностей, а о частоте.
//
// Сообщения сюда не входят: у них своё правило, своё ведро и своё место
// в порядке проверок (docs/protocol.md, «Сообщения»).
func (s *server) limitWrites(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
if wait, ok := s.writes.take(sess.Nick, time.Now()); !ok {
s.rateLimited(w, wait)
return
}
next.ServeHTTP(w, r)
})
}
// clientIP — ключ лимитов, привязанных к адресу.
//
// X-Real-IP ставит nginx на той же машине (ADR-022), и верить заголовку
// можно только тогда, когда соединение пришло оттуда же. Иначе его
// подставит кто угодно: новая строка в заголовке — новое ведро, и лимита
// на IP не существует вовсе. Соединение не с loopback — заголовок
// не читается, ключом становится адрес соединения.
func clientIP(r *http.Request) string {
remote := connIP(r.RemoteAddr)
if !remote.IsValid() {
// Адрес соединения не разобрать. Одно общее ведро на всех —
// лучше, чем ни одного.
return r.RemoteAddr
}
if remote.IsLoopback() {
if ip, err := netip.ParseAddr(strings.TrimSpace(r.Header.Get("X-Real-IP"))); err == nil {
return ip.Unmap().WithZone("").String()
}
}
return remote.String()
}
// connIP — адрес, с которого пришло соединение. Невалидный Addr означает,
// что RemoteAddr не разобрать.
func connIP(remote string) netip.Addr {
host, _, err := net.SplitHostPort(remote)
if err != nil {
host = remote
}
ip, err := netip.ParseAddr(host)
if err != nil {
return netip.Addr{}
}
return ip.Unmap().WithZone("")
}
+194
View File
@@ -0,0 +1,194 @@
package api
import (
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
)
// Все четыре правила ADR-021: пакет расходуется целиком, следующий токен
// набегает ровно через window/count, ведро не переполняется.
func TestRules(t *testing.T) {
cases := []struct {
name string
rule rule
// token — сколько ждать одного токена на пустом ведре.
token time.Duration
}{
{"регистрация", registerRule, 12 * time.Minute},
{"вход", loginRule, time.Minute},
{"сообщения", messagesRule, 2 * time.Second},
{"изменяющие", writesRule, time.Second},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
b := newBuckets(c.rule)
now := time.Now()
for i := 0; i < c.rule.burst; i++ {
if _, ok := b.take("ключ", now); !ok {
t.Fatalf("запрос %d из пакета %d отклонён", i+1, c.rule.burst)
}
}
wait, ok := b.take("ключ", now)
if ok {
t.Fatal("пакет не кончился")
}
if wait != c.token {
t.Errorf("ожидание: получено %v, ожидалось %v", wait, c.token)
}
if got, want := retryAfter(wait), int(c.token.Seconds()); got != want {
t.Errorf("Retry-After: получено %d, ожидалось %d", got, want)
}
// Восстановление: ровно через это время набегает ровно один токен.
if _, ok := b.take("ключ", now.Add(c.token)); !ok {
t.Error("токен не набежал")
}
if _, ok := b.take("ключ", now.Add(c.token)); ok {
t.Error("набежало больше одного токена")
}
// За долгую паузу копится пакет, а не весь пропущенный поток.
for i := 0; i < c.rule.burst; i++ {
if _, ok := b.take("ключ", now.Add(24*time.Hour)); !ok {
t.Fatalf("запрос %d после долгой паузы отклонён", i+1)
}
}
if _, ok := b.take("ключ", now.Add(24*time.Hour)); ok {
t.Error("ведро больше пакета")
}
// Ведро на ключ: чужое полное.
if _, ok := b.take("другой ключ", now); !ok {
t.Error("лимит одного ключа задел другой")
}
})
}
}
// Часы могут прыгнуть назад; долг за это никому не выставляется.
func TestBucketsClockBack(t *testing.T) {
b := newBuckets(messagesRule)
now := time.Now()
for i := 0; i < messagesRule.burst; i++ {
b.take("marta", now)
}
if _, ok := b.take("marta", now.Add(-time.Hour)); ok {
t.Error("время назад добавило токенов")
}
}
// Карта лимита не растёт бесконечно: миллион разных ключей проходит
// сквозь поколения, а вёдер остаётся не больше двух карт.
func TestBucketsBounded(t *testing.T) {
b := newBuckets(registerRule)
now := time.Now()
for i := 0; i < 1_000_000; i++ {
b.take(strconv.Itoa(i), now)
}
if got := b.size(); got > 2*generation {
t.Errorf("вёдер: получено %d, ожидалось не больше %d", got, 2*generation)
}
// Ключ, по которому ходят, смену поколения переживает: его ведро
// переезжает в нынешнюю карту, а не заводится заново.
b = newBuckets(registerRule)
for i := 0; i < registerRule.burst; i++ {
b.take("свой", now)
}
for i := 0; i < 3*generation; i++ {
b.take(strconv.Itoa(i), now)
if _, ok := b.take("свой", now); ok {
t.Fatalf("ведро забыто на %d-м чужом ключе", i+1)
}
}
}
// Ждать меньше секунды бессмысленно: Retry-After в секундах.
func TestRetryAfter(t *testing.T) {
cases := map[time.Duration]int{
-time.Second: 1,
0: 1,
100 * time.Millisecond: 1,
time.Second: 1,
1500 * time.Millisecond: 2,
2 * time.Second: 2,
12 * time.Minute: 720,
}
for wait, want := range cases {
if got := retryAfter(wait); got != want {
t.Errorf("retryAfter(%v): получено %d, ожидалось %d", wait, got, want)
}
}
}
// X-Real-IP ставит nginx с той же машины (ADR-022). Заголовку из сети
// веры нет: иначе лимит на IP снимался бы новой строкой в заголовке.
func TestClientIP(t *testing.T) {
cases := []struct {
name string
remote string
real string
want string
}{
{"без заголовка", "203.0.113.7:41000", "", "203.0.113.7"},
{"заголовок из сети", "203.0.113.7:41000", "198.51.100.1", "203.0.113.7"},
{"заголовок от nginx", "127.0.0.1:41000", "198.51.100.1", "198.51.100.1"},
{"nginx по ipv6", "[::1]:41000", "198.51.100.1", "198.51.100.1"},
{"loopback без заголовка", "127.0.0.1:41000", "", "127.0.0.1"},
{"мусор в заголовке", "127.0.0.1:41000", "не адрес", "127.0.0.1"},
{"пробелы в заголовке", "127.0.0.1:41000", " 198.51.100.1 ", "198.51.100.1"},
{"адрес с портом в заголовке", "127.0.0.1:41000", "198.51.100.1:80", "127.0.0.1"},
{"ipv6 клиента", "[2001:db8::1]:41000", "", "2001:db8::1"},
{"ipv4 в ipv6-форме", "[::ffff:203.0.113.7]:41000", "", "203.0.113.7"},
{"ipv4 в ipv6-форме в заголовке", "127.0.0.1:41000", "::ffff:198.51.100.1", "198.51.100.1"},
{"не разобрать соединение", "сокет", "198.51.100.1", "сокет"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
r := httptest.NewRequest(http.MethodPost, "/api/register", nil)
r.RemoteAddr = c.remote
if c.real != "" {
r.Header.Set("X-Real-IP", c.real)
}
if got := clientIP(r); got != c.want {
t.Errorf("clientIP: получено %q, ожидалось %q", got, c.want)
}
})
}
}
// ULID: 26 символов Crockford base32, время в первых десяти.
func TestULIDTime(t *testing.T) {
// 01ARZ3NDEK — 2016-07-30T23:54:10.259Z.
ms, ok := ulidTime("01ARZ3NDEKTSV4RRFFQ69G5FAV")
if !ok || ms != 1469922850259 {
t.Errorf("ulidTime: получено %d, %v; ожидалось 1469922850259", ms, ok)
}
for _, id := range []string{
"",
"01ARZ3NDEKTSV4RRFFQ69G5FA", // 25 символов
"01ARZ3NDEKTSV4RRFFQ69G5FAVX", // 27 символов
"01arz3ndektsv4rrffq69g5fav", // строчные
"01ARZ3NDEKTSV4RRFFQ69G5FAU", // U вне алфавита Crockford
"81ARZ3NDEKTSV4RRFFQ69G5FAV", // время больше 48 бит
"01ARZ3NDEKTSV4RRFFQ69G5F☺",
} {
if _, ok := ulidTime(id); ok {
t.Errorf("принят кривой ulid %q", id)
}
}
}
// size — сколько вёдер помнят обе карты. Только для тестов: предел размера
// проверяется, а не подразумевается.
func (b *buckets) size() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.cur) + len(b.old)
}
+246
View File
@@ -0,0 +1,246 @@
package api
import (
"encoding/json"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/push"
"github.com/xmatic-squad/bare/internal/store"
)
// clockSkew — на сколько метка времени ULID вправе разойтись с часами
// сервера (ADR-017).
const clockSkew = 5 * time.Minute
// dmKeyID — keyId личного чата: ключ выводится из ECDH, идентификатора
// у него нет (docs/crypto.md, «Сообщение»).
const dmKeyID = "dm"
// maxAck — сколько идентификаторов принимает один ACK.
const maxAck = 500
// target — адресат конверта: ровно одно из двух.
type target struct {
DM string `json:"dm,omitempty"`
Room string `json:"room,omitempty"`
}
// envelope — конверт из docs/protocol.md. Порядок полей — как в нём.
// from и ts ставит сервер: клиентские значения не читаются вовсе (ADR-017).
type envelope struct {
ID string `json:"id"`
To target `json:"to"`
From string `json:"from"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
TS int64 `json:"ts"`
}
// messageIn — тело POST /api/messages. Полей from и ts здесь нет
// намеренно: что бы клиент ни прислал, сервер ставит своё (ADR-017).
type messageIn struct {
ID string `json:"id"`
To target `json:"to"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// POST /api/messages — отправка. Сервер не умеет проверять шифротекст,
// он проверяет форму и раскладывает конверт по очередям (ADR-008).
// Порядок проверок — docs/protocol.md, «Сообщения».
func (s *server) sendMessage(w http.ResponseWriter, r *http.Request) {
var in messageIn
if !decode(w, r, &in) {
return
}
ms, ok := checkForm(w, in)
if !ok {
return
}
now := time.Now()
if d := now.Sub(time.UnixMilli(ms)); d > clockSkew || d < -clockSkew {
Error(w, http.StatusBadRequest, "clock_skew",
"проверьте часы на устройстве: расхождение больше 5 минут")
return
}
// Принадлежность устройства — право, а не форма, поэтому проверяется
// после разбора тела: кривое тело отвечает bad_json и invalid даже
// с чужим X-Device (ADR-043).
device, ok := s.device(w, r)
if !ok {
return
}
sess, _ := auth.From(r)
room := in.To.Room != ""
// Заголовок и адрес чата для пуша: сервер собирает их из того, что
// и так знает, — из ников и имени комнаты (ADR-023).
var signal push.Payload
if room {
access, err := s.st.RoomAccess(r.Context(), in.To.Room, sess.Nick, in.KeyID)
if err != nil {
s.internal(w, r, err)
return
}
if !access.Member {
Error(w, http.StatusForbidden, "not_member", "вы не участник комнаты")
return
}
if !access.KnownKey {
Error(w, http.StatusBadRequest, "unknown_key", "у комнаты нет такого ключа")
return
}
signal = push.Payload{Title: "#" + access.Name, Chat: "room:" + in.To.Room}
} else {
if _, ok := s.peer(w, r, in.To.DM, sess.Nick); !ok {
return
}
signal = push.Payload{Title: "@" + sess.Nick, Chat: "dm:" + sess.Nick}
}
if wait, ok := s.msgs.take(sess.Nick, now); !ok {
s.rateLimited(w, wait)
return
}
env := envelope{
ID: in.ID,
To: target{DM: in.To.DM, Room: in.To.Room},
From: sess.Nick,
KeyID: in.KeyID,
IV: in.IV,
CT: in.CT,
TS: now.UnixMilli(),
}
raw, err := json.Marshal(env)
if err != nil {
s.internal(w, r, err)
return
}
delivery := store.Delivery{
From: env.From,
To: env.To.DM,
Room: env.To.Room,
Exclude: device,
MsgID: env.ID,
Envelope: string(raw),
Now: env.TS,
}
var devices []store.Target
if room {
devices, err = s.st.DeliverRoom(r.Context(), delivery)
} else {
devices, err = s.st.DeliverDM(r.Context(), delivery)
}
if err != nil {
s.internal(w, r, err)
return
}
// Очередь уже записана: подключённое устройство получает конверт
// сразу, остальные — при подключении.
for _, target := range devices {
s.hub.Send(target.ID, hub.Event{Name: "msg", Data: string(raw)})
}
// Пуш — побочный эффект доставки, а не её часть: конверт уже
// в очереди, и ответ на запрос отправку пуша не ждёт (ADR-023).
s.push.Send(s.silent(devices, env.From), signal)
writeJSON(w, http.StatusAccepted, struct {
ID string `json:"id"`
TS int64 `json:"ts"`
}{env.ID, env.TS})
}
// silent — устройства, которым нужен пуш: чужие (устройства отправителя
// пуша не получают, ADR-045), подписанные и молчащие — те, что не держат
// поток событий (ADR-023).
//
// Устройство без подписки отсеивается здесь: отправить ему нечего,
// а место в очереди отправки оно заняло бы (ADR-048). Проверка на
// подключение — ранний отсев: решает её повтор в момент захвата права
// на пуш, потому что между этой строкой и отправкой устройство успевает
// подключиться (ADR-023).
func (s *server) silent(targets []store.Target, from string) []push.Target {
var out []push.Target
for _, target := range targets {
if target.Nick == from || !target.HasPush || s.hub.Connected(target.ID) {
continue
}
out = append(out, push.Target{Device: target.ID, Owner: target.Nick})
}
return out
}
// checkForm проверяет форму полей конверта (docs/crypto.md, «Что сервер
// проверяет») и отдаёт метку времени из ULID. Ответ об ошибке уже написан,
// если вернулось false.
func checkForm(w http.ResponseWriter, in messageIn) (int64, bool) {
ms, ok := ulidTime(in.ID)
if !ok {
Invalid(w, "id", "id — не ulid из 26 символов")
return 0, false
}
if (in.To.DM == "") == (in.To.Room == "") {
Invalid(w, "to", "to — ровно одно из dm и room")
return 0, false
}
if in.To.DM != "" {
if !validNick(in.To.DM) {
Invalid(w, "to", "ник: 232 символа, az, 09, _")
return 0, false
}
if in.KeyID != dmKeyID {
Invalid(w, "keyId", `keyId личного чата — "dm"`)
return 0, false
}
} else {
if !validID(in.To.Room) {
Invalid(w, "to", "room — не 16 байт base64url")
return 0, false
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return 0, false
}
}
if _, ok := decodeExactly(in.IV, ivLen); !ok {
Invalid(w, "iv", "iv — не 12 байт base64url")
return 0, false
}
if ct, err := b64.DecodeString(in.CT); err != nil || len(ct) < minCTLen {
Invalid(w, "ct", "ct — не base64url или слишком короткий")
return 0, false
}
return ms, true
}
// POST /api/ack — клиент записал сообщения в IndexedDB: из очереди
// устройства их можно убрать (ADR-008).
func (s *server) ack(w http.ResponseWriter, r *http.Request) {
var in struct {
IDs []string `json:"ids"`
}
if !decode(w, r, &in) {
return
}
if len(in.IDs) > maxAck {
Invalid(w, "ids", "не больше 500 идентификаторов")
return
}
// Устройство — право: после формы тела (ADR-043).
device, ok := s.device(w, r)
if !ok {
return
}
if err := s.st.Ack(r.Context(), device, in.IDs); err != nil {
s.internal(w, r, err)
return
}
noContent(w)
}
+377
View File
@@ -0,0 +1,377 @@
package api_test
import (
"context"
"encoding/json"
"net/http"
"strconv"
"testing"
"time"
)
// crockford — алфавит ULID (docs/crypto.md, «Идентификаторы»).
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
func nowMillis() int64 { return time.Now().UnixMilli() }
// ulid собирает ULID с заданным временем: первые десять символов —
// 48 бит миллисекунд, остальные шестнадцать — 80 бит «случайности».
func ulid(ms int64, seed byte) string {
out := make([]byte, 26)
for i := 9; i >= 0; i-- {
out[i] = crockford[ms&31]
ms >>= 5
}
for i := 10; i < 26; i++ {
out[i] = crockford[(int(seed)+i)%32]
}
return string(out)
}
// message — тело POST /api/messages в личный чат. Шифротекст сервер
// не проверяет: ему важна только форма.
func message(id, to string) map[string]any {
return map[string]any{
"id": id,
"to": map[string]string{"dm": to},
"keyId": "dm",
"iv": bytesOf(12, 21),
"ct": bytesOf(48, 23),
}
}
// queue — очередь устройства как её видит сервер.
func (e *env) queue(device string) []string {
e.t.Helper()
got, err := e.st.Queue(context.Background(), device)
if err != nil {
e.t.Fatalf("очередь %s: %v", device, err)
}
return got
}
// envelopes разбирает конверты очереди.
func (e *env) envelopes(device string) []envelope {
e.t.Helper()
raw := e.queue(device)
out := make([]envelope, 0, len(raw))
for _, s := range raw {
var env envelope
if err := json.Unmarshal([]byte(s), &env); err != nil {
e.t.Fatalf("разбор конверта %q: %v", s, err)
}
out = append(out, env)
}
return out
}
// envelope — конверт в том виде, в каком его видит клиент.
type envelope struct {
ID string `json:"id"`
To struct {
DM string `json:"dm"`
Room string `json:"room"`
} `json:"to"`
From string `json:"from"`
KeyID string `json:"keyId"`
IV string `json:"iv"`
CT string `json:"ct"`
TS int64 `json:"ts"`
}
// Конверт уходит на все устройства обоих собеседников, кроме отправившего
// (ADR-017): мультидевайс без отдельной логики.
func TestFanout(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
petya, p1 := e.join("petya", 3)
p2 := e.addDevice(petya, deviceOf(4))
id := ulid(nowMillis(), 5)
rec := e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1))
expect(t, rec, http.StatusAccepted, "")
var accepted struct {
ID string `json:"id"`
TS int64 `json:"ts"`
}
decodeBody(t, rec, &accepted)
if accepted.ID != id || accepted.TS == 0 {
t.Errorf("ответ: %+v", accepted)
}
if got := e.queue(m1); len(got) != 0 {
t.Errorf("эхо отправившему устройству: %v", got)
}
for _, device := range []string{m2, p1, p2} {
got := e.envelopes(device)
if len(got) != 1 {
t.Fatalf("очередь %s: получено %d конвертов, ожидался 1", device, len(got))
}
env := got[0]
if env.ID != id || env.From != "marta" || env.To.DM != "petya" || env.KeyID != "dm" {
t.Errorf("конверт для %s: %+v", device, env)
}
if env.IV != bytesOf(12, 21) || env.CT != bytesOf(48, 23) {
t.Errorf("шифротекст изменился: %+v", env)
}
if env.TS != accepted.TS {
t.Errorf("ts: получено %d, ожидалось %d", env.TS, accepted.TS)
}
}
}
// from ставит сервер из сессии; поле from в теле запроса не читается
// вовсе (ADR-017, модель угроз).
func TestFromComesFromSession(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
body := message(ulid(nowMillis(), 3), "petya")
body["from"] = "petya"
body["ts"] = 1
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)), http.StatusAccepted, "")
got := e.envelopes(p1)
if len(got) != 1 {
t.Fatalf("очередь: получено %d конвертов, ожидался 1", len(got))
}
if got[0].From != "marta" {
t.Errorf("from: получено %q, ожидалось \"marta\"", got[0].From)
}
if got[0].TS == 1 {
t.Errorf("ts взят из тела запроса: %d", got[0].TS)
}
}
// ACK удаляет строки очереди только своего устройства.
func TestAck(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
first := ulid(nowMillis(), 4)
second := ulid(nowMillis()+1, 5)
for _, id := range []string{first, second} {
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
ack := map[string]any{"ids": []string{first}}
expect(t, e.do(http.MethodPost, "/api/ack", ack, with(petya), withDevice(p1)), http.StatusNoContent, "")
left := e.envelopes(p1)
if len(left) != 1 || left[0].ID != second {
t.Errorf("очередь p1 после ack: %+v", left)
}
if got := e.queue(p2); len(got) != 2 {
t.Errorf("очередь p2: получено %d конвертов, ожидалось 2", len(got))
}
// Чужие идентификаторы и повторный ack ничего не ломают.
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{first, second}},
with(petya), withDevice(p1)), http.StatusNoContent, "")
if got := e.queue(p1); len(got) != 0 {
t.Errorf("очередь p1: получено %d конвертов, ожидалось 0", len(got))
}
if got := e.queue(p2); len(got) != 2 {
t.Errorf("очередь p2 после ack чужого устройства: получено %d", len(got))
}
}
func TestAckLimit(t *testing.T) {
e := newEnv(t)
c, device := e.join("marta", 1)
ids := make([]string, 500)
for i := range ids {
ids[i] = ulid(nowMillis(), byte(i))
}
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(c), withDevice(device)),
http.StatusNoContent, "")
rec := e.do(http.MethodPost, "/api/ack", map[string]any{"ids": append(ids, ulid(nowMillis(), 9))},
with(c), withDevice(device))
expect(t, rec, http.StatusBadRequest, "invalid")
var field struct {
Field string `json:"field"`
}
decodeBody(t, rec, &field)
if field.Field != "ids" {
t.Errorf("field: получено %q, ожидалось \"ids\"", field.Field)
}
}
// Часы клиента врут в обе стороны одинаково плохо (ADR-017).
func TestClockSkew(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
minute := int64(60 * 1000)
for _, shift := range []int64{-6 * minute, 6 * minute, -24 * 60 * minute, 24 * 60 * minute} {
body := message(ulid(nowMillis()+shift, 3), "petya")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)),
http.StatusBadRequest, "clock_skew")
}
// В пределах пяти минут — принимается.
for _, shift := range []int64{-4 * minute, 4 * minute} {
body := message(ulid(nowMillis()+shift, 4), "petya")
expect(t, e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
}
// Строки contacts заводятся в обе стороны при первом сообщении (ADR-019):
// новое устройство видит список чатов без истории.
func TestContactsFromFirstMessage(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, _ := e.join("petya", 2)
if got := e.contacts(marta); len(got) != 0 {
t.Fatalf("контакты до первого сообщения: %+v", got)
}
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
mine := e.contacts(marta)
if len(mine) != 1 || mine[0].Nick != "petya" || mine[0].CreatedAt == 0 {
t.Errorf("контакты marta: %+v", mine)
}
theirs := e.contacts(petya)
if len(theirs) != 1 || theirs[0].Nick != "marta" {
t.Errorf("контакты petya: %+v", theirs)
}
if len(theirs[0].PublicKey) == 0 {
t.Errorf("в контакте нет публичного ключа: %+v", theirs[0])
}
// Второе сообщение ничего не удваивает.
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
if got := e.contacts(marta); len(got) != 1 {
t.Errorf("контакты marta после второго сообщения: %+v", got)
}
}
// Повтор POST с тем же id не ломает запрос: сервер историю идентификаторов
// не хранит, склеивает клиент (ADR-017).
func TestRepeatedMessageID(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
id := ulid(nowMillis(), 3)
for i := 0; i < 3; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(id, "petya"), with(marta), withDevice(m1)),
http.StatusAccepted, "")
}
if got := e.queue(p1); len(got) != 1 {
t.Errorf("очередь: получено %d конвертов, ожидался 1", len(got))
}
}
func TestMessageRejects(t *testing.T) {
cases := []struct {
name string
change func(map[string]any)
status int
code string
field string
}{
{"id не ulid", func(m map[string]any) { m["id"] = "не ulid" }, http.StatusBadRequest, "invalid", "id"},
{"строчный ulid", func(m map[string]any) {
m["id"] = "01hqzz0000zzzzzzzzzzzzzzzz"
}, http.StatusBadRequest, "invalid", "id"},
{"буква вне алфавита", func(m map[string]any) {
m["id"] = "0" + "I" + ulid(nowMillis(), 1)[2:]
}, http.StatusBadRequest, "invalid", "id"},
{"нет адресата", func(m map[string]any) { delete(m, "to") }, http.StatusBadRequest, "invalid", "to"},
{"оба адресата", func(m map[string]any) {
m["to"] = map[string]string{"dm": "petya", "room": bytesOf(16, 1)}
}, http.StatusBadRequest, "invalid", "to"},
{"кривой ник", func(m map[string]any) {
m["to"] = map[string]string{"dm": "МАРТА"}
}, http.StatusBadRequest, "invalid", "to"},
{"чужой keyId в личном чате", func(m map[string]any) {
m["keyId"] = bytesOf(16, 1)
}, http.StatusBadRequest, "invalid", "keyId"},
{"iv не 12 байт", func(m map[string]any) { m["iv"] = bytesOf(16, 21) }, http.StatusBadRequest, "invalid", "iv"},
{"короткий ct", func(m map[string]any) { m["ct"] = bytesOf(8, 23) }, http.StatusBadRequest, "invalid", "ct"},
{"ct не base64url", func(m map[string]any) { m["ct"] = "!!!" }, http.StatusBadRequest, "invalid", "ct"},
{"неизвестный ник", func(m map[string]any) {
m["to"] = map[string]string{"dm": "kolya"}
}, http.StatusNotFound, "unknown_user", ""},
{"себе", func(m map[string]any) {
m["to"] = map[string]string{"dm": "marta"}
}, http.StatusBadRequest, "self", ""},
// Комнаты — этап 3; членства нет ни у кого.
{"в комнату", func(m map[string]any) {
m["to"] = map[string]string{"room": bytesOf(16, 1)}
m["keyId"] = bytesOf(16, 2)
}, http.StatusForbidden, "not_member", ""},
{"кривой roomId", func(m map[string]any) {
m["to"] = map[string]string{"room": "нет"}
}, http.StatusBadRequest, "invalid", "to"},
{"кривой keyId комнаты", func(m map[string]any) {
m["to"] = map[string]string{"room": bytesOf(16, 1)}
m["keyId"] = "dm"
}, http.StatusBadRequest, "invalid", "keyId"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
body := message(ulid(nowMillis(), 3), "petya")
c.change(body)
rec := e.do(http.MethodPost, "/api/messages", body, with(marta), withDevice(m1))
expect(t, rec, c.status, c.code)
if c.field != "" {
var got struct {
Field string `json:"field"`
}
decodeBody(t, rec, &got)
if got.Field != c.field {
t.Errorf("field: получено %q, ожидалось %q", got.Field, c.field)
}
}
if got := e.queue(p1); len(got) != 0 {
t.Errorf("отвергнутое сообщение попало в очередь: %v", got)
}
})
}
}
// Лимит сообщений — 30 в минуту на пользователя, пакет 10 (ADR-021).
func TestMessageRateLimit(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
rec := e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"), with(marta), withDevice(m1))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Токен набегает раз в две секунды: пакет кончился, ждать до двух.
after, err := strconv.Atoi(rec.Header().Get("Retry-After"))
if err != nil || after < 1 || after > 2 {
t.Errorf("Retry-After: получено %q", rec.Header().Get("Retry-After"))
}
// Лимит на пользователе, а не на устройстве.
m2 := e.addDevice(marta, deviceOf(3))
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 12), "petya"),
with(marta), withDevice(m2)), http.StatusTooManyRequests, "rate_limited")
// Другому пользователю чужой лимит не мешает.
petya, p1 := e.join("kolya", 4)
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 13), "marta"),
with(petya), withDevice(p1)), http.StatusAccepted, "")
}
+163
View File
@@ -0,0 +1,163 @@
package api
import (
"crypto/ecdh"
"encoding/base64"
"encoding/json"
"net/http"
"net/netip"
"net/url"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/push"
)
// Push-подписка принадлежит устройству (ADR-023): её ставит и снимает
// само устройство. Сервер хранит подписку как непрозрачный JSON и лезет
// в неё только при отправке.
// Длины ключей подписки (RFC 8291): p256dh — несжатая точка P-256,
// auth — общий секрет.
const (
p256dhLen = 65
authLen = 16
// maxEndpoint — предел длины адреса подписки. Адреса вендоров —
// две-три сотни символов; всё остальное push-сервисом не является,
// а прочие поля протокола ограничены явно (docs/protocol.md).
maxEndpoint = 2 << 10
)
// subscriptionIn — объект PushSubscription.toJSON(). Поле expirationTime
// браузеры кладут рядом; сервер его не читает и не хранит — хранится
// ровно то, что нужно для отправки.
type subscriptionIn struct {
Endpoint string `json:"endpoint"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
// PUT /api/devices/{id}/push — подписка устройства на пуши. Сбрасывает
// неотработанный пуш: устройство снова готово его принять (ADR-023).
//
// X-Device на этом маршруте обязателен, и устройство в пути тоже обязано
// быть своим: чужому устройству подписку не поставить (docs/protocol.md,
// «Общие правила»).
func (s *server) setPush(w http.ResponseWriter, r *http.Request) {
var in struct {
Subscription subscriptionIn `json:"subscription"`
}
if !decode(w, r, &in) {
return
}
// Форма проверяется раньше прав (ADR-043).
subscription, ok := checkSubscription(w, in.Subscription)
if !ok {
return
}
if _, ok := s.device(w, r); !ok {
return
}
sess, _ := auth.From(r)
set, err := s.st.SetPush(r.Context(), r.PathValue("id"), sess.Nick, subscription)
if err != nil {
s.internal(w, r, err)
return
}
if !set {
unknownDevice(w)
return
}
noContent(w)
}
// DELETE /api/devices/{id}/push — снять подписку. Подписки не было —
// тот же 204: снимать нечего. Чужое устройство — 403, как и на PUT.
func (s *server) deletePush(w http.ResponseWriter, r *http.Request) {
if _, ok := s.device(w, r); !ok {
return
}
sess, _ := auth.From(r)
cleared, err := s.st.ClearPush(r.Context(), r.PathValue("id"), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
if !cleared {
unknownDevice(w)
return
}
noContent(w)
}
// checkSubscription проверяет форму подписки и отдаёт её канонический
// JSON: три поля и ничего больше. Ответ об ошибке уже написан, если
// вернулось false.
func checkSubscription(w http.ResponseWriter, in subscriptionIn) (string, bool) {
if !validEndpoint(in.Endpoint) {
Invalid(w, "subscription", "endpoint — не публичный https-url до 2 КиБ")
return "", false
}
if !pushPoint(in.Keys.P256dh) {
Invalid(w, "subscription", "keys.p256dh — не точка p-256 в 65 байтах base64url")
return "", false
}
if _, ok := pushKey(in.Keys.Auth, authLen); !ok {
Invalid(w, "subscription", "keys.auth — не 16 байт base64url")
return "", false
}
out, err := json.Marshal(in)
if err != nil {
return "", false
}
return string(out), true
}
// validEndpoint — адрес push-сервиса. Выбирает его браузер, сервер знает
// о нём только то, что это абсолютный https-url разумной длины: без TLS
// пуш ушёл бы открытым текстом мимо всех обещаний.
//
// Литеральный непубличный адрес отвергается сразу: push-сервиса по нему
// не бывает, а внутренняя служба бывает (ADR-047). Имя здесь не
// разрешается — за именем всё равно может стоять внутренний адрес,
// поэтому решающая проверка идёт при соединении, в отправщике.
func validEndpoint(raw string) bool {
if raw == "" || len(raw) > maxEndpoint {
return false
}
u, err := url.Parse(raw)
if err != nil || u.Scheme != "https" || u.Host == "" {
return false
}
ip, err := netip.ParseAddr(u.Hostname())
if err != nil {
// Не литерал, а имя: его разберёт отправщик.
return true
}
return push.Public(ip)
}
// pushPoint — p256dh: несжатая точка кривой P-256. Одной длины мало:
// случайные 65 байт точкой не являются, отправка на них падает при
// каждом сообщении, а устройство остаётся с подпиской, которая никогда
// не заработает (docs/protocol.md, «Устройства»).
func pushPoint(s string) bool {
raw, ok := pushKey(s, p256dhLen)
if !ok {
return false
}
_, err := ecdh.P256().NewPublicKey(raw)
return err == nil
}
// pushKey — ключ подписки: ровно n байт base64url. Push API задаёт форму
// без паддинга, но браузер, добавивший паддинг, не должен остаться без
// уведомлений: webpush-go разбирает обе формы, и сервер принимает обе.
func pushKey(s string, n int) ([]byte, bool) {
if raw, err := b64.DecodeString(s); err == nil {
return raw, len(raw) == n
}
raw, err := base64.URLEncoding.DecodeString(s)
return raw, err == nil && len(raw) == n
}
+842
View File
@@ -0,0 +1,842 @@
package api_test
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/ecdh"
"crypto/hkdf"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/xmatic-squad/bare/internal/config"
)
// quiet — сколько ждём, чтобы убедиться, что пуша нет. Всё локально,
// задержек быть не должно.
const quiet = 300 * time.Millisecond
// pushEnv — сервер с настоящей парой VAPID-ключей: без неё пуши выключены
// (docs/deploy.md).
func pushEnv(t *testing.T) *env { return pushEnvWith(t, true) }
// pushEnvWith — то же; local разрешает отправку на 127.0.0.1, где живёт
// подменный push-сервис. Настоящий сервер ходит только по публичным
// адресам (ADR-047), и это проверяется отдельно.
func pushEnvWith(t *testing.T, local bool) *env {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("vapid: %v", err)
}
return envWith(t, func(cfg *config.Config) {
cfg.VAPIDPublic = raw64(key.PublicKey().Bytes())
cfg.VAPIDPrivate = raw64(key.Bytes())
cfg.VAPIDSubject = "mailto:bare@bare.test"
// Push-сервис вендора подменён сервером на 127.0.0.1: в работе
// отправщик ходит только по публичным адресам (ADR-047).
cfg.PushLocal = local
})
}
func raw64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// padded64 — то же, что bytesOf, но с паддингом: браузер вправе прислать
// ключи подписки и в такой форме.
func padded64(n int, seed byte) string {
raw := make([]byte, n)
for i := range raw {
raw[i] = seed + byte(i)
}
return base64.URLEncoding.EncodeToString(raw)
}
// point65 — p256dh настоящей подписки: несжатая точка P-256 в 65 байтах.
// Случайные байты той же длины точкой не являются, и сервер их не примет
// (docs/protocol.md, «Устройства»).
func point65(t *testing.T) []byte {
t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("ключ подписки: %v", err)
}
return key.PublicKey().Bytes()
}
// pushService — push-сервис вендора в тесте. Настоящий FCM тестам не нужен
// и не годится: проверяется, что уходит и что сервер делает с ответом.
type pushService struct {
t *testing.T
url string
got chan delivered
status atomic.Int32
}
// delivered — то, что увидел push-сервис.
type delivered struct {
device string // хвост endpoint: по нему видно, чей это пуш
ttl string
urgency string
encoding string
auth string
record []byte
}
func newPushService(t *testing.T) *pushService {
t.Helper()
open := make(chan struct{})
close(open)
return pushServiceWith(t, open)
}
// newSlowPushService — push-сервис, который принимает запрос и молчит,
// пока тест не отпустит его. Так видно, что делает сервер, пока отправка
// ещё идёт. Отпускать обязательно: иначе остановка сервера ждёт таймаута.
func newSlowPushService(t *testing.T) (*pushService, func()) {
t.Helper()
gate := make(chan struct{})
var once sync.Once
return pushServiceWith(t, gate), func() { once.Do(func() { close(gate) }) }
}
// pushServiceWith — push-сервис, отвечающий не раньше, чем закроется gate.
func pushServiceWith(t *testing.T, gate <-chan struct{}) *pushService {
t.Helper()
p := &pushService{t: t, got: make(chan delivered, 512)}
p.status.Store(http.StatusCreated)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
record, _ := io.ReadAll(r.Body)
got := delivered{
device: strings.TrimPrefix(r.URL.Path, "/push/"),
ttl: r.Header.Get("TTL"),
urgency: r.Header.Get("Urgency"),
encoding: r.Header.Get("Content-Encoding"),
auth: r.Header.Get("Authorization"),
record: record,
}
select {
case p.got <- got:
default:
}
select {
case <-gate:
case <-r.Context().Done():
return
}
w.WriteHeader(int(p.status.Load()))
}))
t.Cleanup(srv.Close)
p.url = srv.URL
return p
}
// next — следующий пуш; его отсутствие — ошибка теста.
func (p *pushService) next() delivered {
p.t.Helper()
select {
case got := <-p.got:
return got
case <-time.After(wait):
p.t.Fatal("пуш не пришёл")
}
return delivered{}
}
// silent требует, чтобы других пушей не было.
func (p *pushService) silent() {
p.t.Helper()
select {
case got := <-p.got:
p.t.Fatalf("лишний пуш устройству %s", got.device)
case <-time.After(quiet):
}
}
// subscriber — устройство с push-подпиской. Ключи настоящие: тест
// расшифровывает пуш ровно так, как это сделал бы браузер (RFC 8291),
// и потому видит, что в нём лежит.
type subscriber struct {
device string
key *ecdh.PrivateKey
auth []byte
}
// subscribe кладёт подписку устройства прямо в базу. Через PUT её сюда
// не поставить: тестовый push-сервис живёт на http, а эндпоинт принимает
// только https. Форму подписки проверяют TestPushSubscription
// и TestPushSubscriptionForm, правила отправки от неё не зависят.
func (e *env) subscribe(nick, device string, p *pushService) *subscriber {
e.t.Helper()
key, err := ecdh.P256().GenerateKey(rand.Reader)
if err != nil {
e.t.Fatalf("ключ подписки: %v", err)
}
auth := make([]byte, 16)
if _, err := rand.Read(auth); err != nil {
e.t.Fatalf("секрет подписки: %v", err)
}
raw, err := json.Marshal(map[string]any{
"endpoint": p.url + "/push/" + device,
"keys": map[string]string{
"p256dh": raw64(key.PublicKey().Bytes()),
"auth": raw64(auth),
},
})
if err != nil {
e.t.Fatalf("подписка: %v", err)
}
set, err := e.st.SetPush(context.Background(), device, nick, string(raw))
if err != nil || !set {
e.t.Fatalf("SetPush: %v (поставлена: %v)", err, set)
}
return &subscriber{device: device, key: key, auth: auth}
}
// open расшифровывает пуш: aes128gcm по RFC 8291, как это делает браузер.
// Без расшифровки нельзя утверждать, что в пуше нет ничего лишнего.
func (s *subscriber) open(t *testing.T, record []byte) map[string]string {
t.Helper()
check := func(what string, err error) {
t.Helper()
if err != nil {
t.Fatalf("%s: %v", what, err)
}
}
// Заголовок записи: соль, размер записи, длина открытого ключа.
const header = 16 + 4 + 1
if len(record) < header {
t.Fatalf("запись короче заголовка: %d байт", len(record))
}
salt := record[:16]
keyLen := int(record[20])
if len(record) < header+keyLen {
t.Fatalf("запись короче ключа отправителя: %d байт", len(record))
}
sender, ct := record[header:header+keyLen], record[header+keyLen:]
remote, err := ecdh.P256().NewPublicKey(sender)
check("ключ отправителя", err)
shared, err := s.key.ECDH(remote)
check("ecdh", err)
info := append([]byte("WebPush: info\x00"), s.key.PublicKey().Bytes()...)
info = append(info, sender...)
ikm, err := hkdf.Key(sha256.New, shared, s.auth, string(info), 32)
check("ikm", err)
cek, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: aes128gcm\x00", 16)
check("ключ записи", err)
nonce, err := hkdf.Key(sha256.New, ikm, salt, "Content-Encoding: nonce\x00", 12)
check("nonce", err)
block, err := aes.NewCipher(cek)
check("aes", err)
gcm, err := cipher.NewGCM(block)
check("gcm", err)
plain, err := gcm.Open(nil, nonce, ct, nil)
check("расшифровка", err)
// Хвост записи — набивка: нули после разделителя 0x02.
plain = bytes.TrimSuffix(bytes.TrimRight(plain, "\x00"), []byte{2})
var out map[string]string
if err := json.Unmarshal(plain, &out); err != nil {
t.Fatalf("нагрузка %q: %v", plain, err)
}
return out
}
// hasPush — что о подписке устройства говорит GET /api/devices.
func (e *env) hasPush(c *http.Cookie, device string) bool {
e.t.Helper()
rec := e.do(http.MethodGet, "/api/devices", nil, with(c))
expect(e.t, rec, http.StatusOK, "")
var list []struct {
ID string `json:"id"`
HasPush bool `json:"hasPush"`
}
decodeBody(e.t, rec, &list)
for _, got := range list {
if got.ID == device {
return got.HasPush
}
}
e.t.Fatalf("устройства %s нет в списке", device)
return false
}
// waitPushGone ждёт, пока подписка исчезнет: снимает её отправщик, уже
// после того, как push-сервис ответил.
func (e *env) waitPushGone(c *http.Cookie, device string) {
e.t.Helper()
for deadline := time.Now().Add(wait); time.Now().Before(deadline); {
if !e.hasPush(c, device) {
return
}
time.Sleep(5 * time.Millisecond)
}
e.t.Fatalf("подписка устройства %s не снята", device)
}
// send — обычная отправка личного сообщения.
func (e *env) send(c *http.Cookie, device, to string, seed byte) {
e.t.Helper()
expect(e.t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), seed), to),
with(c), withDevice(device)), http.StatusAccepted, "")
}
// pushEventually шлёт сообщения, пока не придёт пуш. И разрыв потока,
// и возврат права на пуш случаются после ответа на запрос: момент их
// наступления не назначить, поэтому попытка повторяется.
func (e *env) pushEventually(p *pushService, c *http.Cookie, device, to string) delivered {
e.t.Helper()
for i := 0; i < 8; i++ {
e.send(c, device, to, byte(50+i))
select {
case got := <-p.got:
return got
case <-time.After(200 * time.Millisecond):
}
}
e.t.Fatal("пуш так и не пришёл")
return delivered{}
}
// subscription — тело PUT /api/devices/{id}/push в форме
// PushSubscription.toJSON().
func subscription(t *testing.T) map[string]any {
t.Helper()
return map[string]any{
"endpoint": "https://push.example/one",
"expirationTime": nil,
"keys": map[string]string{"p256dh": raw64(point65(t)), "auth": bytesOf(16, 7)},
}
}
// Подписка ставится и снимается, hasPush честный, чужое устройство — 403
// (docs/protocol.md, «Устройства», «Общие правила»).
func TestPushSubscription(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
body := map[string]any{"subscription": subscription(t)}
if e.hasPush(marta, m1) {
t.Error("hasPush до подписки: true")
}
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, with(marta), withDevice(m1)),
http.StatusNoContent, "")
if !e.hasPush(marta, m1) {
t.Error("hasPush после подписки: false")
}
// Подписка принадлежит устройству: у соседа её не появилось.
if e.hasPush(petya, p1) {
t.Error("подписка досталась чужому устройству")
}
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
http.StatusNoContent, "")
if e.hasPush(marta, m1) {
t.Error("hasPush после снятия: true")
}
// Снимать нечего — тот же 204.
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, with(marta), withDevice(m1)),
http.StatusNoContent, "")
// Чужое устройство в пути — 403, и подписки у него не появилось.
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", body, with(marta), withDevice(m1)),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodDelete, "/api/devices/"+p1+"/push", nil, with(marta), withDevice(m1)),
http.StatusForbidden, "unknown_device")
if e.hasPush(petya, p1) {
t.Error("подписка поставлена чужому устройству")
}
// X-Device обязателен и обязан быть своим.
for _, opts := range [][]func(*http.Request){
{with(marta)},
{with(marta), withDevice(p1)},
{with(marta), withDevice("мусор")},
{with(marta), withDevice(deviceOf(9))},
} {
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push", body, opts...),
http.StatusForbidden, "unknown_device")
expect(t, e.do(http.MethodDelete, "/api/devices/"+m1+"/push", nil, opts...),
http.StatusForbidden, "unknown_device")
}
if e.hasPush(marta, m1) {
t.Error("подписка появилась после отказа")
}
}
// Форма подписки: абсолютный https-адрес и два ключа нужной длины.
func TestPushSubscriptionForm(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
cases := []struct {
name string
change func(map[string]any)
}{
{"нет endpoint", func(s map[string]any) { delete(s, "endpoint") }},
{"endpoint без tls", func(s map[string]any) { s["endpoint"] = "http://push.example/one" }},
{"endpoint без хоста", func(s map[string]any) { s["endpoint"] = "https:///one" }},
{"endpoint не url", func(s map[string]any) { s["endpoint"] = "какой же это url" }},
{"нет ключей", func(s map[string]any) { delete(s, "keys") }},
{"endpoint на loopback", func(s map[string]any) { s["endpoint"] = "https://127.0.0.1:9/push" }},
{"endpoint на link-local", func(s map[string]any) {
s["endpoint"] = "https://169.254.169.254/latest/meta-data/"
}},
{"endpoint в приватной сети", func(s map[string]any) { s["endpoint"] = "https://10.0.0.1/push" }},
{"endpoint на ::1", func(s map[string]any) { s["endpoint"] = "https://[::1]:8411/api/me" }},
{"endpoint длиннее 2 КиБ", func(s map[string]any) {
s["endpoint"] = "https://push.example/" + strings.Repeat("a", 2048)
}},
{"p256dh не 65 байт", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(32, 5), "auth": bytesOf(16, 7)}
}},
{"p256dh не точка на кривой", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(16, 7)}
}},
{"auth не 16 байт", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": bytesOf(65, 5), "auth": bytesOf(32, 7)}
}},
{"ключ не base64url", func(s map[string]any) {
s["keys"] = map[string]string{"p256dh": strings.Repeat("!", 87), "auth": bytesOf(16, 7)}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
sub := subscription(t)
c.change(sub)
rec := e.do(http.MethodPut, "/api/devices/"+m1+"/push",
map[string]any{"subscription": sub}, with(marta), withDevice(m1))
expect(t, rec, http.StatusBadRequest, "invalid")
var field struct {
Field string `json:"field"`
}
decodeBody(t, rec, &field)
if field.Field != "subscription" {
t.Errorf("field: получено %q, ожидалось \"subscription\"", field.Field)
}
})
}
if e.hasPush(marta, m1) {
t.Error("подписка не по форме поставилась")
}
// Паддинг в base64url тоже принимается: браузер вправе его добавить.
padded := subscription(t)
padded["keys"] = map[string]string{
"p256dh": base64.URLEncoding.EncodeToString(point65(t)),
"auth": padded64(16, 7),
}
expect(t, e.do(http.MethodPut, "/api/devices/"+m1+"/push",
map[string]any{"subscription": padded}, with(marta), withDevice(m1)), http.StatusNoContent, "")
// Форма проверяется раньше прав: на запрос к чужому устройству
// приходит отказ по форме, а не по правам (ADR-043).
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push", "не json", with(marta), withDevice(m1)),
http.StatusBadRequest, "bad_json")
broken := subscription(t)
broken["endpoint"] = "http://push.example/one"
expect(t, e.do(http.MethodPut, "/api/devices/"+p1+"/push",
map[string]any{"subscription": broken}, with(marta), withDevice(m1)), http.StatusBadRequest, "invalid")
}
// Пуш уходит только отключённому устройству с подпиской (ADR-023).
// Он несёт заголовок, «новое сообщение» и адрес чата — и ничего больше.
func TestPushToSilentDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
e.subscribe("petya", p1, svc) // подключено по SSE
silent := e.subscribe("petya", p2, svc) // молчит
stream := e.open(p1, petya)
stream.untilReady()
e.send(marta, m1, "petya", 4)
got := svc.next()
if got.device != p2 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p2)
}
// TTL сутки, urgency normal (ADR-023).
if got.ttl != "86400" {
t.Errorf("TTL: получено %q, ожидалось \"86400\"", got.ttl)
}
if got.urgency != "normal" {
t.Errorf("Urgency: получено %q, ожидалось \"normal\"", got.urgency)
}
if got.encoding != "aes128gcm" {
t.Errorf("Content-Encoding: получено %q, ожидалось \"aes128gcm\"", got.encoding)
}
if !strings.HasPrefix(got.auth, "vapid t=") {
t.Errorf("Authorization: получено %q, ожидался vapid", got.auth)
}
payload := silent.open(t, got.record)
if len(payload) != 3 {
t.Errorf("поля нагрузки: %v", payload)
}
if payload["title"] != "@marta" {
t.Errorf("title: получено %q, ожидалось \"@marta\"", payload["title"])
}
if payload["body"] != "новое сообщение" {
t.Errorf("body: получено %q, ожидалось \"новое сообщение\"", payload["body"])
}
if payload["chat"] != "dm:marta" {
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
}
// Шифротекста сообщения в пуше нет ни в каком виде: сервер его
// не пересылает, а плейнтекста он и не знает (ADR-011).
if bytes.Contains(got.record, []byte(bytesOf(48, 23))) {
t.Error("шифротекст сообщения попал в пуш")
}
svc.silent()
}
// Одно молчащее устройство получает один пуш, а не ленту (ADR-023).
func TestPushOncePerSilentDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
if got := svc.next(); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
// Второе и третье сообщение подряд пуша не порождают.
e.send(marta, m1, "petya", 4)
e.send(marta, m1, "petya", 5)
svc.silent()
}
// Подключение по SSE сбрасывает неотработанный пуш: следующее сообщение
// молчащему устройству снова даёт пуш (ADR-023).
func TestPushAgainAfterStream(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
e.send(marta, m1, "petya", 4)
svc.silent()
stream := e.open(p1, petya)
stream.untilReady()
stream.close()
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
}
// 404 и 410 от push-сервиса означают, что подписки больше нет (ADR-011).
func TestPushDeadSubscription(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusGone} {
t.Run(http.StatusText(status), func(t *testing.T) {
svc := newPushService(t)
svc.status.Store(int32(status))
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
e.waitPushGone(petya, p1)
})
}
}
// Прочие отказы push-сервиса подписку не трогают и доставку сообщения
// не роняют. Право на пуш при этом возвращается: иначе одна ошибка
// затыкала бы уведомления устройства до самого подключения.
func TestPushServiceError(t *testing.T) {
svc := newPushService(t)
svc.status.Store(http.StatusInternalServerError)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.next()
if !e.hasPush(petya, p1) {
t.Error("подписка снята по ответу 500")
}
svc.status.Store(http.StatusCreated)
if got := e.pushEventually(svc, marta, m1, "petya"); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
if !e.hasPush(petya, p1) {
t.Error("подписка снята после успешного пуша")
}
}
// Отправитель пуша о собственном сообщении не получает — ни на то
// устройство, с которого писал, ни на остальные свои (ADR-045).
func TestPushNotToSender(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
m2 := e.addDevice(marta, deviceOf(2))
_, p1 := e.join("petya", 3)
e.subscribe("marta", m1, svc)
e.subscribe("marta", m2, svc)
to := e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 4)
got := svc.next()
if got.device != p1 {
t.Fatalf("пуш ушёл устройству отправителя %s", got.device)
}
svc.silent()
if payload := to.open(t, got.record); payload["chat"] != "dm:marta" {
t.Errorf("chat: получено %q, ожидалось \"dm:marta\"", payload["chat"])
}
}
// Пуш из комнаты: заголовок — имя комнаты, адрес чата — её идентификатор
// (ADR-023).
func TestPushFromRoom(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
room := e.makeRoom(marta, "marta", "общая", 40, withDevice(m1))
expect(t, e.changeMembers(marta, room.ID, []string{"petya"}, nil, []string{"marta", "petya"}, 41),
http.StatusOK, "")
member := e.subscribe("petya", p1, svc)
expect(t, e.do(http.MethodPost, "/api/messages",
roomMessage(ulid(nowMillis(), 5), room.ID, keyID(41)), with(marta), withDevice(m1)),
http.StatusAccepted, "")
got := svc.next()
if got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
payload := member.open(t, got.record)
if payload["title"] != "#общая" {
t.Errorf("title: получено %q, ожидалось \"#общая\"", payload["title"])
}
if payload["chat"] != "room:"+room.ID {
t.Errorf("chat: получено %q, ожидалось %q", payload["chat"], "room:"+room.ID)
}
if payload["body"] != "новое сообщение" {
t.Errorf("body: получено %q", payload["body"])
}
svc.silent()
}
// Без VAPID-ключей пуши выключены: подписка ставится, отправки нет.
func TestPushOffWithoutKeys(t *testing.T) {
svc := newPushService(t)
e := newEnv(t)
marta, m1 := e.join("marta", 1)
_, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.silent()
}
// Аккаунт с молчащим push-сервисом не отбирает отправку у остальных:
// доля одного аккаунта в отправщиках ограничена (ADR-048).
func TestPushShareBetweenAccounts(t *testing.T) {
e := pushEnv(t)
stuck, release := newSlowPushService(t)
defer release()
live := newPushService(t)
marta, m1 := e.join("marta", 1)
greedy, g1 := e.join("greedy", 2)
e.subscribe("greedy", g1, stuck)
for seed := byte(10); seed < 30; seed++ {
e.subscribe("greedy", e.addDevice(greedy, deviceOf(seed)), stuck)
}
_, c1 := e.join("carol", 3)
e.subscribe("carol", c1, live)
// Двадцать одно молчащее устройство одного аккаунта: часть заданий
// отбрасывается сразу, остальные занимают не больше своей доли.
e.send(marta, m1, "greedy", 40)
e.send(marta, m1, "carol", 41)
select {
case got := <-live.got:
if got.device != c1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, c1)
}
case <-time.After(wait):
t.Fatal("пуш постороннему аккаунту не ушёл: отправщики заняты чужим")
}
}
// Устройство, подключившееся по SSE во время отправки, не остаётся
// с неотработанным пушем: право возвращается, и следующее сообщение
// после ухода в офлайн снова даёт пуш (ADR-023).
func TestPushReleasedWhenDeviceConnects(t *testing.T) {
e := pushEnv(t)
svc, release := newSlowPushService(t)
defer release()
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
// Отправка уже началась: push-сервис получил запрос и держит его.
if got := svc.next(); got.device != p1 {
t.Fatalf("пуш ушёл устройству %s, ожидалось %s", got.device, p1)
}
// Пока пуш в пути, устройство подключилось: подключение сбрасывает
// неотработанный пуш, а отправщик поставил его позже.
stream := e.open(p1, petya)
stream.untilReady()
release()
// Право на пуш свободно: захват удаётся.
for deadline := time.Now().Add(wait); ; {
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
if err != nil {
t.Fatalf("ClaimPush: %v", err)
}
if ok {
if claimed == "" {
t.Error("подписка пуста")
}
return
}
if time.Now().After(deadline) {
t.Fatal("неотработанный пуш остался висеть на подключённом устройстве")
}
time.Sleep(5 * time.Millisecond)
}
}
// Пуш на непубличный адрес не уходит вовсе: соединения не случается,
// право на пуш возвращается, а адрес подписки в журнал не попадает
// (ADR-047, docs/deploy.md, «Логи»).
func TestPushSkipsLocalEndpoint(t *testing.T) {
svc := newPushService(t)
e := pushEnvWith(t, false)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
e.send(marta, m1, "petya", 3)
svc.silent()
if !e.hasPush(petya, p1) {
t.Error("подписка снята, хотя push-сервис не отвечал")
}
// Право на пуш вернулось: следующее сообщение попробует снова.
claimed, ok, err := e.st.ClaimPush(context.Background(), p1)
if err != nil {
t.Fatalf("ClaimPush: %v", err)
}
if !ok || claimed == "" {
t.Error("право на пуш осталось захваченным")
}
log := e.log.String()
if !strings.Contains(log, "адрес подписки не публичный") {
t.Errorf("в журнале нет причины отказа: %q", log)
}
if strings.Contains(log, "127.0.0.1") || strings.Contains(log, strings.TrimPrefix(svc.url, "http://")) {
t.Errorf("адрес подписки попал в журнал: %q", log)
}
}
// Одно сообщение — несколько молчащих устройств: каждое получает свою
// расшифровываемую нагрузку. Нагрузка на всех одна (ADR-045), но
// шифруется она для каждой подписки отдельно.
func TestPushPayloadPerDevice(t *testing.T) {
svc := newPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
p2 := e.addDevice(petya, deviceOf(3))
p3 := e.addDevice(petya, deviceOf(4))
subs := map[string]*subscriber{
p1: e.subscribe("petya", p1, svc),
p2: e.subscribe("petya", p2, svc),
p3: e.subscribe("petya", p3, svc),
}
e.send(marta, m1, "petya", 5)
seen := make(map[string]bool)
for i := 0; i < len(subs); i++ {
got := svc.next()
to, ok := subs[got.device]
if !ok {
t.Fatalf("пуш ушёл неизвестному устройству %s", got.device)
}
if seen[got.device] {
t.Fatalf("устройство %s получило второй пуш", got.device)
}
seen[got.device] = true
payload := to.open(t, got.record)
if payload["title"] != "@marta" || payload["chat"] != "dm:marta" || payload["body"] != "новое сообщение" {
t.Errorf("нагрузка устройства %s: %v", got.device, payload)
}
}
svc.silent()
}
// Остановка обработчика дожидается начатых отправок. Отправщик пишет
// результат в базу, поэтому закрывать её раньше нельзя, а колбэк
// http.Server.RegisterOnShutdown для этого не годится: сервер запускает
// его в своей горутине и ничего не ждёт. Потоки событий там закрывает
// CloseStreams, отправку останавливает Close — после Shutdown.
func TestCloseWaitsForPush(t *testing.T) {
svc, release := newSlowPushService(t)
e := pushEnv(t)
marta, m1 := e.join("marta", 1)
petya, p1 := e.join("petya", 2)
e.subscribe("petya", p1, svc)
_ = petya
e.send(marta, m1, "petya", 5)
// Отправка началась и висит на медленном сервисе.
svc.next()
done := make(chan struct{})
go func() {
defer close(done)
e.h.Close()
}()
select {
case <-done:
t.Fatal("остановка не дождалась начатой отправки")
case <-time.After(quiet):
}
release()
select {
case <-done:
case <-time.After(wait):
t.Fatal("остановка не закончилась после отправки")
}
}
+305
View File
@@ -0,0 +1,305 @@
package api_test
import (
"fmt"
"io"
"net/http"
"strings"
"testing"
"github.com/xmatic-squad/bare/internal/api"
)
// nickOf — ник для очередного аккаунта теста.
func nickOf(i int) string { return fmt.Sprintf("marta%d", i) }
// fromIP — соединение с этого адреса, без заголовков.
func fromIP(ip string) func(*http.Request) { return withRemote(ip + ":41000") }
// Регистрация — 5 в час на IP (ADR-021).
func TestRegisterRateLimit(t *testing.T) {
e := newEnv(t)
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one), http.StatusCreated, "")
}
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one)
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Токен набегает раз в двенадцать минут; ведро пусто, значит ждать
// почти все 720 секунд.
if got := retryAfterOf(t, rec); got < 700 || got > 720 {
t.Errorf("Retry-After: получено %d, ожидалось около 720", got)
}
// Отказ ничего не завёл.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "kolya", "authKey": bytesOf(32, 1)}),
http.StatusUnauthorized, "invalid_credentials")
// Другой адрес — своё ведро.
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), fromIP("203.0.113.8")),
http.StatusCreated, "")
}
// Неудачная регистрация тратит попытку так же, как удачная: иначе занятые
// ники перебирались бы без счёта.
func TestRegisterRateLimitCountsFailures(t *testing.T) {
e := invited(t, "секрет")
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
body := account(nickOf(i))
body["invite"] = "не секрет"
expect(t, e.do(http.MethodPost, "/api/register", body, one), http.StatusForbidden, "invalid_invite")
}
right := account("marta")
right["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", right, one), http.StatusTooManyRequests, "rate_limited")
// Форма разбирается раньше лимита и попытки не тратит (ADR-043).
fresh := fromIP("203.0.113.9")
for i := 0; i < 20; i++ {
body := account("МАРТА")
body["invite"] = "секрет"
expect(t, e.do(http.MethodPost, "/api/register", body, fresh), http.StatusBadRequest, "invalid_nick")
}
expect(t, e.do(http.MethodPost, "/api/register", right, fresh), http.StatusCreated, "")
}
// Вход — 10 за 10 минут на пару IP+ник (ADR-021).
func TestLoginRateLimit(t *testing.T) {
e := newEnv(t)
e.signUp("marta")
e.signUp("petya")
one := fromIP("203.0.113.7")
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/login", wrong, one), http.StatusUnauthorized, "invalid_credentials")
}
rec := e.do(http.MethodPost, "/api/login", wrong, one)
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
if got := retryAfterOf(t, rec); got < 55 || got > 60 {
t.Errorf("Retry-After: получено %d, ожидалось около 60", got)
}
// Верный пароль с того же адреса ждёт вместе с неверными: ведро
// на паре, а не на исходе попытки.
right := map[string]any{"nick": "marta", "authKey": bytesOf(32, 1)}
expect(t, e.do(http.MethodPost, "/api/login", right, one), http.StatusTooManyRequests, "rate_limited")
// Другой ник с того же адреса — своё ведро.
expect(t, e.do(http.MethodPost, "/api/login", map[string]any{"nick": "petya", "authKey": bytesOf(32, 9)}, one),
http.StatusUnauthorized, "invalid_credentials")
// Тот же ник с другого адреса — тоже своё.
expect(t, e.do(http.MethodPost, "/api/login", right, fromIP("203.0.113.8")), http.StatusOK, "")
}
// Остальные изменяющие запросы — 60 в минуту на пользователя (ADR-021).
func TestWritesRateLimit(t *testing.T) {
e := newEnv(t)
marta := e.signUp("marta")
id := deviceOf(1)
for i := 0; i < 60; i++ {
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
if rec.Code >= http.StatusMultipleChoices {
t.Fatalf("запрос %d из шестидесяти отклонён: %d (%s)", i+1, rec.Code, rec.Body.String())
}
}
rec := e.do(http.MethodPost, "/api/devices", map[string]any{"id": id}, with(marta))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// Шестьдесят в минуту — токен раз в секунду.
if got := retryAfterOf(t, rec); got != 1 {
t.Errorf("Retry-After: получено %d, ожидалось 1", got)
}
// Ведро общее на все изменяющие маршруты пользователя.
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
http.StatusTooManyRequests, "rate_limited")
expect(t, e.do(http.MethodPost, "/api/logout", nil, with(marta)),
http.StatusTooManyRequests, "rate_limited")
// Чтения лимитом не считаются: ADR-021 ограничивает изменяющие.
expect(t, e.do(http.MethodGet, "/api/devices", nil, with(marta)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/me", nil, with(marta)), http.StatusOK, "")
expect(t, e.do(http.MethodGet, "/api/rooms", nil, with(marta)), http.StatusOK, "")
// Сообщения считаются своим правилом и своим ведром.
petya := e.signUp("petya")
e.addDevice(petya, deviceOf(2))
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 3), "petya"),
with(marta), withDevice(id)), http.StatusAccepted, "")
// Другой пользователь чужим лимитом не задет. Строка контакта уже есть:
// её завело сообщение, поэтому 200, а не 201 (ADR-019).
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "marta"}, with(petya)),
http.StatusOK, "")
}
// Лимит сообщений считается отдельно от общего: тридцать в минуту
// не отнимают шестьдесят у остальных запросов (ADR-021).
func TestMessagesOutsideWritesLimit(t *testing.T) {
e := newEnv(t)
marta, m1 := e.join("marta", 1)
e.join("petya", 2)
for i := 0; i < 10; i++ {
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), byte(i)), "petya"),
with(marta), withDevice(m1)), http.StatusAccepted, "")
}
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 11), "petya"),
with(marta), withDevice(m1)), http.StatusTooManyRequests, "rate_limited")
// Пакет сообщений кончился, изменяющие запросы работают.
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": []string{}}, with(marta), withDevice(m1)),
http.StatusNoContent, "")
expect(t, e.do(http.MethodPost, "/api/contacts", map[string]any{"nick": "petya"}, with(marta)),
http.StatusOK, "")
}
// X-Real-IP ставит nginx с той же машины: за ним у каждого адреса своё
// ведро (ADR-055).
func TestRealIPFromLoopback(t *testing.T) {
e := newEnv(t)
nginx := fromIP("127.0.0.1")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), nginx, withRealIP("198.51.100.7")),
http.StatusCreated, "")
}
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.7")),
http.StatusTooManyRequests, "rate_limited")
// Соседний адрес за тем же nginx ждать не должен.
expect(t, e.do(http.MethodPost, "/api/register", account("kolya"), nginx, withRealIP("198.51.100.8")),
http.StatusCreated, "")
}
// Заголовок из сети не читается: иначе лимит на IP снимался бы новой
// строкой в заголовке, то есть не существовал бы вовсе (ADR-055).
func TestRealIPFromNetworkIgnored(t *testing.T) {
e := newEnv(t)
one := fromIP("203.0.113.7")
for i := 0; i < 5; i++ {
expect(t, e.do(http.MethodPost, "/api/register", account(nickOf(i)), one,
withRealIP(fmt.Sprintf("198.51.100.%d", i))), http.StatusCreated, "")
}
rec := e.do(http.MethodPost, "/api/register", account("kolya"), one, withRealIP("198.51.100.200"))
expect(t, rec, http.StatusTooManyRequests, "rate_limited")
// И на входе тоже: ведро на паре адрес соединения + ник.
e2 := newEnv(t)
e2.signUp("marta")
wrong := map[string]any{"nick": "marta", "authKey": bytesOf(32, 9)}
for i := 0; i < 10; i++ {
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP(fmt.Sprintf("198.51.100.%d", i))),
http.StatusUnauthorized, "invalid_credentials")
}
expect(t, e2.do(http.MethodPost, "/api/login", wrong, one, withRealIP("198.51.100.200")),
http.StatusTooManyRequests, "rate_limited")
}
// Предел тела — 32 КиБ на всех маршрутах, ответ один: 413 too_large
// (ADR-026). Отказ приходит раньше сессии, устройства и разбора тела.
func TestTooLargeEverywhere(t *testing.T) {
e := newEnv(t)
marta, device := e.join("marta", 1)
big := strings.Repeat("a", api.MaxBody+1)
routes := []struct{ method, target string }{
{http.MethodPost, "/api/register"},
{http.MethodPost, "/api/login"},
{http.MethodPost, "/api/logout"},
{http.MethodPost, "/api/password"},
{http.MethodDelete, "/api/me"},
{http.MethodPost, "/api/devices"},
{http.MethodDelete, "/api/devices/" + device},
{http.MethodPut, "/api/devices/" + device + "/push"},
{http.MethodDelete, "/api/devices/" + device + "/push"},
{http.MethodPost, "/api/contacts"},
{http.MethodDelete, "/api/contacts/petya"},
{http.MethodPost, "/api/rooms"},
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/members"},
{http.MethodPost, "/api/rooms/" + roomIDOf(1) + "/leave"},
{http.MethodDelete, "/api/rooms/" + roomIDOf(1)},
{http.MethodPost, "/api/messages"},
{http.MethodPost, "/api/ack"},
{http.MethodGet, "/api/me"},
{http.MethodGet, "/api/events?device=" + device},
}
for _, route := range routes {
t.Run(route.method+" "+route.target, func(t *testing.T) {
// С сессией и своим устройством — отказ всё равно по телу.
expect(t, e.do(route.method, route.target, big, with(marta), withDevice(device)),
http.StatusRequestEntityTooLarge, "too_large")
// И без сессии тоже: тело проверяется раньше прав (ADR-043).
expect(t, e.do(route.method, route.target, big),
http.StatusRequestEntityTooLarge, "too_large")
})
}
}
// Тело без заявленной длины обрывается при чтении — тем же кодом и там,
// где раньше отвечало устройство (ADR-043).
func TestTooLargeUnannounced(t *testing.T) {
e := newEnv(t)
marta, _ := e.join("marta", 1)
_, foreign := e.join("petya", 2)
// Валидный json, чтобы разбор дошёл до предела чтения, а не споткнулся
// о первый же байт.
body := `{"id":"` + strings.Repeat("a", api.MaxBody) + `"}`
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms", "/api/devices", "/api/contacts"} {
rec := e.do(http.MethodPost, target, nil, with(marta), withDevice(foreign), func(r *http.Request) {
r.Body = io.NopCloser(strings.NewReader(body))
r.ContentLength = -1
})
expect(t, rec, http.StatusRequestEntityTooLarge, "too_large")
}
}
// Форма запроса разбирается раньше прав: кривое тело с чужим устройством
// отвечает про тело, а не про устройство (ADR-043).
func TestFormBeforeDevice(t *testing.T) {
e := newEnv(t)
_, martaDevice := e.join("marta", 1)
petya, _ := e.join("petya", 2)
for _, target := range []string{"/api/messages", "/api/ack", "/api/rooms"} {
expect(t, e.do(http.MethodPost, target, "{", with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "bad_json")
// Заголовка нет вовсе — то же самое.
expect(t, e.do(http.MethodPost, target, "{", with(petya)),
http.StatusBadRequest, "bad_json")
}
// Кривое поле тела — invalid с этим полем, хотя устройство чужое.
rec := e.do(http.MethodPost, "/api/messages", message("не ulid", "marta"), with(petya), withDevice(martaDevice))
expect(t, rec, http.StatusBadRequest, "invalid")
if got := field(t, rec); got != "id" {
t.Errorf("field: получено %q, ожидалось \"id\"", got)
}
// Часы — свойство самого запроса, а не право: clock_skew тоже раньше
// (docs/protocol.md, «Сообщения»).
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis()-6*60*1000, 3), "marta"),
with(petya), withDevice(martaDevice)), http.StatusBadRequest, "clock_skew")
ids := make([]string, 501)
for i := range ids {
ids[i] = ulid(nowMillis(), byte(i))
}
expect(t, e.do(http.MethodPost, "/api/ack", map[string]any{"ids": ids}, with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "invalid")
room := map[string]any{
"id": "не комната",
"name": "общая",
"keyId": keyID(40),
"keys": keysFor([]string{"petya"}, 40),
}
expect(t, e.do(http.MethodPost, "/api/rooms", room, with(petya), withDevice(martaDevice)),
http.StatusBadRequest, "invalid")
// Тело по форме — тогда отказ по устройству.
expect(t, e.do(http.MethodPost, "/api/messages", message(ulid(nowMillis(), 4), "marta"),
with(petya), withDevice(martaDevice)), http.StatusForbidden, "unknown_device")
}
+425
View File
@@ -0,0 +1,425 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"time"
"unicode"
"unicode/utf8"
"github.com/xmatic-squad/bare/internal/auth"
"github.com/xmatic-squad/bare/internal/hub"
"github.com/xmatic-squad/bare/internal/store"
)
// Комнаты (ADR-018): владелец меняет состав, ключи заворачивают клиенты.
// Сервер проверяет форму и права, хранит шифротекст и раздаёт события.
// maxRoomName — имя комнаты, символов (ADR-021). Имя открыто: это
// метаданные, как и состав.
const maxRoomName = 64
// roomOut — тип Room из docs/protocol.md. keys присутствует всегда,
// пустой — []; в GET /api/rooms это все удерживаемые сервером ключи
// запрашивающего, от старого к новому, в событии room — только новый
// (ADR-059). needsRekey — состояние комнаты, а не свойство события,
// поэтому идёт и в списке, и в событии (ADR-041).
type roomOut struct {
ID string `json:"id"`
Name string `json:"name"`
Owner string `json:"owner"`
Members []string `json:"members"`
CreatedAt int64 `json:"createdAt"`
Keys []keyOut `json:"keys"`
NeedsRekey bool `json:"needsRekey"`
}
// keyOut — завёрнутый ключ комнаты для того, кто его получает.
type keyOut struct {
KeyID string `json:"keyId"`
From string `json:"from"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// keyIn — запись keys[] запроса: WrappedKey из docs/protocol.md.
type keyIn struct {
To string `json:"to"`
IV string `json:"iv"`
CT string `json:"ct"`
}
// GET /api/rooms — комнаты, где пользователь участник, каждая с его
// ключами и признаком needsRekey: владелец, пропустивший событие,
// поднимает долг по ключу отсюда (ADR-041), а участник, пропустивший
// rekey в офлайне, — недостающий ключ (ADR-059).
func (s *server) rooms(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
list, err := s.st.Rooms(r.Context(), sess.Nick)
if err != nil {
s.internal(w, r, err)
return
}
out := make([]roomOut, 0, len(list))
for _, room := range list {
out = append(out, roomJSON(room, room.Keys))
}
writeJSON(w, http.StatusOK, out)
}
// POST /api/rooms — создание комнаты. Идентификатор выдаёт клиент
// (ADR-037), ключ приходит ровно один и заворачивается создателем себе:
// его другие устройства получают комнату вместе с ключом (ADR-018).
func (s *server) createRoom(w http.ResponseWriter, r *http.Request) {
var in struct {
ID string `json:"id"`
Name string `json:"name"`
KeyID string `json:"keyId"`
Keys []keyIn `json:"keys"`
}
if !decode(w, r, &in) {
return
}
// Идентификатор комнаты генерирует клиент: ключ заворачивается до
// запроса и привязан к roomId в info и AAD (ADR-037).
if !validID(in.ID) {
Invalid(w, "id", "id комнаты — не 16 байт base64url")
return
}
if !validRoomName(in.Name) {
Invalid(w, "name", "имя комнаты: 1–64 символа")
return
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return
}
keys, ok := wrappedKeys(w, in.Keys)
if !ok {
return
}
sess, _ := auth.From(r)
// Состав новой комнаты — один создатель, поэтому и ключ ровно один.
// Несовпадение — то же самое, что при rekey: keys не по составу.
if len(keys) != 1 || keys[0].To != sess.Nick {
keysMismatch(w)
return
}
// X-Device здесь необязателен, но чужой и кривой — 403, как и везде,
// где устройство важно (docs/protocol.md, «Общие правила»). Проверка
// идёт после формы тела: права — после неё (ADR-043).
device, ok := s.optionalDevice(w, r)
if !ok {
return
}
change, err := s.st.CreateRoom(r.Context(), store.NewRoom{
ID: in.ID,
Name: in.Name,
Owner: sess.Nick,
KeyID: in.KeyID,
Key: keys[0],
Now: time.Now().UnixMilli(),
})
if errors.Is(err, store.ErrRoomExists) {
// Занятый идентификатор не присоединяет к чужой комнате и не
// перезаписывает свою: клиент берёт новый (ADR-037).
Error(w, http.StatusConflict, "room_conflict", "такая комната уже есть")
return
}
if err != nil {
s.internal(w, r, err)
return
}
// Комната уже записана: остальным устройствам создателя она уходит
// событием, отправившему — ответом на запрос.
s.sendRoom(r, change, device)
writeJSON(w, http.StatusCreated, roomJSON(change.Room, keysFor(change, sess.Nick)))
}
// POST /api/rooms/{id}/members — смена состава и rekey одним запросом
// (ADR-018). Пустые add и remove — чистый rekey.
func (s *server) updateMembers(w http.ResponseWriter, r *http.Request) {
var in struct {
Add []string `json:"add"`
Remove []string `json:"remove"`
KeyID string `json:"keyId"`
Keys []keyIn `json:"keys"`
}
if !decode(w, r, &in) {
return
}
add, ok := uniqueNicks(in.Add)
if !ok {
// Форма — это форма: несуществующий ник верной формы отвечает
// unknown_user, а ник не по форме — invalid, как и в remove
// (ADR-043).
Invalid(w, "add", "добавить можно только ник a–z, 0–9, _")
return
}
remove, ok := uniqueNicks(in.Remove)
if !ok {
Invalid(w, "remove", "убрать можно только участника комнаты")
return
}
for _, nick := range remove {
for _, other := range add {
if nick == other {
Invalid(w, "remove", "один ник нельзя добавить и убрать одним запросом")
return
}
}
}
if !validID(in.KeyID) {
Invalid(w, "keyId", "keyId — не 16 байт base64url")
return
}
keys, ok := wrappedKeys(w, in.Keys)
if !ok {
return
}
sess, _ := auth.From(r)
change, err := s.st.UpdateMembers(r.Context(), store.MembersChange{
RoomID: r.PathValue("id"),
Owner: sess.Nick,
Add: add,
Remove: remove,
KeyID: in.KeyID,
Keys: keys,
Now: time.Now().UnixMilli(),
})
if err != nil {
s.roomError(w, r, err)
return
}
// Событие room уходит и участникам, и — как room_left — убранным;
// каждому участнику со своим ключом (docs/protocol.md, «Комнаты»).
s.sendRoom(r, change, "")
writeJSON(w, http.StatusOK, roomJSON(change.Room, keysFor(change, sess.Nick)))
}
// POST /api/rooms/{id}/leave — выход из комнаты. Владение переходит
// участнику с наименьшим joined_at, опустевшая комната удаляется;
// оставшимся уходит room с needsRekey (ADR-018), другим устройствам
// вышедшего — room_left (ADR-041).
//
// Не участник и несуществующая комната отвечают тем же 204: выходить
// неоткуда, а отдельного кода на этот случай в протоколе нет.
func (s *server) leaveRoom(w http.ResponseWriter, r *http.Request) {
// Заголовок необязателен, но чужой и кривой — 403, как и везде,
// где устройство важно (docs/protocol.md, «Общие правила»).
device, ok := s.optionalDevice(w, r)
if !ok {
return
}
sess, _ := auth.From(r)
change, err := s.st.LeaveRoom(r.Context(), r.PathValue("id"), sess.Nick)
if errors.Is(err, store.ErrNotFound) {
noContent(w)
return
}
if err != nil {
s.internal(w, r, err)
return
}
s.sendRoom(r, change, device)
noContent(w)
}
// DELETE /api/rooms/{id} — удаление комнаты владельцем. Всем участникам,
// включая его самого, уходит room_left.
func (s *server) deleteRoom(w http.ResponseWriter, r *http.Request) {
sess, _ := auth.From(r)
change, err := s.st.DeleteRoom(r.Context(), r.PathValue("id"), sess.Nick)
if err != nil {
s.roomError(w, r, err)
return
}
s.sendRoom(r, change, "")
noContent(w)
}
// roomError переводит отказы хранилища в коды протокола
// (docs/protocol.md, «Комнаты»).
func (s *server) roomError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, store.ErrNotOwner):
Error(w, http.StatusForbidden, "not_owner", "комнату меняет её владелец")
case errors.Is(err, store.ErrUnknownUser):
unknownUser(w)
case errors.Is(err, store.ErrNotMember):
Invalid(w, "remove", "убрать можно только участника комнаты")
case errors.Is(err, store.ErrOwnerRemoval):
Error(w, http.StatusBadRequest, "owner", "владельца убрать нельзя")
case errors.Is(err, store.ErrKeyExists):
Error(w, http.StatusConflict, "key_exists", "такой ключ у комнаты уже был")
case errors.Is(err, store.ErrKeysMismatch):
keysMismatch(w)
default:
s.internal(w, r, err)
}
}
func keysMismatch(w http.ResponseWriter) {
Error(w, http.StatusBadRequest, "keys_mismatch", "ключи не совпадают с составом комнаты")
}
// sendRoom раздаёт события изменившейся комнаты: room участникам, каждому
// с его собственным ключом, и room_left выбывшим. exclude — устройство,
// которому событие не нужно; пусто — нужно всем.
//
// Событие в очередь не кладётся: клиент после каждого ready перечитывает
// GET /api/rooms, а всё, что несёт room, включая needsRekey, есть и там,
// поэтому пропуск во время офлайна ничего не ломает (docs/protocol.md,
// «События», ADR-041).
func (s *server) sendRoom(r *http.Request, change store.RoomChange, exclude string) {
for _, member := range change.Members {
raw, err := json.Marshal(roomJSON(change.Room, keyList(member.Key)))
if err != nil {
s.report(r, err)
continue
}
s.send(member.Devices, exclude, hub.Event{Name: "room", Data: string(raw)})
}
if len(change.Left) == 0 {
return
}
raw, err := json.Marshal(struct {
ID string `json:"id"`
}{change.Room.ID})
if err != nil {
s.report(r, err)
return
}
for _, gone := range change.Left {
s.send(gone.Devices, exclude, hub.Event{Name: "room_left", Data: string(raw)})
}
}
// send отдаёт событие подключённым устройствам, кроме exclude.
func (s *server) send(devices []string, exclude string, ev hub.Event) {
for _, device := range devices {
if device == exclude {
continue
}
s.hub.Send(device, ev)
}
}
// roomJSON собирает Room протокола: состав и ключи всегда списки,
// пустые — [].
func roomJSON(room store.Room, keys []store.RoomKey) roomOut {
out := roomOut{
ID: room.ID,
Name: room.Name,
Owner: room.Owner,
Members: room.Members,
CreatedAt: room.CreatedAt,
Keys: make([]keyOut, 0, len(keys)),
NeedsRekey: room.NeedsRekey,
}
if out.Members == nil {
out.Members = []string{}
}
for _, key := range keys {
out.Keys = append(out.Keys, keyOut{KeyID: key.KeyID, From: key.From, IV: key.IV, CT: key.CT})
}
return out
}
// keyList — ключ события: он один, новый (ADR-059). Остальные свои ключи
// получатель уже видел, а отключённый доберёт их из GET /api/rooms.
func keyList(key *store.RoomKey) []store.RoomKey {
if key == nil {
return nil
}
return []store.RoomKey{*key}
}
// keysFor — ключ участника в итоге изменения: у каждого он свой.
func keysFor(change store.RoomChange, nick string) []store.RoomKey {
for _, member := range change.Members {
if member.Nick == nick {
return keyList(member.Key)
}
}
return nil
}
// wrappedKeys проверяет форму завёрнутых ключей. Содержимое сервер
// не проверяет и проверить не может: это шифротекст (ADR-018).
func wrappedKeys(w http.ResponseWriter, in []keyIn) ([]store.WrappedKey, bool) {
out := make([]store.WrappedKey, 0, len(in))
seen := make(map[string]bool, len(in))
for _, k := range in {
if !validNick(k.To) {
Invalid(w, "keys", "keys[].to — не ник")
return nil, false
}
if seen[k.To] {
// Два ключа одному участнику — это множество keys[].to,
// не равное составу, а не отдельный отказ (ADR-043).
keysMismatch(w)
return nil, false
}
seen[k.To] = true
if _, ok := decodeExactly(k.IV, ivLen); !ok {
Invalid(w, "keys", "iv — не 12 байт base64url")
return nil, false
}
if ct, err := b64.DecodeString(k.CT); err != nil || len(ct) < minCTLen {
Invalid(w, "keys", "ct — не base64url или слишком короткий")
return nil, false
}
out = append(out, store.WrappedKey{To: k.To, IV: k.IV, CT: k.CT})
}
return out, true
}
// uniqueNicks разбирает список ников запроса: повторы схлопываются,
// порядок сохраняется. Второе значение — прошёл ли список проверку формы.
func uniqueNicks(list []string) ([]string, bool) {
out := make([]string, 0, len(list))
seen := make(map[string]bool, len(list))
for _, nick := range list {
if !validNick(nick) {
return nil, false
}
if seen[nick] {
continue
}
seen[nick] = true
out = append(out, nick)
}
return out, true
}
// validRoomName — имя комнаты: непустое, до 64 рун, без управляющих
// символов, без переопределений направления письма и не из одних
// пробелов (ADR-021).
//
// Форма строже, чем «до 64 символов», с этапа 4: имя комнаты уходит
// в заголовок системного уведомления (ADR-045), а туда нельзя ни перевод
// строки, ни разворот текста — на экране блокировки такое имя выглядит
// не строкой списка, а сообщением от системы.
func validRoomName(name string) bool {
if name == "" || utf8.RuneCountInString(name) > maxRoomName {
return false
}
blank := true
for _, r := range name {
if unicode.IsControl(r) || bidi(r) {
return false
}
if !unicode.IsSpace(r) {
blank = false
}
}
return !blank
}
// bidi — переопределения направления письма: U+202A…U+202E и U+2066…U+2069.
// Они переставляют текст на экране местами, оставаясь невидимыми.
func bidi(r rune) bool {
return (r >= 0x202A && r <= 0x202E) || (r >= 0x2066 && r <= 0x2069)
}
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
package api
import "strings"
// ULID — 48 бит миллисекунд и 80 бит случайности, Crockford base32,
// 26 символов (docs/crypto.md, «Идентификаторы»). Сервер читает из него
// только время: расхождение с серверными часами больше пяти минут —
// clock_skew (ADR-017).
const ulidLen = 26
// crockford — алфавит Crockford base32: без I, L, O и U. Канонический
// ULID записывается заглавными; строчные буквы сервер не принимает —
// идентификатор входит в AAD шифротекста побайтно (docs/crypto.md).
const crockford = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
// ulidTime разбирает ULID и отдаёт его метку времени в миллисекундах.
func ulidTime(id string) (int64, bool) {
if len(id) != ulidLen {
return 0, false
}
var ms int64
for i := 0; i < ulidLen; i++ {
v := strings.IndexByte(crockford, id[i])
if v < 0 {
return 0, false
}
if i < 10 {
ms = ms<<5 | int64(v)
}
}
// Первые десять символов — 50 бит, времени отведено 48: старшие два
// обязаны быть нулевыми.
if ms > 1<<48-1 {
return 0, false
}
return ms, true
}
+131
View File
@@ -0,0 +1,131 @@
package api
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"regexp"
"github.com/xmatic-squad/bare/internal/config"
)
// Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму:
// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»).
const (
authKeyLen = 32 // байт
idLen = 16 // байт: deviceId, keyId, roomId
ivLen = 12 // байт
minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает
maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md
)
// b64 — кодировка бинарных полей протокола: base64url без паддинга.
var b64 = base64.RawURLEncoding
// nickRe — ник по ADR-019: только строчные, без регистровых коллизий.
var nickRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`)
func validNick(nick string) bool { return nickRe.MatchString(nick) }
// decodeExactly разбирает base64url и требует ровно n байт.
func decodeExactly(s string, n int) ([]byte, bool) {
raw, err := b64.DecodeString(s)
if err != nil || len(raw) != n {
return nil, false
}
return raw, true
}
// authKey разбирает authKey клиента: base64url ровно 32 байта.
func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) }
// validID — deviceId, keyId и roomId устроены одинаково: 16 случайных
// байт base64url, 22 символа (docs/crypto.md, «Идентификаторы»).
func validID(s string) bool {
_, ok := decodeExactly(s, idLen)
return ok
}
// jwkPublic — публичный ключ в том виде, в каком сервер его хранит
// и отдаёт: четыре поля и ничего больше.
type jwkPublic struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
}
// publicKeyJSON проверяет JWK и отдаёт его канонический JSON.
//
// Поле d — приватный ключ. Его наличие означает, что клиент собирается
// отдать серверу материал, которого у сервера не должно быть ни при каких
// условиях, поэтому такой запрос отвергается целиком, а не чистится молча.
// Всё, что не kty, crv, x и y, отбрасывается: хранится ровно то, что нужно.
func publicKeyJSON(raw json.RawMessage) (string, error) {
var in struct {
Kty string `json:"kty"`
Crv string `json:"crv"`
X string `json:"x"`
Y string `json:"y"`
D json.RawMessage `json:"d"`
}
if len(raw) == 0 {
return "", errors.New("нет публичного ключа")
}
if err := json.Unmarshal(raw, &in); err != nil {
return "", errors.New("публичный ключ — не jwk")
}
if in.D != nil {
return "", errors.New("приватному ключу на сервере не место")
}
if in.Kty != "EC" || in.Crv != "P-256" {
return "", errors.New("ожидается ключ ec p-256")
}
if _, ok := decodeExactly(in.X, 32); !ok {
return "", errors.New("x — не 32 байта base64url")
}
if _, ok := decodeExactly(in.Y, 32); !ok {
return "", errors.New("y — не 32 байта base64url")
}
out, err := json.Marshal(jwkPublic{Kty: in.Kty, Crv: in.Crv, X: in.X, Y: in.Y})
if err != nil {
return "", err
}
return string(out), nil
}
// blobIterations проверяет форму ключевого блоба (docs/crypto.md,
// «Ключевой блоб») и отдаёт iter. Это единственное поле блоба, которое
// сервер читает: его же отдаёт GET /api/kdf. Всё остальное — непрозрачный
// шифротекст.
func blobIterations(blob string) (int, error) {
if blob == "" {
return 0, errors.New("нет ключевого блоба")
}
if len(blob) > maxBlob {
return 0, errors.New("ключевой блоб больше 8 КиБ")
}
var b struct {
V int `json:"v"`
Iter int `json:"iter"`
IV string `json:"iv"`
CT string `json:"ct"`
}
if err := json.Unmarshal([]byte(blob), &b); err != nil {
return 0, errors.New("ключевой блоб — не json")
}
if b.V != 1 {
return 0, fmt.Errorf("версия блоба %d, ожидается 1", b.V)
}
if b.Iter < config.KDFMinIterations || b.Iter > config.KDFMaxIterations {
return 0, fmt.Errorf("iter вне границ %d…%d", config.KDFMinIterations, config.KDFMaxIterations)
}
if _, ok := decodeExactly(b.IV, ivLen); !ok {
return 0, errors.New("iv — не 12 байт base64url")
}
if ct, err := b64.DecodeString(b.CT); err != nil || len(ct) < minCTLen {
return 0, errors.New("ct — не base64url или слишком короткий")
}
return b.Iter, nil
}
+96
View File
@@ -0,0 +1,96 @@
// Package auth — argon2id, сессии, cookie и проверки на входе (ADR-021).
//
// Пароля здесь нет: клиент присылает authKey, выведенный из пароля
// (ADR-015). Сервер хранит argon2id от authKey — чтобы дамп базы не давал
// готового ключа для входа.
package auth
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"golang.org/x/crypto/argon2"
"github.com/xmatic-squad/bare/internal/store"
)
// Параметры argon2id из ADR-021. Вход — 32 случайных байта с точки зрения
// сервера, поэтому параметры умеренные.
const (
SaltLen = 16 // байт
KeyLen = 32 // байт
)
// Params — параметры одного хеша. Пишутся рядом с ним в users.auth_params
// и читаются оттуда при проверке: повышение параметров — перехеш при
// очередном входе, а не миграция всех аккаунтов разом.
type Params struct {
Memory uint32 // КиБ
Time uint32
Threads uint8
}
// Current — параметры для новых хешей.
var Current = Params{Memory: 19456, Time: 2, Threads: 1}
// String — форма записи в базе: "argon2id,m=19456,t=2,p=1".
func (p Params) String() string {
return fmt.Sprintf("argon2id,m=%d,t=%d,p=%d", p.Memory, p.Time, p.Threads)
}
// ParseParams разбирает строку из users.auth_params.
func ParseParams(s string) (Params, error) {
var p Params
n, err := fmt.Sscanf(s, "argon2id,m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads)
if err != nil || n != 3 {
return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s)
}
if p.Memory == 0 || p.Time == 0 || p.Threads == 0 {
return Params{}, fmt.Errorf("auth: нулевой параметр в %q", s)
}
// Sscanf не жалуется на хвост после последнего числа; сверка с обратной
// записью делает разбор точным.
if p.String() != s {
return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s)
}
return p, nil
}
// Hash считает argon2id от authKey с текущими параметрами и новой солью.
func Hash(authKey []byte) (store.Credential, error) {
salt := make([]byte, SaltLen)
if _, err := rand.Read(salt); err != nil {
return store.Credential{}, fmt.Errorf("auth: соль: %w", err)
}
return store.Credential{
Hash: derive(authKey, salt, Current),
Salt: salt,
Params: Current.String(),
}, nil
}
// Verify сверяет authKey с хешем из базы. Второе значение — нужен ли
// перехеш: параметры записи отстали от текущих.
func Verify(authKey []byte, cred store.Credential) (ok, rehash bool) {
p, err := ParseParams(cred.Params)
if err != nil || len(cred.Hash) != KeyLen || len(cred.Salt) == 0 {
return false, false
}
got := derive(authKey, cred.Salt, p)
if subtle.ConstantTimeCompare(got, cred.Hash) != 1 {
return false, false
}
return true, p != Current || len(cred.Salt) != SaltLen
}
// Waste считает столько же, сколько Verify, и выбрасывает результат.
// Вход с несуществующим ником не должен отвечать заметно быстрее входа
// с неверным authKey: одна ошибка на все случаи (docs/protocol.md).
func Waste(authKey []byte) {
derive(authKey, make([]byte, SaltLen), Current)
}
func derive(authKey, salt []byte, p Params) []byte {
return argon2.IDKey(authKey, salt, p.Time, p.Memory, p.Threads, KeyLen)
}
+85
View File
@@ -0,0 +1,85 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
"testing"
"github.com/xmatic-squad/bare/internal/store"
)
func TestParams(t *testing.T) {
if got := Current.String(); got != "argon2id,m=19456,t=2,p=1" {
t.Errorf("запись параметров: получено %q", got)
}
got, err := ParseParams("argon2id,m=19456,t=2,p=1")
if err != nil || got != Current {
t.Errorf("разбор параметров: получено %+v, %v", got, err)
}
for _, bad := range []string{"", "argon2id", "argon2i,m=1,t=1,p=1", "argon2id,m=0,t=2,p=1", "argon2id,m=19456,t=2,p=1,junk"} {
if _, err := ParseParams(bad); err == nil {
t.Errorf("%q разобрано, ожидалась ошибка", bad)
}
}
}
func TestHashVerify(t *testing.T) {
key := []byte("тридцать два байта authKey, ну почти")
cred, err := Hash(key)
if err != nil {
t.Fatalf("Hash: %v", err)
}
if len(cred.Hash) != KeyLen || len(cred.Salt) != SaltLen || cred.Params != Current.String() {
t.Fatalf("хеш: %d байт, соль %d байт, параметры %q", len(cred.Hash), len(cred.Salt), cred.Params)
}
if ok, rehash := Verify(key, cred); !ok || rehash {
t.Errorf("верный authKey: ok=%v rehash=%v", ok, rehash)
}
if ok, _ := Verify([]byte("другой ключ"), cred); ok {
t.Error("неверный authKey принят")
}
// Битая строка параметров — не повод считать хеш подошедшим.
broken := cred
broken.Params = "argon2id"
if ok, _ := Verify(key, broken); ok {
t.Error("хеш с неразобранными параметрами принят")
}
}
func TestVerifyAsksForRehash(t *testing.T) {
key := []byte("authKey")
old := Params{Memory: 8192, Time: 1, Threads: 1}
cred := store.Credential{
Hash: derive(key, make([]byte, SaltLen), old),
Salt: make([]byte, SaltLen),
Params: old.String(),
}
ok, rehash := Verify(key, cred)
if !ok || !rehash {
t.Errorf("устаревшие параметры: ok=%v rehash=%v, ожидалось true/true", ok, rehash)
}
}
func TestToken(t *testing.T) {
token, hash, err := NewToken()
if err != nil {
t.Fatalf("NewToken: %v", err)
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != TokenLen {
t.Fatalf("токен: %q (%v)", token, err)
}
sum := sha256.Sum256(raw)
if string(hash) != string(sum[:]) {
t.Error("в базу уходит не sha-256 токена")
}
got, ok := TokenHash(token)
if !ok || string(got) != string(hash) {
t.Error("TokenHash не совпал с NewToken")
}
for _, bad := range []string{"", "не base64!", base64.RawURLEncoding.EncodeToString([]byte("коротко"))} {
if _, ok := TokenHash(bad); ok {
t.Errorf("мусор %q принят за токен", bad)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/store"
)
// Сессия по ADR-021: токен 32 случайных байта, в базе SHA-256 от него,
// в cookie — base64url. Срок 90 дней без продления.
const (
CookieName = "bare_session"
TokenLen = 32
TTL = 90 * 24 * time.Hour
)
// NewToken выдаёт токен для cookie и его SHA-256 для базы.
func NewToken() (token string, hash []byte, err error) {
raw := make([]byte, TokenLen)
if _, err := rand.Read(raw); err != nil {
return "", nil, fmt.Errorf("auth: токен: %w", err)
}
sum := sha256.Sum256(raw)
return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
}
// TokenHash разбирает токен из cookie в его SHA-256. Мусор — false.
func TokenHash(token string) ([]byte, bool) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != TokenLen {
return nil, false
}
sum := sha256.Sum256(raw)
return sum[:], true
}
// SetCookie ставит cookie сессии. Secure стоит всегда: браузеры считают
// localhost и 127.0.0.1 доверенным происхождением, поэтому локальная
// разработка по http этим не ломается.
func SetCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClearCookie стирает cookie сессии.
func ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// Fail — как auth отвечает на отказ. Тело ошибки в форме протокола
// собирает internal/api (ADR-026), а импортировать его отсюда нельзя:
// api импортирует auth. Поэтому хелперы передаются значениями.
type Fail struct {
// Error пишет ошибку протокола: статус, код, текст для человека.
Error func(w http.ResponseWriter, status int, code, message string)
// Internal пишет 500 и кладёт причину в журнал сервера.
Internal func(w http.ResponseWriter, r *http.Request, err error)
}
// Origin — второй барьер CSRF рядом с SameSite=Strict (ADR-021).
// На всех методах кроме GET и HEAD заголовок Origin обязан равняться
// origin сервера; отсутствующий Origin — тоже отказ.
func Origin(origin string, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if r.Header.Get("Origin") != origin {
fail.Error(w, http.StatusForbidden, "bad_origin", "запрос не с этого сайта")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// Require пропускает дальше только запросы с живой сессией и кладёт её
// в контекст. Без сессии — 401 unauthenticated.
func Require(st *store.Store, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := session(r, st)
if errors.Is(err, store.ErrNotFound) {
fail.Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход")
return
}
if err != nil {
fail.Internal(w, r, err)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
})
}
}
// session читает cookie и находит сессию. Нет cookie, мусор в ней
// и истёкшая сессия неразличимы: store.ErrNotFound.
func session(r *http.Request, st *store.Store) (store.Session, error) {
c, err := r.Cookie(CookieName)
if err != nil {
return store.Session{}, store.ErrNotFound
}
hash, ok := TokenHash(c.Value)
if !ok {
return store.Session{}, store.ErrNotFound
}
return st.Session(r.Context(), hash, time.Now().UnixMilli())
}
type sessionKey struct{}
// From отдаёт сессию из контекста. Её кладёт Require.
func From(r *http.Request) (store.Session, bool) {
sess, ok := r.Context().Value(sessionKey{}).(store.Session)
return sess, ok
}
+79
View File
@@ -0,0 +1,79 @@
// Package config читает конфигурацию из переменных окружения BARE_* (ADR-022).
package config
import (
"fmt"
"os"
"strings"
)
// Config — всё, что сервер знает о своём окружении.
type Config struct {
Addr string // BARE_ADDR — адрес прослушивания
DB string // BARE_DB — путь к файлу SQLite
Origin string // BARE_ORIGIN — единственный допустимый Origin (ADR-021)
VAPIDPublic string // BARE_VAPID_PUBLIC
VAPIDPrivate string // BARE_VAPID_PRIVATE
VAPIDSubject string // BARE_VAPID_SUBJECT
InviteCode string // BARE_INVITE_CODE — пусто означает открытую регистрацию
// PushLocal разрешает отправку пушей на непубличные адреса. Из
// окружения не читается и в работе всегда false: сервер ходит
// только по публичным адресам (ADR-047). Поле существует ради
// тестов, где push-сервис вендора подменён сервером на 127.0.0.1.
PushLocal bool
}
// Значения по умолчанию — локальный запуск без окружения.
const (
defaultAddr = "127.0.0.1:8411"
defaultDB = "bare.db"
defaultOrigin = "http://127.0.0.1:8411"
)
// Параметры, которые сервер сообщает клиенту в GET /api/config. Это
// константы, а не переменные окружения: их значения — часть криптосистемы
// (ADR-013) и протокола (ADR-021), а не настройка машины.
const (
// KDFIterations — целевое число итераций PBKDF2 на клиенте.
KDFIterations = 1_000_000
// KDFMinIterations — нижняя граница: блоб с меньшим iter сервер не примет.
KDFMinIterations = 600_000
// KDFMaxIterations — верхняя граница (ADR-030). Сервер сам раздаёт iter
// из блоба в GET /api/kdf, и с неподъёмным значением аккаунт нельзя
// ни открыть, ни удалить: обе операции начинаются с PBKDF2.
KDFMaxIterations = 10_000_000
// MaxMessageChars — предел текста сообщения.
MaxMessageChars = 4000
)
// Load читает окружение. Незаданная переменная берёт значение по умолчанию;
// заданная пустой — ошибка: пустой адрес, путь к базе или origin неработоспособны.
func Load() (*Config, error) {
c := &Config{
Addr: env("BARE_ADDR", defaultAddr),
DB: env("BARE_DB", defaultDB),
Origin: env("BARE_ORIGIN", defaultOrigin),
VAPIDPublic: env("BARE_VAPID_PUBLIC", ""),
VAPIDPrivate: env("BARE_VAPID_PRIVATE", ""),
VAPIDSubject: env("BARE_VAPID_SUBJECT", ""),
InviteCode: env("BARE_INVITE_CODE", ""),
}
for _, v := range []struct{ key, value string }{
{"BARE_ADDR", c.Addr},
{"BARE_DB", c.DB},
{"BARE_ORIGIN", c.Origin},
} {
if v.value == "" {
return nil, fmt.Errorf("%s пуст: уберите переменную, чтобы взять значение по умолчанию, или задайте непустое", v.key)
}
}
return c, nil
}
func env(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok {
return strings.TrimSpace(v)
}
return fallback
}

Some files were not shown because too many files have changed in this diff Show More