Cryptography in Python: Hashing, Encryption, and Signing Explained
Cryptography is the foundation of modern security. Whether you’re storing passwords, protecting sensitive data, or verifying that a message came from who it claims, cryptographic operations are essential. Yet cryptography is often misunderstoodโdevelopers confuse hashing with encryption, or use the wrong technique for their use case.
This guide demystifies cryptography by exploring three core concepts: hashing, encryption, and signing. You’ll understand what each does, when to use it, and how to implement it correctly in Python.
Why Cryptography Matters
Imagine you’re building a web application. Users trust you with their passwords, personal information, and financial data. Without cryptography:
- Passwords stored in plaintext could be stolen
- Data transmitted over networks could be intercepted
- Users couldn’t verify that messages came from you
- Attackers could modify data without detection
Cryptography solves these problems. It’s not optionalโit’s essential.
Hashing: One-Way Functions
What is Hashing?
Hashing is a one-way function that converts input of any size into a fixed-size string of characters. The same input always produces the same hash, but you can’t reverse the process to get the original input.
Think of it like a fingerprint: unique, consistent, but you can’t reconstruct a person from their fingerprint.
Key Properties
- Deterministic: Same input always produces same output
- Fast: Quick to compute
- Avalanche effect: Tiny input change produces completely different hash
- One-way: Can’t reverse the hash to get original input
- Collision-resistant: Nearly impossible to find two inputs with same hash
Common Hashing Algorithms
| Algorithm | Output Size | Status | Use Case |
|---|---|---|---|
| MD5 | 128 bits | Broken | Don’t use |
| SHA-1 | 160 bits | Deprecated | Legacy only |
| SHA-256 | 256 bits | Secure | Passwords, data integrity |
| SHA-512 | 512 bits | Secure | High-security applications |
| bcrypt | Variable | Secure | Password hashing (preferred) |
Hashing in Python
import hashlib
# Basic hashing with SHA-256
def hash_password(password):
"""Hash a password using SHA-256"""
# Convert string to bytes
password_bytes = password.encode('utf-8')
# Create hash object
hash_object = hashlib.sha256(password_bytes)
# Get hexadecimal representation
hash_hex = hash_object.hexdigest()
return hash_hex
# Example
password = "MySecurePassword123"
hashed = hash_password(password)
print(f"Original: {password}")
print(f"Hashed: {hashed}")
# Same input always produces same hash
print(hash_password(password) == hashed) # True
# Different input produces different hash
print(hash_password("DifferentPassword") == hashed) # False
Better: Using bcrypt for Passwords
import bcrypt
def hash_password_bcrypt(password):
"""Hash a password using bcrypt (recommended for passwords)"""
# Generate salt and hash
salt = bcrypt.gensalt(rounds=12) # rounds = computational cost
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed
def verify_password(password, hashed):
"""Verify a password against its hash"""
return bcrypt.checkpw(password.encode('utf-8'), hashed)
# Example
password = "MySecurePassword123"
hashed = hash_password_bcrypt(password)
# Verify password
print(verify_password(password, hashed)) # True
print(verify_password("WrongPassword", hashed)) # False
# Each hash is different (due to random salt)
print(hash_password_bcrypt(password) == hashed) # False (but both verify!)
Use Cases for Hashing
- Password storage: Never store plaintext passwords
- Data integrity: Verify data hasn’t been modified
- Checksums: Detect file corruption
- Caching: Use hash as cache key
Encryption: Two-Way Functions
What is Encryption?
Encryption converts plaintext into ciphertext using a key. Unlike hashing, encryption is reversibleโwith the correct key, you can decrypt ciphertext back to plaintext.
Symmetric vs Asymmetric Encryption
Symmetric Encryption: Same key encrypts and decrypts
Plaintext + Key โ Encrypt โ Ciphertext
Ciphertext + Key โ Decrypt โ Plaintext
Advantages: Fast, simple Disadvantages: Key must be shared securely
Asymmetric Encryption: Different keys for encryption and decryption
Plaintext + Public Key โ Encrypt โ Ciphertext
Ciphertext + Private Key โ Decrypt โ Plaintext
Advantages: No need to share private key Disadvantages: Slower, more complex
Symmetric Encryption: AES
from cryptography.fernet import Fernet
def encrypt_data(plaintext, key):
"""Encrypt data using Fernet (symmetric encryption)"""
# Create cipher suite
cipher_suite = Fernet(key)
# Encrypt plaintext
ciphertext = cipher_suite.encrypt(plaintext.encode('utf-8'))
return ciphertext
def decrypt_data(ciphertext, key):
"""Decrypt data using Fernet"""
# Create cipher suite
cipher_suite = Fernet(key)
# Decrypt ciphertext
plaintext = cipher_suite.decrypt(ciphertext).decode('utf-8')
return plaintext
# Generate a key (do this once and store securely!)
key = Fernet.generate_key()
print(f"Key: {key}")
# Encrypt data
plaintext = "This is secret data"
ciphertext = encrypt_data(plaintext, key)
print(f"Encrypted: {ciphertext}")
# Decrypt data
decrypted = decrypt_data(ciphertext, key)
print(f"Decrypted: {decrypted}")
print(f"Match: {plaintext == decrypted}") # True
Asymmetric Encryption: RSA
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives import hashes
def generate_rsa_keys():
"""Generate RSA public and private keys"""
# Generate private key
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
# Extract public key
public_key = private_key.public_key()
return private_key, public_key
def encrypt_with_public_key(plaintext, public_key):
"""Encrypt data with public key"""
ciphertext = public_key.encrypt(
plaintext.encode('utf-8'),
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return ciphertext
def decrypt_with_private_key(ciphertext, private_key):
"""Decrypt data with private key"""
plaintext = private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
return plaintext.decode('utf-8')
# Generate keys
private_key, public_key = generate_rsa_keys()
# Encrypt with public key
plaintext = "Secret message"
ciphertext = encrypt_with_public_key(plaintext, public_key)
print(f"Encrypted: {ciphertext[:50]}...")
# Decrypt with private key
decrypted = decrypt_with_private_key(ciphertext, private_key)
print(f"Decrypted: {decrypted}")
print(f"Match: {plaintext == decrypted}") # True
Use Cases for Encryption
- Data confidentiality: Protect sensitive data at rest
- Secure communication: Encrypt data in transit
- Password-protected files: Encrypt files with a password
- API keys: Encrypt sensitive configuration
Signing: Authentication and Non-Repudiation
What is Signing?
Digital signing proves that data came from a specific source and hasn’t been modified. It uses asymmetric cryptography but for a different purpose than encryption.
The signer uses their private key to create a signature. Others verify the signature using the signer’s public key.
How Signing Works
Data + Private Key โ Sign โ Signature
Data + Signature + Public Key โ Verify โ Valid/Invalid
Signing in Python
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
def generate_signing_keys():
"""Generate keys for signing"""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
return private_key, public_key
def sign_data(data, private_key):
"""Sign data with private key"""
signature = private_key.sign(
data.encode('utf-8'),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
return signature
def verify_signature(data, signature, public_key):
"""Verify signature with public key"""
try:
public_key.verify(
signature,
data.encode('utf-8'),
padding.PSS(
mgf=padding.MGF1(hashes.SHA256()),
salt_length=padding.PSS.MAX_LENGTH
),
hashes.SHA256()
)
return True
except Exception:
return False
# Generate keys
private_key, public_key = generate_signing_keys()
# Sign data
data = "Important message"
signature = sign_data(data, private_key)
print(f"Signature: {signature[:50]}...")
# Verify signature
is_valid = verify_signature(data, signature, public_key)
print(f"Signature valid: {is_valid}") # True
# Verify fails if data is modified
is_valid = verify_signature("Modified message", signature, public_key)
print(f"Modified data valid: {is_valid}") # False
Use Cases for Signing
- Code signing: Verify software authenticity
- Document signing: Prove document origin and integrity
- API authentication: Sign API requests
- Certificate verification: Verify SSL/TLS certificates
Comparison: When to Use Each
| Technique | Purpose | Reversible | Use Case |
|---|---|---|---|
| Hashing | Integrity, authentication | No | Passwords, checksums |
| Encryption | Confidentiality | Yes | Protecting data |
| Signing | Authentication, non-repudiation | N/A | Verifying origin |
Decision Tree
Do you need to recover the original data?
โโ Yes โ Use Encryption
โ โโ Do you need to share the key?
โ โ โโ No โ Use Symmetric (AES, Fernet)
โ โ โโ Yes โ Use Asymmetric (RSA)
โ
โโ No โ Use Hashing or Signing
โโ Do you need to verify origin?
โ โโ Yes โ Use Signing
โ โโ No โ Use Hashing
Best Practices
1. Never Use MD5 or SHA-1 for Passwords
# Bad: MD5 is broken
import hashlib
password_hash = hashlib.md5(password.encode()).hexdigest()
# Good: Use bcrypt
import bcrypt
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
2. Store Keys Securely
# Bad: Hardcoded key
KEY = "my-secret-key-12345"
# Good: Load from environment
import os
KEY = os.environ.get('ENCRYPTION_KEY')
if not KEY:
raise ValueError("ENCRYPTION_KEY not set")
3. Use Appropriate Key Sizes
# RSA key size
# 2048 bits: Secure until 2030
# 4096 bits: Secure beyond 2030
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048, # Minimum recommended
)
4. Add Salt to Hashes
# Bad: No salt (vulnerable to rainbow tables)
hash_object = hashlib.sha256(password.encode())
# Good: Use bcrypt (includes salt)
hashed = bcrypt.hashpw(password.encode(), bcrypt.gensalt())
5. Use HTTPS for Transmission
# Encryption protects data at rest
# HTTPS protects data in transit
# Use both!
Installation
Install required libraries:
# For hashing and encryption
pip install cryptography
# For bcrypt (password hashing)
pip install bcrypt
Common Pitfalls
Pitfall 1: Confusing Hashing and Encryption
# Wrong: Trying to decrypt a hash
hashed = hashlib.sha256(password.encode()).hexdigest()
# Can't decrypt this!
# Right: Use encryption if you need to recover data
from cryptography.fernet import Fernet
encrypted = Fernet(key).encrypt(data.encode())
decrypted = Fernet(key).decrypt(encrypted).decode()
Pitfall 2: Reusing Encryption Keys
# Bad: Same key for different purposes
key = Fernet.generate_key()
cipher1 = Fernet(key) # Encrypt user data
cipher2 = Fernet(key) # Encrypt API keys
# Good: Different keys for different purposes
user_key = Fernet.generate_key()
api_key = Fernet.generate_key()
Pitfall 3: Ignoring Timing Attacks
# Bad: Simple string comparison (vulnerable to timing attacks)
if user_input == stored_hash:
authenticate()
# Good: Use constant-time comparison
import hmac
if hmac.compare_digest(user_input, stored_hash):
authenticate()
Conclusion
Cryptography is essential for modern security. Understanding the differences between hashing, encryption, and signing enables you to choose the right tool for each situation.
Key takeaways:
- Hashing is one-way; use for passwords and integrity checks
- Encryption is two-way; use to protect confidential data
- Signing proves origin and integrity; use for authentication
- Use bcrypt for password hashing, not SHA-256
- Use Fernet for symmetric encryption, RSA for asymmetric
- Store keys securely, never hardcode them
- Use HTTPS for data in transit
- Test thoroughly with security in mind
Cryptography isn’t magicโit’s mathematics. By understanding these concepts and implementing them correctly, you build secure applications that protect user data and maintain trust.
Start implementing these patterns in your projects today. Your users will be safer for it.
Comments