Wallets de agente atestiguadas por TEE: qué prueba de verdad un quote TDX
El pitch de una wallet de agente en TEE cabe en una frase: la clave nace dentro del enclave y nadie, ni siquiera el operador, puede sacarla. La especificación dice algo más estrecho, y en esa diferencia está el dinero.
Un agente autónomo que paga su propia inferencia necesita una clave privada. Dónde vive esa clave es toda la pregunta de seguridad. Una clave en una variable de entorno es una clave que el host puede leer. Una clave en una API custodial es una clave que otro puede mover. La tercera respuesta, la que se volvió el default silencioso del stack de agentes, es derivar la clave dentro de una VM confidencial y publicar una atestación de hardware que lo certifique.
Esa respuesta ya está en producción. dstack corre workloads de Docker Compose dentro de VMs confidenciales Intel TDX y le entrega a cada aplicación una clave derivada más un quote TDX. Phala publica un template de agente ERC-8004 en TEE encima. Oasis publicó el mismo patrón para pagos x402 sobre ROFL. El marketing es idéntico en todos lados: agentes verificables, claves que no se pueden robar.
Leímos los documentos normativos en lugar de las landing pages: la spec del guest API v1 de dstack, la guía de atestación TDX, la spec de env encriptado, el modelo de seguridad y el verificador DCAP on-chain de Automata. Abajo está lo que una wallet atestiguada prueba de verdad, cuánto cuesta verificarla y la única sustitución que sobrevive a toda la checklist.
La clave se deriva, no se genera
La primera corrección es que una clave de aplicación en dstack no es aleatoria. Se deriva, de forma determinista, desde una app root key que custodia el KMS. La especificación del guest API v1 — introducida en dstack 0.6.0 — fija los bytes:
// derivación de claves en dstack.guest.v1
salt = "dstack-guest-v1" // 15 bytes ASCII
IKM = app root secp256k1 private key // 32 bytes
info = LP("dstack-guest-v1-key") || LP(algorithm) || LP(domain)
L = 32
key = HKDF-SHA256(salt, IKM, info, L) // RFC 5869
LP(x) es uint32_be(len(x)) || x. El prefijo de longitud no es decoración. La spec es explícita: un domain son bytes arbitrarios elegidos por el caller, así que "cualquier delimitador que también pudiera contener permitiría que dos pares (domain, algorithm) distintos codifiquen a la misma cadena de bytes y compartan clave". Unir con : o / no alcanza, y v1 no lo hace — una corrección sobre v0, donde el path entraba crudo al KDF y los mismos 32 bytes servían para secp256k1 y para ed25519.
Dos propiedades importan para una wallet. Primera, la derivación es plana: "Dos domains producen claves no relacionadas. a/b es una cadena opaca que casualmente contiene un slash, no un hijo de a". No hay jerarquía tipo BIP-32, así que no hay xpub para entregarle a un auditor ni forma de enumerar las direcciones futuras de un agente. Segunda, los 32 bytes de salida se usan directo — en secp256k1, como el escalar privado big-endian, y la llamada falla en vez de plegar el valor si cae fuera de rango, "porque plegar el escalar haría que dos domains aterricen silenciosamente en la misma clave".
La consecuencia práctica: la dirección Ethereum de un agente es función pura de la app root key, el string de algoritmo y un string de domain que elige la aplicación. Mismos inputs, misma dirección, en cualquier máquina que alcance el mismo KMS. Eso es una feature — la wallet sobrevive a una falla de hardware sin backup de seed phrase — y también es la superficie de ataque que aparece más abajo.
La cadena de firmas es lo que verifica la contraparte
Una clave pública derivada, sola, no vale nada. GetKey la devuelve con una cadena de firmas de dos elementos: el link 0 es la app root key firmando un claim sobre la clave derivada, el link 1 es la KMS root key firmando la app root public key. El claim se codifica con los mismos prefijos de longitud:
claim = LP("dstack-guest-v1-key-claim")
|| LP(algorithm)
|| LP(domain)
|| LP(public_key)
digest = keccak256(claim)
link0 = r || s || v // 65 bytes, ECDSA recuperable
La spec incluye algo que casi ningún doc de protocolo trae: un argumento de por qué el formato viejo no se puede forzar al nuevo. El claim de v0 era keccak256("{purpose}:{hex(pubkey)}") con purpose elegido por el caller, así que una app maliciosa podía hacer que la root key firmara casi cualquier string ASCII terminado en dos puntos y hex minúscula. Un claim v1 termina en LP(public_key), cuyos cuatro bytes de longitud son 00 00 00 21 para secp256k1, ubicados a 37 bytes del final — dentro de la región que un preimage v0 obliga a ser solo hex, y 0x00 no es un carácter hex. Las dos cadenas de bytes nunca pueden ser iguales, así que falsificar entre superficies se reduce a un ataque de preimagen sobre keccak256. Hay un test de regresión con el nombre del ataque.
La verificación son seis pasos, y el primero carga con todo: obtener la clave pública raíz del KMS "desde una fuente en la que confíes de forma independiente del agente que estás verificando" — el kmsInfo().k256Pubkey del contrato DstackKms, o un valor fijado fuera de banda. La spec enuncia el modo de falla sin rodeos: "Un atacante que pueda responder tu consulta por el anchor también puede acuñar una cadena auto-consistente, así que leer el anchor desde el KMS que estás verificando no prueba nada".
El paso seis es el que las integraciones se saltan. Después de recuperar la app root key y verificar la firma del KMS, hay que "confirmar que el app_id que usaste es la aplicación con la que querías hablar. La cadena prueba que el KMS emitió esta app root key a alguna aplicación; solo este paso la ata a la tuya". Una cadena que verifica limpio contra un KMS real y pertenece a otro agente es una cadena válida.
Qué mide el quote
La cadena establece la procedencia de la clave. El quote TDX establece el contexto de ejecución. La guía de atestación mapea cada registro:
MRTD contiene la medición del firmware virtual, tomada por el módulo TDX en modo SEAM — OVMF, el primer código ejecutado tras el arranque de la CVM, "que sirve como ancla de confianza del código de la App". RTMR0 registra el setup de hardware virtual: cantidad de CPU, tamaño de memoria, configuración de dispositivos. RTMR1 registra el kernel de Linux. RTMR2 registra el cmdline del kernel, incluido el hash del rootfs, y el initrd. RTMR3 es el interesante: el initrd registra los detalles de la app dstack — compose hash, política de GPU, instance id, app id, key provider.
MRTD hasta RTMR2 se pueden precalcular desde la imagen construida dadas las specs de CPU y RAM, por eso dstack incluye dstack-mr y un build reproducible. RTMR3 no se puede precalcular; se verifica replayando el event log y comprobando que el resultado coincide con el quote. Después se verifica que los eventos replayados — compose hash, instance id, app id, hash del rootfs, key provider — coincidan con lo esperado.
La aplicación ata sus propios datos mediante report_data: hasta 64 bytes, rellenados con ceros a la derecha, y más de 64 bytes es un error, no un truncamiento. En v1 no existe EmitEvent; los eventos RTMR3 de runtime pasaron a ser propiedad del sistema en 0.6.0, así que una aplicación no puede escribir su propia medición. Tiene 64 bytes y disciplina de nonce.
Fíjate en lo que no está en esa lista. Ningún registro mide el modelo del agente, su prompt, su archivo de políticas ni el saldo de la wallet que controla. El compose hash cubre el documento Compose que nombra los digests de las imágenes. Todo lo que el agente decide en runtime queda fuera de la medición.
La variable de entorno que elige tu wallet
Ahora combina tres hechos, cada uno documentado por dstack mismo.
Uno: la clave derivada es función de (app root key, algorithm, domain), y domain lo elige la aplicación. Dos: en la práctica las aplicaciones toman ese domain de la configuración — el template de agente ERC-8004 en TEE exige una variable de entorno AGENT_SALT como parte de su configuración documentada. Tres: la especificación de env encriptado dice que "la encriptación provee confidencialidad, no autenticación de origen", y que como app_id es público y GetAppEnvEncryptPubKey se puede llamar con él, cualquiera con acceso al VMM puede obtener la clave pública de encriptación de la app, encriptar un payload de env distinto y enviarlo como reemplazo. "La CVM va a desencriptar y usar ese payload si la desencriptación tiene éxito".
Es decir: un operador que alcanza el VMM puede cambiar el string de domain. La CVM arranca, deriva otra clave, produce otra dirección y atestigua todo eso. El quote es genuino. Las mediciones coinciden. La cadena de firmas verifica. El compose hash no cambió, porque el documento Compose no cambió — solo cambió el payload de env encriptado. Todas las casillas de la checklist de verificación se marcan, y el agente está firmando con una wallet que el deployer nunca fondeó.
Esto no es una ruptura del TEE. Es exactamente el límite que dstack dibuja en su modelo de seguridad: "Las variables de entorno encriptadas impiden que el host lea tus secretos. Sin embargo, el host puede reemplazar los valores encriptados por otros distintos". La mitigación documentada es el patrón de launch token — poner APP_LAUNCH_TOKEN en el env encriptado y verificar su hash en prelaunch, donde el hash sí queda medido vía app-compose.json — o firmar el payload de env con una clave del desarrollador y verificarla antes de usarla.
Para una wallet hay un arreglo más barato: atar la dirección on-chain antes de que tenga valor. Registra la dirección derivada en un identity registry, fondea solo esa dirección y trata cualquier atestación que traiga otra dirección como un despliegue fallido, no como un agente nuevo. Esa es la propiedad que da un registro de identidad ERC-8004 y que una atestación cruda no da — un compromiso previo sobre qué clave se supone que debe aparecer.
Cuánto cuesta verificarlo on-chain
Todo lo anterior es verificación off-chain. En el momento en que un contrato tiene que decidir si un agente está atestiguado, se paga la verificación DCAP dentro de la EVM. Automata DCAP Attestation es el camino de producción, y su README publica los números:
// Métodos de verificación, Automata DCAP Attestation v1.1
Onchain ~4-5M gas tiempo de proving: instantáneo
RiscZero Groth16 522k gas <1 min
SP1 Groth16 493k gas <30s
SP1 Plonk 569k gas <2 min
// Onchain: ~4M con el precompile RIP-7212, ~5M sin él
Cuatro a cinco millones de gas es el presupuesto de un bloque entero en algunas cadenas, y por eso existe el camino ZK: correr el programa de verificación del quote DCAP dentro de RISC Zero o SP1 y después verificar una sola prueba Groth16 por alrededor de medio millón de gas. El entrypoint acepta ambos:
function verifyAndAttestOnChain(bytes rawQuote, uint32 tcbEvalDataNumber);
function verifyAndAttestWithZkProof(
bytes output,
uint8 zkCoProcessor, // 1 = RiscZero, 2 = Succinct
bytes proof,
bytes32 programIdentifier,
uint32 tcbEvalDataNumber
);
Las notas de release de v1.1 agregan un detalle relevante para cualquiera que siga el roadmap de Ethereum: el soporte del precompile secp256r1 (EIP-7951) está activo en Hoodi y Sepolia con el hardfork Fusaka, y "reduce el costo de verificación ECDSA de 330k gas a 6000 gas por firma", con aproximadamente "1M de gas menos por verificación on-chain de un quote DCAP". El mismo precompile que auditamos para pagos x402 firmados con passkey recorta una quinta parte del costo de verificar un quote TEE. P-256 es la curva que comparten la cadena de atestación de Intel y WebAuthn, y abaratarla ayuda a las dos a la vez.
Dos hechos operativos más. Los contratos fueron auditados por Trail of Bits en febrero de 2025; una revisión de OpenZeppelin en octubre de 2025 sobre un verificador downstream detectó un problema de validez de timestamp en el PCCS Router, corregido en v1.1. Y el entrypoint está desplegado en la misma dirección — 0xaDdeC7e85c2182202b66E331f2a4A0bBB2cEEa1F — en los mainnets de Base, Arbitrum One, Optimism, Polygon, BNB Chain, Unichain, World Chain, HyperEVM y Avalanche, lo que convierte un verificador multi-chain en una constante en vez de una tabla de configuración.
Cuatro límites que dstack documenta sobre sí mismo
El modelo de seguridad es inusualmente franco, y tres de sus límites son estructurales para un agente que paga.
La atestación prueba identidad, no corrección
"La atestación prueba qué código está corriendo, no que el código esté libre de bugs. Prueba que el entorno está aislado, no que tu aplicación maneje bien los secretos". Un agente atestiguado con un agujero de prompt injection es un agente atestiguado que le paga a un atacante. El quote sube el piso sobre quién puede manipular; no dice nada sobre lo que el agente decide.
El estado en disco no tiene garantía de frescura
El volumen LUKS2 "no lleva tag de autenticación", y ninguno de los filesystems "prueba que un disco adjunto represente el último estado de la aplicación. Un operador de infraestructura puede retener, borrar, reemplazar o restaurar una imagen de disco encriptada anterior y válida". Para un agente que lleva su propio presupuesto de gasto o su contador de nonce en disco, eso es un rollback: restauras el disco de ayer y el presupuesto se recarga. La guía de dstack es anclar una versión monótona o un compromiso de estado en un servicio o ledger externo de confianza. Para un agente de pagos eso significa que el límite de gasto va on-chain o en el facilitator, nunca solo en el almacenamiento de la CVM.
El estado del TCB se expone, no se bloquea
El validate_tcb de dstack "no rechaza un quote en base a su string de estado de TCB" — UpToDate, OutOfDate, ConfigurationNeeded pasan todos la primitiva, que solo exige que el modo debug esté apagado y que las mediciones SEAM estén bien formadas. Solo un TCB Revoked se rechaza de plano, vía dcap-qvl. Si un TCB desactualizado es aceptable es explícitamente "una decisión de política que corresponde río abajo". Si tu verificador no implementa esa política, aceptas toda plataforma no revocada.
El cuarto es estructural: "Todas las claves derivan de la KMS root key, protegida por aislamiento TEE. Como en todo sistema basado en TEE, un compromiso del TEE podría exponer la root key". dstack dice estar desarrollando un KMS basado en MPC para eliminar ese punto único de falla. Hasta entonces, cada wallet de agente derivada en un despliegue comparte una sola raíz de compromiso — la misma concentración de riesgo que mapeamos en el modelo de amenazas de agentes, movida una capa hacia abajo, al hardware.
Dónde toca esto a x402
x402 no tiene campo de atestación. El payload de pago lleva una autorización EIP-3009 — una firma sobre una transferencia con nonce y ventana de validez — y los endpoints /verify y /settle del facilitator se ocupan de una sola cosa: si la firma recupera a una dirección que tiene los fondos. Un firmante derivado en TEE es indistinguible de una clave en un .env al nivel del cable.
Ese es el default correcto para un protocolo de pagos, y también es por qué las afirmaciones de "agente atestiguado" hoy casi nunca llegan a la contraparte. Los dos lugares donde la evidencia puede aterrizar están arriba y abajo del pago: una entrada en un identity registry que se compromete con la dirección antes de fondearla, y una capa de política que se niegue a liberar valor a un firmante sin atestación. El trabajo de Oasis con ROFL y x402 empuja por el otro extremo — correr el propio facilitator dentro de un TEE, con el argumento de que los facilitators "hoy son altamente centralizados y opacos" y podrían censurar transacciones. Misma primitiva, lado opuesto del handshake.
Ambas direcciones son patrones de implementación, no protocolo. Nada en la spec de x402 obliga a un facilitator a verificar un quote, y nada obliga a un seller a pedirlo. La brecha es real, y es del tipo de brecha que cierra quien tiene un motivo para cerrarla.
Qué significa para LLM4Agents
LLM4Agents está justo donde la pregunta de la atestación se vuelve concreta: un agente se registra, fondea un balance en stablecoins y llama a un gateway compatible con OpenAI que mide por token. Del audit se siguen tres consecuencias.
Primera, el gateway es un consumidor natural de atestación, y barato. Ya autenticamos cada request. Aceptar una VersionedAttestation opcional en el registro — verificar el quote, replayar el event log, verificar la cadena de firmas, confirmar que la dirección derivada es igual a la que se está registrando — cuesta una verificación por vida de agente, no una por llamada. Eso es verificación off-chain con dcap-qvl, no 4M de gas. El resultado es un tier: la clave de este agente vive demostrablemente en una CVM que corre un compose hash conocido.
Segunda, el hallazgo de la sustitución fija el orden de operaciones. Primero atar, después fondear. Si un agente registra una dirección atestiguada, la plataforma debería fijarla y negarse a servir a otro firmante bajo el mismo agent id, incluso con una atestación válida. La atestación prueba que una clave se derivó en un TEE genuino; solo nuestro propio registro previo prueba que es la clave que el operador quiso.
Tercera, el límite de frescura dice dónde deben vivir los controles de gasto. Un agente que aplica su propio presupuesto dentro de una CVM puede ser revertido por quien tenga el disco. El control de presupuesto va de nuestro lado del medidor — el balance y el tope por período son estado del gateway, no del agente. Así funciona ya la plataforma, y el caso TEE es un argumento para mantenerlo así en vez de empujar los límites hacia el cliente.
La amenaza que esto no resuelve es la que más pesa en la práctica. Un agente atestiguado con el prompt comprometido sigue siendo un agente comprometido, y ahora carga un certificado de hardware que dice que su código es exactamente el que declara. La atestación es evidencia sobre procedencia. No es evidencia sobre criterio, y un sistema de tiers que la trate como lo segundo sería peor que no tener tiers.
Cómo mantenerse en la frontera
Pasos concretos, en el orden en que rinden.
1. Enviar verificación de atestación en el registro, opcional y off-chain. Aceptar una VersionedAttestation en el endpoint de registro. Verificar el quote TDX con dcap-qvl, replayar el event log contra RTMR3, verificar la cadena de dos eslabones con el anchor del KMS leído desde el contrato DstackKms y no desde el agente, y exigir que la dirección derivada sea igual a la que se registra. Rechazar quotes en modo debug. Exponer el estado del TCB en el registro del agente en vez de bloquear por él, y decidir la política con datos.
2. Fijar el firmante. Una vez que un agente registra una dirección atestiguada, tratar cualquier cambio como un agente nuevo. Guardar juntos el app id, el compose hash y la dirección derivada. Esa es la mitigación del camino de sustitución de env y cuesta una columna de base de datos.
3. Publicar nuestras propias mediciones. La asimetría vale la pena notarla: le pedimos a los agentes que prueben qué corren mientras nosotros no probamos nada. Correr el camino de medición y settlement en una CVM con compose hash publicado hace auditable nuestro lado, y es la versión creíble de "no leemos tus prompts". Empezar por el componente de settlement, no por el gateway entero.
4. Tratar la verificación on-chain como herramienta de settlement, no de runtime. A 4-5M de gas por quote, ningún flujo por llamada puede pagarla. Los usos on-chain realistas son entradas de registro únicas y resolución de disputas, y el camino ZK a ~500k de gas es lo que vuelve rutinarios incluso esos. Seguir el rollout de EIP-7951 después de Fusaka, porque otro millón de gas menos cambia qué flujos son costeables.
5. Mantener una posición sobre la brecha en x402. No hay campo de atestación en el payload de pago. Si se propone uno, la forma que vale la pena apoyar es una referencia de evidencia opcional junto a la autorización EIP-3009 — una URI más un digest, verificada fuera de banda, nunca un quote de varios kilobytes en un header HTTP. Hasta entonces, el identity registry es el punto de atadura, y ahí es donde debe ir el esfuerzo.
Corre agentes que pagan por token, en stablecoins
Gateway compatible con OpenAI, settlement con x402 y EIP-3009, balances y topes por agente aplicados de nuestro lado del medidor.
Registrar un agente