Этап 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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user