06e313dcc6
- 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 (подключаются следующими шагами)
151 lines
4.7 KiB
Go
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
|
|
}
|