Encryption Pipeline
ENShell uses ECIES (Elliptic Curve Integrated Encryption Scheme) to ensure that AI agent instructions are encrypted end-to-end. The instruction plaintext exists only inside the CRE workflow during the brief window of analysis. Before that, it's ciphertext. After that, only the score and decision persist.
Why Encrypt?
The ActionSubmitted event on-chain contains only an instructionHash (a keccak256 hash of the instruction). The actual instruction content -- "Swap 0.05 ETH for USDC on Uniswap V2" -- never appears on the blockchain.
This matters because:
- Instructions can contain sensitive intent -- trading strategies, business logic, proprietary workflows
- On-chain data is public -- anyone watching the mempool or reading events can see plaintext
- The CRE needs the plaintext -- Claude can't analyze a hash
ECIES solves this: the instruction is encrypted with a key that only the CRE oracle can decrypt, and the encrypted blob is stored off-chain on the relay.
The ECIES Construction
ENShell uses a standard ECIES construction built from three @noble primitives:
| Primitive | Library | Purpose |
|---|---|---|
| secp256k1 ECDH | @noble/secp256k1 |
Key agreement (shared secret derivation) |
| SHA-256 | @noble/hashes |
Key derivation (shared secret → AES key) |
| AES-256-GCM | @noble/ciphers |
Authenticated encryption |
Key Pair
A secp256k1 key pair is generated for the oracle:
- Private key -- 32 bytes, stored in Chainlink Vault DON (threshold-encrypted across oracle nodes in production, local
.envin simulation) - Public key -- 33 bytes (compressed), shipped publicly in the SDK's
NetworkConfig.oraclePublicKey
The public key for Sepolia: 02cea1f34f52c8e8a2d7d5bf4a768677e600be906fb5c68985fe635ac1331409ca
Encryption Flow (SDK side)
When protect() is called:
1. Generate ephemeral secp256k1 keypair
ephemeralPrivate = random 32 bytes
ephemeralPublic = secp256k1.getPublicKey(ephemeralPrivate) // 33 bytes compressed
2. ECDH key agreement
sharedSecret = secp256k1.getSharedSecret(ephemeralPrivate, oraclePublicKey)
3. Key derivation
aesKey = SHA-256(sharedSecret) // 32 bytes
4. Encrypt
nonce = random 12 bytes
ciphertext = AES-256-GCM.encrypt(aesKey, nonce, utf8encode(instruction))
5. Pack
output = ephemeralPublic (33) || nonce (12) || ciphertext (variable)
return "0x" + hex(output)
The ephemeral key pair is generated fresh for every encryption. This provides forward secrecy per-message -- even if a previous ciphertext is somehow compromised, it reveals nothing about future messages.
Packed Wire Format
Offset Length Content
0 33 Compressed ephemeral public key (secp256k1)
33 12 AES-GCM nonce
45 variable AES-GCM ciphertext (includes 16-byte auth tag)
The entire blob is hex-encoded with a 0x prefix and stored on the relay at PUT /relay/{instructionHash}.
Decryption Flow (CRE side)
Inside the CRE runtime, the workflow decrypts:
1. Unpack
ephemeralPublic = bytes[0..33]
nonce = bytes[33..45]
ciphertext = bytes[45..]
2. ECDH key agreement
sharedSecret = secp256k1.getSharedSecret(oraclePrivateKey, ephemeralPublic)
3. Key derivation
aesKey = SHA-256(sharedSecret)
4. Decrypt
plaintext = AES-256-GCM.decrypt(aesKey, nonce, ciphertext)
return utf8decode(plaintext)
The decryption will fail (AES-GCM authentication error) if the wrong private key is used, providing cryptographic integrity verification.
Security Properties
| Property | Guarantee |
|---|---|
| Confidentiality | Only the oracle private key holder can decrypt |
| Integrity | AES-GCM authentication tag detects tampering |
| Forward secrecy | Ephemeral keys per-message; compromising one doesn't compromise others |
| Non-determinism | Same plaintext produces different ciphertext each time |
| Content addressing | instructionHash = keccak256(plaintext) links on-chain hash to relay payload |
Key Management
Oracle Key Pair
| Environment | Private Key Location | Access Model |
|---|---|---|
| Simulation | .env file on developer machine |
Single operator |
| Production | Vault DON (Chainlink) | Threshold-encrypted across oracle nodes |
In the Vault DON model, the oracle private key is split across multiple oracle nodes using threshold cryptography. No single node (or operator) possesses the complete key. The key is only reassembled inside the CRE runtime during decryption.
Public Key Distribution
The oracle's compressed public key is baked into the SDK's NETWORK_CONFIG:
NETWORK_CONFIG[Network.SEPOLIA].oraclePublicKey
// "02cea1f34f52c8e8a2d7d5bf4a768677e600be906fb5c68985fe635ac1331409ca"
This is safe to distribute publicly -- knowing the public key doesn't help decrypt messages.
Implementation
SDK (encrypt)
import { encryptForOracle, NETWORK_CONFIG, Network } from '@enshell/sdk';
const oracleKey = NETWORK_CONFIG[Network.SEPOLIA].oraclePublicKey;
const encrypted = encryptForOracle('Swap 0.05 ETH for USDC', oracleKey);
// "0x02abc...def" (hex-encoded packed bytes)
CRE Workflow (decrypt)
The CRE workflow uses the same @noble libraries directly:
import { getSharedSecret } from '@noble/secp256k1';
import { gcm } from '@noble/ciphers/aes';
import { sha256 } from '@noble/hashes/sha256';
function decryptInstruction(encryptedHex: string, privateKeyHex: string): string {
const bytes = hexToBytes(encryptedHex);
const ephemeralPub = bytes.slice(0, 33);
const nonce = bytes.slice(33, 45);
const ciphertext = bytes.slice(45);
const shared = getSharedSecret(privateKeyHex, ephemeralPub);
const aesKey = sha256(shared);
const plaintext = gcm(aesKey, nonce).decrypt(ciphertext);
return new TextDecoder().decode(plaintext);
}
SDK (decrypt -- for testing/tooling)
import { decryptAsOracle } from '@enshell/sdk';
const plaintext = decryptAsOracle(encryptedHex, oraclePrivateKeyHex);
Zero External Dependencies
The encryption stack uses only @noble libraries:
@noble/secp256k1-- Pure JavaScript secp256k1 implementation by Paul Miller. Audited, no dependencies.@noble/ciphers-- AES-GCM implementation. Audited, no dependencies.@noble/hashes-- SHA-256 implementation. Audited, no dependencies.
The @noble suite is widely used in the Ethereum ecosystem (ethers.js, viem) and has undergone multiple independent security audits.