# Zero-Knowledge als architektonische Blindheit (2/5)

Source: https://lodos.md/de/blog/zero-knowledge-als-architektonische-blindheit
Published: 2026-07-02 · Author: Samet Samyeli · Language: de · Reading time: 8 min

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:

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

![Das Zwei-Speicher-Modell: beim Schreiben wird jedes Feld anhand seines Sensitive-Flags geroutet, sensible Felder gehen über libsodium in encryptedJson, nicht-sensible Felder in eine Klartext-JSON-Spalte, sodass das Lesen eines öffentlichen Felds nie ein Secret entschlüsselt.](/blog/zero-knowledge-architectural-blindness/4a.webp)

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:

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

![Die Verschlüsselungspipeline: der Master-DEK lebt ausschließlich im Main-Prozess; Renderer und Agent-SDK-Subprozess haben null Vault-Zugriff und keine Decrypt-Fähigkeit, sodass niemals ein Klartext-Secret die IPC-Grenze zur KI überquert.](/blog/zero-knowledge-architectural-blindness/3a.webp)

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/db` wird Main-Prozess-seitig entschlüsselt, ein Filter extrahiert nur `host`, und das wird zum `allowedEgress`-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.

![Pattern A, B und D lassen ein Secret nützliche Arbeit verrichten, ohne je seinen Wert an die KI zurückzugeben; Pattern C, entschlüsseln und an die KI zurückgeben, hat kein Tool, keine IPC-Methode und keinen Code-Pfad, und das Build-Skript erzwingt seine Abwesenheit.](/blog/zero-knowledge-architectural-blindness/2a.webp)

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:

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