// Package crypt — шифрование чувствительных полей перед записью в БД: // сейчас это токены собственных ботов Telegram (миграция 0012). // // Токен бота — полный контроль над ботом, поэтому в базе он лежит // зашифрованным AES-256-GCM (authenticated encryption): утечка дампа // не даёт читать токены, а подмена байтов в колонке ловится при // расшифровке. Ключ — секрет сервера DATA_ENCRYPTION_KEY (32 байта, // base64 или hex), сам он в коде и в логах не появляется. // // Без ключа фича выключается на уровне хендлеров (Cipher == nil): // ввод токена отвечает 400, сохранённое расшифровать некому — ключ // меняется только вместе с явным планом миграции. package crypt import ( "crypto/aes" "crypto/cipher" "crypto/rand" "encoding/base64" "encoding/hex" "errors" "fmt" "io" "strings" ) const ( // keyLen — AES-256: 32 байта. keyLen = 32 // nonceLen — размер nonce для GCM: 96 бит, стандарт из RFC 5116. nonceLen = 12 // tagLen — аутентифицирующая метка GCM (не считается в nonce). tagLen = 16 ) var ( // ErrKeySize — ключ не 32 байта (неверный base64/hex). ErrKeySize = errors.New("encryption key must be 32 bytes") // ErrCorrupt — данные не читаются: не наш формат или биты подделаны. ErrCorrupt = errors.New("ciphertext is corrupt") ) // Cipher — AES-256-GCM поверх одного ключа. Потокобезопасен: // stateless-шум в nonce, общих структур нет. type Cipher struct { aead cipher.AEAD } // New создаёт шифровальщик из ключа в base64 (стандартное кодирование, // допускается и без паддинга) либо в hex. Сам ключ генерируется так: // `openssl rand -base64 32` либо `openssl rand -hex 32`. func New(key string) (*Cipher, error) { raw, err := decodeKey(strings.TrimSpace(key)) if err != nil { return nil, err } block, err := aes.NewCipher(raw) if err != nil { return nil, err } aead, err := cipher.NewGCM(block) if err != nil { return nil, err } return &Cipher{aead: aead}, nil } // Seal шифрует строку: к возвращаемому значению приклеивается случайный // nonce (12 байт), весь результат — base64. Результат не содержит исходного // текста и не выглядит как base64 от него. func (c *Cipher) Seal(plain string) (string, error) { nonce := make([]byte, nonceLen) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return "", fmt.Errorf("nonce: %w", err) } out := c.aead.Seal(nonce, nonce, []byte(plain), nil) return base64.StdEncoding.EncodeToString(out), nil } // Open расшифровывает то, что вернул Seal. Ошибка — либо чужие данные, // либо другой ключ: причина наружу не раскрывается (ErrCorrupt единый), // детали логирует вызывающий. func (c *Cipher) Open(enc string) (string, error) { raw, err := base64.StdEncoding.DecodeString(strings.TrimSpace(enc)) if err != nil { return "", ErrCorrupt } if len(raw) < nonceLen+tagLen { return "", ErrCorrupt } nonce, sealed := raw[:nonceLen], raw[nonceLen:] plain, err := c.aead.Open(nil, nonce, sealed, nil) if err != nil { return "", ErrCorrupt } return string(plain), nil } // decodeKey — base64 или hex, ровно 32 байта. func decodeKey(key string) ([]byte, error) { if key == "" { return nil, ErrKeySize } for _, dec := range []func(string) ([]byte, error){ base64.StdEncoding.DecodeString, base64.RawStdEncoding.DecodeString, hex.DecodeString, } { if raw, err := dec(key); err == nil && len(raw) == keyLen { return raw, nil } } return nil, ErrKeySize }