Files
grendervill 06e313dcc6 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 (подключаются следующими шагами)
2026-09-19 21:17:52 +03:00

151 lines
4.7 KiB
Go

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
}