zero-knowledge
Zero-Knowledge jako ślepota architektoniczna (2/5)
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:
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
// ...
}

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:
// 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

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/dbjest odszyfrowywana po stronie procesu głównego, filtr wyciąga wyłączniehost, 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.

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:
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.