Cryptography Basics
Cryptography Basics¶
Previous: 01. Security Fundamentals | Next: 03. Hashing and Data Integrity
Cryptography is the practice of securing communication and data so that only intended parties can access it. This lesson covers the two main branches of modern cryptography -- symmetric and asymmetric encryption -- along with key exchange protocols, digital signatures, and practical Python implementations. By the end, you will be able to choose the right cryptographic primitive for a given problem and avoid the most common pitfalls.
Difficulty: âââ
Learning Objectives: - Understand the difference between symmetric and asymmetric encryption - Implement AES-GCM and ChaCha20-Poly1305 encryption in Python - Understand RSA, ECDSA, and Ed25519 for asymmetric cryptography - Implement Diffie-Hellman and ECDH key exchange - Create and verify digital signatures - Recognize and avoid common cryptographic pitfalls - Apply modern cryptographic recommendations
Table of Contents¶
- Cryptography Overview
- Symmetric Encryption
- Block Cipher Modes of Operation
- AES-GCM: The Modern Standard
- ChaCha20-Poly1305
- Asymmetric Encryption
- RSA
- Elliptic Curve Cryptography
- Key Exchange
- Digital Signatures
- Common Pitfalls and How to Avoid Them
- Modern Recommendations
- Exercises
- References
1. Cryptography Overview¶
1.1 The Landscape¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Modern Cryptography Taxonomy â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Cryptography â
â âââ Symmetric (shared key) â
â â âââ Block Ciphers â
â â â âââ AES (128/192/256-bit key) â
â â â âââ Modes: ECB, CBC, CTR, GCM, CCM â
â â â âââ Legacy: DES, 3DES, Blowfish (avoid) â
â â âââ Stream Ciphers â
â â âââ ChaCha20(-Poly1305) â
â â âââ Legacy: RC4 (broken, avoid) â
â â â
â âââ Asymmetric (public/private key pair) â
â â âââ Encryption â
â â â âââ RSA-OAEP â
â â â âââ ECIES (Elliptic Curve Integrated Encryption) â
â â âââ Digital Signatures â
â â â âââ RSA-PSS â
â â â âââ ECDSA (secp256r1, secp384r1) â
â â â âââ Ed25519 / Ed448 â
â â âââ Key Exchange â
â â âââ Diffie-Hellman (DH) â
â â âââ ECDH (X25519, P-256) â
â â âââ Post-quantum: ML-KEM (CRYSTALS-Kyber) â
â â â
â âââ Hash Functions (covered in Lesson 03) â
â â âââ SHA-2 (SHA-256, SHA-512) â
â â âââ SHA-3 (Keccak) â
â â âââ BLAKE2/BLAKE3 â
â â â
â âââ Key Derivation Functions â
â âââ HKDF â
â âââ PBKDF2 â
â âââ scrypt / Argon2 (password-based) â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
1.2 Key Concepts¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Core Cryptographic Concepts â
ââââââââââââââââââââââ¬âââââââââââââââââââââââââââââââââââââââââââââââââ€
â Term â Definition â
ââââââââââââââââââââââŒâââââââââââââââââââââââââââââââââââââââââââââââââ€
â Plaintext â The original, unencrypted data â
â Ciphertext â The encrypted (unreadable) output â
â Key â Secret value used for encryption/decryption â
â Nonce / IV â Number used once; prevents identical plaintext â
â â from producing identical ciphertext â
â Authenticated enc. â Encryption that also verifies integrity (AEAD)â
â Key derivation â Deriving a cryptographic key from a password â
â â or another key â
â Forward secrecy â Compromise of long-term keys does not â
â â compromise past session keys â
ââââââââââââââââââââââŽâââââââââââââââââââââââââââââââââââââââââââââââââ
1.3 Python Cryptography Library Setup¶
All code examples in this lesson use the cryptography library, which provides both high-level recipes and low-level primitives.
pip install cryptography
# Verify installation
import cryptography
print(f"cryptography version: {cryptography.__version__}")
# The library has two main layers:
# 1. High-level (recipes): cryptography.fernet, cryptography.hazmat.primitives.kdf
# 2. Low-level (hazmat): cryptography.hazmat.primitives.ciphers, asymmetric, etc.
#
# "hazmat" stands for "hazardous materials" - these primitives can be
# misused. Always prefer high-level APIs when available.
2. Symmetric Encryption¶
Symmetric encryption uses the same key for both encryption and decryption. It is fast and suitable for encrypting large amounts of data.
ââââââââââââ ââââââââââââ ââââââââââââ
âPlaintext âââKeyâââ¶â Encrypt ââââââââââ¶âCiphertextâ
â "Hello" â â (AES) â â 0xA3F1.. â
ââââââââââââ ââââââââââââ ââââââââââââ
ââââââââââââ ââââââââââââ ââââââââââââ
âCiphertextâââKeyâââ¶â Decrypt ââââââââââ¶âPlaintext â
â 0xA3F1.. â â (AES) â â "Hello" â
ââââââââââââ ââââââââââââ ââââââââââââ
Same key used for both operations!
2.1 AES (Advanced Encryption Standard)¶
AES is the most widely used symmetric cipher. It operates on 128-bit blocks and supports key sizes of 128, 192, or 256 bits.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â AES Key Sizes â
ââââââââââââ¬âââââââââââ¬âââââââââââ¬âââââââââââââââââââââââââââââââââââââ€
â Key Size â Rounds â Security â Use Case â
ââââââââââââŒâââââââââââŒâââââââââââŒâââââââââââââââââââââââââââââââââââââ€
â 128-bit â 10 â ~128 bit â General purpose, fast â
â 192-bit â 12 â ~192 bit â Rarely used in practice â
â 256-bit â 14 â ~256 bit â Government/military, post-quantum â
â â â â resistance margin â
ââââââââââââŽâââââââââââŽâââââââââââŽâââââââââââââââââââââââââââââââââââââ
2.2 Fernet: High-Level Symmetric Encryption¶
For simple use cases, the Fernet class provides authenticated encryption with a simple API.
from cryptography.fernet import Fernet
import base64
# Generate a random key (URL-safe base64 encoded)
key = Fernet.generate_key()
print(f"Key: {key.decode()}")
print(f"Key length: {len(base64.urlsafe_b64decode(key))} bytes = "
f"{len(base64.urlsafe_b64decode(key)) * 8} bits")
# Create cipher
cipher = Fernet(key)
# Encrypt
plaintext = b"Sensitive financial data: account balance $50,000"
ciphertext = cipher.encrypt(plaintext)
print(f"\nPlaintext: {plaintext.decode()}")
print(f"Ciphertext: {ciphertext[:60]}...")
print(f"Ciphertext length: {len(ciphertext)} bytes")
# Decrypt
decrypted = cipher.decrypt(ciphertext)
assert decrypted == plaintext
print(f"Decrypted: {decrypted.decode()}")
# Fernet includes a timestamp - you can set a TTL (time-to-live)
import time
token = cipher.encrypt(b"temporary secret")
# time.sleep(2) # Uncomment to test expiration
try:
cipher.decrypt(token, ttl=60) # Valid for 60 seconds
print("\nToken is still valid")
except Exception as e:
print(f"\nToken expired: {e}")
What Fernet does under the hood:
1. Generate random 128-bit IV
2. Encrypt with AES-128-CBC
3. HMAC-SHA256 for authentication
4. Concatenate: version || timestamp || IV || ciphertext || HMAC
5. Base64-encode the result
3. Block Cipher Modes of Operation¶
A block cipher (like AES) encrypts fixed-size blocks. Modes of operation define how to handle messages longer than one block.
3.1 ECB Mode (NEVER Use This)¶
ECB (Electronic Codebook) encrypts each block independently. Identical plaintext blocks produce identical ciphertext blocks, leaking patterns.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â ECB Mode - WHY IT IS BROKEN â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Plaintext blocks: [AAAA] [BBBB] [AAAA] [CCCC] [AAAA] â
â â â â â â â
â AES AES AES AES AES â
â â â â â â â
â Ciphertext blocks: [X1X1] [Y2Y2] [X1X1] [Z3Z3] [X1X1] â
â â
â Problem: Identical plaintext blocks â identical ciphertext! â
â An attacker can see patterns without decrypting. â
â â
â Classic example: ECB-encrypted bitmap image preserves shapes â
â because adjacent identical pixels produce identical ciphertext.â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
# Demonstration: ECB leaks patterns
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os
key = os.urandom(32) # AES-256
# ECB mode (DO NOT USE in production - for demonstration only)
def ecb_encrypt_block(key: bytes, plaintext: bytes) -> bytes:
"""Encrypt a single block with AES-ECB. For demonstration only!"""
cipher = Cipher(algorithms.AES(key), modes.ECB())
encryptor = cipher.encryptor()
return encryptor.update(plaintext) + encryptor.finalize()
# Same plaintext block produces same ciphertext
block1 = b"AAAAAAAAAAAAAAAA" # 16 bytes = 1 AES block
block2 = b"BBBBBBBBBBBBBBBB"
ct1a = ecb_encrypt_block(key, block1)
ct1b = ecb_encrypt_block(key, block1) # Same input
ct2 = ecb_encrypt_block(key, block2)
print("ECB Pattern Leak Demonstration:")
print(f" Block 'AAA...' â {ct1a.hex()[:32]}...")
print(f" Block 'AAA...' â {ct1b.hex()[:32]}... (SAME ciphertext!)")
print(f" Block 'BBB...' â {ct2.hex()[:32]}... (different)")
print(f" ct1a == ct1b: {ct1a == ct1b}") # True - this is the problem
3.2 CBC Mode (Legacy, Use with Care)¶
CBC (Cipher Block Chaining) XORs each plaintext block with the previous ciphertext block before encryption. Requires a random IV.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â CBC Mode â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â IV âââ â
â ⌠â
â P1 âââ¶ XOR âââ¶ AES âââ¶ C1 â
â â â
â ⌠â
â P2 âââââââââââ¶ XOR âââ¶ AES âââ¶ C2 â
â â â
â ⌠â
â P3 âââââââââââââââââââ¶ XOR âââ¶ AES âââ¶ C3 â
â â
â Each ciphertext block depends on ALL previous blocks. â
â Random IV ensures same plaintext encrypts differently. â
â â
â â Requires padding (e.g., PKCS#7) â
â â Vulnerable to padding oracle attacks if not authenticated â
â â Not parallelizable for encryption â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding
import os
def aes_cbc_encrypt(key: bytes, plaintext: bytes) -> tuple:
"""AES-CBC encryption with PKCS7 padding. Returns (iv, ciphertext)."""
iv = os.urandom(16) # Random 128-bit IV
# Pad plaintext to block size
padder = padding.PKCS7(128).padder()
padded = padder.update(plaintext) + padder.finalize()
# Encrypt
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
encryptor = cipher.encryptor()
ciphertext = encryptor.update(padded) + encryptor.finalize()
return iv, ciphertext
def aes_cbc_decrypt(key: bytes, iv: bytes, ciphertext: bytes) -> bytes:
"""AES-CBC decryption with PKCS7 unpadding."""
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
decryptor = cipher.decryptor()
padded = decryptor.update(ciphertext) + decryptor.finalize()
# Remove padding
unpadder = padding.PKCS7(128).unpadder()
plaintext = unpadder.update(padded) + unpadder.finalize()
return plaintext
# Usage
key = os.urandom(32) # AES-256
message = b"CBC mode requires padding and a random IV for each message"
iv, ct = aes_cbc_encrypt(key, message)
print(f"IV: {iv.hex()}")
print(f"Ciphertext: {ct.hex()[:64]}...")
pt = aes_cbc_decrypt(key, iv, ct)
print(f"Decrypted: {pt.decode()}")
# Same plaintext, different IV â different ciphertext
iv2, ct2 = aes_cbc_encrypt(key, message)
print(f"\nSame message, new IV:")
print(f" ct1: {ct.hex()[:32]}...")
print(f" ct2: {ct2.hex()[:32]}...")
print(f" Same? {ct == ct2}") # False - good!
3.3 CTR Mode¶
CTR (Counter) mode turns a block cipher into a stream cipher. It is parallelizable and does not require padding.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â CTR Mode â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Nonce|Counter=0 âââ¶ AES âââ¶ Keystream0 ââXORâââ¶ C0 â
â â² â
â â â
â P0 â
â â
â Nonce|Counter=1 âââ¶ AES âââ¶ Keystream1 ââXORâââ¶ C1 â
â â² â
â â â
â P1 â
â â
â â Parallelizable (encryption and decryption) â
â â No padding needed â
â â Can seek to any block â
â â Nonce reuse is catastrophic (XOR of two plaintexts leaked) â
â â No authentication (use GCM instead) â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
4. AES-GCM: The Modern Standard¶
GCM (Galois/Counter Mode) combines CTR mode encryption with GMAC authentication. It provides both confidentiality and integrity in a single operation -- this is called Authenticated Encryption with Associated Data (AEAD).
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â AES-GCM (AEAD) â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Input: Key, Nonce (96-bit), Plaintext, AAD (optional) â
â Output: Ciphertext, Authentication Tag (128-bit) â
â â
â âââââââââââ âââââââââââââââ ââââââââââââââ â
â â Nonce ââââââ¶â AES-CTR ââââââ¶â Ciphertext â â
â âââââââââââ â Encryption â ââââââââ¬ââââââ â
â âââââââââââââââ â â
â âââââââââââ â â
â â AAD ââââââââââââ â â
â â(header) â ⌠⌠â
â âââââââââââ âââââââââââââââ ââââââââââââ â
â â GHASH ââââââ¶â Auth â â
â â (GMAC) â â Tag â â
â âââââââââââââââ ââââââââââââ â
â â
â AAD = Associated Authenticated Data â
â - Authenticated but NOT encrypted â
â - Example: message headers, packet sequence numbers â
â - Tampering with AAD causes authentication to fail â
â â
â â AEAD: Confidentiality + Integrity + Authenticity â
â â Parallelizable â
â â Hardware acceleration (AES-NI) â
â â Nonce MUST be unique per key (never reuse!) â
â â 96-bit nonce limits to ~2^32 encryptions per key â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
4.1 AES-GCM Implementation¶
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def aes_gcm_encrypt(key: bytes, plaintext: bytes,
aad: bytes = None) -> tuple:
"""
Encrypt with AES-GCM.
Returns (nonce, ciphertext_with_tag).
"""
aesgcm = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce (NIST recommended)
ciphertext = aesgcm.encrypt(nonce, plaintext, aad)
return nonce, ciphertext
def aes_gcm_decrypt(key: bytes, nonce: bytes, ciphertext: bytes,
aad: bytes = None) -> bytes:
"""
Decrypt with AES-GCM.
Raises InvalidTag if authentication fails.
"""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, aad)
# Generate a 256-bit key
key = AESGCM.generate_key(bit_length=256)
print(f"Key: {key.hex()} ({len(key) * 8} bits)")
# Encrypt a message
message = b"Top secret: The missile codes are 12345"
aad = b"message-id: 42, timestamp: 2026-01-15" # Authenticated but not encrypted
nonce, ciphertext = aes_gcm_encrypt(key, message, aad)
print(f"\nNonce: {nonce.hex()}")
print(f"Ciphertext: {ciphertext.hex()[:64]}...")
print(f"CT length: {len(ciphertext)} bytes "
f"(plaintext: {len(message)} + tag: 16)")
# Decrypt
plaintext = aes_gcm_decrypt(key, nonce, ciphertext, aad)
print(f"Decrypted: {plaintext.decode()}")
# Tamper with ciphertext â authentication fails
tampered_ct = bytearray(ciphertext)
tampered_ct[0] ^= 0xFF # Flip bits in first byte
try:
aes_gcm_decrypt(key, nonce, bytes(tampered_ct), aad)
print("ERROR: Should have failed!")
except Exception as e:
print(f"\nTamper detected: {type(e).__name__}")
# Tamper with AAD â authentication fails
try:
aes_gcm_decrypt(key, nonce, ciphertext, b"tampered AAD")
print("ERROR: Should have failed!")
except Exception as e:
print(f"AAD tamper detected: {type(e).__name__}")
4.2 File Encryption with AES-GCM¶
import os
import struct
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from pathlib import Path
class FileEncryptor:
"""Encrypt/decrypt files using AES-256-GCM."""
CHUNK_SIZE = 64 * 1024 # 64 KB chunks
NONCE_SIZE = 12
TAG_SIZE = 16
def __init__(self, key: bytes):
if len(key) != 32:
raise ValueError("Key must be 256 bits (32 bytes)")
self.aesgcm = AESGCM(key)
def encrypt_file(self, input_path: str, output_path: str) -> dict:
"""
Encrypt a file chunk by chunk.
Format: [nonce (12B)][chunk_count (4B)][encrypted_chunk1][encrypted_chunk2]...
"""
input_file = Path(input_path)
if not input_file.exists():
raise FileNotFoundError(f"Input file not found: {input_path}")
file_size = input_file.stat().st_size
chunks_written = 0
with open(input_path, 'rb') as fin, open(output_path, 'wb') as fout:
# Write file nonce (used as base; each chunk gets nonce + counter)
base_nonce = os.urandom(self.NONCE_SIZE)
fout.write(base_nonce)
# Placeholder for chunk count
chunk_count_pos = fout.tell()
fout.write(struct.pack('<I', 0)) # Will update later
while True:
chunk = fin.read(self.CHUNK_SIZE)
if not chunk:
break
# Derive unique nonce for this chunk
chunk_nonce = self._derive_chunk_nonce(base_nonce, chunks_written)
# AAD includes chunk index to prevent reordering
aad = struct.pack('<I', chunks_written)
encrypted = self.aesgcm.encrypt(chunk_nonce, chunk, aad)
# Write: [length (4B)][encrypted_data]
fout.write(struct.pack('<I', len(encrypted)))
fout.write(encrypted)
chunks_written += 1
# Update chunk count
fout.seek(chunk_count_pos)
fout.write(struct.pack('<I', chunks_written))
return {
"input_size": file_size,
"chunks": chunks_written,
"output_size": Path(output_path).stat().st_size
}
def decrypt_file(self, input_path: str, output_path: str) -> dict:
"""Decrypt a file encrypted with encrypt_file."""
with open(input_path, 'rb') as fin, open(output_path, 'wb') as fout:
base_nonce = fin.read(self.NONCE_SIZE)
chunk_count = struct.unpack('<I', fin.read(4))[0]
for i in range(chunk_count):
chunk_nonce = self._derive_chunk_nonce(base_nonce, i)
aad = struct.pack('<I', i)
enc_len = struct.unpack('<I', fin.read(4))[0]
encrypted = fin.read(enc_len)
decrypted = self.aesgcm.decrypt(chunk_nonce, encrypted, aad)
fout.write(decrypted)
return {"chunks_decrypted": chunk_count}
def _derive_chunk_nonce(self, base_nonce: bytes, chunk_index: int) -> bytes:
"""Derive a unique nonce for each chunk by XORing with chunk index."""
nonce_int = int.from_bytes(base_nonce, 'big') ^ chunk_index
return nonce_int.to_bytes(self.NONCE_SIZE, 'big')
# Usage example
key = AESGCM.generate_key(bit_length=256)
encryptor = FileEncryptor(key)
# Create a test file
test_data = b"Hello, encrypted world! " * 10000 # ~240 KB
with open("/tmp/test_plain.bin", "wb") as f:
f.write(test_data)
# Encrypt
info = encryptor.encrypt_file("/tmp/test_plain.bin", "/tmp/test_encrypted.bin")
print(f"Encrypted: {info}")
# Decrypt
info = encryptor.decrypt_file("/tmp/test_encrypted.bin", "/tmp/test_decrypted.bin")
print(f"Decrypted: {info}")
# Verify
with open("/tmp/test_decrypted.bin", "rb") as f:
decrypted_data = f.read()
assert decrypted_data == test_data
print("Verification: OK - decrypted data matches original")
# Clean up
for f in ["/tmp/test_plain.bin", "/tmp/test_encrypted.bin", "/tmp/test_decrypted.bin"]:
Path(f).unlink(missing_ok=True)
5. ChaCha20-Poly1305¶
ChaCha20-Poly1305 is an AEAD cipher that combines the ChaCha20 stream cipher with the Poly1305 MAC. It is the primary alternative to AES-GCM.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â AES-GCM vs ChaCha20-Poly1305 â
ââââââââââââââââââââ¬âââââââââââââââââââââââ¬âââââââââââââââââââââââââââââ€
â â AES-256-GCM â ChaCha20-Poly1305 â
ââââââââââââââââââââŒâââââââââââââââââââââââŒâââââââââââââââââââââââââââââ€
â Key size â 256 bits â 256 bits â
â Nonce size â 96 bits â 96 bits â
â Tag size â 128 bits â 128 bits â
â Speed (HW accel) â Very fast (AES-NI) â Slower with AES-NI â
â Speed (software) â Slower â Faster (no special HW) â
â Mobile/embedded â Needs HW support â Excellent (pure software) â
â Side channels â Needs care (T-tables)â Inherently constant-time â
â Used by â TLS, IPsec, disk enc â TLS (Google/CF), WireGuardâ
â Nonce misuse â Catastrophic â Catastrophic â
â Post-quantum â Neither provides PQ â Neither provides PQ â
â â resistance alone â resistance alone â
ââââââââââââââââââââŽâââââââââââââââââââââââŽâââââââââââââââââââââââââââââ
5.1 ChaCha20-Poly1305 Implementation¶
import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
# Generate a 256-bit key
key = ChaCha20Poly1305.generate_key()
chacha = ChaCha20Poly1305(key)
# Encrypt
nonce = os.urandom(12) # 96-bit nonce
message = b"ChaCha20-Poly1305 is great for mobile and embedded devices"
aad = b"metadata: device=mobile, version=1"
ciphertext = chacha.encrypt(nonce, message, aad)
print(f"Plaintext: {message.decode()}")
print(f"Ciphertext: {ciphertext.hex()[:64]}...")
print(f"CT length: {len(ciphertext)} (plaintext {len(message)} + tag 16)")
# Decrypt
plaintext = chacha.decrypt(nonce, ciphertext, aad)
assert plaintext == message
print(f"Decrypted: {plaintext.decode()}")
# Tamper detection
tampered = bytearray(ciphertext)
tampered[-1] ^= 0x01
try:
chacha.decrypt(nonce, bytes(tampered), aad)
except Exception as e:
print(f"Tamper detected: {type(e).__name__}")
5.2 XChaCha20-Poly1305 (Extended Nonce)¶
XChaCha20 uses a 192-bit nonce (vs 96-bit), which is large enough to be randomly generated without realistic collision risk. This eliminates the nonce-management burden.
# XChaCha20 is available through libsodium bindings (PyNaCl)
# pip install pynacl
import nacl.secret
import nacl.utils
# XChaCha20-Poly1305 with 192-bit random nonce
key = nacl.utils.random(nacl.secret.SecretBox.KEY_SIZE) # 256-bit
box = nacl.secret.SecretBox(key)
# Encrypt - nonce is generated automatically (192-bit, random-safe)
message = b"With XChaCha20, random nonces are always safe"
encrypted = box.encrypt(message)
print(f"Nonce size: {box.NONCE_SIZE} bytes = {box.NONCE_SIZE * 8} bits")
print(f"Encrypted length: {len(encrypted)} bytes")
# Decrypt
decrypted = box.decrypt(encrypted)
print(f"Decrypted: {decrypted.decode()}")
6. Asymmetric Encryption¶
Asymmetric (public-key) cryptography uses a key pair: a public key for encryption (or verification) and a private key for decryption (or signing).
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Asymmetric Cryptography â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Key Generation â
â ââââââââââââ â
â â KeyGen() ââââ¶ Private Key (keep secret!) â
â â ââââ¶ Public Key (share freely) â
â ââââââââââââ â
â â
â Encryption (anyone â key owner) â
â âââââââââââââ Public Key ââââââââââââ â
â â Plaintext ââââââââââââââââ¶â Encrypt ââââ¶ Ciphertext â
â âââââââââââââ ââââââââââââ â
â â
â Decryption (only key owner) â
â âââââââââââââ Private Key ââââââââââââ â
â âCiphertext ââââââââââââââââ¶â Decrypt ââââ¶ Plaintext â
â âââââââââââââ ââââââââââââ â
â â
â Signing (key owner â anyone can verify) â
â âââââââââââââ Private Key ââââââââââââ â
â â Message ââââââââââââââââ¶â Sign ââââ¶ Signature â
â âââââââââââââ ââââââââââââ â
â â
â Verification (anyone with public key) â
â âââââââââââââ Public Key ââââââââââââ â
â â Message + ââââââââââââââââ¶â Verify ââââ¶ Valid / Invalid â
â â Signature â ââââââââââââ â
â âââââââââââââ â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
6.1 Hybrid Encryption¶
Asymmetric encryption is slow and limited in the amount of data it can encrypt. In practice, we use hybrid encryption: encrypt the data with a symmetric key, then encrypt the symmetric key with the recipient's public key.
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Hybrid Encryption â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Sender: â
â 1. Generate random symmetric key (e.g., AES-256) â
â 2. Encrypt data with symmetric key (AES-GCM) â
â 3. Encrypt symmetric key with recipient's public key (RSA) â
â 4. Send: [encrypted_key] + [encrypted_data] â
â â
â Recipient: â
â 1. Decrypt symmetric key with private key (RSA) â
â 2. Decrypt data with symmetric key (AES-GCM) â
â â
â This gives us: â
â - Speed of symmetric encryption for bulk data â
â - Convenience of public-key encryption for key distribution â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
7. RSA¶
RSA (Rivest-Shamir-Adleman) is the most widely deployed asymmetric algorithm. Its security is based on the difficulty of factoring large integers.
7.1 RSA Key Generation and Encryption¶
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=4096, # 2048 minimum, 4096 recommended
)
public_key = private_key.public_key()
# Display key info
print(f"Key size: {private_key.key_size} bits")
print(f"Public exponent: {private_key.private_numbers().public_numbers.e}")
# Serialize keys (PEM format)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.BestAvailableEncryption(b"my-password")
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)
print(f"\nPublic key (first 80 chars):\n{public_pem.decode()[:80]}...")
# Encrypt with public key (using OAEP padding - ALWAYS use OAEP, never PKCS1v15)
message = b"Secret message for RSA encryption"
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
print(f"\nCiphertext: {ciphertext.hex()[:64]}...")
print(f"CT length: {len(ciphertext)} bytes")
# Decrypt with private key
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
print(f"Decrypted: {plaintext.decode()}")
7.2 RSA Hybrid Encryption¶
import os
import json
import base64
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
class HybridEncryptor:
"""RSA + AES-GCM hybrid encryption."""
def __init__(self, public_key=None, private_key=None):
self.public_key = public_key
self.private_key = private_key
@classmethod
def generate_keypair(cls):
"""Generate a new RSA key pair and return an encryptor."""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=4096,
)
return cls(
public_key=private_key.public_key(),
private_key=private_key,
)
def encrypt(self, plaintext: bytes) -> dict:
"""Encrypt data using hybrid encryption."""
if not self.public_key:
raise ValueError("Public key required for encryption")
# 1. Generate random AES-256 key
aes_key = AESGCM.generate_key(bit_length=256)
# 2. Encrypt data with AES-GCM
nonce = os.urandom(12)
aesgcm = AESGCM(aes_key)
encrypted_data = aesgcm.encrypt(nonce, plaintext, None)
# 3. Encrypt AES key with RSA public key
encrypted_key = self.public_key.encrypt(
aes_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# 4. Package everything together
return {
"encrypted_key": base64.b64encode(encrypted_key).decode(),
"nonce": base64.b64encode(nonce).decode(),
"ciphertext": base64.b64encode(encrypted_data).decode(),
}
def decrypt(self, package: dict) -> bytes:
"""Decrypt hybrid-encrypted data."""
if not self.private_key:
raise ValueError("Private key required for decryption")
# 1. Decrypt AES key with RSA private key
encrypted_key = base64.b64decode(package["encrypted_key"])
aes_key = self.private_key.decrypt(
encrypted_key,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# 2. Decrypt data with AES-GCM
nonce = base64.b64decode(package["nonce"])
ciphertext = base64.b64decode(package["ciphertext"])
aesgcm = AESGCM(aes_key)
return aesgcm.decrypt(nonce, ciphertext, None)
# Usage
encryptor = HybridEncryptor.generate_keypair()
# Encrypt a large message
large_message = b"A" * 100000 # 100 KB - too large for raw RSA
package = encryptor.encrypt(large_message)
print("Hybrid Encryption Package:")
print(f" Encrypted key length: {len(base64.b64decode(package['encrypted_key']))} bytes")
print(f" Nonce: {package['nonce']}")
print(f" Ciphertext length: {len(base64.b64decode(package['ciphertext']))} bytes")
# Decrypt
decrypted = encryptor.decrypt(package)
assert decrypted == large_message
print(f"\nDecrypted successfully: {len(decrypted)} bytes match original")
8. Elliptic Curve Cryptography¶
ECC provides the same security as RSA with much smaller key sizes, making it faster and more efficient.
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â RSA vs Elliptic Curve Key Sizes â
ââââââââââââââââââââ¬âââââââââââââââââââ¬âââââââââââââââââââââââââââââââââ€
â Security Level â RSA Key Size â ECC Key Size â
ââââââââââââââââââââŒâââââââââââââââââââŒâââââââââââââââââââââââââââââââââ€
â 128-bit â 3072 bits â 256 bits (P-256/secp256r1) â
â 192-bit â 7680 bits â 384 bits (P-384/secp384r1) â
â 256-bit â 15360 bits â 521 bits (P-521/secp521r1) â
ââââââââââââââââââââŽâââââââââââââââââââŽâââââââââââââââââââââââââââââââââ€
â ECC is ~10-15x smaller for equivalent security! â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
8.1 ECDSA (Elliptic Curve Digital Signature Algorithm)¶
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
# Generate key pair using P-256 curve (NIST recommended)
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
print(f"Curve: {private_key.curve.name}")
print(f"Key size: {private_key.curve.key_size} bits")
# Sign a message
message = b"This message is signed with ECDSA"
signature = private_key.sign(
message,
ec.ECDSA(hashes.SHA256())
)
print(f"\nSignature: {signature.hex()[:64]}...")
print(f"Signature length: {len(signature)} bytes") # ~70-72 bytes for P-256
# Verify
try:
public_key.verify(signature, message, ec.ECDSA(hashes.SHA256()))
print("Signature valid!")
except Exception:
print("Signature invalid!")
# Tampered message fails
try:
public_key.verify(signature, b"tampered message", ec.ECDSA(hashes.SHA256()))
print("ERROR: Should have failed!")
except Exception:
print("Tampered message: signature verification failed (expected)")
8.2 Ed25519 (Modern Signature Algorithm)¶
Ed25519 is a modern EdDSA signature scheme using Curve25519. It is deterministic (no random nonce needed during signing), fast, and resistant to side-channel attacks.
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
# Generate Ed25519 key pair
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
# Sign (no hash algorithm parameter needed - it uses SHA-512 internally)
message = b"Ed25519 is the recommended signature algorithm for new systems"
signature = private_key.sign(message)
print(f"Signature: {signature.hex()}")
print(f"Signature length: {len(signature)} bytes") # Always 64 bytes
# Verify
try:
public_key.verify(signature, message)
print("Ed25519 signature valid!")
except Exception as e:
print(f"Invalid: {e}")
# Key sizes are small
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption
)
pub_bytes = public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
priv_bytes = private_key.private_bytes(Encoding.Raw, PrivateFormat.Raw, NoEncryption())
print(f"\nPublic key: {len(pub_bytes)} bytes ({len(pub_bytes) * 8} bits)")
print(f"Private key: {len(priv_bytes)} bytes ({len(priv_bytes) * 8} bits)")
print(f"Signature: {len(signature)} bytes ({len(signature) * 8} bits)")
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Signature Algorithm Comparison â
ââââââââââââââââ¬âââââââââââ¬âââââââââââ¬âââââââââââ¬ââââââââââââââââââââââ€
â â RSA-2048 âECDSA P256â Ed25519 â Ed448 â
ââââââââââââââââŒâââââââââââŒâââââââââââŒâââââââââââŒââââââââââââââââââââââ€
â Pub key size â 256 B â 64 B â 32 B â 57 B â
â Sig size â 256 B â ~72 B â 64 B â 114 B â
â Sign speed â Slow â Fast â Very fastâ Fast â
â Verify speed â Fast â Moderate â Fast â Fast â
â Deterministicâ No â No* â Yes â Yes â
â Side-channel â Needs â Needs â Inherent â Inherent â
â resistance â care â care â â â
â Standard â PKCS#1 â FIPS â RFC 8032 â RFC 8032 â
ââââââââââââââââŽâââââââââââŽâââââââââââŽâââââââââââŽââââââââââââââââââââââ€
â * RFC 6979 provides deterministic ECDSA, but not all impls use it â
â Recommendation: Use Ed25519 for new systems â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
9. Key Exchange¶
Key exchange protocols allow two parties to establish a shared secret over an insecure channel.
9.1 Diffie-Hellman Key Exchange¶
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Diffie-Hellman Key Exchange â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Alice Bob â
â âââââ âââ â
â â
â 1. Choose private key: a 1. Choose private: b â
â 2. Compute: A = g^a mod p 2. Compute: B = g^b â
â mod p â
â â
â 3. Send A âââââââââââââââââââââââââââââââââââ¶ Receive A â
â Receive B âââââââââââââââââââââââââââââââ 4. Send B â
â â
â 5. Compute: 5. Compute: â
â shared = B^a mod p shared = A^b mod p â
â = (g^b)^a mod p = (g^a)^b â
â = g^(ab) mod p mod p â
â = g^(ab) â
â mod p â
â â
â Both arrive at the SAME shared secret: g^(ab) mod p â
â â
â Eve sees: g, p, A=g^a, B=g^b â
â Computing a from A requires solving the Discrete Logarithm â
â Problem -- believed to be computationally infeasible for â
â large primes. â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
9.2 ECDH (Elliptic Curve Diffie-Hellman)¶
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
def ecdh_key_exchange():
"""
Demonstrate ECDH key exchange between Alice and Bob.
Uses X25519-equivalent (P-256 shown here for compatibility).
"""
# 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 computes shared secret using her private key + Bob's public key
alice_shared = alice_private.exchange(ec.ECDH(), bob_public)
# Bob computes shared secret using his private key + Alice's public key
bob_shared = bob_private.exchange(ec.ECDH(), alice_public)
# Both shared secrets are identical
assert alice_shared == bob_shared
print(f"ECDH shared secret: {alice_shared.hex()[:32]}...")
print(f"Shared secret length: {len(alice_shared)} bytes")
# Derive actual encryption key from shared secret using HKDF
# (raw ECDH output should NOT be used directly as an encryption key)
alice_key = HKDF(
algorithm=hashes.SHA256(),
length=32, # 256-bit key for AES-256
salt=None,
info=b"ecdh-derived-key-v1",
).derive(alice_shared)
bob_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=b"ecdh-derived-key-v1",
).derive(bob_shared)
assert alice_key == bob_key
print(f"Derived AES key: {alice_key.hex()}")
return alice_key
derived_key = ecdh_key_exchange()
9.3 X25519 Key Exchange¶
X25519 is the recommended ECDH curve for modern applications (used in TLS 1.3, WireGuard, Signal).
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
# Alice
alice_private = X25519PrivateKey.generate()
alice_public = alice_private.public_key()
# Bob
bob_private = X25519PrivateKey.generate()
bob_public = bob_private.public_key()
# Exchange
alice_shared = alice_private.exchange(bob_public)
bob_shared = bob_private.exchange(alice_public)
assert alice_shared == bob_shared
print(f"X25519 shared secret: {alice_shared.hex()}")
# Derive keys using HKDF
def derive_keys(shared_secret: bytes, context: bytes) -> dict:
"""Derive separate keys for encryption and MAC from shared secret."""
# Encryption key
enc_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=context + b"-enc",
).derive(shared_secret)
# MAC key (for additional message authentication if needed)
mac_key = HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=None,
info=context + b"-mac",
).derive(shared_secret)
return {"encryption_key": enc_key, "mac_key": mac_key}
keys = derive_keys(alice_shared, b"session-2026-01-15")
print(f"Encryption key: {keys['encryption_key'].hex()}")
print(f"MAC key: {keys['mac_key'].hex()}")
10. Digital Signatures¶
Digital signatures provide authentication, integrity, and non-repudiation. They allow anyone to verify that a message was created by the holder of a specific private key and has not been modified.
10.1 How Digital Signatures Work¶
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Digital Signature Process â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â SIGNING (by the author, using their private key): â
â â
â ââââââââââââ ââââââââââââ ââââââââââââ âââââââââââââ â
â â Message ââââââ¶â Hash ââââââ¶â Sign ââââââ¶â Signature â â
â â â â (SHA-256)â â(Private â â â â
â ââââââââââââ ââââââââââââ â Key) â âââââââââââââ â
â ââââââââââââ â
â â
â VERIFICATION (by anyone, using the author's public key): â
â â
â ââââââââââââ ââââââââââââ â
â â Message ââââââ¶â Hash âââââââ â
â ââââââââââââ â (SHA-256)â â â
â ââââââââââââ ⌠â
â ââââââââââââ ââââââââââââ â
â âââââââââââââ â Verify ââââââ¶â Valid / â â
â â Signature ââââââââââââââââââ¶â(Public â â Invalid â â
â âââââââââââââ â Key) â ââââââââââââ â
â ââââââââââââ â
â â
ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
10.2 Practical: Document Signing System¶
import json
import time
import base64
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.hazmat.primitives.serialization import (
Encoding, PublicFormat, PrivateFormat, NoEncryption
)
from dataclasses import dataclass, asdict
from typing import Optional
@dataclass
class SignedDocument:
"""A document with a digital signature."""
content: str
author: str
timestamp: float
public_key: str # Base64-encoded public key
signature: str # Base64-encoded signature
class DocumentSigner:
"""Sign and verify documents using Ed25519."""
def __init__(self):
self.private_key = Ed25519PrivateKey.generate()
self.public_key = self.private_key.public_key()
def get_public_key_b64(self) -> str:
"""Get base64-encoded public key for sharing."""
raw = self.public_key.public_bytes(Encoding.Raw, PublicFormat.Raw)
return base64.b64encode(raw).decode()
def sign_document(self, content: str, author: str) -> SignedDocument:
"""Sign a document and return it with the signature."""
timestamp = time.time()
# Create canonical message to sign
message = self._canonical_message(content, author, timestamp)
# Sign
signature = self.private_key.sign(message)
return SignedDocument(
content=content,
author=author,
timestamp=timestamp,
public_key=self.get_public_key_b64(),
signature=base64.b64encode(signature).decode(),
)
@staticmethod
def verify_document(doc: SignedDocument) -> dict:
"""Verify a signed document. Returns verification result."""
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
try:
# Reconstruct the canonical message
message = DocumentSigner._canonical_message(
doc.content, doc.author, doc.timestamp
)
# Decode public key and signature
pub_bytes = base64.b64decode(doc.public_key)
signature = base64.b64decode(doc.signature)
# Load public key
public_key = Ed25519PublicKey.from_public_bytes(pub_bytes)
# Verify
public_key.verify(signature, message)
return {
"valid": True,
"author": doc.author,
"signed_at": time.ctime(doc.timestamp),
"public_key": doc.public_key[:20] + "...",
}
except Exception as e:
return {
"valid": False,
"error": str(e),
}
@staticmethod
def _canonical_message(content: str, author: str, timestamp: float) -> bytes:
"""Create a canonical byte representation for signing."""
canonical = json.dumps({
"content": content,
"author": author,
"timestamp": timestamp,
}, sort_keys=True, separators=(',', ':')).encode()
return canonical
# Create signers for Alice and Bob
alice_signer = DocumentSigner()
bob_signer = DocumentSigner()
# Alice signs a document
doc = alice_signer.sign_document(
content="I, Alice, hereby agree to pay Bob $100.",
author="Alice"
)
print("Signed Document:")
print(f" Content: {doc.content}")
print(f" Author: {doc.author}")
print(f" Timestamp: {time.ctime(doc.timestamp)}")
print(f" Signature: {doc.signature[:40]}...")
# Anyone can verify using Alice's public key (embedded in document)
result = DocumentSigner.verify_document(doc)
print(f"\nVerification: {result}")
# Tamper with the document
tampered_doc = SignedDocument(
content="I, Alice, hereby agree to pay Bob $10000.", # Changed!
author=doc.author,
timestamp=doc.timestamp,
public_key=doc.public_key,
signature=doc.signature,
)
tamper_result = DocumentSigner.verify_document(tampered_doc)
print(f"\nTampered verification: {tamper_result}")
11. Common Pitfalls and How to Avoid Them¶
11.1 The Deadly Sins of Cryptography¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Top Cryptographic Pitfalls (and How to Avoid Them) â
ââââââ¬âââââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââââââââ€
â # â Pitfall â Correct Approach â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 1 â Using ECB mode â Use AES-GCM or ChaCha20-Poly1305 â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 2 â Reusing nonces/IVs â Random nonce per message (or counter) â
â â with the same key â Use XChaCha20 for random-safe nonces â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 3 â Encrypt without â Always use AEAD (GCM, Poly1305) â
â â authenticating â Never use raw CBC/CTR â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 4 â Rolling your own â Use established libraries (cryptography, â
â â crypto â libsodium/NaCl, OpenSSL) â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 5 â Weak/predictable â Use os.urandom() or secrets module â
â â random numbers â NEVER use random.random() for crypto â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 6 â Hardcoded keys â Use key management (AWS KMS, Vault) â
â â in source code â Environment variables at minimum â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 7 â Using MD5/SHA-1 â Use SHA-256+ for hashes, bcrypt/argon2 â
â â for passwords â for passwords â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 8 â RSA with PKCS#1 â Use RSA-OAEP for encryption â
â â v1.5 padding â Use RSA-PSS for signatures â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 9 â Comparing MACs â Use hmac.compare_digest() for â
â â with == â constant-time comparison â
ââââââŒâââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââ€
â 10 â Not rotating keys â Implement key rotation schedules â
â â â Use key versioning â
ââââââŽâââââââââââââââââââââŽââââââââââââââââââââââââââââââââââââââââââââ
11.2 Nonce Reuse Disaster¶
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
# Demonstration: Why nonce reuse with CTR/GCM is catastrophic
key = os.urandom(32)
nonce = os.urandom(16) # Same nonce used twice - BAD!
def ctr_encrypt(key, nonce, plaintext):
cipher = Cipher(algorithms.AES(key), modes.CTR(nonce))
encryptor = cipher.encryptor()
return encryptor.update(plaintext) + encryptor.finalize()
# Two messages encrypted with the SAME key and nonce
msg1 = b"Attack at dawn!!!" # 17 bytes
msg2 = b"Retreat at night!" # 17 bytes
ct1 = ctr_encrypt(key, nonce, msg1)
ct2 = ctr_encrypt(key, nonce, msg2)
# XOR of two ciphertexts = XOR of two plaintexts!
# In CTR mode: ct = plaintext XOR keystream
# So: ct1 XOR ct2 = (msg1 XOR keystream) XOR (msg2 XOR keystream)
# = msg1 XOR msg2 (keystream cancels out!)
xor_result = bytes(a ^ b for a, b in zip(ct1, ct2))
expected = bytes(a ^ b for a, b in zip(msg1, msg2))
print("Nonce Reuse Attack Demonstration:")
print(f" ct1 XOR ct2: {xor_result.hex()}")
print(f" msg1 XOR msg2: {expected.hex()}")
print(f" Match: {xor_result == expected}")
print()
print(" An attacker who knows msg1 can recover msg2:")
recovered = bytes(a ^ b for a, b in zip(xor_result, msg1))
print(f" Recovered msg2: {recovered.decode()}")
print()
print(" LESSON: Never reuse a nonce with the same key!")
11.3 Secure vs Insecure Random¶
import random
import secrets
import os
# INSECURE - never use for cryptography
# random.random() uses Mersenne Twister (MT19937)
# It is deterministic and predictable if you observe enough outputs
insecure_key = bytes([random.randint(0, 255) for _ in range(32)])
print(f"INSECURE key: {insecure_key.hex()}")
print(f" Source: random.random() - Mersenne Twister (PREDICTABLE)")
# SECURE - use for cryptography
secure_key = os.urandom(32)
print(f"\nSECURE key: {secure_key.hex()}")
print(f" Source: os.urandom() - OS CSPRNG (/dev/urandom)")
# ALSO SECURE - Python 3.6+ secrets module
secure_token = secrets.token_bytes(32)
print(f"\nSECURE key: {secure_token.hex()}")
print(f" Source: secrets.token_bytes() - wraps os.urandom()")
# For URL-safe tokens
url_token = secrets.token_urlsafe(32)
print(f"\nURL-safe token: {url_token}")
# For comparison tokens (timing-safe)
a = secrets.token_bytes(32)
b = secrets.token_bytes(32)
print(f"\nConstant-time comparison: {secrets.compare_digest(a, b)}")
12. Modern Recommendations¶
12.1 Algorithm Selection Guide (2025+)¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Modern Cryptographic Recommendations â
âââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Use Case â Recommended Algorithm(s) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Symmetric enc. â AES-256-GCM (with hardware) or â
â â ChaCha20-Poly1305 (without hardware / mobile) â
â â XChaCha20-Poly1305 if random nonces needed â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Key exchange â X25519 (ECDH with Curve25519) â
â â ML-KEM (post-quantum, hybrid with X25519) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Digital sig. â Ed25519 (general purpose) â
â â Ed448 (higher security margin) â
â â ECDSA P-256 (legacy compatibility) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Hashing â SHA-256 / SHA-3-256 (general) â
â â BLAKE3 (speed-critical) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Password hash â Argon2id (preferred) â
â â bcrypt (widely supported) â
â â scrypt (memory-hard alternative) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â Key derivation â HKDF-SHA256 (from high-entropy input) â
â â Argon2id (from passwords) â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â TLS â TLS 1.3 with X25519 + AES-256-GCM â
â â or ChaCha20-Poly1305 â
âââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â AVOID â MD5, SHA-1, DES, 3DES, RC4, RSA-1024, â
â â ECB mode, PKCS#1 v1.5, custom algorithms â
âââââââââââââââââââŽââââââââââââââââââââââââââââââââââââââââââââââââââââ
12.2 Key Size Recommendations¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Minimum Key Sizes (NIST / ANSSI 2025+) â
âââââââââââââââââââââââ¬ââââââââââââââââââââââââââââââââââââââââââââââââ€
â Algorithm â Minimum Key Size â
âââââââââââââââââââââââŒââââââââââââââââââââââââââââââââââââââââââââââââ€
â AES â 128-bit (256-bit for post-quantum margin) â
â RSA (if you must) â 3072-bit (4096 recommended) â
â ECDSA / ECDH â P-256 / Curve25519 (256-bit) â
â EdDSA â Ed25519 (256-bit) â
â Hash output â 256-bit (SHA-256, SHA-3-256, BLAKE2b-256) â
â HMAC key â Same as hash output size (256-bit) â
âââââââââââââââââââââââŽââââââââââââââââââââââââââââââââââââââââââââââââ
12.3 Post-Quantum Cryptography¶
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
â Post-Quantum Cryptography (PQC) â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ€
â â
â Problem: Quantum computers (Shor's algorithm) will break: â
â - RSA (factoring) â
â - ECDSA/ECDH (elliptic curve discrete log) â
â - DH (discrete log) â
â â
â NOT affected by quantum: â
â - AES (Grover's algorithm halves effective key size: â
â AES-256 â ~128-bit security, still safe) â
â - SHA-256 (similar halving, still adequate) â
â â
â NIST PQC Standards (finalized 2024): â
â âââ ML-KEM (CRYSTALS-Kyber) â key encapsulation â
â âââ ML-DSA (CRYSTALS-Dilithium) â digital signatures â
â âââ SLH-DSA (SPHINCS+) â hash-based signatures â
â âââ FN-DSA (FALCON) â lattice-based signatures â
â â
â Current recommendation: Hybrid mode â
â - Use X25519 + ML-KEM together for key exchange â
â - If classical OR PQ algorithm is broken, you are still safe â
â - Chrome, Firefox, and Cloudflare already support hybrid PQ â
â â
âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
13. Exercises¶
Exercise 1: Symmetric Encryption (Beginner)¶
Write a Python function that: 1. Takes a plaintext string and a password as input 2. Derives an AES-256 key from the password using PBKDF2 (with a random salt) 3. Encrypts the plaintext with AES-GCM 4. Returns a single base64-encoded string containing: salt + nonce + ciphertext 5. Write the corresponding decryption function
Hints:
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
# Use iterations=600000, salt_size=16, nonce_size=12
Exercise 2: Hybrid Encryption (Intermediate)¶
Implement a simplified version of PGP-like encryption: 1. Alice generates an RSA-4096 key pair 2. Bob generates an RSA-4096 key pair 3. Alice wants to send a signed and encrypted message to Bob: - Sign the message with Alice's private key - Generate a random AES-256 key - Encrypt the message with AES-GCM - Encrypt the AES key with Bob's public RSA key - Package: encrypted_key + nonce + ciphertext + signature + alice_public_key 4. Bob receives the package and: - Decrypts the AES key with his private RSA key - Decrypts the message with AES-GCM - Verifies the signature using Alice's public key
Exercise 3: Key Exchange Protocol (Intermediate)¶
Simulate a secure chat between Alice and Bob: 1. Both perform X25519 key exchange 2. Derive separate keys for each direction (Alice-to-Bob, Bob-to-Alice) using HKDF 3. Each message gets a unique nonce (use a counter) 4. Messages are encrypted with ChaCha20-Poly1305 5. Include a message counter to detect replay attacks
Exercise 4: Nonce Reuse Attack (Advanced)¶
Given two ciphertexts encrypted with AES-CTR using the same key and nonce:
ct1 = bytes.fromhex("a1b2c3d4e5f6071829")
ct2 = bytes.fromhex("b4a3d2c5f4e7162738")
And knowing that plaintext1 is b"plaintext":
1. Recover plaintext2
2. Explain why this attack works mathematically
3. Describe how to prevent this vulnerability
Exercise 5: Digital Signature Verification (Advanced)¶
Build a simple code-signing system: 1. A "publisher" signs Python scripts with Ed25519 2. A "runner" verifies signatures before executing scripts 3. Maintain a registry of trusted public keys 4. Handle key rotation (old signatures should remain valid with old keys) 5. Add timestamp verification (reject signatures older than 30 days)
Exercise 6: Cryptographic Audit (Advanced)¶
Review the following code and identify ALL cryptographic vulnerabilities. There are at least 8 issues:
import hashlib
import base64
from Crypto.Cipher import AES # PyCryptodome
def encrypt_message(password, message):
key = hashlib.md5(password.encode()).digest() # 128-bit key from MD5
iv = b'\x00' * 16 # Static IV
cipher = AES.new(key, AES.MODE_CBC, iv)
# Manual padding
pad_len = 16 - (len(message) % 16)
padded = message + chr(pad_len) * pad_len
encrypted = cipher.encrypt(padded.encode())
return base64.b64encode(encrypted).decode()
def verify_password(stored_hash, password):
return hashlib.sha256(password.encode()).hexdigest() == stored_hash
For each vulnerability, explain: - What is wrong - Why it is dangerous - How to fix it
References¶
- Ferguson, Schneier, Kohno. Cryptography Engineering. Wiley, 2010.
- Bernstein, D.J. "Curve25519: New Diffie-Hellman Speed Records". 2006.
- NIST SP 800-175B: Guideline for Using Cryptographic Standards
- NIST Post-Quantum Cryptography - https://csrc.nist.gov/projects/post-quantum-cryptography
- Python
cryptographylibrary docs - https://cryptography.io/ - Latacora, "Cryptographic Right Answers" (2018, updated regularly)
- RFC 8032: Edwards-Curve Digital Signature Algorithm (EdDSA)
- RFC 8439: ChaCha20 and Poly1305 for IETF Protocols
Previous: 01. Security Fundamentals | Next: 03. Hashing and Data Integrity