Every time you log in, make a payment, or send a message, cryptography is silently protecting you. But most developers treat it as a black box - "just use HTTPS and bcrypt." This guide gives you a practical understanding of how encryption, hashing, and digital signatures actually work, with Python code for every concept and clear guidance on when to use what. Continue with the mTLS and X.509 Python tutorial for a protocol-level application, the cryptography and PKI foundations module for machine identity, and the authorized ethical hacking workflow for the defensive testing perspective.
The Three Pillars of Cryptography
- Encryption: Scramble data so only authorized parties can read it. Reversible - you can decrypt to get the original data back.
- Hashing: Generate a fixed-size fingerprint of data. Not reversible - you can't get the original data from the hash. Used for integrity checks and passwords.
- Digital Signatures: Prove that a message was sent by a specific person and hasn't been tampered with. Combines hashing + asymmetric encryption.
Part 1: Encryption
Encryption transforms readable data (plaintext) into scrambled data (ciphertext) using a key. Only someone with the correct key can reverse the process (decrypt). There are two types:
AES-256: The Gold Standard for Symmetric Encryption
AES (Advanced Encryption Standard) with a 256-bit key is used by governments, banks, and every HTTPS connection. It's fast, secure, and battle-tested.
# pip install cryptography
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
# ── AES-256-GCM (Authenticated Encryption) ────
# GCM mode provides BOTH encryption AND integrity verification
# If anyone tampers with the ciphertext, decryption fails
# Generate a random 256-bit key (store this securely!)
key = AESGCM.generate_key(bit_length=256)
print(f"Key (hex): {key.hex()}")
# Output: a 64-character hex string (32 bytes = 256 bits)
# Encrypt
plaintext = b"Patient record: John Doe, DOB 1990-01-15, Diagnosis: ..."
nonce = os.urandom(12) # 96-bit nonce (MUST be unique per encryption!)
aes = AESGCM(key)
ciphertext = aes.encrypt(nonce, plaintext, None)
print(f"Encrypted: {ciphertext[:20].hex()}...")
# Output: gibberish bytes - completely unreadable
# Decrypt
decrypted = aes.decrypt(nonce, ciphertext, None)
print(f"Decrypted: {decrypted.decode()}")
# Output: "Patient record: John Doe, DOB 1990-01-15, Diagnosis: ..."
# ── With Associated Data (AAD) ─────────────────
# AAD is authenticated but NOT encrypted - useful for metadata
# (e.g., patient ID is visible but tamper-proof)
aad = b"patient-id:12345"
ciphertext = aes.encrypt(nonce, plaintext, aad)
decrypted = aes.decrypt(nonce, ciphertext, aad) # Must provide same AAD
# If AAD is modified, decryption raises InvalidTag
# ⚠️ CRITICAL: Never reuse a nonce with the same key!
# AES-GCM with a repeated nonce is catastrophically broken.
# Always use os.urandom(12) for each encryption.
RSA: Asymmetric Encryption & Key Exchange
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
# Generate RSA key pair (2048-bit minimum, 4096 for high security)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
# ── Encrypt with public key, decrypt with private key ──
message = b"Top secret: the launch code is 12345"
# Anyone with the public key can encrypt
ciphertext = public_key.encrypt(
message,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
print(f"Encrypted ({len(ciphertext)} bytes): {ciphertext[:20].hex()}...")
# Only the private key holder can decrypt
decrypted = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None,
),
)
print(f"Decrypted: {decrypted.decode()}")
# ⚠️ RSA can only encrypt small data (< key size - padding)
# For large data: encrypt with AES, encrypt AES key with RSA
# This is called "hybrid encryption" - exactly how TLS works!
How TLS Uses Both (Hybrid Encryption)
Part 2: Hashing Algorithms
A hash function takes any input and produces a fixed-size output (the hash/digest). It's a one-way function - you can't reverse it to get the original data. Two key properties: the same input always produces the same hash, and even a tiny change in input produces a completely different hash.
import hashlib
# ── SHA-256 (Secure Hash Algorithm) ────────────
message = b"Hello, World!"
hash_value = hashlib.sha256(message).hexdigest()
print(f"SHA-256: {hash_value}")
# Output: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
# Change ONE character:
hash_value2 = hashlib.sha256(b"Hello, World?").hexdigest()
print(f"SHA-256: {hash_value2}")
# Output: 8945e5fdde8ac1e3a2db735cbc206e5ba97a835deab69e7e50bd2fc84e3a82f2
# Completely different! This is the "avalanche effect"
# ── Common hash algorithms ─────────────────────
algorithms = {
"MD5": hashlib.md5(message).hexdigest(),
"SHA-1": hashlib.sha1(message).hexdigest(),
"SHA-256": hashlib.sha256(message).hexdigest(),
"SHA-512": hashlib.sha512(message).hexdigest(),
"SHA-3": hashlib.sha3_256(message).hexdigest(),
"BLAKE2": hashlib.blake2b(message).hexdigest(),
}
for name, h in algorithms.items():
print(f"{name:10}: {h[:32]}... ({len(h)//2} bytes)")
# Output:
# MD5 : ed076287532e86365e841e92bfc50d8c... (16 bytes) ← BROKEN, don't use!
# SHA-1 : 0a0a9f2a6772942557ab5355d76af442... (20 bytes) ← BROKEN, don't use!
# SHA-256 : dffd6021bb2bd5b0af676290809ec3a5... (32 bytes) ← Standard choice
# SHA-512 : 374d794a95cdcfd8b35993185fef9ba3... (64 bytes) ← Extra security
# SHA-3 : 1af17a664e3fa8e419b8ba05c2a173... (32 bytes) ← Latest standard
# BLAKE2 : 021ced8799296ceca557832ab941a50b... (64 bytes) ← Fastest secure hash
| Algorithm | Output Size | Security | Use For |
|---|---|---|---|
| MD5 | 128-bit | BROKEN | Never for security. Only checksums for non-critical files. |
| SHA-1 | 160-bit | BROKEN | Legacy only. Git uses it but is migrating to SHA-256. |
| SHA-256 | 256-bit | Strong | Default choice. TLS certs, JWT signatures, integrity checks. |
| SHA-3 | 256-bit | Strong | Different design than SHA-2. Good if SHA-2 ever breaks. |
| BLAKE2 | 256/512-bit | Strong | Fastest secure hash. File integrity, MACs. |
| bcrypt/Argon2 | Variable | Designed for passwords | Password storage ONLY. Intentionally slow. |
Password Hashing: bcrypt, scrypt, Argon2
Never store passwords in plain text. Never use SHA-256 for passwords. Regular hash functions are too fast - an attacker can try billions of guesses per second. Password hashing algorithms are intentionally slow to make brute-force attacks impractical.
# ── bcrypt (most widely used) ──────────────────
# pip install bcrypt
import bcrypt
password = b"my_secure_password_123"
# Hash (with automatic salt generation)
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
print(f"bcrypt: {hashed.decode()}")
# Output: \$2b\$12\$LJ3m4ys3Lg.Hy5OwF5IkNe7Yjv6RmXKN6bLHFhMGCNmYq3xKp.VK
# Format: \$2b\$ROUNDS\$SALT+HASH
# Verify password
is_valid = bcrypt.checkpw(password, hashed)
print(f"Valid: {is_valid}") # True
is_valid = bcrypt.checkpw(b"wrong_password", hashed)
print(f"Valid: {is_valid}") # False
# ── Argon2 (winner of Password Hashing Competition) ──
# pip install argon2-cffi
from argon2 import PasswordHasher
ph = PasswordHasher(
time_cost=3, # Number of iterations
memory_cost=65536, # 64 MB of RAM per hash (prevents GPU attacks)
parallelism=4, # Use 4 threads
)
hashed = ph.hash("my_secure_password_123")
print(f"Argon2: {hashed}")
# Output: \$argon2id\$v=19\$m=65536,t=3,p=4\$SALT\$HASH
# Verify
try:
ph.verify(hashed, "my_secure_password_123")
print("Valid!")
except Exception:
print("Invalid!")
# ── WHY is SHA-256 bad for passwords? ──────────
# SHA-256: 10 BILLION hashes/second on a GPU
# bcrypt(12): ~13 hashes/second on the same GPU
# Argon2: ~3 hashes/second (also needs 64MB RAM per attempt!)
# Brute-forcing a 10-char password:
# SHA-256: hours
# bcrypt: centuries
# Argon2: longer than the universe
HMAC: Hash-Based Message Authentication
HMAC combines a hash function with a secret key to produce an authentication code. It proves both integrity (data wasn't tampered) and authenticity (sender knows the secret key).
import hmac
import hashlib
secret_key = b"my-webhook-secret-key"
message = b'{"event":"payment.completed","amount":100}'
# Create HMAC
signature = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
print(f"HMAC-SHA256: {signature}")
# Verify HMAC (webhook receiver)
received_signature = signature # From X-Signature header
expected = hmac.new(secret_key, message, hashlib.sha256).hexdigest()
is_valid = hmac.compare_digest(received_signature, expected)
print(f"Valid: {is_valid}") # True
# hmac.compare_digest prevents timing attacks!
# Real-world uses of HMAC:
# 1. Webhook signatures (Stripe, GitHub, Slack)
# 2. JWT signatures (HS256 = HMAC-SHA256)
# 3. API request signing (AWS Signature V4)
# 4. Cookie integrity (prevent tampering)
Part 3: Digital Signatures
A digital signature proves: (1) the message was sent by a specific entity, and (2) the message hasn't been modified. It uses asymmetric keys: sign with private key, verify with public key.
from cryptography.hazmat.primitives.asymmetric import ec, utils
from cryptography.hazmat.primitives import hashes
# ── ECDSA (Elliptic Curve Digital Signature Algorithm) ──
# Faster and smaller than RSA signatures
private_key = ec.generate_private_key(ec.SECP256R1()) # P-256 curve
public_key = private_key.public_key()
message = b"Transfer \$1000 from account A to account B"
# Sign with private key (only the sender can do this)
signature = private_key.sign(
message,
ec.ECDSA(hashes.SHA256()),
)
print(f"Signature ({len(signature)} bytes): {signature[:20].hex()}...")
# Verify with public key (anyone can verify)
try:
public_key.verify(signature, message, ec.ECDSA(hashes.SHA256()))
print("Signature VALID - message is authentic and unmodified")
except Exception:
print("Signature INVALID - message was tampered with!")
# Tamper with the message and try again:
tampered = b"Transfer \$9999 from account A to account B"
try:
public_key.verify(signature, tampered, ec.ECDSA(hashes.SHA256()))
print("Valid")
except Exception:
print("INVALID - tampering detected!")
# Real-world uses of digital signatures:
# 1. TLS certificates (CA signs server certificates)
# 2. JWT (RS256 = RSA signature, ES256 = ECDSA signature)
# 3. Code signing (Apple, Microsoft sign their software)
# 4. Git commits (gpg signed commits)
# 5. Blockchain transactions (prove ownership without revealing private key)
The Complete Decision Guide
| I Need To... | Use This | Algorithm |
|---|---|---|
| Encrypt data at rest | Symmetric encryption | AES-256-GCM |
| Encrypt data in transit | TLS (hybrid) | ECDHE + AES-256-GCM |
| Store passwords | Password hash | Argon2id or bcrypt |
| Verify file integrity | Hash function | SHA-256 or BLAKE2 |
| Sign a JWT | Digital signature | RS256 (RSA) or ES256 (ECDSA) |
| Verify webhook payload | HMAC | HMAC-SHA256 |
| Exchange keys securely | Key exchange | ECDH (Diffie-Hellman) |
| Sign TLS certificates | Digital signature | RSA-2048 or ECDSA P-256 |
| Generate API tokens | CSPRNG | os.urandom() / secrets module |
Common Mistakes
Cryptography is the foundation of all software security. You don't need to implement algorithms from scratch - but you do need to choose the right tool for each job and use it correctly. Remember the three rules: AES for encrypting data, bcrypt/Argon2 for passwords, SHA-256 for integrity. Use established libraries, never reuse nonces, and keep your keys out of your code. That covers 95% of real-world cryptography needs.