DEV Community

Cover image for XChaCha20-Poly1305 in a Mesh Network: A Practical Deep Dive
Michael Muriithi
Michael Muriithi

Posted on

XChaCha20-Poly1305 in a Mesh Network: A Practical Deep Dive

Most encryption tutorials stop at "use AES-256-GCM." But in a mesh network, the threat model is different. Keys rotate constantly. Messages arrive out of order. Some nodes are compromised. You need an AEAD cipher that's fast, portable, and plays well with key derivation.

GhostWire uses XChaCha20-Poly1305 as its primary cipher. Here's why, and how it works in practice.

Why Not AES-256-GCM?

AES is fast — on hardware that has AES-NI instructions. But GhostWire runs on ESP32, Raspberry Pi, old laptops, and phones. Not all of these have hardware AES acceleration.

ChaCha20-Poly1305:

  • Constant-time on all platforms (no timing side-channels)
  • Fast in software — often faster than AES on devices without AES-NI
  • Simpler implementation — fewer ways to get it wrong

XChaCha20-Poly1305 extends this with a 192-bit nonce (vs 96-bit for standard ChaCha20-Poly1305). That's critical for mesh networks where you might encrypt millions of messages per key — a 96-bit nonce would exhaust after ~2^32 messages with the same key.

The Encryption Flow

Here's how a message gets encrypted in GhostWire:

use chacha20poly1305::{XChaCha20Poly1305, KeyInit, aead::Aead};
use chacha20poly1305::aead::OsRng;
use x25519_dalek::{EphemeralSecret, PublicKey};

// 1. Key exchange (X25519 ECDH)
let secret = EphemeralSecret::random_from_rng(OsRng);
let public = PublicKey::from(&secret);

// 2. Derive shared secret
let shared = secret.diffie_hellman(&peer_public);

// 3. Derive encryption key from shared secret
let key = hkdf_expand(shared.as_bytes(), b"ghostwire-v1");

// 4. Encrypt with XChaCha20-Poly1305
let cipher = XChaCha20Poly1305::new(key.into());
let nonce = XChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, plaintext)?;

// 5. Send: nonce || ciphertext || tag
Enter fullscreen mode Exit fullscreen mode

The nonce is random (not sequential) because messages might arrive out of order in a mesh network. With a 192-bit random nonce, the probability of collision is negligible even with millions of messages.

Forward Secrecy with the Ratchet

Static keys are a liability. If a node is compromised, all past messages are exposed. GhostWire implements a Double Ratchet pattern (inspired by Signal) for session keys:

Session Key = HKDF(previous_key, message_number)
Enter fullscreen mode Exit fullscreen mode

Every message derives a new key from the previous one. Compromising the current key doesn't expose past messages — the previous keys are deleted after use.

struct RatchetState {
    root_key: [u8; 32],
    chain_key: [u8; 32],
    message_number: u32,
}

impl RatchetState {
    fn next_message_key(&mut self) -> [u8; 32] {
        let key = hkdf_expand(&self.chain_key, &self.message_number.to_le_bytes());
        self.chain_key = hkdf_expand(&self.chain_key, b"chain");
        self.message_number += 1;
        key
    }

    fn advance_chain(&mut self, new_chain_key: [u8; 32]) {
        self.root_key = hkdf_expand(&self.root_key, &new_chain_key);
        self.chain_key = new_chain_key;
    }
}
Enter fullscreen mode Exit fullscreen mode

This gives us forward secrecy (past messages are safe) and future secrecy (compromising one key doesn't expose future messages, if the ratchet step happens).

Post-Quantum Hybrid

XChaCha20-Poly1305 is quantum-safe (it's not based on factoring or discrete log). But the key exchange (X25519) isn't — a quantum computer could derive the shared secret.

GhostWire solves this with hybrid key exchange:

shared_secret = X25519(my_secret, peer_public) || ML-KEM-768(my_secret, peer_public)
Enter fullscreen mode Exit fullscreen mode

Both classical and post-quantum shared secrets are concatenated and fed into HKDF. An attacker needs to break both to get the key. If ML-KEM-768 falls to quantum computing, X25519 still protects you (and vice versa).

fn hybrid_shared_secret(
    x25519_secret: &EphemeralSecret,
    x25519_public: &PublicKey,
    mlkem_ciphertext: &[u8],
) -> [u8; 64] {
    let classical = x25519_secret.diffie_hellman(x25519_public);
    let pq = mlkem_decaps(mlkem_secret, mlkem_ciphertext);

    let mut combined = [0u8; 64];
    combined[..32].copy_from_slice(classical.as_bytes());
    combined[32..].copy_from_slice(&pq);
    combined
}
Enter fullscreen mode Exit fullscreen mode

Performance on Constrained Devices

We benchmarked on an ESP32-S3 (240MHz, no AES-NI):

Operation XChaCha20-Poly1305 AES-256-GCM
Encrypt 1KB 12μs 18μs
Encrypt 64KB 0.8ms 1.1ms
Key derivation 45μs 45μs

ChaCha20 is ~33% faster on this platform. On x86 with AES-NI, AES wins — but GhostWire isn't targeting data centers.

What's Next

In the next article, we'll cover Sphinx onion routing — how GhostWire hides message metadata and sender identity using layered encryption.

GitHub: github.com/Phantomojo/GhostWire-secure-mesh-communication

Website: ghostwire.cc


Built from Nairobi, under RVC.

Top comments (0)