Skip to main content
โšก Calmops

Cryptography in Python: Hashing, Encryption, and Signing Explained

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