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