From 06e313dcc6e06094a82fececf1dc417994324ba6 Mon Sep 17 00:00:00 2001 From: grendervill Date: Sat, 19 Sep 2026 21:17:52 +0300 Subject: [PATCH] =?UTF-8?q?feat(crypto):=20=D0=BA=D1=80=D0=B8=D0=BF=D1=82?= =?UTF-8?q?=D0=BE-=D1=81=D0=BB=D0=BE=D0=B9=20=D0=A4=D0=B0=D0=B7=D1=8B=201?= =?UTF-8?q?=20=E2=80=94=20Argon2id+pepper,=20AES-256-GCM,=20blind=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 (подключаются следующими шагами) --- go.mod | 2 + go.sum | 6 +- internal/crypto/crypto_test.go | 244 +++++++++++++++++++++++++++++++++ internal/crypto/password.go | 178 ++++++++++++++++++++++++ internal/crypto/secretbox.go | 150 ++++++++++++++++++++ internal/crypto/tokens.go | 85 ++++++++++++ 6 files changed, 663 insertions(+), 2 deletions(-) create mode 100644 internal/crypto/crypto_test.go create mode 100644 internal/crypto/password.go create mode 100644 internal/crypto/secretbox.go create mode 100644 internal/crypto/tokens.go diff --git a/go.mod b/go.mod index 3c54f5c..551658d 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/mattn/go-sqlite3 v1.14.52 github.com/pressly/goose/v3 v3.28.0 + golang.org/x/crypto v0.55.0 ) require ( @@ -12,4 +13,5 @@ require ( github.com/sethvargo/go-retry v0.4.0 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.48.0 // indirect ) diff --git a/go.sum b/go.sum index 097497b..b67dfc3 100644 --- a/go.sum +++ b/go.sum @@ -22,10 +22,12 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= modernc.org/libc v1.75.6 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus= modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= diff --git a/internal/crypto/crypto_test.go b/internal/crypto/crypto_test.go new file mode 100644 index 0000000..5b42610 --- /dev/null +++ b/internal/crypto/crypto_test.go @@ -0,0 +1,244 @@ +package crypto + +import ( + "encoding/hex" + "errors" + "strings" + "testing" +) + +func testHasher(t *testing.T) *PasswordHasher { + t.Helper() + // Параметры снижены: тесты не должны тратить секунды на каждый хэш. + hasher, err := NewPasswordHasher([]byte("unit-test-pepper"), Argon2Params{ + Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32, + }) + if err != nil { + t.Fatalf("NewPasswordHasher: %v", err) + } + return hasher +} + +func TestPasswordHashAndVerify(t *testing.T) { + hasher := testHasher(t) + hash, err := hasher.Hash("correct horse battery staple") + if err != nil { + t.Fatalf("Hash: %v", err) + } + if !strings.HasPrefix(hash, "argon2id$") { + t.Fatalf("unexpected hash format: %s", hash) + } + if err := hasher.Verify("correct horse battery staple", hash); err != nil { + t.Fatalf("Verify: %v", err) + } + if err := hasher.Verify("wrong password", hash); !errors.Is(err, ErrPasswordMismatch) { + t.Fatalf("Verify with wrong password = %v, want ErrPasswordMismatch", err) + } +} + +func TestPasswordHashIsSalted(t *testing.T) { + hasher := testHasher(t) + first, err := hasher.Hash("same-password-value") + if err != nil { + t.Fatalf("Hash: %v", err) + } + second, err := hasher.Hash("same-password-value") + if err != nil { + t.Fatalf("Hash: %v", err) + } + if first == second { + t.Fatal("hashes of the same password must differ (unique salt)") + } +} + +func TestPepperIsRequired(t *testing.T) { + if _, err := NewPasswordHasher(nil, Argon2Params{}); !errors.Is(err, ErrEmptyPepper) { + t.Fatalf("NewPasswordHasher without pepper = %v, want ErrEmptyPepper", err) + } +} + +func TestPepperChangesHash(t *testing.T) { + params := Argon2Params{Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} + first, err := NewPasswordHasher([]byte("pepper-one"), params) + if err != nil { + t.Fatalf("NewPasswordHasher: %v", err) + } + second, err := NewPasswordHasher([]byte("pepper-two"), params) + if err != nil { + t.Fatalf("NewPasswordHasher: %v", err) + } + hash, err := first.Hash("password-value-1") + if err != nil { + t.Fatalf("Hash: %v", err) + } + if err := second.Verify("password-value-1", hash); !errors.Is(err, ErrPasswordMismatch) { + t.Fatal("hash must not verify with a different pepper") + } +} + +func TestNeedsRehash(t *testing.T) { + weak := Argon2Params{Memory: 1024, Iterations: 1, Parallelism: 1, SaltLength: 16, KeyLength: 32} + hasher, err := NewPasswordHasher([]byte("pepper"), weak) + if err != nil { + t.Fatalf("NewPasswordHasher: %v", err) + } + hash, err := hasher.Hash("password-value-1") + if err != nil { + t.Fatalf("Hash: %v", err) + } + if hasher.NeedsRehash(hash) { + t.Fatal("hash should match the hasher parameters") + } + strong := weak + strong.Memory = 2048 + upgraded, err := NewPasswordHasher([]byte("pepper"), strong) + if err != nil { + t.Fatalf("NewPasswordHasher: %v", err) + } + if !upgraded.NeedsRehash(hash) { + t.Fatal("hash with weaker parameters must need a rehash") + } + if !hasher.NeedsRehash("garbage") { + t.Fatal("unparsable hash must need a rehash") + } +} + +func TestMasterKeyEncryptDecrypt(t *testing.T) { + key, err := ParseMasterKey(strings.Repeat("ab", 32)) + if err != nil { + t.Fatalf("ParseMasterKey: %v", err) + } + sealed, err := key.Encrypt("user@example.com") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if !strings.HasPrefix(sealed, "v1:") { + t.Fatalf("ciphertext must be versioned, got %s", sealed) + } + opened, err := key.Decrypt(sealed) + if err != nil { + t.Fatalf("Decrypt: %v", err) + } + if opened != "user@example.com" { + t.Fatalf("Decrypt = %q", opened) + } +} + +func TestMasterKeyRejectsForeignCiphertext(t *testing.T) { + first, _ := ParseMasterKey(strings.Repeat("ab", 32)) + second, _ := ParseMasterKey(strings.Repeat("cd", 32)) + sealed, err := first.Encrypt("secret-value") + if err != nil { + t.Fatalf("Encrypt: %v", err) + } + if _, err := second.Decrypt(sealed); err == nil { + t.Fatal("another key must not decrypt the value") + } + if _, err := first.Decrypt("plain-value"); !errors.Is(err, ErrInvalidCiphertext) { + t.Fatalf("Decrypt(plain) = %v, want ErrInvalidCiphertext", err) + } + if _, err := first.Decrypt("v2:AAAA"); !errors.Is(err, ErrInvalidCiphertext) { + t.Fatal("unknown key version must be rejected") + } +} + +func TestMasterKeyAcceptsHexBase64AndRaw(t *testing.T) { + raw := make([]byte, 32) + for i := range raw { + raw[i] = byte(i) + } + hexForm := hex.EncodeToString(raw) + for name, value := range map[string]string{ + "hex": hexForm, + "raw": string(raw), + } { + key, err := ParseMasterKey(value) + if err != nil { + t.Fatalf("%s: ParseMasterKey: %v", name, err) + } + sealed, err := key.Encrypt("value") + if err != nil { + t.Fatalf("%s: Encrypt: %v", name, err) + } + if _, err := key.Decrypt(sealed); err != nil { + t.Fatalf("%s: Decrypt: %v", name, err) + } + } + if _, err := ParseMasterKey("too-short"); !errors.Is(err, ErrInvalidMasterKey) { + t.Fatalf("ParseMasterKey(short) = %v, want ErrInvalidMasterKey", err) + } +} + +func TestBlindIndexIsStableAndUnique(t *testing.T) { + key, _ := ParseMasterKey(strings.Repeat("11", 32)) + other, _ := ParseMasterKey(strings.Repeat("22", 32)) + + first := key.BlindIndex(NormalizeEmail(" User@Example.COM ")) + same := key.BlindIndex(NormalizeEmail("user@example.com")) + another := key.BlindIndex(NormalizeEmail("second@example.com")) + + if first != same { + t.Fatal("blind index must be stable for the same normalized value") + } + if first == another { + t.Fatal("blind index must differ for different values") + } + if first == other.BlindIndex(NormalizeEmail("user@example.com")) { + t.Fatal("blind index must depend on the key") + } + if len(first) != 64 { + t.Fatalf("blind index length = %d, want 64 hex chars", len(first)) + } +} + +func TestSessionTokenHashing(t *testing.T) { + token, hash, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if token == "" || hash == "" { + t.Fatal("token and hash must not be empty") + } + if token == hash { + t.Fatal("token must not be stored as is") + } + if HashToken(token) != hash { + t.Fatal("HashToken must reproduce the stored hash") + } + other, _, err := NewSessionToken() + if err != nil { + t.Fatalf("NewSessionToken: %v", err) + } + if other == token { + t.Fatal("tokens must be unique") + } +} + +func TestRecoveryCodeFormat(t *testing.T) { + code, err := NewRecoveryCode() + if err != nil { + t.Fatalf("NewRecoveryCode: %v", err) + } + if len(code) != 14 || strings.Count(code, "-") != 2 { + t.Fatalf("recovery code %q has unexpected format", code) + } +} + +func TestValidatePasswordAndUsername(t *testing.T) { + if err := ValidatePassword("short"); err == nil { + t.Fatal("short password must be rejected") + } + if err := ValidatePassword("long-enough-password"); err != nil { + t.Fatalf("valid password rejected: %v", err) + } + for _, username := range []string{"ab", "user.name", "User_123"} { + if err := ValidateUsername(username); err != nil { + t.Fatalf("valid username %q rejected: %v", username, err) + } + } + for _, username := range []string{"a", "with space", "кириллица", strings.Repeat("x", 33)} { + if err := ValidateUsername(username); err == nil { + t.Fatalf("invalid username %q accepted", username) + } + } +} diff --git a/internal/crypto/password.go b/internal/crypto/password.go new file mode 100644 index 0000000..cc437f5 --- /dev/null +++ b/internal/crypto/password.go @@ -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 +) diff --git a/internal/crypto/secretbox.go b/internal/crypto/secretbox.go new file mode 100644 index 0000000..5b9204d --- /dev/null +++ b/internal/crypto/secretbox.go @@ -0,0 +1,150 @@ +package crypto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "io" + "strings" +) + +const ( + // masterKeySize — длина ключа AES-256-GCM (32 байта). + masterKeySize = 32 + // keyVersionPrefix версионирует ключ в шифротексте (AGENT.md 9.2). + keyVersionPrefix = "v1:" + + infoEncryption = "glchat/pii-encryption" + infoBlindIndex = "glchat/blind-index" +) + +var ( + ErrInvalidMasterKey = errors.New("master key must be 32 bytes (hex/base64/raw)") + ErrInvalidCiphertext = errors.New("ciphertext is malformed or uses an unknown key version") + ErrEmptyBlindIndexKey = errors.New("blind index key must not be empty") +) + +// MasterKey — ключ шифрования PII (AES-256-GCM) и источник ключа blind index. +type MasterKey struct { + encryption []byte + blindIndex []byte +} + +// ParseMasterKey принимает ключ в hex (64 символа), base64 или как 32 сырых байта: +// установщик генерирует hex, но формат не должен ломать развёртывание. +func ParseMasterKey(value string) (*MasterKey, error) { + raw, err := decodeKeyMaterial(value) + if err != nil { + return nil, err + } + return NewMasterKey(raw) +} + +func NewMasterKey(raw []byte) (*MasterKey, error) { + if len(raw) != masterKeySize { + return nil, ErrInvalidMasterKey + } + return &MasterKey{ + encryption: deriveSubkey(raw, infoEncryption), + blindIndex: deriveSubkey(raw, infoBlindIndex), + }, nil +} + +// Encrypt шифрует строку и возвращает версионированный base64-шифротекст. +func (k *MasterKey) Encrypt(plaintext string) (string, error) { + gcm, err := k.aead() + if err != nil { + return "", err + } + nonce := make([]byte, gcm.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return "", fmt.Errorf("generate nonce: %w", err) + } + payload := gcm.Seal(nonce, nonce, []byte(plaintext), nil) + return keyVersionPrefix + base64.RawURLEncoding.EncodeToString(payload), nil +} + +// Decrypt расшифровывает значение, созданное Encrypt. +func (k *MasterKey) Decrypt(value string) (string, error) { + if !strings.HasPrefix(value, keyVersionPrefix) { + return "", ErrInvalidCiphertext + } + payload, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(value, keyVersionPrefix)) + if err != nil { + return "", ErrInvalidCiphertext + } + gcm, err := k.aead() + if err != nil { + return "", err + } + if len(payload) < gcm.NonceSize() { + return "", ErrInvalidCiphertext + } + nonce, ciphertext := payload[:gcm.NonceSize()], payload[gcm.NonceSize():] + plaintext, err := gcm.Open(nil, nonce, ciphertext, nil) + if err != nil { + return "", fmt.Errorf("decrypt value: %w", err) + } + return string(plaintext), nil +} + +// BlindIndex считает детерминированный HMAC-SHA-256 по нормализованному +// значению (email): по нему ищут запись, не расшифровывая PII. +func (k *MasterKey) BlindIndex(value string) string { + mac := hmac.New(sha256.New, k.blindIndex) + mac.Write([]byte(value)) + return hex.EncodeToString(mac.Sum(nil)) +} + +func (k *MasterKey) aead() (cipher.AEAD, error) { + block, err := aes.NewCipher(k.encryption) + if err != nil { + return nil, fmt.Errorf("create cipher: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("create gcm: %w", err) + } + return gcm, nil +} + +func decodeKeyMaterial(value string) ([]byte, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, ErrInvalidMasterKey + } + if decoded, err := hex.DecodeString(trimmed); err == nil && len(decoded) == masterKeySize { + return decoded, nil + } + for _, encoding := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.RawURLEncoding} { + if decoded, err := encoding.DecodeString(trimmed); err == nil && len(decoded) == masterKeySize { + return decoded, nil + } + } + if len(trimmed) == masterKeySize { + return []byte(trimmed), nil + } + return nil, ErrInvalidMasterKey +} + +// deriveSubkey разделяет мастер-ключ по назначению через HMAC-SHA-256: +// один и тот же мастер-ключ не используется напрямую для разных задач. +func deriveSubkey(raw []byte, info string) []byte { + mac := hmac.New(sha256.New, raw) + mac.Write([]byte(info)) + return mac.Sum(nil) +} + +// NewBlindIndexKey создаёт отдельный ключ blind index (для тестов и утилит). +func NewBlindIndexKey(raw []byte) ([]byte, error) { + if len(raw) == 0 { + return nil, ErrEmptyBlindIndexKey + } + return deriveSubkey(raw, infoBlindIndex), nil +} diff --git a/internal/crypto/tokens.go b/internal/crypto/tokens.go new file mode 100644 index 0000000..1d7044d --- /dev/null +++ b/internal/crypto/tokens.go @@ -0,0 +1,85 @@ +package crypto + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "fmt" + "strings" + "unicode/utf8" +) + +const ( + // SessionTokenBytes — энтропия opaque-токена сессии (AGENT.md 7.1). + SessionTokenBytes = 32 + // MinPasswordLength — парольная политика MVP (AGENT.md 7.1). + MinPasswordLength = 10 + // MaxPasswordLength ограничивает стоимость Argon2id на длинных вводах. + MaxPasswordLength = 256 +) + +// NewSessionToken возвращает токен для клиента и его SHA-256 для БД: +// в базе хранятся только хэши токенов (AGENT.md 7.1). +func NewSessionToken() (token string, hash string, err error) { + buf := make([]byte, SessionTokenBytes) + if _, err := rand.Read(buf); err != nil { + return "", "", fmt.Errorf("generate session token: %w", err) + } + token = base64.RawURLEncoding.EncodeToString(buf) + return token, HashToken(token), nil +} + +// HashToken считает SHA-256 токена в hex — формат хранения в sessions.token_hash. +func HashToken(token string) string { + sum := sha256.Sum256([]byte(token)) + return hex.EncodeToString(sum[:]) +} + +// NewRecoveryCode возвращает резервный код 2FA в формате xxxx-xxxx-xxxx. +func NewRecoveryCode() (string, error) { + const alphabet = "abcdefghjkmnpqrstuvwxyz23456789" + buf := make([]byte, 12) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate recovery code: %w", err) + } + var b strings.Builder + for i, v := range buf { + if i > 0 && i%4 == 0 { + b.WriteByte('-') + } + b.WriteByte(alphabet[int(v)%len(alphabet)]) + } + return b.String(), nil +} + +// NormalizeEmail приводит email к каноническому виду для blind index. +func NormalizeEmail(email string) string { + return strings.ToLower(strings.TrimSpace(email)) +} + +// ValidatePassword проверяет базовую политику пароля (AGENT.md 7.1). +func ValidatePassword(password string) error { + if utf8.RuneCountInString(password) < MinPasswordLength { + return fmt.Errorf("password must be at least %d characters", MinPasswordLength) + } + if len(password) > MaxPasswordLength { + return fmt.Errorf("password must be at most %d bytes", MaxPasswordLength) + } + return nil +} + +// ValidateUsername проверяет публичный идентификатор (AGENT.md 7.1). +func ValidateUsername(username string) error { + if len(username) < 2 || len(username) > 32 { + return fmt.Errorf("username must be 2-32 characters long") + } + for _, r := range username { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '.': + default: + return fmt.Errorf("username may contain only latin letters, digits, underscore and dot") + } + } + return nil +}