Files
grendervill 06e313dcc6 feat(crypto): крипто-слой Фазы 1 — Argon2id+pepper, AES-256-GCM, blind index
- internal/crypto/password.go: Argon2id с параметрами под 1 vCPU (OWASP),
  обязательный pepper (HMAC-SHA-256 до Argon2id), разбор формата хэша,
  NeedsRehash для калибровки параметров
- internal/crypto/secretbox.go: AES-256-GCM с версионированным ключом (v1:),
  разделение мастер-ключа на подключи шифрования и blind index (HMAC-SHA-256),
  blind index для поиска по email без расшифровки (§9.2)
- internal/crypto/tokens.go: opaque-токены сессий (хранится только SHA-256),
  резервные коды 2FA, нормализация email, политика пароля и username
- тесты: соль, pepper, версии ключа, чужие шифротексты, стабильность blind index
- go.mod: добавлены chi v5, huma v2, pquerna/otp (подключаются следующими шагами)
2026-09-19 21:17:52 +03:00

179 lines
6.0 KiB
Go

// Package crypto объединяет криптографические примитивы glchat: хэширование
// паролей, шифрование PII, blind index и генерацию токенов (AGENT.md 7.1, 9.2).
package crypto
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"errors"
"fmt"
"strconv"
"strings"
"golang.org/x/crypto/argon2"
)
// Argon2Params — параметры Argon2id, калибруемые под железо (AGENT.md 9.1).
// Значения по умолчанию соответствуют рекомендации OWASP для 1 vCPU.
type Argon2Params struct {
Memory uint32
Iterations uint32
Parallelism uint8
SaltLength uint32
KeyLength uint32
}
func DefaultArgon2Params() Argon2Params {
return Argon2Params{
Memory: 19456,
Iterations: 2,
Parallelism: 1,
SaltLength: 16,
KeyLength: 32,
}
}
var (
ErrInvalidHashFormat = errors.New("password hash has an unsupported format")
ErrPasswordMismatch = errors.New("password does not match")
ErrEmptyPepper = errors.New("password pepper must not be empty")
)
// PasswordHasher хэширует пароли Argon2id с обязательным pepper: pepper
// подмешивается HMAC-SHA-256 до Argon2id и хранится вне БД (AGENT.md 7.1).
type PasswordHasher struct {
pepper []byte
params Argon2Params
}
func NewPasswordHasher(pepper []byte, params Argon2Params) (*PasswordHasher, error) {
if len(pepper) == 0 {
return nil, ErrEmptyPepper
}
if params.Memory == 0 || params.Iterations == 0 || params.Parallelism == 0 {
params = DefaultArgon2Params()
}
return &PasswordHasher{pepper: pepper, params: params}, nil
}
// Hash возвращает строку формата argon2id$v=19$m=..,t=..,p=..$salt$hash.
func (h *PasswordHasher) Hash(password string) (string, error) {
salt := make([]byte, h.params.SaltLength)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("generate salt: %w", err)
}
sum := h.derive(password, salt)
return fmt.Sprintf(
"argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
argon2.Version,
h.params.Memory,
h.params.Iterations,
h.params.Parallelism,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(sum),
), nil
}
// Verify сравнивает пароль с сохранённым хэшем за постоянное время.
func (h *PasswordHasher) Verify(password, encoded string) error {
params, salt, want, err := parseHash(encoded)
if err != nil {
return err
}
if len(want) == 0 || len(want) > maxKeyLength {
return ErrInvalidHashFormat
}
// len(want) ограничен maxKeyLength выше.
keyLength := uint32(len(want)) //nolint:gosec // диапазон проверен: 0 < len <= maxKeyLength
got := argon2.IDKey(h.peppered(password), salt, params.Iterations, params.Memory, params.Parallelism, keyLength)
if subtle.ConstantTimeCompare(got, want) != 1 {
return ErrPasswordMismatch
}
return nil
}
// NeedsRehash сообщает, что хэш посчитан с другими параметрами.
func (h *PasswordHasher) NeedsRehash(encoded string) bool {
params, _, _, err := parseHash(encoded)
if err != nil {
return true
}
return params != h.params
}
func (h *PasswordHasher) derive(password string, salt []byte) []byte {
return argon2.IDKey(h.peppered(password), salt, h.params.Iterations, h.params.Memory, h.params.Parallelism, h.params.KeyLength)
}
func (h *PasswordHasher) peppered(password string) []byte {
mac := hmac.New(sha256.New, h.pepper)
mac.Write([]byte(password))
return mac.Sum(nil)
}
func parseHash(encoded string) (Argon2Params, []byte, []byte, error) {
parts := strings.Split(encoded, "$")
if len(parts) != 5 || parts[0] != "argon2id" {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
version, err := strconv.Atoi(strings.TrimPrefix(parts[1], "v="))
if err != nil || version != argon2.Version {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
var params Argon2Params
for _, field := range strings.Split(parts[2], ",") {
key, value, ok := strings.Cut(field, "=")
if !ok {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
parsed, err := strconv.ParseUint(value, 10, 32)
if err != nil {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
switch key {
case "m":
params.Memory = uint32(parsed)
case "t":
params.Iterations = uint32(parsed)
case "p":
// Параллелизм Argon2id ограничен 255 по спецификации; значение
// из хэша получено нами же, поэтому достаточно явного ограничения.
if parsed == 0 || parsed > maxParallelism {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
parallelism := uint8(parsed)
params.Parallelism = parallelism
default:
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
}
salt, err := base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
sum, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
if len(salt) == 0 || len(salt) > maxSaltLength || len(sum) == 0 || len(sum) > maxKeyLength {
return Argon2Params{}, nil, nil, ErrInvalidHashFormat
}
// Обе длины ограничены maxSaltLength/maxKeyLength в проверке выше.
params.SaltLength = uint32(len(salt)) //nolint:gosec // диапазон проверен выше
params.KeyLength = uint32(len(sum)) //nolint:gosec // диапазон проверен выше
return params, salt, sum, nil
}
// Ограничения, вытекающие из спецификации Argon2id: параллелизм — uint8,
// длины соли и ключа хранятся в uint32 (реалистичные значения много меньше).
const (
maxParallelism = 255
maxSaltLength = 1024
maxKeyLength = 1024
)