Skip to main content

Public-Key Cryptography in Python: RSA Explained

Public-key cryptography (also called asymmetric cryptography) uses two mathematically linked keys: a public key that anyone can know and a private key that only the owner knows. Data encrypted with the public key can only be decrypted with the private key, and vice versa. RSA is the most widely deployed public-key algorithm and is fundamental to HTTPS, email encryption, and secure key exchange in 2026.

Public-key cryptography solves the key distribution problem that plagues symmetric encryption: how do two parties share a secret key securely without an existing secure channel? In asymmetric encryption, Alice publishes her public key openly. Bob encrypts a message with Alice's public key, and only Alice (with her private key) can decrypt it. No pre-shared secret is needed.

How RSA Works at a High Level

RSA relies on the mathematical difficulty of factoring large numbers. The public key is the product of two large primes (n = p × q), and the private key is the prime factors themselves. Encryption scrambles data using the public key; decryption uses the private key. An attacker who has the public key but not the private key cannot decrypt the ciphertext because factoring n into p and q would take thousands of years with current technology (for 2048-bit or larger keys).

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend

# Generate a 2048-bit RSA key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)

# Extract the public key
public_key = private_key.public_key()

# Encrypt a message with the public key
plaintext = b"Secret message from Alice to Bob"
ciphertext = public_key.encrypt(
plaintext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
print(f"Ciphertext: {ciphertext.hex()}")

# Decrypt with the private key (only owner can do this)
decrypted = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
print(f"Decrypted: {decrypted}") # b'Secret message from Alice to Bob'

The key size matters: 2048-bit RSA keys are standard and considered secure through 2030. 4096-bit keys provide additional security margin for data that must remain confidential for decades (government classified, financial).

RSA Encryption: The OAEP Padding Story

Raw RSA encryption (textbook RSA) is insecure in practice. An attacker can recover plaintext through various attacks: if two messages encrypt to the same ciphertext (deterministic), or by analyzing patterns. The solution is padding: adding randomness to the plaintext before encryption.

OAEP (Optimal Asymmetric Encryption Padding) is the standard padding scheme. It adds random bytes to the plaintext, ensuring identical messages encrypt differently. Every encryption of the same plaintext produces a different ciphertext (randomized encryption).

# WRONG: Raw RSA (vulnerable)
# ciphertext = pow(plaintext, e, n) # DO NOT USE
# Vulnerable to: pattern analysis, chosen-plaintext attacks

# CORRECT: RSA with OAEP padding
ciphertext = public_key.encrypt(
plaintext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)

The OAEP parameters are important: MGF1 (Mask Generation Function) uses SHA-256 to generate a mask, and the hash algorithm is SHA-256. This is standard and recommended for 2026.

Key Pair Generation, Storage, and Format

from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend

# Generate a key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)

# Serialize the private key to PEM format (for storage)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption() # In production, encrypt with password
)
print(private_pem)
# Output: b'-----BEGIN PRIVATE KEY-----\nMIIEvQIBA...'

# In production, protect the private key with a password
private_pem_encrypted = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(b"strong_passphrase")
)

# Save to file
with open("private_key.pem", "wb") as f:
f.write(private_pem_encrypted)

# Serialize the public key
public_pem = private_key.public_key().public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
print(public_pem)
# Output: b'-----BEGIN PUBLIC KEY-----\nMIIBIjANB...'

with open("public_key.pem", "wb") as f:
f.write(public_pem)

# Later, load the keys from disk
with open("private_key.pem", "rb") as f:
loaded_private = serialization.load_pem_private_key(
f.read(),
password=b"strong_passphrase",
backend=default_backend()
)

with open("public_key.pem", "rb") as f:
loaded_public = serialization.load_pem_public_key(
f.read(),
backend=default_backend()
)

PEM (Privacy Enhanced Mail) is the standard format for storing cryptographic keys. Files are readable text starting with "-----BEGIN PRIVATE KEY-----" and are human-friendly for inspection.

RSA in TLS/HTTPS: Key Exchange

RSA is used in HTTPS to establish a secure connection. The protocol works as follows:

  1. Browser sends a ClientHello to the server.
  2. Server responds with its RSA public key (embedded in a certificate signed by a Certificate Authority).
  3. Browser generates a random session key (symmetric, for bulk data encryption).
  4. Browser encrypts the session key with the server's RSA public key and sends it.
  5. Only the server (with the private key) can decrypt and recover the session key.
  6. Both parties now share the same session key and use symmetric encryption (AES) for the rest of the connection.

This hybrid approach combines the security advantages of both: public-key crypto (no pre-shared secret) and symmetric crypto (fast, suitable for large data).

RSA in Hybrid Encryption: Combining Asymmetric and Symmetric

Real applications rarely encrypt large data with RSA directly (it's slow and limited to messages smaller than the key size). Instead, use hybrid encryption:

from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
import os

def hybrid_encrypt(public_key, plaintext: bytes) -> bytes:
"""
Encrypt plaintext using hybrid encryption:
1. Generate a random symmetric key (AES)
2. Encrypt plaintext with the symmetric key (fast)
3. Encrypt the symmetric key with the RSA public key
4. Return: RSA-encrypted-key + IV + AES-encrypted-plaintext
"""

# Generate a random session key for AES-256
session_key = os.urandom(32)
iv = os.urandom(12)

# Encrypt the plaintext with AES-256-GCM
cipher = Cipher(
algorithms.AES(session_key),
modes.GCM(iv),
backend=default_backend()
)
encryptor = cipher.encryptor()
ciphertext = encryptor.update(plaintext) + encryptor.finalize()
tag = encryptor.tag

# Encrypt the session key with RSA
encrypted_session_key = public_key.encrypt(
session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)

# Return: encrypted-session-key + IV + ciphertext + tag
return encrypted_session_key + iv + ciphertext + tag

def hybrid_decrypt(private_key, encrypted_data: bytes) -> bytes:
"""Decrypt hybrid-encrypted data."""

# Extract components
# RSA-encrypted key is 256 bytes (2048-bit RSA key size)
encrypted_session_key = encrypted_data[:256]
iv = encrypted_data[256:268] # 12 bytes
tag = encrypted_data[-16:] # 16 bytes
ciphertext = encrypted_data[268:-16]

# Decrypt the session key with RSA
session_key = private_key.decrypt(
encrypted_session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)

# Decrypt the plaintext with AES
cipher = Cipher(
algorithms.AES(session_key),
modes.GCM(iv, tag),
backend=default_backend()
)
decryptor = cipher.decryptor()
plaintext = decryptor.update(ciphertext) + decryptor.finalize()
return plaintext

# Example: Large file encryption
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
backend=default_backend()
)
public_key = private_key.public_key()

large_plaintext = b"This is a large document " * 1000 # 24 KB

encrypted = hybrid_encrypt(public_key, large_plaintext)
print(f"Encrypted ({len(encrypted)} bytes): {encrypted[:50].hex()}...")

decrypted = hybrid_decrypt(private_key, encrypted)
assert decrypted == large_plaintext
print("Hybrid encryption successful!")

Key Takeaways

  • Public-key cryptography uses two linked keys: a public key (known to all) and a private key (secret). Data encrypted with one key is decrypted with the other.
  • RSA is the most deployed public-key algorithm; 2048-bit keys are standard and secure through 2030.
  • Always use OAEP padding with RSA; raw textbook RSA is insecure and vulnerable to multiple attacks.
  • For large data, use hybrid encryption: encrypt the plaintext with symmetric encryption (fast), and encrypt the symmetric key with RSA.
  • RSA is fundamental to HTTPS: the server's public key (in a certificate) is used to encrypt the session key; only the server's private key can decrypt it.

Frequently Asked Questions

Should I use RSA-2048 or RSA-4096?

RSA-2048 is secure through 2030 and is the standard. Use RSA-4096 for data that must remain confidential for decades (government classified) or if you want additional security margin. 4096-bit keys are slower.

What is the public exponent 65537?

It's a standard, small exponent used in RSA public keys for faster encryption. 65537 (0x10001 in hex) is secure and recommended. Never change it unless you have a specific reason and cryptographic expertise.

Can I use my RSA private key for both encryption and decryption?

Yes. The same key pair can be used for both encryption (public key encrypts, private key decrypts) and digital signatures (private key signs, public key verifies). However, using the same key for multiple purposes increases risk. Best practice: separate keys for encryption and signing.

How do I know if my RSA key is strong?

A key is strong if: (1) it was generated by a cryptographic library (not custom code), (2) it's at least 2048 bits, (3) the private key is stored securely and protected with a passphrase, and (4) it's never been exposed in logs or version control.

Is RSA being replaced in 2026?

NIST is transitioning to post-quantum algorithms (like CRYSTALS-Kyber) for long-term security against future quantum computers. However, RSA remains standard for HTTPS and is secure against classical computers. Transition to post-quantum crypto is gradual; RSA is not deprecated.

Further Reading