zero-knowledge

Zero-Knowledge comme cécité architecturale (2/5)

La plupart des gestionnaires de secrets exposent getSecret(name), et le texte en clair atterrit dans la transcription de l''IA. Le schéma de lodos rend cet échange structurellement impossible, l''IA ne peut pas voir la valeur d''un secret, et le build l''impose.

10 min de lecture

Votre assistant IA vient de demander à voir votre clé Stripe live. La plupart des gestionnaires de secrets le lui permettraient, l'API expose getSecret(name), l'agent l'appelle, le texte en clair atterrit dans la transcription, et de là dans n'importe quel pipeline avec lequel l'agent communique. La question intéressante est de savoir quel type de schéma rend cet échange structurellement impossible.

Pour lodos, j'ai choisi une position facile à vendre et difficile à concevoir : l'IA ne peut pas voir la valeur d'un secret dans le coffre. Pas « ne veut pas », ne peut pas. Il n'existe aucun chemin de code qui renvoie un secret en clair à l'IA appelante. Le schéma Prisma n'a aucune colonne que l'IA pourrait lire ; la couche IPC n'a aucune méthode qui renvoie un champ déchiffré ; le registre d'outils MCP n'a aucun outil dont le contrat serait donne-moi la valeur. Si un futur moi essaie d'en ajouter un, le script de build l'intercepte avant le merge.

Cet article est la visite guidée du schéma, ce à quoi ressemble concrètement la conception d'une cécité architecturale plutôt qu'une cécité de politique, et ce à quoi on renonce en échange. Le coffre est la couche dont je suis le plus fier, en partie parce que l'ingénierie est honnête sur ce qu'elle ne fait pas.

Le modèle à deux stores

La conception naïve, c'est une seule colonne : encryptedJson contient la charge utile de la section, on déchiffre à la lecture, on sert les champs depuis le blob déchiffré. Ça fonctionne, et ça a une classe de bug, n'importe quel chemin de lecture finit par déchiffrer. La requête qui voulait account_id (public) déchiffrait aussi api_key (sensible). Le journal d'audit capte peut-être le déchiffrement gaspillé ; la fuite vers la transcription du LLM, probablement pas.

Les sections du coffre ont donc deux stores, côte à côte :

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

Le modèle à deux stores : à l'écriture, chaque champ est routé selon son indicateur sensible, les champs sensibles passent par libsodium vers encryptedJson, les champs non sensibles vers une colonne JSON en clair, de sorte que lire un champ public ne déchiffre jamais un secret.

Chaque champ sait s'il est sensitive: true ou sensitive: false. Le routage se fait une seule fois, à l'écriture. Les champs sensibles passent par libsodium ; les champs non sensibles atterrissent dans une colonne JSON en clair. Lire un champ non sensible, c'est une lecture SQLite, pas de déchiffrement, pas d'accès à la DEK, pas d'écriture d'audit. Lire un champ sensible exige le mot de passe maître.

Ce qu'on perd : la possibilité de se mentir à soi-même sur quels champs sont quoi. On ne peut pas décider à la lecture. Le schéma impose de trancher en amont.

Ce qu'on gagne : une classe de bug supprimée. L'outil MCP de chat secrets_field_metadata renvoie la vraie longueur pour les champs non sensibles et 0 pour les champs sensibles, non pas parce qu'on cache la longueur, mais parce que le chemin des métadonnées ne touche jamais encryptedJson. La discipline tient au niveau du type, pas au niveau du commentaire.

Le pipeline de chiffrement

Le côté sensible tourne sur Argon2id et libsodium. Rien d'exotique :

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

Le pipeline de chiffrement : la DEK maître ne vit que dans le processus principal ; le renderer et le sous-processus du Agent SDK n'ont aucun accès au coffre ni aucune capacité de déchiffrement, si bien qu'aucun secret en clair ne traverse jamais la frontière IPC vers l'IA.

Trois propriétés comptent pour la thèse de la cécité. Premièrement, la DEK maître ne vit que dans la mémoire du processus principal, jamais dans le renderer, jamais dans le sous-processus du moteur de chat, jamais dans une charge utile IPC. Deuxièmement, une section unique compromise n'est pas un coffre compromis : chaque section a sa propre sectionKey aléatoire, enveloppée par la DEK maître. Troisièmement, l'état déverrouillé a un délai d'inactivité de cinq minutes et un plafond absolu de trente ; passé ce délai, chaque section n'est qu'à une re-saisie de mot de passe d'être à nouveau illisible.

C'est le moment où j'imagine les lecteurs sensibles à la sécurité dire « oui mais » et commencer à poser des questions sur les dumps mémoire, les fichiers de swap, la résidence GPU. Des questions légitimes, mais largement hors sujet ici. Le point pertinent, c'est que l'IA ne peut pas atteindre ce pipeline. Le Agent SDK tourne dans un sous-processus qui n'a aucun accès à l'état du coffre ; il communique avec le processus principal via IPC, et la surface IPC n'a aucune méthode qui renvoie du texte en clair.

Motif A, B, D: jamais C

Il existe exactement quatre façons d'utiliser un secret dans ce système :

  • A: Ne quitte jamais le coffre. Un secret est référencé par son nom dans un workflow, déchiffré côté processus principal, injecté dans l'environnement d'un sous-processus, jamais renvoyé à l'IA. L'IA voit {{ secrets.stripe.live_key }} dans le YAML et un HTTP 200 dans la réponse.
  • B: Filtre d'egress étroit. Une valeur comme postgres://user:pass@host/db est déchiffrée côté processus principal, un filtre en extrait uniquement host, et cela devient la valeur allowedEgress. Le secret en tant que valeur ne traverse jamais la frontière IPC ; une projection dérivée, si.
  • C: Déchiffrer et renvoyer à l'IA. ❌ N'existe pas. Aucun outil MCP enregistré. Aucune méthode IPC. Aucun chemin de code. Si on grep la base de code pour un nom d'outil correspondant à _value_get$, le résultat est vide par construction. Le script de build l'impose.
  • D: Injecter et exécuter. Un sous-processus est lancé avec le secret dans son environnement. Six couches entre le secret et n'importe quelle surface IA : env uniquement (jamais argv), rédacteur d'encodage sur stdout/stderr, pas de construction de chaîne shell, pare-feu de contenu sur la sortie, entrée de journal d'audit HMAC avec chemin opaque, liste blanche d'egress codée en dur par appel.

Les motifs A, B et D permettent chacun à un secret d'accomplir un travail utile sans jamais renvoyer sa valeur à l'IA ; le motif C, déchiffrer et renvoyer à l'IA, n'a ni outil, ni méthode IPC, ni chemin de code, et le script de build en impose l'absence.

L'intérêt d'écrire cela sous forme de motifs, c'est que C est celui que la plupart des produits expédient. Leur outil de gestion de secrets renvoie le texte en clair à la boucle de l'agent, et ils appellent ça de l'intégration. Les motifs A à D sont aussi de l'intégration, ils ne mettent simplement pas le secret dans la transcription.

Le compromis est honnête. Certains workflows sont plus simples avec le motif C. Aucun n'est nécessaire avec le motif C. Alors on expédie A, B, D, et on utilise un grep au moment du build pour tenir C à l'écart.

La chaîne d'audit opaque

Le journal d'audit est la partie qui m'a le plus surpris lorsque je l'ai conçue.

Une ligne d'audit du coffre dit « section X, champ Y a été injecté par l'acteur Z à l'instant T, signature S, signature précédente P. » Le schéma naturel stocke fieldPath en clair. C'est une fuite : quiconque a un accès en lecture à la table d'audit voit que l'agent IA a injecté stripe.live_key, pas juste qu'il a injecté quelque chose. Pour un acheteur soumis à régulation qui fait une revue de conformité, cette table d'audit elle-même devient une matière secrète.

Le schéma ne stocke donc pas le chemin. Il stocke le hash :

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

Un jeton opaque de seize caractères. Même les réponses d'erreur l'utilisent : quand une injection échoue, l'erreur est { pathHash: "a3f2…", sectionExists: true }, jamais { field: "stripe.live_key", error: "not found" }. Le lecteur d'audit, qui tourne dans le processus principal dans le contexte propre de l'utilisateur, fait la recherche inverse. L'IA ne voit jamais le chemin en clair, et une table d'audit divulguée ne révèle rien sur les secrets stockés.

La chaîne est liée par HMAC, chaque ligne signe (payload + signature-précédente) avec une sous-clé dérivée de la DEK maître. verifyAuditChain() tourne au démarrage ; une ligne altérée ou supprimée brise la chaîne et remonte dans l'interface comme une alerte forensique. La suppression définitive est interdite ; l'archivage est une suppression douce, la ligne restant en place.

On peut prendre une capture d'écran de la table d'audit et la montrer à un auditeur SOC2. L'auditeur voit un identifiant d'acteur, une section, un hash de champ opaque, un horodatage, et une chaîne HMAC. Les noms réels des secrets ne fuitent pas. C'est ce pour quoi les acheteurs soumis à conformité paient.

Quand l'utilisateur n'arrivait pas à distinguer l'architecture d'un bug

Lors du premier vrai dogfooding, un utilisateur a exécuté echo $API_KEY via secret_inject_and_run. L'appel a échoué avec MCP_LETHAL_TRIFECTA_GATE. L'utilisateur a soumis le cas à l'assistant IA pour diagnostic ; l'IA a passé environ 3k tokens à expliquer que la commande était rejetée parce que c'était du « texte statique » et a recommandé de passer à un appel curl. Faux. La condition de la porte était allowedEgress.length === 0 && riskTier === 'novel', l'appel n'avait aucune liste blanche d'egress, il a donc été rejeté comme potentiellement exfiltrant sans destination déclarée.

Le ticket de bug a été reclassé en victoire du moat. L'architecture fonctionnait correctement, et la confusion de l'IA était elle-même une preuve : le système refusait sur la base de la forme de l'egress, pas du contenu de la commande, et le mauvais diagnostic de l'IA était en aval d'une description d'outil peu utile, pas d'un refus cassé. On a corrigé la description ; la porte est restée en place.

La leçon à laquelle je reviens sans cesse, c'est qu'un refus que l'IA ne comprend pas reste un refus. La cécité architecturale n'exige pas l'adhésion du LLM.

Ce que ça vaut commercialement

L'argument que je présente aux fondateurs qui réfléchissent à ce genre de travail est simple. Les acheteurs SOC2 et ISO n'accepteront pas « nous promettons » comme politique. Ils accepteront « nous ne pouvons pas ». Un fournisseur qui peut voir vos clés live et promet de ne pas le faire est un fournisseur qui a occasionnellement des CVE dans sa pile de journalisation. Un fournisseur dont le schéma ne peut pas voir la valeur relève d'une autre catégorie de risque, et cette catégorie se tarife différemment.

Le chemin de Phase-3 que j'ai laissé ouvert dès le premier jour, c'est l'enveloppement par destinataire. La clé de section est déjà une indirection ; aujourd'hui elle n'est enveloppée qu'une fois, par la DEK maître. Dans un contexte d'équipe, elle serait enveloppée N fois, une fois par clé publique de destinataire, et le serveur ne pourrait toujours pas déchiffrer, car il ne détient aucune clé privée. L'enveloppement est additif, pas une réécriture, parce que l'indirection par section a été maintenue générique dès le premier commit. Cette porte reste ouverte sans qu'aucune fonctionnalité n'ait été expédiée pour elle aujourd'hui.

La couche suivante

La conception du schéma protège le secret au repos et à l'injection. Mais les schémas sont du code, et le code peut être modifié. Le prochain article, 32 invariants au moment du build qui font échouer mon build en cas de régression, passe en revue le script qui empêche le schéma Prisma de régresser, le moteur de workflow de développer un eval, et le registre d'outils MCP de faire pousser un secret_value_get. Le refus devient un grep, et le grep est livré avec sa propre fausse violation pour prouver qu'il a encore des dents.

zero-knowledgesecuritearchitectureencryption

Fais tourner ton entreprise sur ta propre machine.

Rejoins la liste d’attente et sois prioritaire à l’ouverture de lodos.