Cryptography Fundamentals
Understand symmetric and asymmetric encryption, hashing, digital signatures, PKI, and TLS — the mathematical backbone of all secure communication.
Learning Objectives
- → Differentiate symmetric and asymmetric encryption with practical examples
- → Explain hashing and why it's one-way
- → Understand digital signatures and certificate chains (PKI)
- → Describe how TLS 1.3 establishes a secure connection
- → Identify common cryptographic weaknesses: weak algorithms, misuse
Why Cryptography?
The CIA Triad depends on crypto:
- Confidentiality → encryption (AES, RSA)
- Integrity → hashing (SHA-256), HMAC
- Authentication → digital signatures, certificates
Symmetric Encryption
Same key encrypts and decrypts — fast, used for bulk data:
Plaintext → [AES-256 + Key] → Ciphertext
Ciphertext → [AES-256 + Key] → Plaintext
Common algorithms:
| Algorithm | Key Size | Status |
|-----------|----------|--------|
| AES-128/256 | 128/256 bit | ✅ Secure |
| ChaCha20 | 256 bit | ✅ Secure |
| 3DES | 112 bit effective | ⚠️ Legacy |
| DES | 56 bit | ❌ Broken |
| RC4 | variable | ❌ Broken |
from cryptography.fernet import Fernet
# Generate key
key = Fernet.generate_key()
f = Fernet(key)
# Encrypt
plaintext = b"sensitive data"
ciphertext = f.encrypt(plaintext)
# Decrypt
recovered = f.decrypt(ciphertext)
Problem with symmetric: How do you securely share the key?
→ Answer: asymmetric encryption for key exchange.
Asymmetric Encryption
Two mathematically related keys:
- Public key — share with everyone (encrypts)
- Private key — keep secret (decrypts)
Alice's keys: public_alice, private_alice
Bob's keys: public_bob, private_bob
Bob sends Alice a secret:
secret → [encrypt with public_alice] → ciphertext
ciphertext → [decrypt with private_alice] → secret
Only Alice can decrypt — only she has private_alice
Common algorithms:
| Algorithm | Use | Status |
|-----------|-----|--------|
| RSA-2048/4096 | Key exchange, signatures | ✅ Secure |
| ECDSA (P-256) | Signatures | ✅ Secure |
| ECDH | Key exchange | ✅ Secure (forward secrecy) |
| DSA-1024 | Signatures | ❌ Weak |
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate key pair
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
public_key = private_key.public_key()
# Encrypt with public key
ciphertext = public_key.encrypt(
b"secret message",
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(), label=None)
)
# Decrypt with private key
plaintext = private_key.decrypt(ciphertext,
padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(), label=None)
)
Hashing
A hash function maps data of any size to a fixed-size digest:
- One-way — cannot reverse hash to input
- Deterministic — same input always gives same output
- Avalanche effect — tiny input change → completely different hash
- Collision resistant — hard to find two inputs with same hash
import hashlib
data = b"password123"
h = hashlib.sha256(data).hexdigest()
# → "ef92b778bafe771e89245b89ecbc08a44a4e166c06659911881f383d4473e94f"
# SHA-256 produces 256 bits = 32 bytes = 64 hex chars
| Algorithm | Output | Status |
|---|---|---|
| SHA-256 | 256 bit | ✅ Secure |
| SHA-3-256 | 256 bit | ✅ Secure |
| SHA-1 | 160 bit | ❌ Broken (collision) |
| MD5 | 128 bit | ❌ Broken (collision) |
| bcrypt | variable | ✅ For passwords |
Password Hashing — Always Salt!
import bcrypt
# Hash password (bcrypt includes salt automatically)
password = b"hunter2"
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
# Verify
is_valid = bcrypt.checkpw(password, hashed)
# NEVER store plain SHA-256 of passwords — use bcrypt/argon2/scrypt
# Plain SHA-256: vulnerable to rainbow table attacks
# bcrypt: slow by design (10-12 rounds), salted, work factor adjustable
HMAC – Message Authentication Code
HMAC ensures both integrity and authentication:
import hmac, hashlib
key = b"shared_secret_key"
message = b"the data to authenticate"
mac = hmac.new(key, message, hashlib.sha256).hexdigest()
# → attach MAC to message; receiver recomputes and compares
Digital Signatures
Signatures prove who sent data and that it wasn't modified:
Signing (sender): hash(message) → [sign with private_key] → signature
Verifying (anyone): hash(message) → [verify with public_key, signature] → valid/invalid
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
# Sign
signature = private_key.sign(b"document content",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
# Verify (raises exception if invalid)
public_key.verify(signature, b"document content",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256())
PKI – Public Key Infrastructure
How do you know a public key really belongs to who claims?
→ Certificate Authorities (CA) sign certificates:
Certificate contains:
- Subject (e.g. *.google.com)
- Public key
- Issuer (CA name)
- Validity period
- Signature by CA's private key
Chain of trust:
Root CA → Intermediate CA → Server Certificate
(in your OS/browser trust store)
# Inspect a certificate
openssl s_client -connect google.com:443 2>/dev/null | openssl x509 -text -noout
# Check certificate expiry
echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -dates
TLS 1.3 Handshake
Client Server
│──── ClientHello ───────────────▶│ (supported ciphers, TLS 1.3, random)
│◀─── ServerHello ────────────────│ (chosen cipher, server random)
│◀─── Certificate ────────────────│ (server's certificate)
│◀─── CertificateVerify ──────────│ (proof server has private key)
│◀─── Finished ───────────────────│ (HMAC of handshake)
│──── Finished ───────────────────▶│
│════ Encrypted Application Data ═│
Key exchange uses ECDHE → forward secrecy (past sessions safe if key later compromised).
Common Cryptographic Weaknesses
| Weakness | Example | Risk |
|---|---|---|
| Weak algorithm | MD5, DES, RC4 | Broken, collision |
| Short key | RSA-512, DES | Brute-forceable |
| ECB mode | AES-ECB for blocks | Pattern leakage |
| Hardcoded key | key = "mysecretkey" | Key exposure |
| No salt | SHA256(password) | Rainbow table |
| Predictable IV | static IV in CBC | Duplicate encryption |
| Timing attack | string comparison | Leak key bits |
Using Python hashlib: (1) compute SHA-256 of 'password', 'password123', 'Password123!', (2) observe the avalanche effect — change one character and show the new hash, (3) use hashcat (or online tools) to crack the MD5 hash '5f4dcc3b5aa765d61d8327deb882cf99', (4) compare bcrypt and SHA-256 timing for 10,000 hash operations.
Why is bcrypt preferred over SHA-256 for password storage?
What is a rainbow table attack?
Using openssl: (1) connect to github.com:443 and dump the certificate, (2) identify: subject, issuer, validity dates, public key algorithm, (3) check if Perfect Forward Secrecy is used (look for ECDHE in cipher), (4) test a site with expired or self-signed cert.
What does Perfect Forward Secrecy mean?
Write a Python function that: (1) takes a message and shared secret, computes HMAC-SHA256, (2) attaches it to the message as a signature, (3) implements a verify() function that rejects tampered messages, (4) demonstrates that changing one byte in the message invalidates the MAC. Use hmac.compare_digest() to prevent timing attacks.
Why use hmac.compare_digest() instead of == for MAC comparison?