Этап 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,123 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/xmatic-squad/bare/internal/config"
|
||||
)
|
||||
|
||||
// Сервер не умеет и не пытается проверять шифротексты. Он проверяет форму:
|
||||
// base64url, длины, версии (docs/crypto.md, «Что сервер проверяет»).
|
||||
const (
|
||||
authKeyLen = 32 // байт
|
||||
ivLen = 12 // байт
|
||||
minCTLen = 16 // байт: короче тега AES-GCM шифротекста не бывает
|
||||
maxBlob = 8 << 10 // ключевой блоб, docs/protocol.md
|
||||
)
|
||||
|
||||
// b64 — кодировка бинарных полей протокола: base64url без паддинга.
|
||||
var b64 = base64.RawURLEncoding
|
||||
|
||||
// nickRe — ник по ADR-019: только строчные, без регистровых коллизий.
|
||||
var nickRe = regexp.MustCompile(`^[a-z0-9_]{2,32}$`)
|
||||
|
||||
func validNick(nick string) bool { return nickRe.MatchString(nick) }
|
||||
|
||||
// decodeExactly разбирает base64url и требует ровно n байт.
|
||||
func decodeExactly(s string, n int) ([]byte, bool) {
|
||||
raw, err := b64.DecodeString(s)
|
||||
if err != nil || len(raw) != n {
|
||||
return nil, false
|
||||
}
|
||||
return raw, true
|
||||
}
|
||||
|
||||
// authKey разбирает authKey клиента: base64url ровно 32 байта.
|
||||
func authKey(s string) ([]byte, bool) { return decodeExactly(s, authKeyLen) }
|
||||
|
||||
// jwkPublic — публичный ключ в том виде, в каком сервер его хранит
|
||||
// и отдаёт: четыре поля и ничего больше.
|
||||
type jwkPublic struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
}
|
||||
|
||||
// publicKeyJSON проверяет JWK и отдаёт его канонический JSON.
|
||||
//
|
||||
// Поле d — приватный ключ. Его наличие означает, что клиент собирается
|
||||
// отдать серверу материал, которого у сервера не должно быть ни при каких
|
||||
// условиях, поэтому такой запрос отвергается целиком, а не чистится молча.
|
||||
// Всё, что не kty, crv, x и y, отбрасывается: хранится ровно то, что нужно.
|
||||
func publicKeyJSON(raw json.RawMessage) (string, error) {
|
||||
var in struct {
|
||||
Kty string `json:"kty"`
|
||||
Crv string `json:"crv"`
|
||||
X string `json:"x"`
|
||||
Y string `json:"y"`
|
||||
D json.RawMessage `json:"d"`
|
||||
}
|
||||
if len(raw) == 0 {
|
||||
return "", errors.New("нет публичного ключа")
|
||||
}
|
||||
if err := json.Unmarshal(raw, &in); err != nil {
|
||||
return "", errors.New("публичный ключ — не jwk")
|
||||
}
|
||||
if in.D != nil {
|
||||
return "", errors.New("приватному ключу на сервере не место")
|
||||
}
|
||||
if in.Kty != "EC" || in.Crv != "P-256" {
|
||||
return "", errors.New("ожидается ключ ec p-256")
|
||||
}
|
||||
if _, ok := decodeExactly(in.X, 32); !ok {
|
||||
return "", errors.New("x — не 32 байта base64url")
|
||||
}
|
||||
if _, ok := decodeExactly(in.Y, 32); !ok {
|
||||
return "", errors.New("y — не 32 байта base64url")
|
||||
}
|
||||
out, err := json.Marshal(jwkPublic{Kty: in.Kty, Crv: in.Crv, X: in.X, Y: in.Y})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
// blobIterations проверяет форму ключевого блоба (docs/crypto.md,
|
||||
// «Ключевой блоб») и отдаёт iter. Это единственное поле блоба, которое
|
||||
// сервер читает: его же отдаёт GET /api/kdf. Всё остальное — непрозрачный
|
||||
// шифротекст.
|
||||
func blobIterations(blob string) (int, error) {
|
||||
if blob == "" {
|
||||
return 0, errors.New("нет ключевого блоба")
|
||||
}
|
||||
if len(blob) > maxBlob {
|
||||
return 0, errors.New("ключевой блоб больше 8 КиБ")
|
||||
}
|
||||
var b struct {
|
||||
V int `json:"v"`
|
||||
Iter int `json:"iter"`
|
||||
IV string `json:"iv"`
|
||||
CT string `json:"ct"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(blob), &b); err != nil {
|
||||
return 0, errors.New("ключевой блоб — не json")
|
||||
}
|
||||
if b.V != 1 {
|
||||
return 0, fmt.Errorf("версия блоба %d, ожидается 1", b.V)
|
||||
}
|
||||
if b.Iter < config.KDFMinIterations || b.Iter > config.KDFMaxIterations {
|
||||
return 0, fmt.Errorf("iter вне границ %d…%d", config.KDFMinIterations, config.KDFMaxIterations)
|
||||
}
|
||||
if _, ok := decodeExactly(b.IV, ivLen); !ok {
|
||||
return 0, errors.New("iv — не 12 байт base64url")
|
||||
}
|
||||
if ct, err := b64.DecodeString(b.CT); err != nil || len(ct) < minCTLen {
|
||||
return 0, errors.New("ct — не base64url или слишком короткий")
|
||||
}
|
||||
return b.Iter, nil
|
||||
}
|
||||
Reference in New Issue
Block a user