← Blog

"AppSec Series #5: Cryptography for AppSec Engineers — Hashing, Encryption and TLS"

You don't need to implement cryptography — you need to use it correctly. Learn hashing, symmetric and asymmetric encryption, TLS internals, key management, and the crypto mistakes that create vulnerabilities.

reading now
views
comments

Series Navigation

Part 4: Authentication and Authorization

Part 6: API Security


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.

0 / 1000