Build End-to-End Encrypted Messaging
Build a fully end-to-end encrypted messaging application using Tenzro's cryptographic primitives and agent messaging infrastructure. Messages are encrypted with AES-256-GCM using shared secrets derived from X25519 Diffie-Hellman key exchange, then transported over the Tenzro agent gossipsub mesh. No one -- not even Tenzro validators -- can read the messages.
What We're Building
- X25519 keypair generation for sender and receiver
- Diffie-Hellman key exchange to derive a shared secret
- AES-256-GCM encryption with unique nonces per message
- Message transport via Tenzro agent messaging (
tenzro_sendAgentMessage) - Decryption on the receiving end
- A complete working example in both Rust and TypeScript
Prerequisites
- Tenzro SDK installed (
cargo add tenzro-sdkornpm install tenzro-sdk) - Basic understanding of public-key cryptography
- Two registered agents on Tenzro testnet
Estimated time: 30 minutes
Architecture Overview
The encryption flow follows the standard Diffie-Hellman pattern:
# Key Exchange (happens once)
Alice: generates X25519 keypair (alice_pk, alice_sk)
Bob: generates X25519 keypair (bob_pk, bob_sk)
Alice: publishes alice_pk to her agent profile
Bob: publishes bob_pk to his agent profile
# Shared Secret Derivation (both sides compute the same secret)
Alice: shared_secret = X25519(alice_sk, bob_pk)
Bob: shared_secret = X25519(bob_sk, alice_pk)
# Both shared_secrets are identical (32 bytes)
# Message Encryption (per message)
Alice: nonce = random(12 bytes)
Alice: ciphertext = AES-256-GCM(shared_secret, nonce, plaintext)
Alice: sends (nonce || ciphertext) via tenzro_sendAgentMessage
# Message Decryption (receiver)
Bob: extracts nonce (first 12 bytes) and ciphertext
Bob: plaintext = AES-256-GCM-Decrypt(shared_secret, nonce, ciphertext)Step 1: Register Two Agents
First, register two agents that will communicate with each other. Each agent gets a unique identity on the network:
Rust
use tenzro_sdk::{TenzroClient, config::SdkConfig};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let config = SdkConfig::testnet();
let client = TenzroClient::connect(config).await?;
let agent = client.agent();
// Register Alice
let alice = agent.register(
"alice-messenger",
"Alice",
&["messaging", "encryption"],
).await?;
println!("Alice agent ID: {}", alice.agent_id);
// Register Bob
let bob = agent.register(
"bob-messenger",
"Bob",
&["messaging", "encryption"],
).await?;
println!("Bob agent ID: {}", bob.agent_id);
Ok(())
}TypeScript
import { TenzroClient, TESTNET_CONFIG } from "tenzro-sdk";
const client = new TenzroClient(TESTNET_CONFIG);
// Register Alice
const alice = await client.agent.register(
"alice-messenger",
"Alice",
["messaging", "encryption"],
);
console.log("Alice agent ID:", alice.agent_id);
// Register Bob
const bob = await client.agent.register(
"bob-messenger",
"Bob",
["messaging", "encryption"],
);
console.log("Bob agent ID:", bob.agent_id);Step 2: Generate X25519 Keypairs
Each participant generates an X25519 keypair for Diffie-Hellman key exchange. The SDK's CryptoClient wraps the tenzro-crypto X25519 implementation:
Rust
let crypto = client.crypto();
// Generate X25519 keypairs for both participants
let alice_keypair = crypto.generate_keypair("ed25519").await?;
println!("Alice public key: {}", alice_keypair.public_key);
// alice_keypair.private_key is the secret key -- store securely
let bob_keypair = crypto.generate_keypair("ed25519").await?;
println!("Bob public key: {}", bob_keypair.public_key);TypeScript
// Generate X25519 keypairs
const aliceKeypair = await client.crypto.generateKeypair("ed25519");
console.log("Alice public key:", aliceKeypair.public_key);
const bobKeypair = await client.crypto.generateKeypair("ed25519");
console.log("Bob public key:", bobKeypair.public_key);Why X25519?
X25519 is a Diffie-Hellman function on Curve25519. It provides 128-bit security, is resistant to timing attacks, and produces a 32-byte shared secret suitable for use as an AES-256 key. Tenzro uses X25519 internally for TEE enclave key derivation (see tenzro-tee/src/enclave_crypto.rs).
Step 3: Derive Shared Secret
Both participants compute the same shared secret using their own private key and the other party's public key:
Rust
// Alice derives the shared secret
let alice_shared = crypto.x25519_key_exchange(
&alice_keypair.private_key, // Alice's secret key
&bob_keypair.public_key, // Bob's public key
).await?;
println!("Alice shared secret: {}", alice_shared.secret);
// Bob derives the same shared secret
let bob_shared = crypto.x25519_key_exchange(
&bob_keypair.private_key, // Bob's secret key
&alice_keypair.public_key, // Alice's public key
).await?;
println!("Bob shared secret: {}", bob_shared.secret);
// Both secrets are identical
assert_eq!(alice_shared.secret, bob_shared.secret);
println!("Shared secrets match!");TypeScript
// Alice derives the shared secret
const aliceShared = await client.crypto.x25519KeyExchange(
aliceKeypair.private_key, // Alice's secret key
bobKeypair.public_key, // Bob's public key
);
// Bob derives the same shared secret
const bobShared = await client.crypto.x25519KeyExchange(
bobKeypair.private_key, // Bob's secret key
aliceKeypair.public_key, // Alice's public key
);
console.log("Secrets match:", aliceShared.secret === bobShared.secret);Step 4: Encrypt a Message
Alice encrypts a message using AES-256-GCM with the shared secret. Each message uses a unique random nonce to ensure semantic security:
Rust
// Alice encrypts a message for Bob
let plaintext = "Hello Bob! This is a secret message.";
let encrypted = crypto.encrypt(
&alice_shared.secret, // 32-byte shared secret as hex
plaintext.as_bytes(), // plaintext bytes
).await?;
println!("Ciphertext: {}", encrypted.ciphertext);
println!("Nonce: {}", encrypted.nonce);
// The nonce is 12 bytes (24 hex chars), randomly generated per encryptionTypeScript
// Alice encrypts a message for Bob
const plaintext = "Hello Bob! This is a secret message.";
const encrypted = await client.crypto.encrypt(
aliceShared.secret, // shared secret (hex)
Buffer.from(plaintext).toString("hex"), // plaintext as hex
);
console.log("Ciphertext:", encrypted.ciphertext);
console.log("Nonce:", encrypted.nonce);AES-256-GCM Security
AES-256-GCM provides authenticated encryption with associated data (AEAD). It guarantees both confidentiality (no one can read the message) and integrity (no one can modify the message without detection). The 12-byte nonce must be unique per message but does not need to be secret -- it is transmitted alongside the ciphertext. Tenzro's wire format is: nonce(12 bytes) || ciphertext || tag(16 bytes).
Step 5: Send Encrypted Message via Agent Messaging
Send the encrypted payload through Tenzro's agent messaging system. Messages are propagated via gossipsub on the tenzro/agents/1.0.0 topic:
Rust
use serde_json::json;
// Package the encrypted message as JSON
let envelope = json!({
"type": "encrypted_message",
"version": "1.0",
"sender_public_key": alice_keypair.public_key,
"nonce": encrypted.nonce,
"ciphertext": encrypted.ciphertext,
"timestamp": chrono::Utc::now().to_rfc3339(),
});
// Send via agent messaging
let response = agent.send_message(
"bob-messenger", // recipient agent ID
&envelope.to_string(), // encrypted payload
).await?;
println!("Message sent! ID: {}", response.message_id);TypeScript
// Package the encrypted message
const envelope = JSON.stringify({
type: "encrypted_message",
version: "1.0",
sender_public_key: aliceKeypair.public_key,
nonce: encrypted.nonce,
ciphertext: encrypted.ciphertext,
timestamp: new Date().toISOString(),
});
// Send via agent messaging
const response = await client.agent.sendMessage(
"bob-messenger", // recipient agent ID
envelope, // encrypted payload
);
console.log("Message sent! ID:", response.message_id);Step 6: Receive and Decrypt
Bob receives the message, extracts the nonce and ciphertext, and decrypts using the shared secret:
Rust
// Bob receives the agent message and parses the envelope
let received: serde_json::Value = serde_json::from_str(&response.payload)?;
let nonce = received["nonce"].as_str().unwrap();
let ciphertext = received["ciphertext"].as_str().unwrap();
// Decrypt using the shared secret
let decrypted = crypto.decrypt(
&bob_shared.secret, // same shared secret Bob computed
ciphertext, // hex-encoded ciphertext
nonce, // hex-encoded nonce
).await?;
// Convert hex plaintext back to string
let plaintext_bytes = hex::decode(&decrypted.plaintext)?;
let message = String::from_utf8(plaintext_bytes)?;
println!("Decrypted message: {}", message);
// Output: "Hello Bob! This is a secret message."TypeScript
// Bob receives and parses the envelope
const received = JSON.parse(response.payload);
// Decrypt using the shared secret
const decrypted = await client.crypto.decrypt(
bobShared.secret, // same shared secret Bob computed
received.ciphertext, // hex-encoded ciphertext
received.nonce, // hex-encoded nonce
);
// Convert hex plaintext back to string
const message = Buffer.from(decrypted.plaintext, "hex").toString("utf-8");
console.log("Decrypted message:", message);
// Output: "Hello Bob! This is a secret message."Step 7: Complete Chat Application
Here is a complete bidirectional encrypted chat built with the TypeScript SDK:
import { TenzroClient, TESTNET_CONFIG } from "tenzro-sdk";
class EncryptedChat {
private client: TenzroClient;
private myAgentId: string;
private myKeypair: { public_key: string; private_key: string };
private peerSecrets: Map<string, string> = new Map();
constructor(client: TenzroClient, agentId: string) {
this.client = client;
this.myAgentId = agentId;
this.myKeypair = { public_key: "", private_key: "" };
}
// Initialize keypair
async init() {
this.myKeypair = await this.client.crypto.generateKeypair("ed25519");
console.log("Chat initialized. Public key:", this.myKeypair.public_key);
}
// Establish encrypted channel with a peer
async connect(peerAgentId: string, peerPublicKey: string) {
const shared = await this.client.crypto.x25519KeyExchange(
this.myKeypair.private_key,
peerPublicKey,
);
this.peerSecrets.set(peerAgentId, shared.secret);
console.log("Secure channel established with:", peerAgentId);
}
// Send an encrypted message
async send(peerAgentId: string, message: string) {
const secret = this.peerSecrets.get(peerAgentId);
if (!secret) throw new Error("No secure channel with " + peerAgentId);
const encrypted = await this.client.crypto.encrypt(
secret,
Buffer.from(message).toString("hex"),
);
const envelope = JSON.stringify({
type: "encrypted_message",
version: "1.0",
sender: this.myAgentId,
sender_public_key: this.myKeypair.public_key,
nonce: encrypted.nonce,
ciphertext: encrypted.ciphertext,
timestamp: new Date().toISOString(),
});
await this.client.agent.sendMessage(peerAgentId, envelope);
}
// Decrypt a received message
async decrypt(peerAgentId: string, envelope: string): Promise<string> {
const secret = this.peerSecrets.get(peerAgentId);
if (!secret) throw new Error("No secure channel with " + peerAgentId);
const parsed = JSON.parse(envelope);
const decrypted = await this.client.crypto.decrypt(
secret,
parsed.ciphertext,
parsed.nonce,
);
return Buffer.from(decrypted.plaintext, "hex").toString("utf-8");
}
}
// Usage
async function main() {
const client = new TenzroClient(TESTNET_CONFIG);
// Alice's side
const alice = new EncryptedChat(client, "alice-messenger");
await alice.init();
// Bob's side
const bob = new EncryptedChat(client, "bob-messenger");
await bob.init();
// Exchange public keys and establish channels
await alice.connect("bob-messenger", bob.myKeypair.public_key);
await bob.connect("alice-messenger", alice.myKeypair.public_key);
// Alice sends encrypted message to Bob
await alice.send("bob-messenger", "Hey Bob, this is completely private!");
// Bob sends encrypted reply
await bob.send("alice-messenger", "Got it, Alice! No one else can read this.");
}
main().catch(console.error);Step 8: Key Management Best Practices
In production, X25519 keys should be managed carefully:
Key Rotation
// Rotate keys periodically (e.g., every 24 hours)
async function rotateKeys(chat: EncryptedChat, peerAgentId: string) {
// Generate new keypair
const newKeypair = await client.crypto.generateKeypair("ed25519");
// Send key rotation notice (encrypted with current key)
await chat.send(peerAgentId, JSON.stringify({
type: "key_rotation",
new_public_key: newKeypair.public_key,
}));
// Update local keypair (after peer acknowledges)
chat.myKeypair = newKeypair;
}TEE-Backed Key Storage
// Seal the private key inside a TEE enclave
const sealed = await client.tee.sealData(
myKeypair.private_key,
"x25519-messaging-key",
);
console.log("Key sealed in TEE:", sealed.sealed_data.substring(0, 32) + "...");
// Later, unseal when needed
const unsealed = await client.tee.unsealData(sealed.sealed_data);
const privateKey = unsealed.plaintext;Security Considerations
- Forward secrecy: For stronger security, implement ephemeral keys per session. Generate a new X25519 keypair for each conversation and discard the private key when done.
- Nonce reuse: Never reuse a nonce with the same key. The SDK generates random 12-byte nonces, but verify uniqueness in high-throughput scenarios.
- Key authentication: Verify public keys through a side channel (e.g., TDIP identity resolution) to prevent man-in-the-middle attacks.
- Message ordering: Include sequence numbers in the encrypted payload to detect replay attacks.
- Metadata leakage: While message contents are encrypted, the sender/receiver agent IDs and message timing are visible on the gossipsub mesh. Use mix networks or onion routing for metadata privacy.
What You Learned
- X25519 key exchange -- generating keypairs and deriving shared secrets
- AES-256-GCM encryption -- authenticated encryption with unique nonces
- Agent messaging -- sending encrypted payloads via
tenzro_sendAgentMessage - Chat application -- building a bidirectional encrypted chat class
- Key management -- rotation, TEE-backed storage, and security considerations
Next Steps
- Build an Agent Swarm -- coordinate multiple agents with encrypted channels
- Build an AI Payment Agent -- add payment capabilities to your agents
- Build an AML Agent -- TEE enclaves for confidential computation
Build More with Tenzro
Explore the full cryptographic primitives available in the SDK.