Encryption Fundamentals
Learn symmetric and asymmetric encryption, understand common algorithms like AES and RSA, and master key management best practices.
Encryption transforms readable data (plaintext) into unreadable data (ciphertext) that can only be reversed with the correct key. It's the primary tool for protecting data confidentiality—ensuring that even if data is intercepted or stolen, it remains useless to attackers.
There are two fundamental types of encryption: symmetric (one key for both encryption and decryption) and asymmetric (separate keys for encryption and decryption). Each has distinct use cases, and understanding when to use which is crucial for secure system design.
Symmetric Encryption
Symmetric encryption uses the same key for both encryption and decryption. It's fast and efficient, making it ideal for encrypting large amounts of data.
How It Works
Plaintext + Key → [Encryption Algorithm] → Ciphertext
Ciphertext + Key → [Decryption Algorithm] → Plaintext
The security depends entirely on keeping the key secret. If an attacker obtains the key, they can decrypt all data encrypted with it.
Common Symmetric Algorithms
| Algorithm | Key Size | Status | Use Case |
|---|---|---|---|
| AES-256 | 256 bits | ✅ Recommended | General purpose, government standard |
| AES-128 | 128 bits | ✅ Secure | Faster than AES-256, still very secure |
| ChaCha20 | 256 bits | ✅ Recommended | Mobile/embedded, software implementations |
| 3DES | 168 bits | ⚠️ Deprecated | Legacy systems only |
| DES | 56 bits | ❌ Broken | Never use |
| RC4 | Variable | ❌ Broken | Never use |
AES (Advanced Encryption Standard)
AES is the gold standard for symmetric encryption. Adopted by NIST in 2001, it's used everywhere—from HTTPS to disk encryption to cloud storage.
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
import os
# High-level API (recommended for most use cases)
def encrypt_with_fernet(plaintext: bytes) -> tuple[bytes, bytes]:
"""Encrypt data using Fernet (AES-128-CBC + HMAC)."""
key = Fernet.generate_key()
f = Fernet(key)
ciphertext = f.encrypt(plaintext)
return ciphertext, key
def decrypt_with_fernet(ciphertext: bytes, key: bytes) -> bytes:
"""Decrypt Fernet-encrypted data."""
f = Fernet(key)
return f.decrypt(ciphertext)
# Example usage
message = b"Sensitive configuration data"
encrypted, key = encrypt_with_fernet(message)
decrypted = decrypt_with_fernet(encrypted, key)
assert decrypted == message
Block Cipher Modes
AES is a block cipher—it encrypts fixed-size blocks (128 bits). To encrypt data larger than one block, you need a mode of operation:
| Mode | Description | Use Case |
|---|---|---|
| GCM | Galois/Counter Mode | ✅ Recommended — authenticated encryption |
| CBC | Cipher Block Chaining | Common but requires separate MAC |
| CTR | Counter Mode | Parallelizable, needs MAC |
| ECB | Electronic Codebook | ❌ Never use — reveals patterns |
Always use authenticated encryption (like GCM) which provides both confidentiality and integrity:
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
import os
def encrypt_aes_gcm(plaintext: bytes, associated_data: bytes = b"") -> tuple[bytes, bytes, bytes]:
"""
Encrypt using AES-256-GCM (authenticated encryption).
Returns: (ciphertext, key, nonce)
"""
key = AESGCM.generate_key(bit_length=256)
aesgcm = AESGCM(key)
# Nonce must be unique for each encryption with the same key
# 96 bits (12 bytes) is recommended for GCM
nonce = os.urandom(12)
# Associated data is authenticated but not encrypted
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
return ciphertext, key, nonce
def decrypt_aes_gcm(ciphertext: bytes, key: bytes, nonce: bytes,
associated_data: bytes = b"") -> bytes:
"""Decrypt AES-256-GCM ciphertext."""
aesgcm = AESGCM(key)
return aesgcm.decrypt(nonce, ciphertext, associated_data)
The ECB Problem
ECB mode encrypts each block independently, which reveals patterns in the plaintext:
Original Image: ECB Encrypted: CBC/GCM Encrypted:
███░░░███ ▓▓▓░░░▓▓▓ ▒█░▓▒█░▓▒
█████████ ▓▓▓▓▓▓▓▓▓ ░▓█▒░▓█▒░
███░░░███ ▓▓▓░░░▓▓▓ ▓░▒█▓░▒█▓
(Pattern visible) (Pattern visible) (Random - secure)
This is why you should never use ECB for real data.
Asymmetric Encryption
Asymmetric encryption uses a key pair: a public key (shared openly) and a private key (kept secret). Data encrypted with the public key can only be decrypted with the private key, and vice versa.
How It Works
Encryption: Plaintext + Public Key → Ciphertext
Decryption: Ciphertext + Private Key → Plaintext
Signing: Message + Private Key → Signature
Verification: Message + Signature + Public Key → Valid/Invalid
This solves the key distribution problem—you can share your public key with anyone without compromising security.
Common Asymmetric Algorithms
| Algorithm | Key Size | Status | Use Case |
|---|---|---|---|
| RSA | 2048+ bits | ✅ Secure | Encryption, signatures, key exchange |
| ECDSA | 256+ bits | ✅ Recommended | Digital signatures (smaller, faster) |
| Ed25519 | 256 bits | ✅ Recommended | Signatures (modern, fast) |
| ECDH | 256+ bits | ✅ Recommended | Key exchange |
| DSA | 2048+ bits | ⚠️ Deprecated | Legacy signatures only |
RSA Example
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes, serialization
def generate_rsa_keypair():
"""Generate a 2048-bit RSA key pair."""
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()
return private_key, public_key
def rsa_encrypt(plaintext: bytes, public_key) -> bytes:
"""Encrypt data with RSA public key."""
return public_key.encrypt(
plaintext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
def rsa_decrypt(ciphertext: bytes, private_key) -> bytes:
"""Decrypt data with RSA private key."""
return private_key.decrypt(
ciphertext,
padding.OAEP(
mgf=padding.MGF1(algorithm=hashes.SHA256()),
algorithm=hashes.SHA256(),
label=None
)
)
# Example usage
private_key, public_key = generate_rsa_keypair()
message = b"Secret message for recipient"
encrypted = rsa_encrypt(message, public_key)
decrypted = rsa_decrypt(encrypted, private_key)
assert decrypted == message
Digital Signatures with Ed25519
Ed25519 is a modern signature algorithm that's faster and more secure than RSA signatures:
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
def generate_signing_keypair():
"""Generate Ed25519 signing key pair."""
private_key = Ed25519PrivateKey.generate()
public_key = private_key.public_key()
return private_key, public_key
def sign_message(message: bytes, private_key) -> bytes:
"""Sign a message with Ed25519 private key."""
return private_key.sign(message)
def verify_signature(message: bytes, signature: bytes, public_key) -> bool:
"""Verify an Ed25519 signature."""
try:
public_key.verify(signature, message)
return True
except Exception:
return False
# Example: Signing a container image digest
private_key, public_key = generate_signing_keypair()
image_digest = b"sha256:abc123def456..."
signature = sign_message(image_digest, private_key)
# Anyone with the public key can verify
is_valid = verify_signature(image_digest, signature, public_key)
print(f"Signature valid: {is_valid}") # True
Symmetric vs Asymmetric: When to Use Which
| Aspect | Symmetric | Asymmetric |
|---|---|---|
| Speed | Fast (1000x+) | Slow |
| Key Management | Complex (shared secret) | Easier (public/private) |
| Key Size | 128-256 bits | 2048+ bits |
| Use Case | Bulk data encryption | Key exchange, signatures |
Hybrid Encryption
In practice, systems use hybrid encryption: asymmetric encryption to exchange a symmetric key, then symmetric encryption for the actual data.
1. Alice generates random AES key (symmetric)
2. Alice encrypts data with AES key
3. Alice encrypts AES key with Bob's RSA public key (asymmetric)
4. Alice sends: encrypted data + encrypted AES key
5. Bob decrypts AES key with his RSA private key
6. Bob decrypts data with AES key
This is exactly how TLS works (covered in Part 3).
Key Management Best Practices
Encryption is only as strong as your key management. Here are essential practices:
1. Never Hardcode Keys
# ❌ BAD: Key in source code
ENCRYPTION_KEY = "super_secret_key_123"
# ✅ GOOD: Key from environment/secrets manager
import os
ENCRYPTION_KEY = os.environ.get('ENCRYPTION_KEY')
# ✅ BETTER: Use a secrets manager
from aws_secretsmanager import get_secret
ENCRYPTION_KEY = get_secret('app/encryption-key')
2. Use Key Derivation Functions (KDFs)
When deriving keys from passwords, use a proper KDF:
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
import os
import base64
def derive_key_from_password(password: str, salt: bytes = None) -> tuple[bytes, bytes]:
"""Derive a secure encryption key from a password."""
if salt is None:
salt = os.urandom(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32, # 256 bits for AES-256
salt=salt,
iterations=600000, # OWASP recommended minimum
)
key = kdf.derive(password.encode())
return key, salt
# Store the salt alongside encrypted data
password = "user_master_password"
key, salt = derive_key_from_password(password)
3. Rotate Keys Regularly
# Example: AWS KMS key rotation policy
Resources:
EncryptionKey:
Type: AWS::KMS::Key
Properties:
EnableKeyRotation: true # Automatic annual rotation
KeyPolicy:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root'
Action: 'kms:*'
Resource: '*'
4. Use Hardware Security Modules (HSMs) for High-Value Keys
For production systems handling sensitive data:
- AWS KMS / Cloud KMS / Azure Key Vault for cloud workloads
- HashiCorp Vault for multi-cloud or on-premises
- Hardware HSMs (Luna, nCipher) for highest security requirements
Common Encryption Mistakes
1. Using Encryption Without Authentication
# ❌ BAD: AES-CBC without MAC (vulnerable to padding oracle attacks)
cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
# ✅ GOOD: AES-GCM (authenticated encryption)
aesgcm = AESGCM(key)
ciphertext = aesgcm.encrypt(nonce, plaintext, associated_data)
2. Reusing Nonces/IVs
# ❌ BAD: Static nonce
NONCE = b"static_nonce" # Catastrophic for GCM!
# ✅ GOOD: Random nonce for each encryption
nonce = os.urandom(12)
3. Weak Key Generation
# ❌ BAD: Predictable key
import random
key = bytes([random.randint(0, 255) for _ in range(32)])
# ✅ GOOD: Cryptographically secure random
import os
key = os.urandom(32)
# ✅ ALSO GOOD: Library-provided generation
from cryptography.fernet import Fernet
key = Fernet.generate_key()
Encryption in DevOps Contexts
Encrypting Secrets in CI/CD
# GitHub Actions: Using encrypted secrets
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy with encrypted credentials
env:
DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # Decrypted at runtime
run: ./deploy.sh
Encrypting Data at Rest
# AWS S3 bucket with server-side encryption
aws s3 cp sensitive-file.txt s3://my-bucket/ \
--sse aws:kms \
--sse-kms-key-id alias/my-key
# Kubernetes secrets encryption at rest
# In kube-apiserver configuration:
--encryption-provider-config=/etc/kubernetes/encryption-config.yaml
SOPS for GitOps
# Encrypted with SOPS (secrets shown in plaintext for example)
# Actual file would have encrypted values
database:
password: ENC[AES256_GCM,data:abc123...]
host: ENC[AES256_GCM,data:def456...]
sops:
kms:
- arn:aws:kms:us-east-1:123456789:key/abc-123
encrypted_suffix: _encrypted
Summary
Key takeaways for encryption:
- Symmetric encryption (AES-256-GCM) for bulk data—fast but requires secure key exchange
- Asymmetric encryption (RSA, ECDSA) for key exchange and signatures—solves distribution problem
- Always use authenticated encryption (GCM mode)—never encrypt without integrity protection
- Never reuse nonces/IVs—catastrophic for stream ciphers and GCM
- Use proper key management—secrets managers, KDFs for passwords, regular rotation
- Avoid deprecated algorithms—no DES, 3DES, RC4, or ECB mode
In the next section, we'll explore hashing—one-way functions used for integrity verification and password storage.
Found an issue?