Cryptography is the mathematical foundation of information security. Every TLS connection, every encrypted database field, every password hash, every digital signature relies on cryptographic primitives. You don't need to be a mathematician to work in security, but you do need to understand what these tools do, when to use them, and — crucially — how to avoid misusing them.
Symmetric Encryption: AES
AES (Advanced Encryption Standard) is the dominant symmetric cipher — both parties share the same key for encryption and decryption.
Key sizes: AES-128, AES-192, AES-256. The number indicates the key length in bits. AES-256 is considered computationally infeasible to brute-force with any foreseeable technology.
Modes of operation matter enormously. The cipher alone isn't enough — you need to decide how blocks of data are chained together:
-
ECB (Electronic Codebook): Each block encrypted independently. Never use this. Identical plaintext blocks produce identical ciphertext blocks, leaking structural information. Famous example: ECB-encrypted images are still visually recognizable.
-
CBC (Cipher Block Chaining): Each block XORed with the previous ciphertext block before encryption. Requires a random IV (Initialization Vector). Good choice for data at rest when implemented correctly. IV must be random and unique per encryption operation.
-
GCM (Galois/Counter Mode): Authenticated encryption — provides both confidentiality and integrity/authentication in a single operation. The preferred mode for modern applications. Produces a ciphertext AND an authentication tag.
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
import base64
def encrypt_aes_gcm(plaintext: bytes, key: bytes) -> dict:
"""Encrypt using AES-256-GCM (authenticated encryption)"""
nonce = get_random_bytes(16) # Random nonce per encryption
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
ciphertext, tag = cipher.encrypt_and_digest(plaintext)
return {
'ciphertext': base64.b64encode(ciphertext).decode(),
'nonce': base64.b64encode(nonce).decode(),
'tag': base64.b64encode(tag).decode()
}
def decrypt_aes_gcm(encrypted: dict, key: bytes) -> bytes:
"""Decrypt and verify authentication tag"""
nonce = base64.b64decode(encrypted['nonce'])
ciphertext = base64.b64decode(encrypted['ciphertext'])
tag = base64.b64decode(encrypted['tag'])
cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
return cipher.decrypt_and_verify(ciphertext, tag) # Raises exception if tampered
Asymmetric Encryption: RSA and Elliptic Curves
Asymmetric cryptography uses a key pair: a public key (shareable with anyone) and a private key (kept secret). Data encrypted with the public key can only be decrypted with the private key.
RSA: The classic asymmetric algorithm. Security relies on the difficulty of factoring large numbers. Minimum 2048-bit keys today; 4096-bit for long-term security. RSA is primarily used for key exchange and digital signatures — it's too slow for bulk data encryption.
Elliptic Curve Cryptography (ECC): Achieves equivalent security with much smaller keys. A 256-bit ECC key provides roughly the same security as a 3072-bit RSA key. Preferred for modern systems due to performance advantages.
ECDSA (Elliptic Curve Digital Signature Algorithm) is used to sign data — proving both authenticity (who sent it) and integrity (it wasn't modified).
A key insight: you should almost never use asymmetric encryption to encrypt bulk data directly. RSA encrypting a large file directly is slow and has size limitations. Instead, use hybrid encryption: encrypt the data with AES (fast), then encrypt the AES key with RSA (asymmetric). TLS does exactly this.
Hashing: One-Way Functions
A hash function takes arbitrary input and produces a fixed-length output (digest). Good hash functions are:
- Deterministic: Same input always produces same output
- One-way: Cannot recover input from output
- Collision-resistant: Hard to find two inputs producing the same output
- Avalanche: Small input change → dramatically different output
SHA-256: Part of the SHA-2 family. 256-bit output. Cryptographically secure and widely used for data integrity verification, digital signatures, and certificate fingerprints.
SHA-3: The newest SHA standard, based on a completely different internal structure (Keccak sponge construction). Not more widely deployed than SHA-256 but a good alternative.
MD5 and SHA-1: Broken. Do not use for security purposes. Both have known collision vulnerabilities — two different inputs can produce the same hash. Still commonly seen in legacy systems; when you encounter them, they should be flagged for replacement.
Password Hashing: Not the Same as Data Hashing
Here's a critical distinction that's often misunderstood: you should NOT use SHA-256 to hash passwords. SHA-256 is designed to be fast — exactly what you don't want for password hashing. A GPU can compute billions of SHA-256 hashes per second, enabling rapid brute-force attacks.
Password hashing algorithms are intentionally slow and memory-intensive:
bcrypt: Adaptive — you can increase the cost factor as hardware gets faster. The de facto standard for password hashing for many years.
Argon2: The winner of the Password Hashing Competition (2015). Configurable memory hardness, parallelism, and iteration count. Recommended for new systems.
scrypt: Memory-hard, predates Argon2. Used by many cryptocurrency systems.
import bcrypt
import argon2
# bcrypt
password = b"user_password_here"
hashed = bcrypt.hashpw(password, bcrypt.gensalt(rounds=12))
is_valid = bcrypt.checkpw(password, hashed)
# Argon2 (preferred for new systems)
ph = argon2.PasswordHasher(time_cost=2, memory_cost=65536, parallelism=2)
hashed = ph.hash("user_password_here")
is_valid = ph.verify(hashed, "user_password_here")
Key Derivation Functions (KDF)
KDFs derive cryptographic keys from a source of entropy (often a password or a master key). I worked with HKDF extensively in my eCTF firmware work:
HKDF (HMAC-based Key Derivation Function): Two-phase process — extract (produce a uniform pseudorandom key from input) and expand (derive purpose-specific keys). This is how you safely generate multiple distinct keys from a single master secret.
PBKDF2: Password-Based Key Derivation Function. Takes a password, a salt, and an iteration count. Used in WPA2 Wi-Fi security and many encryption tools.
The Golden Rules
After all of this, here are the rules that matter most:
-
Don't implement your own cryptographic algorithms. Use well-vetted libraries (libsodium, OpenSSL, Python's cryptography package).
-
Don't use deprecated algorithms. No MD5, no SHA-1, no DES, no RC4.
-
Use authenticated encryption. AES-GCM over AES-CBC for new implementations.
-
Random values must be cryptographically random. Use
os.urandom(),secrets, or equivalent — notrandom.random(). -
Never hardcode keys. Keys in source code end up in version control, logs, and error messages.
-
Separate keys by purpose. Don't use the same key for encryption and authentication.
-
Plan for key rotation. Cryptographic keys have lifetimes. Build your system to support rotation without service disruption.
Cryptography is a tool. Like any tool, its effectiveness depends on using it correctly. The library will compute AES-GCM correctly; it's your job to use the right mode, generate random IVs, and store keys securely.
Thanks for reading