zero-knowledge
Zero-Knowledge als architektonische Blindheit (2/5)
Die meisten Secret-Manager exponieren getSecret(name), und der Klartext landet im Transcript der KI. Das lodos-Schema macht diesen Austausch strukturell unbeantwortbar, die KI kann den Wert eines Secrets nicht sehen, und der Build erzwingt das.
Dein KI-Assistent will gerade deinen Stripe-Live-Key sehen. Die meisten Secret-Manager würden das zulassen, die API exponiert getSecret(name), der Agent ruft sie auf, der Klartext landet im Transcript, und von dort in welcher Pipeline auch immer der Agent als Nächstes spricht. Die interessante Frage ist, welche Art von Schema diesen Austausch strukturell unbeantwortbar macht.
Für lodos habe ich mich für eine Position entschieden, die leicht zu bewerben und schwer zu bauen ist: Die KI kann den Wert keines Secrets im Vault sehen. Nicht „will nicht", kann nicht. Es gibt keinen Code-Pfad, der ein Klartext-Secret an die aufrufende KI zurückgibt. Das Prisma-Schema hat keine Spalte, die die KI lesen könnte; die IPC-Schicht hat keine Methode, die ein entschlüsseltes Feld zurückgibt; die MCP-Tool-Registry hat kein Tool, dessen Vertrag lautet gib mir den Wert. Falls ein zukünftiges Ich versucht, eines hinzuzufügen, fängt das Build-Skript das vor dem Merge ab.
Dieser Beitrag ist der Schema-Walkthrough, wie es tatsächlich aussieht, architektonische Blindheit statt Policy-Blindheit zu entwerfen, und was man dafür aufgibt. Der Vault ist die Schicht, auf die ich am stolzesten bin, teils weil das Engineering ehrlich darüber ist, was es nicht tut.
Das Zwei-Speicher-Modell
Das naive Design ist eine Spalte: encryptedJson hält die Section-Payload, man entschlüsselt beim Lesen, man liefert Felder aus dem entschlüsselten Blob. Das funktioniert, und es hat eine Bug-Klasse, jeder Lesepfad entschlüsselt irgendwann. Der Lookup, der account_id (öffentlich) wollte, entschlüsselte auch api_key (sensibel) mit. Das Audit-Log fängt den unnötigen Decrypt vielleicht ab; der Leak ins LLM-Transcript wahrscheinlich nicht.
Also haben Vault-Sections zwei Speicher, nebeneinander:
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
// ...
}

Jedes Feld weiß, ob es sensitive: true oder sensitive: false ist. Das Routing passiert einmal, beim Schreiben. Sensible Felder gehen über libsodium; nicht-sensible Felder landen in einer Klartext-JSON-Spalte. Das Lesen eines nicht-sensiblen Felds ist ein SQLite-Read, kein Decrypt, kein DEK-Zugriff, kein Audit-Write. Das Lesen eines sensiblen Felds erfordert das Master-Passwort.
Man verliert: die Fähigkeit, sich selbst zu belügen, welche Felder was sind. Man kann die Entscheidung nicht beim Lesen treffen. Das Schema erzwingt den Aufruf im Voraus.
Man gewinnt: eine gelöschte Bug-Klasse. Das Chat-MCP-Tool secrets_field_metadata gibt für nicht-sensible Felder die echte Länge zurück und für sensible 0, nicht weil wir die Länge versteckt haben, sondern weil der Metadaten-Pfad encryptedJson nie berührt. Die Disziplin greift auf Typ-Ebene, nicht auf Kommentar-Ebene.
Die Verschlüsselungspipeline
Die sensible Seite läuft auf Argon2id und libsodium. Nichts Exotisches:
// 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

Drei Eigenschaften sind für den Blindheits-Anspruch relevant. Erstens: Der Master-DEK lebt nur im Speicher des Main-Prozesses, nie im Renderer, nie im Chat-Engine-Subprozess, nie in einer IPC-Payload. Zweitens: Eine kompromittierte einzelne Section ist kein kompromittierter Vault, jede Section hat ihren eigenen zufälligen sectionKey, gewrapt vom Master-DEK. Drittens: Der Unlock-Status hat ein Idle-Timeout von fünf Minuten und ein absolutes Limit von dreißig; danach ist jede Section einen erneuten Prompt davon entfernt, unlesbar zu sein.
Das ist der Punkt, an dem ich erwarte, dass sicherheitsbewusste Leser „ja, aber" sagen und nach Memory-Dumps, Swap-Dateien, GPU-Residenz fragen. Faire Fragen, meist außerhalb des hiesigen Scopes. Der relevante Punkt ist, dass die KI nicht in diese Pipeline hineinreichen kann. Der Agent-SDK läuft in einem Subprozess, der keinen Zugriff auf den Vault-Status hat; er spricht mit dem Main-Prozess über IPC, und die IPC-Oberfläche hat keine Methode, die Klartext zurückgibt.
Pattern A, B, D: niemals C
Es gibt genau vier Arten, wie ein Secret in diesem System verwendet werden kann:
- A: Verlässt den Vault nie. Ein Secret wird per Name in einem Workflow referenziert, Main-Prozess-seitig entschlüsselt, in eine Subprozess-Env injiziert, nie an die KI zurückgegeben. Die KI sieht
{{ secrets.stripe.live_key }}in YAML und ein HTTP 200 in der Antwort. - B: Enger Egress-Filter. Ein Wert wie
postgres://user:pass@host/dbwird Main-Prozess-seitig entschlüsselt, ein Filter extrahiert nurhost, und das wird zumallowedEgress-Wert. Das Secret als Wert überquert nie die IPC-Grenze; eine abgeleitete Projektion tut es. - C: Entschlüsseln und an die KI zurückgeben. ❌ Existiert nicht. Kein MCP-Tool registriert. Keine IPC-Methode. Kein Code-Pfad. Wenn man die Codebase nach einem Tool-Namen durchsucht, der auf
_value_get$matcht, ist das Ergebnis per Konstruktion leer. Das Build-Skript erzwingt das. - D: Injizieren und ausführen. Ein Subprozess wird gestartet, mit dem Secret in seiner Env. Sechs Schichten zwischen dem Secret und jeder KI-Oberfläche: nur-Env (nicht argv), Encoding-Redactor auf stdout/stderr, kein Shell-String-Building, Content-Firewall auf der Ausgabe, HMAC-Audit-Log-Eintrag mit opakem Pfad, hartkodierte Egress-Allowlist pro Aufruf.

Der Sinn, diese als Patterns zu formulieren, ist, dass C dasjenige ist, das die meisten Produkte ausliefern. Deren Secret-Manager-Tool gibt den Klartext an den Agent-Loop zurück, und sie nennen das Integration. Pattern A bis D sind auch Integration, sie stecken das Secret nur nicht ins Transcript.
Der Trade-off ist ehrlich. Manche Workflows sind mit Pattern C einfacher. Keiner ist mit Pattern C notwendig. Also liefern wir A, B, D aus, und wir nutzen einen Build-Time-Grep, um C draußen zu halten.
Die opake Audit-Chain
Das Audit-Log ist der Teil, der mich beim Design am meisten überrascht hat.
Eine Vault-Audit-Zeile sagt „Section X, Feld Y wurde von Actor Z zur Zeit T injiziert, Signatur S, Vorgänger-Signatur P." Das naheliegende Schema speichert fieldPath als Klartext. Das ist ein Leak: Jeder mit Lesezugriff auf die Audit-Tabelle sieht, dass der KI-Agent stripe.live_key injiziert hat, nicht nur, dass er etwas injiziert hat. Für einen regulierten Käufer, der ein Compliance-Review durchführt, wird diese Audit-Tabelle selbst zu geheimem Material.
Also speichert das Schema nicht den Pfad. Es speichert den Hash:
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
Ein sechzehnstelliger opaker Token. Selbst die Fehlerantworten nutzen ihn: Wenn eine Injektion fehlschlägt, lautet der Fehler { pathHash: "a3f2…", sectionExists: true }, niemals { field: "stripe.live_key", error: "not found" }. Der Audit-Reader, der im Main-Prozess im eigenen Kontext des Nutzers läuft, macht den umgekehrten Lookup. Die KI sieht den Klartext-Pfad nie, und eine geleakte Audit-Tabelle enthüllt nichts darüber, welche Secrets gespeichert sind.
Die Chain ist HMAC-verkettet, jede Zeile signiert (payload + prev-signature) mit einem vom Master-DEK abgeleiteten Sub-Key. verifyAuditChain() läuft beim Start; eine manipulierte oder gelöschte Zeile bricht die Chain und erscheint in der UI als forensischer Alarm. Hard-Delete ist verboten; Archivieren ist ein Soft-Delete, bei dem die Zeile an Ort und Stelle bleibt.
Man kann die Audit-Tabelle screenshotten und einem SOC2-Auditor zeigen. Der Auditor sieht eine Actor-Kennung, eine Section, einen opaken Feld-Hash, einen Zeitstempel und eine HMAC-Chain. Die tatsächlichen Secret-Namen leaken nicht. Das ist es, wofür Compliance-Käufer bezahlen.
Als der Nutzer die Architektur nicht von einem Bug unterscheiden konnte
Beim ersten echten Dogfooding führte ein Nutzer echo $API_KEY über secret_inject_and_run aus. Der Aufruf schlug mit MCP_LETHAL_TRIFECTA_GATE fehl. Der Nutzer brachte das zur Diagnose zum KI-Assistenten; die KI verbrauchte etwa 3k Tokens damit, zu erklären, der Befehl sei abgelehnt worden, weil er „statischer Text" sei, und empfahl, auf einen curl-Aufruf umzusteigen. Falsch. Die Gate-Bedingung war allowedEgress.length === 0 && riskTier === 'novel', der Aufruf hatte keine Egress-Allowlist, also wurde er als potenziell exfiltrierend ohne deklariertes Ziel abgelehnt.
Das Bug-Ticket wurde als Moat-Win umklassifiziert. Die Architektur funktionierte korrekt, und die Verwirrung der KI war selbst ein Beweis dafür: Das System verweigerte basierend auf der Egress-Form, nicht auf dem Befehlsinhalt, und die falsche Diagnose der KI war die Folge einer unhilfreichen Tool-Beschreibung, nicht einer kaputten Verweigerung. Wir haben die Beschreibung bearbeitet; das Gate blieb, wie es war.
Die Lehre, zu der ich immer wieder zurückkehre: Eine Verweigerung, die die KI nicht versteht, ist immer noch eine Verweigerung. Architektonische Blindheit erfordert nicht, dass das LLM damit einverstanden ist.
Was das kommerziell wert ist
Das Pitch, das ich Foundern gebe, die über diese Art von Arbeit nachdenken, ist unkompliziert. SOC2- und ISO-Käufer akzeptieren „wir versprechen es" nicht als Policy. Sie akzeptieren „wir können nicht." Ein Anbieter, der deine Live-Keys sehen kann und verspricht, es nicht zu tun, ist ein Anbieter, der gelegentlich CVEs in seinem Logging-Stack hat. Ein Anbieter, dessen Schema den Wert nicht sehen kann, ist eine andere Risikokategorie, und diese Kategorie wird anders bepreist.
Der Phase-3-Pfad, den ich von Anfang an offengehalten habe, ist Per-Empfänger-Wrapping. Der Section-Key ist bereits eine Indirektion; heute wird er einmal vom Master-DEK gewrapt. In einem Team-Setting wird er N-mal gewrapt, einmal pro Empfänger-Public-Key, und der Server kann trotzdem nicht entschlüsseln, weil er keinen Private-Key besitzt. Das Wrapping ist additiv, kein Rewrite, weil die Per-Section-Indirektion vom ersten Commit an generisch gehalten wurde. Diese Tür bleibt offen, ohne dass jetzt ein Feature dafür ausgeliefert werden muss.
Die nächste Schicht
Schema-Design schützt das Secret im Ruhezustand und bei der Injektion. Aber Schemata sind Code, und Code kann bearbeitet werden. Der nächste Beitrag, 32 Build-Time Invariants That Fail My Build If I Regress, geht das Skript durch, das das Prisma-Schema davor bewahrt, zu regressieren, die Workflow-Engine davor, ein eval zu entwickeln, und die MCP-Tool-Registry davor, ein secret_value_get sprießen zu lassen. Verweigerung wird zu einem Grep, und der Grep liefert seine eigene gefälschte Verletzung mit, um zu beweisen, dass der Grep noch Zähne hat.