L{CORE} Encryption System
This document provides a comprehensive guide to the encryption system used in L{CORE} for protecting sensitive attestation data.
Overview
L{CORE} uses NaCl box encryption (X25519-XSalsa20-Poly1305) to protect sensitive data stored in the Cartesi rollup. This provides:
- Asymmetric encryption - Only the TEE can decrypt data
- Forward secrecy - Each message uses an ephemeral keypair
- Authenticated encryption - Data integrity is verified on decryption
- Fast performance - Optimized for RISC-V execution in Cartesi
Architecture
sequenceDiagram
participant User
participant Attestor as Attestor (TEE)
participant Cartesi as Cartesi Rollup
participant DB as SQLite
Note over Attestor: Holds PRIVATE key
Note over Cartesi: Holds PUBLIC key
User->>Attestor: Request attestation
Attestor->>Attestor: Generate proof (Reclaim)
Attestor->>Cartesi: Submit attestation via InputBox
Cartesi->>Cartesi: Generate ephemeral keypair
Cartesi->>Cartesi: Encrypt with admin PUBLIC key
Cartesi->>DB: Store encrypted data
Note over User,DB: Later - Data Query
User->>Cartesi: Query attestations
Cartesi->>Cartesi: Return encrypted response
Cartesi->>Attestor: Encrypted data
Attestor->>Attestor: Decrypt with admin PRIVATE key
Attestor->>Attestor: Sign decryption proof
Attestor->>User: Decrypted data + proof
Key Management
Key Types
| Key | Location | Purpose |
|---|---|---|
| Admin Public Key | Cartesi SQLite DB | Encrypt outputs |
| Admin Private Key | TEE environment | Decrypt responses |
| Ephemeral Keypair | Generated per-message | Forward secrecy |
Key Generation
Generate a NaCl keypair for L{CORE}:
node -e "
const nacl = require('tweetnacl');
const keypair = nacl.box.keyPair();
console.log('LCORE_ADMIN_PUBLIC_KEY=' + Buffer.from(keypair.publicKey).toString('base64'));
console.log('LCORE_ADMIN_PRIVATE_KEY=' + Buffer.from(keypair.secretKey).toString('base64'));
"
Output:
LCORE_ADMIN_PUBLIC_KEY=abc123...base64...
LCORE_ADMIN_PRIVATE_KEY=xyz789...base64...
Key Storage
| Environment | Variable | Format |
|---|---|---|
| Attestor | LCORE_ADMIN_PRIVATE_KEY | Base64-encoded 32 bytes |
| Attestor | LCORE_ADMIN_PUBLIC_KEY | Base64-encoded 32 bytes (optional, derived) |
| Cartesi | encryption_config table | Base64-encoded public key |
Security Notes:
- The private key never leaves the TEE
- The public key is stored in Cartesi's SQLite database
- Keys are 32 bytes (256 bits) for X25519
Encryption Format
EncryptedOutput Structure
All encrypted data follows this format:
interface EncryptedOutput {
version: 1; // Protocol version
algorithm: 'nacl-box'; // X25519-XSalsa20-Poly1305
nonce: string; // Base64-encoded 24-byte nonce
ciphertext: string; // Base64-encoded encrypted data
publicKey: string; // Base64-encoded ephemeral public key
}
Response Envelope
Encrypted responses are wrapped in an envelope:
// Encrypted response
{
encrypted: true,
payload: EncryptedOutput,
metadata?: Record<string, unknown> // Optional unencrypted metadata
}
// Plaintext response (non-sensitive data)
{
encrypted: false,
data: T
}
Example Encrypted Response
{
"encrypted": true,
"payload": {
"version": 1,
"algorithm": "nacl-box",
"nonce": "kR9y2mF4xL5nQ8tV1wZcBvNmHjKlPqRs",
"ciphertext": "Yx9mQ2nL5vR8tKjH3wZcF4bNmHjPlQr...",
"publicKey": "aB3cD4eF5gH6iJ7kL8mN9oP0qR1sT2uV"
}
}
Encryption Process (Cartesi Side)
The Cartesi rollup encrypts sensitive data before outputting it.
Code Flow
// cartesi/src/encryption.ts
import nacl from 'tweetnacl';
function encryptOutput(data: unknown): EncryptedOutput {
// 1. Get admin public key from database
const config = getActiveEncryptionConfig();
const adminPublicKey = base64ToUint8Array(config.public_key);
// 2. Convert data to bytes
const plaintext = JSON.stringify(data);
const plaintextBytes = new TextEncoder().encode(plaintext);
// 3. Generate ephemeral keypair (forward secrecy)
const ephemeral = nacl.box.keyPair();
// 4. Generate random 24-byte nonce
const nonce = nacl.randomBytes(nacl.box.nonceLength);
// 5. Encrypt using NaCl box
const ciphertext = nacl.box(
plaintextBytes,
nonce,
adminPublicKey, // Recipient's public key
ephemeral.secretKey // Sender's ephemeral secret key
);
// 6. Return encrypted output with ephemeral public key
return {
version: 1,
algorithm: 'nacl-box',
nonce: uint8ArrayToBase64(nonce),
ciphertext: uint8ArrayToBase64(ciphertext),
publicKey: uint8ArrayToBase64(ephemeral.publicKey),
};
}
What Gets Encrypted
| Data Type | Encrypted | Reason |
|---|---|---|
| Individual attestations | ✅ Yes | Contains PII |
| Attestation lists | ✅ Yes | Contains PII |
| Provider schemas | ❌ No | Public configuration |
| Aggregate counts | ❌ No | No PII |
| Access grants | ❌ No | Just addresses |
Decryption Process (Attestor Side)
The Attestor (running in TEE) decrypts responses from Cartesi.
Code Flow
// src/lcore/encryption.ts
import nacl from 'tweetnacl';
function decryptOutput<T>(encrypted: EncryptedOutput): T {
// 1. Get admin private key from environment
const adminPrivateKey = base64ToUint8Array(
process.env.LCORE_ADMIN_PRIVATE_KEY
);
// 2. Decode encrypted components
const nonce = base64ToUint8Array(encrypted.nonce);
const ciphertext = base64ToUint8Array(encrypted.ciphertext);
const ephemeralPublicKey = base64ToUint8Array(encrypted.publicKey);
// 3. Decrypt using NaCl box.open
const decrypted = nacl.box.open(
ciphertext,
nonce,
ephemeralPublicKey, // Sender's ephemeral public key
adminPrivateKey // Recipient's private key
);
if (!decrypted) {
throw new Error('Decryption failed');
}
// 4. Parse JSON and return
const plaintext = new TextDecoder().decode(decrypted);
return JSON.parse(plaintext);
}
Decryption Proof
After decryption, the TEE generates a signed proof:
interface DecryptionProof {
ciphertextHash: string; // SHA256 of encrypted payload
plaintextHash: string; // SHA256 of decrypted data
timestamp: number; // Unix timestamp
teeAddress: string; // TEE wallet address
signature: string; // ECDSA signature
}
This allows clients to verify:
- The data was decrypted by a trusted TEE
- The ciphertext hash matches the Cartesi output
- The plaintext hash matches the returned data
NaCl Box Protocol
Algorithm Details
X25519-XSalsa20-Poly1305:
| Component | Algorithm | Purpose |
|---|---|---|
| Key Exchange | X25519 (Curve25519) | Derive shared secret |
| Encryption | XSalsa20 | Symmetric stream cipher |
| Authentication | Poly1305 | MAC for integrity |
How It Works
ephemeral_public
admin_public
Forward Secrecy
Each encryption uses a new ephemeral keypair:
- Cartesi generates
ephemeral = nacl.box.keyPair() - Cartesi encrypts with
ephemeral.secretKey+admin.publicKey - Cartesi includes
ephemeral.publicKeyin output - Attestor decrypts with
ephemeral.publicKey+admin.privateKey
Benefits:
- Compromising one message doesn't compromise others
- Past messages remain secure even if keys are later compromised
Code Examples
Encrypting Data (Cartesi)
import { encryptResponse, isEncryptionConfigured } from './encryption';
// In a query handler
function handleAttestationQuery(owner: string) {
const attestations = getAttestationsByOwner(owner);
// Encrypt because it contains PII
if (isEncryptionConfigured()) {
return encryptResponse(attestations);
}
// Fallback to plaintext (not recommended for production)
return { encrypted: false, data: attestations };
}
Decrypting Data (Attestor)
import { processLCoreResponse, initDecryption } from '#src/lcore/encryption.ts';
// Initialize at startup
initDecryption();
// When receiving Cartesi response
async function handleCartesiResponse(response: unknown) {
const result = await processLCoreResponse(response);
if ('error' in result) {
throw new Error(result.error);
}
console.log('Decrypted data:', result.data);
console.log('Was encrypted:', result.wasEncrypted);
if (result.proof) {
console.log('Decryption proof:', result.proof);
}
return result.data;
}
Verifying Decryption Proof
import { verifyDecryptionProof } from '#src/lcore/encryption.ts';
function verifyResponse(data: unknown, proof: DecryptionProof) {
// Verify proof signature
const isValid = verifyDecryptionProof(proof);
if (!isValid) {
throw new Error('Invalid decryption proof');
}
// Optionally verify specific TEE address
const isValidTee = verifyDecryptionProof(
proof,
'0xKnownTeeAddress...'
);
return isValid && isValidTee;
}
Setting Up Encryption
1. Generate Keys
node -e "
const nacl = require('tweetnacl');
const keypair = nacl.box.keyPair();
console.log('LCORE_ADMIN_PUBLIC_KEY=' + Buffer.from(keypair.publicKey).toString('base64'));
console.log('LCORE_ADMIN_PRIVATE_KEY=' + Buffer.from(keypair.secretKey).toString('base64'));
"
2. Configure Attestor
Add to .env.eigencloud:
LCORE_ADMIN_PRIVATE_KEY=your-base64-private-key
LCORE_ADMIN_PUBLIC_KEY=your-base64-public-key # Optional
3. Register Public Key in Cartesi
Submit an input to Cartesi via InputBox:
const input = {
type: 'set_encryption_key',
params: {
public_key: 'your-base64-public-key'
}
};
await inputBox.addInput(DAPP_ADDRESS, JSON.stringify(input));
Or via the Attestor API:
curl -X POST http://${ATTESTOR_IP}:8001/api/lcore/set-encryption-key \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"publicKey": "your-base64-public-key"}'
4. Verify Configuration
# Check if encryption is configured
curl "http://${CARTESI_NODE_IP}:10000/inspect/$(python3 -c "import urllib.parse; print(urllib.parse.quote('{\"type\":\"encryption_status\",\"params\":{}}'))")"
Troubleshooting
"Decryption not configured"
Cause: LCORE_ADMIN_PRIVATE_KEY not set in Attestor environment.
Solution:
# Verify the key is set
echo $LCORE_ADMIN_PRIVATE_KEY
# If empty, add it to .env.eigencloud
"Decryption failed - invalid ciphertext or key mismatch"
Cause: Keys don't match between Cartesi and Attestor.
Solution:
// Verify keys match
const nacl = require('tweetnacl');
const publicKey = Buffer.from(process.env.LCORE_ADMIN_PUBLIC_KEY, 'base64');
const privateKey = Buffer.from(process.env.LCORE_ADMIN_PRIVATE_KEY, 'base64');
// Derive public key from private key
const derived = nacl.box.keyPair.fromSecretKey(privateKey).publicKey;
// Compare
if (Buffer.compare(publicKey, derived) === 0) {
console.log('Keys match');
} else {
console.log('KEY MISMATCH - regenerate keys');
}
"Invalid public key length"
Cause: Key is not 32 bytes.
Solution:
# Check key length
echo -n "$LCORE_ADMIN_PUBLIC_KEY" | base64 -d | wc -c
# Should output: 32
"Encryption not configured - admin public key not set"
Cause: Public key not registered in Cartesi database.
Solution: Register the key via set_encryption_key input (see above).
Security Considerations
Threat Model
| Threat | Protected | How |
|---|---|---|
| Cartesi node compromise | ✅ | Data encrypted, only TEE can decrypt |
| Man-in-the-middle | ✅ | Authenticated encryption (Poly1305) |
| Replay attacks | ✅ | Unique nonce per message |
| Key compromise (single) | ✅ | Forward secrecy via ephemeral keys |
Not Protected Against
| Threat | Why |
|---|---|
| TEE compromise | Trust assumption - use remote attestation |
| Quantum computing | NaCl not post-quantum (upgrade path available) |
| Metadata analysis | Encrypted payload size visible |
Best Practices
- Never log private keys - Mask in logs
- Rotate keys periodically - Use
set_encryption_keyto rotate - Verify TEE attestation - Use EigenCloud remote attestation
- Use HTTPS - Encrypt transport layer too
- Audit key access - Monitor who accesses the private key
References
- TweetNaCl.js Documentation
- NaCl: Networking and Cryptography Library
- X25519 Key Exchange (RFC 7748)
- XSalsa20 Stream Cipher
- Poly1305-AES MAC
Credits
L{CORE} encryption is built on TweetNaCl, a JavaScript port of the NaCl cryptographic library by Daniel J. Bernstein.