// 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 )