Cryptography Fundamentals

Hashing, HMAC, Symmetric & Asymmetric Encryption, Digital Signatures

Introduction

Cryptography is the mathematical foundation of all modern security. It provides the tools to keep data secret (encryption), verify that data hasn't been tampered with (hashing/MACs), and prove who sent a message (digital signatures). This lesson walks through each primitive with working Python examples, from the deprecated MD5 through modern RSA key pairs.

Hashing Algorithms

A hash function maps data of any size to a fixed-size digest. Good hash functions are one-way (you cannot reverse them), deterministic (same input → same output), and collision-resistant (two different inputs should not produce the same hash).

AlgorithmOutput SizeStatusUse Today?
MD5128-bit (32 hex chars)Broken❌ Never for security
SHA-1160-bit (40 hex chars)Deprecated❌ Legacy only
SHA-256256-bit (64 hex chars)Secure✅ General purpose
SHA-3-256256-bit (64 hex chars)Secure✅ When SHA-2 is a concern
BLAKE2b512-bit (128 hex chars)Secure✅ High-performance

Hashing with hashlib

# hashing_demo.py
import hashlib

message = "Hello, Security World!"
data = message.encode("utf-8")

# MD5, broken, shown only for comparison
md5_hash = hashlib.md5(data).hexdigest()

# SHA-256, recommended standard
sha256_hash = hashlib.sha256(data).hexdigest()

# SHA-3-256, different family, equally secure
sha3_hash = hashlib.sha3_256(data).hexdigest()

# BLAKE2b, fast and secure
blake2_hash = hashlib.blake2b(data).hexdigest()

print(f"Input:    '{message}'")
print(f"MD5:      {md5_hash}  (32 chars), ⚠️  DO NOT USE")
print(f"SHA-256:  {sha256_hash}  (64 chars), ✅")
print(f"SHA3-256: {sha3_hash}  (64 chars), ✅")
print(f"BLAKE2b:  {blake2_hash[:64]}...  (128 chars), ✅")

# Hashing a file, stream it for large files
def hash_file(filepath: str) -> str:
    sha256 = hashlib.sha256()
    with open(filepath, "rb") as f:
        for chunk in iter(lambda: f.read(65536), b""):
            sha256.update(chunk)
    return sha256.hexdigest()

print("\nFile hash function defined, use hash_file('/path/to/file')")
Expected Output:
Input:    'Hello, Security World!'
MD5:      b87636b2ff06c20e5f53c0a6105ebbad  (32 chars), ⚠️  DO NOT USE
SHA-256:  d21966f3d545931bfe06a789453b10fc86a09d5bca6f7df9828e82d029863492  (64 chars), ✅
SHA3-256: f4f2ad8b42a0aac3d08eca2ae5dfb2bb5ac0b3e881ea4c42e0800f3f472bfa4f  (64 chars), ✅
BLAKE2b:  48f28b98411c29b54a9783fb0fbe519ec2adb49e6dd4bf7039d25f7e5963b231...  (128 chars), ✅

File hash function defined, use hash_file('/path/to/file')

HMAC for Message Authentication

A plain hash verifies data integrity but not authenticity, anyone can compute a hash. HMAC (Hash-based Message Authentication Code) uses a secret key, proving the message came from someone who knows the key.

# hmac_demo.py
import hmac
import hashlib
import secrets

# Generate a strong secret key
secret_key = secrets.token_bytes(32)   # 256-bit key

def sign_message(key: bytes, message: str) -> str:
    """Produce an HMAC-SHA256 signature."""
    return hmac.new(key, message.encode("utf-8"), hashlib.sha256).hexdigest()

def verify_message(key: bytes, message: str, signature: str) -> bool:
    """Verify an HMAC signature using constant-time comparison."""
    expected = sign_message(key, message)
    # hmac.compare_digest prevents timing attacks
    return hmac.compare_digest(expected, signature)

# Sign a payload
payload = '{"user_id": 42, "action": "transfer", "amount": 500}'
mac = sign_message(secret_key, payload)
print(f"Payload:   {payload}")
print(f"HMAC-SHA256: {mac}")

# Verify, untampered
is_valid = verify_message(secret_key, payload, mac)
print(f"\nVerification (original): {is_valid}")   # True

# Tampered payload, same MAC will fail
tampered = '{"user_id": 42, "action": "transfer", "amount": 9999}'
is_valid_tampered = verify_message(secret_key, tampered, mac)
print(f"Verification (tampered): {is_valid_tampered}")   # False
Expected Output:
Payload:   {"user_id": 42, "action": "transfer", "amount": 500}
HMAC-SHA256: e8e402f8cf0eeef9737cce64437c5f4209fd34362c55b7bcce3103a6c6bb0f10

Verification (original): True
Verification (tampered): False

Symmetric Encryption

Symmetric encryption uses the same key to encrypt and decrypt. It is fast and suitable for encrypting large amounts of data, but requires a secure channel to share the key.

How It Works

Plaintext
+ Key
Ciphertext
+ Same Key
Plaintext

Fernet Symmetric Encryption

Fernet (from the cryptography library) provides authenticated symmetric encryption using AES-128-CBC + HMAC-SHA256. It is the simplest safe choice for symmetric encryption in Python.

Install: pip install cryptography

# fernet_demo.py
from cryptography.fernet import Fernet

# Generate and save a key (store securely, not in source code!)
key = Fernet.generate_key()
cipher = Fernet(key)

print(f"Key (base64): {key.decode()[:40]}...")

# Encrypt
plaintext = b"Top secret: database password is hunter2"
token = cipher.encrypt(plaintext)
print(f"\nPlaintext: {plaintext.decode()}")
print(f"Encrypted: {token.decode()[:60]}...")

# Decrypt
decrypted = cipher.decrypt(token)
print(f"Decrypted: {decrypted.decode()}")

# Tokens have a timestamp, they can expire
import time
from cryptography.fernet import InvalidToken

try:
    # time_to_live=1 means token is valid for 1 second only
    time.sleep(2)
    cipher.decrypt(token, ttl=1)
except InvalidToken:
    print("\nToken expired, use ttl= for time-limited secrets")
Expected Output:
Key (base64): T3y-o05hjRag8XRRD0kA8K1bHwi7QLy_hl768-LX...

Plaintext: Top secret: database password is hunter2
Encrypted: gAAAAABplheOPObxnW8LB_MXmOm32TqzbiXuv7ls98Ek6LHLwfK7eqqFuXYf...
Decrypted: Top secret: database password is hunter2

Token expired, use ttl= for time-limited secrets

Asymmetric Encryption & Digital Signatures

Asymmetric encryption uses a key pair: a public key (shared with everyone) and a private key (kept secret). Data encrypted with the public key can only be decrypted with the private key, and vice versa for signatures.

Encryption Flow

Alice encrypts with Bob's public key. Only Bob's private key can decrypt it. This ensures confidentiality without sharing a secret key.

Signature Flow

Alice signs with her private key. Anyone with Alice's public key can verify the signature. This proves authenticity and non-repudiation.

RSA Key Generation, Encryption & Digital Signatures

Install: pip install cryptography

# rsa_demo.py
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization

# ── 1. Generate RSA key pair ──────────────────────────────────
private_key = rsa.generate_private_key(
    public_exponent=65537,
    key_size=2048,          # Minimum recommended; use 4096 for high-security
)
public_key = private_key.public_key()

print("RSA key pair generated (2048-bit)")
print(f"Public key type: {type(public_key).__name__}")

# ── 2. Encrypt with public key ────────────────────────────────
message = b"Confidential: API_KEY=sk-live-abc123xyz"
ciphertext = public_key.encrypt(
    message,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    )
)
print(f"\nEncrypted ({len(ciphertext)} bytes): {ciphertext.hex()[:40]}...")

# ── 3. Decrypt with private key ───────────────────────────────
decrypted = private_key.decrypt(
    ciphertext,
    padding.OAEP(
        mgf=padding.MGF1(algorithm=hashes.SHA256()),
        algorithm=hashes.SHA256(),
        label=None,
    )
)
print(f"Decrypted: {decrypted.decode()}")

# ── 4. Sign a message with private key ───────────────────────
document = b"Transfer $1000 to account 9876"
signature = private_key.sign(
    document,
    padding.PSS(
        mgf=padding.MGF1(hashes.SHA256()),
        salt_length=padding.PSS.MAX_LENGTH,
    ),
    hashes.SHA256(),
)
print(f"\nSignature ({len(signature)} bytes): {signature.hex()[:40]}...")

# ── 5. Verify signature with public key ──────────────────────
from cryptography.exceptions import InvalidSignature

try:
    public_key.verify(
        signature,
        document,
        padding.PSS(
            mgf=padding.MGF1(hashes.SHA256()),
            salt_length=padding.PSS.MAX_LENGTH,
        ),
        hashes.SHA256(),
    )
    print("Signature VALID ✅")
except InvalidSignature:
    print("Signature INVALID ❌")

# Verify with tampered document
try:
    public_key.verify(
        signature,
        b"Transfer $9999 to account 9876",   # tampered!
        padding.PSS(mgf=padding.MGF1(hashes.SHA256()), salt_length=padding.PSS.MAX_LENGTH),
        hashes.SHA256(),
    )
except InvalidSignature:
    print("Tampered document signature INVALID ❌ (correct behavior)")
Expected Output:
RSA key pair generated (2048-bit)
Public key type: RSAPublicKey

Encrypted (256 bytes): 881e51e262a23044d58f1cccb9aa7d70a4627f80...
Decrypted: Confidential: API_KEY=sk-live-abc123xyz

Signature (256 bytes): 4e6a5354b08b7f677f3305ec90605fc521b0a218...
Signature VALID ✅
Tampered document signature INVALID ❌ (correct behavior)

Key Takeaways

  • MD5 / SHA-1 are broken, never use them for security purposes; SHA-256+ is the minimum standard
  • HMAC adds authentication, a plain hash verifies integrity; HMAC proves the sender has the secret key
  • Fernet is the simplest safe symmetric encryption, AES-128-CBC + HMAC-SHA256 bundled together
  • RSA is for small data only, use it to encrypt keys or sign documents, not to encrypt bulk data
  • OAEP padding is mandatory for RSA encryption, raw RSA without padding is completely insecure
  • Digital signatures prove authenticity, sign with private key, verify with public key
  • Use constant-time comparison, always use hmac.compare_digest() to prevent timing attacks
What's Next?

Lesson 3 applies hashing directly to the most common security problem developers face: storing user passwords safely.

  • Why plain SHA-256 is wrong for passwords, rainbow tables and GPU cracking
  • bcrypt, Argon2, PBKDF2, purpose-built password hashing algorithms
  • Salts and peppers, defending against precomputed attacks