# Zero-Knowledge como ceguera arquitectónica (2/5)

Source: https://lodos.md/es/blog/zero-knowledge-como-ceguera-arquitectonica
Published: 2026-07-02 · Author: Samet Samyeli · Language: es · Reading time: 10 min

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:

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

![El modelo de dos almacenes: en el momento de escritura, cada campo se enruta según su flag de sensibilidad, los campos sensibles pasan por libsodium hacia encryptedJson, los no sensibles hacia una columna JSON en texto plano, de modo que leer un campo público nunca desencripta un secreto.](/blog/zero-knowledge-architectural-blindness/4a.webp)

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:

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

![El pipeline de encriptación: el DEK maestro vive únicamente en el proceso principal; el renderer y el subproceso del Agent SDK no tienen ningún acceso al vault ni capacidad de desencriptado, de modo que ningún secreto en texto plano cruza jamás el límite IPC hacia la IA.](/blog/zero-knowledge-architectural-blindness/3a.webp)

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/db` se desencripta del lado del proceso principal, un filtro extrae solo `host`, y eso se convierte en el valor de `allowedEgress`. 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.

![Los patrones A, B y D permiten que un secreto haga trabajo útil sin devolver jamás su valor a la IA; el patrón C, desencriptar y devolver a la IA, no tiene herramienta, ni método IPC, ni ruta de código, y el script de build impone su ausencia.](/blog/zero-knowledge-architectural-blindness/2a.webp)

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:

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