Series Navigation
← Part 4: Authentication and Authorization
The Golden Rule of Cryptography
Never implement your own cryptographic algorithms. Use well-audited libraries. Rolling your own crypto has a near-100% failure rate even for expert cryptographers. Use: libsodium, cryptography (Python), javax.crypto (Java), crypto (Node.js built-in).
Hashing vs Encryption
HASHING (one-way):
Input → [Hash Function] → Fixed-length digest
- Cannot reverse (no key)
- Same input always produces same output
- Use for: passwords, data integrity, digital signatures
- Algorithms: SHA-256, SHA-3, bcrypt, Argon2
ENCRYPTION (two-way):
Input + Key → [Encrypt] → Ciphertext
Ciphertext + Key → [Decrypt] → Input
- Can reverse with correct key
- Use for: data confidentiality (PII, sensitive data)
- Algorithms: AES-256-GCM, ChaCha20-Poly1305
Hashing — Use Cases and Algorithms
import hashlib
import hmac
import secrets
# ── Data integrity verification ─────────────────────────────────────────────
def compute_file_hash(filepath: str) -> str:
sha256 = hashlib.sha256()
with open(filepath, 'rb') as f:
for chunk in iter(lambda: f.read(8192), b''):
sha256.update(chunk)
return sha256.hexdigest()
# Verify download integrity
expected_hash = "abc123..."
actual_hash = compute_file_hash("downloaded_file.zip")
if not hmac.compare_digest(expected_hash, actual_hash):
raise SecurityError("File integrity check failed — possible tampering")
# ── HMAC — Authenticated hashing ────────────────────────────────────────────
# Combines hashing with a secret key — proves both integrity AND authenticity
def sign_data(data: bytes, secret_key: bytes) -> str:
signature = hmac.new(secret_key, data, hashlib.sha256).hexdigest()
return signature
def verify_data(data: bytes, secret_key: bytes, signature: str) -> bool:
expected = sign_data(data, secret_key)
# Use compare_digest — constant time comparison (prevents timing attacks)
return hmac.compare_digest(expected, signature)
# Use case: webhook signatures (Stripe, GitHub use this pattern)
def verify_stripe_webhook(payload: bytes, stripe_sig: str, secret: str) -> bool:
timestamp, signatures = parse_stripe_header(stripe_sig)
signed_payload = f"{timestamp}.{payload.decode()}"
expected_sig = hmac.new(secret.encode(), signed_payload.encode(), hashlib.sha256).hexdigest()
return any(hmac.compare_digest(expected_sig, sig) for sig in signatures)
# ── Password hashing ─────────────────────────────────────────────────────────
# Already covered in Part 4 — use bcrypt or Argon2
Why bcrypt/Argon2 for passwords but SHA-256 for files?
SHA-256 is fast — hashes 500MB/s. That's good for files but terrible for passwords — an attacker can try billions of passwords per second. bcrypt is slow by design — ~300ms per check. An attacker can only try ~3 passwords/second.
Symmetric Encryption — AES-256-GCM
Use AES-256-GCM (Galois/Counter Mode). The "GCM" part provides authenticated encryption — it detects tampering.
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os, base64, json
class SymmetricEncryption:
def __init__(self, key: bytes):
# Key must be 32 bytes (256 bits) for AES-256
assert len(key) == 32, "AES-256 requires 32-byte key"
self.cipher = AESGCM(key)
def encrypt(self, plaintext: str, associated_data: bytes = None) -> str:
"""Encrypt and return base64-encoded payload"""
nonce = os.urandom(12) # 96-bit nonce — NEVER reuse with same key!
ciphertext = self.cipher.encrypt(
nonce,
plaintext.encode('utf-8'),
associated_data # additional authenticated data (not encrypted, but verified)
)
# Package nonce + ciphertext together
payload = {'nonce': base64.b64encode(nonce).decode(),
'ct': base64.b64encode(ciphertext).decode()}
return base64.b64encode(json.dumps(payload).encode()).decode()
def decrypt(self, encrypted: str, associated_data: bytes = None) -> str:
"""Decrypt base64-encoded payload"""
payload = json.loads(base64.b64decode(encrypted))
nonce = base64.b64decode(payload['nonce'])
ciphertext = base64.b64decode(payload['ct'])
plaintext = self.cipher.decrypt(nonce, ciphertext, associated_data)
return plaintext.decode('utf-8')
# Usage
key = os.urandom(32) # generate once, store securely (AWS KMS, Vault)
enc = SymmetricEncryption(key)
# Encrypt sensitive PII
encrypted_ssn = enc.encrypt("123-45-6789", associated_data=b"user:456")
# Store encrypted_ssn in database
# Decrypt when needed
ssn = enc.decrypt(encrypted_ssn, associated_data=b"user:456")
# associated_data acts as context binding — prevents ciphertext reuse across users
Asymmetric Encryption — RSA and ECDSA
from cryptography.hazmat.primitives.asymmetric import rsa, padding, ec
from cryptography.hazmat.primitives import hashes, serialization
# ── RSA key generation ───────────────────────────────────────────────────────
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048 # minimum; use 4096 for long-lived keys
)
public_key = private_key.public_key()
# ── Encrypt with public key (only private key can decrypt) ───────────────────
ciphertext = public_key.encrypt(
b"Secret message",
padding.OAEP( # OAEP padding — much safer than PKCS1v15
mgf=padding.MGF1(hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# ── Decrypt with private key ─────────────────────────────────────────────────
plaintext = private_key.decrypt(ciphertext, padding.OAEP(...))
# ── Digital signature — sign with private, verify with public ────────────────
signature = private_key.sign(
b"Document content",
padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
hashes.SHA256()
)
# Anyone with public key can verify
try:
public_key.verify(signature, b"Document content", padding.PSS(...), hashes.SHA256())
print("Signature valid")
except InvalidSignature:
print("Signature INVALID — document tampered")
# ── ECDSA — smaller keys, same security as RSA (preferred for JWTs) ──────────
private_key = ec.generate_private_key(ec.SECP256R1()) # P-256 curve
# 256-bit EC key ≈ 3072-bit RSA key in strength
TLS — How It Actually Works
TLS 1.3 Handshake (simplified):
Client Server
│ │
│── ClientHello ──────────────► │ (supported cipher suites, key share)
│ │
│ ◄──────────────── ServerHello │ (chosen cipher, server's key share)
│ ◄──────────── {Certificate} │ (server's X.509 certificate)
│ ◄──────── {CertificateVerify} │ (proves server owns private key)
│ ◄────────────── {Finished} │
│ │
│── {Finished} ───────────────► │ (both sides derive same session keys)
│ │
│ ◄══════════════════════════► │ Encrypted application data
What to check in TLS configuration:
# Check TLS version and cipher suites
sslyze --regular api.example.com:443
# Nmap TLS scan
nmap --script ssl-enum-ciphers -p 443 api.example.com
# testssl.sh (comprehensive)
docker run -ti drwetter/testssl.sh api.example.com:443
# Secure TLS configuration
ssl_protocols TLSv1.2 TLSv1.3; # disable 1.0 and 1.1
# Strong cipher suites (TLS 1.2 fallback)
ssl_ciphers 'ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305';
ssl_prefer_server_ciphers on;
# Perfect Forward Secrecy — session keys not derived from long-term key
ssl_dhparam /etc/ssl/certs/dhparam.pem; # generate: openssl dhparam -out dhparam.pem 4096
# HSTS — force HTTPS for 1 year
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# OCSP Stapling — certificate revocation check
ssl_stapling on;
ssl_stapling_verify on;
Common Crypto Mistakes
# ❌ ECB mode — reveals patterns in plaintext
from Crypto.Cipher import AES
cipher = AES.new(key, AES.MODE_ECB) # NEVER USE ECB
# Same plaintext block → same ciphertext block → reveals structure
# ❌ Using random.random() for security
import random
token = random.random() # PREDICTABLE — Mersenne Twister is not cryptographic
# ✅ Always use secrets module or os.urandom()
import secrets
token = secrets.token_hex(32) # cryptographically secure
session_id = secrets.token_urlsafe(32)
# ❌ MD5 or SHA1 for passwords
import hashlib
hashed = hashlib.md5(password).hexdigest() # cracked in seconds
# ❌ Hardcoded encryption key
AES_KEY = b'1234567890123456' # Visible in source code, version history
# ✅ Key from environment / KMS
AES_KEY = bytes.fromhex(os.environ['AES_KEY']) # Set in Kubernetes secret / AWS Secrets Manager
# ❌ Reusing nonces with AES-GCM
# If you encrypt two messages with same key + nonce in GCM, both are broken
# ✅ Generate fresh nonce every time
nonce = os.urandom(12) # inside the encrypt() method, every call
# ❌ Ignoring certificate errors
import ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False # DON'T DO THIS
ctx.verify_mode = ssl.CERT_NONE # DON'T DO THIS
# ✅ Default SSL context verifies everything
ctx = ssl.create_default_context() # default is secure
Key Management — The Hardest Part
Encryption is only as strong as your key management.
Key Management Lifecycle:
1. Generation → Use cryptographically secure RNG, appropriate size
2. Storage → Never in code, git, or plaintext files
3. Distribution → Use KMS, Vault, or Kubernetes secrets
4. Rotation → Rotate regularly, support key versioning
5. Revocation → Quickly revoke compromised keys
6. Destruction → Securely wipe when no longer needed
# AWS KMS — let AWS manage the master key
import boto3
kms = boto3.client('kms')
def encrypt_with_kms(plaintext: str, key_id: str) -> bytes:
response = kms.encrypt(
KeyId=key_id,
Plaintext=plaintext.encode('utf-8')
)
return response['CiphertextBlob']
def decrypt_with_kms(ciphertext: bytes) -> str:
response = kms.decrypt(CiphertextBlob=ciphertext)
return response['Plaintext'].decode('utf-8')
# Data key pattern — encrypt data with local key, encrypt local key with KMS
def generate_data_key(key_id: str) -> tuple[bytes, bytes]:
response = kms.generate_data_key(KeyId=key_id, KeySpec='AES_256')
plaintext_key = response['Plaintext'] # use to encrypt data, then destroy
encrypted_key = response['CiphertextBlob'] # store alongside encrypted data
return plaintext_key, encrypted_key
What's Next
In Part 6 we attack and defend APIs — REST authentication, GraphQL injection, gRPC security, rate limiting, and the unique attack surface each API style presents.
Discussion
Loading...Leave a Comment
All comments are reviewed before appearing. No links please.