Tools · Cryptography and passwords in practice · 14 / 20
Elliptic curve key exchange with X25519: how two parties agree on a key
Run an X25519 Diffie-Hellman exchange in Python, derive a symmetric key with HKDF, and see why authentication is still needed to stop a machine in the middle.
The idea
In Diffie-Hellman key agreement, each party generates a key pair and sends only the public key. Each combines its own private key with the other’s public key and arrives at the same shared secret, which an eavesdropper who sees only public keys cannot compute. X25519 is the elliptic curve Diffie-Hellman function over Curve25519, used in TLS 1.3, SSH, Signal, WireGuard, and age.
Never use the raw shared secret directly
The output of X25519 is 32 bytes with structure. Pass it through a key derivation function such as HKDF with a context string to produce the actual encryption key. The context, sometimes called info, separates keys for different purposes.
Lab: two parties, one key
Generate key pairs for Alice and Bob, exchange public keys, compute shared secrets, and confirm they match. Derive an AES-GCM key from each and encrypt a message from Alice that Bob decrypts. Then simulate Mallory: generate a third key pair and show that if Mallory can replace Bob’s public key in transit, Alice ends up sharing a key with Mallory instead.
import os
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def derive(shared):
return HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=b'hackblue lab v1').derive(shared)
alice, bob = X25519PrivateKey.generate(), X25519PrivateKey.generate()
k_alice = derive(alice.exchange(bob.public_key()))
k_bob = derive(bob.exchange(alice.public_key()))
assert k_alice == k_bob
nonce = os.urandom(12)
ct = AESGCM(k_alice).encrypt(nonce, b'meet at the lab', None)
print(AESGCM(k_bob).decrypt(nonce, ct, None))
mallory = X25519PrivateKey.generate()
k_alice_mitm = derive(alice.exchange(mallory.public_key())) # Alice thinks this is Bob
print('Alice and Mallory share a key:', k_alice_mitm == derive(mallory.exchange(alice.public_key())))Authentication closes the gap
Key agreement alone does not tell you who is on the other side. TLS solves this with certificates: the server signs the handshake with a key certified by a CA. SSH uses host keys and known_hosts. Signal uses safety numbers. Whenever you see key exchange, ask what authenticates the public keys.
Diagram
X25519 key agreement
- Generate
Alice and Bob each create a private and public key.
- Exchange
They send public keys; private keys never leave.
- Combine
Each computes the same shared secret.
- Derive and authenticate
HKDF produces the key; signatures or certificates prove identities.
Check yourself
1. What do the parties send each other in X25519 key agreement?
2. What should be applied to the raw shared secret before using it as an encryption key?
3. What does unauthenticated key exchange fail to prevent?