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