New in 2026: Master Python for AI, Data Science

ProgrammingPython

Asymmetric Encryption and Digital Signatures with RSA in Python – (Part 3 of 5)

Developer using Python to perform asymmetric encryption and digital signatures, with cryptographic keys and secure message icons illustrated.

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.

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

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()

Related posts
Python

Pydantic Agent Basics: A Complete 2026 Tutorial

ProgrammingPython

Production-Ready MCP Servers — Security, Testing & Deployment

ProgrammingPython

Build Your First MCP Server with Python SDK — Fundamentals

ProgrammingPython

Connect FastAPI to MCP — Two Integration Patterns

Leave a Reply