Шифрование
Проблема наивного подхода
Section titled “Проблема наивного подхода”Если шифровать всё одним мастер-ключом напрямую:
ciphertext = AES(data, MASTER_KEY)То при утечке или плановой смене MASTER_KEY нужно перешифровать каждую запись в БД. Это долго, рискованно и требует downtime.
DEK + KEK паттерн
Section titled “DEK + KEK паттерн”Два уровня ключей:
- 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 “Реализация”Структура зашифрованного поля в БД
Section titled “Структура зашифрованного поля в БД”Единый тип EncryptedValue — хранится как два поля в таблице:
type EncryptedValue struct { DEKEncrypted []byte // AES-GCM(dek, kek) — 32+12+16 = 60 байт KeyVersion int // версия KEK которым зашифрован DEK ValueEncrypted []byte // AES-GCM(plaintext, dek)}В таблицах БД это выглядит так:
-- dns_providerscredentials_dek bytea -- зашифрованный DEKcredentials_kv smallint -- key_versioncredentials_enc bytea -- зашифрованные данные
-- certificatescert_pem_dek byteacert_pem_kv smallintcert_pem_enc bytea
key_pem_dek byteakey_pem_kv smallintkey_pem_enc byteaKeyring — набор версий KEK
Section titled “Keyring — набор версий KEK”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}Encrypt / Decrypt
Section titled “Encrypt / Decrypt”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)}Ротация ключей
Section titled “Ротация ключей”Процедура
Section titled “Процедура”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 из ENVMigrate endpoint
Section titled “Migrate endpoint”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"})}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%) ✓
Статус: в процессе...Что шифруется
Section titled “Что шифруется”| Таблица | Поле | Почему |
|---|---|---|
dns_providers |
credentials |
API-ключи DNS провайдера |
certificates |
cert_pem |
TLS сертификат |
certificates |
key_pem |
Приватный ключ сертификата |
certificates |
acme_account_key |
ACME аккаунт Let’s Encrypt |
Пароли пользователей — отдельно, bcrypt, ротации не требуют.
Генерация ключа
Section titled “Генерация ключа”# Генерация нового KEKopenssl rand -base64 32
# Или в Gokey := make([]byte, 32)rand.Read(key)fmt.Println(base64.StdEncoding.EncodeToString(key))Ключи хранятся только в ENV — никогда в git, никогда в БД, никогда в логах.