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 }