Encryption in Practice
Fernet, AES-GCM, RSA, Hybrid Encryption & Key Storage
Introduction
Encryption protects data confidentiality, it transforms readable plaintext into ciphertext that is meaningless without the key. This lesson covers the three encryption tools you'll use in real Python applications: Fernet for simplicity, AES-GCM for performance with authentication, and RSA for key exchange. We finish with the hybrid encryption pattern that combines RSA and AES, the same approach used by TLS, PGP, and Signal.
Fernet, Simple Authenticated Encryption
Fernet is the safest default choice for symmetric encryption in Python. It bundles AES-128-CBC with a SHA-256 HMAC for authentication, you get confidentiality and integrity in one package.
Install: pip install cryptography
# fernet_practice.py
from cryptography.fernet import Fernet, MultiFernet
import os
# ── Key management ────────────────────────────────────────────
# Option 1: Generate and store to file
key = Fernet.generate_key()
with open("secret.key", "wb") as kf:
kf.write(key)
# Option 2: Load from environment variable
# key = os.environ["ENCRYPTION_KEY"].encode()
cipher = Fernet(key)
# ── Encrypt a record ──────────────────────────────────────────
user_ssn = b"123-45-6789"
encrypted_ssn = cipher.encrypt(user_ssn)
print(f"Original SSN: {user_ssn.decode()}")
print(f"Encrypted: {encrypted_ssn.decode()[:60]}...")
# ── Decrypt ───────────────────────────────────────────────────
decrypted_ssn = cipher.decrypt(encrypted_ssn)
print(f"Decrypted: {decrypted_ssn.decode()}")
# ── Key rotation with MultiFernet ────────────────────────────
old_key = key
new_key = Fernet.generate_key()
# MultiFernet tries new key first, falls back to old key
mf = MultiFernet([Fernet(new_key), Fernet(old_key)])
# Rotate: re-encrypts tokens with the new key
rotated = mf.rotate(encrypted_ssn)
print(f"\nRotated token: {rotated.decode()[:60]}...")
print(f"Still decryptable: {mf.decrypt(rotated).decode()}")Expected Output:
Original SSN: 123-45-6789 Encrypted: gAAAAABplh2HYiXdltA9pX1nqHfOFENEBiWM5fOkfZFcj6WzFDe-j0EnI1HP... Decrypted: 123-45-6789 Rotated token: gAAAAABplh2HKsvaqIlsU_zWf_-4NsFhkojuZxrwdwWOVdIgbAxZLg8Aq_k1... Still decryptable: 123-45-6789
AES-GCM, Authenticated Encryption
AES-GCM (Galois/Counter Mode) is the gold standard for encrypting data in production systems. It provides both confidentiality (AES) and authenticity (GCM authentication tag) in a single pass, and is hardware-accelerated on modern CPUs.
AES-GCM Encryption
# aes_gcm_encrypt.py
import os
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# Generate a 256-bit AES key
key = secrets.token_bytes(32) # 256 bits
# Generate a 96-bit nonce (MUST be unique for each encryption)
# Never reuse a nonce with the same key!
nonce = os.urandom(12) # 96 bits (recommended for GCM)
aesgcm = AESGCM(key)
# Plaintext to encrypt
plaintext = b"Sensitive database record: credit_card=4111111111111111"
# Additional Authenticated Data, included in authentication tag
# but NOT encrypted (e.g., record ID, timestamp, user_id)
aad = b"user_id:42|record_type:payment"
# Encrypt, ciphertext includes the 16-byte authentication tag appended
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
print(f"Plaintext ({len(plaintext)} bytes): {plaintext.decode()}")
print(f"Nonce ({len(nonce)} bytes): {nonce.hex()}")
print(f"Ciphertext ({len(ciphertext)} bytes): {ciphertext.hex()[:40]}...")
print(f" (last 16 bytes are the authentication tag)")
# Store: nonce + ciphertext (and AAD separately, unencrypted)
stored = nonce + ciphertext
print(f"\nStored blob ({len(stored)} bytes): nonce || ciphertext || tag")Expected Output:
Plaintext (54 bytes): Sensitive database record: credit_card=4111111111111111 Nonce (12 bytes): 0c13b9dcf4227e12a2815d6a Ciphertext (71 bytes): a2e22e394b317485e37cf79b2963c6ed74309ca2... (last 16 bytes are the authentication tag) Stored blob (82 bytes): nonce || ciphertext || tag
AES-GCM Decryption with Authentication
# aes_gcm_decrypt.py
import os
import secrets
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
# ── Setup: recreate the encrypted blob from Part 1 ────────────
key = secrets.token_bytes(32)
nonce = os.urandom(12)
plaintext = b"Sensitive database record: credit_card=4111111111111111"
aad = b"user_id:42|record_type:payment"
ciphertext = AESGCM(key).encrypt(nonce, plaintext, aad)
stored = nonce + ciphertext # nonce || ciphertext || tag
# ── Decryption ────────────────────────────────────────────────
def decrypt_record(key: bytes, stored_blob: bytes, aad: bytes) -> bytes:
"""Decrypt an AES-GCM encrypted record. Raises InvalidTag if tampered."""
nonce = stored_blob[:12] # First 12 bytes are the nonce
ciphertext = stored_blob[12:] # Rest is ciphertext + tag
return AESGCM(key).decrypt(nonce, ciphertext, aad)
# Successful decryption
decrypted = decrypt_record(key, stored, aad)
print(f"Decrypted: {decrypted.decode()}")
# Tampered ciphertext, GCM tag verification FAILS
tampered = bytearray(stored)
tampered[20] ^= 0xFF # Flip a bit in the middle
try:
decrypt_record(key, bytes(tampered), aad)
except InvalidTag:
print("Tampered ciphertext detected ❌, InvalidTag raised")
# Wrong AAD, also fails authentication
try:
decrypt_record(key, stored, b"user_id:99|record_type:payment")
except InvalidTag:
print("Wrong AAD detected ❌, InvalidTag raised")
print("\n✅ AES-GCM provides both confidentiality AND integrity")Expected Output:
Decrypted: Sensitive database record: credit_card=4111111111111111 Tampered ciphertext detected ❌, InvalidTag raised Wrong AAD detected ❌, InvalidTag raised ✅ AES-GCM provides both confidentiality AND integrity
Hybrid Encryption (RSA + AES)
RSA is too slow to encrypt large data directly. The solution used by TLS, PGP, and Signal: use RSA to encrypt a random AES key, then use AES-GCM to encrypt the actual data. This gives you the key-distribution benefits of RSA with the speed of AES.
Hybrid Pattern (used by TLS, PGP, Signal)
# hybrid_encryption.py
import os
import secrets
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def hybrid_encrypt(public_key, plaintext: bytes) -> dict:
"""Encrypt data using RSA+AES hybrid encryption."""
# 1. Generate random AES session key
session_key = secrets.token_bytes(32) # 256-bit AES key
nonce = os.urandom(12)
# 2. Encrypt data with AES-GCM
aesgcm = AESGCM(session_key)
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
# 3. Encrypt session key with RSA public key
encrypted_key = public_key.encrypt(
session_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)
)
return {
"encrypted_key": encrypted_key, # RSA-encrypted AES key
"nonce": nonce,
"ciphertext": ciphertext, # AES-GCM encrypted data
}
def hybrid_decrypt(private_key, envelope: dict) -> bytes:
"""Decrypt a hybrid-encrypted envelope."""
# 1. Decrypt session key with RSA private key
session_key = private_key.decrypt(
envelope["encrypted_key"],
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
)
)
# 2. Decrypt data with recovered AES session key
aesgcm = AESGCM(session_key)
return aesgcm.decrypt(envelope["nonce"], envelope["ciphertext"], None)
# ── Demo ─────────────────────────────────────────────────────
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
large_document = b"This is a large document. " * 100 # 2600 bytes
envelope = hybrid_encrypt(public_key, large_document)
print(f"Document size: {len(large_document)} bytes")
print(f"Encrypted key: {len(envelope['encrypted_key'])} bytes (RSA)")
print(f"Ciphertext: {len(envelope['ciphertext'])} bytes (AES-GCM)")
recovered = hybrid_decrypt(private_key, envelope)
print(f"\nDecryption successful: {recovered == large_document}")
print(f"First 50 chars: {recovered[:50].decode()}")Expected Output:
Document size: 2600 bytes Encrypted key: 256 bytes (RSA) Ciphertext: 2616 bytes (AES-GCM) Decryption successful: True First 50 chars: This is a large document. This is a large document.
Secure Key Storage Guidelines
- Never hardcode keys in source code or commit them to version control
- Use environment variables for development; secrets managers for production
- HashiCorp Vault, AWS Secrets Manager, GCP Secret Manager, purpose-built for key lifecycle management (covered in Lesson 15)
- Hardware Security Modules (HSMs), for high-value keys that should never leave hardware
- Rotate keys regularly, use
MultiFernetor equivalent to rotate without re-encrypting all data at once
Key Takeaways
- Fernet is the simplest safe choice, use it when you want encryption with minimal complexity
- AES-GCM is the production standard, authenticated encryption, hardware-accelerated, used in TLS 1.3
- Never reuse an AES-GCM nonce, nonce reuse with the same key completely breaks GCM security
- RSA is for small data only, use RSA to encrypt AES keys, not bulk data
- Hybrid encryption = RSA key exchange + AES data encryption, the pattern behind TLS, PGP, and Signal
- Keys need lifecycle management, generation, storage, rotation, and revocation are as important as the algorithm
What's Next?
With cryptography fundamentals covered, Lesson 5 shifts to the network layer, how data actually travels across the internet and how to debug it.
- OSI and TCP/IP models, the 7-layer stack explained
- Packet analysis with Wireshark/tcpdump, debugging network issues
- Python socket basics, DNS resolution and service discovery