Skip to main content

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

KeyLocationPurpose
Admin Public KeyCartesi SQLite DBEncrypt outputs
Admin Private KeyTEE environmentDecrypt responses
Ephemeral KeypairGenerated per-messageForward 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

EnvironmentVariableFormat
AttestorLCORE_ADMIN_PRIVATE_KEYBase64-encoded 32 bytes
AttestorLCORE_ADMIN_PUBLIC_KEYBase64-encoded 32 bytes (optional, derived)
Cartesiencryption_config tableBase64-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 TypeEncryptedReason
Individual attestations✅ YesContains PII
Attestation lists✅ YesContains PII
Provider schemas❌ NoPublic configuration
Aggregate counts❌ NoNo PII
Access grants❌ NoJust 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:

  1. The data was decrypted by a trusted TEE
  2. The ciphertext hash matches the Cartesi output
  3. The plaintext hash matches the returned data

NaCl Box Protocol

Algorithm Details

X25519-XSalsa20-Poly1305:

ComponentAlgorithmPurpose
Key ExchangeX25519 (Curve25519)Derive shared secret
EncryptionXSalsa20Symmetric stream cipher
AuthenticationPoly1305MAC for integrity

How It Works

NaCl Box Encryption Flow
NaCl Box Encryption
Sender (Cartesi)
ephemeral_secret
ephemeral_public
Recipient (Attestor)
admin_private (secret)
admin_public
X25519 ECDH
Key Exchange
Shared Secret
(32 bytes)
XSalsa20
Encryption
Poly1305
MAC
Ciphertext
+ Auth Tag

Forward Secrecy

Each encryption uses a new ephemeral keypair:

  1. Cartesi generates ephemeral = nacl.box.keyPair()
  2. Cartesi encrypts with ephemeral.secretKey + admin.publicKey
  3. Cartesi includes ephemeral.publicKey in output
  4. 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

ThreatProtectedHow
Cartesi node compromiseData encrypted, only TEE can decrypt
Man-in-the-middleAuthenticated encryption (Poly1305)
Replay attacksUnique nonce per message
Key compromise (single)Forward secrecy via ephemeral keys

Not Protected Against

ThreatWhy
TEE compromiseTrust assumption - use remote attestation
Quantum computingNaCl not post-quantum (upgrade path available)
Metadata analysisEncrypted payload size visible

Best Practices

  1. Never log private keys - Mask in logs
  2. Rotate keys periodically - Use set_encryption_key to rotate
  3. Verify TEE attestation - Use EigenCloud remote attestation
  4. Use HTTPS - Encrypt transport layer too
  5. Audit key access - Monitor who accesses the private key

References


Credits

L{CORE} encryption is built on TweetNaCl, a JavaScript port of the NaCl cryptographic library by Daniel J. Bernstein.