Skip to content

Symmetric & Asymmetric Encryption

Encryption transforms readable data into an unreadable format that can only be reversed with the correct key. The two fundamental approaches — symmetric and asymmetric — solve different problems and are almost always used together in practice.


Symmetric Encryption

In symmetric encryption, the same key is used for both encryption and decryption. It is fast, efficient, and used for encrypting bulk data.

┌──────────┐ Key K ┌──────────┐ Key K ┌──────────┐
│ Plaintext│───────────────▶│Ciphertext│───────────────▶│ Plaintext│
│ │ Encrypt │ │ Decrypt │ │
└──────────┘ └──────────┘ └──────────┘
Both sides must possess the SAME secret key K.
If an attacker obtains K, all encrypted data is compromised.

The Key Distribution Problem

The fundamental challenge of symmetric encryption: how do you securely share the key? If you send the key over the same channel as the data, an attacker can intercept both. This problem is what motivated the invention of asymmetric encryption.

AES (Advanced Encryption Standard)

AES is the most widely used symmetric cipher in the world. Adopted by the U.S. government in 2001, it has withstood decades of cryptanalysis and remains unbroken.

PropertyValue
Block size128 bits (16 bytes)
Key sizes128, 192, or 256 bits
TypeBlock cipher
StatusCurrent standard, no known practical attacks
SpeedHardware-accelerated on modern CPUs (AES-NI)

AES Modes of Operation

Since AES operates on fixed 16-byte blocks, a mode of operation defines how to handle messages of arbitrary length.

ModeAuthenticatedParallelizableNotes
ECBNoYesNever use. Identical plaintext blocks produce identical ciphertext — patterns leak through
CBCNoDecrypt onlyLegacy. Susceptible to padding oracle attacks
CTRNoYesTurns block cipher into stream cipher. Fast but no integrity
GCMYesYesRecommended. Provides both confidentiality and authenticity
CCMYesNoUsed in constrained environments (IoT, Bluetooth)
ECB Mode (NEVER USE):
┌────┐ ┌────┐ ┌────┐ ┌────┐
│ P1 │ │ P2 │ │ P1 │ │ P3 │ Plaintext blocks
└──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘
│ │ │ │
▼ ▼ ▼ ▼ AES with key K
┌────┐ ┌────┐ ┌────┐ ┌────┐
│ C1 │ │ C2 │ │ C1 │ │ C3 │ Ciphertext blocks
└────┘ └────┘ └────┘ └────┘
P1 = P1, so C1 = C1 ← Pattern leaked!
GCM Mode (RECOMMENDED):
┌────┐ ┌────┐ ┌────┐ ┌────┐
│ P1 │ │ P2 │ │ P1 │ │ P3 │ Plaintext blocks
└──┬─┘ └──┬─┘ └──┬─┘ └──┬─┘
│ │ │ │
▼ ▼ ▼ ▼ AES-GCM with key K + nonce
┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌──────────────┐
│ C1 │ │ C2 │ │ C3 │ │ C4 │ │Authentication│
└────┘ └────┘ └────┘ └────┘ │ Tag │
└──────────────┘
All ciphertext blocks are unique. Tag verifies integrity.

AES-GCM in Practice

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def encrypt_aes_gcm(plaintext: bytes, key: bytes,
associated_data: bytes = None) -> tuple:
"""Encrypt data using AES-256-GCM.
Returns (nonce, ciphertext) tuple.
The nonce MUST be unique for every encryption with the same key.
"""
# Generate a random 96-bit nonce (NEVER reuse with the same key)
nonce = os.urandom(12)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
return nonce, ciphertext
def decrypt_aes_gcm(nonce: bytes, ciphertext: bytes, key: bytes,
associated_data: bytes = None) -> bytes:
"""Decrypt data encrypted with AES-256-GCM.
Raises InvalidTag if ciphertext was tampered with.
"""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, associated_data)
# Usage
key = AESGCM.generate_key(bit_length=256)
message = b"Top secret: launch codes are 12345"
# Associated data is authenticated but NOT encrypted
# (e.g., a header that must not be tampered with)
aad = b"message-id:abc123"
nonce, ciphertext = encrypt_aes_gcm(message, key, aad)
plaintext = decrypt_aes_gcm(nonce, ciphertext, key, aad)
print(f"Decrypted: {plaintext.decode()}")
# Tamper detection: modify ciphertext and try to decrypt
tampered = bytearray(ciphertext)
tampered[0] ^= 0xFF # flip a bit
try:
decrypt_aes_gcm(nonce, bytes(tampered), key, aad)
except Exception as e:
print(f"Tamper detected: {e}")

ChaCha20-Poly1305

ChaCha20-Poly1305 is a modern authenticated encryption cipher designed by Daniel J. Bernstein. It is the primary alternative to AES-GCM.

PropertyAES-256-GCMChaCha20-Poly1305
Key size256 bits256 bits
Nonce size96 bits96 bits
Speed (with AES-NI)FasterSlightly slower
Speed (without AES-NI)SlowerMuch faster
Side-channel resistanceRequires careful implementationNaturally resistant
Used byMost systemsTLS 1.3, WireGuard, SSH

Asymmetric Encryption

In asymmetric (public-key) encryption, there are two mathematically linked keys: a public key that anyone can know and a private key that must be kept secret.

┌──────────────────────────────────────────────────────────┐
│ Asymmetric Encryption │
│ │
│ Alice generates a key pair: │
│ Public Key (shared with everyone) │
│ Private Key (kept secret by Alice) │
│ │
│ Bob encrypts with Alice's PUBLIC key: │
│ ┌──────────┐ Alice's Public Key ┌──────────┐ │
│ │ Plaintext│─────────────────────▶│Ciphertext│ │
│ └──────────┘ └──────────┘ │
│ │
│ Only Alice can decrypt with her PRIVATE key: │
│ ┌──────────┐ Alice's Private Key ┌──────────┐ │
│ │Ciphertext│─────────────────────▶│ Plaintext│ │
│ └──────────┘ └──────────┘ │
│ │
│ Even Bob cannot decrypt what he encrypted. │
└──────────────────────────────────────────────────────────┘

RSA

RSA (Rivest-Shamir-Adleman) was the first practical public-key cryptosystem (1977). Its security is based on the difficulty of factoring the product of two large prime numbers.

PropertyRecommendation
Minimum key size2048 bits (3072+ recommended for post-2030)
Padding schemeOAEP for encryption, PSS for signing
PerformanceSlow — do not use for bulk data
StatusWidely used but being replaced by elliptic curve alternatives
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
# Generate RSA key pair
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
# Encrypt with public key (anyone can do this)
message = b"Confidential: merger details inside"
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# Decrypt with private key (only the key holder)
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
print(f"Decrypted: {plaintext.decode()}")
# Serialize keys for storage/transmission
pem_private = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(
b"passphrase"
),
)
pem_public = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

Elliptic Curve Cryptography (ECC)

ECC provides the same security level as RSA with much smaller key sizes, making it faster and more efficient.

Security LevelRSA Key SizeECC Key SizeImprovement
80-bit1024 bits160 bits6x smaller
112-bit2048 bits224 bits9x smaller
128-bit3072 bits256 bits12x smaller
192-bit7680 bits384 bits20x smaller
256-bit15360 bits521 bits30x smaller

Common ECC algorithms:

AlgorithmPurposeCurve
ECDSADigital signaturesP-256, P-384
Ed25519Digital signaturesCurve25519
ECDHKey exchangeP-256, X25519
ECIESEncryptionVarious

Diffie-Hellman Key Exchange

Diffie-Hellman (DH) solves the key distribution problem by allowing two parties to derive a shared secret over an untrusted channel without ever transmitting the key itself.

┌──────────────────────────────────────────────────────────────┐
│ Diffie-Hellman Key Exchange (ECDH) │
│ │
│ Alice Bob │
│ ────── ───── │
│ 1. Generate private key: a │
│ Compute public key: A = a * G ──── A ────▶ │
│ │
│ 2. ◀── B ──── │
│ Bob generates private key: b │
│ Computes public key: B = b * G │
│ │
│ 3. Alice computes: Bob computes: │
│ shared = a * B shared = b * A │
│ shared = a * (b * G) shared = b * (a * G) │
│ shared = ab * G shared = ab * G │
│ │
│ Both arrive at the SAME shared secret: ab * G │
│ An eavesdropper knows A and B but cannot compute ab * G │
│ (this is the Elliptic Curve Discrete Logarithm Problem) │
└──────────────────────────────────────────────────────────────┘

ECDH in Practice

from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
# Alice generates her key pair
alice_private = ec.generate_private_key(ec.SECP256R1())
alice_public = alice_private.public_key()
# Bob generates his key pair
bob_private = ec.generate_private_key(ec.SECP256R1())
bob_public = bob_private.public_key()
# Alice and Bob exchange public keys (over untrusted channel)
# Then each computes the shared secret:
alice_shared = alice_private.exchange(ec.ECDH(), bob_public)
bob_shared = bob_private.exchange(ec.ECDH(), alice_public)
# Both arrive at the same shared secret
assert alice_shared == bob_shared
# Derive a usable encryption key from the shared secret
# (raw shared secret should not be used directly as a key)
alice_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"encryption-key",
).derive(alice_shared)
bob_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"encryption-key",
).derive(bob_shared)
assert alice_key == bob_key
print("Shared key derived successfully!")
print(f"Key: {alice_key.hex()}")

Ephemeral Diffie-Hellman (ECDHE)

In practice, Diffie-Hellman keys should be ephemeral — generated fresh for each session. This provides forward secrecy: even if a long-term key is compromised in the future, past sessions remain secure.

Without forward secrecy:
Attacker records encrypted traffic today.
Years later, attacker steals the server's private key.
Attacker decrypts ALL recorded past traffic. ✗
With forward secrecy (ECDHE):
Each session uses a unique ephemeral DH key.
Ephemeral keys are discarded after the session.
Even if the server's long-term key is stolen,
past session keys cannot be recovered. ✓

Hybrid Encryption

Asymmetric encryption is too slow for bulk data. Symmetric encryption requires a shared key. The solution is hybrid encryption: use asymmetric encryption to securely exchange a symmetric key, then use that symmetric key for the actual data.

┌──────────────────────────────────────────────────────────┐
│ Hybrid Encryption │
│ │
│ Step 1: Generate a random symmetric key (session key) │
│ K = random_bytes(32) │
│ │
│ Step 2: Encrypt the session key with RSA/ECDH │
│ encrypted_K = RSA_encrypt(K, recipient_pubkey) │
│ │
│ Step 3: Encrypt the data with AES-GCM using K │
│ ciphertext = AES_GCM_encrypt(data, K) │
│ │
│ Step 4: Send both encrypted_K and ciphertext │
│ [encrypted_K | nonce | ciphertext | auth_tag] │
│ │
│ Recipient: │
│ 1. Decrypt K with their private key │
│ 2. Decrypt ciphertext with K │
└──────────────────────────────────────────────────────────┘
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes
import os
def hybrid_encrypt(plaintext: bytes, recipient_public_key) -> dict:
"""Encrypt data using hybrid RSA + AES-GCM."""
# 1. Generate a random AES session key
session_key = AESGCM.generate_key(bit_length=256)
# 2. Encrypt the session key with RSA
encrypted_key = recipient_public_key.encrypt(
session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# 3. Encrypt the data with AES-GCM
nonce = os.urandom(12)
aesgcm = AESGCM(session_key)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
return {
"encrypted_key": encrypted_key,
"nonce": nonce,
"ciphertext": ciphertext,
}
def hybrid_decrypt(encrypted: dict, recipient_private_key) -> bytes:
"""Decrypt data encrypted with hybrid RSA + AES-GCM."""
# 1. Decrypt the session key with RSA
session_key = recipient_private_key.decrypt(
encrypted["encrypted_key"],
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
# 2. Decrypt the data with AES-GCM
aesgcm = AESGCM(session_key)
return aesgcm.decrypt(
encrypted["nonce"], encrypted["ciphertext"], None
)
# Usage
private_key = rsa.generate_private_key(
public_exponent=65537, key_size=2048
)
public_key = private_key.public_key()
# Encrypt a large file (only the 256-bit key goes through RSA)
large_data = b"x" * 10_000_000 # 10 MB
encrypted = hybrid_encrypt(large_data, public_key)
decrypted = hybrid_decrypt(encrypted, private_key)
assert decrypted == large_data
print("Hybrid encryption: 10 MB encrypted and decrypted successfully")

Algorithm Selection Guide

ScenarioRecommended Algorithm
Encrypting files at restAES-256-GCM
Encrypting in constrained environmentsChaCha20-Poly1305
Encrypting data for a specific recipientHybrid: RSA-OAEP + AES-GCM
Key exchange for a sessionECDHE (X25519 or P-256)
Digital signaturesEd25519 (preferred) or ECDSA P-256
Legacy compatibilityRSA-2048 with OAEP padding

What to Avoid

AlgorithmWhyReplacement
DES, 3DES56-bit keys (DES), slow (3DES)AES-256
RC4Multiple known attacksAES-GCM or ChaCha20
RSA-1024Factorable with modern hardwareRSA-2048+ or ECC
RSA with PKCS#1 v1.5Padding oracle attacksRSA-OAEP
Blowfish64-bit block size causes issues at scaleAES-256

Key Management Best Practices

The strongest cipher is worthless if keys are mismanaged.

PracticeWhy
Never hardcode keys in source codeKeys will end up in version control
Use a Key Management Service (AWS KMS, GCP KMS, HashiCorp Vault)Centralized, audited key lifecycle
Rotate keys periodicallyLimits exposure from a single compromise
Use separate keys for separate purposesEncryption key differs from signing key
Generate keys with cryptographic randomnessUse os.urandom(), crypto.randomBytes(), SecureRandom
Encrypt keys at restMaster keys protect data keys (envelope encryption)
Destroy keys securely when no longer neededPrevent recovery from memory or disk

Next Steps