115 lines
4.3 KiB
Go
115 lines
4.3 KiB
Go
// 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
|
||
}
|