Этап 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
+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
}