Tenzro Testnet is live —request testnet TNZO
← Back to Tutorials
CryptographyIntermediate

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

Prerequisites

  • Tenzro SDK installed (cargo add tenzro-sdk or npm 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 encryption

TypeScript

// 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

What You Learned

Next Steps

Build More with Tenzro

Explore the full cryptographic primitives available in the SDK.