Перейти к основному содержимому

0005. Шифрование секретов at-rest: алгоритм, формат и ротация

Копия ADR из репозитория: backend/docs/adr/0005-secret-encryption-at-rest.md. Канонический текст — в репозитории; портал обновляется командой npm run sync:adr.

  • Статус: accepted
  • Дата: 2026-06-01
  • Связанные задачи: OP-32 (F2 · Шифрование секретов)

Контекст и проблема​

ТЗ 9.5 «Безопасность». В БД будут лежать чувствительные поля: API-ключи кабинетов Wildberries (OP-30), доступы ЛК. Утечка дампа БД без шифрования эквивалентна утечке всех ключей.

Нужна симметричная криптография с тремя свойствами:

  1. Authenticated encryption — подмена байт в БД ловится при расшифровке.
  2. Прозрачность — сервисный слой получает строку, отдаёт строку; не нужно таскать связку (iv, tag, ciphertext) по коду и БД.
  3. Ротация ключа без data-migration: переключение нового ключа активным, а старый — пока остаётся в ring для чтения легаси-данных.

Управление инфраструктурными секретами (где хранится сам ключ — env / Vault / HSM) — вне scope OP-32; это F8.

Рассмотренные варианты​

Алгоритм​

  1. AES-256-CBC + HMAC-SHA256 (encrypt-then-mac).
    • Знакомая комбинация, чистый Node crypto. − Нужно правильно сложить две операции и не ошибиться; шире поверхность ошибки.
  2. AES-256-GCM.
    • Authenticated encryption в одном проходе, нативно поддерживается Node crypto, рекомендован NIST для at-rest. − Требует уникальный nonce на пару (ключ, шифрование) — решается через randomBytes(12) для каждой записи (вероятность коллизии ≈ 0 для ожидаемых объёмов).
  3. libsodium / node:crypto.createSecretKey + chacha20-poly1305.
    • Современный AEAD. − Дополнительная зависимость; нет ощутимого выигрыша для нашего объёма.

Формат хранимой строки​

  1. JSON {v,kid,iv,tag,ct}.
    • Расширяемо. − Кавычки, экранирование, размер; неприятно индексировать.
  2. Точечно-разделённая строка v1.kid.iv.tag.ct (base64url-сегменты).
    • Минимальный размер, легко читается человеком, удобно для логов (по префиксу v1.<kid> видно версию и какой ключ использован). − Менее «открытый», но это утилитный формат, не публичный.

Управление ключом​

  1. Один ключ из env, без ротации.
    • Просто. − Невозможно сменить ключ без data-migration.
  2. Key-ring: словарь kid → key в env, активный kid отдельно.
    • Плавная ротация: добавили новый, переключили активным, перешифровали, удалили старый. − Чуть сложнее env.

Решение​

AES-256-GCM с key-ring​

  • Алгоритм — aes-256-gcm, IV 96 бит (randomBytes(12)), auth-tag 128 бит.
  • Формат хранения — v1.{kid}.{iv}.{tag}.{ciphertext}, сегменты base64url без padding. Версия в начале — для будущего апгрейда алгоритма без data-migration (новые записи получат v2.…, decrypt-путь умеет читать обе).
  • Key-ring задаётся переменной окружения ENCRYPTION_KEYS=kid1:hex64[,kid2:hex64...], активный ключ — ENCRYPTION_ACTIVE_KID. Hex64 = 32 байта = ключ AES-256. Валидируется zod-схемой env.schema.ts.
  • Сервис — src/core/crypto/EncryptionService (@Global-модуль): на старте парсит ring, проверяет, что активный kid присутствует. Падает при дублирующемся kid или отсутствующем активном.
  • Ошибки расшифровки (битый формат, неизвестный kid, неверный auth-tag) — единый EncryptionError. Глобальный exception-filter мапит его в 500 без подробностей наружу.

Ротация ключа​

  1. Сгенерировать новый ключ: openssl rand -hex 32.
  2. Добавить в env: ENCRYPTION_KEYS=k1:…,k2:новый-hex. Поднять приложение.
  3. Переключить активный: ENCRYPTION_ACTIVE_KID=k2. Передеплой — новые записи шифруются k2, старые продолжают читаться k1.
  4. По мере записи/обновления старых полей они перешифровываются k2. Утилита массовой перешифровки — отдельная Story при появлении достаточного объёма зашифрованных данных.
  5. Когда легаси-записей под k1 не осталось — убрать k1 из ENCRYPTION_KEYS.

В README бэкенда — пошаговый чек-лист в разделе «Ротация ключа шифрования».

Последствия​

  • Положительные:
    • GCM ловит подмену байт автоматически — не нужен ручной HMAC-слой.
    • Версионирование формата открывает дверь к смене алгоритма без data-migration.
    • Key-ring позволяет ротировать ключ без перешифровки всех данных одновременно.
  • Отрицательные / компромиссы:
    • IV генерируется случайным — вероятность коллизии 96-битного nonce при объёме ≪ 2³² записей пренебрежимо мала, но не нулевая. Если в будущем объём вырастет до миллиардов записей, перейдём на детерминированный nonce (счётчик) с отдельным ADR.
    • GCM не любит больших сообщений (>64 GiB на пару key-iv); у нас секреты в десятки-сотни байт — не релевантно.
  • Дальше (вне scope OP-32):
    • OP-30 — WB-кабинеты: поле apiKey хранится через EncryptionService.
    • Массовая перешифровка — отдельная Story при первой ротации.
    • HSM / Vault для самого ключа — F8 (управление инфраструктурными секретами).