zero-knowledge
Zero-Knowledge como ceguera arquitectónica (2/5)
La mayoría de los gestores de secretos exponen getSecret(name), y el texto plano termina en la transcripción de la IA. El esquema de lodos hace que ese intercambio sea estructuralmente irrealizable, la IA no puede ver el valor de un secreto, y el build lo impone.
Tu asistente de IA acaba de pedir ver tu clave live de Stripe. La mayoría de los gestores de secretos se lo permitirían, la API expone getSecret(name), el agente la invoca, el texto plano termina en la transcripción, y de ahí pasa a cualquier pipeline con el que hable el agente. La pregunta interesante es qué tipo de esquema hace que ese intercambio sea estructuralmente irrealizable.
Para lodos elegí una postura fácil de anunciar y difícil de construir: la IA no puede ver el valor de ningún secreto en el vault. No es "no quiere", no puede. No existe ninguna ruta de código que devuelva un secreto en texto plano a la IA que lo solicita. El esquema de Prisma no tiene ninguna columna que la IA pueda leer; la capa IPC no tiene ningún método que devuelva un campo desencriptado; el registro de herramientas MCP no tiene ninguna herramienta cuyo contrato sea dame el valor. Si un yo futuro intenta añadir una, el script de build la detecta antes del merge.
Este post es el recorrido por el esquema, cómo es en la práctica diseñar ceguera arquitectónica en lugar de ceguera por política, y qué se sacrifica a cambio. El vault es la capa de la que más orgulloso estoy, en parte porque la ingeniería es honesta sobre lo que no hace.
El modelo de dos almacenes
El diseño ingenuo es una sola columna: encryptedJson contiene el payload de la sección, se desencripta al leer, se sirven los campos desde el blob desencriptado. Eso funciona, y tiene una clase de bug, cualquier ruta de lectura termina desencriptando. La búsqueda que quería account_id (público) también desencriptaba api_key (sensible). El log de auditoría podría detectar el desencriptado innecesario; la fuga hacia la transcripción del LLM probablemente no.
Así que las secciones del vault tienen dos almacenes, uno junto al otro:
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
// ...
}

Cada campo sabe si es sensitive: true o sensitive: false. El enrutamiento ocurre una sola vez, en el momento de escritura. Los campos sensibles pasan por libsodium; los no sensibles caen en una columna JSON en texto plano. Leer un campo no sensible es una lectura de SQLite, sin desencriptado, sin acceso al DEK, sin escritura de auditoría. Leer un campo sensible requiere la contraseña maestra.
Lo que se pierde: la capacidad de mentirse a uno mismo sobre qué campos son cuáles. No se puede decidir en el momento de lectura. El esquema obliga a tomar la decisión de antemano.
Lo que se gana: una clase de bug eliminada. La herramienta MCP de chat secrets_field_metadata devuelve la longitud real para los campos no sensibles y 0 para los sensibles, no porque ocultemos la longitud, sino porque la ruta de metadatos nunca toca encryptedJson. La disciplina se sostiene a nivel de tipos, no a nivel de comentario.
El pipeline de encriptación
El lado sensible funciona sobre Argon2id y libsodium. Nada exótico:
// 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

Tres propiedades importan para la afirmación de ceguera. Primero, el DEK maestro vive únicamente en la memoria del proceso principal, nunca en el renderer, nunca en el subproceso del motor de chat, nunca en un payload IPC. Segundo, una sección comprometida no es un vault comprometido: cada sección tiene su propia sectionKey aleatoria, envuelta por el DEK maestro. Tercero, el estado de desbloqueo tiene un timeout de inactividad de cinco minutos y un tope absoluto de treinta; pasado eso, cada sección queda a un solo reingreso de contraseña de volverse ilegible.
Aquí es donde espero que los lectores con mentalidad de seguridad digan "sí, pero" y empiecen a preguntar sobre volcados de memoria, archivos de swap, residencia en GPU. Preguntas válidas, en su mayoría fuera del alcance aquí. El punto relevante es que la IA no puede alcanzar este pipeline. El Agent SDK corre en un subproceso que no tiene acceso al estado del vault; habla con el proceso principal por IPC, y la superficie IPC no tiene ningún método que devuelva texto plano.
Patrón A, B, D: nunca C
Hay exactamente cuatro formas en que un secreto puede usarse en este sistema:
- A: Nunca sale del vault. Un secreto se referencia por nombre en un workflow, se desencripta del lado del proceso principal, se inyecta en el entorno de un subproceso, y nunca se devuelve a la IA. La IA ve
{{ secrets.stripe.live_key }}en el YAML y un HTTP 200 en la respuesta. - B: Filtro de salida estrecho. Un valor como
postgres://user:pass@host/dbse desencripta del lado del proceso principal, un filtro extrae solohost, y eso se convierte en el valor deallowedEgress. El secreto como valor nunca cruza el límite IPC; una proyección derivada sí. - C: Desencriptar y devolver a la IA. ❌ No existe. No hay ninguna herramienta MCP registrada. No hay ningún método IPC. No hay ninguna ruta de código. Si haces grep en el código buscando cualquier nombre de herramienta que coincida con
_value_get$, el resultado está vacío por construcción. El script de build lo impone. - D: Inyectar y ejecutar. Se lanza un subproceso con el secreto en su entorno. Seis capas entre el secreto y cualquier superficie de IA: solo en entorno (no en argv), redactor de codificación en stdout/stderr, sin construcción de strings de shell, firewall de contenido sobre la salida, entrada de log de auditoría con HMAC con ruta opaca, lista blanca de salida fija por llamada.

El sentido de escribir esto como patrones es que C es el que envía la mayoría de los productos. Su herramienta de gestión de secretos devuelve el texto plano al bucle del agente, y a eso lo llaman integración. Los patrones A a D también son integración, simplemente no ponen el secreto en la transcripción.
El intercambio es honesto. Algunos workflows son más fáciles con el patrón C. Ninguno es necesario con el patrón C. Así que enviamos A, B, D, y usamos un grep en tiempo de build para mantener C fuera.
La cadena de auditoría opaca
El log de auditoría es la parte que más me sorprendió al diseñarla.
Una fila de auditoría del vault dice "sección X, campo Y fue inyectado por el actor Z en el momento T, firma S, firma-previa P." El esquema natural almacena fieldPath en texto plano. Eso es una fuga: cualquiera con acceso de lectura a la tabla de auditoría ve que el agente de IA inyectó stripe.live_key, no solo que inyectó algo. Para un comprador regulado haciendo una revisión de cumplimiento, esa misma tabla de auditoría se convierte en material secreto.
Así que el esquema no almacena la ruta. Almacena el hash:
fieldPathHash: sha256(sectionName + '.' + fieldKey).slice(0, 16)
Un token opaco de dieciséis caracteres. Hasta las respuestas de error lo usan: cuando falla una inyección, el error es { pathHash: "a3f2…", sectionExists: true }, nunca { field: "stripe.live_key", error: "not found" }. El lector de auditoría, ejecutándose en el proceso principal dentro del propio contexto del usuario, hace la búsqueda inversa. La IA nunca ve la ruta en texto plano, y una tabla de auditoría filtrada no revela nada sobre qué secretos están almacenados.
La cadena está enlazada con HMAC, cada fila firma (payload + firma-previa) con una subclave derivada del DEK maestro. verifyAuditChain() se ejecuta al arrancar; una fila alterada o eliminada rompe la cadena y sale a la superficie en la UI como alerta forense. El borrado definitivo está prohibido; archivar es un borrado suave con la fila dejada en su sitio.
Puedes tomarle una captura de pantalla a la tabla de auditoría y mostrársela a un auditor de SOC2. El auditor ve un identificador de actor, una sección, un hash de campo opaco, una marca de tiempo y una cadena HMAC. Los nombres reales de los secretos no se filtran. Eso es lo que los compradores de cumplimiento están pagando.
Cuando el usuario no pudo distinguir la arquitectura de un bug
Durante el primer dogfood real, un usuario ejecutó echo $API_KEY a través de secret_inject_and_run. La llamada falló con MCP_LETHAL_TRIFECTA_GATE. El usuario se lo llevó al asistente de IA para diagnosticarlo; la IA gastó unos 3k tokens explicando que el comando fue rechazado porque era "texto estático" y recomendó cambiar a una llamada curl. Incorrecto. La condición de la compuerta era allowedEgress.length === 0 && riskTier === 'novel', la llamada no tenía lista blanca de salida, así que fue rechazada por potencialmente exfiltrar sin un destino declarado.
El ticket de bug se reclasificó como victoria del moat. La arquitectura funcionó correctamente, y la confusión de la IA fue en sí misma evidencia: el sistema rechazó según la forma de la salida, no según el contenido del comando, y el diagnóstico erróneo de la IA era consecuencia de una descripción de herramienta poco útil, no de un rechazo roto. Editamos la descripción; la compuerta se quedó donde estaba.
La lección a la que sigo volviendo es que un rechazo que la IA no entiende sigue siendo un rechazo. La ceguera arquitectónica no requiere que el LLM esté de acuerdo con ella.
Lo que esto vale comercialmente
El discurso que hago a los fundadores que piensan en este tipo de trabajo es directo. Los compradores de SOC2 e ISO no aceptarán "lo prometemos" como política. Aceptarán "no podemos." Un proveedor que puede ver tus claves live y promete no hacerlo es un proveedor que ocasionalmente tiene CVEs en su stack de logging. Un proveedor cuyo esquema no puede ver el valor es una categoría de riesgo distinta, y esa categoría se cotiza diferente.
El camino de Fase 3 que dejé abierto desde el primer día es el envoltorio por destinatario. La clave de sección ya es una indirección; hoy se envuelve una sola vez con el DEK maestro. En un entorno de equipo, se envuelve N veces, una por cada clave pública de destinatario, y el servidor sigue sin poder desencriptar porque no tiene ninguna clave privada. El envoltorio es aditivo, no una reescritura, porque la indirección por sección se mantuvo genérica desde el primer commit. Esa puerta permanece abierta sin que se haya enviado ninguna función para ella todavía.
La siguiente capa
El diseño del esquema protege el secreto en reposo y en la inyección. Pero los esquemas son código, y el código se puede editar. El próximo post, 32 Build-Time Invariants That Fail My Build If I Regress, recorre el script que evita que el esquema de Prisma retroceda, que el motor de workflows desarrolle un eval, y que el registro de herramientas MCP haga brotar un secret_value_get. El rechazo se convierte en un grep, y el grep se envía con su propia violación falsa para demostrar que el grep todavía tiene dientes.