Этап 1: аккаунты — argon2id, сессии, ключевой блоб, вход и регистрация
Сервер: миграция 001 со всей схемой storage.md, store на modernc.org/sqlite (WAL, foreign_keys, один писатель), фоновая чистка раз в час, argon2id с параметрами ADR-021 и сверкой constant-time, сессии по SHA-256 токена, cookie bare_session, глобальная проверка Origin, девять эндпоинтов аккаунта. Ник в журнал не попадает: для /api/ пишется шаблон маршрута. Клиент: crypto.js по crypto.md построчно — мастер из пароля, два независимых ключа из мастера, ключевой блоб с ником в AAD, отпечаток от сырой точки; db.js со всеми хранилищами версии 1; экран входа и регистрации, настройки со сменой пароля, выходом и удалением аккаунта. Пароль не покидает клиент: проверено на боевом сервере — ни пароля, ни priv.d ни в одном теле запроса, вход на втором устройстве даёт тот же отпечаток. ADR-027: код internal для 500, причина только в журнале. ADR-028: тексты состояний клиента сведены в ui.md. ADR-029: вход под другим ником стирает историю только после подтверждения. ADR-030: верхняя граница итераций KDF, проверка границ на обеих сторонах. ADR-031: служебный выход перед повторным входом не заканчивает сеанс. ADR-032: каталог состояния 0700, файлы базы 0600. Прямые зависимости: modernc.org/sqlite, golang.org/x/crypto. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015DbCjVfTFq4ZFG8juD45YJ
This commit is contained in:
+15
-1
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -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` в журнале означает ошибку в коде или в окружении и разбирается, а не считается нормой.
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR-028: Тексты состояний клиента
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/ui.md` задаёт пять ошибок формы входа и тексты экранов. Этап 1 упёрся в состояния, которых в этих перечнях нет, а показать их надо:
|
||||
|
||||
- запрос не дошёл (сети нет, сервер молчит) и ответ с кодом, на который у клиента нет сценария, — `500 internal` (ADR-027), `429`, `too_large`;
|
||||
- ключевой блоб не разбирается или не расшифровывается;
|
||||
- `iter` в блобе расходится с ответом `GET /api/kdf` — `docs/crypto.md` прямо требует показать это ошибкой;
|
||||
- пароль короче 12 символов: проверить длину может только клиент, сервер пароля не видит (ADR-013, ADR-015);
|
||||
- в настройках — несовпадение нового пароля с повтором, подтверждение опасной операции не тем паролем, ответ об успешной смене пароля.
|
||||
|
||||
Придумывать эти строки в коде молча нельзя: тексты — часть интерфейса, а не деталь реализации.
|
||||
|
||||
## Решение
|
||||
|
||||
Перечни `docs/ui.md` дополняются разделом «Тексты состояний». Правила прежние: строчные, коротко, говорят, что случилось. Ошибка — строкой цветом `mark`, ответ об успехе — той же строкой цветом `mute`.
|
||||
|
||||
- «нет соединения» — запрос не дошёл. Тот же текст, что у полосы в чате: состояние одно.
|
||||
- «сервер не справился, попробуйте позже» — код ответа, на который у клиента нет сценария.
|
||||
- «слишком часто, попробуйте позже» — `429 rate_limited`.
|
||||
- «пароль: не короче 12 символов» — проверка клиента при регистрации и смене пароля.
|
||||
- «пароли не совпадают» — новый пароль и повтор различаются.
|
||||
- «ключ аккаунта повреждён» — блоб не разобран, не расшифрован или не соответствует публичному ключу аккаунта.
|
||||
- «параметры ключа не совпали» — `iter` блоба не равен ответу `GET /api/kdf`.
|
||||
- «неверный пароль» — `401 invalid_credentials` в настройках, где ник заведомо свой.
|
||||
- «пароль изменён» — ответ на успешную смену.
|
||||
- «аккаунт и вся история будут удалены навсегда.» — подтверждение удаления аккаунта.
|
||||
|
||||
## Следствия
|
||||
|
||||
- `docs/ui.md` остаётся единственным местом, где живут тексты интерфейса.
|
||||
- Клиент разбирает `error` по перечню `docs/protocol.md`; всё, чего в перечне нет, и всё, что случилось до ответа, сводится к двум строкам — «нет соединения» и «сервер не справился, попробуйте позже».
|
||||
- Новый экран приносит свои тексты в `docs/ui.md` тем же порядком: сначала документ, потом код.
|
||||
@@ -0,0 +1,21 @@
|
||||
# ADR-029: Вход под другим ником стирает историю только после подтверждения
|
||||
|
||||
## Контекст
|
||||
|
||||
`docs/storage.md` держит правило «один аккаунт на браузерный профиль»: база стирается целиком, потому что история на устройстве — единственная копия. Стирание там разрешено только после подтверждения, и `docs/ui.md` описывает это подтверждение ровно в одном месте — у кнопки «выйти» в настройках.
|
||||
|
||||
Экран входа в это правило не попал, а достижим с целой базой: по `docs/ui.md` («Сеть и состояния») `401` выбрасывает на экран входа и намеренно оставляет IndexedDB нетронутой. Ввод другого ника в форму входа или регистрации уносил всю историю прежнего аккаунта молча, до единого вопроса.
|
||||
|
||||
Просто отказать во входе под другим ником нельзя: с экрана входа выйти из прежнего аккаунта нечем — сессии уже нет, настройки недоступны. Отказ запер бы устройство.
|
||||
|
||||
## Решение
|
||||
|
||||
- Если на устройстве лежат ключи другого ника, экран входа спрашивает подтверждение до вычисления ключа: «на этом устройстве история @nick. вход под другим ником удалит её.» с кнопками «удалить» и «отмена». Текст — в `docs/ui.md`, «Вход и регистрация».
|
||||
- Подтверждение — согласие, а не стирание: база уносится там же, где и раньше, — после успешного входа или регистрации. Отказ сервера ничего не удаляет.
|
||||
- Правило «один аккаунт на браузерный профиль» остаётся. Меняется одно: молчаливого стирания нет ни на одном экране.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Единственная копия истории не исчезает без вопроса ни в одном сценарии.
|
||||
- Кнопки «экспортировать» в этом подтверждении нет: экспорта нет вовсе до этапа 5. Когда он появится, кнопка придёт сюда тем же порядком — сначала `docs/ui.md`.
|
||||
- Сравнивается ник, а не отпечаток: перерегистрация под тем же ником вопроса не вызовет. Смена ключа у знакомого ника — предмет TOFU (ADR-016), этап 3.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-030: Верхняя граница итераций KDF и проверка границ на клиенте
|
||||
|
||||
Уточняет [ADR-013](013-password-policy-kdf.md): нижняя граница остаётся, к ней добавляется верхняя.
|
||||
|
||||
## Контекст
|
||||
|
||||
ADR-013 задаёт целевое число итераций PBKDF2 и нижнюю границу, верхней нет. `iter` — единственное поле ключевого блоба, которое сервер разбирает сам и потом сам же раздаёт клиентам через `GET /api/kdf`, то есть отвечает за его вменяемость. Регистрация одним запросом с `iter = 10^12` принималась: аккаунт после этого нельзя ни открыть, ни удалить — обе операции начинаются с PBKDF2, который не заканчивается.
|
||||
|
||||
С другой стороны, клиент брал число итераций из `GET /api/kdf` и `GET /api/config` как есть и считал по нему `authKey`, который тут же уходит на сервер. Нижнюю границу не проверял никто, кроме сервера, и только у блоба — а PBKDF2 считает клиент, и проверить параметр перед вычислением может только он.
|
||||
|
||||
## Решение
|
||||
|
||||
- Границы числа итераций — от 600 000 до 10 000 000. Верхняя — порядок над целевым значением 1 000 000: запас на повышение и предел, за которым вход перестаёт заканчиваться.
|
||||
- Сервер отвергает ключевой блоб с `iter` вне границ: `400 invalid`, `field: blob`.
|
||||
- Клиент проверяет границы до `deriveBits`: и число из `GET /api/kdf` и `GET /api/config`, и `iter` при разборе блоба. Число от сервера вне границ — «параметры ключа не совпали»; `iter` блоба вне границ — «ключ аккаунта повреждён», как любой другой дефект его формы (ADR-028).
|
||||
- Границы записаны в `docs/crypto.md` рядом с целевым значением.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Аккаунт с неоткрываемым `iter` завести нельзя.
|
||||
- Ослабить KDF ответом `/api/kdf` тоже нельзя: границу держат обе стороны, и клиентская стоит раньше вычисления. Активно-злонамеренный оператор остаётся вне модели угроз — он подменит и сам клиент.
|
||||
- Поднять целевое значение выше верхней границы без правки границы не выйдет. Это и требуется: такое повышение — решение, а не настройка.
|
||||
@@ -0,0 +1,22 @@
|
||||
# ADR-031: Служебный выход перед повторным входом
|
||||
|
||||
## Контекст
|
||||
|
||||
Ключевой блоб отдаёт только `POST /api/login`: `GET /api/me` его не возвращает, отдельного эндпоинта в `docs/protocol.md` нет. Поэтому смена пароля проходит через вход. Вход заводит новую сессию и перезаписывает cookie, а cookie — `HttpOnly`: прежний токен после этого недостижим, закрыть ту сессию клиенту уже нечем. Оставлять её живой нельзя — украденная cookie пережила бы смену пароля, ради которой всё и затевалось. Значит, выход идёт первым, до входа.
|
||||
|
||||
Но `POST /api/logout` — обычный непубличный запрос, и `401 unauthenticated` на нём по `docs/ui.md` («Сеть и состояния») выбрасывает на экран входа. Отсюда отказ. Смена пароля с неверным старым паролем закрывает сессию и падает на входе; клиент остаётся на настройках и показывает «неверный пароль». Вторая попытка, уже с верным паролем, начинается с того же служебного выхода, получает `401` — и уходит на экран входа молча: ошибка пишется в узел, которого в документе уже нет. Удаление аккаунта после такой попытки получает `401` на `DELETE /api/me` и тоже уезжает на экран входа, ничего не удалив.
|
||||
|
||||
## Решение
|
||||
|
||||
- Смена пароля и удаление аккаунта начинаются со служебного выхода, потом входят заново. Порядок «выход → вход» не оставляет на сервере сессию, токена от которой нет ни у кого.
|
||||
- Служебный выход не заканчивает сеанс для пользователя. `401 unauthenticated` на нём означает «сессии и так нет» и считается успехом: следующий шаг открывает новую. Обработчик истёкшей сессии на таком ответе не зовётся, ошибка не бросается. Прочие отказы — нет сети, `500` — поднимаются наверх и показываются как есть: при живой сессии входить заново нельзя.
|
||||
- Мимо обработчика идёт ровно этот вызов. `401 unauthenticated` на любом другом запросе по-прежнему ведёт на экран входа с сохранением IndexedDB.
|
||||
- Удаление аккаунта входит прямым `POST /api/login`, без разбора блоба: аккаунт с испорченным блобом обязан удаляться.
|
||||
- Кнопка «выйти» пользуется тем же вызовом: сеанс там заканчивает сам клиент — стирает базу и рисует экран входа, — а не ответ сервера.
|
||||
|
||||
## Следствия
|
||||
|
||||
- Сорвавшаяся смена пароля оставляет клиент без сессии, но на своём экране и со своей строкой: «неверный пароль», «нет соединения». Следующая попытка — смена пароля или удаление аккаунта — начинается с того же служебного выхода и проходит целиком.
|
||||
- Перезагрузка страницы в этом состоянии показывает экран входа: `GET /api/me` отвечает `401`, IndexedDB цела. Это обычный сценарий истёкшей сессии, отдельного обхождения не требует.
|
||||
- Сервер не меняется: `POST /api/logout` и `POST /api/login` работают как записано в `docs/protocol.md`.
|
||||
- В `docs/ui.md` правило уточняется до `401 unauthenticated`: `401 invalid_credentials` — ошибка формы, на экран входа она не выбрасывала и раньше.
|
||||
@@ -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`.
|
||||
+7
-2
@@ -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 хранит по своим правилам.
|
||||
|
||||
+2
-1
@@ -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`.
|
||||
|
||||
## Статика и служебное
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ DELETE FROM sessions WHERE expires_at < :now;
|
||||
|
||||
## Клиент — IndexedDB
|
||||
|
||||
База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия).
|
||||
База `bare`, версия 1. Один аккаунт на браузерный профиль: выход из аккаунта стирает базу целиком после подтверждения (история на этом устройстве — единственная копия). Вход под другим ником стирает её так же и тоже после подтверждения — на экране входа (ADR-029).
|
||||
|
||||
```
|
||||
meta key: string → value
|
||||
|
||||
+21
-4
@@ -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` в настройках | «неверный пароль» |
|
||||
|
||||
## Доступность
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
@@ -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", "такого ника нет")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+126
-13
@@ -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. Длинный путь обрезается.
|
||||
|
||||
+174
-62
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+382
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -9,16 +9,19 @@
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icons/icon-180.png">
|
||||
<script type="module" src="/js/main.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<main class="boot">
|
||||
<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="7" aria-hidden="true">
|
||||
<path d="M10 26V10h16"/>
|
||||
<path d="M38 10h16v16"/>
|
||||
<path d="M54 38v16H38"/>
|
||||
<path d="M26 54H10V38"/>
|
||||
</svg>
|
||||
<span class="word">bare</span>
|
||||
</main>
|
||||
<div id="app">
|
||||
<main class="boot">
|
||||
<svg class="mark" viewBox="0 0 64 64" fill="none" stroke="currentColor" stroke-width="7" aria-hidden="true">
|
||||
<path d="M10 26V10h16"/>
|
||||
<path d="M38 10h16v16"/>
|
||||
<path d="M54 38v16H38"/>
|
||||
<path d="M26 54H10V38"/>
|
||||
</svg>
|
||||
<span class="word">bare</span>
|
||||
</main>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+130
@@ -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 });
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
+114
@@ -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 = () => {};
|
||||
});
|
||||
}
|
||||
+338
@@ -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();
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user