Этап 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:
2026-08-22 14:06:07 +03:00
co-authored by Claude Opus 5
parent 32717cb7dd
commit 597c55301c
39 changed files with 4220 additions and 96 deletions
+344
View File
@@ -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", "ник: 232 символа, az, 09, _")
return
}
key, ok := authKey(in.AuthKey)
if !ok {
Invalid(w, "authKey", "authKey — не 32 байта base64url")
return
}
public, err := publicKeyJSON(in.PublicKey)
if err != nil {
Invalid(w, "publicKey", err.Error())
return
}
if _, err := blobIterations(in.Blob); err != nil {
Invalid(w, "blob", err.Error())
return
}
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", "такого ника нет")
}
+464
View File
@@ -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
View File
@@ -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
View File
@@ -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())
}
}
+123
View File
@@ -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
}
+96
View File
@@ -0,0 +1,96 @@
// Package auth — argon2id, сессии, cookie и проверки на входе (ADR-021).
//
// Пароля здесь нет: клиент присылает authKey, выведенный из пароля
// (ADR-015). Сервер хранит argon2id от authKey — чтобы дамп базы не давал
// готового ключа для входа.
package auth
import (
"crypto/rand"
"crypto/subtle"
"fmt"
"golang.org/x/crypto/argon2"
"github.com/xmatic-squad/bare/internal/store"
)
// Параметры argon2id из ADR-021. Вход — 32 случайных байта с точки зрения
// сервера, поэтому параметры умеренные.
const (
SaltLen = 16 // байт
KeyLen = 32 // байт
)
// Params — параметры одного хеша. Пишутся рядом с ним в users.auth_params
// и читаются оттуда при проверке: повышение параметров — перехеш при
// очередном входе, а не миграция всех аккаунтов разом.
type Params struct {
Memory uint32 // КиБ
Time uint32
Threads uint8
}
// Current — параметры для новых хешей.
var Current = Params{Memory: 19456, Time: 2, Threads: 1}
// String — форма записи в базе: "argon2id,m=19456,t=2,p=1".
func (p Params) String() string {
return fmt.Sprintf("argon2id,m=%d,t=%d,p=%d", p.Memory, p.Time, p.Threads)
}
// ParseParams разбирает строку из users.auth_params.
func ParseParams(s string) (Params, error) {
var p Params
n, err := fmt.Sscanf(s, "argon2id,m=%d,t=%d,p=%d", &p.Memory, &p.Time, &p.Threads)
if err != nil || n != 3 {
return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s)
}
if p.Memory == 0 || p.Time == 0 || p.Threads == 0 {
return Params{}, fmt.Errorf("auth: нулевой параметр в %q", s)
}
// Sscanf не жалуется на хвост после последнего числа; сверка с обратной
// записью делает разбор точным.
if p.String() != s {
return Params{}, fmt.Errorf("auth: не разобрать параметры %q", s)
}
return p, nil
}
// Hash считает argon2id от authKey с текущими параметрами и новой солью.
func Hash(authKey []byte) (store.Credential, error) {
salt := make([]byte, SaltLen)
if _, err := rand.Read(salt); err != nil {
return store.Credential{}, fmt.Errorf("auth: соль: %w", err)
}
return store.Credential{
Hash: derive(authKey, salt, Current),
Salt: salt,
Params: Current.String(),
}, nil
}
// Verify сверяет authKey с хешем из базы. Второе значение — нужен ли
// перехеш: параметры записи отстали от текущих.
func Verify(authKey []byte, cred store.Credential) (ok, rehash bool) {
p, err := ParseParams(cred.Params)
if err != nil || len(cred.Hash) != KeyLen || len(cred.Salt) == 0 {
return false, false
}
got := derive(authKey, cred.Salt, p)
if subtle.ConstantTimeCompare(got, cred.Hash) != 1 {
return false, false
}
return true, p != Current || len(cred.Salt) != SaltLen
}
// Waste считает столько же, сколько Verify, и выбрасывает результат.
// Вход с несуществующим ником не должен отвечать заметно быстрее входа
// с неверным authKey: одна ошибка на все случаи (docs/protocol.md).
func Waste(authKey []byte) {
derive(authKey, make([]byte, SaltLen), Current)
}
func derive(authKey, salt []byte, p Params) []byte {
return argon2.IDKey(authKey, salt, p.Time, p.Memory, p.Threads, KeyLen)
}
+85
View File
@@ -0,0 +1,85 @@
package auth
import (
"crypto/sha256"
"encoding/base64"
"testing"
"github.com/xmatic-squad/bare/internal/store"
)
func TestParams(t *testing.T) {
if got := Current.String(); got != "argon2id,m=19456,t=2,p=1" {
t.Errorf("запись параметров: получено %q", got)
}
got, err := ParseParams("argon2id,m=19456,t=2,p=1")
if err != nil || got != Current {
t.Errorf("разбор параметров: получено %+v, %v", got, err)
}
for _, bad := range []string{"", "argon2id", "argon2i,m=1,t=1,p=1", "argon2id,m=0,t=2,p=1", "argon2id,m=19456,t=2,p=1,junk"} {
if _, err := ParseParams(bad); err == nil {
t.Errorf("%q разобрано, ожидалась ошибка", bad)
}
}
}
func TestHashVerify(t *testing.T) {
key := []byte("тридцать два байта authKey, ну почти")
cred, err := Hash(key)
if err != nil {
t.Fatalf("Hash: %v", err)
}
if len(cred.Hash) != KeyLen || len(cred.Salt) != SaltLen || cred.Params != Current.String() {
t.Fatalf("хеш: %d байт, соль %d байт, параметры %q", len(cred.Hash), len(cred.Salt), cred.Params)
}
if ok, rehash := Verify(key, cred); !ok || rehash {
t.Errorf("верный authKey: ok=%v rehash=%v", ok, rehash)
}
if ok, _ := Verify([]byte("другой ключ"), cred); ok {
t.Error("неверный authKey принят")
}
// Битая строка параметров — не повод считать хеш подошедшим.
broken := cred
broken.Params = "argon2id"
if ok, _ := Verify(key, broken); ok {
t.Error("хеш с неразобранными параметрами принят")
}
}
func TestVerifyAsksForRehash(t *testing.T) {
key := []byte("authKey")
old := Params{Memory: 8192, Time: 1, Threads: 1}
cred := store.Credential{
Hash: derive(key, make([]byte, SaltLen), old),
Salt: make([]byte, SaltLen),
Params: old.String(),
}
ok, rehash := Verify(key, cred)
if !ok || !rehash {
t.Errorf("устаревшие параметры: ok=%v rehash=%v, ожидалось true/true", ok, rehash)
}
}
func TestToken(t *testing.T) {
token, hash, err := NewToken()
if err != nil {
t.Fatalf("NewToken: %v", err)
}
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != TokenLen {
t.Fatalf("токен: %q (%v)", token, err)
}
sum := sha256.Sum256(raw)
if string(hash) != string(sum[:]) {
t.Error("в базу уходит не sha-256 токена")
}
got, ok := TokenHash(token)
if !ok || string(got) != string(hash) {
t.Error("TokenHash не совпал с NewToken")
}
for _, bad := range []string{"", "не base64!", base64.RawURLEncoding.EncodeToString([]byte("коротко"))} {
if _, ok := TokenHash(bad); ok {
t.Errorf("мусор %q принят за токен", bad)
}
}
}
+139
View File
@@ -0,0 +1,139 @@
package auth
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"time"
"github.com/xmatic-squad/bare/internal/store"
)
// Сессия по ADR-021: токен 32 случайных байта, в базе SHA-256 от него,
// в cookie — base64url. Срок 90 дней без продления.
const (
CookieName = "bare_session"
TokenLen = 32
TTL = 90 * 24 * time.Hour
)
// NewToken выдаёт токен для cookie и его SHA-256 для базы.
func NewToken() (token string, hash []byte, err error) {
raw := make([]byte, TokenLen)
if _, err := rand.Read(raw); err != nil {
return "", nil, fmt.Errorf("auth: токен: %w", err)
}
sum := sha256.Sum256(raw)
return base64.RawURLEncoding.EncodeToString(raw), sum[:], nil
}
// TokenHash разбирает токен из cookie в его SHA-256. Мусор — false.
func TokenHash(token string) ([]byte, bool) {
raw, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || len(raw) != TokenLen {
return nil, false
}
sum := sha256.Sum256(raw)
return sum[:], true
}
// SetCookie ставит cookie сессии. Secure стоит всегда: браузеры считают
// localhost и 127.0.0.1 доверенным происхождением, поэтому локальная
// разработка по http этим не ломается.
func SetCookie(w http.ResponseWriter, token string, expires time.Time) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: token,
Path: "/",
Expires: expires,
MaxAge: int(time.Until(expires).Seconds()),
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// ClearCookie стирает cookie сессии.
func ClearCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: CookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
// Fail — как auth отвечает на отказ. Тело ошибки в форме протокола
// собирает internal/api (ADR-026), а импортировать его отсюда нельзя:
// api импортирует auth. Поэтому хелперы передаются значениями.
type Fail struct {
// Error пишет ошибку протокола: статус, код, текст для человека.
Error func(w http.ResponseWriter, status int, code, message string)
// Internal пишет 500 и кладёт причину в журнал сервера.
Internal func(w http.ResponseWriter, r *http.Request, err error)
}
// Origin — второй барьер CSRF рядом с SameSite=Strict (ADR-021).
// На всех методах кроме GET и HEAD заголовок Origin обязан равняться
// origin сервера; отсутствующий Origin — тоже отказ.
func Origin(origin string, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
if r.Header.Get("Origin") != origin {
fail.Error(w, http.StatusForbidden, "bad_origin", "запрос не с этого сайта")
return
}
}
next.ServeHTTP(w, r)
})
}
}
// Require пропускает дальше только запросы с живой сессией и кладёт её
// в контекст. Без сессии — 401 unauthenticated.
func Require(st *store.Store, fail Fail) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sess, err := session(r, st)
if errors.Is(err, store.ErrNotFound) {
fail.Error(w, http.StatusUnauthorized, "unauthenticated", "нужен вход")
return
}
if err != nil {
fail.Internal(w, r, err)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), sessionKey{}, sess)))
})
}
}
// session читает cookie и находит сессию. Нет cookie, мусор в ней
// и истёкшая сессия неразличимы: store.ErrNotFound.
func session(r *http.Request, st *store.Store) (store.Session, error) {
c, err := r.Cookie(CookieName)
if err != nil {
return store.Session{}, store.ErrNotFound
}
hash, ok := TokenHash(c.Value)
if !ok {
return store.Session{}, store.ErrNotFound
}
return st.Session(r.Context(), hash, time.Now().UnixMilli())
}
type sessionKey struct{}
// From отдаёт сессию из контекста. Её кладёт Require.
func From(r *http.Request) (store.Session, bool) {
sess, ok := r.Context().Value(sessionKey{}).(store.Session)
return sess, ok
}
+16
View File
@@ -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) {
+69
View File
@@ -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
}
+73
View File
@@ -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);
+58
View File
@@ -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
}
+149
View File
@@ -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
}
+213
View File
@@ -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
}
+119
View File
@@ -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
}