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 (подключаются следующими шагами)
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// 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
|
||||
)
|
||||
Reference in New Issue
Block a user