Cryptography Basics

Introduction

Cryptography is the practice and study of techniques for secure communication in the presence of adversaries. It's the mathematical foundation that enables confidentiality, integrity, and authenticity in digital systems. From HTTPS securing your web browsing to encrypted messaging apps, cryptography is everywhere in modern computing.

This reading covers the essential cryptographic concepts every developer needs to understand: symmetric and asymmetric encryption, hashing, common algorithms like AES and RSA, and digital signatures. You don't need to be a cryptographer to use cryptography correctly, but you do need to understand the basics to avoid dangerous mistakes.

Learning Objectives

By the end of this reading, you will be able to:

  • Distinguish between symmetric and asymmetric encryption and when to use each
  • Understand cryptographic hashing and its applications
  • Use AES for symmetric encryption correctly
  • Understand RSA and public key cryptography
  • Implement digital signatures for authentication and non-repudiation
  • Recognize common cryptographic mistakes and how to avoid them
  • Apply cryptographic primitives to solve real-world security problems

Cryptographic Primitives

Cryptography provides three main types of operations:

  1. Encryption: Making data unreadable to unauthorized parties
  2. Hashing: Creating fixed-size fingerprints of data
  3. Digital Signatures: Proving authenticity and integrity

Let's explore each in detail.

Cryptographic Hashing

A cryptographic hash function takes input of any size and produces a fixed-size output (the hash or digest). Good hash functions are:

  • Deterministic: Same input always produces same output
  • Fast to compute: Efficient for any size input
  • Irreversible: Cannot derive input from output (one-way function)
  • Collision-resistant: Hard to find two inputs with same output
  • Avalanche effect: Small input change completely changes output

Common Hash Functions

  • SHA-256: 256-bit output, part of SHA-2 family, widely used
  • SHA-3: Newest standard, different design from SHA-2
  • BLAKE2: Fast, secure alternative to SHA-2
  • MD5: BROKEN - Do not use for security (only for checksums)
  • SHA-1: DEPRECATED - Being phased out, vulnerabilities known

Hashing Examples

import hashlib

# SHA-256 hashing
def hash_data(data):
    """Create SHA-256 hash of data"""
    if isinstance(data, str):
        data = data.encode('utf-8')

    hash_object = hashlib.sha256(data)
    return hash_object.hexdigest()

# Example usage
message = "Hello, World!"
hash_value = hash_data(message)
print(f"SHA-256: {hash_value}")
# Output: SHA-256: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f

# Small change in input completely changes output (avalanche effect)
message2 = "Hello, World?"
hash_value2 = hash_data(message2)
print(f"SHA-256: {hash_value2}")
# Completely different hash despite only one character change

Use Cases for Hashing

1. Password Storage (with salt and key derivation)

import hashlib
import os

# BAD: Never store passwords like this!
def store_password_bad(password):
    # Plain MD5 is completely insecure for passwords
    return hashlib.md5(password.encode()).hexdigest()

# BETTER: Use salt and multiple iterations
def store_password_better(password):
    # Generate random salt
    salt = os.urandom(32)  # 32 bytes = 256 bits

    # Use PBKDF2 with many iterations (slow is good for passwords!)
    iterations = 100000
    key = hashlib.pbkdf2_hmac(
        'sha256',
        password.encode('utf-8'),
        salt,
        iterations
    )

    # Store salt and hash together
    return salt + key

def verify_password(stored_password, provided_password):
    # Extract salt (first 32 bytes)
    salt = stored_password[:32]

    # Extract hash (remaining bytes)
    stored_key = stored_password[32:]

    # Hash the provided password with same salt
    key = hashlib.pbkdf2_hmac(
        'sha256',
        provided_password.encode('utf-8'),
        salt,
        100000
    )

    # Compare in constant time to prevent timing attacks
    return hmac.compare_digest(key, stored_key)
# BEST: Use specialized password hashing libraries
import bcrypt

def hash_password(password):
    # bcrypt automatically handles salting and uses adaptive cost
    salt = bcrypt.gensalt(rounds=12)  # Higher = slower = more secure
    hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
    return hashed

def verify_password(password, hashed):
    return bcrypt.checkpw(password.encode('utf-8'), hashed)

# Example
password = "my_secure_password"
hashed = hash_password(password)
print(f"Hashed: {hashed}")

# Verify
is_valid = verify_password("my_secure_password", hashed)
print(f"Password valid: {is_valid}")  # True

is_valid = verify_password("wrong_password", hashed)
print(f"Password valid: {is_valid}")  # False

2. Data Integrity Verification

import hashlib
import json

def create_checksum(file_path):
    """Create SHA-256 checksum of a file"""
    sha256_hash = hashlib.sha256()

    with open(file_path, "rb") as f:
        # Read file in chunks to handle large files
        for byte_block in iter(lambda: f.read(4096), b""):
            sha256_hash.update(byte_block)

    return sha256_hash.hexdigest()

def verify_file_integrity(file_path, expected_checksum):
    """Verify file hasn't been tampered with"""
    actual_checksum = create_checksum(file_path)
    return actual_checksum == expected_checksum

# Example: Verifying downloaded file
expected = "abc123def456..."  # From trusted source
if verify_file_integrity("downloaded_file.zip", expected):
    print("File integrity verified")
else:
    print("WARNING: File may be corrupted or tampered with!")

3. Hash-based Message Authentication Code (HMAC)

import hmac
import hashlib

def create_hmac(message, secret_key):
    """Create HMAC for message authentication"""
    if isinstance(message, str):
        message = message.encode('utf-8')
    if isinstance(secret_key, str):
        secret_key = secret_key.encode('utf-8')

    return hmac.new(secret_key, message, hashlib.sha256).hexdigest()

def verify_hmac(message, signature, secret_key):
    """Verify HMAC signature"""
    expected_signature = create_hmac(message, secret_key)
    # Use constant-time comparison to prevent timing attacks
    return hmac.compare_digest(signature, expected_signature)

# Example: API request signing
secret_key = "your-secret-key-here"
message = "GET /api/users?page=1"

# Sender creates signature
signature = create_hmac(message, secret_key)

# Receiver verifies signature
is_valid = verify_hmac(message, signature, secret_key)
print(f"Signature valid: {is_valid}")

Symmetric Encryption

Symmetric encryption uses the same key for both encryption and decryption. It's fast and efficient for encrypting large amounts of data.

Characteristics

  • Same key for encryption and decryption
  • Fast - suitable for large data volumes
  • Key distribution problem - how do both parties get the key securely?

AES (Advanced Encryption Standard)

AES is the gold standard for symmetric encryption. It's used everywhere from file encryption to VPNs to secure messaging.

Key sizes:

  • AES-128: 128-bit key (16 bytes)
  • AES-192: 192-bit key (24 bytes)
  • AES-256: 256-bit key (32 bytes)

Modes of operation:

  • ECB (Electronic Codebook): NEVER USE - insecure, patterns visible
  • CBC (Cipher Block Chaining): Common but requires padding
  • GCM (Galois/Counter Mode): RECOMMENDED - provides encryption + authentication
  • CTR (Counter Mode): Good for parallel processing

AES-GCM Example

from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os

class SecureEncryption:
    def __init__(self):
        # Generate a random 256-bit key (keep this secret!)
        self.key = AESGCM.generate_key(bit_length=256)

    def encrypt(self, plaintext, associated_data=None):
        """
        Encrypt plaintext using AES-256-GCM

        Args:
            plaintext: Data to encrypt
            associated_data: Additional authenticated data (not encrypted)

        Returns:
            tuple: (nonce, ciphertext)
        """
        if isinstance(plaintext, str):
            plaintext = plaintext.encode('utf-8')

        if associated_data and isinstance(associated_data, str):
            associated_data = associated_data.encode('utf-8')

        # Generate random nonce (number used once)
        # CRITICAL: Never reuse a nonce with the same key!
        nonce = os.urandom(12)  # 96 bits for GCM

        # Create cipher instance
        aesgcm = AESGCM(self.key)

        # Encrypt and authenticate
        ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)

        return nonce, ciphertext

    def decrypt(self, nonce, ciphertext, associated_data=None):
        """
        Decrypt ciphertext using AES-256-GCM

        Args:
            nonce: The nonce used during encryption
            ciphertext: Encrypted data
            associated_data: Additional authenticated data

        Returns:
            bytes: Decrypted plaintext

        Raises:
            cryptography.exceptions.InvalidTag: If authentication fails
        """
        if associated_data and isinstance(associated_data, str):
            associated_data = associated_data.encode('utf-8')

        aesgcm = AESGCM(self.key)

        # Decrypt and verify authentication tag
        plaintext = aesgcm.decrypt(nonce, ciphertext, associated_data)

        return plaintext.decode('utf-8')

# Example usage
crypto = SecureEncryption()

# Encrypt sensitive data
secret_message = "This is confidential information"
nonce, ciphertext = crypto.encrypt(secret_message)

print(f"Encrypted (hex): {ciphertext.hex()}")

# Decrypt
decrypted = crypto.decrypt(nonce, ciphertext)
print(f"Decrypted: {decrypted}")

# With associated data (authenticated but not encrypted)
nonce, ciphertext = crypto.encrypt(
    "account_number: 12345",
    associated_data="user_id: 42"
)

# Must provide same associated data to decrypt
decrypted = crypto.decrypt(nonce, ciphertext, associated_data="user_id: 42")

Common Mistakes with Symmetric Encryption

# MISTAKE 1: Using ECB mode
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend

# BAD: ECB reveals patterns in plaintext
def encrypt_ecb_bad(plaintext, key):
    cipher = Cipher(
        algorithms.AES(key),
        modes.ECB(),  # NEVER USE THIS!
        backend=default_backend()
    )
    encryptor = cipher.encryptor()
    return encryptor.update(plaintext) + encryptor.finalize()

# MISTAKE 2: Reusing IV/nonce
# BAD: Using same IV for multiple encryptions
static_iv = b'0' * 16  # NEVER DO THIS!

def encrypt_bad(plaintext, key):
    cipher = Cipher(algorithms.AES(key), modes.CBC(static_iv))
    encryptor = cipher.encryptor()
    return encryptor.update(plaintext) + encryptor.finalize()

# MISTAKE 3: No authentication (encryption without integrity)
# Use AEAD modes like GCM instead of plain CBC/CTR

# MISTAKE 4: Hardcoded keys
SECRET_KEY = b'hardcoded_key123'  # NEVER HARDCODE KEYS!

Asymmetric Encryption (Public Key Cryptography)

Asymmetric encryption uses two related keys: a public key (can be shared) and a private key (must be kept secret). Data encrypted with one key can only be decrypted with the other.

Characteristics

  • Key pair: Public and private key
  • Slow: Much slower than symmetric encryption
  • Key distribution: Solves the key exchange problem
  • Common uses: Key exchange, digital signatures, authentication

RSA (Rivest-Shamir-Adleman)

RSA is the most widely-used public key algorithm.

Key sizes:

  • 2048 bits: Minimum recommended
  • 3072 bits: Better security margin
  • 4096 bits: High security (slower)
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization

class RSAEncryption:
    def __init__(self, key_size=2048):
        # Generate RSA key pair
        self.private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=key_size
        )
        self.public_key = self.private_key.public_key()

    def encrypt(self, plaintext):
        """Encrypt with public key"""
        if isinstance(plaintext, str):
            plaintext = plaintext.encode('utf-8')

        ciphertext = self.public_key.encrypt(
            plaintext,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        return ciphertext

    def decrypt(self, ciphertext):
        """Decrypt with private key"""
        plaintext = self.private_key.decrypt(
            ciphertext,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA256()),
                algorithm=hashes.SHA256(),
                label=None
            )
        )
        return plaintext.decode('utf-8')

    def export_public_key(self):
        """Export public key in PEM format"""
        pem = self.public_key.public_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PublicFormat.SubjectPublicKeyInfo
        )
        return pem.decode('utf-8')

    def export_private_key(self, password=None):
        """Export private key in PEM format (optionally encrypted)"""
        encryption = serialization.NoEncryption()

        if password:
            if isinstance(password, str):
                password = password.encode('utf-8')
            encryption = serialization.BestAvailableEncryption(password)

        pem = self.private_key.private_bytes(
            encoding=serialization.Encoding.PEM,
            format=serialization.PrivateFormat.PKCS8,
            encryption_algorithm=encryption
        )
        return pem.decode('utf-8')

# Example usage
rsa_crypto = RSAEncryption()

# Encrypt with public key (anyone can do this)
message = "Secret message"
encrypted = rsa_crypto.encrypt(message)
print(f"Encrypted: {encrypted.hex()[:64]}...")

# Decrypt with private key (only private key holder can do this)
decrypted = rsa_crypto.decrypt(encrypted)
print(f"Decrypted: {decrypted}")

# Export keys
public_pem = rsa_crypto.export_public_key()
print("Public Key:")
print(public_pem)

private_pem = rsa_crypto.export_private_key(password="strong_password")
print("\nPrivate Key (encrypted):")
print(private_pem[:100] + "...")

Hybrid Encryption

In practice, we combine symmetric and asymmetric encryption:

  1. Generate random symmetric key
  2. Encrypt data with symmetric key (fast)
  3. Encrypt symmetric key with recipient's public key
  4. Send both encrypted data and encrypted key
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def hybrid_encrypt(plaintext, recipient_public_key):
    """
    Hybrid encryption: Use RSA to encrypt AES key, use AES to encrypt data
    """
    # 1. Generate random AES key
    aes_key = AESGCM.generate_key(bit_length=256)

    # 2. Encrypt plaintext with AES-GCM
    aesgcm = AESGCM(aes_key)
    nonce = os.urandom(12)

    if isinstance(plaintext, str):
        plaintext = plaintext.encode('utf-8')

    ciphertext = aesgcm.encrypt(nonce, plaintext, None)

    # 3. Encrypt AES key with recipient's RSA public key
    encrypted_key = recipient_public_key.encrypt(
        aes_key,
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )

    return {
        'encrypted_key': encrypted_key,
        'nonce': nonce,
        'ciphertext': ciphertext
    }

def hybrid_decrypt(encrypted_data, private_key):
    """
    Hybrid decryption
    """
    # 1. Decrypt AES key with RSA private key
    aes_key = private_key.decrypt(
        encrypted_data['encrypted_key'],
        padding.OAEP(
            mgf=padding.MGF1(algorithm=hashes.SHA256()),
            algorithm=hashes.SHA256(),
            label=None
        )
    )

    # 2. Decrypt ciphertext with AES key
    aesgcm = AESGCM(aes_key)
    plaintext = aesgcm.decrypt(
        encrypted_data['nonce'],
        encrypted_data['ciphertext'],
        None
    )

    return plaintext.decode('utf-8')

# Example
rsa_crypto = RSAEncryption()
large_message = "This is a large message that would be inefficient to encrypt with RSA alone. " * 100

encrypted = hybrid_encrypt(large_message, rsa_crypto.public_key)
decrypted = hybrid_decrypt(encrypted, rsa_crypto.private_key)

print(f"Original length: {len(large_message)}")
print(f"Decryption successful: {decrypted == large_message}")

Digital Signatures

Digital signatures provide authentication (proves who created it) and non-repudiation (creator cannot deny creating it).

How Digital Signatures Work

  1. Signing: Hash the message, then encrypt hash with private key
  2. Verification: Decrypt signature with public key, compare with hash of message
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

class DigitalSignature:
    def __init__(self):
        # Generate key pair
        self.private_key = rsa.generate_private_key(
            public_exponent=65537,
            key_size=2048
        )
        self.public_key = self.private_key.public_key()

    def sign(self, message):
        """Create digital signature of message"""
        if isinstance(message, str):
            message = message.encode('utf-8')

        signature = self.private_key.sign(
            message,
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH
            ),
            hashes.SHA256()
        )
        return signature

    def verify(self, message, signature):
        """Verify digital signature"""
        if isinstance(message, str):
            message = message.encode('utf-8')

        try:
            self.public_key.verify(
                signature,
                message,
                padding.PSS(
                    mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.MAX_LENGTH
                ),
                hashes.SHA256()
            )
            return True
        except Exception as e:
            return False

# Example usage
signer = DigitalSignature()

# Alice signs a message
message = "I agree to the terms of this contract"
signature = signer.sign(message)
print(f"Signature: {signature.hex()[:64]}...")

# Bob verifies the signature
is_valid = signer.verify(message, signature)
print(f"Signature valid: {is_valid}")

# Tampering detection
tampered_message = "I do not agree to the terms of this contract"
is_valid = signer.verify(tampered_message, signature)
print(f"Tampered message valid: {is_valid}")  # False

Use Case: Software Updates

import json
import base64

class SoftwareUpdateSigner:
    def __init__(self, private_key):
        self.private_key = private_key

    def create_signed_update(self, version, download_url, checksum):
        """Create signed update manifest"""
        update_info = {
            'version': version,
            'download_url': download_url,
            'checksum': checksum,
            'timestamp': '2025-12-23T10:00:00Z'
        }

        # Create canonical JSON (sorted keys for consistency)
        canonical = json.dumps(update_info, sort_keys=True)

        # Sign the update info
        signature = self.private_key.sign(
            canonical.encode('utf-8'),
            padding.PSS(
                mgf=padding.MGF1(hashes.SHA256()),
                salt_length=padding.PSS.MAX_LENGTH
            ),
            hashes.SHA256()
        )

        # Return manifest with signature
        return {
            'update': update_info,
            'signature': base64.b64encode(signature).decode('utf-8')
        }

class SoftwareUpdateVerifier:
    def __init__(self, public_key):
        self.public_key = public_key

    def verify_update(self, manifest):
        """Verify signed update manifest"""
        try:
            # Extract signature
            signature = base64.b64decode(manifest['signature'])

            # Recreate canonical JSON
            canonical = json.dumps(manifest['update'], sort_keys=True)

            # Verify signature
            self.public_key.verify(
                signature,
                canonical.encode('utf-8'),
                padding.PSS(
                    mgf=padding.MGF1(hashes.SHA256()),
                    salt_length=padding.PSS.MAX_LENGTH
                ),
                hashes.SHA256()
            )
            return True
        except Exception as e:
            print(f"Verification failed: {e}")
            return False

# Example: Software vendor publishes update
vendor_keys = RSAEncryption()
signer = SoftwareUpdateSigner(vendor_keys.private_key)

update_manifest = signer.create_signed_update(
    version="2.0.1",
    download_url="https://example.com/app-2.0.1.zip",
    checksum="abc123def456..."
)

print("Update manifest:")
print(json.dumps(update_manifest, indent=2))

# Client verifies update
verifier = SoftwareUpdateVerifier(vendor_keys.public_key)
is_valid = verifier.verify_update(update_manifest)
print(f"\nUpdate verification: {is_valid}")

Key Management

The security of cryptographic systems depends entirely on keeping keys secret. Key management is often the weakest link.

Best Practices

import os
from pathlib import Path

# BAD: Hardcoded keys
API_KEY = "sk_live_abc123"  # NEVER DO THIS!

# BAD: Keys in version control
# config.py:
# SECRET_KEY = "my-secret-key"

# GOOD: Environment variables
import os
api_key = os.environ.get('API_KEY')
if not api_key:
    raise ValueError("API_KEY environment variable not set")

# GOOD: Key files with proper permissions
def load_key_secure(key_file_path):
    """Load key from file with permission check"""
    path = Path(key_file_path)

    # Check file permissions (Unix/Linux)
    stat = path.stat()
    mode = oct(stat.st_mode)[-3:]

    if mode != '600':  # Should be readable/writable by owner only
        raise PermissionError(f"Key file has insecure permissions: {mode}")

    with open(key_file_path, 'rb') as f:
        return f.read()

# GOOD: Key rotation
class KeyManager:
    def __init__(self):
        self.keys = {}
        self.active_key_id = None

    def add_key(self, key_id, key):
        """Add a new key"""
        self.keys[key_id] = {
            'key': key,
            'created': datetime.now()
        }

    def set_active_key(self, key_id):
        """Set which key to use for new encryption"""
        self.active_key_id = key_id

    def encrypt(self, plaintext):
        """Encrypt with active key"""
        key_id = self.active_key_id
        key = self.keys[key_id]['key']

        aesgcm = AESGCM(key)
        nonce = os.urandom(12)
        ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None)

        # Include key ID so we know which key to use for decryption
        return {
            'key_id': key_id,
            'nonce': nonce,
            'ciphertext': ciphertext
        }

    def decrypt(self, encrypted_data):
        """Decrypt with appropriate key"""
        key_id = encrypted_data['key_id']
        key = self.keys[key_id]['key']

        aesgcm = AESGCM(key)
        plaintext = aesgcm.decrypt(
            encrypted_data['nonce'],
            encrypted_data['ciphertext'],
            None
        )
        return plaintext.decode('utf-8')

# Example: Key rotation
manager = KeyManager()

# Add initial key
manager.add_key('key-2024-01', AESGCM.generate_key(bit_length=256))
manager.set_active_key('key-2024-01')

# Encrypt some data
data1 = manager.encrypt("sensitive data 1")

# Later: Rotate to new key
manager.add_key('key-2024-02', AESGCM.generate_key(bit_length=256))
manager.set_active_key('key-2024-02')

# New encryptions use new key
data2 = manager.encrypt("sensitive data 2")

# But we can still decrypt old data
print(manager.decrypt(data1))  # Uses key-2024-01
print(manager.decrypt(data2))  # Uses key-2024-02

Common Cryptographic Mistakes

1. Rolling Your Own Crypto

# NEVER DO THIS
def my_encryption(plaintext, key):
    """My custom super secure encryption algorithm"""
    result = ""
    for i, char in enumerate(plaintext):
        result += chr(ord(char) ^ ord(key[i % len(key)]))
    return result

# INSTEAD: Use well-tested libraries
from cryptography.fernet import Fernet

key = Fernet.generate_key()
f = Fernet(key)
encrypted = f.encrypt(b"my secret")

2. Using Weak Random Number Generators

import random

# BAD: Not cryptographically secure
random.seed(12345)
weak_key = ''.join(random.choices('0123456789abcdef', k=32))

# GOOD: Use cryptographically secure RNG
import secrets
strong_key = secrets.token_hex(32)

3. Not Using Authenticated Encryption

# BAD: Encryption without authentication (vulnerable to tampering)
# Using plain AES-CBC or AES-CTR without HMAC

# GOOD: Use AEAD modes like AES-GCM
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
# GCM provides both encryption and authentication

4. Improper IV/Nonce Usage

# BAD: Reusing IV
iv = b'0' * 16
cipher1 = Cipher(algorithms.AES(key), modes.CBC(iv))
cipher2 = Cipher(algorithms.AES(key), modes.CBC(iv))  # Same IV!

# GOOD: Generate random IV for each encryption
import os
iv = os.urandom(16)

Exercises

Basic Exercises

  1. Hash Function Properties: Explain why each property is important:

    • Deterministic
    • One-way (irreversible)
    • Collision-resistant
    • Avalanche effect
  2. Password Storage: Fix this insecure password storage code:

def store_password(username, password):
    hashed = hashlib.md5(password.encode()).hexdigest()
    db.execute("INSERT INTO users VALUES (?, ?)", (username, hashed))
  1. Symmetric vs Asymmetric: For each scenario, would you use symmetric or asymmetric encryption?
    • Encrypting a 1GB database backup
    • Sending a message to someone you've never met
    • Encrypting your laptop's hard drive
    • Establishing a secure connection to a website

Intermediate Exercises

  1. File Encryption Tool: Create a command-line tool that:

    • Encrypts a file with AES-256-GCM
    • Stores the encrypted file with nonce and authentication tag
    • Can decrypt the file back
    • Uses a password-derived key (PBKDF2)
  2. Message Verification: Implement a system where:

    • Alice can send a signed message to Bob
    • Bob can verify the message came from Alice and wasn't tampered with
    • Use RSA for signatures and include timestamp to prevent replay attacks
  3. Secure API Request Signing: Create an HMAC-based request signing system:

    • Client signs requests with shared secret
    • Server verifies signature before processing
    • Include timestamp to prevent replay attacks
    • Sign both URL and request body

Advanced Exercises

  1. Secure File Sharing: Design and implement a secure file sharing system:

    • User A can upload encrypted file
    • User A can grant access to User B without re-encrypting
    • Use hybrid encryption (RSA + AES)
    • Include integrity verification
    • Support key rotation
  2. Certificate Chain Verification: Implement basic X.509 certificate chain verification:

    • Load a certificate chain
    • Verify each certificate is signed by the next in chain
    • Check certificate validity periods
    • Verify the root certificate is trusted
  3. Secure Communication Protocol: Design a secure messaging protocol:

    • Initial key exchange using RSA
    • Ongoing communication using AES
    • Perfect forward secrecy (new keys for each session)
    • Message authentication and ordering
    • Replay attack prevention

Summary

Cryptography provides the mathematical tools for secure communication and data protection:

  1. Hashing: One-way functions that create fixed-size fingerprints of data. Essential for password storage (with proper salting and key derivation), integrity verification, and message authentication.

  2. Symmetric Encryption: Fast encryption using the same key for encryption and decryption. AES-GCM is the recommended algorithm, providing both confidentiality and authenticity.

  3. Asymmetric Encryption: Uses public/private key pairs. Solves the key distribution problem but is much slower than symmetric encryption. RSA is widely used, typically in hybrid schemes.

  4. Digital Signatures: Provide authentication and non-repudiation. Essential for software updates, certificates, and any scenario requiring proof of origin.

  5. Key Management: The weakest link in most systems. Never hardcode keys, use environment variables or key management services, rotate keys regularly, and use proper access controls.

Critical Principles

  • Never roll your own cryptography
  • Use well-tested libraries and standard algorithms
  • Use authenticated encryption (AEAD modes like GCM)
  • Generate cryptographically secure random values
  • Never reuse IVs/nonces with the same key
  • Use appropriate key sizes (AES-256, RSA-2048 minimum)
  • Implement proper key management and rotation
  • Hash passwords with salt and key derivation functions (bcrypt, Argon2)

Key Takeaways

  • Cryptography is a tool, not a complete security solution
  • Modern crypto libraries make it easier to use cryptography correctly
  • Most vulnerabilities come from improper use, not weak algorithms
  • Key management is often harder than the cryptography itself
  • When in doubt, use authenticated encryption (AES-GCM)
  • Asymmetric crypto is for key exchange, symmetric crypto is for data encryption

Further Reading

  • "Serious Cryptography" by Jean-Philippe Aumasson
  • "Cryptography Engineering" by Ferguson, Schneier, and Kohno
  • NIST Cryptographic Standards: https://csrc.nist.gov/
  • cryptography.io documentation: https://cryptography.io/
  • OWASP Cryptographic Storage Cheat Sheet

Next Steps

Continue to 03-auth.md to learn how cryptography is applied to authentication and authorization systems, including passwords, sessions, tokens, and OAuth.