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_dekstored in the database doesn't identify which key wrapped it."
Измерено 2026-09-12, в checkout:
| файл:строка | что там |
|---|---|
apps/api/src/db/schema/llm.ts:255 | wrappedDek: text('wrapped_dek').notNull() — шесть колонок конверта, ни одна не говорит, каким ключом |
apps/api/src/db/schema/git.ts:72 | то же, ТЕ ЖЕ шесть колонок |
envelope-encryption.service.ts:128-138 | decrypt пробует текущий и переходит к предыдущему внутри catch, не различающего причину |
envelope-encryption.service.ts:148-160 | rewrap делает то же, и catch без причины — то, что решает, находится ли запись уже на текущем ключе |
docs/runbook.md, "Before: size it up" | единственный возможный запрос считает всего строк по таблице — никогда сколько осталось |
Отсюда два конкретных дефекта:
- У шага 2 ротации нет прогресса. Кто прерывает скрипт посередине, не знает, где остановился; единственный способ узнать — запустить снова и прочитать печатаемый им счёт. А вопрос, решающий шаг 3, — «остались ли учётные данные на старом ключе?» — не имеет ответа в SQL.
- Неверный ключ и повреждённый 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 решил не трогать.