In the first two parts of this series, you learned the fundamentals of end-to-end encryption and how to implement strong symmetric encryption using AES. Now, let’s tackle asymmetric encryption in Python. This post covers public/private key cryptography, secure key exchange, and digital signatures—all with hands-on code in Python.
Page Contents
Why Asymmetric Encryption?
Symmetric encryption is fast and reliable, but sharing secret keys is risky, especially over networks. Asymmetric encryption uses two keys: a public key (shared freely) and a private key (kept secret). Anyone can encrypt a message with your public key, but only you can decrypt it using your private key.
This system solves the “key exchange problem” and adds the ability to digitally sign messages, confirming origin and integrity.
How Asymmetric Encryption Works
Suppose Alice wants to send Bob a secret message:
- Bob generates a private key and publishes his public key.
- Alice encrypts her message using Bob’s public key.
- Only Bob can decrypt it, using his private key.
This forms the foundation for secure email, messaging apps, and even software package signing.
Generating Key Pairs with Python for asymmetric encryption in Python
Python’s cryptography library lets you create, store, and use RSA key pairs simply.
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
# Generate a 2048-bit key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
public_key = private_key.public_key()
# Save the private key
with open("private_key.pem", "wb") as f:
f.write(private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
))
# Save the public key
with open("public_key.pem", "wb") as f:
f.write(public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
))
Tip: Protect your private key file—anyone with it can decrypt your messages.
Encrypting and Decrypting a Message with RSA
RSA isn’t efficient for large data. Instead, use it to encrypt small secrets, such as AES keys or short messages.
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
message = b'This is a top secret message.'
# Encrypt with recipient's public key
encrypted = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# Decrypt with private key
decrypted = private_key.decrypt(
encrypted,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
print(decrypted.decode())
What OAEP Padding Does:
The padding ensures the ciphertext is unique for each operation and resists cryptographic attacks.
Putting It All Together: Secure Key Exchange
Combining asymmetric and symmetric encryption allows two parties to:
- Use RSA (asymmetric) to securely send a symmetric AES key,
- Then use that AES key to encrypt bulky data (messages/files).
from cryptography.fernet import Fernet
# Alice creates a random AES key for the session
session_key = Fernet.generate_key()
# She encrypts it with Bob's public RSA key
encrypted_session_key = public_key.encrypt(
session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# Bob decrypts the session key with his private key
decrypted_session_key = private_key.decrypt(
encrypted_session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# Now both share the session key
assert decrypted_session_key == session_key
# Use the session key for fast symmetric encryption as in Part 2
cipher = Fernet(session_key)
ciphertext = cipher.encrypt(b"Hello Bob, this is encrypted with our shared key!")
plaintext = cipher.decrypt(ciphertext)
print(plaintext.decode())
Digital Signatures: Proving Origin and Data Integrity
Digital signatures let you “sign” data to prove it came from you and hasn’t changed.
Sign a message:
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.asymmetric import padding
message = b"This is an important message."
signature = private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
Verify a signature:
try:
public_key.verify(
signature,
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
print("Signature is valid.")
except Exception as e:
print("Signature invalid or message has changed!")
Takeaway:
Signing proves who sent the message and that no one else tampered with it.
Security Notes and Limitations
- RSA keys should be 2048 bits at minimum; 3072+ bits is better for long-term secrets.
- Never share your private key. Use encrypted PEM files for storage.
- RSA encryption is slow; use it only for exchanging symmetric keys and small data.
- For very high security or modern applications, consider Elliptic Curve Cryptography (ECC), which is more efficient than RSA.
- Always verify and validate keys before use.
When Should You Use Asymmetric Encryption?
- Exchanging keys over an insecure channel
- Verifying signatures on code/software or documents
- Securely encrypting short secrets (passwords, session keys)
- Authenticating senders in messaging apps
For all bulk data transfer, rely on symmetric encryption with securely exchanged keys.
Common Pitfalls
- Never encrypt large files directly with RSA—performance is poor and cipher length is limited.
- Don’t store private keys in public repositories.
- Always use padding (OAEP for encryption, PSS for signatures).
- Rotate keys periodically for services exposed to the internet.
Where to Go Next: ECC and Real-life Applications
In advanced E2EE systems, Elliptic Curve Cryptography is preferred for sharing keys or signatures due to greater efficiency. Python’s cryptography library supports ECC as well. For most prototyping, RSA is a fine starting point.
For bonus learning:
- Review your created PEM files and try loading keys from disk.
- Try signing, tampering, and then verifying messages to see signature validation in action.
Further Reading & External Resources
- Python Cryptography Library Documentation
- OWASP Cryptographic Storage Cheat Sheet
- RFC8017: PKCS #1 v2.2 RSA Cryptography Specification
- NIST Digital Signature Standard (DSS)
- Part – 1 – End-to-End Encryption in Python: Complete Developer Guide
- Part – 2 – Implementing AES Symmetric Encryption in Python: A Practical Guide
What’s Next
In Part 4, we’ll combine the previous lessons and build a simple E2EE chat system in Python. I’ll show how to use asymmetric encryption for key exchange, symmetric encryption for messages, and digital signatures for message integrity—all in one practical project.
Complete Example: RSA Encryption, Decryption, and Digital Signatures in Python
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import utils
def generate_rsa_key_pair():
"""
Generate a 2048-bit RSA key pair.
Returns (private_key, public_key) objects.
"""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048
)
public_key = private_key.public_key()
return private_key, public_key
def save_key_to_pem(key, filename, private=False, password=None):
"""
Save RSA key to PEM file.
For private keys, password (bytes) can be provided for encryption.
"""
if private:
encryption_algo = (serialization.BestAvailableEncryption(password)
if password else serialization.NoEncryption())
pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=encryption_algo
)
else:
pem = key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
with open(filename, 'wb') as f:
f.write(pem)
print(f"Key saved to {filename}")
def rsa_encrypt(public_key, message: bytes) -> bytes:
return public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
def rsa_decrypt(private_key, ciphertext: bytes) -> bytes:
return private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
def rsa_sign(private_key, message: bytes) -> bytes:
return private_key.sign(
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
def rsa_verify(public_key, message: bytes, signature: bytes) -> bool:
try:
public_key.verify(
signature,
message,
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
return True
except Exception:
return False
def main():
message = b"This is a confidential message."
# Generate keys
private_key, public_key = generate_rsa_key_pair()
# Optionally save keys to PEM files
save_key_to_pem(private_key, 'private_key.pem', private=True)
save_key_to_pem(public_key, 'public_key.pem')
# Encrypt the message with public key
encrypted_msg = rsa_encrypt(public_key, message)
print(f"Encrypted Message (hex): {encrypted_msg.hex()}")
# Decrypt the message with private key
decrypted_msg = rsa_decrypt(private_key, encrypted_msg)
print(f"Decrypted Message: {decrypted_msg.decode()}")
# Sign the message
signature = rsa_sign(private_key, message)
print(f"Signature (hex): {signature.hex()}")
# Verify the signature
is_valid = rsa_verify(public_key, message, signature)
print(f"Signature valid: {is_valid}")
# Tampering test (comment this in to test signature invalidation)
# tampered_message = b"This is a tampered message."
# is_valid_tampered = rsa_verify(public_key, tampered_message, signature)
# print(f"Signature valid after tampering: {is_valid_tampered}")
if __name__ == "__main__":
main()

