Post-Quantum Cryptography with Python
Implement NIST FIPS 203 and FIPS 204 quantum-safe encryption in Python.
Introduction
30 Second Summary
Every encrypted message you send today could be intercepted, stored, and decrypted once quantum computers mature. Nation-state adversaries are already collecting encrypted data at scale, betting on future quantum breakthroughs to unlock it all.
In this project, you will build a quantum-safe messaging lab in Python that implements the new NIST post-quantum cryptography standards. You will prove you can protect data against both classical and quantum attacks.
What You'll Build
A complete post-quantum cryptography lab that builds both a classical (vulnerable) encryption system and a quantum-resistant system side by side, then composes them into an authenticated, encrypted messaging protocol.
By the end of this project, you'll have:
- A classical encryption baseline using RSA-OAEP + AES-256-GCM that demonstrates the system quantum computers will break.
- A quantum-resistant key exchange using ML-KEM-768 (FIPS 203) with the KEM-DEM hybrid encryption pattern.
- A complete quantum-safe messaging protocol combining ML-KEM-768 encryption with ML-DSA-65 (FIPS 204) digital signatures and tamper detection.
- A migration comparison report showing key sizes, ciphertext sizes, and the NIST IR 8547 migration timeline.
- Secret Mission: Implement hybrid encryption combining X25519 + ML-KEM-768 with HKDF key derivation, matching production deployments by Chrome, Cloudflare, and AWS.
Are there any prerequisites?
You need Python 3.12.1, VS Code (or Cursor), and a basic understanding of public/private key cryptography concepts (what keys are, what encryption does). No prior quantum computing knowledge is needed.
Set Up Your PQC Lab Environment
To build a quantum-safe messaging system, you need a clean Python environment with two specialized libraries. The pqcrypto package provides the NIST-standardized post-quantum algorithms, and the cryptography package provides the classical RSA and AES primitives you will compare them against.
In this step, you will create a project directory, set up an isolated Python environment, install both packages, and verify the post-quantum cryptography modules are working.
In this step, get ready to:
- Create a project directory and Python virtual environment.
- Install the pqcrypto and cryptography packages.
- Verify that ML-KEM-768 and ML-DSA-65 modules load and print key sizes.
Create your project directory and virtual environment
A virtual environment keeps your project's packages isolated from the rest of your system. This ensures the specific library versions you install here do not conflict with anything else on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (the backtick key, above Tab on most keyboards).
Where is the backtick key?
The backtick (`) is typically located to the left of the 1 key on your keyboard, sharing a key with the tilde (~).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and set up a virtual environment by running these commands:
mkdir pqc-lab
cd pqc-lab
python -m venv venv
What do these commands do?
The first two commands create a folder called pqc-lab on your Desktop and move your terminal into it.
The third command creates a virtual environment named venv inside your project folder. This gives you an isolated Python installation where you can install packages without affecting anything else.
- Confirm the venv folder was created by running:
ls
- You should see venv listed in the output.
Don't see the venv folder?
Make sure you typed python -m venv venv exactly as shown. If you see a "python not found" error, try python3 -m venv venv instead.
help me figure out why python -m venv failed
- Activate the virtual environment by running the command for your operating system:
macOS
source venv/bin/activate
Windows
venv\Scripts\activate
Linux
source venv/bin/activate
- You should see (venv) appear at the beginning of your terminal prompt, confirming the environment is active.
Don't see (venv) in your prompt?
On Windows, if you get an error about running scripts being disabled, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned in PowerShell and try activating again.
On macOS/Linux, double check you typed source before the path.
help me activate my Python virtual environment
Install the PQC and cryptography packages
The pqcrypto package provides CFFI bindings to PQClean implementations of NIST PQC algorithms. The cryptography package provides the classical RSA and AES primitives you will use as a comparison baseline.
- Install both libraries by running this command:
pip install pqcrypto==0.4.0 cryptography==49.0.0
What is pqcrypto?
The pqcrypto library wraps the PQClean C implementations of NIST's finalized post-quantum algorithms. It exposes ML-KEM-768 (FIPS 203) for key exchange and ML-DSA-65 (FIPS 204) for digital signatures.
- Wait for the installation to finish. You should see "Successfully installed" followed by the package names and versions.
Installation failed?
Make sure (venv) appears in your prompt before running pip install. Installing outside the virtual environment can cause permission errors.
If you see a network error, check your internet connection and try again.
help me fix my pip install error for pqcrypto
Create and run the verification script
Now you will create a script that imports from both algorithm modules and prints key sizes. This confirms the packages installed correctly and the PQC modules are operational.
- Create a new file called verify_install.py inside your pqc-lab folder. In VS Code, click File in the top menu bar.
- Click New File.
- Copy and paste the following code into the new file:
from pqcrypto.kem.ml_kem_768 import generate_keypair as kem_keygen
from pqcrypto.sign.ml_dsa_65 import generate_keypair as sig_keygen
print("PQC Library Verification")
print("=" * 40)
# Test ML-KEM-768
pk, sk = kem_keygen()
print(f"ML-KEM-768 public key size: {len(pk)} bytes")
print(f"ML-KEM-768 secret key size: {len(sk)} bytes")
# Test ML-DSA-65
pk_sig, sk_sig = sig_keygen()
print(f"ML-DSA-65 public key size: {len(pk_sig)} bytes")
print(f"ML-DSA-65 secret key size: {len(sk_sig)} bytes")
print("\nAll PQC modules loaded successfully!")
print("Ready to build quantum-safe systems.")
What does this code do?
The script imports the key generation functions from both ML-KEM-768 (quantum-safe key exchange) and ML-DSA-65 (quantum-safe signatures).
It generates a keypair from each algorithm and prints the sizes of the public and secret keys. If both imports succeed and key generation works, your PQC environment is ready.
- Save the file by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- When the save dialog appears, make sure you are inside the pqc-lab folder on your Desktop.
- Name the file verify_install.py and click Save.
- Run the verification script by running this command in your terminal:
python verify_install.py
- You should see output showing ML-KEM-768 public key size as 1184 bytes, ML-KEM-768 secret key size as 2400 bytes, ML-DSA-65 public and secret key sizes, and the message "All PQC modules loaded successfully!" at the end.
Seeing a ModuleNotFoundError?
This means the packages are not installed in the environment your terminal is using. Confirm that (venv) appears in your terminal prompt. If it does not, re-run the activation command and then try python verify_install.py again.
help me fix ModuleNotFoundError for pqcrypto
✔️ Awesome, I've got everything!
Great. Double check you have saved verify_install.py and that the output shows 1184 bytes for the ML-KEM-768 public key.
ⓧ I'd like to double check the full code
from pqcrypto.kem.ml_kem_768 import generate_keypair as kem_keygen
from pqcrypto.sign.ml_dsa_65 import generate_keypair as sig_keygen
print("PQC Library Verification")
print("=" * 40)
# Test ML-KEM-768
pk, sk = kem_keygen()
print(f"ML-KEM-768 public key size: {len(pk)} bytes")
print(f"ML-KEM-768 secret key size: {len(sk)} bytes")
# Test ML-DSA-65
pk_sig, sk_sig = sig_keygen()
print(f"ML-DSA-65 public key size: {len(pk_sig)} bytes")
print(f"ML-DSA-65 secret key size: {len(sk_sig)} bytes")
print("\nAll PQC modules loaded successfully!")
print("Ready to build quantum-safe systems.")
Your PQC lab environment is ready. Next up, you will build a classical encryption baseline using RSA and AES to see exactly what quantum computers threaten.
Build the Classical Encryption Baseline
Your PQC lab environment is verified and ready. Now it's time to build the encryption system you'll eventually replace with quantum-resistant alternatives.
In this step, you'll implement a classical hybrid encryption system using RSA-2048 and AES-256-GCM. This is the exact pattern that protects most internet traffic today across TLS, S/MIME, and PGP. It's also exactly what a quantum computer running Shor's Algorithm would break.
In this step, get ready to:
- Implement RSA-OAEP + AES-256-GCM encryption (the hybrid pattern).
- Implement the matching decryption flow and size analysis.
- Run the script and observe the quantum vulnerability baseline.
Create the classical encryption file
Classical hybrid encryption works in two layers. First, you generate a random symmetric key (AES-256) and encrypt the actual message with it. Then you wrap that symmetric key using the recipient's RSA public key.
This way, only the recipient's private key can unwrap the AES key, and only the AES key can decrypt the message.
- Create a new file called classical_crypto.py in your pqc-lab folder.
- Add the imports and encryption function by pasting the following code:
import os
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def classical_encrypt(message: bytes, public_key) -> dict:
"""Encrypt using RSA-OAEP key wrapping + AES-256-GCM."""
# Generate random AES-256 key
aes_key = AESGCM.generate_key(bit_length=256)
# Wrap the AES key with RSA-OAEP (SHA-256)
wrapped_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Encrypt message with AES-256-GCM
nonce = os.urandom(12)
aesgcm = AESGCM(aes_key)
ciphertext = aesgcm.encrypt(nonce, message, None)
return {"wrapped_key": wrapped_key, "nonce": nonce, "ciphertext": ciphertext}
What does this code do?
- The imports pull in RSA for key wrapping, AES-GCM for message encryption, and serialization helpers for measuring key sizes later.
- AESGCM.generate_key(bit_length=256) creates a random 32-byte symmetric key.
- RSA-OAEP wraps (encrypts) that AES key using the recipient's public key. Only their private key can unwrap it.
- AES-256-GCM encrypts the actual message. GCM provides both confidentiality and integrity (authenticated encryption).
Why two layers of encryption?
RSA can only encrypt data smaller than its key size (about 190 bytes for RSA-2048 with OAEP padding). Messages are almost always larger than that.
The solution: encrypt the message with a fast symmetric cipher (AES), then use RSA only to protect the small AES key. This is the KEM-DEM pattern you'll see again in the post-quantum step.
You won't see a change yet. The next chunk adds the decryption function that completes the round-trip.
Add the decryption function
The decryption flow reverses the two layers. First, RSA unwraps the AES key. Then AES-GCM decrypts the message.
- Below the classical_encrypt function, add the decryption function:
def classical_decrypt(encrypted: dict, private_key) -> bytes:
"""Decrypt using RSA-OAEP key unwrapping + AES-256-GCM."""
# Unwrap the AES key
aes_key = private_key.decrypt(
encrypted["wrapped_key"],
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Decrypt the message
aesgcm = AESGCM(aes_key)
plaintext = aesgcm.decrypt(encrypted["nonce"], encrypted["ciphertext"], None)
return plaintext
What does this code do?
- private_key.decrypt() unwraps the AES key using the same OAEP padding parameters. Only the matching private key can do this.
- aesgcm.decrypt() decrypts the ciphertext and verifies its integrity. If even one bit was tampered with, GCM raises an error.
You won't see a change yet. The next chunk adds the demo function that exercises both encrypt and decrypt.
Add the demo and size analysis
The demo function generates an RSA-2048 keypair, encrypts and decrypts a message, then prints size measurements. These sizes become your baseline for comparison with post-quantum algorithms.
- Below classical_decrypt, add the demo function and the main entry point:
def demo_classical():
"""Demonstrate classical hybrid encryption with size analysis."""
print("Classical Encryption: RSA-2048 + AES-256-GCM")
print("=" * 55)
# Generate RSA-2048 keypair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
# Encrypt
message = b"Quantum computers will break RSA by 2035. Migrate now."
encrypted = classical_encrypt(message, public_key)
# Decrypt
decrypted = classical_decrypt(encrypted, private_key)
print(f"\nOriginal: {message.decode()}")
print(f"Decrypted: {decrypted.decode()}")
print(f"Success: {decrypted == message}")
# Size analysis
pub_bytes = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
print(f"\n{'SIZE ANALYSIS':=^55}")
print(f"RSA-2048 public key (DER): {len(pub_bytes)} bytes")
print(f"RSA-OAEP wrapped key: {len(encrypted['wrapped_key'])} bytes")
print(f"AES-GCM nonce: {len(encrypted['nonce'])} bytes")
print(f"AES-GCM ciphertext: {len(encrypted['ciphertext'])} bytes")
print(f"\n{'QUANTUM VULNERABILITY':=^55}")
print("Shor's algorithm factors RSA-2048 in polynomial time")
print("on a sufficiently large quantum computer.")
print("\nThreat model: HARVEST NOW, DECRYPT LATER (HNDL)")
print("Adversaries intercept and store encrypted traffic today.")
print("When quantum computers mature, they decrypt the stored data.")
print("Any data that must remain secret for 10+ years is at risk NOW.")
if __name__ == "__main__":
demo_classical()
What does this code do?
- demo_classical() generates an RSA-2048 keypair, encrypts a test message, then decrypts it to prove the round-trip works.
- The size analysis section serializes the public key in DER format and measures each component's byte size. You'll compare these against ML-KEM-768 in the next step.
- The quantum vulnerability section explains Shor's algorithm and the Harvest Now, Decrypt Later (HNDL) threat. Adversaries are intercepting encrypted traffic today, planning to decrypt it once quantum computers mature.
- Save classical_crypto.py.
Seeing a syntax error on save?
- Check that the f-string curly braces are not accidentally doubled. The format {len(pub_bytes)} needs exactly one pair of braces.
- Make sure the if __name__ == "__main__": line is at the very bottom of the file with no indentation.
- Verify the three functions are not nested inside each other. Each def should start at column 0.
Help me fix a syntax error in my classical_crypto.py file.
Run the script and observe the output
- Run the classical encryption demo by running this command:
python classical_crypto.py
You should see the message encrypt and decrypt successfully, followed by the size analysis and quantum vulnerability explanation.
What should I see?
The output prints the original message, the decrypted message (both should match), and Success: True.
The size analysis shows RSA-2048 public key at approximately 294 bytes (DER format) and the RSA-OAEP wrapped key at exactly 256 bytes. These are your baseline numbers for comparison with post-quantum key sizes in the next step.
Seeing ModuleNotFoundError?
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- If not, reactivate it with venv\Scripts\activate and try again.
Help me fix a ModuleNotFoundError when running classical_crypto.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
Here is the complete classical_crypto.py file for comparison:
import os
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def classical_encrypt(message: bytes, public_key) -> dict:
"""Encrypt using RSA-OAEP key wrapping + AES-256-GCM."""
# Generate random AES-256 key
aes_key = AESGCM.generate_key(bit_length=256)
# Wrap the AES key with RSA-OAEP (SHA-256)
wrapped_key = public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Encrypt message with AES-256-GCM
nonce = os.urandom(12)
aesgcm = AESGCM(aes_key)
ciphertext = aesgcm.encrypt(nonce, message, None)
return {"wrapped_key": wrapped_key, "nonce": nonce, "ciphertext": ciphertext}
def classical_decrypt(encrypted: dict, private_key) -> bytes:
"""Decrypt using RSA-OAEP key unwrapping + AES-256-GCM."""
# Unwrap the AES key
aes_key = private_key.decrypt(
encrypted["wrapped_key"],
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Decrypt the message
aesgcm = AESGCM(aes_key)
plaintext = aesgcm.decrypt(encrypted["nonce"], encrypted["ciphertext"], None)
return plaintext
def demo_classical():
"""Demonstrate classical hybrid encryption with size analysis."""
print("Classical Encryption: RSA-2048 + AES-256-GCM")
print("=" * 55)
# Generate RSA-2048 keypair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
# Encrypt
message = b"Quantum computers will break RSA by 2035. Migrate now."
encrypted = classical_encrypt(message, public_key)
# Decrypt
decrypted = classical_decrypt(encrypted, private_key)
print(f"\nOriginal: {message.decode()}")
print(f"Decrypted: {decrypted.decode()}")
print(f"Success: {decrypted == message}")
# Size analysis
pub_bytes = public_key.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
print(f"\n{'SIZE ANALYSIS':=^55}")
print(f"RSA-2048 public key (DER): {len(pub_bytes)} bytes")
print(f"RSA-OAEP wrapped key: {len(encrypted['wrapped_key'])} bytes")
print(f"AES-GCM nonce: {len(encrypted['nonce'])} bytes")
print(f"AES-GCM ciphertext: {len(encrypted['ciphertext'])} bytes")
print(f"\n{'QUANTUM VULNERABILITY':=^55}")
print("Shor's algorithm factors RSA-2048 in polynomial time")
print("on a sufficiently large quantum computer.")
print("\nThreat model: HARVEST NOW, DECRYPT LATER (HNDL)")
print("Adversaries intercept and store encrypted traffic today.")
print("When quantum computers mature, they decrypt the stored data.")
print("Any data that must remain secret for 10+ years is at risk NOW.")
if __name__ == "__main__":
demo_classical()
You now have a working classical encryption baseline. The RSA-OAEP wrapped key is 256 bytes, and the public key is about 294 bytes in DER format. These become your comparison numbers. Next up, you'll replace RSA key exchange with ML-KEM-768 and see how post-quantum sizes compare.
Implement Post-Quantum Key Exchange with ML-KEM-768
In the previous step, you built a classical RSA-2048 encryption system and measured its key and ciphertext sizes. You also saw the core vulnerability: Shor's algorithm can factor the RSA modulus in polynomial time once a sufficiently powerful quantum computer exists.
Now you will replace RSA key exchange with ML-KEM-768 (FIPS 203), the NIST-standardized quantum-resistant Key Encapsulation Mechanism. Instead of encrypting a key directly like RSA does, a KEM produces a shared secret that both parties can derive independently. This shared secret then becomes the AES key for encrypting the actual message.
In this step, get ready to:
- Generate ML-KEM-768 keypairs and verify encapsulation produces matching shared secrets.
- Build a full KEM-DEM hybrid encryption flow using ML-KEM-768 + AES-256-GCM.
- Produce a size comparison table showing ML-KEM-768 vs RSA-2048 tradeoffs.
Create pqc_kem.py with raw key encapsulation
A KEM works differently from RSA. Instead of encrypting a pre-existing key, the encapsulation operation generates a fresh shared secret AND the ciphertext in one step. The recipient uses decapsulation to recover the same shared secret from the ciphertext.
- Create a new file called pqc_kem.py in your pqc-lab folder.
- Add the imports and a demonstration function that generates a keypair, runs encapsulation/decapsulation, and verifies the shared secrets match:
import os
from secrets import compare_digest
from pqcrypto.kem.ml_kem_768 import generate_keypair, encrypt, decrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def demo_kem():
"""Demonstrate ML-KEM-768 key encapsulation and hybrid encryption."""
print("Post-Quantum Key Exchange: ML-KEM-768 (FIPS 203)")
print("=" * 55)
# Generate ML-KEM-768 keypair
public_key, secret_key = generate_keypair()
print(f"\nML-KEM-768 Key Sizes:")
print(f" Public key: {len(public_key)} bytes")
print(f" Secret key: {len(secret_key)} bytes")
# Encapsulate: produces ciphertext + shared secret
ciphertext, shared_secret_sender = encrypt(public_key)
# Decapsulate: recovers the same shared secret
shared_secret_receiver = decrypt(secret_key, ciphertext)
print(f"\nEncapsulation:")
print(f" Ciphertext: {len(ciphertext)} bytes")
print(f" Shared secret: {len(shared_secret_sender)} bytes")
print(f" Secrets match: {compare_digest(shared_secret_sender, shared_secret_receiver)}")
if __name__ == "__main__":
demo_kem()
What does this code do?
This demonstrates the raw ML-KEM-768 primitives from the pqcrypto library.
- generate_keypair() returns a public key (1184 bytes) and secret key (2400 bytes) as raw bytes.
- encrypt(public_key) is the encapsulation step. It generates BOTH a ciphertext and a 32-byte shared secret in one operation.
- decrypt(secret_key, ciphertext) is decapsulation. It recovers the same 32-byte shared secret from the ciphertext.
- compare_digest performs a constant-time comparison to verify both parties derived identical secrets.
Why is encrypt called encrypt if it's encapsulation?
The pqcrypto library uses the names encrypt/decrypt for KEM operations because they map to the underlying IND-CCA2 public-key encryption that the KEM is built from. In NIST terminology, these are Encaps and Decaps. The result is not an encrypted message but a shared secret that both sides can use as a symmetric key.
- Save pqc_kem.py.
- Run the script to confirm encapsulation works by running:
python pqc_kem.py
- You should see ML-KEM-768 public key size of 1184 bytes, ciphertext of 1088 bytes, shared secret of 32 bytes, and Secrets match: True.
Seeing an import error?
- Make sure your virtual environment is activated. You should see (venv) at the start of your terminal prompt.
- Verify the import uses pqcrypto.kem.ml_kem_768 with underscores, not hyphens or camelCase.
Help me debug my pqc_kem.py import error
Build the KEM-DEM hybrid encryption functions
The raw shared secret from ML-KEM-768 is exactly 32 bytes. That is the perfect size for an AES-256 key. The KEM-DEM pattern uses the KEM to establish a shared secret, then uses that secret as the key for a symmetric cipher (the DEM) to encrypt the actual message.
- In pqc_kem.py, add the pqc_hybrid_encrypt function between the imports and the demo_kem() function:
def pqc_hybrid_encrypt(message: bytes, recipient_public_key: bytes) -> dict:
"""Encrypt using ML-KEM-768 + AES-256-GCM (KEM-DEM pattern)."""
# KEM: encapsulate to get ciphertext + shared secret
kem_ciphertext, shared_secret = encrypt(recipient_public_key)
# DEM: use shared secret as AES-256-GCM key to encrypt message
nonce = os.urandom(12)
aesgcm = AESGCM(shared_secret)
encrypted_message = aesgcm.encrypt(nonce, message, None)
return {
"kem_ciphertext": kem_ciphertext,
"nonce": nonce,
"encrypted_message": encrypted_message,
}
What does this function do?
This is the sender's side of the KEM-DEM pattern. It performs two operations in sequence.
- KEM step: encapsulate against the recipient's public key to produce a ciphertext and a 32-byte shared secret.
- DEM step: use the shared secret directly as the AES-256-GCM key to encrypt the plaintext message.
- The bundle (KEM ciphertext + nonce + encrypted message) is everything the recipient needs to recover the plaintext.
- Add the pqc_hybrid_decrypt function directly below pqc_hybrid_encrypt:
def pqc_hybrid_decrypt(encrypted: dict, recipient_secret_key: bytes) -> bytes:
"""Decrypt using ML-KEM-768 + AES-256-GCM."""
# Decapsulate to recover the shared secret
shared_secret = decrypt(recipient_secret_key, encrypted["kem_ciphertext"])
# Decrypt the message with the recovered key
aesgcm = AESGCM(shared_secret)
plaintext = aesgcm.decrypt(encrypted["nonce"], encrypted["encrypted_message"], None)
return plaintext
What does this function do?
This is the recipient's side. It reverses the KEM-DEM process: decapsulate the KEM ciphertext to recover the shared secret, then use that secret to decrypt the AES-GCM ciphertext. If the KEM ciphertext was tampered with, decapsulation fails and the wrong key is derived, causing AES-GCM decryption to reject the message.
- Now add the hybrid encryption test inside demo_kem(). Find the last print statement that shows Secrets match: and add this code below it:
message = b"This message is protected by lattice-based cryptography."
encrypted = pqc_hybrid_encrypt(message, public_key)
decrypted = pqc_hybrid_decrypt(encrypted, secret_key)
print(f"\nKEM-DEM Hybrid Encryption:")
print(f" Original: {message.decode()}")
print(f" Decrypted: {decrypted.decode()}")
print(f" Success: {decrypted == message}")
What does this test prove?
This exercises the full round-trip. Alice encrypts a message using Bob's public key (KEM encapsulation + AES-GCM). Bob decrypts using his secret key (KEM decapsulation + AES-GCM). If the decrypted message matches the original, the entire KEM-DEM pipeline works correctly.
- Save pqc_kem.py and run it again:
python pqc_kem.py
- You should now see a new section KEM-DEM Hybrid Encryption: with the original and decrypted messages matching and Success: True.
Seeing a decryption error?
- Make sure the pqc_hybrid_encrypt and pqc_hybrid_decrypt functions are placed ABOVE the demo_kem() function definition.
- Check that the test code inside demo_kem() uses public_key for encryption and secret_key for decryption. Swapping them will fail.
Help me debug my KEM-DEM encryption
Add the size comparison table
ML-KEM-768 keys and ciphertexts are roughly 4x larger than RSA-2048. The comparison table makes this tradeoff concrete and explains why the increase is acceptable for quantum resistance.
- In demo_kem(), find the line that prints Success: {decrypted == message} and add this code below it:
print(f"\n{'SIZE COMPARISON: ML-KEM-768 vs RSA-2048':=^55}")
print(f"{'Metric':<25} {'RSA-2048':<12} {'ML-KEM-768':<12}")
print(f"{'-'*25} {'-'*12} {'-'*12}")
print(f"{'Public key':<25} {'~294 B':<12} {'1184 B':<12}")
print(f"{'Secret/Private key':<25} {'~1218 B':<12} {'2400 B':<12}")
print(f"{'Ciphertext':<25} {'256 B':<12} {'1088 B':<12}")
print(f"{'Shared secret':<25} {'32 B':<12} {'32 B':<12}")
print(f"\nWHY ~4x LARGER KEYS ARE ACCEPTABLE:")
print("- TLS handshakes already move multiple kilobytes")
print("- Extra ~1KB is negligible on modern networks")
print("- ML-KEM operations are sub-millisecond on modern CPUs")
print("- Security basis: Module Learning With Errors (MLWE)")
print("- No known quantum algorithm speeds up lattice problems")
What do these numbers mean?
- ML-KEM-768 public keys are 1184 bytes vs RSA-2048's ~294 bytes. About 4x larger, but still under 2 KB.
- The shared secret is 32 bytes in both cases. Both approaches produce the same size symmetric key for AES-256.
- The security basis is the Module Learning With Errors (MLWE) lattice problem. No known quantum algorithm provides a meaningful speedup against lattice problems, unlike Shor's algorithm against RSA factoring.
- Save pqc_kem.py and run the final version:
python pqc_kem.py
- You should see the full output: key sizes, encapsulation verification with Secrets match: True, the hybrid encryption round-trip with Success: True, and the formatted size comparison table.
Table formatting looks broken?
- Check that the f-string alignment operators use the correct format. Each column uses left-alignment with a specific width (e.g. 25, 12).
- Make sure the indentation is consistent. All lines inside demo_kem() must be indented by 4 spaces.
Help me fix my comparison table output
Why is a ~4x size increase acceptable?
A TLS handshake already exchanges multiple kilobytes of data (certificates, extensions, client hello). Adding an extra ~1 KB for ML-KEM-768 is negligible on modern networks. The tradeoff is clear: slightly larger messages in exchange for resistance to quantum attacks that would completely break RSA.
ML-KEM is also faster than RSA for key generation and encapsulation on modern CPUs. The performance story is actually better, not worse.
✔️ Awesome, I've got everything!
Great. Double check you have saved pqc_kem.py and your output shows all three sections: key sizes, encapsulation match, and the comparison table.
ⓧ I'd like to double check the full code
Here is the complete pqc_kem.py file. Compare yours against it:
import os
from secrets import compare_digest
from pqcrypto.kem.ml_kem_768 import generate_keypair, encrypt, decrypt
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def pqc_hybrid_encrypt(message: bytes, recipient_public_key: bytes) -> dict:
"""Encrypt using ML-KEM-768 + AES-256-GCM (KEM-DEM pattern)."""
kem_ciphertext, shared_secret = encrypt(recipient_public_key)
nonce = os.urandom(12)
aesgcm = AESGCM(shared_secret)
encrypted_message = aesgcm.encrypt(nonce, message, None)
return {
"kem_ciphertext": kem_ciphertext,
"nonce": nonce,
"encrypted_message": encrypted_message,
}
def pqc_hybrid_decrypt(encrypted: dict, recipient_secret_key: bytes) -> bytes:
"""Decrypt using ML-KEM-768 + AES-256-GCM."""
shared_secret = decrypt(recipient_secret_key, encrypted["kem_ciphertext"])
aesgcm = AESGCM(shared_secret)
plaintext = aesgcm.decrypt(encrypted["nonce"], encrypted["encrypted_message"], None)
return plaintext
def demo_kem():
"""Demonstrate ML-KEM-768 key encapsulation and hybrid encryption."""
print("Post-Quantum Key Exchange: ML-KEM-768 (FIPS 203)")
print("=" * 55)
public_key, secret_key = generate_keypair()
print(f"\nML-KEM-768 Key Sizes:")
print(f" Public key: {len(public_key)} bytes")
print(f" Secret key: {len(secret_key)} bytes")
ciphertext, shared_secret_sender = encrypt(public_key)
shared_secret_receiver = decrypt(secret_key, ciphertext)
print(f"\nEncapsulation:")
print(f" Ciphertext: {len(ciphertext)} bytes")
print(f" Shared secret: {len(shared_secret_sender)} bytes")
print(f" Secrets match: {compare_digest(shared_secret_sender, shared_secret_receiver)}")
message = b"This message is protected by lattice-based cryptography."
encrypted = pqc_hybrid_encrypt(message, public_key)
decrypted = pqc_hybrid_decrypt(encrypted, secret_key)
print(f"\nKEM-DEM Hybrid Encryption:")
print(f" Original: {message.decode()}")
print(f" Decrypted: {decrypted.decode()}")
print(f" Success: {decrypted == message}")
print(f"\n{'SIZE COMPARISON: ML-KEM-768 vs RSA-2048':=^55}")
print(f"{'Metric':<25} {'RSA-2048':<12} {'ML-KEM-768':<12}")
print(f"{'-'*25} {'-'*12} {'-'*12}")
print(f"{'Public key':<25} {'~294 B':<12} {'1184 B':<12}")
print(f"{'Secret/Private key':<25} {'~1218 B':<12} {'2400 B':<12}")
print(f"{'Ciphertext':<25} {'256 B':<12} {'1088 B':<12}")
print(f"{'Shared secret':<25} {'32 B':<12} {'32 B':<12}")
print(f"\nWHY ~4x LARGER KEYS ARE ACCEPTABLE:")
print("- TLS handshakes already move multiple kilobytes")
print("- Extra ~1KB is negligible on modern networks")
print("- ML-KEM operations are sub-millisecond on modern CPUs")
print("- Security basis: Module Learning With Errors (MLWE)")
print("- No known quantum algorithm speeds up lattice problems")
if __name__ == "__main__":
demo_kem()
You now have a working post-quantum key exchange that produces the same 32-byte shared secret on both sides without relying on RSA factoring. Next up, you will add ML-DSA-65 digital signatures for authentication and compose everything into a complete quantum-safe messaging protocol.
Add Quantum-Safe Signatures and Complete the Protocol
Your ML-KEM-768 key exchange from the previous step establishes a shared secret between two parties. But key exchange alone does not prove who you are talking to. Without authentication, an attacker could intercept the exchange and substitute their own public key.
In this step, you will add ML-DSA-65 (FIPS 204) digital signatures for authentication, then compose all primitives into a complete quantum-safe message exchange between two parties.
In this step, get ready to:
- Build quantum-safe send and receive functions that combine ML-KEM-768 encryption with ML-DSA-65 signatures.
- Run a full authenticated message exchange between Alice and Bob.
- Demonstrate tamper detection by flipping a byte and confirming signature verification fails.
Create the quantum-safe send and receive functions
A complete secure messaging protocol needs three guarantees: confidentiality (only the recipient can read it), integrity (nobody altered it), and authenticity (the sender is who they claim). Your new file will compose ML-KEM-768 for the first guarantee and ML-DSA-65 for the second and third.
- Create a new file called pqc_messaging.py in your pqc-lab folder.
- Add the imports and the send function by pasting the following code:
import os
from pqcrypto.kem.ml_kem_768 import (
generate_keypair as kem_keygen,
encrypt as kem_encrypt,
decrypt as kem_decrypt,
)
from pqcrypto.sign.ml_dsa_65 import (
generate_keypair as sig_keygen,
sign,
verify,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def quantum_safe_send(message: bytes, recipient_kem_pk: bytes, sender_sig_sk: bytes) -> dict:
"""Send a quantum-safe authenticated and encrypted message."""
# Step 1: Encapsulate shared secret with recipient's KEM public key
kem_ciphertext, shared_secret = kem_encrypt(recipient_kem_pk)
# Step 2: Encrypt message with AES-256-GCM using KEM-derived key
nonce = os.urandom(12)
aesgcm = AESGCM(shared_secret)
encrypted_message = aesgcm.encrypt(nonce, message, None)
# Step 3: Sign the encrypted payload (sign-then-send pattern)
signature = sign(sender_sig_sk, encrypted_message)
return {
"kem_ciphertext": kem_ciphertext,
"nonce": nonce,
"encrypted_message": encrypted_message,
"signature": signature,
}
What does this code do?
The imports pull in both KEM (key exchange) and signing primitives from pqcrypto, aliasing them so both can coexist in one file.
- kem_encrypt(recipient_kem_pk) produces a ciphertext and a 32-byte shared secret only the recipient can recover.
- aesgcm.encrypt() encrypts the actual message using that shared secret as the AES-256 key.
- sign(sender_sig_sk, encrypted_message) signs the encrypted payload so the recipient can verify who sent it.
You won't see output yet. The next chunk adds the receive function that completes the pair.
- Add the receive function below quantum_safe_send() by pasting this code:
def quantum_safe_receive(
bundle: dict, recipient_kem_sk: bytes, sender_sig_pk: bytes
) -> bytes:
"""Receive and verify a quantum-safe message."""
# Step 1: Verify signature first (authenticate before decrypt)
try:
is_valid = verify(sender_sig_pk, bundle["encrypted_message"], bundle["signature"])
except Exception:
is_valid = False
if not is_valid:
raise ValueError("SIGNATURE VERIFICATION FAILED - message may be tampered!")
# Step 2: Decapsulate shared secret
shared_secret = kem_decrypt(recipient_kem_sk, bundle["kem_ciphertext"])
# Step 3: Decrypt message
aesgcm = AESGCM(shared_secret)
plaintext = aesgcm.decrypt(bundle["nonce"], bundle["encrypted_message"], None)
return plaintext
Why verify the signature before decrypting?
The authenticate-before-decrypt pattern avoids wasting computation on tampered messages. If the signature is invalid, the function raises a ValueError immediately without attempting decapsulation or decryption.
The try/except wrapper handles both cases: the verify function returning False or raising an exception on invalid signatures.
- Save pqc_messaging.py.
Seeing a syntax error on save?
- Make sure there is exactly one blank line between the end of quantum_safe_send() and the start of quantum_safe_receive().
- Check that the return statement in quantum_safe_send is indented inside the function, not at the top level.
Why is my pqc_messaging.py showing a syntax error?
Run the full Alice-to-Bob exchange
With both functions in place, you can now simulate a real message exchange. Alice will generate signing keys, Bob will generate KEM keys, and Alice will send Bob an authenticated, encrypted message.
- Add the demo_messaging() function and the main block at the bottom of pqc_messaging.py:
def demo_messaging():
"""Demonstrate complete quantum-safe messaging between Alice and Bob."""
print("Quantum-Safe Messaging: ML-KEM-768 + AES-GCM + ML-DSA-65")
print("=" * 60)
# Key generation
print("\n[Key Generation]")
bob_kem_pk, bob_kem_sk = kem_keygen()
alice_sig_pk, alice_sig_sk = sig_keygen()
print(f" Bob's KEM public key: {len(bob_kem_pk)} bytes")
print(f" Alice's signing public key: {len(alice_sig_pk)} bytes")
# Alice sends message to Bob
message = b"Meeting at 3pm. Quantum-safe channel confirmed."
print(f"\n[Alice -> Bob]")
print(f" Plaintext: {message.decode()}")
bundle = quantum_safe_send(message, bob_kem_pk, alice_sig_sk)
print(f" KEM ciphertext: {len(bundle['kem_ciphertext'])} bytes")
print(f" Encrypted message: {len(bundle['encrypted_message'])} bytes")
print(f" ML-DSA signature: {len(bundle['signature'])} bytes")
total = len(bundle["kem_ciphertext"]) + len(bundle["encrypted_message"]) + len(bundle["nonce"]) + len(bundle["signature"])
print(f" Total wire size: {total} bytes")
# Bob receives and verifies
print(f"\n[Bob receives]")
recovered = quantum_safe_receive(bundle, bob_kem_sk, alice_sig_pk)
print(f" Signature: VERIFIED")
print(f" Decrypted: {recovered.decode()}")
print(f" Integrity: {recovered == message}")
# Tamper detection
print(f"\n[Tamper Detection Demo]")
tampered = bundle.copy()
msg_bytes = bytearray(bundle["encrypted_message"])
msg_bytes[-1] ^= 0xFF
tampered["encrypted_message"] = bytes(msg_bytes)
try:
quantum_safe_receive(tampered, bob_kem_sk, alice_sig_pk)
print(" ERROR: Tampered message accepted!")
except ValueError as e:
print(f" {e}")
print(" Tamper detection: WORKING CORRECTLY")
if __name__ == "__main__":
demo_messaging()
What does this demo do?
- Bob generates an ML-KEM-768 keypair (for receiving encrypted messages). Alice generates an ML-DSA-65 keypair (for signing messages she sends).
- quantum_safe_send() encapsulates, encrypts, and signs in one call. The bundle contains everything Bob needs.
- quantum_safe_receive() verifies the signature, decapsulates the shared secret, and decrypts the message.
- The tamper detection section flips one byte of the ciphertext and confirms the signature check catches it.
- Save pqc_messaging.py.
- Run the script by entering this command in your terminal:
python pqc_messaging.py
- You should see the full exchange output: Bob's KEM public key (1184 bytes), Alice's signing key size, the KEM ciphertext (1088 bytes), encrypted message size, ML-DSA signature size (approximately 3293 bytes), and total wire size.
Seeing an ImportError or NameError?
- Check that your import block at the top includes both pqcrypto.kem.ml_kem_768 and pqcrypto.sign.ml_dsa_65.
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- Verify that demo_messaging() is defined at the module level (no extra indentation), not inside another function.
Help me debug my pqc_messaging.py import or function error.
Verify tamper detection and the integrity guarantee
Look at the last section of your output, labelled [Tamper Detection Demo]. This is where the script flips a single byte of the encrypted payload and attempts to pass it through the receive function.
- Confirm your output shows SIGNATURE VERIFICATION FAILED - message may be tampered! followed by Tamper detection: WORKING CORRECTLY.
Why does signing the ciphertext prove integrity?
The signature covers the exact bytes of the encrypted message. If even one bit changes in transit, the mathematical relationship between the signature and the data breaks. The receiver knows immediately that the message was altered.
This is the sign-then-send pattern. Alice signs what she encrypted, so Bob can verify authenticity without needing to decrypt first.
Seeing ERROR: Tampered message accepted?
- This means the tampered ciphertext passed signature verification, which should not happen. Check that the msg_bytes[-1] ^= 0xFF line is modifying the encrypted_message before passing it to quantum_safe_receive().
- Verify that tampered["encrypted_message"] is reassigned with the modified bytes, not the original.
Why is my tamper detection not catching the modified message?
✔️ Awesome, I've got everything!
Great. Double check you have saved your file.
ⓧ I'd like to double check the full code
Here is the complete pqc_messaging.py for comparison:
import os
from pqcrypto.kem.ml_kem_768 import (
generate_keypair as kem_keygen,
encrypt as kem_encrypt,
decrypt as kem_decrypt,
)
from pqcrypto.sign.ml_dsa_65 import (
generate_keypair as sig_keygen,
sign,
verify,
)
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def quantum_safe_send(message: bytes, recipient_kem_pk: bytes, sender_sig_sk: bytes) -> dict:
"""Send a quantum-safe authenticated and encrypted message."""
kem_ciphertext, shared_secret = kem_encrypt(recipient_kem_pk)
nonce = os.urandom(12)
aesgcm = AESGCM(shared_secret)
encrypted_message = aesgcm.encrypt(nonce, message, None)
signature = sign(sender_sig_sk, encrypted_message)
return {
"kem_ciphertext": kem_ciphertext,
"nonce": nonce,
"encrypted_message": encrypted_message,
"signature": signature,
}
def quantum_safe_receive(
bundle: dict, recipient_kem_sk: bytes, sender_sig_pk: bytes
) -> bytes:
"""Receive and verify a quantum-safe message."""
try:
is_valid = verify(sender_sig_pk, bundle["encrypted_message"], bundle["signature"])
except Exception:
is_valid = False
if not is_valid:
raise ValueError("SIGNATURE VERIFICATION FAILED - message may be tampered!")
shared_secret = kem_decrypt(recipient_kem_sk, bundle["kem_ciphertext"])
aesgcm = AESGCM(shared_secret)
plaintext = aesgcm.decrypt(bundle["nonce"], bundle["encrypted_message"], None)
return plaintext
def demo_messaging():
"""Demonstrate complete quantum-safe messaging between Alice and Bob."""
print("Quantum-Safe Messaging: ML-KEM-768 + AES-GCM + ML-DSA-65")
print("=" * 60)
print("\n[Key Generation]")
bob_kem_pk, bob_kem_sk = kem_keygen()
alice_sig_pk, alice_sig_sk = sig_keygen()
print(f" Bob's KEM public key: {len(bob_kem_pk)} bytes")
print(f" Alice's signing public key: {len(alice_sig_pk)} bytes")
message = b"Meeting at 3pm. Quantum-safe channel confirmed."
print(f"\n[Alice -> Bob]")
print(f" Plaintext: {message.decode()}")
bundle = quantum_safe_send(message, bob_kem_pk, alice_sig_sk)
print(f" KEM ciphertext: {len(bundle['kem_ciphertext'])} bytes")
print(f" Encrypted message: {len(bundle['encrypted_message'])} bytes")
print(f" ML-DSA signature: {len(bundle['signature'])} bytes")
total = len(bundle["kem_ciphertext"]) + len(bundle["encrypted_message"]) + len(bundle["nonce"]) + len(bundle["signature"])
print(f" Total wire size: {total} bytes")
print(f"\n[Bob receives]")
recovered = quantum_safe_receive(bundle, bob_kem_sk, alice_sig_pk)
print(f" Signature: VERIFIED")
print(f" Decrypted: {recovered.decode()}")
print(f" Integrity: {recovered == message}")
print(f"\n[Tamper Detection Demo]")
tampered = bundle.copy()
msg_bytes = bytearray(bundle["encrypted_message"])
msg_bytes[-1] ^= 0xFF
tampered["encrypted_message"] = bytes(msg_bytes)
try:
quantum_safe_receive(tampered, bob_kem_sk, alice_sig_pk)
print(" ERROR: Tampered message accepted!")
except ValueError as e:
print(f" {e}")
print(" Tamper detection: WORKING CORRECTLY")
if __name__ == "__main__":
demo_messaging()
You now have a complete quantum-safe messaging protocol that encrypts, authenticates, and detects tampering. Next up, you will build a comparison report that runs both the classical and post-quantum approaches side by side to visualize the migration trade-offs.
Build the Migration Comparison Report
You now have a complete quantum-safe messaging protocol with encryption, key exchange, and tamper detection. The final piece is a comparison report that puts your classical and post-quantum systems side by side.
In a real migration scenario, engineering teams need hard numbers to justify the switch to PQC. In this step, you will build a utility that runs both approaches, measures their outputs, and prints a formatted analysis including the NIST timeline that is driving urgency across every industry.
In this step, get ready to:
- Create a comparison script that measures both classical and PQC key exchange flows.
- Add ML-DSA-65 signature measurements and the NIST IR 8547 migration timeline.
- Run the report and verify all values match the NIST specifications.
Create the key exchange comparison script
The comparison script runs both RSA-OAEP and ML-KEM-768 key exchange flows, then prints their sizes in a formatted table. This gives you concrete numbers to reference when discussing PQC migration.
- Create a new file called compare.py in your pqc-lab folder and paste the following code:
import time
from pqcrypto.kem.ml_kem_768 import (
generate_keypair as kem_keygen,
encrypt as kem_encrypt,
)
from pqcrypto.sign.ml_dsa_65 import (
generate_keypair as sig_keygen,
sign,
verify,
)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def print_comparison():
"""Produce a formatted migration comparison report."""
print("POST-QUANTUM CRYPTOGRAPHY MIGRATION REPORT")
print("=" * 60)
print(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}")
# Measure classical RSA-2048 key exchange
rsa_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rsa_public = rsa_private.public_key()
rsa_pub_bytes = rsa_public.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
aes_key = AESGCM.generate_key(bit_length=256)
rsa_ct = rsa_public.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Measure PQC ML-KEM-768 key exchange
kem_pk, kem_sk = kem_keygen()
kem_ct, kem_ss = kem_encrypt(kem_pk)
# Measure PQC digital signatures
sig_pk, sig_sk = sig_keygen()
test_msg = b"Test message for signature measurement"
signature = sign(sig_sk, test_msg)
# Print formatted key exchange comparison table
print(f"\n{'KEY EXCHANGE COMPARISON'}")
print("-" * 60)
print(f"{'Metric':<28} {'RSA-2048':<14} {'ML-KEM-768':<14}")
print(f"{'-'*28} {'-'*14} {'-'*14}")
print(f"{'Public key (bytes)':<28} {len(rsa_pub_bytes):<14} {len(kem_pk):<14}")
print(f"{'Ciphertext (bytes)':<28} {len(rsa_ct):<14} {len(kem_ct):<14}")
print(f"{'Shared secret (bytes)':<28} {len(aes_key):<14} {len(kem_ss):<14}")
print(f"{'Quantum resistant':<28} {'NO':<14} {'YES':<14}")
print(f"{'NIST standard':<28} {'Deprecated*':<14} {'FIPS 203':<14}")
if __name__ == "__main__":
print_comparison()
What does this code do?
The script generates real keys for both RSA-2048 and ML-KEM-768, then measures the actual byte sizes of the public keys, ciphertexts, and shared secrets. These are live measurements from your machine, not hardcoded values.
The comparison table formats both systems side by side so you can see exactly how much larger the post-quantum artifacts are. The table also includes the quantum resistance status and NIST standard reference for each approach.
- Save compare.py.
- Run the script in your terminal by running:
python compare.py
- You should see a formatted table with RSA-2048 and ML-KEM-768 columns showing public key sizes, ciphertext sizes, shared secret sizes, and quantum resistance status.
Seeing an import error?
Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt. If not, re-activate it with venv\Scripts\activate.
Help me debug an import error in my compare.py script.
Add signature measurements and migration context
The key exchange table shows why RSA is vulnerable, but a complete migration report also needs signature sizes and the official NIST timeline that is driving organizations to act now.
- In compare.py, find this line inside the print_comparison() function:
print(f"{'NIST standard':<28} {'Deprecated*':<14} {'FIPS 203':<14}")
- Add the following code directly below that line (still inside the function, above the if __name__ block):
# Display ML-DSA-65 signature size analysis
print(f"\n{'DIGITAL SIGNATURES: ML-DSA-65 (FIPS 204)'}")
print("-" * 60)
print(f" Public key size: {len(sig_pk)} bytes")
print(f" Secret key size: {len(sig_sk)} bytes")
print(f" Signature size: {len(signature)} bytes")
print(f" (RSA-2048 PSS signature: 256 bytes)")
print(f" (Size increase: ~{len(signature) // 256}x for quantum resistance)")
# NIST IR 8547 migration timeline
print(f"\n{'NIST IR 8547 MIGRATION TIMELINE'}")
print("-" * 60)
print(" 2024: PQC standards finalized (FIPS 203, 204, 205)")
print(" 2025-2030: Active migration period (begin immediately)")
print(" 2030: Quantum-vulnerable algorithms DEPRECATED")
print(" 2035: Quantum-vulnerable algorithms REMOVED from standards")
print(" High-risk systems (govt, finance, health): migrate NOW")
# Harvest Now, Decrypt Later threat model explanation
print(f"\n{'HARVEST NOW, DECRYPT LATER (HNDL)'}")
print("-" * 60)
print(" Adversaries intercept encrypted traffic today and store it.")
print(" When quantum computers mature, they decrypt stored data.")
print(" Any data requiring secrecy beyond 2035 is at risk TODAY.")
print(" This is not theoretical: nation-state actors are known to")
print(" collect and store encrypted communications at scale.")
# Key size justification for PQC adoption
print(f"\n{'WHY LARGER KEY SIZES ARE ACCEPTABLE'}")
print("-" * 60)
print(f" ML-KEM-768 public key: {len(kem_pk)} bytes (~4x RSA-2048)")
print(f" ML-KEM-768 ciphertext: {len(kem_ct)} bytes (~4x RSA-2048)")
print(" A typical TLS handshake already moves multiple kilobytes.")
print(" ML-KEM operations run in sub-millisecond on modern CPUs.")
print(" ML-KEM is FASTER than RSA for key generation and encaps.")
print(" The bandwidth cost is negligible for quantum resistance.")
print(f"\n * Per NIST IR 8547, RSA will be removed from standards by 2035.")
What does this code do?
This section prints four analysis blocks. The signature section shows ML-DSA-65 key and signature sizes compared to RSA-2048 PSS. The NIST timeline section presents the official migration deadlines from NIST IR 8547.
The Harvest Now, Decrypt Later section explains why the migration deadline matters for data encrypted today. The key size section argues why the ~4x increase in key sizes is an acceptable tradeoff for quantum resistance.
- Save compare.py.
Not sure where to paste the code?
The new code goes between the last line of the key exchange table (the NIST standard row) and the if __name__ == "__main__": block at the bottom. Make sure the indentation matches (4 spaces, inside the function).
Help me fix my compare.py indentation.
Run the report and verify NIST specification values
The final check confirms your measurements match the official NIST FIPS 203 specification values. If they do, your lab is correctly implementing the standardized algorithms.
- Run the complete report by running:
python compare.py
- Verify the following values in the KEY EXCHANGE COMPARISON table under the ML-KEM-768 column:
- Public key (bytes): 1184.
- Ciphertext (bytes): 1088.
- Shared secret (bytes): 32.
Why do these values matter?
These sizes are defined in the FIPS 203 specification and are constant for ML-KEM-768 regardless of the message being encrypted. The 1184-byte public key and 1088-byte ciphertext are roughly 4x larger than RSA-2048, but this is negligible in practice since TLS handshakes already move multiple kilobytes.
The 32-byte shared secret is identical to what RSA-OAEP produces. This means you can swap the key exchange mechanism without changing anything downstream in your AES-256-GCM encryption.
- Also confirm the DIGITAL SIGNATURES section shows ML-DSA-65 signature size of approximately 3293 bytes.
- Confirm the NIST IR 8547 MIGRATION TIMELINE section shows the 2024, 2030, and 2035 milestones.
Values don't match the expected sizes?
If ML-KEM-768 public key is not showing 1184 bytes, check that your import uses ml_kem_768 and not ml_kem_512 or ml_kem_1024. Each variant has different sizes.
Help me debug my compare.py output values.
✔️ Awesome, I've got everything!
Great. Double check you have saved compare.py and that all values match the NIST specs.
ⓧ I'd like to double check the full code
Here is the complete compare.py file for reference:
import time
from pqcrypto.kem.ml_kem_768 import (
generate_keypair as kem_keygen,
encrypt as kem_encrypt,
)
from pqcrypto.sign.ml_dsa_65 import (
generate_keypair as sig_keygen,
sign,
verify,
)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def print_comparison():
"""Produce a formatted migration comparison report."""
print("POST-QUANTUM CRYPTOGRAPHY MIGRATION REPORT")
print("=" * 60)
print(f"Generated: {time.strftime('%Y-%m-%d %H:%M:%S')}")
rsa_private = rsa.generate_private_key(public_exponent=65537, key_size=2048)
rsa_public = rsa_private.public_key()
rsa_pub_bytes = rsa_public.public_bytes(Encoding.DER, PublicFormat.SubjectPublicKeyInfo)
aes_key = AESGCM.generate_key(bit_length=256)
rsa_ct = rsa_public.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
kem_pk, kem_sk = kem_keygen()
kem_ct, kem_ss = kem_encrypt(kem_pk)
sig_pk, sig_sk = sig_keygen()
test_msg = b"Test message for signature measurement"
signature = sign(sig_sk, test_msg)
print(f"\n{'KEY EXCHANGE COMPARISON'}")
print("-" * 60)
print(f"{'Metric':<28} {'RSA-2048':<14} {'ML-KEM-768':<14}")
print(f"{'-'*28} {'-'*14} {'-'*14}")
print(f"{'Public key (bytes)':<28} {len(rsa_pub_bytes):<14} {len(kem_pk):<14}")
print(f"{'Ciphertext (bytes)':<28} {len(rsa_ct):<14} {len(kem_ct):<14}")
print(f"{'Shared secret (bytes)':<28} {len(aes_key):<14} {len(kem_ss):<14}")
print(f"{'Quantum resistant':<28} {'NO':<14} {'YES':<14}")
print(f"{'NIST standard':<28} {'Deprecated*':<14} {'FIPS 203':<14}")
print(f"\n{'DIGITAL SIGNATURES: ML-DSA-65 (FIPS 204)'}")
print("-" * 60)
print(f" Public key size: {len(sig_pk)} bytes")
print(f" Secret key size: {len(sig_sk)} bytes")
print(f" Signature size: {len(signature)} bytes")
print(f" (RSA-2048 PSS signature: 256 bytes)")
print(f" (Size increase: ~{len(signature) // 256}x for quantum resistance)")
print(f"\n{'NIST IR 8547 MIGRATION TIMELINE'}")
print("-" * 60)
print(" 2024: PQC standards finalized (FIPS 203, 204, 205)")
print(" 2025-2030: Active migration period (begin immediately)")
print(" 2030: Quantum-vulnerable algorithms DEPRECATED")
print(" 2035: Quantum-vulnerable algorithms REMOVED from standards")
print(" High-risk systems (govt, finance, health): migrate NOW")
print(f"\n{'HARVEST NOW, DECRYPT LATER (HNDL)'}")
print("-" * 60)
print(" Adversaries intercept encrypted traffic today and store it.")
print(" When quantum computers mature, they decrypt stored data.")
print(" Any data requiring secrecy beyond 2035 is at risk TODAY.")
print(" This is not theoretical: nation-state actors are known to")
print(" collect and store encrypted communications at scale.")
print(f"\n{'WHY LARGER KEY SIZES ARE ACCEPTABLE'}")
print("-" * 60)
print(f" ML-KEM-768 public key: {len(kem_pk)} bytes (~4x RSA-2048)")
print(f" ML-KEM-768 ciphertext: {len(kem_ct)} bytes (~4x RSA-2048)")
print(" A typical TLS handshake already moves multiple kilobytes.")
print(" ML-KEM operations run in sub-millisecond on modern CPUs.")
print(" ML-KEM is FASTER than RSA for key generation and encaps.")
print(" The bandwidth cost is negligible for quantum resistance.")
print(f"\n * Per NIST IR 8547, RSA will be removed from standards by 2035.")
if __name__ == "__main__":
print_comparison()
Your migration comparison report is complete. You now have a full PQC lab that spans classical encryption, quantum-resistant key exchange, authenticated messaging, and a side-by-side analysis. Next up, you can take on the Secret Mission and implement the hybrid encryption pattern that Chrome, Cloudflare, and AWS use in production today.
Secret mission
Implement Hybrid Encryption (X25519 + ML-KEM-768)
During the NIST transition period, every major security body recommends hybrid cryptography: combining classical and post-quantum algorithms so the system remains secure as long as at least one holds. Build the exact pattern Chrome, Cloudflare, and AWS deploy in production today.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally on your machine, so there are no ongoing costs. Decide whether to keep your resources for future experimentation or delete them entirely.
Resources you used:
- pqc-lab project directory (contains all Python scripts and the venv virtual environment with installed packages).
Keep everything running
No action needed. Choose this if you want to keep experimenting with post-quantum cryptography, build additional tools like quantum-safe file encryption, or extend the lab into a chat application.
- Your scripts and virtual environment remain in the pqc-lab folder on your Desktop.
- To resume work later, open a terminal in VS Code, navigate to pqc-lab, and activate the virtual environment again.
Pause - I'll come back to this later
Deactivate the virtual environment to free up your terminal session. Your files stay on disk for when you return.
- Deactivate the virtual environment by running:
deactivate
- Your pqc-lab folder and all scripts remain on your Desktop.
- To resume, navigate back to pqc-lab and reactivate with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
Delete - I don't want to use this again
Remove all project files, the virtual environment, and installed packages. This frees up disk space completely.
- Deactivate the virtual environment first by running:
deactivate
- Navigate up to the Desktop by running:
cd ~/Desktop
- Delete the entire project folder by running the command for your operating system.
macOS
rm -rf pqc-lab
Windows
rmdir /s /q pqc-lab
- Confirm the folder is gone by running ls (macOS) or dir (Windows). You should no longer see pqc-lab in the output.
Nice Work!
Nice Work!
You just built a fully functional post-quantum cryptography lab implementing the NIST standards that organizations worldwide are racing to adopt before the 2035 deadline.
You learned how to:
- Built a classical RSA-OAEP + AES-256-GCM encryption baseline and understood why Shor's algorithm threatens it through the Harvest Now, Decrypt Later attack model.
- Implemented ML-KEM-768 (FIPS 203) key encapsulation with the KEM-DEM pattern and ML-DSA-65 (FIPS 204) digital signatures with tamper detection.
- Composed a complete quantum-safe message exchange protocol combining encryption, authentication, and integrity verification between two parties.
- Secret Mission: Implemented the hybrid X25519 + ML-KEM-768 pattern with HKDF key derivation, matching production deployments by Chrome, Cloudflare, and AWS.
Ready to quiz yourself?