axe/internal/crypt/cipher.go
2026-10-18 22:07:25 +03:00

115 lines
4.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}