179 lines
6.0 KiB
Go
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
|
||
|
|
)
|