Files
mayatnikovandClaude Opus 5 597c55301c Этап 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
2026-08-22 14:06:07 +03:00

59 lines
2.1 KiB
Go

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
}