The time has come to settle the course's oldest debt. Since 02-01, every time we used AES-GCM or HMAC we parked the same question: how did the secret key get to both sides without anyone intercepting it? In 04-01, asymmetric cryptography gave a partial answer (encrypting toward a public key), but there was another idea, earlier and more elegant, published by Diffie and Hellman in 1976: two people who have never met can agree on a shared secret talking over a public channel where everyone listens, and the eavesdropper still learns nothing about the secret. It sounds like magic; it's arithmetic. In this lesson you'll see the intuition (mixing colors), a toy Diffie-Hellman with small integers, its modern version with X25519, how to turn the shared secret into a real AES key with HKDF, and what ephemerality and forward secrecy are. And you'll discover the fatal flaw that this whole thing carries — an attack with a name of its own — which only the signatures from 04-03 and module 5's certificates will be able to close.
Contents
- The problem, stated precisely
- The intuition: mixing colors
- Toy Diffie-Hellman with small integers
- Modern ECDH: X25519 in pyca/cryptography
- From shared secret to key: HKDF
- Full demonstration: MediNube ↔ Clínica Sol
- Ephemeral keys and forward secrecy
- The fatal flaw: man-in-the-middle
- How the hole gets closed (and where)
The problem, stated precisely
Let's pin down the challenge precisely, because its solution is counterintuitive. Two parties — MediNube and Clínica Sol — want to agree on a common secret key to then encrypt with AES-256-GCM (module 2). The conditions:
- They've never shared anything before: there's no prior secret to start from.
- The only channel is public: an attacker, whom we'll always call by her role, can read everything that's transmitted.
- In the end, both must have the same secret, and the attacker — who has watched every byte go by — must not be able to compute it.
That this is possible seems to defy common sense: if the spy sees everything that's said, how can she not repeat the calculation? The answer lies in the one-way functions you already know: data is exchanged that lets each party finish the calculation using their own private secret, but that leaves the spy, who has no private secret, locked out at the door.
The intuition: mixing colors
The classic analogy, and the best one. Imagine mixing paints is easy, but separating a mix back into its original colors is practically impossible (that's the "one-way function"):
- MediNube and Clínica Sol agree in public on a base color, say yellow. The attacker sees it: doesn't matter.
- Each party privately picks a color of their own and tells no one. MediNube picks red; the clinic picks blue.
- Each party mixes their secret with the public yellow and sends the mix over the channel. MediNube sends orange (yellow+red); the clinic sends green (yellow+blue). The attacker watches the orange and the green fly across the network.
- Each party adds their own secret to the mix they received. MediNube takes the green and adds its red; the clinic takes the orange and adds its blue. Both arrive at the same final color (yellow+red+blue): a shared brown.
flowchart TD
Base["Public base color: YELLOW (everyone sees it)"]
Base --> MN1["MediNube: yellow + secret RED = ORANGE"]
Base --> CS1["Clínica Sol: yellow + secret BLUE = GREEN"]
MN1 -->|sends ORANGE over the public channel| CS2
CS1 -->|sends GREEN over the public channel| MN2
MN2["MediNube: received GREEN + its RED"] --> Final["= BROWN (yellow+red+blue)"]
CS2["Clínica Sol: received ORANGE + its BLUE"] --> Final
Espia["Attacker: sees yellow, orange, and green... but separating paints is infeasible"] -.can't.-> Final
The attacker has the yellow, the orange, and the green. To get to brown she'd need the red or the blue, and those never traveled: to extract them she'd have to "separate" a mix, which is exactly what's infeasible. The private secrets (red, blue) never leave their owners; only the mixes travel. Swap "mixing paints" for a mathematical one-way operation and you have Diffie-Hellman.
Toy Diffie-Hellman with small integers
Toy-example warning (golden rule 1). Ridiculously small numbers, without any of the checks a real DH requires. It's here to show the mechanism, nothing more. In production, X25519 from
pyca/cryptography.
The one-way operation of classic DH is modular exponentiation: pow(g, x, p) is easy; recovering x from the result (the discrete logarithm, a relative of the elliptic one from 04-02) is infeasible with a large p. The "yellow" is two public numbers: a prime p and a base g.
# --- TOY DIFFIE-HELLMAN: for learning purposes only ---
# PUBLIC parameters, agreed on in plain view (the "yellow").
p = 23 # prime (in real life: >= 2048 bits)
g = 5 # base
# --- PRIVATE secrets: each party picks their own from the CSPRNG (golden rule 3) ---
a = 6 # MediNube's secret (the "red"; never transmitted)
b = 15 # the clinic's secret (the "blue"; never transmitted)
# --- Each party publishes their "mix" (DH public key) ---
A = pow(g, a, p) # MediNube sends A = 5^6 mod 23 = 8 (the "orange")
B = pow(g, b, p) # Clinic sends B = 5^15 mod 23 = 19 (the "green")
print(f"Traveling over the public channel: A={A}, B={B}")
# --- Each party combines what they received with THEIR OWN secret ---
secret_medinube = pow(B, a, p) # (g^b)^a mod p
secret_clinic = pow(A, b, p) # (g^a)^b mod p
print(secret_medinube, secret_clinic) # 2 and 2: the same!
assert secret_medinube == secret_clinicThe heart of it is an equality of powers: (g^b)^a = g^(b·a) = g^(a·b) = (g^a)^b (mod p). Each party reaches g^(a·b) mod p by a different route, using their own secret exponent. The attacker sees p, g, A=8, and B=19, but to compute g^(a·b) she'd need a or b, and extracting them from A or B is the discrete logarithm — infeasible with large parameters.
# What the attacker CANNOT do efficiently with large p:
# recover 'a' from A. With p=23, she can (brute force); with a 2048-bit p, she can't.
for candidate_a in range(p):
if pow(g, candidate_a, p) == A:
print(f"(toy example only) a = {candidate_a}") # a = 6
breakThat loop, which breaks the toy example in microseconds, is exactly what becomes impossible as p grows. Classic DH is still alive (in its elliptic-curve variant), but with enormous numbers and checks we've skipped here. That's why we move on to the modern version.
Modern ECDH: X25519 in pyca/cryptography
ECDH (Elliptic Curve Diffie-Hellman) is the same protocol over the curve arithmetic from 04-02: the secrets are scalars, the public keys are points, and the "combination" is scalar multiplication. With X25519 (the curve MediNube chose in 04-02 for exchange), the API hides all the arithmetic behind a single method, exchange():
from cryptography.hazmat.primitives.asymmetric import x25519 # Each party generates their EPHEMERAL pair (private = random scalar; public = point). priv_medinube = x25519.X25519PrivateKey.generate() priv_clinic = x25519.X25519PrivateKey.generate() # Only the public keys are exchanged over the channel (serialized to bytes/PEM). pub_medinube = priv_medinube.public_key() pub_clinic = priv_clinic.public_key() # Each party combines ITS OWN private key with the OTHER PARTY'S public one -> same secret. secret_mn = priv_medinube.exchange(pub_clinic) secret_cs = priv_clinic.exchange(pub_medinube) print(secret_mn == secret_cs) # True print(len(secret_mn)) # 32 bytes of shared secret
Map each line against the toy example and against the colors: generate() is picking the secret (red/blue) plus publishing the mix (orange/green); exchange(other_party_public) is the final combining step. Nothing about the private key ever leaves the object. And since generating an X25519 pair is practically free (04-02), there's no problem creating a new one for every exchange — the foundation of section 7's forward secrecy.
From shared secret to key: HKDF
Fatal temptation: use secret_mn (those 32 bytes) directly as an AES-256 key. Don't. The secret from a DH/ECDH exchange isn't a uniform key: it's a mathematical element of the curve with possible structure and bias, not a uniformly random bit string. Feeding it straight into AES violates the principle that keys must be uniform.
The right tool you already know from 02-04: HKDF (HMAC-based Key Derivation Function). HKDF "condenses" that secret into a cryptographically uniform key of whatever size you want, and along the way lets you bind the key to a context with the info parameter — just like we did with b"medinube:historiales:v1":
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
def derive_channel_key(shared_secret: bytes) -> bytes:
"""Turns the raw ECDH secret into a uniform AES-256 key."""
return HKDF(
algorithm=hashes.SHA256(),
length=32, # 256 bits for AES-256-GCM
salt=None, # optional; TLS uses handshake nonces
info=b"medinube:canal-clinica:v1", # binds the key to THIS purpose (02-04)
).derive(shared_secret)
channel_key = derive_channel_key(secret_mn)
print(len(channel_key)) # 32 bytes, ready for AES-256-GCMThe info with its versioned tag (:v1) is the same pattern from the course: a key derived for the "canal-clinica" purpose never collides with one derived for, say, b"medinube:backup:v1", even though both start from the same secret. Crypto-agility (golden rule 8) baked into the name.
Full demonstration: MediNube ↔ Clínica Sol
Let's put the three pieces together — ECDH + HKDF + AES-GCM — for what we've been promising since 02-01: sending a fragment of a Clínica Sol record to MediNube, establishing the key on the fly, with no prior secret. We reuse module 2's v1 format (version(0x01)||nonce(12)||ciphertext+tag) and its per-patient AAD:
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import x25519
# ---- PHASE 1: key exchange (public channel) ----
# Each party generates its ephemeral pair and sends its serialized public key.
priv_cs = x25519.X25519PrivateKey.generate()
priv_mn = x25519.X25519PrivateKey.generate()
pub_cs_bytes = priv_cs.public_key().public_bytes_raw() # 32 bytes over the channel
pub_mn_bytes = priv_mn.public_key().public_bytes_raw() # 32 bytes over the channel
# ---- PHASE 2: each party derives THE SAME channel key ----
pub_mn = x25519.X25519PublicKey.from_public_bytes(pub_mn_bytes)
pub_cs = x25519.X25519PublicKey.from_public_bytes(pub_cs_bytes)
key_cs = derive_channel_key(priv_cs.exchange(pub_mn)) # clinic's side
key_mn = derive_channel_key(priv_mn.exchange(pub_cs)) # MediNube's side
assert key_cs == key_mn # same secret
# ---- PHASE 3: the clinic encrypts a record with AES-256-GCM (module 2) ----
patient_id = "ana.perez"
aad = f"paciente={patient_id};formato=v1".encode() # the course's AAD
record = b"Allergies: penicillin. Blood type: A+. Last visit: 2026-07-01"
nonce = os.urandom(12) # fresh nonce (golden rule 3)
envelope = bytes([0x01]) + nonce + AESGCM(key_cs).encrypt(nonce, record, aad)
# ---- PHASE 4: MediNube decrypts ----
assert envelope[0] == 0x01 # format version
received_nonce, ciphertext = envelope[1:13], envelope[13:]
decrypted = AESGCM(key_mn).decrypt(received_nonce, ciphertext, aad)
print(decrypted.decode())sequenceDiagram
participant CS as Clínica Sol
participant MN as MediNube
CS->>MN: CS's X25519 ephemeral public key (32 bytes, in the clear)
MN->>CS: MN's X25519 ephemeral public key (32 bytes, in the clear)
Note over CS,MN: each side: exchange() -> shared secret -> HKDF -> AES-256 key
CS->>MN: v1 envelope = 0x01 || nonce || AES-GCM(record, AAD)
Note over MN: AES-GCM.decrypt -> ana.perez's record
We've done it: two parties with no prior secret, a channel where everything is visible, and yet a record encrypted with a key that never traveled. Every primitive from the course finds its place: X25519 agrees, HKDF condenses, AES-GCM protects, the AAD binds the ciphertext to the patient. API note: public_bytes_raw() / from_public_bytes() are X25519's compact form (32 raw bytes); you can also use PEM (SubjectPublicKeyInfo) as in 04-01/04-02 if you prefer a self-describing format.
Ephemeral keys and forward secrecy
Notice that in the demo, each X25519 pair was generated with generate() on the spot and never saved anywhere. That's an ephemeral key: used for one exchange and discarded. The alternative would be static keys (fixed, reused on every connection). The difference has a name and enormous consequences: forward secrecy (also perfect forward secrecy, PFS).
Imagine the attacker records today all the encrypted traffic between Clínica Sol and MediNube — she can't read it, but she archives it. And tomorrow, through carelessness, a theft, or a court order, she gets one party's private key. Can she now decrypt what she recorded?
| DH with static keys | DH with ephemeral keys (with PFS) | |
|---|---|---|
| A new pair is generated... | Once, reused forever | On every session/connection |
| If the private key is stolen today... | Decrypts all recorded past traffic | Decrypts nothing of the past |
| Cost | Lower (no key regeneration) | Minimal with X25519 (generating is nearly free, 04-02) |
| Recommendation | Avoid for channels | Modern standard |
The key to forward secrecy: since ephemeral private keys are destroyed after each exchange, compromising a party's long-term key doesn't reveal already-negotiated session secrets — those secrets no longer exist anywhere. Each conversation stays protected "going forward" against future compromises. This is where 04-02's promise pays off: generating an EC pair is so cheap that doing it per connection costs nothing, and that's why forward secrecy is today's standard (the final "E" in ECDHE in TLS means exactly ephemeral).
The fatal flaw: man-in-the-middle
And now the bad news, the same shadow that has been following the whole module. Go back to the demonstration and ask yourself 04-01's and 04-03's question: when MediNube receives pub_cs_bytes, how does it know those 32 bytes really are Clínica Sol's public key? It doesn't. They're bytes that arrived over a public channel. And that's where the attack slips in.
Enter MalloryClinic, an attacker sitting in the middle of the channel (a compromised router, a hostile wifi, a poisoned DNS). Instead of just listening (which DH is immune to), she intercepts and substitutes the public keys: she performs two exchanges, one with each party, impersonating the other in each one.
sequenceDiagram
participant CS as Clínica Sol
participant M as MalloryClinic (in the middle)
participant MN as MediNube
CS->>M: CS's public key
M->>MN: MALLORY's public key (posing as CS)
MN->>M: MN's public key
M->>CS: MALLORY's public key (posing as MN)
Note over CS,M: CS agrees on a key... with MALLORY
Note over M,MN: MN agrees on another key... with MALLORY
CS->>M: encrypted envelope (CS-Mallory key)
Note over M: decrypts, READS the record, re-encrypts
M->>MN: encrypted envelope (Mallory-MN key)
The result is devastating and silent: Clínica Sol establishes an encrypted channel with Mallory believing she's talking to MediNube; MediNube establishes another with Mallory believing it's talking to the clinic. Mallory decrypts each message with one key, reads it (ana.perez's record, in the clear, right before her eyes), re-encrypts it with the other, and forwards it. Both ends see a channel that "works": messages arrive, decrypt, everything looks correct. Diffie-Hellman's mathematics is perfect; the broken link is, once again, the authenticity of the public keys.
Diffie-Hellman, by itself, guarantees confidentiality against a passive eavesdropper, but not against an active attacker who can modify traffic. Unauthenticated DH is a house with no lock: gorgeous inside, open to whoever pushes the door.
How the hole gets closed (and where)
The cure is to authenticate the exchange's public keys: let each party prove that the key it's sending is truly theirs. And to "prove authorship" you already have the tool from the previous lesson: digital signatures. If Clínica Sol signs its ephemeral public key with its long-term Ed25519 key, MalloryClinic won't be able to substitute it — she doesn't know how to forge the clinic's signature. This combination (ephemeral DH for the secret + long-term signature for authentication) is exactly what modern TLS uses, and it has a name: authenticated ephemeral DH.
But notice that this only shifts the problem: to verify Clínica Sol's signature, MediNube needs its authentic Ed25519 public key... and how does it know that one belongs to the clinic? It's the uncomfortable question from 04-01 and 04-03, now unavoidable: you need someone trustworthy to certify which public key belongs to whom. That infrastructure — X.509 certificates and certificate authorities — is module 5, and TLS (which combines ECDHE + certificates + AES-GCM exactly as done here, and which you'll see in 05-02) is its flagship application. We leave it, one last time, explicitly open: we now have almost every piece; what's missing is the one that gives trust to public keys.
Before we go there, one lesson remains that ties together this module's two threads — encrypting toward someone (04-01) and agreeing on a key (this one) — into the pattern the real world uses to actually encrypt: hybrid encryption, in 04-05.
Common Mistakes and Tips
- Using the raw ECDH secret as a key. Mistake number one.
exchange()doesn't return a ready-to-use key: it returns material that must go through HKDF. Skipping the KDF is a real security failure, not a technicality. - Believing Diffie-Hellman authenticates. It doesn't: it gives confidentiality against a passive eavesdropper and nothing against an active man-in-the-middle. DH agrees on a secret with whoever is on the other end, no matter who that is. Authenticating the keys is a separate step (signatures + certificates).
- Reusing static DH keys when you could use ephemeral ones. You give up forward secrecy: a future compromise of the key decrypts all recorded past traffic. With X25519, generating a pair per session is nearly free — do it.
- Confusing X25519 with Ed25519 (again). X25519 is only for exchange (
exchange); Ed25519 is only for signing (sign). They don't share an API and aren't interchangeable. Each purpose, its own pair. - Reusing the AES-GCM nonce between channel messages. The key coming from a DH exchange doesn't exempt you from module 2's rule: a fresh nonce per message. A session key doesn't authorize repeating nonces.
- Implementing classic DH by hand with
pow. The toy example skips validating parameters, checking that received keys are in the right group, and defending against small subgroups. X25519 was designed so none of that can blow up on you: use it. - Forgetting HKDF's
infoor reusing it across different purposes. Theb"medinube:canal-clinica:v1"label is what cryptographically separates this key from any other derived from the same secret. A clear, versioned label per use.
Exercises
-
Toy example by hand. With
p = 23,g = 5, MediNube's secreta = 4, and the clinic's secretb = 3: computeA,B, and check that both parties arrive at the same secret. Then put yourself in the attacker's shoes: recoveraby brute force fromA. Explain in one sentence why that brute force becomes impossible with a 2048-bitp. -
Full channel with forward secrecy. Write
open_channel()that returns the channel's AES-256 key, derived by both parties with ephemeral X25519 + HKDF (info=b"medinube:canal-clinica:v1"), and use that key so MediNube can send Clínica Sol thev1envelope of a short message with AADpaciente=ana.perez;formato=v1. Then generate a second channel withopen_channel()and check that its key differs from the first. What security property have you just demonstrated, and why does it protect already-recorded traffic? -
The attack in code. Simulate MalloryClinic without any encryption, just the keys: generate ephemeral pairs for the clinic, for MediNube, and two for Mallory. Compute (a) the secret the clinic would believe it shares (its private key × the public key it received, which is Mallory's) and (b) the one MediNube would believe it shares. Check that the clinic and MediNube don't share a secret with each other, but that Mallory shares one with each of them. Explain what would have prevented the attack.
Solutions
p, g, a, b = 23, 5, 4, 3 A = pow(g, a, p) # 5^4 mod 23 = 4 B = pow(g, b, p) # 5^3 mod 23 = 10 assert pow(B, a, p) == pow(A, b, p) # both: 5^12 mod 23 = 18 # The attacker recovers 'a' from A by brute force: recovered_a = next(x for x in range(p) if pow(g, x, p) == A) # 4 print(recovered_a)
That brute force — trying every exponent until A is reproduced — is the discrete logarithm. With a 2048-bit p, the number of possible exponents is astronomical and no efficient shortcut exists, so the attack that takes microseconds here would take longer than the age of the universe.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.asymmetric import x25519
def open_channel():
priv_a, priv_b = x25519.X25519PrivateKey.generate(), x25519.X25519PrivateKey.generate()
key_a = derive_channel_key(priv_a.exchange(priv_b.public_key()))
key_b = derive_channel_key(priv_b.exchange(priv_a.public_key()))
assert key_a == key_b
return key_a
key1 = open_channel()
aad = b"paciente=ana.perez;formato=v1"
nonce = os.urandom(12)
envelope = bytes([0x01]) + nonce + AESGCM(key1).encrypt(nonce, b"Appointment confirmed 09:00", aad)
print(AESGCM(key1).decrypt(envelope[1:13], envelope[13:], aad).decode())
key2 = open_channel()
print(key1 == key2) # False: each channel, a different keyYou've demonstrated forward secrecy: since each session uses an ephemeral pair that gets discarded, the keys of two sessions are unrelated. If tomorrow the attacker steals a long-term private key, she can't reconstruct a past session's key — that ephemeral private key no longer exists — so yesterday's recorded traffic stays unreadable.
from cryptography.hazmat.primitives.asymmetric import x25519 cs, mn = x25519.X25519PrivateKey.generate(), x25519.X25519PrivateKey.generate() m_cs, m_mn = x25519.X25519PrivateKey.generate(), x25519.X25519PrivateKey.generate() # CS receives Mallory's public key (m_cs) believing it's MN's; and vice versa. secret_cs = derive_channel_key(cs.exchange(m_cs.public_key())) secret_mn = derive_channel_key(mn.exchange(m_mn.public_key())) # Mallory reproduces both with her two private keys: secret_mallory_with_cs = derive_channel_key(m_cs.exchange(cs.public_key())) secret_mallory_with_mn = derive_channel_key(m_mn.exchange(mn.public_key())) print(secret_cs == secret_mn) # False: CS and MN do NOT share anything print(secret_cs == secret_mallory_with_cs) # True: Mallory can decrypt CS print(secret_mn == secret_mallory_with_mn) # True: Mallory can decrypt MN
The clinic and MediNube believe they have a shared channel, but each of them has it with Mallory, who reads and forwards in the middle. What would have prevented it: authenticating the public keys — if the clinic signed its ephemeral public key with its long-term Ed25519 key (04-03), Mallory couldn't substitute it without forging that signature. And to verify that signature, you need to know the clinic's authentic Ed25519 key — the trust problem from module 5.
Conclusion
02-01's debt is settled. Diffie-Hellman lets two parties with no prior secret agree on a common key over a public channel: each one picks a private secret, publishes a "mix" (pow(g, a, p) in the classic version; a point in ECDH), and combines the other party's mix with its own secret to arrive at the same result — while the eavesdropper, who has seen everything, is locked out by the discrete logarithm. In practice you use X25519 with exchange(), its raw secret is never used directly but instead goes through HKDF (info=b"medinube:canal-clinica:v1") to get the AES-256-GCM key, and keys are generated ephemeral to gain forward secrecy: stealing the long-term key doesn't decrypt the past. You put it all together — X25519 + HKDF + AES-GCM — to send a record from Clínica Sol to MediNube. But DH has an Achilles' heel that isn't mathematical but one of identity: without authenticating the public keys, MalloryClinic sits in the middle and reads everything without anyone noticing. Authentication begins with 04-03's signatures and is completed with module 5's certificates — and TLS (05-02) does exactly what you've done here, ECDHE included. Before that, one last piece of the module: joining "encrypting toward someone" and "agreeing on a key" into the pattern the real world uses to actually encrypt. It's hybrid encryption, in 04-05. See you there.
Applied Cryptography Course
Module 1: Cryptography Fundamentals
- What Cryptography Is and What It's For
- Encoding, Obfuscation, and Encryption
- Randomness and Entropy
- Kerckhoffs's Principle and the Golden Rules
Module 2: Symmetric Cryptography
- Symmetric Encryption: AES and ChaCha20
- Modes of Operation
- Authenticated Encryption (AEAD)
- Key Derivation (KDF)
Module 3: Hashes, MACs, and Passwords
Module 4: Asymmetric Cryptography
- Public-Key Fundamentals and RSA
- Elliptic Curve Cryptography
- Digital Signatures
- Key Exchange: Diffie-Hellman
- Hybrid Encryption
