Сервер: миграция 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
120 lines
5.0 KiB
Go
120 lines
5.0 KiB
Go
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
|
|
}
|