Skip to content

Шифрование

Проблема наивного подхода

Section titled “Проблема наивного подхода”

Если шифровать всё одним мастер-ключом напрямую:

ciphertext = AES(data, MASTER_KEY)

То при утечке или плановой смене MASTER_KEY нужно перешифровать каждую запись в БД. Это долго, рискованно и требует downtime.


Два уровня ключей:

  • KEK (Key Encryption Key) — мастер-ключ из ENV. Шифрует только DEK’и, не данные.
  • DEK (Data Encryption Key) — уникальный случайный 32-байтный ключ на каждую запись. Шифрует данные.
ENV: MASTER_KEY_V1=... MASTER_KEY_V2=... (при ротации держим оба)
БД:
┌─────────────────────────────────────────────────────────┐
│ dns_credentials │
│ dek_encrypted = AES-GCM(dek, KEK_V1) │ ← при ротации
│ key_version = 1 │ ← перешифруем только это
│ value_encrypted = AES-GCM(plaintext, dek) │ ← не трогаем
└─────────────────────────────────────────────────────────┘

При ротации KEK: берём dek_encrypted, расшифровываем старым KEK, перешифровываем новым KEK, обновляем key_version. Данные (value_encrypted) не трогаем вообще.


Структура зашифрованного поля в БД

Section titled “Структура зашифрованного поля в БД”

Единый тип EncryptedValue — хранится как два поля в таблице:

internal/crypto/encrypted_value.go
type EncryptedValue struct {
DEKEncrypted []byte // AES-GCM(dek, kek) — 32+12+16 = 60 байт
KeyVersion int // версия KEK которым зашифрован DEK
ValueEncrypted []byte // AES-GCM(plaintext, dek)
}

В таблицах БД это выглядит так:

-- dns_providers
credentials_dek bytea -- зашифрованный DEK
credentials_kv smallint -- key_version
credentials_enc bytea -- зашифрованные данные
-- certificates
cert_pem_dek bytea
cert_pem_kv smallint
cert_pem_enc bytea
key_pem_dek bytea
key_pem_kv smallint
key_pem_enc bytea
internal/crypto/keyring.go
type Keyring struct {
keys map[int][]byte // version → 32-byte key
currentVer int
}
// Загружается из ENV при старте:
// ENCRYPTION_KEY_V1=base64...
// ENCRYPTION_KEY_V2=base64... ← текущий (наибольшая версия)
func LoadKeyring() (*Keyring, error) {
kr := &Keyring{keys: make(map[int][]byte)}
for i := 1; ; i++ {
val := os.Getenv(fmt.Sprintf("ENCRYPTION_KEY_V%d", i))
if val == "" {
break
}
key, _ := base64.StdEncoding.DecodeString(val)
kr.keys[i] = key
kr.currentVer = i
}
if len(kr.keys) == 0 {
return nil, errors.New("no encryption keys configured")
}
return kr, nil
}
func (kr *Keyring) Current() ([]byte, int) {
return kr.keys[kr.currentVer], kr.currentVer
}
func (kr *Keyring) Get(version int) ([]byte, error) {
key, ok := kr.keys[version]
if !ok {
return nil, fmt.Errorf("unknown key version %d", version)
}
return key, nil
}
internal/crypto/crypto.go
func Encrypt(kr *Keyring, plaintext []byte) (*EncryptedValue, error) {
// Генерируем уникальный DEK для этой записи
dek := make([]byte, 32)
io.ReadFull(rand.Reader, dek)
// Шифруем данные DEK'ом
valueEnc, err := aesGCMEncrypt(plaintext, dek)
if err != nil {
return nil, err
}
// Шифруем DEK текущим KEK
kek, version := kr.Current()
dekEnc, err := aesGCMEncrypt(dek, kek)
if err != nil {
return nil, err
}
return &EncryptedValue{
DEKEncrypted: dekEnc,
KeyVersion: version,
ValueEncrypted: valueEnc,
}, nil
}
func Decrypt(kr *Keyring, ev *EncryptedValue) ([]byte, error) {
// Достаём KEK нужной версии
kek, err := kr.Get(ev.KeyVersion)
if err != nil {
return nil, err
}
// Расшифровываем DEK
dek, err := aesGCMDecrypt(ev.DEKEncrypted, kek)
if err != nil {
return nil, err
}
// Расшифровываем данные
return aesGCMDecrypt(ev.ValueEncrypted, dek)
}
func aesGCMEncrypt(plaintext, key []byte) ([]byte, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := make([]byte, gcm.NonceSize())
io.ReadFull(rand.Reader, nonce)
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
func aesGCMDecrypt(ciphertext, key []byte) ([]byte, error) {
block, _ := aes.NewCipher(key)
gcm, _ := cipher.NewGCM(block)
nonce := ciphertext[:gcm.NonceSize()]
return gcm.Open(nil, nonce, ciphertext[gcm.NonceSize():], nil)
}

1. Сгенерировать новый ключ:
openssl rand -base64 32
2. Добавить в ENV (держать старый!):
ENCRYPTION_KEY_V1=<старый> ← оставляем для расшифровки
ENCRYPTION_KEY_V2=<новый> ← новый текущий
3. Задеплоить панель — новые записи сразу пишутся с V2
4. Запустить миграцию:
POST /api/v1/admin/crypto/rotate?target_version=2
5. После завершения миграции убрать V1 из ENV
internal/handler/admin/crypto.go
func (h *Handler) RotateKeys(c fiber.Ctx) error {
targetVersion := c.QueryInt("target_version")
go h.cryptoService.Rotate(context.Background(), targetVersion)
return c.JSON(fiber.Map{"status": "rotation_started"})
}
internal/crypto/rotation.go
func (s *Service) Rotate(ctx context.Context, targetVersion int) {
tables := []RotatableTable{
{Table: "dns_providers", Fields: []string{"credentials"}},
{Table: "certificates", Fields: []string{"cert_pem", "key_pem", "acme_account_key"}},
}
for _, t := range tables {
s.rotateTable(ctx, t, targetVersion)
}
}
func (s *Service) rotateTable(ctx context.Context, t RotatableTable, targetVer int) {
// Обрабатываем батчами по 100 записей
var offset int
for {
rows := s.db.Raw(`
SELECT id, {dek_fields}, {kv_fields}
FROM ? WHERE {any_kv} != ?
LIMIT 100 OFFSET ?`,
t.Table, targetVer, offset,
).Rows()
count := 0
for rows.Next() {
row := scanRow(rows, t.Fields)
// Для каждого поля: расшифровать DEK старым KEK → зашифровать новым
updates := map[string]any{}
for _, field := range t.Fields {
ev := row.EncryptedValues[field]
if ev.KeyVersion == targetVer {
continue
}
// Расшифровываем DEK старым ключом
oldKEK, _ := s.keyring.Get(ev.KeyVersion)
dek, _ := aesGCMDecrypt(ev.DEKEncrypted, oldKEK)
// Перешифровываем DEK новым ключом
newKEK, _ := s.keyring.Get(targetVer)
newDEKEnc, _ := aesGCMEncrypt(dek, newKEK)
// value_encrypted НЕ ТРОГАЕМ
updates[field+"_dek"] = newDEKEnc
updates[field+"_kv"] = targetVer
}
s.db.Model(t.Table).Where("id = ?", row.ID).Updates(updates)
count++
}
if count == 0 {
break
}
offset += 100
}
}

Прогресс ротации в админке

Section titled “Прогресс ротации в админке”
Ротация ключей
Цель: Key Version 2
dns_providers: ████████████░░░░ 47/52 (90%)
certificates: ████████████████ 12/12 (100%) ✓
Статус: в процессе...

Таблица Поле Почему
dns_providers credentials API-ключи DNS провайдера
certificates cert_pem TLS сертификат
certificates key_pem Приватный ключ сертификата
certificates acme_account_key ACME аккаунт Let’s Encrypt

Пароли пользователей — отдельно, bcrypt, ротации не требуют.


Terminal window
# Генерация нового KEK
openssl rand -base64 32
# Или в Go
key := make([]byte, 32)
rand.Read(key)
fmt.Println(base64.StdEncoding.EncodeToString(key))

Ключи хранятся только в ENV — никогда в git, никогда в БД, никогда в логах.