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,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
|
||||
}
|
||||
Reference in New Issue
Block a user