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