TRENDING
Macro photo of an open hard disk drive showing the read-write head suspended just above a reflective platter
September 15, 2026
How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups
A massive rusted anchor chain shackle lying on a pebble beach, its iron surface deeply corroded orange and brown
September 15, 2026
Microsoft’s Humanist AI Code of Conduct Turns Agent Containment Into a Chain of Command
A hand holds a hypodermic syringe with visible dosage markings against a black background, illustrating a SQL injection vulnerability
September 15, 2026
CISA Orders Federal Agencies to Patch an Actively Exploited Cisco Email Gateway Flaw by September 17
A physical slide dimmer light switch positioned partway along its track, next to a wall outlet plate
September 15, 2026
How to Build a Feature Flag System in Python With Sticky Percentage Rollouts
Leeds Castle's medieval moat, portcullis gate, and stone bridge reflected in the water
September 15, 2026
Fyxer’s OpenAI Case Study Turns a Decade of Assistant Work Into an AI Moat
15 Sep 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Two different ropes spliced together into one continuous line, a visual metaphor for combining two independent cryptographic secrets into one hybrid key
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
September 15, 2026
An ABB industrial robot arm and a black mobile AGV robot standing together on a factory floor
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
September 15, 2026
Macro photo of a ceramic microcontroller chip with an exposed gold die, representing embedded device security
Italy’s Exein Raises $270 Million to Build a Foundation Model for Physical AI Security
September 15, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 188 Posts
News 188 Posts
Learning Hub 159 Posts
Home/Learning Hub/How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
Learning Hub

How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM

Learn how to combine classical X25519 Diffie-Hellman with post-quantum ML-KEM-768 into one hybrid session key in Python, the same defense-in-depth pattern Chrome and OpenSSH already use by default.

September 15, 2026 17 Min Read
6

An attacker who records your encrypted traffic today does not need to break it today. They can store it and wait for a sufficiently powerful quantum computer, then decrypt everything at once. Security researchers call this “harvest now, decrypt later,” and it is why browsers, SSH clients, and now enterprise platform vendors have started shipping key exchanges that combine two independent algorithms instead of one.

Table Of Content

  • Why Hybrid Key Exchange, Not Just Post-Quantum Alone
  • Prerequisites
  • Understanding the Two Building Blocks
  • Classical Diffie-Hellman: A Symmetric Exchange
  • ML-KEM: An Asymmetric Encapsulation Mechanism
  • Step 1: Install cryptography and Confirm ML-KEM Support
  • Step 2: Build a Plain X25519 Exchange
  • Step 3: Build a Plain ML-KEM-768 Exchange
  • A Common Mistake: Treating ML-KEM Like Diffie-Hellman
  • Step 4: Combine Both Secrets Into One Hybrid Key
  • Gotcha: Concatenation Order Must Match Exactly
  • Gotcha: A Downgraded Key Is Indistinguishable by Shape
  • Step 5: Add Key Confirmation So Mismatches Fail Loudly
  • Step 6: Assemble a Reusable Hybrid Handshake Module
  • Step 7: Use the Derived Key for Real Encryption
  • Step 8: Write Tests to Lock In the Behavior
  • Common Mistakes and Gotchas
  • How to Verify Everything Works End to End
  • How This Maps to Real-World Systems
  • Next Steps

In this tutorial you will build one of those hybrid key exchanges from scratch in Python: a classical X25519 Diffie-Hellman exchange combined with a post-quantum ML-KEM-768 key encapsulation, merged into a single session key with a key derivation function. You will also personally reproduce three real bugs that this kind of protocol is prone to, and fix each one, so the failure modes are not abstract warnings but things you watched happen on your own machine.

By the end you will have a small, tested Python module implementing the same combiner shape used by real hybrid TLS groups such as X25519MLKEM768, plus a working understanding of why “just add post-quantum crypto” is not as simple as swapping one function call for another.

Why Hybrid Key Exchange, Not Just Post-Quantum Alone

A key encapsulation mechanism, or KEM, is a newer kind of public-key primitive designed to resist attacks from quantum computers. ML-KEM (the algorithm this tutorial uses) was standardized by NIST in FIPS 203, finalized on August 13, 2024. It replaces the earlier draft name “Kyber” that you may see in older articles and RFC drafts.

You might reasonably ask: if ML-KEM is quantum-resistant, why not just use it alone and retire classical Diffie-Hellman entirely? The OpenSSH project, which has shipped post-quantum key exchange by default since 2022, explains the answer plainly on its own post-quantum cryptography page:

“All the post-quantum algorithms implemented by OpenSSH are ‘hybrids’ that combine a post-quantum algorithm with a classical algorithm. For example mlkem768x25519-sha256 combines ML-KEM, a post-quantum key agreement scheme, with ECDH/x25519, a classical key agreement algorithm that was formerly OpenSSH’s preferred default. This ensures that the combined, hybrid algorithm is no worse than the previous best classical algorithm, even if the post-quantum algorithm turns out to be completely broken by future cryptanalysis.”

That is the whole rationale in one sentence: ML-KEM is newer and less battle-tested than X25519, which has survived roughly a decade of public cryptanalysis with no known practical breaks. A hybrid combiner means an attacker has to break BOTH algorithms to recover your key, not just the weaker or newer one. If ML-KEM turns out to have a flaw nobody has found yet, X25519 still protects you today. If a quantum computer eventually breaks X25519, ML-KEM still protects you against that. Neither algorithm is a single point of failure for the other.

This is not a hypothetical design pattern. Chrome has shipped a hybrid X25519 plus ML-KEM group (named X25519MLKEM768 in TLS) by default since 2024, and OpenSSH made its own hybrid combination, mlkem768x25519-sha256, the default key exchange in OpenSSH 10.0 in April 2025. A Red Hat blog post published the same day this tutorial was written describes a related real-world use case: an independent software vendor combining classical Elliptic Curve Diffie-Hellman with ML-KEM in a hybrid TLS implementation, specifically to protect data as it enters and leaves a confidential-computing trusted execution environment hosting a customer’s workload in the public cloud.

Prerequisites

  • Python 3.10 or later (this tutorial was built and tested on Python 3.13.14).
  • pip, to install one package.
  • A general sense of what public-key cryptography does (a key pair, a shared secret) is helpful but not required; the tutorial defines every term it uses.
  • No C compiler is required. The cryptography package ships prebuilt wheels for Windows, macOS, and Linux, so a plain pip install is enough.

Understanding the Two Building Blocks

Classical Diffie-Hellman: A Symmetric Exchange

X25519 is elliptic-curve Diffie-Hellman using Curve25519. Both sides generate a private/public key pair, exchange public keys, and each side runs the exact same operation: their own private key against the other side’s public key. Both sides call a method with the same name, and both get the same output. This symmetry is the shape most developers already associate with “key exchange.”

ML-KEM: An Asymmetric Encapsulation Mechanism

ML-KEM does not work that way, and this is the single most common source of confusion when people first use it. There is no symmetric “exchange” call. Instead:

  1. The receiver generates a key pair and publishes the public key.
  2. The sender takes that public key and calls encapsulate, which generates a fresh random shared secret and produces a ciphertext that carries it, bound to that specific public key.
  3. The sender transmits the ciphertext (not the shared secret) to the receiver.
  4. The receiver calls decapsulate on the ciphertext with their private key, recovering the same shared secret the sender generated.

Only one side ever calls encapsulate(), and only the other side ever calls decapsulate(). Step 3 of this tutorial deliberately reproduces what happens when you get that backwards.

Step 1: Install cryptography and Confirm ML-KEM Support

Install the pyca/cryptography library. ML-KEM support was added in version 47.0.0; anything from PyPI today is well past that.

pip install cryptography

Expected output ends with something like:

Successfully installed cffi-2.1.1 cryptography-50.0.1 pycparser-3.0

Confirm the module you need is importable:

python -c "from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey; print('ok')"

If this prints ok, you are ready. The pyca/cryptography documentation flags this module with a “hazardous materials” warning, meaning it exposes raw cryptographic primitives rather than a ready-made protocol. That warning is accurate: everything you build in this tutorial is a teaching implementation, not a drop-in replacement for TLS or SSH.

Step 2: Build a Plain X25519 Exchange

Start with the classical half on its own, so you have a clean baseline before adding any post-quantum complexity.

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey

alice_private = X25519PrivateKey.generate()
alice_public = alice_private.public_key()

bob_private = X25519PrivateKey.generate()
bob_public = bob_private.public_key()

# Both sides run the identical operation: their own private key against
# the other side's public key.
alice_shared = alice_private.exchange(bob_public)
bob_shared = bob_private.exchange(alice_public)

print("alice_shared:", alice_shared.hex())
print("bob_shared:  ", bob_shared.hex())
print("match:", alice_shared == bob_shared)

Running this produces:

alice_shared: 2158a158068a43ff3d42f05e50e99fc5d5b22a655c135b21cef30e16e223b278
bob_shared:   2158a158068a43ff3d42f05e50e99fc5d5b22a655c135b21cef30e16e223b278
match: True

Both sides landed on the same 32-byte secret by calling the same exchange() method. Nothing surprising yet. This is the baseline you already know from ordinary Diffie-Hellman.

Step 3: Build a Plain ML-KEM-768 Exchange

Now build the post-quantum half on its own, and pay close attention to which side calls which method.

from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey

# Bob is the receiver: he generates a keypair and publishes the public half.
bob_private = MLKEM768PrivateKey.generate()
bob_public = bob_private.public_key()
print("bob_public raw bytes:", len(bob_public.public_bytes_raw()))

# Alice is the sender: she encapsulates a fresh secret to Bob's public key.
alice_shared, ciphertext = bob_public.encapsulate()
print("ciphertext bytes:", len(ciphertext))
print("alice_shared:", alice_shared.hex())

# Bob decapsulates the ciphertext with his private key to recover the
# same shared secret Alice generated.
bob_shared = bob_private.decapsulate(ciphertext)
print("bob_shared:  ", bob_shared.hex())
print("match:", alice_shared == bob_shared)
bob_public raw bytes: 1184
ciphertext bytes: 1088
alice_shared: 62a57d894802cf8c8bb51f23521fd7be4612a0778d9b7c5e2a99e08a8286c7a7
bob_shared:   62a57d894802cf8c8bb51f23521fd7be4612a0778d9b7c5e2a99e08a8286c7a7
match: True

Notice the sizes: a 1184-byte public key and a 1088-byte ciphertext, against a 32-byte shared secret at the end. Those exact figures match FIPS 203’s ML-KEM-768 parameter set (Wikipedia’s ML-KEM article independently lists the same 1184-byte public key and 1088-byte ciphertext, with a 256-bit, or 32-byte, shared secret). This size difference from X25519’s 32-byte public keys is the real-world reason migration checklists spend so much time on schema and payload limits before touching any protocol code: a field sized for a 32-byte classical key will not hold a 1184-byte post-quantum one.

A Common Mistake: Treating ML-KEM Like Diffie-Hellman

Because X25519’s exchange() is symmetric, it is tempting to assume ML-KEM works the same way: generate your own keypair, and somehow “exchange” it with the other side. Let’s actually try that mistake and see what happens.

# Alice mistakenly generates her OWN ML-KEM keypair and encapsulates to it,
# instead of to Bob's public key.
alice_own_private = MLKEM768PrivateKey.generate()
alice_own_public = alice_own_private.public_key()
mistaken_shared, mistaken_ciphertext = alice_own_public.encapsulate()

# Bob, unaware of the mistake, tries to decapsulate Alice's ciphertext
# with HIS OWN private key, the only one he has.
bob_recovered = bob_private.decapsulate(mistaken_ciphertext)
print("decapsulate() raised no exception")
print("alice's real secret:   ", mistaken_shared.hex())
print("bob's recovered secret:", bob_recovered.hex())
print("they match:", mistaken_shared == bob_recovered)
decapsulate() raised no exception
alice's real secret:   d9992900459c8bbc0fe5d8cd40a8b7ea4e79109926077574bf25d26595683612
bob's recovered secret:0d3b15ca217335550005719868776ffb3325737caedaddcba55bfad2b76401c6
they match: False

This is the important part: decapsulate() did not raise any error at all. It silently returned a completely different 32-byte value. This is a deliberate design property of ML-KEM (built on a construction called implicit rejection) meant to resist chosen-ciphertext attacks: rather than signaling “invalid ciphertext” out loud, which an attacker could use as a side channel, a mismatched decapsulation just produces a value that looks like any other shared secret, with no error to catch. You will see why this matters again in Step 5.

Step 4: Combine Both Secrets Into One Hybrid Key

Now merge the classical and post-quantum shared secrets into a single session key. The approach here mirrors, in simplified form, how draft-ietf-tls-hybrid-design specifies combining shares for real hybrid TLS groups: concatenate the classical share first, then the post-quantum share, and feed the result through a key derivation function. The pyca/cryptography documentation for X25519 recommends exactly this pattern too, using HKDF: “the shared_key should be passed to a key derivation function,” which “allows mixing of additional information into the key.”

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

CONTEXT = b"sxz-hybrid-handshake-v1:X25519+ML-KEM-768"

def derive_hybrid_key(classical_secret: bytes, pq_secret: bytes, info: bytes) -> bytes:
    combined = classical_secret + pq_secret
    hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info)
    return hkdf.derive(combined)

# Bob (receiver): generates BOTH a classical and a post-quantum keypair.
bob_x_private = X25519PrivateKey.generate()
bob_x_public = bob_x_private.public_key()
bob_kem_private = MLKEM768PrivateKey.generate()
bob_kem_public = bob_kem_private.public_key()

# Alice (sender): generates her own X25519 keypair, and encapsulates to
# Bob's ML-KEM public key.
alice_x_private = X25519PrivateKey.generate()
alice_x_public = alice_x_private.public_key()

alice_x_shared = alice_x_private.exchange(bob_x_public)
alice_kem_shared, kem_ciphertext = bob_kem_public.encapsulate()
alice_key = derive_hybrid_key(alice_x_shared, alice_kem_shared, CONTEXT)

# Bob recovers both shares and derives the same key.
bob_x_shared = bob_x_private.exchange(alice_x_public)
bob_kem_shared = bob_kem_private.decapsulate(kem_ciphertext)
bob_key = derive_hybrid_key(bob_x_shared, bob_kem_shared, CONTEXT)

print("alice_key:", alice_key.hex())
print("bob_key:  ", bob_key.hex())
print("match:", alice_key == bob_key)
alice_key: 07c7e0b9152584520d691575dd863957af77ca23d8506b7a4f1e0da8c1f338e1
bob_key:   07c7e0b9152584520d691575dd863957af77ca23d8506b7a4f1e0da8c1f338e1
match: True

Both sides now hold a single 32-byte key derived from two independent secrets: one classical, one post-quantum. Neither secret alone can reproduce this key; you need both.

Gotcha: Concatenation Order Must Match Exactly

HKDF has no concept of “these two byte strings are logically the same regardless of order.” If one side concatenates classical-then-post-quantum and the other concatenates post-quantum-then-classical, they derive two completely different, unrelated keys, with no error anywhere in the chain.

# Bob's colleague "fixes" the code and swaps the argument order by accident.
bob_key_wrong_order = derive_hybrid_key(bob_kem_shared, bob_x_shared, CONTEXT)
print("bob_key (correct order):", bob_key.hex())
print("bob_key (SWAPPED order):", bob_key_wrong_order.hex())
print("still matches alice?", bob_key_wrong_order == alice_key)
bob_key (correct order): 07c7e0b9152584520d691575dd863957af77ca23d8506b7a4f1e0da8c1f338e1
bob_key (SWAPPED order): 4c918549fcb4c12fd39678bfd252867ad8611955be187306d8a2626011d6e806
still matches alice? False

This is not a theoretical worry. It is precisely why draft-ietf-tls-hybrid-design spells out the rule explicitly: “The order of shares in the concatenation MUST be the same as the order of algorithms indicated in the definition of the NamedGroup.” Two independent implementations of the same named combination have to agree on byte order, or they will never successfully talk to each other, and the failure will look like a generic decryption error rather than an obvious “you got the order wrong” message.

Interestingly, real-world hybrid schemes do not even agree with each other on naming order: TLS calls the combination “X25519MLKEM768” (classical name first), while OpenSSH calls the same pairing “mlkem768x25519-sha256” (post-quantum name first). The lesson generalizes past just this tutorial: whenever you are combining two pieces of secret material, write down and test the exact byte order, because nothing about the algorithms themselves enforces one.

Gotcha: A Downgraded Key Is Indistinguishable by Shape

Here is a subtler problem. What if an attacker, or a misconfigured peer, strips the ML-KEM half out entirely and only uses the classical secret?

classical_only_hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=CONTEXT)
classical_only_key = classical_only_hkdf.derive(alice_x_shared)
print("hybrid key length:        ", len(alice_key), "bytes")
print("classical-only key length:", len(classical_only_key), "bytes")
hybrid key length:         32 bytes
classical-only key length: 32 bytes

Both are 32 bytes. Nothing about the shape of the output tells you whether ML-KEM ever contributed to it. If your code does not explicitly commit to “this key was derived using both algorithms,” a silent downgrade to classical-only crypto is invisible from the output alone. The fix is to bind an explicit, distinct context string (an algorithm identifier) into the KDF’s info parameter for each combination you support, so two peers who disagree on which algorithms were actually used derive different keys, rather than two keys that merely happen to be the same length. This is the same role the NamedGroup identifier plays inside TLS 1.3’s own key schedule.

Step 5: Add Key Confirmation So Mismatches Fail Loudly

You have now seen two ways a hybrid handshake can silently produce mismatched keys (wrong concatenation order, an undetected downgrade) plus one way ML-KEM itself can silently produce a wrong shared secret (decapsulating with the wrong keypair, from Step 3). None of these raise an exception. Left alone, a mismatch would only surface later, when real encrypted application data fails to decrypt, which is a confusing place to debug a handshake bug.

The fix used by real protocols like TLS 1.3 (its “Finished” message) is key confirmation: each side proves they hold the derived key by sending a keyed hash over a fixed label, and the other side verifies it before trusting the key for anything else. From here on, keep adding to the same file you started in Step 4, since the code below uses the X25519PrivateKey, MLKEM768PrivateKey, derive_hybrid_key, and CONTEXT definitions already in that file.

import hmac
import hashlib

def confirmation_tag(session_key: bytes, label: bytes) -> bytes:
    return hmac.new(session_key, label, hashlib.sha256).digest()

def verify_confirmation(session_key: bytes, label: bytes, tag: bytes) -> bool:
    expected = confirmation_tag(session_key, label)
    # Constant-time comparison. Using == here leaks timing information
    # about how many leading bytes matched, which matters for a MAC check.
    return hmac.compare_digest(expected, tag)

Note the use of hmac.compare_digest rather than Python’s ordinary == operator. A plain equality check on bytes short-circuits at the first mismatched byte, and the tiny timing difference that creates is a documented class of side-channel attack against MAC verification. compare_digest always takes the same amount of time regardless of where the strings first differ.

Wire the confirmation step into a full handshake function that can optionally reintroduce the order-mismatch bug from Step 4 on purpose, so you can compare a healthy run against a broken one:

def run_handshake(introduce_bug: bool = False):
    bob_x_priv = X25519PrivateKey.generate()
    bob_x_pub = bob_x_priv.public_key()
    bob_kem_priv = MLKEM768PrivateKey.generate()
    bob_kem_pub = bob_kem_priv.public_key()

    alice_x_priv = X25519PrivateKey.generate()
    alice_x_pub = alice_x_priv.public_key()

    alice_x_shared = alice_x_priv.exchange(bob_x_pub)
    alice_kem_shared, kem_ct = bob_kem_pub.encapsulate()
    alice_key = derive_hybrid_key(alice_x_shared, alice_kem_shared, CONTEXT)

    bob_x_shared = bob_x_priv.exchange(alice_x_pub)
    bob_kem_shared = bob_kem_priv.decapsulate(kem_ct)

    if introduce_bug:
        # Simulate the earlier order-mismatch bug reappearing.
        bob_key = derive_hybrid_key(bob_kem_shared, bob_x_shared, CONTEXT)
    else:
        bob_key = derive_hybrid_key(bob_x_shared, bob_kem_shared, CONTEXT)

    label = b"sxz-hybrid-handshake-v1:client-finished"
    alice_tag = confirmation_tag(alice_key, label)
    bob_accepts = verify_confirmation(bob_key, label, alice_tag)
    return alice_key, bob_key, bob_accepts


print("=== Healthy handshake ===")
alice_key, bob_key, accepted = run_handshake(introduce_bug=False)
print("keys match:", alice_key == bob_key)
print("Bob accepts confirmation tag:", accepted)

print()
print("=== Handshake with the order-mismatch bug reintroduced ===")
alice_key2, bob_key2, accepted2 = run_handshake(introduce_bug=True)
print("keys match:", alice_key2 == bob_key2)
print("Bob accepts confirmation tag:", accepted2)
=== Healthy handshake ===
keys match: True
Bob accepts confirmation tag: True

=== Handshake with the order-mismatch bug reintroduced ===
keys match: False
Bob accepts confirmation tag: False

Without this check, Bob would have silently kept the wrong key and only discovered the problem once real ciphertext failed to decrypt later. With it, the mismatch is caught at the handshake itself, with a clear signal about what went wrong.

Step 6: Assemble a Reusable Hybrid Handshake Module

Now combine everything from Steps 4 and 5 into one importable module, wrapped in a small Responder/Initiator API so the roles are explicit and you cannot accidentally call the wrong method on the wrong side. One change from Step 5: verify_confirmation now raises a HandshakeError instead of returning a plain boolean, so a caller cannot forget to check the return value and silently proceed on a failed handshake. Save this as hybrid_kex.py:

from __future__ import annotations

import hmac
import hashlib
from dataclasses import dataclass

from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.asymmetric.mlkem import MLKEM768PrivateKey, MLKEM768PublicKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes

CONTEXT = b"sxz-hybrid-handshake-v1:X25519+ML-KEM-768"
CLIENT_FINISHED_LABEL = b"sxz-hybrid-handshake-v1:client-finished"


class HandshakeError(Exception):
    """Raised when a peer's confirmation tag does not verify."""


def derive_hybrid_key(classical_secret: bytes, pq_secret: bytes, info: bytes = CONTEXT) -> bytes:
    combined = classical_secret + pq_secret
    hkdf = HKDF(algorithm=hashes.SHA256(), length=32, salt=None, info=info)
    return hkdf.derive(combined)


def confirmation_tag(session_key: bytes, label: bytes) -> bytes:
    return hmac.new(session_key, label, hashlib.sha256).digest()


def verify_confirmation(session_key: bytes, label: bytes, tag: bytes) -> None:
    expected = confirmation_tag(session_key, label)
    if not hmac.compare_digest(expected, tag):
        raise HandshakeError("confirmation tag mismatch: peers derived different keys")


@dataclass
class ResponderBundle:
    """What the responder (receiver) publishes to start a handshake."""
    x25519_public: X25519PublicKey
    mlkem_public: MLKEM768PublicKey


@dataclass
class InitiatorMessage:
    """What the initiator (sender) sends back to the responder."""
    x25519_public: X25519PublicKey
    kem_ciphertext: bytes
    confirmation_tag: bytes


class Responder:
    """The side that generates both keypairs first (e.g. a server)."""

    def __init__(self) -> None:
        self._x_private = X25519PrivateKey.generate()
        self._kem_private = MLKEM768PrivateKey.generate()
        self.session_key: bytes | None = None

    def bundle(self) -> ResponderBundle:
        return ResponderBundle(
            x25519_public=self._x_private.public_key(),
            mlkem_public=self._kem_private.public_key(),
        )

    def finish(self, message: InitiatorMessage) -> bytes:
        x_shared = self._x_private.exchange(message.x25519_public)
        kem_shared = self._kem_private.decapsulate(message.kem_ciphertext)
        key = derive_hybrid_key(x_shared, kem_shared)
        verify_confirmation(key, CLIENT_FINISHED_LABEL, message.confirmation_tag)
        self.session_key = key
        return key


class Initiator:
    """The side that reacts to a responder's published bundle (e.g. a client)."""

    def __init__(self) -> None:
        self._x_private = X25519PrivateKey.generate()
        self.session_key: bytes | None = None

    def respond(self, bundle: ResponderBundle) -> InitiatorMessage:
        x_shared = self._x_private.exchange(bundle.x25519_public)
        kem_shared, kem_ciphertext = bundle.mlkem_public.encapsulate()
        key = derive_hybrid_key(x_shared, kem_shared)
        self.session_key = key
        tag = confirmation_tag(key, CLIENT_FINISHED_LABEL)
        return InitiatorMessage(
            x25519_public=self._x_private.public_key(),
            kem_ciphertext=kem_ciphertext,
            confirmation_tag=tag,
        )

The Responder generates both keypairs and publishes a bundle. The Initiator reacts to that bundle: it runs the symmetric X25519 exchange, encapsulates to the ML-KEM public key (never generating its own ML-KEM keypair, closing off the Step 3 mistake by construction), derives the key, and attaches a confirmation tag before sending anything back. The Responder only trusts the derived key after that tag verifies.

Step 7: Use the Derived Key for Real Encryption

Two 32-byte values matching each other is a weak proof that anything actually works. Use the derived key for real authenticated encryption with AES-256-GCM, and confirm a message really does round-trip between the two independent parties.

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from hybrid_kex import Responder, Initiator, HandshakeError

bob = Responder()
alice = Initiator()

bundle = bob.bundle()
message = alice.respond(bundle)
bob_key = bob.finish(message)
alice_key = alice.session_key

print("Bob's session key: ", bob_key.hex())
print("Alice's session key:", alice_key.hex())
print("keys match:", bob_key == alice_key)

aesgcm = AESGCM(bob_key)
nonce = os.urandom(12)
plaintext = b"launch the confidential workload at 09:00 UTC"
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data=None)

aesgcm_alice = AESGCM(alice_key)
recovered = aesgcm_alice.decrypt(nonce, ciphertext, associated_data=None)
print("Decrypted by the other party:", recovered)
print("round trip correct:", recovered == plaintext)
Bob's session key:  f8394fe6fb93791528749c403a16853eb63aa9147838ad31c792fce2b0c33a1d
Alice's session key: f8394fe6fb93791528749c403a16853eb63aa9147838ad31c792fce2b0c33a1d
keys match: True
Decrypted by the other party: b'launch the confidential workload at 09:00 UTC'
round trip correct: True

Now confirm the protective side too: tamper with the ML-KEM ciphertext in transit, the way an active attacker (or a corrupted network link) might, and verify the handshake refuses to complete rather than silently producing a broken key.

bob2 = Responder()
alice2 = Initiator()
bundle2 = bob2.bundle()
message2 = alice2.respond(bundle2)

# Flip one byte of the ML-KEM ciphertext in transit.
tampered_ct = bytearray(message2.kem_ciphertext)
tampered_ct[0] ^= 0x01
message2.kem_ciphertext = bytes(tampered_ct)

try:
    bob2.finish(message2)
    print("UNEXPECTED: handshake succeeded with a tampered ciphertext")
except HandshakeError as exc:
    print("Correctly rejected:", exc)
Correctly rejected: confirmation tag mismatch: peers derived different keys

Because of ML-KEM’s implicit rejection behavior from Step 3, decapsulating the tampered ciphertext does not raise an exception on its own; it just produces a different shared secret. The confirmation tag from Step 5 is what actually catches the tampering and turns it into a clear, loud error instead of a silently broken session.

Step 8: Write Tests to Lock In the Behavior

Install pytest and write tests that pin down the properties you have been demonstrating by hand, so a future refactor cannot quietly reintroduce any of the three bugs above.

pip install pytest
import pytest
from hybrid_kex import (
    Responder, Initiator, HandshakeError,
    derive_hybrid_key, confirmation_tag, verify_confirmation,
)

def test_handshake_produces_matching_keys():
    bob = Responder()
    alice = Initiator()
    message = alice.respond(bob.bundle())
    bob_key = bob.finish(message)
    assert bob_key == alice.session_key
    assert len(bob_key) == 32

def test_two_handshakes_produce_different_keys():
    bob1, alice1 = Responder(), Initiator()
    key1 = bob1.finish(alice1.respond(bob1.bundle()))
    bob2, alice2 = Responder(), Initiator()
    key2 = bob2.finish(alice2.respond(bob2.bundle()))
    assert key1 != key2

def test_swapped_concatenation_order_breaks_the_key():
    classical, pq = b"\x11" * 32, b"\x22" * 32
    assert derive_hybrid_key(classical, pq) != derive_hybrid_key(pq, classical)

def test_tampered_kem_ciphertext_is_rejected():
    bob, alice = Responder(), Initiator()
    message = alice.respond(bob.bundle())
    tampered = bytearray(message.kem_ciphertext)
    tampered[0] ^= 0x01
    message.kem_ciphertext = bytes(tampered)
    with pytest.raises(HandshakeError):
        bob.finish(message)

def test_confirmation_tag_rejects_wrong_key():
    tag = confirmation_tag(b"\xaa" * 32, b"test-label")
    with pytest.raises(Exception):
        verify_confirmation(b"\xbb" * 32, b"test-label", tag)

def test_confirmation_tag_accepts_correct_key():
    key = b"\xcc" * 32
    label = b"test-label"
    tag = confirmation_tag(key, label)
    verify_confirmation(key, label, tag)  # should not raise

def test_different_info_context_changes_derived_key():
    classical, pq = b"\x33" * 32, b"\x44" * 32
    key_a = derive_hybrid_key(classical, pq, info=b"context-a")
    key_b = derive_hybrid_key(classical, pq, info=b"context-b")
    assert key_a != key_b

Run the suite:

pytest test_hybrid_kex.py -v
collected 7 items

test_hybrid_kex.py::test_handshake_produces_matching_keys PASSED         [ 14%]
test_hybrid_kex.py::test_two_handshakes_produce_different_keys PASSED    [ 28%]
test_hybrid_kex.py::test_swapped_concatenation_order_breaks_the_key PASSED [ 42%]
test_hybrid_kex.py::test_tampered_kem_ciphertext_is_rejected PASSED      [ 57%]
test_hybrid_kex.py::test_confirmation_tag_rejects_wrong_key PASSED       [ 71%]
test_hybrid_kex.py::test_confirmation_tag_accepts_correct_key PASSED     [ 85%]
test_hybrid_kex.py::test_different_info_context_changes_derived_key PASSED [100%]

============================== 7 passed in 0.06s ==============================

Common Mistakes and Gotchas

  • Calling encapsulate() on both sides. Only the sender encapsulates, to the receiver’s public key. The receiver only ever decapsulates. There is no symmetric “exchange” for a KEM.
  • Assuming a wrong-key decapsulation will raise an exception. It will not. ML-KEM’s implicit rejection design means a wrong key or tampered ciphertext produces a different, valid-looking 32-byte secret with no error. Always add an explicit confirmation step if the handshake needs to fail loudly.
  • Disagreeing on concatenation order. Both sides must combine the classical and post-quantum shares in exactly the same byte order, or they derive unrelated keys with no error anywhere in the chain.
  • Trusting output shape to prove which algorithms ran. A classical-only key and a real hybrid key are both 32 bytes. Bind a distinct context string per algorithm combination into your KDF’s info parameter so a silent downgrade changes the derived key, not just its label.
  • Comparing MAC tags with == instead of hmac.compare_digest. Plain equality short-circuits at the first mismatched byte, which leaks timing information about a secret comparison.
  • Reusing key pairs across handshakes. The pyca/cryptography X25519 documentation notes explicitly that a real handshake generates a fresh key pair every time. Reusing keys breaks the forward secrecy property (that recording today’s traffic does not compromise yesterday’s) even in a purely classical exchange, and the same logic applies to the ML-KEM half.

How to Verify Everything Works End to End

  1. Run python step1_classical.py and confirm match: True.
  2. Run python step2_mlkem.py and confirm both the real exchange matches and the mistaken-keypair demo shows they match: False with no exception raised.
  3. Run python step3_combine.py and confirm the swapped-order key differs from the correct one.
  4. Run python step5_full_demo.py (or your equivalent using the hybrid_kex module) and confirm the AES-GCM round trip decrypts correctly, and the tampered-ciphertext case is rejected with a HandshakeError.
  5. Run pytest -v and confirm all tests pass.

If every one of those checks matches what is shown above, you have a working, tested implementation of the same combiner pattern real hybrid TLS and SSH deployments use today.

How This Maps to Real-World Systems

This tutorial’s module is deliberately minimal and is not a production protocol implementation; do not use it as-is to secure real traffic. But the underlying pattern is exactly what is already running in software you likely use daily:

  • Chrome and BoringSSL have shipped a hybrid TLS group combining X25519 and ML-KEM-768, named X25519MLKEM768, by default since 2024.
  • OpenSSH added its own hybrid combination, mlkem768x25519-sha256, in version 9.9 (September 2024), and made it the default key exchange algorithm in OpenSSH 10.0 (April 2025). OpenSSH has offered some form of post-quantum hybrid key agreement by default since version 9.0 in 2022, initially via a different pairing, sntrup761x25519-sha512.
  • draft-ietf-tls-hybrid-design is the specification defining exactly how TLS 1.3 combines classical and post-quantum shares for standardized hybrid groups, using the same concatenate-then-KDF shape this tutorial builds by hand.

Next Steps

From here, a few directions are worth exploring:

  • Add ML-DSA, the post-quantum signature counterpart to ML-KEM, to authenticate the responder’s bundle before the initiator trusts it at all; the pyca/cryptography documentation exposes it with a nearly identical API shape.
  • Wire this handshake over a real TCP socket instead of passing Python objects directly between two local variables, and see what changes once you have to serialize the public keys and ciphertext for transmission.
  • Read NIST’s FIPS 203 summary page for the full specification this tutorial’s ML-KEM-768 calls are built on.
  • If you are responsible for a production migration rather than a learning exercise, sxz.io’s earlier Post-Quantum Cryptography in Python adoption checklist covers the inventory, schema, and release-gate planning work that has to happen around a change like this, separate from the cryptography itself.

Tags:

Application SecurityCryptographyKey ExchangePost-Quantum CryptographyPython

Share

An ABB industrial robot arm and a black mobile AGV robot standing together on a factory floor
Previous Post

Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
15 Sep
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
15 Sep
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
Trending
September 15, 2026
How to Build a Hybrid Post-Quantum Key Exchange in Python With X25519 and ML-KEM
September 15, 2026
Canonical’s Zenoh Snaps Turn ROS 2’s Middleware Fix Into a Packaging Decision
September 15, 2026
Italy’s Exein Raises $270 Million to Build a Foundation Model for Physical AI Security
September 15, 2026
How to Build a Changed Block Tracking System in Python to Speed Up Incremental Backups
September 15, 2026
Microsoft’s Humanist AI Code of Conduct Turns Agent Containment Into a Chain of Command
September 15, 2026
CISA Orders Federal Agencies to Patch an Actively Exploited Cisco Email Gateway Flaw by September 17

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026