# Zero-Knowledge как архитектурная слепота (2/5)

Source: https://lodos.md/ru/blog/zero-knowledge-architectural-blindness-ru
Published: 2026-07-02 · Author: Samet Samyeli · Language: ru · Reading time: 8 min

Большинство менеджеров секретов предоставляют 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 два хранилища, бок о бок:

```prisma
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, чувствительные поля проходят через libsodium в encryptedJson, нечувствительные, в колонку с plaintext JSON, поэтому чтение публичного поля никогда не расшифровывает секрет.](/blog/zero-knowledge-architectural-blindness/4a.webp)

Каждое поле знает, является ли оно `sensitive: true` или `sensitive: false`. Маршрутизация происходит один раз, в момент записи. Чувствительные поля проходят через libsodium; нечувствительные попадают в колонку с plaintext JSON. Чтение нечувствительного поля, это чтение из SQLite: без расшифровки, без доступа к DEK, без записи в audit. Чтение чувствительного поля требует мастер-пароля.

Вы теряете: возможность обманывать себя насчёт того, какие поля какие. Нельзя решить это в момент чтения. Схема вынуждает сделать выбор заранее.

Вы получаете: удалённый класс багов. MCP-инструмент чата `secrets_field_metadata` возвращает реальную длину для нечувствительных полей и `0` для чувствительных, не потому что мы скрыли длину, а потому что путь метаданных вообще не касается `encryptedJson`. Дисциплина держится на уровне типов, а не на уровне комментариев.

## Пайплайн шифрования

Чувствительная сторона работает на Argon2id и libsodium. Ничего экзотического:

```typescript
// 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 и субпроцесс Agent SDK не имеют доступа к vault и не могут расшифровывать, поэтому ни один plaintext-секрет никогда не пересекает границу IPC на пути к AI.](/blog/zero-knowledge-architectural-blindness/3a.webp)

Для заявления о «слепоте» важны три свойства. Во-первых, мастер-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 для каждого вызова.

![Паттерны A, B и D позволяют секрету выполнять полезную работу, никогда не возвращая своё значение AI; паттерн C, расшифровать и вернуть AI, не имеет ни инструмента, ни метода IPC, ни пути в коде, и скрипт сборки гарантирует его отсутствие.](/blog/zero-knowledge-architectural-blindness/2a.webp)

Смысл описания этого в виде паттернов в том, что **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 становится секретным материалом.

Поэтому схема не хранит путь. Она хранит хэш:

```typescript
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 всё ещё есть зубы.
