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

ADR 0158 — Id мастер-ключа, записанный в конверте, как наблюдаемость и никогда как полномочие

Статус​

Accepted. Соответствует BRB-016 реестра сопровождающего, который объявляет зависимость от BRB-010, — и зависимость буквальна: это решение меняет формат конверта, а доказательство того, что нынешний формат переживает целую ротацию, родилось в той же поставке, в RN-562, до этой строки кода. Не отменяет ADR 0027: схема конверта, CREDENTIALS_MASTER_KEY_PREVIOUS и rewrap-deks.ts остаются как есть.

Контекст​

CREDENTIALS_MASTER_KEY оборачивает DEK, шифрующие секреты пользователя, — ключи LLM в user_credentials и токены git в project_git_connections. Её ротация — процедура с худшим исходом в runbook, и он сам пишет почему (docs/runbook.md, раздел "What's at stake"):

"The wrapped_dek stored in the database doesn't identify which key wrapped it."

Измерено 2026-09-12, в checkout:

файл:строкачто там
apps/api/src/db/schema/llm.ts:255wrappedDek: text('wrapped_dek').notNull() — шесть колонок конверта, ни одна не говорит, каким ключом
apps/api/src/db/schema/git.ts:72то же, ТЕ ЖЕ шесть колонок
envelope-encryption.service.ts:128-138decrypt пробует текущий и переходит к предыдущему внутри catch, не различающего причину
envelope-encryption.service.ts:148-160rewrap делает то же, и catch без причины — то, что решает, находится ли запись уже на текущем ключе
docs/runbook.md, "Before: size it up"единственный возможный запрос считает всего строк по таблице — никогда сколько осталось

Отсюда два конкретных дефекта:

  1. У шага 2 ротации нет прогресса. Кто прерывает скрипт посередине, не знает, где остановился; единственный способ узнать — запустить снова и прочитать печатаемый им счёт. А вопрос, решающий шаг 3, — «остались ли учётные данные на старом ключе?» — не имеет ответа в SQL.
  2. Неверный ключ и повреждённый blob — один и тот же catch. Оба дают одинаковый общий отказ, а это диагнозы с противоположными действиями: один требует заново опубликовать предыдущий ключ, другой — расследовать запись.

Урок, управляющий этой областью, — RN-475: id, который должен был быть записан внутри материала и не был, сделал так, что автоматический режим никогда не аутентифицировался, и стоил охоты за однострочным дефектом.

Решение​

1. Конверт начинает нести key_id, в КОЛОНКЕ​

user_credentials и project_git_connections получают key_id text, допускающую NULL, а EncryptedSecret получает keyId?: string | null. Две таблицы меняются ВМЕСТЕ: они делят один конверт и один скрипт, и изменение только одной дало бы массив, в котором запрос отвечает на половину.

Колонка, а не префикс внутри blob, потому что главный потребитель — запрос SQL, шаг 2 runbook. Префикс вынудил бы читать и декодировать каждую строку, чтобы ответить «сколько осталось», а это вопрос, который всё и мотивировал.

2. Значение — ОТПЕЧАТОК выведенного ключа, и ничего больше​

key_id = HMAC-SHA256(chave_derivada, 'brabo-master-key-id') → 16 primeiros hex

Это отвечает на TODO(humano), который задача оставила открытым, — «откуда берётся значение key_id? Это не может быть хеш ключа (даёт офлайн-оракул проверки тому, кто читает базу), а ручной счётчик в .env — ещё одна переменная, в которой оператор может ошибиться». Обе половины возражения учтены, и первая измерена, а не предположена:

Нового оракула нет. У того, кто читает базу, уже есть ИДЕАЛЬНЫЙ офлайн-оракул проверки, и это сам конверт: wrapped_dek + dek_iv + dek_auth_tag — это AES-256-GCM, который АУТЕНТИФИЦИРУЕТ. Проверка кандидата-passphrase уже сегодня — это scryptSync(candidata, SALT, 32) и затем decipher, чей тег проверяется или нет, — точный ответ на каждого кандидата. С key_id проверка того же кандидата — scryptSync(candidata, SALT, 32) и затем HMAC. Стоимость одной попытки одинакова в обоих случаях, и в ней доминирует scrypt, который оба должны оплатить. Атакующий с доступом на чтение к базе ничего не выигрывает; то, что он выиграл бы, у него уже было.

И новой переменной нет. Оператор нигде не вводит key_id: это чистая функция ключа, который оператор уже опубликовал. Нет второго значения, которое нужно держать синхронным, а это был довод, которым docblock сервиса оправдывал отказ записывать какой-либо id (envelope-encryption.service.ts:36-38, "um identificador de chave no registro é mais um metadado a manter em sincronia"), — правда для ПРОИЗВОЛЬНОГО id, ложь для отпечатка.

Записывается HMAC, а не ключ и не его материал, и это 8 байт.

3. key_id — это НАБЛЮДАЕМОСТЬ, никогда не ПОЛНОМОЧИЕ — и здесь это решение отходит от очевидного​

Чтение НЕ начинает выбирать ключ по метке. decrypt остаётся байт в байт как есть: пробует текущий, переходит к предыдущему, решает GCM. rewrap тоже сохраняет попытку как критерий «уже на текущем ключе».

Очевидный путь — использовать key_id, чтобы пропустить попытку, — рассматривался и был отвергнут из-за собственного режима отказа: строка, чья метка говорит «текущий», но чей конверт на старом ключе, была бы молча пропущена rewrap, осталась бы вне счёта переобёрнутых, а шаг 3 runbook сделал бы её навсегда нечитаемой. Метка — лишние данные в колонке; конверт — истина. Замена полномочия криптографии меткой превращает несогласованность метаданных в потерю секрета.

Поэтому метка служит трём вещам, все вне пути криптографического решения:

  • запросу шага 2 (WHERE key_id IS DISTINCT FROM '<atual>');
  • диагнозу, когда НИ ОДИН из двух ключей не открывает, — сообщение начинает называть, какой ключ обернул эту запись, отличая «пришло из другого окружения» от «запись без key_id, записанная до этого изменения» и от «метка говорит, что на текущем, и всё равно не открывается», что является несогласованностью выше и заслуживает быть сказанным вслух;
  • источнику, который rewrap записывает вместе с новым конвертом.

4. Никакой миграции массива​

key_id рождается при следующей записи: encrypt его записывает, rewrap его записывает. Сегодняшние строки остаются с NULL, и NULL значит ровно «записано до этого изменения, ключ неизвестен» — никогда «на текущем ключе». Принудительная переобёртка всего массива лишь ради заполнения колонки превратила бы улучшение наблюдаемости в рискованную операцию над всеми учётными данными.

Принятое и объявленное последствие: в существующей установке запрос шага 2 начинает работать только после первой ротации. До неё всё NULL, и честный ответ запроса — «не знаю ни об одной», что отличается от нуля, — поэтому запрос runbook считает три диапазона раздельно (текущий / предыдущий / без метки), а не одно число.

5. Оператору нужно знать ТЕКУЩИЙ отпечаток, поэтому api его сообщает​

Колонка, которую никто не может ни с чем сравнить, бесполезна. Сервис начинает записывать при загрузке, в ОДНОЙ строке, отпечаток текущего ключа, а уже существовавшее предупреждение о ротации (:75-78) начинает называть ОБА. Это то же место, куда runbook уже велит смотреть оператору ("Confirm the api is in rotation mode — it warns in the log").

Вывод в лог безопасен по тому же доводу, что в пункте 2: это HMAC в 8 байт, и он не удешевляет ни одной попытки против базы.

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

Что становится возможным

  • Ответить на «сколько учётных данных ещё на предыдущем ключе?» в SQL, и именно это закрывает критерий BRB-016. Runbook использует запрос в шагах 2 и 3.
  • Различать в выводе rewrap-deks.ts запись из другого окружения и подделанную запись — раньше это была одна и та же строка ошибки.
  • Прервать шаг 2 и возобновить, зная, что осталось, не завися от счёта, напечатанного уже завершившимся запуском.

Цена, объявленная

  • Миграция в двух таблицах (ALTER TABLE ... ADD COLUMN key_id text). Допускает NULL и без значения по умолчанию, так что это новые метаданные в новой колонке: не переписывает строки, не блокирует большую таблицу.
  • Существующий массив остаётся без метки до первой ротации (пункт 4).
  • Установка, восстанавливающая дамп ДРУГОГО окружения, получает новый и лучший симптом: key_id строк не совпадает с логом, и Restore перестаёт нуждаться в диагностике методом исключения, которую сегодня описывает runbook.

Что НЕ меняется, и это важная половина

  • Алгоритм, размер DEK, SALT и scrypt. Ни один байт encryptedApiKey ничем из этого не переписывается.
  • decrypt. Запасной путь с двумя ключами остаётся механизмом, и решает по-прежнему криптография.
  • Имя и вызов скрипта (node scripts/rewrap-deks.js), на которые уже указывает runbook.
  • Число шагов ротации: по-прежнему три. Это решение делает её НАБЛЮДАЕМОЙ, никогда более короткой.

Что остаётся вне, названное

  • KMS, HSM и ключ по tenant остаются другим дизайном, и ADR 0027 остаётся как есть.
  • Индекс по key_id. Обе таблицы по природе малы (одна строка на пользователя/provider, одна на проект), а запрос выполняется раз в много месяцев, при ротации: индекс стоил бы записи каждый день, чтобы сэкономить seq scan дважды в год.
  • Половина диагноза в decrypt. Диагноз читает оператор, запускающий скрипт, и он находится именно там; помещение того же сообщения на горячий путь чтения изменило бы decrypt, который этот ADR решил не трогать.