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:
2026-09-19 21:17:52 +03:00
parent a671027461
commit 06e313dcc6
6 changed files with 663 additions and 2 deletions
+2
View File
@@ -5,6 +5,7 @@ go 1.26.0
require ( require (
github.com/mattn/go-sqlite3 v1.14.52 github.com/mattn/go-sqlite3 v1.14.52
github.com/pressly/goose/v3 v3.28.0 github.com/pressly/goose/v3 v3.28.0
golang.org/x/crypto v0.55.0
) )
require ( require (
@@ -12,4 +13,5 @@ require (
github.com/sethvargo/go-retry v0.4.0 // indirect github.com/sethvargo/go-retry v0.4.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/sync v0.22.0 // indirect golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.48.0 // indirect
) )
+4 -2
View File
@@ -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.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 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= 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 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= 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.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:yKk8qo+Di4gkmvRboK8ocCqH22FiUCR6jRy2OwtCRus=
modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ= modernc.org/libc v1.75.6/go.mod h1:bO5o2ztHxBb2rjz0PgdHN0sSMw57CgxGFLZ3Qd/QpVQ=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+244
View File
@@ -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)
}
}
}
+178
View File
@@ -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
)
+150
View File
@@ -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
}
+85
View File
@@ -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
}