diff --git a/cmd/bare/main.go b/cmd/bare/main.go index 10223ec..076e3ce 100644 --- a/cmd/bare/main.go +++ b/cmd/bare/main.go @@ -22,6 +22,7 @@ import ( "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" ) @@ -70,9 +71,17 @@ func serve() error { 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) + } srv := &http.Server{ - Handler: api.New(static, os.Stdout), + Handler: api.New(cfg, st, static, os.Stdout), ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 120 * time.Second, // OPTIONS * иначе обслуживает net/http сам, в обход middleware: @@ -91,6 +100,11 @@ func serve() error { 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) { diff --git a/docs/crypto.md b/docs/crypto.md index 0563374..65321cb 100644 --- a/docs/crypto.md +++ b/docs/crypto.md @@ -13,7 +13,7 @@ authKey = HKDF-SHA256(master, salt = пусто, info = "bare-auth-v1", 32 ба kek = HKDF-SHA256(master, salt = пусто, info = "bare-kek-v1") → AES-GCM-256 ``` -`iter` — из `GET /api/kdf?nick=` перед входом, из `GET /api/config` при регистрации. Целевое значение сервера — 1 000 000, нижняя граница — 600 000 (ADR-013). WebCrypto: `deriveBits` из PBKDF2, результат импортируется `importKey("raw", …, "HKDF")`, дальше `deriveBits`/`deriveKey`. +`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. diff --git a/docs/decisions/027-internal-error-code.md b/docs/decisions/027-internal-error-code.md new file mode 100644 index 0000000..913da93 --- /dev/null +++ b/docs/decisions/027-internal-error-code.md @@ -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` в журнале означает ошибку в коде или в окружении и разбирается, а не считается нормой. diff --git a/docs/decisions/028-client-state-texts.md b/docs/decisions/028-client-state-texts.md new file mode 100644 index 0000000..0da02dd --- /dev/null +++ b/docs/decisions/028-client-state-texts.md @@ -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` тем же порядком: сначала документ, потом код. diff --git a/docs/decisions/029-login-under-other-nick.md b/docs/decisions/029-login-under-other-nick.md new file mode 100644 index 0000000..f71244b --- /dev/null +++ b/docs/decisions/029-login-under-other-nick.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. diff --git a/docs/decisions/030-kdf-iterations-bounds.md b/docs/decisions/030-kdf-iterations-bounds.md new file mode 100644 index 0000000..7ce97c7 --- /dev/null +++ b/docs/decisions/030-kdf-iterations-bounds.md @@ -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` тоже нельзя: границу держат обе стороны, и клиентская стоит раньше вычисления. Активно-злонамеренный оператор остаётся вне модели угроз — он подменит и сам клиент. +- Поднять целевое значение выше верхней границы без правки границы не выйдет. Это и требуется: такое повышение — решение, а не настройка. diff --git a/docs/decisions/031-relogin-service-logout.md b/docs/decisions/031-relogin-service-logout.md new file mode 100644 index 0000000..9357983 --- /dev/null +++ b/docs/decisions/031-relogin-service-logout.md @@ -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` — ошибка формы, на экран входа она не выбрасывала и раньше. diff --git a/docs/decisions/032-state-permissions.md b/docs/decisions/032-state-permissions.md new file mode 100644 index 0000000..c472753 --- /dev/null +++ b/docs/decisions/032-state-permissions.md @@ -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`. diff --git a/docs/deploy.md b/docs/deploy.md index cdfb48d..ca226a1 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -16,8 +16,11 @@ GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o bar 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): ``` @@ -48,6 +51,8 @@ ExecStart=/opt/bare/bare serve Restart=on-failure RestartSec=2 StateDirectory=bare +StateDirectoryMode=0700 +UMask=0077 NoNewPrivileges=yes ProtectSystem=strict ProtectHome=yes @@ -128,8 +133,8 @@ ssh xmatic 'sudo install -m 0755 -o root -g root /tmp/bare /opt/bare/bare && sud ## Бэкап -`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. +`sqlite3 /var/lib/bare/bare.db "VACUUM INTO '/var/lib/bare/backup.db'"` или копия файла при остановленном сервисе. В базе только шифротексты и метаданные — бэкап не содержит переписки. Копия наследует режим 0600 (ADR-032); при восстановлении в другое место права надо выставить руками. ## Логи -Сервер пишет в stdout: время, метод, путь, статус, длительность; ник — только для ошибок аутентификации по лимитам; IP не пишется. journald хранит по своим правилам. +Сервер пишет в stdout: время, метод, путь, статус, длительность; для маршрутов `/api/` вместо пути пишется шаблон (`/api/users/{nick}`), чтобы ник не попадал в журнал, а если отказ случился до маршрутизации (`Origin`, предел тела) и шаблона ещё нет — просто `/api/`; ник — только для ошибок аутентификации по лимитам; IP не пишется. Причины ответов `500 internal` (ADR-027) пишутся отдельной строкой, без данных запроса. journald хранит по своим правилам. diff --git a/docs/protocol.md b/docs/protocol.md index ff54fc2..c2aee13 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -10,6 +10,7 @@ HTTP-API под `/api/`, JSON в обе стороны, `Content-Type: applicati - Тело запроса — до 32 КиБ, иначе `413 too_large`. - Rate limiting — `429` с `Retry-After` (секунды). - Неизвестный путь — `404 not_found`; неверный JSON — `400 bad_json`; валидация — `400 invalid` с полем `field`. +- Сбой на стороне сервера — `500 internal`; причина остаётся в журнале сервера и клиенту не показывается (ADR-027). - Неподдерживаемый метод на известном пути — тоже `404 not_found`: кода `405` в протоколе нет (ADR-026). ## Типы @@ -127,7 +128,7 @@ event: ready data: {} ## Коды ошибок -`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`. +`unauthenticated`, `bad_origin`, `unknown_device`, `bad_json`, `invalid`, `invalid_nick`, `nick_taken`, `invite_required`, `invalid_invite`, `invalid_credentials`, `unknown_user`, `self`, `device_conflict`, `clock_skew`, `not_member`, `unknown_key`, `not_owner`, `owner`, `key_exists`, `keys_mismatch`, `not_found`, `rate_limited`, `too_large`, `internal`. ## Статика и служебное diff --git a/docs/storage.md b/docs/storage.md index e2608b3..ba9204f 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -98,7 +98,7 @@ DELETE FROM sessions WHERE expires_at < :now; ## Клиент — IndexedDB -База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). +База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029). ``` meta key: string → value diff --git a/docs/ui.md b/docs/ui.md index 9de3fc7..f14d895 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -16,7 +16,9 @@ > пароль — это ключ шифрования, а не запись в базе. восстановления нет. не короче 12 символов; лучше — фраза из нескольких слов. -Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». +Кнопка одна, в стиле строки ввода. Пока идёт PBKDF2 — состояние «вычисляем ключ…», кнопка заблокирована. Ошибки — строкой под формой цветом `mark`: «неверный ник или пароль», «ник занят», «ник: 2–32 символа, a–z, 0–9, _», «нужен инвайт-код», «инвайт-код не подходит». Форму ника и длину пароля клиент проверяет сам, до PBKDF2, в обоих режимах. Остальные состояния — «Тексты состояний». + +Если на устройстве лежат ключи другого ника, до вычисления ключа — подтверждение «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена» (ADR-029). База стирается после успешного входа или регистрации; отказ сервера её не трогает. ## Список чатов (сайдбар) @@ -53,9 +55,9 @@ - «установить приложение»: кнопка, если есть `beforeinstallprompt`; на iOS — инструкция «поделиться → на экран «домой»». - «устройства»: список `id` (первые 8 символов), дата, «это устройство», «удалить». - «история»: «занято N МБ»; «экспорт» → скачивание `.bare`; «импорт» → выбор файла → «добавлено N сообщений» / «архив создан другим аккаунтом» / «файл повреждён». -- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». +- «сменить пароль»: старый, новый, повтор; чекбокс «выйти на других устройствах». Ответ — «пароль изменён». - «выйти»: подтверждение «история на этом устройстве будет удалена. экспортировать сначала?» с кнопками «экспортировать», «выйти», «отмена». -- «удалить аккаунт»: пароль + подтверждение. +- «удалить аккаунт»: пароль + подтверждение «аккаунт и вся история будут удалены навсегда.» с кнопками «удалить» и «отмена». ## Баннер установки (iOS) @@ -70,7 +72,22 @@ - SSE переподключается браузером; после `ready` клиент перечитывает комнаты и контакты и повторяет `pending`. - Без сети: полоса «нет соединения» цветом `stone` над вводом; ввод не блокируется — сообщения уходят в `pending`. - `clock_skew` — «проверьте часы на устройстве: расхождение больше 5 минут». -- `401` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). +- `401 unauthenticated` на любом запросе — выход на экран входа с сохранением IndexedDB (сессия истекла, история остаётся). Исключение одно: служебный выход перед повторным входом при смене пароля и удалении аккаунта (ADR-031) — там этот ответ означает, что сессии и так нет. + +## Тексты состояний + +Общие для всех форм строки (ADR-028). Ошибка — цветом `mark`, ответ об успехе — цветом `mute`, место одно. + +| состояние | текст | +|---|---| +| запрос не дошёл | «нет соединения» | +| код ответа, на который нет сценария (`internal`, `too_large`, прочее) | «сервер не справился, попробуйте позже» | +| `429 rate_limited` | «слишком часто, попробуйте позже» | +| пароль короче 12 символов | «пароль: не короче 12 символов» | +| новый пароль и повтор различаются | «пароли не совпадают» | +| ключевой блоб не разобран, не расшифрован или не соответствует публичному ключу | «ключ аккаунта повреждён» | +| `iter` блоба не равен ответу `GET /api/kdf` | «параметры ключа не совпали» | +| `401 invalid_credentials` в настройках | «неверный пароль» | ## Доступность diff --git a/go.mod b/go.mod index cfe0663..6459dbb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,20 @@ module github.com/xmatic-squad/bare go 1.27.0 + +require ( + 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/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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..e2c6978 --- /dev/null +++ b/go.sum @@ -0,0 +1,52 @@ +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/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= +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.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +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= +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= diff --git a/internal/api/account.go b/internal/api/account.go new file mode 100644 index 0000000..769b509 --- /dev/null +++ b/internal/api/account.go @@ -0,0 +1,344 @@ +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 — ответ не раскрывает, существует ли ник (ADR-015). +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 + } + 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 + } + } + if !validNick(in.Nick) { + Error(w, http.StatusBadRequest, "invalid_nick", "ник: 2–32 символа, a–z, 0–9, _") + 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 + } + + 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 + } + 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) + if err := s.st.SetPassword(r.Context(), u.Nick, cred, in.Blob, in.LogoutOthers, sess.TokenHash); err != nil { + s.internal(w, r, err) + return + } + 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) — это этап 3, до появления комнат случай не наступает. + if err := s.st.DeleteUser(r.Context(), u.Nick); err != nil { + s.internal(w, r, err) + return + } + 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", "такого ника нет") +} diff --git a/internal/api/account_test.go b/internal/api/account_test.go new file mode 100644 index 0000000..40c77c2 --- /dev/null +++ b/internal/api/account_test.go @@ -0,0 +1,464 @@ +package api_test + +import ( + "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 сессии. +func (e *env) signUp(nick string) *http.Cookie { + e.t.Helper() + rec := e.do(http.MethodPost, "/api/register", account(nick)) + expect(e.t, rec, http.StatusCreated, "") + return e.cookie(rec) +} + +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) + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 2310195..cd6f305 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -1,14 +1,21 @@ // Package api собирает маршруты и общие для всех ответов правила: -// заголовки безопасности (ADR-021), лимит тела запроса, лог в stdout. +// заголовки безопасности (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/store" ) // MaxBody — предел тела запроса, 32 КиБ (ADR-021). @@ -20,13 +27,42 @@ 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'" -// New собирает обработчик: /healthz, всё остальное — статика. -// log — куда писать строки запросов; nil отключает лог. -func New(static http.Handler, logw io.Writer) http.Handler { +// server — общее для обработчиков: настройки, база, куда писать журнал. +type server struct { + cfg *config.Config + st *store.Store + logw io.Writer +} + +// New собирает обработчик: /api/, /healthz, всё остальное — статика. +// logw — куда писать строки запросов и причины отказов; nil отключает лог. +func New(cfg *config.Config, st *store.Store, static http.Handler, logw io.Writer) http.Handler { + s := &server{cfg: cfg, st: st, logw: logw} + fail := auth.Fail{Error: Error, Internal: s.internal} + // Сессия проверяется на всех непубличных маршрутах (docs/protocol.md). + private := auth.Require(st, fail) + 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", private(http.HandlerFunc(s.deleteMe))) + mux.Handle("POST /api/logout", private(http.HandlerFunc(s.logout))) + mux.Handle("POST /api/password", private(http.HandlerFunc(s.password))) + mux.Handle("GET /api/users/{nick}", private(http.HandlerFunc(s.user))) + + // Всё прочее под /api/ — 404, включая неподдерживаемый метод известного + // пути: кода 405 в протоколе нет (ADR-026). Этот маршрут заодно не даёт + // запросам к /api/ уходить в обработчик статики. + mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { NotFound(w) }) mux.Handle("/", static) - return logging(logw, headers(limitBody(mux))) + + return logging(logw, headers(auth.Origin(cfg.Origin, fail)(limitBody(mux)))) } func healthz(w http.ResponseWriter, r *http.Request) { @@ -36,17 +72,23 @@ func healthz(w http.ResponseWriter, r *http.Request) { 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) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.Header().Set("Cache-Control", "no-store") - w.WriteHeader(status) - json.NewEncoder(w).Encode(map[string]string{ - "error": code, - "message": message, - }) + 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 — ответ на неизвестный путь и на неподдерживаемый метод @@ -55,6 +97,50 @@ 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) { @@ -94,12 +180,39 @@ func logging(out io.Writer, next http.Handler) http.Handler { fmt.Fprintf(out, "%s %s %s %d %s\n", start.Format(time.RFC3339), r.Method, - logPath(r.URL), + 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. Длинный путь обрезается. diff --git a/internal/api/api_test.go b/internal/api/api_test.go index 1f7f6e8..fdc07b0 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -6,37 +6,140 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "strings" "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" ) -func handler(t *testing.T) http.Handler { +const origin = "https://bare.test" + +// env — сервер на временной базе плюс журнал, в который он пишет. +type env struct { + t *testing.T + h http.Handler + st *store.Store + log *bytes.Buffer +} + +func newEnv(t *testing.T) *env { return invited(t, "") } + +// invited — сервер на временной базе; непустой code включает инвайты. +func invited(t *testing.T, code string) *env { t.Helper() static, err := web.New() if err != nil { t.Fatalf("web.New: %v", err) } - return api.New(static, nil) + 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", + InviteCode: code, + } + e := &env{t: t, st: st, log: &bytes.Buffer{}} + e.h = api.New(cfg, st, static, e.log) + 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 +} + +func with(c *http.Cookie) func(*http.Request) { + return func(r *http.Request) { + if c != nil { + r.AddCookie(c) + } + } +} + +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) { - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/healthz", nil)) + e := newEnv(t) + rec := e.do(http.MethodGet, "/healthz", nil) - res := rec.Result() - defer res.Body.Close() - if res.StatusCode != http.StatusOK { - t.Errorf("статус: получено %d, ожидалось 200", res.StatusCode) + if rec.Code != http.StatusOK { + t.Errorf("статус: получено %d, ожидалось 200", rec.Code) } - body, err := io.ReadAll(res.Body) - if err != nil { - t.Fatalf("чтение тела: %v", err) - } - if string(body) != "ok" { - t.Errorf("тело: получено %q, ожидалось \"ok\"", body) + if rec.Body.String() != "ok" { + t.Errorf("тело: получено %q, ожидалось \"ok\"", rec.Body.String()) } want := map[string]string{ @@ -45,17 +148,16 @@ func TestHealthz(t *testing.T) { "X-Content-Type-Options": "nosniff", } for header, value := range want { - if got := res.Header.Get(header); got != value { + if got := rec.Header().Get(header); got != value { t.Errorf("%s: получено %q, ожидалось %q", header, got, value) } } } func TestStaticNotModified(t *testing.T) { - h := handler(t) + e := newEnv(t) - first := httptest.NewRecorder() - h.ServeHTTP(first, httptest.NewRequest(http.MethodGet, "/app.css", nil)) + first := e.do(http.MethodGet, "/app.css", nil) if first.Code != http.StatusOK { t.Fatalf("статус: получено %d, ожидалось 200", first.Code) } @@ -67,11 +169,9 @@ func TestStaticNotModified(t *testing.T) { t.Errorf("Cache-Control: получено %q, ожидалось \"no-cache\"", got) } - req := httptest.NewRequest(http.MethodGet, "/app.css", nil) - req.Header.Set("If-None-Match", etag) - second := httptest.NewRecorder() - h.ServeHTTP(second, req) - + 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) } @@ -84,52 +184,44 @@ func TestStaticNotModified(t *testing.T) { } func TestBodyTooLarge(t *testing.T) { - body := strings.NewReader(strings.Repeat("a", api.MaxBody+1)) - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/nope", body)) + e := newEnv(t) + rec := e.do(http.MethodPost, "/api/login", strings.Repeat("a", api.MaxBody+1)) + expect(t, rec, http.StatusRequestEntityTooLarge, "too_large") +} - if rec.Code != http.StatusRequestEntityTooLarge { - t.Fatalf("статус: получено %d, ожидалось 413", rec.Code) - } - var got map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("разбор тела: %v", err) - } - if got["error"] != "too_large" { - t.Errorf("код ошибки: получено %q, ожидалось \"too_large\"", got["error"]) - } +// Тело без заявленной длины обрывается при чтении — тем же кодом. +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) { - rec := httptest.NewRecorder() - handler(t).ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/app.css", nil)) + e := newEnv(t) + expect(t, e.do(http.MethodPost, "/app.css", nil), http.StatusNotFound, "not_found") +} - if rec.Code != http.StatusNotFound { - t.Fatalf("статус: получено %d, ожидалось 404", rec.Code) - } - var got map[string]string - if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { - t.Fatalf("разбор тела: %v", err) - } - if got["error"] != "not_found" { - t.Errorf("код ошибки: получено %q, ожидалось \"not_found\"", got["error"]) - } +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) { - static, err := web.New() - if err != nil { - t.Fatalf("web.New: %v", err) - } - var log bytes.Buffer - h := api.New(static, &log) + e := newEnv(t) target := "/x%0a2026-01-01T00:00:00Z%20GET%20/fake%20200%201ms" - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, target, nil)) + e.do(http.MethodGet, target, nil) - line := log.String() + line := e.log.String() if n := strings.Count(line, "\n"); n != 1 { t.Errorf("строк в логе: получено %d, ожидалась 1: %q", n, line) } @@ -137,19 +229,25 @@ func TestLogPathEscaped(t *testing.T) { t.Errorf("путь не в percent-форме: %q", line) } - log.Reset() - long := "/" + strings.Repeat("z", 4096) - h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, long, nil)) - if len(log.String()) > 512 { - t.Errorf("длина строки лога: получено %d байт, ожидалось не больше 512", len(log.String())) + 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 - h := api.New(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + 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) @@ -158,3 +256,17 @@ func TestFlushThroughMiddleware(t *testing.T) { 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()) + } +} diff --git a/internal/api/valid.go b/internal/api/valid.go new file mode 100644 index 0000000..0244736 --- /dev/null +++ b/internal/api/valid.go @@ -0,0 +1,123 @@ +package api + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "regexp" + + "github.com/xmatic-squad/bare/internal/config" +) + +// Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму: +// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»). +const ( + authKeyLen = 32 // байт + 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) } + +// 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 +} diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..b417387 --- /dev/null +++ b/internal/auth/auth.go @@ -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) +} diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go new file mode 100644 index 0000000..a65d475 --- /dev/null +++ b/internal/auth/auth_test.go @@ -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) + } + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000..7370127 --- /dev/null +++ b/internal/auth/session.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go index c3639df..411e370 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,6 +25,22 @@ const ( 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) { diff --git a/internal/store/cleanup.go b/internal/store/cleanup.go new file mode 100644 index 0000000..5fa1914 --- /dev/null +++ b/internal/store/cleanup.go @@ -0,0 +1,69 @@ +package store + +import ( + "context" + "fmt" + "time" +) + +// Сроки хранения из docs/storage.md. +const ( + queueTTL = 30 * 24 * time.Hour // недоставленное сообщение + deviceTTL = 90 * 24 * time.Hour // молчащее устройство + roomKeysKept = 2 // ключей комнаты на комнату + + cleanupEvery = time.Hour +) + +// RunCleanup чистит базу раз в час, пока не отменён ctx. Первый проход — +// сразу при старте: сервер, который перезапускают чаще раза в час, иначе +// не чистился бы никогда. Ошибку отдаёт report; nil — молчать. +func (s *Store) RunCleanup(ctx context.Context, report func(error)) { + tick := time.NewTicker(cleanupEvery) + defer tick.Stop() + for { + if err := s.Cleanup(ctx, time.Now()); err != nil && report != nil && ctx.Err() == nil { + report(err) + } + select { + case <-ctx.Done(): + return + case <-tick.C: + } + } +} + +// Cleanup выполняет один проход чистки (docs/storage.md, «Фоновая чистка»). +func (s *Store) Cleanup(ctx context.Context, now time.Time) error { + ms := now.UnixMilli() + steps := []struct { + what string + query string + args []any + }{ + {"очередь", `DELETE FROM queue WHERE created_at < ?`, []any{ms - queueTTL.Milliseconds()}}, + {"устройства", `DELETE FROM devices WHERE last_seen < ?`, []any{ms - deviceTTL.Milliseconds()}}, + {"сессии", `DELETE FROM sessions WHERE expires_at < ?`, []any{ms}}, + // Ключи комнат: у каждой комнаты остаются два последних key_id. + // Возраст key_id — время его самой поздней записи: ключ раздаётся + // участникам не одной строкой, а по строке на участника. + {"ключи комнат", ` + DELETE FROM room_keys WHERE (room_id, key_id) NOT IN ( + SELECT room_id, key_id FROM ( + SELECT room_id, key_id, + ROW_NUMBER() OVER ( + PARTITION BY room_id + ORDER BY MAX(created_at) DESC, key_id DESC + ) AS rn + FROM room_keys + GROUP BY room_id, key_id + ) WHERE rn <= ? + )`, []any{roomKeysKept}}, + } + for _, step := range steps { + if _, err := s.db.ExecContext(ctx, step.query, step.args...); err != nil { + return fmt.Errorf("store: чистка (%s): %w", step.what, err) + } + } + return nil +} diff --git a/internal/store/migrations/001_init.sql b/internal/store/migrations/001_init.sql new file mode 100644 index 0000000..0f9fbed --- /dev/null +++ b/internal/store/migrations/001_init.sql @@ -0,0 +1,73 @@ +-- Полная схема v1 (docs/storage.md, ADR-020). Время — миллисекунды Unix. +-- Таблицы этапов 2–3 создаются сразу: схема одна, миграция одна. + +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); diff --git a/internal/store/sessions.go b/internal/store/sessions.go new file mode 100644 index 0000000..ac758a9 --- /dev/null +++ b/internal/store/sessions.go @@ -0,0 +1,58 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// Session — строка sessions. Токена здесь нет: в базе лежит только +// SHA-256 от него (ADR-021). +type Session struct { + TokenHash []byte + Nick string + DeviceID string // пусто, пока сессия не привязана к устройству + CreatedAt int64 + ExpiresAt int64 +} + +// CreateSession записывает сессию. tokenHash — SHA-256 токена из cookie. +func (s *Store) CreateSession(ctx context.Context, tokenHash []byte, nick string, createdAt, expiresAt int64) error { + _, err := s.db.ExecContext(ctx, ` + INSERT INTO sessions (token_hash, nick, device_id, created_at, expires_at) + VALUES (?, ?, NULL, ?, ?)`, tokenHash, nick, createdAt, expiresAt) + if err != nil { + return fmt.Errorf("store: создание сессии: %w", err) + } + return nil +} + +// Session читает живую сессию по хешу токена. Истёкшая считается +// отсутствующей: чистит её фоновая задача, а не запрос. +func (s *Store) Session(ctx context.Context, tokenHash []byte, now int64) (Session, error) { + var ( + sess Session + device sql.NullString + ) + err := s.db.QueryRowContext(ctx, ` + SELECT token_hash, nick, device_id, created_at, expires_at + FROM sessions WHERE token_hash = ? AND expires_at > ?`, tokenHash, now). + Scan(&sess.TokenHash, &sess.Nick, &device, &sess.CreatedAt, &sess.ExpiresAt) + if errors.Is(err, sql.ErrNoRows) { + return Session{}, ErrNotFound + } + if err != nil { + return Session{}, fmt.Errorf("store: чтение сессии: %w", err) + } + sess.DeviceID = device.String + return sess, nil +} + +// DeleteSession удаляет одну сессию — выход на этом устройстве. +func (s *Store) DeleteSession(ctx context.Context, tokenHash []byte) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM sessions WHERE token_hash = ?`, tokenHash); err != nil { + return fmt.Errorf("store: удаление сессии: %w", err) + } + return nil +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..c8a0e41 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,149 @@ +// Package store — SQLite: открытие базы, миграции, запросы (ADR-020). +// +// В базе только шифротексты и метаданные: истории сообщений, плейнтекста +// и паролей здесь нет и не будет (docs/storage.md). +package store + +import ( + "database/sql" + "embed" + "errors" + "fmt" + "io/fs" + "net/url" + "sort" + "strings" + + _ "modernc.org/sqlite" +) + +//go:embed migrations +var migrations embed.FS + +// Ошибки, которые обработчикам нужно различать. Остальное — внутренние сбои. +var ( + // ErrNotFound — строки нет. + ErrNotFound = errors.New("store: не найдено") + // ErrNickTaken — ник уже занят. + ErrNickTaken = errors.New("store: ник занят") +) + +// Store — база и её единственное соединение на запись. +type Store struct { + db *sql.DB + applied []string +} + +// Applied — миграции, применённые при этом открытии базы. Пусто, если +// схема уже была свежей. +func (s *Store) Applied() []string { return s.applied } + +// Open открывает базу, ставит режим из docs/storage.md и применяет миграции. +func Open(path string) (*Store, error) { + db, err := sql.Open("sqlite", dsn(path)) + if err != nil { + return nil, fmt.Errorf("store: открытие %s: %w", path, err) + } + + // Одно соединение на всю базу. modernc.org/sqlite, как и любой SQLite, + // допускает ровно одного писателя; при нескольких соединениях запись + // упирается в SQLITE_BUSY, а busy_timeout лечит это ожиданием, а не + // корректностью — «database is locked» всё равно возможен на upgrade + // транзакции из read в write. Пул из одной штуки убирает класс ошибок + // целиком: очередь выстраивает database/sql. Цена — чтения ждут запись; + // для чата на десятки человек это незаметно. SSE держит соединение + // с клиентом, а не с базой, поэтому поток событий пул не занимает. + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("store: %s недоступна: %w", path, err) + } + s := &Store{db: db} + if err := s.migrate(); err != nil { + db.Close() + return nil, err + } + return s, nil +} + +// Close закрывает базу. +func (s *Store) Close() error { return s.db.Close() } + +// dsn собирает строку соединения с режимом из docs/storage.md. +// Прагмы применяются к каждому новому соединению; journal_mode=WAL +// хранится в самом файле, остальные — свойство соединения. +func dsn(path string) string { + q := url.Values{} + q.Add("_pragma", "journal_mode(WAL)") + q.Add("_pragma", "synchronous(NORMAL)") + q.Add("_pragma", "foreign_keys(1)") + q.Add("_pragma", "busy_timeout(5000)") + return "file:" + (&url.URL{Path: path}).EscapedPath() + "?" + q.Encode() +} + +// migrate применяет недостающие миграции по порядку, каждую в своей +// транзакции. Версия схемы — PRAGMA user_version, она же номер последней +// применённой миграции. Откатов нет: ошибку правит следующая миграция. +func (s *Store) migrate() error { + files, err := migrationFiles() + if err != nil { + return err + } + var version int + if err := s.db.QueryRow("PRAGMA user_version").Scan(&version); err != nil { + return fmt.Errorf("store: чтение user_version: %w", err) + } + if version > len(files) { + return fmt.Errorf("store: база версии %d новее бинаря (%d миграций)", version, len(files)) + } + for i := version; i < len(files); i++ { + name := files[i] + body, err := fs.ReadFile(migrations, "migrations/"+name) + if err != nil { + return fmt.Errorf("store: чтение миграции %s: %w", name, err) + } + if err := s.applyMigration(i+1, name, string(body)); err != nil { + return err + } + s.applied = append(s.applied, name) + } + return nil +} + +func (s *Store) applyMigration(version int, name, body string) error { + tx, err := s.db.Begin() + if err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + defer tx.Rollback() + + if _, err := tx.Exec(body); err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + // user_version не принимает подстановку, поэтому число подставляется + // форматированием; version — счётчик миграций, не пользовательские данные. + if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", version)); err != nil { + return fmt.Errorf("store: миграция %s: %w", name, err) + } + return tx.Commit() +} + +// migrationFiles отдаёт имена миграций в порядке номеров. +func migrationFiles() ([]string, error) { + entries, err := fs.ReadDir(migrations, "migrations") + if err != nil { + return nil, fmt.Errorf("store: каталог миграций: %w", err) + } + names := make([]string, 0, len(entries)) + for _, e := range entries { + if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") { + continue + } + names = append(names, e.Name()) + } + // Имена вида NNN_*.sql: лексикографический порядок совпадает с числовым. + sort.Strings(names) + return names, nil +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..7948b84 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,213 @@ +package store + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" +) + +func open(t *testing.T, path string) *Store { + t.Helper() + s, err := Open(path) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { s.Close() }) + return s +} + +func TestMigrateAndRestart(t *testing.T) { + path := filepath.Join(t.TempDir(), "bare.db") + + first := open(t, path) + if got := first.Applied(); len(got) != 1 || got[0] != "001_init.sql" { + t.Fatalf("применённые миграции: получено %v, ожидалось [001_init.sql]", got) + } + if got := version(t, first); got != 1 { + t.Errorf("user_version: получено %d, ожидалась 1", got) + } + // Все восемь таблиц из docs/storage.md на месте. + for _, table := range []string{"users", "devices", "sessions", "contacts", "rooms", "room_members", "room_keys", "queue"} { + var name string + err := first.db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table).Scan(&name) + if err != nil { + t.Errorf("таблица %s: %v", table, err) + } + } + // Внешние ключи включены — иначе каскадные удаления молча не работают. + var fk int + if err := first.db.QueryRow("PRAGMA foreign_keys").Scan(&fk); err != nil || fk != 1 { + t.Errorf("foreign_keys: получено %d (%v), ожидалась 1", fk, err) + } + var mode string + if err := first.db.QueryRow("PRAGMA journal_mode").Scan(&mode); err != nil || mode != "wal" { + t.Errorf("journal_mode: получено %q (%v), ожидался wal", mode, err) + } + first.Close() + + // Повторный старт на той же базе ничего не применяет. + second := open(t, path) + if got := second.Applied(); len(got) != 0 { + t.Errorf("повторный старт применил %v, ожидалось ничего", got) + } + if got := version(t, second); got != 1 { + t.Errorf("user_version после перезапуска: получено %d, ожидалась 1", got) + } +} + +func version(t *testing.T, s *Store) int { + t.Helper() + var v int + if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil { + t.Fatalf("user_version: %v", err) + } + return v +} + +func TestUsersAndSessions(t *testing.T) { + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + + u := User{ + Nick: "marta", + Cred: Credential{Hash: []byte("hash"), Salt: []byte("salt"), Params: "argon2id,m=19456,t=2,p=1"}, + PublicKey: `{"kty":"EC"}`, + KeyBlob: `{"v":1}`, + CreatedAt: 1, + } + if err := s.CreateUser(ctx, u); err != nil { + t.Fatalf("CreateUser: %v", err) + } + if err := s.CreateUser(ctx, u); !errors.Is(err, ErrNickTaken) { + t.Errorf("повторный ник: получено %v, ожидалось ErrNickTaken", err) + } + if _, err := s.User(ctx, "нет-такого"); !errors.Is(err, ErrNotFound) { + t.Errorf("чужой ник: получено %v, ожидалось ErrNotFound", err) + } + + now := time.Now().UnixMilli() + live := []byte("token-hash-1") + other := []byte("token-hash-2") + if err := s.CreateSession(ctx, live, "marta", now, now+1000); err != nil { + t.Fatalf("CreateSession: %v", err) + } + if err := s.CreateSession(ctx, other, "marta", now, now+1000); err != nil { + t.Fatalf("CreateSession: %v", err) + } + sess, err := s.Session(ctx, live, now) + if err != nil || sess.Nick != "marta" || sess.DeviceID != "" { + t.Fatalf("Session: %+v, %v", sess, err) + } + if _, err := s.Session(ctx, live, now+2000); !errors.Is(err, ErrNotFound) { + t.Errorf("истёкшая сессия: получено %v, ожидалось ErrNotFound", err) + } + + // Смена пароля с logoutOthers: остаётся только текущая сессия. + cred := Credential{Hash: []byte("new"), Salt: []byte("salt2"), Params: "argon2id,m=19456,t=2,p=1"} + if err := s.SetPassword(ctx, "marta", cred, `{"v":1,"new":true}`, true, live); err != nil { + t.Fatalf("SetPassword: %v", err) + } + if _, err := s.Session(ctx, other, now); !errors.Is(err, ErrNotFound) { + t.Errorf("чужая сессия после logoutOthers: получено %v, ожидалось ErrNotFound", err) + } + if _, err := s.Session(ctx, live, now); err != nil { + t.Errorf("текущая сессия после logoutOthers: %v", err) + } + got, err := s.User(ctx, "marta") + if err != nil { + t.Fatalf("User: %v", err) + } + if string(got.Cred.Hash) != "new" || got.KeyBlob != `{"v":1,"new":true}` { + t.Errorf("хеш и блоб: получено %q / %q", got.Cred.Hash, got.KeyBlob) + } + + // Удаление пользователя уносит сессии каскадом. + if err := s.DeleteUser(ctx, "marta"); err != nil { + t.Fatalf("DeleteUser: %v", err) + } + if _, err := s.Session(ctx, live, now); !errors.Is(err, ErrNotFound) { + t.Errorf("сессия после удаления аккаунта: получено %v, ожидалось ErrNotFound", err) + } +} + +func TestCleanup(t *testing.T) { + ctx := context.Background() + s := open(t, filepath.Join(t.TempDir(), "bare.db")) + now := time.Now() + ms := now.UnixMilli() + day := int64(24 * 60 * 60 * 1000) + + exec := func(query string, args ...any) { + t.Helper() + if _, err := s.db.ExecContext(ctx, query, args...); err != nil { + t.Fatalf("%s: %v", query, err) + } + } + exec(`INSERT INTO users (nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at) + VALUES ('marta', x'00', x'00', 'argon2id,m=19456,t=2,p=1', '{}', '{}', ?)`, ms) + exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('old', 'marta', ?, ?)`, ms, ms-100*day) + exec(`INSERT INTO devices (id, nick, created_at, last_seen) VALUES ('new', 'marta', ?, ?)`, ms, ms) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('old', 'cascade', '{}', ?)`, ms) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'stale', '{}', ?)`, ms-40*day) + exec(`INSERT INTO queue (device_id, msg_id, envelope, created_at) VALUES ('new', 'fresh', '{}', ?)`, ms) + exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'01', 'marta', ?, ?)`, ms, ms-day) + exec(`INSERT INTO sessions (token_hash, nick, created_at, expires_at) VALUES (x'02', 'marta', ?, ?)`, ms, ms+day) + exec(`INSERT INTO rooms (id, name, owner, created_at) VALUES ('r', 'общая', 'marta', ?)`, ms) + for i, key := range []string{"k1", "k2", "k3"} { + exec(`INSERT INTO room_keys (room_id, nick, key_id, sender, iv, ct, created_at) + VALUES ('r', 'marta', ?, 'marta', 'iv', 'ct', ?)`, key, ms+int64(i)) + } + + if err := s.Cleanup(ctx, now); err != nil { + t.Fatalf("Cleanup: %v", err) + } + + if got := ids(t, s, `SELECT id FROM devices ORDER BY id`); !equal(got, []string{"new"}) { + t.Errorf("устройства: получено %v, ожидалось [new]", got) + } + // Очередь устройства 'old' ушла каскадом вместе с ним, 'stale' — по сроку. + if got := ids(t, s, `SELECT msg_id FROM queue ORDER BY msg_id`); !equal(got, []string{"fresh"}) { + t.Errorf("очередь: получено %v, ожидалось [fresh]", got) + } + if got := ids(t, s, `SELECT hex(token_hash) FROM sessions ORDER BY token_hash`); !equal(got, []string{"02"}) { + t.Errorf("сессии: получено %v, ожидалось [02]", got) + } + if got := ids(t, s, `SELECT DISTINCT key_id FROM room_keys ORDER BY key_id`); !equal(got, []string{"k2", "k3"}) { + t.Errorf("ключи комнат: получено %v, ожидалось [k2 k3]", got) + } +} + +func ids(t *testing.T, s *Store, query string) []string { + t.Helper() + rows, err := s.db.Query(query) + if err != nil { + t.Fatalf("%s: %v", query, err) + } + defer rows.Close() + var out []string + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + t.Fatalf("scan: %v", err) + } + out = append(out, v) + } + if err := rows.Err(); err != nil { + t.Fatalf("rows: %v", err) + } + return out +} + +func equal(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..2e38fe5 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,119 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" +) + +// Credential — argon2id-хеш authKey, его соль и параметры (ADR-021). +// Параметры лежат рядом с хешем, чтобы их можно было повышать перехешем +// при очередном входе, а не миграцией всех аккаунтов разом. +type Credential struct { + Hash []byte + Salt []byte + Params string +} + +// User — строка users. PublicKey и KeyBlob — JSON клиента; сервер их +// не расшифровывает и не интерпретирует сверх проверки формы. +type User struct { + Nick string + Cred Credential + PublicKey string + KeyBlob string + CreatedAt int64 +} + +// CreateUser заводит пользователя. Занятый ник — ErrNickTaken. +func (s *Store) CreateUser(ctx context.Context, u User) error { + // ON CONFLICT DO NOTHING вместо разбора кода ошибки драйвера: + // занятый ник виден по нулю затронутых строк. + res, err := s.db.ExecContext(ctx, ` + INSERT INTO users (nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(nick) DO NOTHING`, + u.Nick, u.Cred.Hash, u.Cred.Salt, u.Cred.Params, u.PublicKey, u.KeyBlob, u.CreatedAt) + if err != nil { + return fmt.Errorf("store: создание пользователя: %w", err) + } + n, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("store: создание пользователя: %w", err) + } + if n == 0 { + return ErrNickTaken + } + return nil +} + +// User читает пользователя по нику. Нет такого — ErrNotFound. +func (s *Store) User(ctx context.Context, nick string) (User, error) { + var u User + err := s.db.QueryRowContext(ctx, ` + SELECT nick, auth_hash, auth_salt, auth_params, public_key, key_blob, created_at + FROM users WHERE nick = ?`, nick). + Scan(&u.Nick, &u.Cred.Hash, &u.Cred.Salt, &u.Cred.Params, &u.PublicKey, &u.KeyBlob, &u.CreatedAt) + if errors.Is(err, sql.ErrNoRows) { + return User{}, ErrNotFound + } + if err != nil { + return User{}, fmt.Errorf("store: чтение пользователя: %w", err) + } + return u, nil +} + +// SetAuth заменяет только хеш authKey — перехеш при входе, когда параметры +// в базе отстали от текущих (ADR-021). +func (s *Store) SetAuth(ctx context.Context, nick string, cred Credential) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ? WHERE nick = ?`, + cred.Hash, cred.Salt, cred.Params, nick) + if err != nil { + return fmt.Errorf("store: перехеш: %w", err) + } + return nil +} + +// SetPassword заменяет хеш authKey и ключевой блоб в одной транзакции: +// разъехавшиеся хеш и блоб означали бы аккаунт, в который нельзя войти +// или ключ которого не расшифровать. При logoutOthers в той же транзакции +// удаляются все сессии пользователя, кроме keep — текущей. +func (s *Store) SetPassword(ctx context.Context, nick string, cred Credential, blob string, logoutOthers bool, keep []byte) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` + UPDATE users SET auth_hash = ?, auth_salt = ?, auth_params = ?, key_blob = ? WHERE nick = ?`, + cred.Hash, cred.Salt, cred.Params, blob, nick); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + if logoutOthers { + if _, err := tx.ExecContext(ctx, ` + DELETE FROM sessions WHERE nick = ? AND token_hash <> ?`, nick, keep); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("store: смена пароля: %w", err) + } + return nil +} + +// DeleteUser удаляет пользователя; устройства, сессии, контакты, членство +// и очереди уносит каскад. +// +// Комнаты, где пользователь владелец, каскадом не удаляются: rooms.owner +// ссылается на users(nick) без ON DELETE, и удаление такого пользователя +// упрётся в внешний ключ. Передача владения и удаление пустых комнат — +// ADR-018, этап 3; до появления комнат случай не наступает. +func (s *Store) DeleteUser(ctx context.Context, nick string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM users WHERE nick = ?`, nick); err != nil { + return fmt.Errorf("store: удаление пользователя: %w", err) + } + return nil +} diff --git a/web/app.css b/web/app.css index 46f9bf3..855cc52 100644 --- a/web/app.css +++ b/web/app.css @@ -35,10 +35,33 @@ body { -webkit-text-size-adjust: 100%; } -/* заставка: знак и слово, больше пока ничего */ +/* #app — колонка ровно в высоту окна: от неё считают высоту экраны, + поэтому сайдбар с «ты: @nick» стоит на месте, а прокручивается + только содержимое */ + +#app { + display: flex; + flex-direction: column; + height: 100%; +} + +:focus-visible { + outline: 2px solid var(--ink); + outline-offset: 2px; +} + +/* знак: четыре угла, один цвет, без скруглений */ + +.mark { + width: 18px; + height: 18px; + flex: none; +} + +/* заставка: знак и слово, пока не поднялись модули */ .boot { - min-height: 100%; + flex: 1; display: flex; align-items: center; justify-content: center; @@ -54,3 +77,360 @@ body { font-size: 15px; letter-spacing: -0.02em; } + +/* формы: рамка 1 px ink, прямые углы, цель нажатия 44 px */ + +.form { + max-width: 360px; +} + +.field { + display: block; + margin-bottom: 14px; +} + +.field > span { + display: block; + margin-bottom: 6px; + font-size: 11px; + color: var(--mute); +} + +input[type="text"], +input[type="password"] { + display: block; + width: 100%; + min-height: 44px; + padding: 11px 14px; + border: 1px solid var(--ink); + border-radius: 0; + background: var(--bone); + color: var(--ink); + font: inherit; + font-size: 14px; +} + +.button { + display: block; + width: 100%; + min-height: 44px; + padding: 11px 14px; + border: 1px solid var(--ink); + border-radius: 0; + background: var(--bone); + color: var(--ink); + font: inherit; + font-size: 14px; + text-align: center; + cursor: pointer; +} + +.button[disabled] { + border-color: var(--edge); + color: var(--stone); + cursor: default; +} + +/* hidden обязан прятать: display у .button перебивает таблицу браузера, + поэтому кнопке нужно отдельное правило */ + +.button[hidden] { + display: none; +} + +.check { + display: flex; + align-items: center; + gap: 10px; + min-height: 44px; + font-size: 12px; + color: var(--text2); + cursor: pointer; +} + +.check input { + width: 15px; + height: 15px; + accent-color: var(--ink); +} + +.row { + display: flex; + gap: 10px; + margin-top: 12px; +} + +.row .button { + flex: 1; + width: auto; +} + +/* подписи и строки состояния: акцент mark — только ошибка */ + +.hint { + margin: -4px 0 14px; + font-size: 11px; + line-height: 1.5; + color: var(--mute); +} + +.message { + min-height: 18px; + margin: 10px 0 0; + font-size: 12px; + color: var(--mute); +} + +.message--error { + color: var(--mark); +} + +.confirm { + margin: 12px 0 0; + font-size: 12px; + color: var(--text2); +} + +/* вход и регистрация */ + +.auth { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + overflow-y: auto; +} + +.auth__inner { + width: 100%; + max-width: 320px; +} + +.auth .form { + max-width: none; +} + +.auth__brand { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 28px; + font-size: 15px; + letter-spacing: -0.02em; +} + +.tabs { + display: flex; + align-items: center; + gap: 2px; + margin-bottom: 22px; +} + +.tab { + min-height: 32px; + padding: 6px 10px; + border: 0; + background: none; + color: var(--mute); + font: inherit; + font-size: 13px; + cursor: pointer; +} + +.tab.is-on { + background: var(--ink); + color: var(--bone); +} + +.tabs__sep { + color: var(--stone); + font-size: 13px; +} + +/* каркас: сайдбар и экран */ + +.shell { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.side { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.brand { + flex: none; + display: flex; + align-items: center; + gap: 10px; + height: 56px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + font-size: 15px; + letter-spacing: -0.02em; +} + +.list { + flex: 1; + min-height: 0; + padding: 20px; + overflow-y: auto; +} + +.section { + margin: 0; + padding: 0 8px 10px; + font-size: 10px; + font-weight: 400; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--stone); +} + +.items { + margin: 0 0 22px; + padding: 0; + list-style: none; +} + +.me { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + min-height: 44px; + margin-top: auto; + padding: 16px 20px; + border: 0; + border-top: 1px solid var(--line); + background: none; + color: var(--mute); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} + +.me i { + flex: none; + width: 6px; + height: 6px; + background: var(--ink); +} + +.main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; +} + +.head { + flex: none; + display: flex; + align-items: center; + gap: 14px; + height: 56px; + padding: 0 20px; + border-bottom: 1px solid var(--line); + font-size: 15px; +} + +.back { + min-height: 32px; + padding: 6px 10px; + border: 1px solid var(--edge); + border-radius: 0; + background: none; + color: var(--mute); + font: inherit; + font-size: 12px; + cursor: pointer; +} + +.body { + flex: 1; + min-height: 0; + padding: 20px; + overflow-y: auto; +} + +/* настройки */ + +.settings { + max-width: 460px; +} + +.block { + padding: 20px 0; + border-top: 1px solid var(--line); +} + +.block--first { + padding-top: 0; + border-top: 0; +} + +.self { + margin: 0 0 10px; + font-size: 13px; + color: var(--text2); +} + +.fp { + margin: 0; + font-size: 12px; + color: var(--mute); +} + +.block .form { + margin-top: 14px; +} + +@media (min-width: 760px) { + .shell { + display: grid; + grid-template-columns: 224px 1fr; + } + + .side { + border-right: 1px solid var(--line); + } + + .brand { + height: 64px; + } + + .head { + height: 64px; + padding: 0 32px; + } + + .body { + padding: 28px 32px; + } +} + +/* мобильный: один экран за раз, цели нажатия не меньше 44 px */ + +@media (max-width: 759px) { + .shell[data-screen="list"] .main { + display: none; + } + + .shell[data-screen="screen"] .side { + display: none; + } + + .tab, + .back { + min-height: 44px; + } +} diff --git a/web/index.html b/web/index.html index 88370b2..94c876e 100644 --- a/web/index.html +++ b/web/index.html @@ -9,16 +9,19 @@ + -
- - bare -
+
+
+ + bare +
+
diff --git a/web/js/api.js b/web/js/api.js new file mode 100644 index 0000000..f296443 --- /dev/null +++ b/web/js/api.js @@ -0,0 +1,130 @@ +// Обёртки над fetch. Форма запросов и ответов — docs/protocol.md: +// JSON в обе стороны, cookie сессии, ошибка — {error, message}. +// SSE и ACK появятся на этапе 2. + +// ApiError — ответ сервера с кодом из перечня docs/protocol.md. +export class ApiError extends Error { + constructor(code, message, status, field) { + super(message || code); + this.name = "ApiError"; + this.code = code; + this.status = status; + this.field = field; + } +} + +// NetworkError — запрос не дошёл: сети нет, сервер не ответил. +// Это состояние клиента, а не код протокола. +export class NetworkError extends Error { + constructor() { + super("нет соединения"); + this.name = "NetworkError"; + } +} + +// Тексты состояний и ошибок — docs/ui.md и ADR-028. +const TEXT = { + invalid_credentials: "неверный ник или пароль", + nick_taken: "ник занят", + invalid_nick: "ник: 2–32 символа, a–z, 0–9, _", + invite_required: "нужен инвайт-код", + invalid_invite: "инвайт-код не подходит", + rate_limited: "слишком часто, попробуйте позже", +}; + +export function errorText(err) { + if (err instanceof NetworkError) { + return "нет соединения"; + } + if (err instanceof ApiError && TEXT[err.code]) { + return TEXT[err.code]; + } + return "сервер не справился, попробуйте позже"; +} + +// expired вызывается, когда сервер сказал «нужен вход»: сессия истекла +// или её завершили с другого устройства. IndexedDB при этом не трогается +// (docs/ui.md, «Сеть и состояния»). +let expired = () => {}; + +export function onSessionExpired(handler) { + expired = handler; +} + +// quiet: не звать expired() на 401 unauthenticated. Нужно ровно там, где +// «сессии нет» — не конец сеанса, а ожидаемый ответ (dropSession). +async function request(method, path, body, { quiet = false } = {}) { + const init = { method, credentials: "same-origin", cache: "no-store" }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + let response; + try { + response = await fetch(path, init); + } catch { + throw new NetworkError(); + } + + let data = null; + if ((response.headers.get("Content-Type") ?? "").startsWith("application/json")) { + data = await response.json().catch(() => null); + } + if (response.ok) { + return data; + } + + const code = typeof data?.error === "string" ? data.error : "internal"; + // Отличаем истёкшую сессию от неверного пароля: 401 invalid_credentials — + // обычная ошибка формы входа, 401 unauthenticated — выход на экран входа. + if (code === "unauthenticated" && !quiet) { + expired(); + } + throw new ApiError(code, data?.message, response.status, data?.field); +} + +export function config() { + return request("GET", "/api/config"); +} + +export function kdf(nick) { + return request("GET", `/api/kdf?nick=${encodeURIComponent(nick)}`); +} + +export function register(body) { + return request("POST", "/api/register", body); +} + +export function login(nick, authKey) { + return request("POST", "/api/login", { nick, authKey }); +} + +export function me() { + return request("GET", "/api/me"); +} + +// dropSession — служебный выход перед повторным входом (ADR-031). Смена +// пароля и удаление аккаунта входят заново, а вход перезаписывает cookie: +// прежнюю сессию закрываем сами, пока её токен ещё при нас. +// +// 401 unauthenticated здесь означает «сессии и так нет» — это успех, а не +// конец сеанса: следующим шагом идёт login, он заведёт новую. Остальные +// отказы поднимаются наверх: при живой сессии входить заново нельзя, +// её строка осталась бы на сервере без владельца. +export async function dropSession() { + try { + await request("POST", "/api/logout", undefined, { quiet: true }); + } catch (err) { + if (!(err instanceof ApiError) || err.code !== "unauthenticated") { + throw err; + } + } +} + +export function password(body) { + return request("POST", "/api/password", body); +} + +export function deleteMe(authKey) { + return request("DELETE", "/api/me", { authKey }); +} diff --git a/web/js/crypto.js b/web/js/crypto.js new file mode 100644 index 0000000..1e92ae1 --- /dev/null +++ b/web/js/crypto.js @@ -0,0 +1,242 @@ +// Криптография клиента — всё по docs/crypto.md. +// +// Только WebCrypto: crypto.subtle и crypto.getRandomValues. Строки-константы +// входят в вывод ключей, менять их нельзя. Бинарные поля — base64url без +// паддинга, пароль нормализуется в NFC. +// +// Модуль не знает про DOM: его можно импортировать в node и прогнать. + +const subtle = globalThis.crypto.subtle; + +// Константы вывода ключей (docs/crypto.md). Буквальные строки. +const SALT_PREFIX = "bare-v1:"; +const INFO_AUTH = "bare-auth-v1"; +const INFO_KEK = "bare-kek-v1"; +const BLOB_AAD = "bare-blob-v1|"; + +// Длина секрета аккаунта (ADR-014) и вектора инициализации AES-GCM. +export const SECRET_LEN = 32; +const IV_LEN = 12; + +// Границы числа итераций PBKDF2 (ADR-013, ADR-030). Число приходит от +// сервера — в ответе /api/kdf, /api/config или полем iter в блобе, — а +// считает по нему клиент, поэтому проверить его может только он. +export const MIN_ITERATIONS = 600_000; +export const MAX_ITERATIONS = 10_000_000; + +export function validIterations(iterations) { + return Number.isSafeInteger(iterations) + && iterations >= MIN_ITERATIONS + && iterations <= MAX_ITERATIONS; +} + +// Пустая соль HKDF: «salt = пусто» из docs/crypto.md. +const EMPTY = new Uint8Array(0); +const ECDH_P256 = { name: "ECDH", namedCurve: "P-256" }; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function utf8(text) { + return encoder.encode(text); +} + +export function random(length) { + const bytes = new Uint8Array(length); + globalThis.crypto.getRandomValues(bytes); + return bytes; +} + +export function b64url(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let binary = ""; + for (let i = 0; i < bytes.length; i += 1) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); +} + +export function unb64url(text) { + if (typeof text !== "string" || /[^A-Za-z0-9_-]/.test(text)) { + throw new Error("не base64url"); + } + const padded = text.replaceAll("-", "+").replaceAll("_", "/"); + const binary = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4)); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export function hex(input) { + const bytes = input instanceof Uint8Array ? input : new Uint8Array(input); + let out = ""; + for (let i = 0; i < bytes.length; i += 1) { + out += bytes[i].toString(16).padStart(2, "0"); + } + return out; +} + +// fingerprintGroups режет отпечаток на группы по 4 символа: так его +// показывают человеку (ADR-016). +export function fingerprintGroups(fingerprint) { + return fingerprint.match(/.{1,4}/g) ?? []; +} + +// wipe затирает сырые байты, когда они больше не нужны. +export function wipe(bytes) { + if (bytes instanceof Uint8Array) { + bytes.fill(0); + } +} + +// accountSalt — соль KDF: детерминированная, известна до входа. +export async function accountSalt(nick) { + return new Uint8Array(await subtle.digest("SHA-256", utf8(SALT_PREFIX + nick))); +} + +// deriveAccountKeys выводит мастер-ключ из пароля и два независимых ключа +// из него: authKey уходит на сервер, kek шифрует ключевой блоб. +// Пароль и мастер остаются в памяти и затираются здесь же. +export async function deriveAccountKeys(nick, password, iterations) { + // Ослабленный или неподъёмный iter не должен доходить до PBKDF2: + // authKey, выведенный по нему, уходит на сервер (ADR-030). + if (!validIterations(iterations)) { + throw new Error("iter вне границ"); + } + const salt = await accountSalt(nick); + const secret = utf8(password.normalize("NFC")); + const material = await subtle.importKey("raw", secret, "PBKDF2", false, ["deriveBits"]); + wipe(secret); + + const masterBits = await subtle.deriveBits( + { name: "PBKDF2", salt, iterations, hash: "SHA-256" }, + material, + 256, + ); + const master = await subtle.importKey("raw", masterBits, "HKDF", false, ["deriveBits", "deriveKey"]); + wipe(new Uint8Array(masterBits)); + + const authBits = await subtle.deriveBits( + { name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_AUTH) }, + master, + 256, + ); + const kek = await subtle.deriveKey( + { name: "HKDF", hash: "SHA-256", salt: EMPTY, info: utf8(INFO_KEK) }, + master, + { name: "AES-GCM", length: 256 }, + false, + ["encrypt", "decrypt"], + ); + const authKey = b64url(authBits); + wipe(new Uint8Array(authBits)); + return { authKey, kek }; +} + +// generateIdentity — ключевая пара аккаунта и секрет для экспорта истории. +// Пара extractable: приватный ключ нужно упаковать в блоб. +export async function generateIdentity() { + const keyPair = await subtle.generateKey(ECDH_P256, true, ["deriveBits"]); + return { keyPair, secret: random(SECRET_LEN) }; +} + +// publicJwk — публичный ключ ровно в той форме, в какой его хранит сервер. +export function publicJwk(jwk) { + return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; +} + +export async function exportPublicJwk(publicKey) { + return publicJwk(await subtle.exportKey("jwk", publicKey)); +} + +export async function exportPrivateJwk(privateKey) { + return subtle.exportKey("jwk", privateKey); +} + +// importPublic — чужой или свой публичный ключ из JWK. Extractable: из него +// считается отпечаток по сырой точке. +export async function importPublic(jwk) { + return subtle.importKey("jwk", publicJwk(jwk), ECDH_P256, true, []); +} + +// importPrivate — приватный ключ после расшифровки блоба: наружу больше +// не выходит (docs/crypto.md, «Хранение на устройстве»). +export async function importPrivate(jwk) { + return subtle.importKey("jwk", jwk, ECDH_P256, false, ["deriveBits"]); +} + +// importSecret — секрет аккаунта как ключ HKDF, тоже non-extractable. +export async function importSecret(bytes) { + return subtle.importKey("raw", bytes, "HKDF", false, ["deriveKey", "deriveBits"]); +} + +// fingerprint — SHA-256 несжатой точки публичного ключа, 64 hex строчными. +export async function fingerprint(publicKey) { + const raw = await subtle.exportKey("raw", publicKey); + return hex(await subtle.digest("SHA-256", raw)); +} + +export async function fingerprintOf(jwk) { + return fingerprint(await importPublic(jwk)); +} + +// sealBlob собирает ключевой блоб: приватный ключ и секрет аккаунта под kek. +// Ник в AAD — блоб одного аккаунта не подходит другому. +export async function sealBlob({ nick, iterations, kek, priv, secret }) { + const plain = utf8(JSON.stringify({ priv, secret: b64url(secret) })); + const iv = random(IV_LEN); + const ct = await subtle.encrypt( + { name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) }, + kek, + plain, + ); + wipe(plain); + return JSON.stringify({ v: 1, iter: iterations, iv: b64url(iv), ct: b64url(ct) }); +} + +// parseBlob читает форму блоба, не расшифровывая: iter нужен до того, +// как появится kek. +export function parseBlob(text) { + const blob = JSON.parse(text); + if (blob === null || typeof blob !== "object") { + throw new Error("блоб — не объект"); + } + if (blob.v !== 1) { + throw new Error("версия блоба не 1"); + } + if (!validIterations(blob.iter)) { + throw new Error("iter блоба вне границ"); + } + if (typeof blob.iv !== "string" || typeof blob.ct !== "string") { + throw new Error("iv или ct блоба — не строка"); + } + return blob; +} + +// openBlob расшифровывает блоб и отдаёт сырые JWK приватного ключа +// и байты секрета. Жить им — до импорта в CryptoKey. +export async function openBlob(blob, kek, nick) { + const iv = unb64url(blob.iv); + const ct = unb64url(blob.ct); + if (iv.length !== IV_LEN) { + throw new Error("iv блоба — не 12 байт"); + } + const plain = await subtle.decrypt( + { name: "AES-GCM", iv, additionalData: utf8(BLOB_AAD + nick) }, + kek, + ct, + ); + const bytes = new Uint8Array(plain); + const parsed = JSON.parse(decoder.decode(bytes)); + wipe(bytes); + if (parsed === null || typeof parsed !== "object" || parsed.priv === null || typeof parsed.priv !== "object") { + throw new Error("в блобе нет приватного ключа"); + } + const secret = unb64url(parsed.secret); + if (secret.length !== SECRET_LEN) { + throw new Error("секрет аккаунта — не 32 байта"); + } + return { priv: parsed.priv, secret }; +} diff --git a/web/js/db.js b/web/js/db.js new file mode 100644 index 0000000..e226d00 --- /dev/null +++ b/web/js/db.js @@ -0,0 +1,114 @@ +// IndexedDB клиента — схема из docs/storage.md, «Клиент — IndexedDB». +// +// База `bare`, версия 1, все хранилища заводятся сразу: одна версия — одна +// схема, даже если часть хранилищ наполняется на следующих этапах. +// CryptoKey кладётся объектом: structured clone умеет их хранить, +// и приватный ключ остаётся non-extractable. + +const NAME = "bare"; +const VERSION = 1; + +let opening = null; + +export function open() { + if (!opening) { + opening = new Promise((resolve, reject) => { + const request = indexedDB.open(NAME, VERSION); + request.onupgradeneeded = () => create(request.result); + request.onsuccess = () => { + const db = request.result; + // Соседняя вкладка стирает базу при выходе из аккаунта — отпускаем + // соединение, иначе удаление зависнет заблокированным. + db.onversionchange = () => { + db.close(); + opening = null; + }; + resolve(db); + }; + request.onerror = () => reject(request.error); + request.onblocked = () => reject(new Error("база занята другой вкладкой")); + }); + opening.catch(() => { + opening = null; + }); + } + return opening; +} + +function create(db) { + db.createObjectStore("meta"); // ключ — строка снаружи значения + db.createObjectStore("chats", { keyPath: "id" }); + const messages = db.createObjectStore("messages", { keyPath: "id" }); + messages.createIndex("chat", ["chatId", "id"]); + db.createObjectStore("roomKeys", { keyPath: ["roomId", "keyId"] }); + db.createObjectStore("peers", { keyPath: "nick" }); +} + +function done(tx) { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error ?? new Error("транзакция отменена")); + }); +} + +function value(request) { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +// meta читает несколько ключей одной транзакцией. +export async function meta(keys) { + const db = await open(); + const store = db.transaction("meta", "readonly").objectStore("meta"); + const out = {}; + await Promise.all(keys.map(async (key) => { + out[key] = await value(store.get(key)); + })); + return out; +} + +// putMeta пишет пары «ключ — значение» одной транзакцией. +export async function putMeta(entries) { + const db = await open(); + const tx = db.transaction("meta", "readwrite"); + const store = tx.objectStore("meta"); + for (const [key, item] of Object.entries(entries)) { + store.put(item, key); + } + await done(tx); +} + +// persist просит браузер не вычищать базу: история на устройстве — +// единственная копия (docs/storage.md). +export async function persist() { + if (!navigator.storage?.persist) { + return false; + } + try { + return await navigator.storage.persist(); + } catch { + return false; + } +} + +// destroy стирает базу целиком: выход из аккаунта уносит историю +// (docs/storage.md, один аккаунт на браузерный профиль). +export async function destroy() { + const db = await open().catch(() => null); + if (db) { + db.close(); + } + opening = null; + await new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(NAME); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + // Блокировка — не ответ: соседние вкладки закрывают соединение + // по versionchange, после чего удаление доходит до success. + // Сказать «история удалена», не удалив её, нельзя. + request.onblocked = () => {}; + }); +} diff --git a/web/js/main.js b/web/js/main.js new file mode 100644 index 0000000..49cacf8 --- /dev/null +++ b/web/js/main.js @@ -0,0 +1,338 @@ +// Загрузка, роутинг по hash и состояние аккаунта. +// +// Экраны из js/ui/ занимаются только разметкой: всё, что меняет состояние — +// вход, регистрация, смена пароля, выход, удаление аккаунта — живёт здесь +// и отдаётся экранам через ctx. + +import * as api from "./api.js"; +import * as db from "./db.js"; +import { + deriveAccountKeys, + exportPrivateJwk, + exportPublicJwk, + fingerprintOf, + generateIdentity, + importPrivate, + importSecret, + openBlob, + parseBlob, + publicJwk, + sealBlob, + validIterations, + wipe, +} from "./crypto.js"; +import { clear } from "./ui/dom.js"; +import { renderAuth } from "./ui/auth.js"; +import { renderSettings } from "./ui/settings.js"; +import { frame } from "./ui/shell.js"; + +// Минимальная длина пароля — ADR-013. +const MIN_PASSWORD = 12; + +// Ник — ADR-019. Клиент проверяет ту же форму, что и сервер. +const NICK = /^[a-z0-9_]{2,32}$/; + +const state = { config: null, me: null }; + +// AccountError — то, что случилось с ключевым материалом, а не с сетью. +// Сообщение уже пригодно для показа человеку (ADR-028). +class AccountError extends Error { + constructor(text) { + super(text); + this.name = "AccountError"; + } +} + +const ctx = { + minPassword: MIN_PASSWORD, + validNick: (nick) => NICK.test(nick), + storedNick, + get config() { + return state.config; + }, + get me() { + return state.me; + }, + errorText, + ensureConfig, + signUp, + signIn, + changePassword, + deleteAccount, + signOut, + go, +}; + +// --- роутинг ----------------------------------------------------------- + +// render рисует экран под текущий hash. Маршруты — docs/ui.md, «Каркас»; +// на этом этапе есть только список и настройки, остальные ведут в пустой +// список: чатов, контактов и комнат ещё нет. +function render() { + const app = document.getElementById("app"); + clear(app); + if (!state.me) { + renderAuth(app, ctx); + return; + } + const settings = (location.hash || "#/") === "#/settings"; + const { root, main } = frame(ctx, settings ? "screen" : "list"); + if (settings) { + renderSettings(main, ctx); + } + app.append(root); +} + +function go(hash) { + if (location.hash === hash) { + render(); + } else { + location.hash = hash; + } +} + +// --- состояние --------------------------------------------------------- + +async function ensureConfig() { + if (!state.config) { + state.config = await api.config(); + } + return state.config; +} + +// restore отвечает на вопрос «вошли ли мы»: сессия у сервера и ключи +// на устройстве нужны вместе. Ключей нет — нужен вход, он их и вернёт. +async function restore() { + let who; + try { + who = await api.me(); + } catch { + return null; + } + let meta; + try { + meta = await db.meta(["nick", "publicKey", "fingerprint", "privateKey"]); + } catch { + return null; + } + if (!meta.privateKey || meta.nick !== who.nick) { + return null; + } + return { nick: meta.nick, publicKey: meta.publicKey, fingerprint: meta.fingerprint }; +} + +// storedNick — чьи ключи лежат на устройстве. Вход под другим ником стирает +// базу, поэтому экран входа сначала спрашивает (ADR-029). +async function storedNick() { + try { + const meta = await db.meta(["nick"]); + return meta.nick ?? null; + } catch { + return null; + } +} + +// --- аккаунт ----------------------------------------------------------- + +// derive — вывод ключей по числу итераций, пришедшему от сервера. Границы +// проверяются до PBKDF2: authKey уходит на сервер сразу после вычисления, +// а слишком большой iter не заканчивается вовсе (ADR-030). +async function derive(nick, password, iterations) { + if (!validIterations(iterations)) { + throw new AccountError("параметры ключа не совпали"); + } + return deriveAccountKeys(nick, password, iterations); +} + +async function signUp(nick, password, invite) { + const config = await ensureConfig(); + const iterations = config.kdfIterations; + const { authKey, kek } = await derive(nick, password, iterations); + const { keyPair, secret } = await generateIdentity(); + try { + const priv = await exportPrivateJwk(keyPair.privateKey); + const body = { + nick, + authKey, + publicKey: await exportPublicJwk(keyPair.publicKey), + blob: await sealBlob({ nick, iterations, kek, priv, secret }), + }; + if (invite) { + body.invite = invite; + } + await api.register(body); + await adopt(nick, priv, secret); + } finally { + wipe(secret); + } +} + +async function signIn(nick, password) { + const config = await ensureConfig(); + const { authKey, iterations, priv, secret } = await unlock(nick, password); + try { + await adopt(nick, priv, secret); + if (iterations < config.kdfIterations) { + await raise(nick, password, authKey, priv, secret, config.kdfIterations); + } + } finally { + wipe(secret); + } +} + +// unlock скачивает ключевой блоб и расшифровывает его. Блоб отдаёт только +// вход — другого источника в протоколе нет, поэтому смена пароля тоже +// проходит через login (docs/crypto.md, «Повышение итераций и смена пароля»). +async function unlock(nick, password) { + const { iterations } = await api.kdf(nick); + const { authKey, kek } = await derive(nick, password, iterations); + const account = await api.login(nick, authKey); + + let blob; + try { + blob = parseBlob(account.blob); + } catch { + throw new AccountError("ключ аккаунта повреждён"); + } + // Расшифровка идёт по iter из блоба; расхождение с ответом /api/kdf + // означает несогласованность данных (docs/crypto.md, «Ключевой блоб»). + if (blob.iter !== iterations) { + throw new AccountError("параметры ключа не совпали"); + } + let opened; + try { + opened = await openBlob(blob, kek, nick); + } catch { + throw new AccountError("ключ аккаунта повреждён"); + } + // Публичный ключ, который сервер раздаёт собеседникам, обязан + // соответствовать приватному из блоба. + if (opened.priv.x !== account.publicKey?.x || opened.priv.y !== account.publicKey?.y) { + wipe(opened.secret); + throw new AccountError("ключ аккаунта повреждён"); + } + return { authKey, iterations, priv: opened.priv, secret: opened.secret }; +} + +// adopt кладёт ключи на устройство. Сырые байты дальше не идут: +// в IndexedDB попадают только non-extractable CryptoKey (docs/crypto.md). +async function adopt(nick, priv, secret) { + const previous = await db.meta(["nick"]); + if (previous.nick && previous.nick !== nick) { + // На устройстве история другого аккаунта: один аккаунт на браузерный + // профиль (docs/storage.md). Согласие на стирание спрашивает экран + // входа до вычисления ключа (ADR-029) — здесь оно уже получено. + await db.destroy(); + } + const publicKey = publicJwk(priv); + const fingerprint = await fingerprintOf(publicKey); + await db.putMeta({ + nick, + publicKey, + fingerprint, + privateKey: await importPrivate(priv), + accountSecret: await importSecret(secret), + }); + state.me = { nick, publicKey, fingerprint }; +} + +// raise — автоматическое повышение итераций сразу после входа, молча +// и без завершения чужих сессий (docs/crypto.md). +async function raise(nick, password, authKey, priv, secret, iterations) { + try { + const next = await derive(nick, password, iterations); + await api.password({ + authKey, + newAuthKey: next.authKey, + blob: await sealBlob({ nick, iterations, kek: next.kek, priv, secret }), + logoutOthers: false, + }); + } catch { + // Не вышло — вход уже состоялся, повторим при следующем. + } +} + +async function changePassword(current, next, logoutOthers) { + const config = await ensureConfig(); + const nick = state.me.nick; + // unlock входит заново — иначе не добыть блоб, — а вход заводит новую + // сессию и перезаписывает cookie. Прежнюю закрываем сами и до входа: + // иначе её строка осталась бы жить на сервере, а токена от неё нет уже + // ни у кого. Сорвавшийся unlock после этого оставляет клиент без сессии, + // но не на экране входа: следующая попытка начинается с того же + // служебного выхода и проходит целиком (ADR-031). + await api.dropSession(); + const { authKey, priv, secret } = await unlock(nick, current); + try { + const iterations = config.kdfIterations; + const derived = await derive(nick, next, iterations); + await api.password({ + authKey, + newAuthKey: derived.authKey, + blob: await sealBlob({ nick, iterations, kek: derived.kek, priv, secret }), + logoutOthers, + }); + } finally { + wipe(secret); + } +} + +// deleteAccount входит заново тем же порядком, что и смена пароля: сессии +// может уже не быть — её закрывает сорвавшаяся смена пароля, — а DELETE +// /api/me без сессии не проходит. Блоб здесь не нужен, поэтому вход прямой, +// без unlock: аккаунт с испорченным блобом обязан удаляться (ADR-031). +async function deleteAccount(password) { + const nick = state.me.nick; + const { iterations } = await api.kdf(nick); + const { authKey } = await derive(nick, password, iterations); + await api.dropSession(); + await api.login(nick, authKey); + await api.deleteMe(authKey); + await forget(); +} + +async function signOut() { + try { + await api.dropSession(); + } catch { + // Сети нет или сервер не справился; база стирается в любом случае. + } + await forget(); +} + +// forget уносит историю: она на этом устройстве единственная копия +// (docs/storage.md, docs/ui.md). +async function forget() { + await db.destroy(); + state.me = null; +} + +function errorText(err) { + if (err instanceof AccountError) { + return err.message; + } + return api.errorText(err); +} + +// --- старт ------------------------------------------------------------- + +async function boot() { + db.persist(); + try { + await ensureConfig(); + } catch { + // Ошибку покажем на первой попытке входа: экран рисуется и без конфигурации. + } + state.me = await restore(); + render(); + addEventListener("hashchange", render); + // 401 unauthenticated на любом запросе — на экран входа, IndexedDB цела. + api.onSessionExpired(() => { + if (state.me) { + state.me = null; + render(); + } + }); +} + +boot(); diff --git a/web/js/ui/auth.js b/web/js/ui/auth.js new file mode 100644 index 0000000..4d5043d --- /dev/null +++ b/web/js/ui/auth.js @@ -0,0 +1,188 @@ +// Экран входа и регистрации — docs/ui.md, «Вход и регистрация». + +import { clear, confirmPanel, el, field, mark, message, setError, setNote } from "./dom.js"; + +const HINT = "пароль — это ключ шифрования, а не запись в базе. восстановления нет. " + + "не короче 12 символов; лучше — фраза из нескольких слов."; + +const MODES = [ + ["in", "вход"], + ["up", "регистрация"], +]; + +export function renderAuth(root, ctx) { + // Состояние экрана переживает перерисовку: режим, введённый ник и ошибка. + const view = { mode: "in", nick: "", error: "" }; + const paint = () => { + clear(root); + root.append(screen(ctx, view, paint)); + }; + paint(); +} + +function screen(ctx, view, paint) { + const main = el("main", "auth"); + const inner = el("div", "auth__inner"); + main.append(inner); + + const brand = el("div", "auth__brand"); + brand.append(mark(), el("span", null, "bare")); + inner.append(brand); + inner.append(tabs(view, paint)); + + const form = el("form", "form"); + form.noValidate = true; + + const register = view.mode === "up"; + const nick = field("ник", { name: "nick", autocomplete: "username" }); + nick.input.value = view.nick; + const password = field("пароль", { + type: "password", + name: "password", + autocomplete: register ? "new-password" : "current-password", + }); + form.append(nick.wrap, password.wrap); + + let invite = null; + if (register) { + form.append(el("p", "hint", HINT)); + if (ctx.config?.inviteRequired) { + invite = field("инвайт-код", { name: "invite", autocomplete: "off" }); + form.append(invite.wrap); + } + } + + const label = register ? "регистрация" : "вход"; + const submit = el("button", "button", label); + submit.type = "submit"; + form.append(submit); + + // На устройстве могут лежать ключи другого ника: вход под этим сотрёт + // историю прежнего, поэтому сначала подтверждение (ADR-029). + const wipe = confirmPanel("", "удалить"); + form.append(wipe.root); + + const note = message(); + form.append(note); + if (view.error) { + setError(note, view.error); + } + + const fail = (text) => { + view.error = text; + if (text) { + setError(note, text); + } else { + setNote(note, ""); + } + }; + + // run — то, ради чего экран: PBKDF2 и запрос к серверу. Вызывается либо + // сразу, либо после подтверждения стирания. + const run = async () => { + submit.disabled = true; + submit.textContent = "вычисляем ключ…"; + const pass = password.input.value; + try { + if (register) { + await ctx.signUp(view.nick, pass, invite ? invite.input.value.trim() : ""); + } else { + await ctx.signIn(view.nick, pass); + } + ctx.go("#/"); + } catch (err) { + submit.disabled = false; + submit.textContent = label; + fail(ctx.errorText(err)); + } + }; + + const hideWipe = () => { + wipe.root.hidden = true; + submit.hidden = false; + }; + wipe.no.addEventListener("click", () => { + hideWipe(); + submit.focus(); + }); + wipe.yes.addEventListener("click", () => { + hideWipe(); + run(); + }); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) { + return; + } + view.nick = nick.input.value.trim().toLowerCase(); + nick.input.value = view.nick; + const pass = password.input.value; + fail(""); + hideWipe(); + + if (!ctx.validNick(view.nick)) { + fail("ник: 2–32 символа, a–z, 0–9, _"); + nick.input.focus(); + return; + } + // Длина пароля проверяется в обоих режимах: короче минимума пароля + // нет ни у одного аккаунта, считать по нему PBKDF2 незачем (docs/ui.md). + if ([...pass].length < ctx.minPassword) { + fail(`пароль: не короче ${ctx.minPassword} символов`); + password.input.focus(); + return; + } + try { + await ctx.ensureConfig(); + } catch (err) { + fail(ctx.errorText(err)); + return; + } + // Поле инвайта могло не появиться: конфигурация приехала только что. + if (register && ctx.config.inviteRequired && invite === null) { + view.error = "нужен инвайт-код"; + paint(); + return; + } + + const other = await ctx.storedNick(); + if (other && other !== view.nick) { + wipe.text.textContent = `на этом устройстве история @${other}. вход под другим ником удалит её.`; + wipe.root.hidden = false; + submit.hidden = true; + wipe.yes.focus(); + return; + } + await run(); + }); + + inner.append(form); + return main; +} + +// tabs — переключатель «вход / регистрация». +function tabs(view, paint) { + const wrap = el("div", "tabs"); + MODES.forEach(([mode, text], index) => { + if (index > 0) { + wrap.append(el("span", "tabs__sep", "/")); + } + const tab = el("button", "tab", text); + tab.type = "button"; + const on = view.mode === mode; + if (on) { + tab.classList.add("is-on"); + } + tab.setAttribute("aria-pressed", String(on)); + tab.addEventListener("click", () => { + if (view.mode !== mode) { + view.mode = mode; + view.error = ""; + paint(); + } + }); + wrap.append(tab); + }); + return wrap; +} diff --git a/web/js/ui/dom.js b/web/js/ui/dom.js new file mode 100644 index 0000000..f6271d5 --- /dev/null +++ b/web/js/ui/dom.js @@ -0,0 +1,94 @@ +// Сборка узлов. innerHTML не используется нигде: сообщения и ники — +// пользовательские данные (docs/ui.md, CSP из ADR-021). + +const SVG = "http://www.w3.org/2000/svg"; + +export function el(tag, className, text) { + const node = document.createElement(tag); + if (className) { + node.className = className; + } + if (text !== undefined) { + node.textContent = text; + } + return node; +} + +export function clear(node) { + while (node.firstChild) { + node.removeChild(node.firstChild); + } +} + +// mark — знак «скобы»: четыре угла рамки (docs/identity/brief.md). +export function mark() { + const svg = document.createElementNS(SVG, "svg"); + svg.setAttribute("class", "mark"); + svg.setAttribute("viewBox", "0 0 64 64"); + svg.setAttribute("fill", "none"); + svg.setAttribute("stroke", "currentColor"); + svg.setAttribute("stroke-width", "7"); + svg.setAttribute("aria-hidden", "true"); + for (const d of ["M10 26V10h16", "M38 10h16v16", "M54 38v16H38", "M26 54H10V38"]) { + const path = document.createElementNS(SVG, "path"); + path.setAttribute("d", d); + svg.append(path); + } + return svg; +} + +// field — подпись и строка ввода. Отдаёт и то, и другое: подпись идёт +// в форму, ввод нужен обработчику. +export function field(label, { type = "text", name, autocomplete } = {}) { + const wrap = el("label", "field"); + wrap.append(el("span", null, label)); + const input = el("input"); + input.type = type; + if (name) { + input.name = name; + } + if (autocomplete) { + input.autocomplete = autocomplete; + } + input.autocapitalize = "off"; + input.spellcheck = false; + wrap.append(input); + return { wrap, input }; +} + +export function button(text, className = "button") { + const node = el("button", className, text); + node.type = "button"; + return node; +} + +// confirmPanel — вопрос и две кнопки; спрятан, пока не спросили. +// Вопрос отдаётся наружу: его текст бывает известен только к моменту показа. +export function confirmPanel(question, yesLabel) { + const root = el("div"); + root.hidden = true; + const text = el("p", "confirm", question); + const yes = button(yesLabel); + const no = button("отмена"); + const row = el("div", "row"); + row.append(yes, no); + root.append(text, row); + return { root, text, yes, no }; +} + +// message — строка состояния под формой: ошибка цветом mark, ответ — mute. +export function message() { + const node = el("p", "message"); + node.setAttribute("aria-live", "polite"); + return node; +} + +export function setError(node, text) { + node.className = "message message--error"; + node.textContent = text; +} + +export function setNote(node, text) { + node.className = "message"; + node.textContent = text; +} diff --git a/web/js/ui/settings.js b/web/js/ui/settings.js new file mode 100644 index 0000000..4130793 --- /dev/null +++ b/web/js/ui/settings.js @@ -0,0 +1,179 @@ +// Настройки — docs/ui.md, «Настройки». На этом этапе только разделы, +// которые уже работают: кто ты, смена пароля, выход, удаление аккаунта. +// Уведомления, устройства, история и установка приложения — дальше по плану. + +import { ApiError } from "../api.js"; +import { fingerprintGroups } from "../crypto.js"; +import { button, confirmPanel, el, field, message, setError, setNote } from "./dom.js"; + +export function renderSettings(root, ctx) { + root.append(head(ctx)); + const body = el("div", "body settings"); + body.append(identity(ctx), passwordBlock(ctx), exitBlock(ctx), deleteBlock(ctx)); + root.append(body); +} + +function head(ctx) { + const bar = el("div", "head"); + const back = button("назад", "back"); + back.addEventListener("click", () => ctx.go("#/")); + bar.append(back, el("span", "title", "настройки")); + return bar; +} + +// identity — «ты: @nick» и свой отпечаток группами по 4 в две строки. +function identity(ctx) { + const box = el("section", "block block--first"); + box.append(el("p", "self", `ты: @${ctx.me.nick}`)); + const groups = fingerprintGroups(ctx.me.fingerprint ?? ""); + box.append(el("p", "fp", groups.slice(0, 8).join(" "))); + box.append(el("p", "fp", groups.slice(8).join(" "))); + return box; +} + +function passwordBlock(ctx) { + const box = block("сменить пароль"); + const form = el("form", "form"); + form.noValidate = true; + + const current = field("старый", { type: "password", autocomplete: "current-password" }); + const next = field("новый", { type: "password", autocomplete: "new-password" }); + const again = field("повтор", { type: "password", autocomplete: "new-password" }); + form.append(current.wrap, next.wrap, again.wrap); + + const check = el("label", "check"); + const others = el("input"); + others.type = "checkbox"; + // Смена пароля по желанию завершает остальные сессии (ADR-015); + // снять галочку можно, но это осознанный выбор, а не умолчание. + others.checked = true; + check.append(others, el("span", null, "выйти на других устройствах")); + form.append(check); + + const submit = el("button", "button", "сменить пароль"); + submit.type = "submit"; + const note = message(); + form.append(submit, note); + + form.addEventListener("submit", async (event) => { + event.preventDefault(); + if (submit.disabled) { + return; + } + setNote(note, ""); + if (next.input.value !== again.input.value) { + setError(note, "пароли не совпадают"); + return; + } + if ([...next.input.value].length < ctx.minPassword) { + setError(note, `пароль: не короче ${ctx.minPassword} символов`); + return; + } + submit.disabled = true; + submit.textContent = "вычисляем ключ…"; + try { + await ctx.changePassword(current.input.value, next.input.value, others.checked); + for (const input of [current.input, next.input, again.input]) { + input.value = ""; + } + setNote(note, "пароль изменён"); + } catch (err) { + setError(note, passwordError(ctx, err)); + } finally { + submit.disabled = false; + submit.textContent = "сменить пароль"; + } + }); + + box.append(form); + return box; +} + +function exitBlock(ctx) { + const box = block("выйти"); + const start = button("выйти"); + // Кнопки «экспортировать» пока нет: экспорт — этап 5 (docs/plan.md). + const panel = confirmPanel("история на этом устройстве будет удалена. экспортировать сначала?", "выйти"); + + start.addEventListener("click", () => { + start.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + panel.root.hidden = true; + start.hidden = false; + start.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + await ctx.signOut(); + ctx.go("#/"); + }); + + box.append(start, panel.root); + return box; +} + +function deleteBlock(ctx) { + const box = block("удалить аккаунт"); + const form = el("form", "form"); + form.noValidate = true; + + const password = field("пароль", { type: "password", autocomplete: "current-password" }); + const submit = el("button", "button", "удалить аккаунт"); + submit.type = "submit"; + const panel = confirmPanel("аккаунт и вся история будут удалены навсегда.", "удалить"); + const note = message(); + form.append(password.wrap, submit, panel.root, note); + + const back = () => { + panel.root.hidden = true; + panel.yes.disabled = false; + panel.no.disabled = false; + panel.yes.textContent = "удалить"; + submit.hidden = false; + }; + + form.addEventListener("submit", (event) => { + event.preventDefault(); + setNote(note, ""); + submit.hidden = true; + panel.root.hidden = false; + panel.yes.focus(); + }); + panel.no.addEventListener("click", () => { + back(); + submit.focus(); + }); + panel.yes.addEventListener("click", async () => { + panel.yes.disabled = true; + panel.no.disabled = true; + panel.yes.textContent = "вычисляем ключ…"; + try { + await ctx.deleteAccount(password.input.value); + ctx.go("#/"); + } catch (err) { + back(); + setError(note, passwordError(ctx, err)); + } + }); + + box.append(form); + return box; +} + +function block(title) { + const box = el("section", "block"); + box.append(el("h2", "section", title)); + return box; +} + +// passwordError: в настройках 401 означает ровно одно — не тот пароль. +function passwordError(ctx, err) { + if (err instanceof ApiError && err.code === "invalid_credentials") { + return "неверный пароль"; + } + return ctx.errorText(err); +} diff --git a/web/js/ui/shell.js b/web/js/ui/shell.js new file mode 100644 index 0000000..5b015cf --- /dev/null +++ b/web/js/ui/shell.js @@ -0,0 +1,38 @@ +// Каркас: сайдбар со списком чатов и место под экран — docs/ui.md, «Каркас» +// и «Список чатов». Чаты появятся на этапе 2, секции пока пустые. + +import { el, mark } from "./dom.js"; + +// frame отдаёт корень и место под экран. screen — что показывать +// на мобильном, где виден один экран за раз: "list" или "screen". +export function frame(ctx, screen) { + const root = el("div", "shell"); + root.dataset.screen = screen; + const main = el("main", "main"); + root.append(side(ctx), main); + return { root, main }; +} + +function side(ctx) { + const nav = el("nav", "side"); + + const brand = el("div", "brand"); + brand.append(mark(), el("span", null, "bare")); + nav.append(brand); + + const list = el("div", "list"); + for (const title of ["каналы", "личные"]) { + list.append(el("h2", "section", title), el("ul", "items")); + } + nav.append(list); + + const me = el("button", "me"); + me.type = "button"; + const dot = el("i"); + dot.setAttribute("aria-hidden", "true"); + me.append(dot, el("span", null, `ты: @${ctx.me.nick}`)); + me.addEventListener("click", () => ctx.go("#/settings")); + nav.append(me); + + return nav; +}