DEV Community

Philip Stayetski
Philip Stayetski

Posted on

X25519 Key Exchange Explained: Walk Through the Handshake

X25519 is everywhere. Here's what actually happens in the handshake.

Start reading any modern protocol spec — WireGuard, Signal, Noise, Pilot Protocol — and you hit the same wall: "X25519 key exchange." The name appears in a security-considerations section, maybe with a one-liner about Curve25519, and the spec moves on. If you're an engineer building with these protocols (or writing one yourself), that wall is frustrating. What does the handshake actually do? What bytes cross the wire? And why X25519 instead of the ECDHE you already know from TLS?

This is the walkthrough I wanted when I first encountered it: an end-to-end trace of a real X25519 handshake, using an agent-to-agent tunnel as the concrete example.

The problem X25519 solves

Before any encrypted tunnel carries data, both sides need a shared secret that nobody else knows. The classic approach — the Diffie-Hellman you learn in a cryptography course — works over a finite field: both sides pick a private number, send a public value computed from it, and combine their private number with the other side's public value to arrive at the same secret.

X25519 does the same thing, but over an elliptic curve (Curve25519) instead of a finite field. The math difference matters because:

  • Smaller keys: 32-byte public keys instead of the 256-byte or larger keys finite-field DH needs for equivalent security.
  • Faster computation: Curve25519 operations are constant-time and vectorize well on modern CPUs (no side-channel leaks from data-dependent branches).
  • No validation needed: Every 32-byte string is a valid Curve25519 public key. Finite-field DH requires checking that the received value isn't out of range or in a small subgroup.

The handshake, step by step

Let's trace what happens when two agents — call them Agent A and Agent B — establish an encrypted tunnel. They've discovered each other through a registry and agreed to talk. Now they need a shared secret.

Step 1: Generate keypairs.

Each agent generates an X25519 keypair. A keypair is two values:

  • Private key (scalar): 32 random bytes, clamped to prevent small-subgroup attacks (the low 3 bits cleared, the high bit cleared, the second-highest bit set).
  • Public key: The result of the scalar multiplication clamp(private) × basepoint — another 32-byte value derived deterministically from the private key.

In Go (Pilot's implementation language), this is:

privateKey := make([]byte, 32)
rand.Read(privateKey)
publicKey, _ := x25519.X25519(privateKey, curve25519.Basepoint)
Enter fullscreen mode Exit fullscreen mode

The standard library's crypto/curve25519 (stdlib-only, no external deps) does the clamping and multiplication in one call.

Step 2: Exchange public keys.

Agent A sends its 32-byte public key to Agent B. Agent B sends its 32-byte public key to Agent A. Transfer is over UDP — these 32 bytes plus a few bytes of protocol framing fit in a single packet.

The raw exchange at this point is just two 32-byte values crossing the wire. No handshake state beyond "sent pubkey, waiting for peer's."

Step 3: Compute the shared secret.

Each agent performs the scalar multiplication again, this time using their own private key and the peer's public key:

sharedSecret, err := x25519.X25519(myPrivate, peerPublic)
Enter fullscreen mode Exit fullscreen mode

Both agents compute the same 32-byte value — this is the crucial property of Diffie-Hellman. An eavesdropper who saw both public keys pass across the wire cannot compute this value (the computational Diffie-Hellman assumption: given a×G and b×G, computing a×b×G is infeasible).

Step 4: Derive session keys.

The shared secret isn't used directly as an encryption key. Instead, it's fed through a key-derivation function (KDF) to produce separate keys for the two directions of the tunnel:

encrypt_key_a_to_b = HKDF(shared_secret, salt, "pilot-enc-a2b")
encrypt_key_b_to_a = HKDF(shared_secret, salt, "pilot-enc-b2a")
Enter fullscreen mode Exit fullscreen mode

The salt comes from the handshake transcript — a hash of every message sent so far — which binds the session keys to this specific handshake and prevents replay attacks (key confirmation).

Step 5: Encrypt traffic.

Now the tunnel is live. Every packet is encrypted with AES-256-GCM using the derived session key. Each packet carries a nonce that increments monotonically — the receiver rejects any packet that reuses a nonce (replay protection for free, since GCM's security relies on nonce uniqueness).

A real trace from an agent tunnel

Here's what the actual bytes look like when two Pilot Protocol agents handshake. I've simplified the framing to show only the X25519 exchange (the real protocol also includes version negotiation and a handshake transcript hash).

Agent A → B (handshake init, 44 bytes UDP):

Type: 0x01 (KeyExchange)
Pubkey: d85a336b5e8f8a1c9a7b3e2f1c6d8a4b0e2f3c5a7b9d1e3f5c7a9b0d2e4f6c8a
Enter fullscreen mode Exit fullscreen mode

Agent B → A (handshake response, 44 bytes UDP):

Type: 0x02 (KeyExchange)
Pubkey: e7b9c1d3f5a7b9c1d3e5f7a9b0c2d4e6f8a0b2c4d6e8f0a2c4d6e8f0a2c4d6e8
Enter fullscreen mode Exit fullscreen mode

After these two packets, both sides compute the shared secret. The next packet from either direction is encrypted with AES-256-GCM. Total handshake latency: one round trip. About 200-300 microseconds on a modern CPU for the X25519 scalar multiplication itself.

Why protocol specs keep using X25519

Walk through the steps above and a pattern emerges: X25519 fits naturally into the constraints protocol designers actually face.

  • It's fast enough to do per-connection without thinking about it. An X25519 key exchange takes microseconds — not milliseconds — on modern hardware. You don't need session resumption or ticket caching for performance reasons.
  • The implementation is simple enough to audit. The entire Curve25519 scalar multiplication fits in about 300 lines of C (the original TweetNaCl implementation is even smaller). The Go standard library ships it. No external crypto library dependency needed.
  • No edge cases. Finite-field DH has corner cases around group validation and small subgroups that a careful implementation must handle. X25519 eliminates them: every 32-byte input is valid, and clamping handles the small-subgroup threat at the scalar level.
  • It composes cleanly with modern AEAD. The 32-byte shared secret maps naturally into HKDF for key derivation, and HKDF's output feeds AES-256-GCM or ChaCha20-Poly1305. No weird size mismatches.

Common misconceptions

"X25519 and Curve25519 are the same thing." Almost — Curve25519 is the elliptic curve; X25519 is the Diffie-Hellman function defined over it. The distinction matters when you read specs: X25519 is specifically the ECDH function, not the curve itself.

"X25519 needs a trusted setup or certificate." No. X25519 is a key-exchange algorithm, not a PKI. It gives both sides a shared secret after exchanging public keys, but it doesn't tell you who you exchanged it with. Authentication — proving that Agent A really is Agent A — requires an additional step (typically an Ed25519 signature on the handshake transcript). X25519 and Ed25519 share the same curve math (Curve25519 vs Ed25519), but they're different operations: key exchange vs signing.

"You need a separate library for X25519." Not anymore. Go's crypto/curve25519 is stdlib. OpenSSL 1.1.0+ includes it. Libsodium wraps it. BoringSSL ships it. If your language's crypto standard library shipped in the last five years, X25519 is almost certainly already there.

Putting it together

When you see "X25519 key exchange" in a protocol spec, here's what the author is saying: two parties each generate a 32-byte keypair, exchange the public halves, compute a shared 32-byte secret, and derive session keys from it. No CA. No certificates in the key-exchange phase. One round trip. Microseconds of CPU.

The agent tunnel in the example above — two Pilot Protocol agents on different machines, each behind NAT — establishes an encrypted tunnel with exactly this exchange. The X25519 handshake runs once when the tunnel opens, and the derived session keys encrypt every packet after that. The whole handshake takes a single round trip and fits in two 44-byte UDP packets.

If you're building agent infrastructure (or any encrypted transport), the protocol stack you want is X25519 for key exchange, HKDF for key derivation, and AES-256-GCM for bulk encryption — all in your standard library, zero external dependencies. That's the combination that keeps appearing in protocol specs because it works, it's auditable, and it doesn't impose operational complexity.


Pilot Protocol uses exactly this stack for every agent-to-agent tunnel: X25519 + HKDF + AES-256-GCM, all from the Go standard library, zero external crypto dependencies. The same key exchange described above runs between every pair of agents on the network.
.

Top comments (0)