zero-knowledge
Zero-Knowledge как архитектурная слепота (2/5)
Большинство менеджеров секретов предоставляют getSecret(name), и plaintext оказывается в транскрипте AI. Схема lodos делает такой обмен структурно невозможным, AI не может увидеть значение секрета, и это гарантирует сборка.
Ваш AI-ассистент только что попросил показать ему live-ключ от Stripe. Большинство менеджеров секретов позволили бы это, API предоставляет getSecret(name), агент вызывает его, plaintext оказывается в транскрипте, а оттуда, в любом пайплайне, с которым разговаривает агент. Интересный вопрос в том, какая схема делает такой обмен структурно невозможным.
Для lodos я выбрал позицию, которую легко рекламировать и трудно спроектировать: AI не может увидеть значение ни одного секрета в vault. Не «не будет», не может. Не существует пути в коде, который возвращал бы plaintext-секрет вызывающему AI. В схеме Prisma нет колонки, которую AI мог бы прочитать; в слое IPC нет метода, возвращающего расшифрованное поле; в реестре MCP-инструментов нет инструмента, чей контракт звучал бы как дай мне значение. Если я в будущем попытаюсь его добавить, скрипт сборки поймает это до merge.
Этот пост, разбор схемы: как на самом деле выглядит проектирование архитектурной слепоты вместо слепоты на уровне политики, и чем за это приходится платить. Vault, это слой, которым я горжусь больше всего, отчасти потому, что инженерия здесь честна в отношении того, чего она не делает.
Модель двух хранилищ
Наивный дизайн, одна колонка: encryptedJson хранит payload секции, вы расшифровываете при чтении, отдаёте поля из расшифрованного blob. Это работает, и у этого подхода есть один класс багов, любой путь чтения рано или поздно расшифровывает. Запрос, которому нужен был account_id (публичный), заодно расшифровывал и api_key (чувствительный). Audit-лог, возможно, зафиксирует лишнюю расшифровку; утечку в транскрипт LLM он, скорее всего, не заметит.
Поэтому у секций vault два хранилища, бок о бок:
model SecretSection {
id String @id @default(cuid())
name String // "stripe", "aws", "github"
encryptedJson Bytes // libsodium secretbox(sensitive-fields, sectionKey)
nonSensitive Json // { account_id, region, username, ... } - plain
fieldSchema Json // [{key, type, sensitive, required}, ...]
keyWrapped Bytes // sectionKey, wrapped by masterDEK
// ...
}

Каждое поле знает, является ли оно sensitive: true или sensitive: false. Маршрутизация происходит один раз, в момент записи. Чувствительные поля проходят через libsodium; нечувствительные попадают в колонку с plaintext JSON. Чтение нечувствительного поля, это чтение из SQLite: без расшифровки, без доступа к DEK, без записи в audit. Чтение чувствительного поля требует мастер-пароля.
Вы теряете: возможность обманывать себя насчёт того, какие поля какие. Нельзя решить это в момент чтения. Схема вынуждает сделать выбор заранее.
Вы получаете: удалённый класс багов. MCP-инструмент чата secrets_field_metadata возвращает реальную длину для нечувствительных полей и 0 для чувствительных, не потому что мы скрыли длину, а потому что путь метаданных вообще не касается encryptedJson. Дисциплина держится на уровне типов, а не на уровне комментариев.
Пайплайн шифрования
Чувствительная сторона работает на Argon2id и libsodium. Ничего экзотического:
// On unlock
const masterDEK = await argon2id(masterPassword, {
memLimit: 64 * 1024 * 1024, // 64MB
opsLimit: 3,
salt: vault.argon2Salt,
});
// HMAC check against vault.masterKeyCheck - fail-fast on wrong password
// On read of a sensitive field
const sectionKey = unwrap(section.keyWrapped, masterDEK);
const plaintext = sodium.crypto_secretbox_open(
section.encryptedJson, section.nonce, sectionKey,
);
const value = JSON.parse(plaintext)[field];
sectionKey.fill(0); // zero-fill before GC

Для заявления о «слепоте» важны три свойства. Во-первых, мастер-DEK живёт только в памяти main-процесса, никогда в renderer, никогда в субпроцессе chat-engine, никогда в payload IPC. Во-вторых, компрометация одной секции не означает компрометацию всего vault: у каждой секции свой случайный sectionKey, обёрнутый мастер-DEK. В-третьих, состояние разблокировки имеет idle-таймаут в пять минут и абсолютный предел в тридцать; после этого каждая секция снова требует запроса пароля, чтобы стать читаемой.
Это то место, где я ожидаю от security-ориентированных читателей реакции «да, но» и вопросов про дампы памяти, файлы подкачки, резидентность в GPU. Справедливые вопросы, в основном за пределами темы этого поста. Важный момент в том, что AI не может дотянуться до этого пайплайна. Agent SDK работает в субпроцессе, у которого нет доступа к состоянию vault; он общается с main-процессом через IPC, а на поверхности IPC нет метода, возвращающего plaintext.
Паттерны A, B, D: но никогда C
Есть ровно четыре способа использовать секрет в этой системе:
- A: Никогда не покидает vault. Секрет упоминается по имени в workflow, расшифровывается на стороне main-процесса, внедряется в env субпроцесса и никогда не возвращается AI. AI видит
{{ secrets.stripe.live_key }}в YAML и HTTP 200 в ответе. - B: Узкий фильтр egress. Значение вроде
postgres://user:pass@host/dbрасшифровывается на стороне main-процесса, фильтр извлекает толькоhost, и это становится значениемallowedEgress. Сам секрет как значение никогда не пересекает границу IPC; пересекает её производная проекция. - C: Расшифровать и вернуть AI. ❌ Не существует. Нет зарегистрированного MCP-инструмента. Нет метода IPC. Нет пути в коде. Если прогрепать кодовую базу на имя любого инструмента, соответствующее
_value_get$, результат по построению будет пустым. Это гарантирует скрипт сборки. - D: Внедрить и выполнить. Субпроцесс порождается с секретом в его env. Между секретом и любой поверхностью AI: шесть слоёв: только env (не argv), редактор-кодировщик на stdout/stderr, отсутствие построения shell-строк, content-firewall на выходных данных, запись в HMAC audit-лог с непрозрачным путём, жёстко заданный allowlist egress для каждого вызова.

Смысл описания этого в виде паттернов в том, что C, это тот, который поставляет большинство продуктов. Их инструмент менеджера секретов возвращает plaintext в цикл агента, и они называют это интеграцией. Паттерны от A до D, тоже интеграция, они просто не кладут секрет в транскрипт.
Обмен здесь честный. Некоторые workflow с паттерном C проще. Но ни один не является необходимым с паттерном C. Поэтому мы поставляем A, B, D и используем grep на этапе сборки, чтобы не пускать C.
Непрозрачная цепочка audit
Audit-лог, это часть, которая удивила меня больше всего при проектировании.
Строка audit vault сообщает: «секция X, поле Y было внедрено актором Z в момент времени T, подпись S, предыдущая подпись P». Естественная схема хранит fieldPath как plaintext. Это утечка: любой, у кого есть доступ на чтение к таблице audit, увидит, что AI-агент внедрил именно stripe.live_key, а не просто что он что-то внедрил. Для регулируемого покупателя, проводящего compliance-проверку, сама эта таблица audit становится секретным материалом.
Поэтому схема не хранит путь. Она хранит хэш:
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
Шестнадцатисимвольный непрозрачный токен. Даже ответы об ошибках используют его: когда внедрение не удаётся, ошибка выглядит как { pathHash: "a3f2…", sectionExists: true }, а не { field: "stripe.live_key", error: "not found" }. Читатель audit-лога, работающий в main-процессе в собственном контексте пользователя, делает обратный поиск. AI никогда не видит plaintext-путь, а утёкшая таблица audit не раскрывает, какие именно секреты хранятся.
Цепочка связана через HMAC, каждая строка подписывает (payload + предыдущая подпись) под-ключом, производным от мастер-DEK. verifyAuditChain() запускается при старте; изменённая или удалённая строка разрывает цепочку и всплывает в UI как криминалистическое предупреждение. Жёсткое удаление запрещено; архивация, это soft-delete с сохранением строки на месте.
Вы можете сделать скриншот таблицы audit и показать его аудитору SOC2. Аудитор увидит идентификатор актора, секцию, непрозрачный хэш поля, временную метку и цепочку HMAC. Реальные имена секретов не утекают. Именно за это платят покупатели, которым важен compliance.
Когда пользователь не смог отличить архитектуру от бага
Во время первого реального dogfood-теста пользователь запустил echo $API_KEY через secret_inject_and_run. Вызов провалился с MCP_LETHAL_TRIFECTA_GATE. Пользователь отнёс это AI-ассистенту на диагностику; AI потратил около 3 тысяч токенов, объясняя, что команда была отклонена, потому что это был «статический текст», и рекомендовал перейти на вызов curl. Неверно. Условие блокировки было allowedEgress.length === 0 && riskTier === 'novel', у вызова не было allowlist egress, поэтому он был отклонён как потенциально выводящий данные наружу без заявленного назначения.
Тикет о баге был переклассифицирован в победу moat. Архитектура сработала правильно, а замешательство AI само по себе было доказательством: система отказала на основании формы egress, а не содержимого команды, и неверный диагноз AI был следствием неудачного описания инструмента, а не сломанного отказа. Мы отредактировали описание; блокировка осталась на месте.
Урок, к которому я постоянно возвращаюсь: отказ, который AI не понимает, всё равно остаётся отказом. Архитектурная слепота не требует согласия LLM с ней.
Чего это стоит коммерчески
Питч, который я делаю фаундерам, размышляющим о подобной работе, прост. Покупатели, ориентированные на SOC2 и ISO, не примут в качестве политики «мы обещаем». Они примут «мы не можем». Вендор, который может увидеть ваши live-ключи и обещает этого не делать,, это вендор, у которого время от времени случаются CVE в стеке логирования. Вендор, чья схема не может увидеть значение,, это другая категория риска, и эта категория оценивается иначе.
Путь для Phase-3, который я оставил открытым с самого начала,, это оборачивание для каждого получателя отдельно. Ключ секции уже представляет собой уровень косвенности; сегодня он оборачивается один раз мастер-DEK. В командной настройке он будет обёрнут N раз, по одному разу на публичный ключ каждого получателя,, и сервер по-прежнему не сможет расшифровать, потому что не владеет ни одним приватным ключом. Такое оборачивание, это дополнение, а не переписывание, потому что косвенность на уровне секции была сохранена достаточно универсальной с самого первого коммита. Эта дверь остаётся открытой без единой отгруженной ради неё фичи прямо сейчас.
Следующий слой
Дизайн схемы защищает секрет в покое и при внедрении. Но схемы, это код, а код можно отредактировать. Следующий пост, 32 инварианта времени сборки, которые ломают мою сборку при регрессии, проходит через скрипт, который не даёт схеме Prisma регрессировать, движку workflow, обрасти eval, а реестру MCP-инструментов, прорастить secret_value_get. Отказ становится grep'ом, и этот grep поставляется с собственным фейковым нарушением, чтобы доказать, что у grep всё ещё есть зубы.