axe/internal/crypt/cipher.go

115 lines
4.3 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
// 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
}