0070. Шифрование vault резервных копий и recovery kit (B3)
Статус: accepted. Owner: Ian Panaev. Дата: 2026-10-07. Дополняет ADR 0054 и ADR 0056; сужает отказ ADR 0037 от «собственного шифрованного хранилища чанков»: решение не вводит chunk store, дедупликацию или собственную криптографию, а добавляет аутентифицированное шифрование поверх существующих файлов vault стандартными примитивами node:crypto.
Контекст
Vault ADR 0054 хранит каталог (хеши паролей, хеши токенов и сессий, журнал безопасности) и все опубликованные файлы в открытом виде; защита сводилась к требованию шифровать том (LUKS, BitLocker, NAS). Такая защита не покрывает копию vault на съёмном носителе, в облаке, у подрядчика, на списанном диске и не защищает от подмены файлов в vault: SHA-256 из manifest и сами файлы лежат рядом, и тот, кто может писать в vault, перепишет оба.
Факты кода, на которые опирается решение:
- Весь ввод-вывод vault проходит через
FileVault,vault-files.tsиvault-maintenance.ts; приложение (capture, verify, restore, retention) видит vault только через портыCaptureVaultиMaintainedVault. - Манифест записывает SHA-256 и размер содержимого (
inventory, таблицы, blob): эти значения остаются теми же, если шифровать только то, что лежит на диске. - Blob именуется неизменяемым ID upload и разделяется между точками;
hasBlobсверяет размер файла с inventory. vault.jsonобязателен перед любой записью: нераспознанная версия или значениеencryptionстарый релиз отвергает (parseVaultIdentityстрог), поэтому зашифрованный vault не читается старым кодом как открытый.- Агент работает без человека: ключ ему нужен постоянно; оператору нужен второй путь, не зависящий от сервера.
Угрозы и границы
Защищаем содержимое и каталог от читающего vault и обнаруживаем подмену файлов пишущим в vault. Не защищаем от противника с доступом к серверу-источнику (у него ключ и сами данные), от наблюдения за размерами файлов, числом точек и временем записи (они видны в имени каталога и метаданных файловой системы), от потери всех копий ключа (восстановление тогда невозможно: это сказано оператору прямо).
Решение
- Vault выбирает шифрование при создании и не меняет его.
vault.jsonверсии 2 с"encryption": "aes-256-gcm-v1"— зашифрованный vault; версия 1 с"none"— прежний открытый, он остаётся читаемым и пригодным. Смешанных vault нет.vault.jsonне аутентифицирован, поэтому открытый vault, в котором естьkeys/*.json, отвергается какintegrity_mismatch(подмена), а открытый vault, которому задан ключ, — какvault_key_invalidс указанием убрать ключ (ошибка настройки или подмена, удалившая и слоты): переписанный на «открытый»vault.jsonне заставит агента писать точки без шифрования, и агент сообщает оба кода вlastError. Противник, который удалил иkeys/, и настройку ключа агента, не защищён этой проверкой: администратор, который хочет открытый vault, не задаёт ключ. Преобразование на месте не предлагается: создайте новый зашифрованный vault и делайте точки в нём; старый хранится до конца срока жизни его точек.vault initпо умолчанию создаёт зашифрованный vault; открытый — только с явным--no-encryption. - Что шифруется: содержимое blob,
inventory.ndjson,tables/*.ndjson,manifest.jsonи временные файлы prune. Открытыми остаютсяvault.json(ID, время, схема),keys/*.json(обёрнутые ключи) иCOMMITTED(ID точки и SHA-256 манифеста): без них нельзя найти и проверить завершённость точки до разблокировки. Имена файлов и каталогов, размеры и числа точек видны. - Шифр: AES-256-GCM по схеме STREAM. Файл — заголовок 44 байта (
ARKVGCM1, log2 размера чанка = 20, три нулевых байта, 32 случайных байта соли) и чанки по 1 МиБ открытого текста + 16 байт тега. Ключ файла:HKDF-SHA256(мастер-ключ, соль файла, "arkvory-vault-v1/file|<vaultId>|<вид>|<имя>"), где вид —blob,table,inventory,manifestилиscratch, а имя — ID blob или<pointId>/<файл>. Поэтому файл, подменённый другим файлом или перенесённый в другую точку или vault, не проходит проверку. Nonce чанка — 8 байт номера, три нуля и байт признака последнего чанка; заголовок входит в AAD. Усечение, добавление данных и перестановка чанков дают отказ. Ключ файла уникален (соль), счётчик nonce не повторяется. Размер файла однозначно следует из размера содержимого (44 + размер + 16 × max(1, ⌈размер / 1 МиБ⌉)), поэтомуhasBlobи вызовы проверки сверяют его. SHA-256 и размеры в manifest и inventory по-прежнему относятся к открытому содержимому, аbytesточки — к нему же. - Ключи. Мастер-ключ (32 случайных байта) создаётся при инициализации и нигде не хранится открытым. Доступ задают слоты
keys/<slotId>.json(slotId— 16 hex): мастер-ключ, обёрнутый AES-256-GCM под ключом слота (HKDF-SHA256от 32 случайных байт ключа слота, AAD — vault, слот и вид). Вид слота:agent(ключ лежит в файле службы, строкаAK1-…) иrecovery(ключ в recovery kit, строкаRK1-…). Ключ слота — 32 случайных байта в виде 14 групп по 4 символа Base32 (последняя группа — контрольная), поэтому опечатка при переписывании с бумаги определяется до попытки расшифровки (vault_key_invalid), а парольных фраз и KDF нет: у случайного ключа нет слабости, которую KDF исправлял бы. - Recovery kit.
vault init(иvault key add-recovery) пишет текстовый файл на путь оператора: ID vault, время, формат, строкуRK1-…, инструкцию. Файл создаётся атомарно (wx, права 0600), не внутри vault и не внутри storage; команда сразу перечитывает его и разблокирует vault, и только затем сообщает об успехе («пробное открытие»). Kit хранится вне сервера-источника. Ключ в логи, аргументы командной строки, API и manifest не попадает. - Агент получает ключ из файла
ARKVORY_BACKUP_VAULT_KEY_FILE(строкаAK1-…), читает его при каждой разблокировке и держит мастер-ключ только в памяти процесса. Запущенный агент перепроверяет файл ключа и слот не реже раза в минуту: отозванный или удалённый ключ перестаёт работать в течение минуты, а не после перезапуска (идущая копия при этом завершится ошибкой и будет повторена по правилам задания). Каждая операция берёт шифр из результата своей разблокировки; для зашифрованного vault неудачная проверка — всегда отказ, а не «работа без шифра», и следующий вызов проверяет заново. Для зашифрованного vault без ключа или с неверным ключом vault недоступен: heartbeat даётvaultAvailable = falseиlastError = vault_key_missingлибоvault_key_invalid, критическое предупреждениеvault_unavailableи журнал; захват не начинается. CLI принимает ключ параметром--key-file(agent или recovery) или той же переменной. - Ротация и отзыв доступа — операции над слотами под исключительным замком администрирования ключей (файл
keys/.admin.lock, создаётся эксклюзивно; второй администратор получаетbusy. Замок, оставленный сбоем, не перехватывается автоматически — два процесса могли бы одновременно счесть его устаревшим, а время файла на NAS идёт по чужим часам, — оператор удаляет его сам, когда уверен, что других команд нет; PostgreSQL для этих команд не нужен, поэтому замок18471/21не используется):vault key list(слоты, виды, времена),vault key add-recovery,vault key rotate-agent(новый ключ агента: сначала файл, затем слот, затем пробное открытие; прежние слоты агента остаются и перечисляются в журнале какbackup.vault.key.previous, потому что работающий агент читает прежний файл; оператор устанавливает новый файлarkvory configure --vault-key-fileи затем снимает прежние слотыvault key remove),vault key remove --vault <каталог> --key-file <файл> --slot <slotId>. Нельзя удалить последний слот и последний слотrecovery. Новый слот проверяется разблокировкой. Файл ключа или kit записывается до слота: сбой между ними оставляет файл, ключ которого ничего не открывает, но никогда — слот, ключа которого ни у кого нет (такой слотrecoveryобманул бы правило «последний слотrecovery»). Мастер-ключ не меняется, поэтому отзыв слота закрывает доступ тому, у кого нет мастер-ключа; перешифрование уже записанных точек не обещается: при компрометации ключа создаётся новый vault. - Совместимость. Открытый vault работает как раньше. Зашифрованный vault релизом без поддержки отвергается как неподдерживаемый формат. Манифесты, точки и restore не меняют схему БД и API.
Альтернативы
- restic за адаптером (ADR 0037): внешний бинарник, лицензия, SBOM, спайк на Windows и NAS; откладывается вместе с S3. Данное решение не мешает ему: порт vault не меняется.
- Шифрование на уровне тома (LUKS, BitLocker) остаётся обязательной защитой от потери носителя, но не защищает копию тома, облачное хранилище, подрядчика и не обнаруживает подмену. Решения дополняют друг друга.
- Парольная фраза и KDF (scrypt). Человеческий пароль слаб, а агенту ключ нужен без человека; слот со случайным ключом проще и надёжнее. Если оператору нужна парольная фраза, он хранит ключ в менеджере паролей.
- Шифрование на стороне блока метаданных БД. Не относится к vault и не решает копию.
- Единый nonce-счётчик на vault. Потребовал бы координации между процессами; уникальный ключ файла с солью исключает повтор без неё.
Последствия
- Снижение скорости: AES-GCM поток на одном потоке: измерено около 260 МиБ/с на шифрование и 300 МиБ/с на расшифровку (Node 24, один поток, без AES-NI-специфичной настройки); для HDD и NAS это не узкое место, для NVMe и 10 GbE — может быть.
- Размер vault растёт на 16 байт на МиБ плюс заголовок; блоки остаются общими между точками (дедупликация по ID сохраняется, ключ файла стабилен для ID blob).
- Потеря всех слотов или их ключей делает vault нечитаемым: это объясняется при создании и в документации; kit проверяется при создании и командой
vault key verify. - Отказ расшифровки (подмена, повреждение, не тот vault) —
integrity_mismatch, неверный или отсутствующий ключ —vault_key_invalid/vault_key_missing. - Prune и forget работают без изменений; временные файлы prune шифруются тем же способом.
- Признак «зашифрован» передаётся отдельным аддитивным изменением контракта (схема БД 34): агент пишет в heartbeat
vault_encrypted(изvault.json; приvault_key_*читается без ключа),GET /api/v1/backup/statusотдаётvault.encrypted(boolean или null: неизвестно, агент старой версии, нечитаемыйvault.json, vault не подключён), консоль иarkvoryctl backup statusпоказывают его, а для открытого vault консоль выводит подсказку, не предупреждение (warningsне меняются). Признак — последнее известное значение агента: офлайн-агент не делает vault открытым. Он не заменяетvault key listи не аутентифицирован так же, какvault.json.
Проверка
Unit: потоковый шифр (границы чанков: 0, 1, 1 МиБ ± 1, несколько чанков; усечение, добавление, перестановка, подмена файла, неверный вид или имя), размер зашифрованного файла, ключи и слоты (формат, контрольная сумма, разблокировка любым слотом, неверный ключ, обёртка привязана к vault и слоту, ограничения удаления, ротация). Integration на PostgreSQL: capture в зашифрованный vault, verify --deep, restore в пустую цель со сверкой данных, на диске vault нет открытых значений каталога и содержимого; неверный и отсутствующий ключ; подменённый, усечённый и переставленный blob и таблица; prune и retention на зашифрованном vault; агент сообщает vault_key_*; открытый vault работает как раньше.