# Zero-Knowledge jako ślepota architektoniczna (2/5)

Source: https://lodos.md/pl/blog/zero-knowledge-jako-slepota-architektoniczna
Published: 2026-07-02 · Author: Samet Samyeli · Language: pl · Reading time: 8 min

Większość menedżerów sekretów udostępnia getSecret(name), a jawny tekst trafia prosto do transkryptu AI. Schemat lodos sprawia, że taka wymiana staje się strukturalnie niemożliwa, AI nie może zobaczyć wartości sekretu, a build to wymusza.

---

Twój asystent AI właśnie poprosił o wgląd w Twój klucz produkcyjny Stripe. Większość menedżerów sekretów by na to pozwoliła, API udostępnia `getSecret(name)`, agent go wywołuje, jawny tekst trafia do transkryptu, a stamtąd do dowolnego pipeline'u, z którym agent rozmawia. Interesujące pytanie brzmi: jaki schemat sprawia, że taka wymiana staje się **strukturalnie niemożliwa do zrealizowania**?

Dla lodos wybrałem pozycję, którą łatwo zareklamować, a trudno zaimplementować: AI nie może zobaczyć wartości żadnego sekretu w vault. Nie „nie chce”, *nie może*. Nie istnieje żadna ścieżka kodu, która zwracałaby jawny sekret wywołującemu AI. Schemat Prisma nie ma kolumny, którą AI mogłoby odczytać; warstwa IPC nie ma metody zwracającej odszyfrowane pole; rejestr narzędzi MCP nie zawiera narzędzia, którego kontraktem byłoby *daj mi wartość*. Jeśli przyszły ja spróbuje takie dodać, skrypt budujący złapie to jeszcze przed merge'em.

Ten wpis to spacer po schemacie, jak w praktyce wygląda projektowanie **ślepoty architektonicznej** zamiast ślepoty opartej na polityce, i co się za to oddaje. Vault to warstwa, z której jestem najbardziej dumny, częściowo dlatego, że ten inżynieryjny projekt jest szczery co do tego, czego nie robi.

## Model dwóch magazynów

Naiwny projekt to jedna kolumna: `encryptedJson` przechowuje ładunek sekcji, odszyfrowujesz przy odczycie, serwujesz pola z odszyfrowanego blobu. To działa i ma jedną klasę błędów, każda ścieżka odczytu w końcu coś odszyfrowuje. Zapytanie, które chciało tylko `account_id` (publiczne), odszyfrowywało też `api_key` (wrażliwe). Log audytowy może wychwycić zmarnowane odszyfrowanie; wyciek do transkryptu LLM raczej nie.

Dlatego sekcje vault mają dwa magazyny, obok siebie:

```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
  // ...
}
```

![Model dwóch magazynów: przy zapisie każde pole jest kierowane zgodnie z flagą sensitive, pola wrażliwe trafiają przez libsodium do encryptedJson, pola niewrażliwe do jawnej kolumny JSON, dzięki czemu odczyt pola publicznego nigdy nie odszyfrowuje sekretu.](/blog/zero-knowledge-architectural-blindness/4a.webp)

Każde pole wie, czy jest `sensitive: true`, czy `sensitive: false`. Kierowanie odbywa się raz, przy zapisie. Pola wrażliwe przechodzą przez libsodium; pola niewrażliwe lądują w jawnej kolumnie JSON. Odczyt pola niewrażliwego to zwykły odczyt SQLite, bez odszyfrowania, bez dostępu do DEK, bez zapisu do audytu. Odczyt pola wrażliwego wymaga hasła głównego.

Tracisz: możliwość okłamywania samego siebie co do tego, które pole jest jakie. Nie możesz zdecydować przy odczycie. Schemat wymusza tę decyzję z góry.

Zyskujesz: usuniętą klasę błędów. Narzędzie MCP czatu `secrets_field_metadata` zwraca rzeczywistą długość dla pól niewrażliwych i `0` dla wrażliwych, nie dlatego, że ukryliśmy długość, ale dlatego, że ścieżka metadanych nigdy nie dotyka `encryptedJson`. Dyscyplina obowiązuje na poziomie typów, nie na poziomie komentarzy.

## Potok szyfrowania

Wrażliwa strona działa na Argon2id i libsodium. Nic egzotycznego:

```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
```

![Potok szyfrowania: główny DEK istnieje wyłącznie w procesie głównym; renderer oraz podproces Agent SDK nie mają żadnego dostępu do vault ani możliwości odszyfrowania, więc żaden jawny sekret nigdy nie przekracza granicy IPC w stronę AI.](/blog/zero-knowledge-architectural-blindness/3a.webp)

Dla twierdzenia o ślepocie liczą się trzy właściwości. Po pierwsze, główny DEK istnieje wyłącznie w pamięci procesu głównego, nigdy w rendererze, nigdy w podprocesie silnika czatu, nigdy w ładunku IPC. Po drugie, skompromitowanie jednej sekcji nie oznacza skompromitowania całego vault: każda sekcja ma własny losowy `sectionKey`, owinięty przez główny DEK. Po trzecie, stan odblokowania ma limit bezczynności pięciu minut i bezwzględny limit trzydziestu; po jego przekroczeniu każda sekcja jest o jedno ponowne podanie hasła od stania się nieczytelną.

To ten moment, w którym spodziewam się, że czytelnicy zorientowani na bezpieczeństwo powiedzą „tak, ale” i zaczną pytać o zrzuty pamięci, pliki wymiany, rezydencję w GPU. Uzasadnione pytania, w większości poza zakresem tego wpisu. Istotne jest to, że **AI nie ma jak sięgnąć do tego potoku**. Agent SDK działa w podprocesie, który nie ma dostępu do stanu vault; komunikuje się z procesem głównym przez IPC, a powierzchnia IPC nie ma żadnej metody zwracającej jawny tekst.

## Wzorzec A, B, D: nigdy C

W tym systemie istnieją dokładnie cztery sposoby użycia sekretu:

- **A: Nigdy nie opuszcza vault.** Sekret jest przywoływany po nazwie w workflow, odszyfrowywany po stronie procesu głównego, wstrzykiwany do env podprocesu, nigdy nie zwracany do AI. AI widzi `{{ secrets.stripe.live_key }}` w YAML i odpowiedź HTTP 200.
- **B: Wąski filtr wyjścia (egress).** Wartość taka jak `postgres://user:pass@host/db` jest odszyfrowywana po stronie procesu głównego, filtr wyciąga wyłącznie `host`, i to on staje się wartością `allowedEgress`. Sam sekret jako wartość nigdy nie przekracza granicy IPC; przekracza ją jedynie wyprowadzona projekcja.
- **C: Odszyfruj i zwróć do AI.** ❌ Nie istnieje. Brak zarejestrowanego narzędzia MCP. Brak metody IPC. Brak ścieżki kodu. Jeśli przeszukasz bazę kodu w poszukiwaniu nazwy narzędzia pasującej do `_value_get$`, wynik jest pusty z założenia. Skrypt budujący to egzekwuje.
- **D: Wstrzyknij i uruchom.** Uruchamiany jest podproces z sekretem w swoim env. Sześć warstw między sekretem a jakąkolwiek powierzchnią AI: tylko env (nie argv), redaktor kodujący na stdout/stderr, brak budowania stringów powłoki, zapora treści (content-firewall) na wyjściu, wpis w logu audytowym HMAC z nieprzezroczystą ścieżką, zakodowana na sztywno lista dozwolonych adresów wyjściowych dla każdego wywołania.

![Wzorce A, B i D pozwalają sekretowi wykonać użyteczną pracę bez zwracania jego wartości do AI; wzorzec C, odszyfruj i zwróć do AI, nie ma narzędzia, metody IPC ani ścieżki kodu, a skrypt budujący egzekwuje jego brak.](/blog/zero-knowledge-architectural-blindness/2a.webp)

Sens opisywania tego jako wzorców polega na tym, że **C to ten, który wysyła większość produktów**. Ich narzędzie do zarządzania sekretami zwraca jawny tekst do pętli agenta i nazywają to *integracją*. Wzorce od A do D też są integracją, po prostu nie umieszczają sekretu w transkrypcie.

To uczciwy kompromis. Niektóre przepływy pracy są łatwiejsze z wzorcem C. Żaden nie jest z nim *konieczny*. Dlatego wysyłamy A, B, D, a wzorzec C trzymamy z dala za pomocą grepa uruchamianego przy buildzie.

## Nieprzezroczysty łańcuch audytu

Log audytowy to część, która zaskoczyła mnie najbardziej podczas projektowania.

Wiersz audytu vault mówi: „sekcja X, pole Y zostało wstrzyknięte przez aktora Z w czasie T, podpis S, poprzedni podpis P”. Naturalny schemat przechowywałby `fieldPath` jako jawny tekst. To wyciek: każdy z dostępem do odczytu tabeli audytowej widzi, że agent AI wstrzyknął `stripe.live_key`, a nie tylko *że coś wstrzyknął*. Dla regulowanego klienta przeprowadzającego przegląd zgodności ta sama tabela audytowa staje się materiałem poufnym.

Dlatego schemat nie przechowuje ścieżki. Przechowuje jej skrót:

```typescript
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
```

Szesnastoznakowy nieprzezroczysty token. Używają go nawet odpowiedzi błędów: gdy wstrzyknięcie się nie powiedzie, błąd to `{ pathHash: "a3f2…", sectionExists: true }`, nigdy `{ field: "stripe.live_key", error: "not found" }`. Czytnik audytu, działający w procesie głównym we własnym kontekście użytkownika, wykonuje odwrotne wyszukiwanie. AI nigdy nie widzi jawnej ścieżki, a wyciek tabeli audytowej nie ujawnia niczego o tym, jakie sekrety są przechowywane.

Łańcuch jest powiązany za pomocą HMAC, każdy wiersz podpisuje `(payload + prev-signature)` kluczem podrzędnym wyprowadzonym z głównego DEK. `verifyAuditChain()` uruchamia się przy starcie; naruszony lub usunięty wiersz łamie łańcuch i pojawia się w UI jako alert kryminalistyczny. Twarde usuwanie jest zabronione; archiwizacja to miękkie usunięcie, przy którym wiersz pozostaje na miejscu.

Możesz zrobić zrzut ekranu tabeli audytowej i pokazać go audytorowi SOC2. Audytor widzi identyfikator aktora, sekcję, nieprzezroczysty skrót pola, znacznik czasu i łańcuch HMAC. Rzeczywiste nazwy sekretów nie wyciekają. *Za to właśnie* płacą klienci dbający o zgodność.

## Gdy użytkownik nie mógł odróżnić architektury od błędu

Podczas pierwszego prawdziwego testowania na żywo (dogfood) użytkownik uruchomił `echo $API_KEY` przez `secret_inject_and_run`. Wywołanie zakończyło się błędem `MCP_LETHAL_TRIFECTA_GATE`. Użytkownik zwrócił się z tym do asystenta AI po diagnozę; AI zużyło około 3 tys. tokenów, tłumacząc, że polecenie zostało odrzucone, ponieważ było „statycznym tekstem”, i zarekomendowało przejście na wywołanie curl. Błędnie. Warunkiem bramki było `allowedEgress.length === 0 && riskTier === 'novel'`, wywołanie nie miało listy dozwolonych adresów wyjściowych, więc zostało odrzucone jako potencjalna eksfiltracja bez zadeklarowanego celu.

Zgłoszenie błędu zostało przeklasyfikowane jako zwycięstwo fosy ochronnej (moat-win). Architektura zadziałała poprawnie, a zagubienie AI samo w sobie było dowodem: system odmówił na podstawie kształtu wyjścia, nie treści polecenia, a błędna diagnoza AI wynikała z niepomocnego opisu narzędzia, a nie z zepsutej odmowy. Poprawiliśmy opis; bramka pozostała bez zmian.

Lekcja, do której wciąż wracam, jest taka, że odmowa, której AI nie rozumie, wciąż jest odmową. **Ślepota architektoniczna nie wymaga, aby LLM się z nią zgadzał.**

## Ile to jest warte komercyjnie

Argument, którym przekonuję founderów rozważających tego rodzaju pracę, jest prosty. Klienci wymagający SOC2 i ISO nie zaakceptują jako polityki „obiecujemy”. Zaakceptują „nie możemy”. Dostawca, który *może* zobaczyć Twoje produkcyjne klucze i *obiecuje*, że tego nie zrobi, to dostawca, który od czasu do czasu ma CVE w swoim stosie logowania. Dostawca, którego schemat *nie może* zobaczyć wartości, to zupełnie inna kategoria ryzyka, i ta kategoria inaczej się wycenia.

Ścieżka Fazy 3, którą zostawiłem otwartą od pierwszego dnia, to owijanie per-odbiorca (per-recipient wrap). Klucz sekcji już jest warstwą pośrednią; dziś jest owijany raz przez główny DEK. W ustawieniu zespołowym byłby owijany N razy, raz dla każdego klucza publicznego odbiorcy, a serwer nadal nie mógłby odszyfrować, bo nie posiada żadnego klucza prywatnego. To owijanie jest addytywne, nie wymaga przepisania, ponieważ pośredniość na poziomie sekcji była utrzymywana w sposób generyczny od pierwszego commita. Te drzwi pozostają otwarte, mimo że żadna funkcja jeszcze z nich nie korzysta.

## Kolejna warstwa

Projekt schematu chroni sekret w spoczynku i przy wstrzykiwaniu. Ale schematy to kod, a kod można edytować. Kolejny wpis, **32 niezmienniki czasu budowania, które wysadzają mój build w razie regresji**, przechodzi przez skrypt, który pilnuje, by schemat Prisma się nie cofnął, silnik workflow nie obrósł w `eval`, a rejestr narzędzi MCP nie wypuścił `secret_value_get`. Odmowa staje się grepem, a ten grep dostarczany jest wraz z własnym fałszywym naruszeniem, które dowodzi, że grep wciąż ma zęby.
