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), доступы ЛК. Утечка дампа БД без шифрования эквивалентна утечке всех ключей.
Нужна симметричная криптография с тремя свойствами:
- Authenticated encryption — подмена байт в БД ловится при расшифровке.
- Прозрачность — сервисный слой получает строку, отдаёт строку; не нужно таскать связку (iv, tag, ciphertext) по коду и БД.
- Ротация ключа без data-migration: переключение нового ключа активным, а старый — пока остаётся в ring для чтения легаси-данных.
Управление инфраструктурными секретами (где хранится сам ключ — env / Vault / HSM) — вне scope OP-32; это F8.
Рассмотренные варианты
Алгоритм
- AES-256-CBC + HMAC-SHA256 (encrypt-then-mac).
- Знакомая комбинация, чистый Node
crypto. − Нужно правильно сложить две операции и не ошибиться; шире поверхность ошибки.
- Знакомая комбинация, чистый Node
- AES-256-GCM.
- Authenticated encryption в одном проходе, нативно поддерживается Node
crypto, рекомендован NIST для at-rest. − Требует уникальный nonce на пару (ключ, шифрование) — решается черезrandomBytes(12)для каждой записи (вероятность коллизии ≈ 0 для ожидаемых объёмов).
- Authenticated encryption в одном проходе, нативно поддерживается Node
- libsodium /
node:crypto.createSecretKey + chacha20-poly1305.- Современный AEAD. − Дополнительная зависимость; нет ощутимого выигрыша для нашего объёма.
Формат хранимой строки
- JSON
{v,kid,iv,tag,ct}.- Расширяемо. − Кавычки, экранирование, размер; неприятно индексировать.
- Точечно-разделённая строка
v1.kid.iv.tag.ct(base64url-сегменты).- Минимальный размер, легко читается человеком, удобно для логов
(по префиксу
v1.<kid>видно версию и какой ключ использован). − Менее «открытый», но это утилитный формат, не публичный.
- Минимальный размер, легко читается человеком, удобно для логов
(по префиксу
Управление ключом
- Один ключ из env, без ротации.
- Просто. − Невозможно сменить ключ без data-migration.
- 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 без подробностей наружу.
Ротация ключа
- Сгенерировать новый ключ:
openssl rand -hex 32. - Добавить в env:
ENCRYPTION_KEYS=k1:…,k2:новый-hex. Поднять приложение. - Переключить активный:
ENCRYPTION_ACTIVE_KID=k2. Передеплой — новые записи шифруютсяk2, старые продолжают читатьсяk1. - По мере записи/обновления старых полей они перешифровываются
k2. Утилита массовой перешифровки — отдельная Story при появлении достаточного объёма зашифрованных данных. - Когда легаси-записей под
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 (управление инфраструктурными секретами).
- OP-30 — WB-кабинеты: поле