The Foundations of Secrecy#

Encryption converts readable data (plaintext) into unreadable data (ciphertext) using an algorithm and a key. The algorithm is public; the key is the only secret. That is the whole design principle (Kerckhoffs's principle): if your security depends on nobody knowing how you encrypt, you do not have encryption, you have obscurity.

Encryption gives you confidentiality. On its own it does not prove who sent the data or that it was not modified, which is why modern schemes bundle an integrity check with it (authenticated encryption, covered below) and why digital signatures exist as a separate tool. It is also different from hashing: encryption is meant to be reversed by whoever holds the key, a hash is not (see hashing vs encoding).

There are two families: symmetric and asymmetric.

1. Symmetric Encryption#

In symmetric encryption, the same key encrypts and decrypts. It must stay secret between everyone who needs to read the data.

  • Speed: Very fast. Modern CPUs have AES instructions built in, so encrypting gigabytes (a disk, a video stream, a database backup) is cheap.
  • The standard: AES (Advanced Encryption Standard) with 128- or 256-bit keys. ChaCha20-Poly1305 is the common alternative, popular on devices without AES hardware support.
  • The problem: key distribution. If Alice and Bob have never met, how do they agree on a secret key over a channel an attacker is watching?

The simplest safe API in Python is Fernet from the cryptography package. It combines AES-128 in CBC mode with an HMAC-SHA256 tag, a random IV, and a timestamp, so you cannot accidentally forget the integrity check:

from cryptography.fernet import Fernet, InvalidToken

key = Fernet.generate_key()          # 32 random bytes, base64url-encoded
f = Fernet(key)

token = f.encrypt(b"Top Secret Mission Data")
print(token[:20])                    # b'gAAAAAB...' - version byte + timestamp

print(f.decrypt(token).decode())     # Top Secret Mission Data

# Tamper with one character and decryption refuses to return anything
bad = token[:-5] + (b"A" if token[-5:-4] != b"A" else b"B") + token[-4:]
try:
    f.decrypt(bad)
except InvalidToken:
    print("tampered token rejected")

Run it twice and the token differs each time even for the same plaintext, because a fresh random IV is used. That is a feature: identical ciphertexts would tell an eavesdropper that two messages are identical. Fernet's decrypt also accepts a ttl= argument (seconds), which rejects tokens older than that age, handy for short-lived links.

2. Asymmetric Encryption (Public-Key Cryptography)#

Asymmetric encryption solves key distribution with a key pair:

  1. Public key: shared openly. Anyone can use it to encrypt a message to you.
  2. Private key: kept secret. Only it can decrypt what was encrypted to the public key.

If Alice wants to send Bob a secret, she encrypts with Bob's public key. Only Bob's private key can decrypt it; Alice herself cannot undo it. (Signatures use the same key pair the other way round: the private key signs and the public key verifies.)

  • Speed: far slower than AES and limited in how much it can encrypt in one operation.
  • The standards: RSA (use 2048 bits minimum, 3072+ for new long-lived keys) with OAEP padding, and elliptic-curve schemes (ECC, such as X25519 for key agreement).
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes

private_key = rsa.generate_private_key(public_exponent=65537, key_size=3072)
public_key = private_key.public_key()

oaep = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()),
                    algorithm=hashes.SHA256(), label=None)

ct = public_key.encrypt(b"meet at noon", oaep)
print(len(ct))                                   # 384 - always the key size in bytes
print(private_key.decrypt(ct, oaep))             # b'meet at noon'

The size limit is real: with a 3072-bit key and OAEP-SHA256, the maximum plaintext is 318 bytes (key bytes minus two hash lengths minus two). Try to encrypt a 1 MB file and you get a ValueError. Never use "textbook" RSA or the old PKCS#1 v1.5 encryption padding in new code; both have practical attacks.

Hybrid Encryption: How Real Systems Combine Them#

Because symmetric is fast but hard to share and asymmetric is easy to share but slow and size-limited, real systems use both. The pattern is called hybrid or envelope encryption: generate a random symmetric key per message or file, encrypt the data with it, then encrypt (wrap) only that small key with the recipient's public key.

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM

def seal(public_key, data: bytes) -> tuple[bytes, bytes, bytes]:
    data_key = AESGCM.generate_key(bit_length=256)
    nonce = os.urandom(12)
    ciphertext = AESGCM(data_key).encrypt(nonce, data, None)
    wrapped_key = public_key.encrypt(data_key, oaep)   # 32 bytes fits easily
    return wrapped_key, nonce, ciphertext

def open_sealed(private_key, wrapped_key, nonce, ciphertext) -> bytes:
    data_key = private_key.decrypt(wrapped_key, oaep)
    return AESGCM(data_key).decrypt(nonce, ciphertext, None)

w, n, c = seal(public_key, b"x" * 1_000_000)
print(len(open_sealed(private_key, w, n, c)))          # 1000000

PGP, S/MIME and cloud key-management services (AWS KMS, Google Cloud KMS) all work this way. HTTPS uses a variant: in TLS 1.3 the browser and server run an ephemeral Diffie-Hellman key agreement, so both sides derive the same symmetric key without either one sending it, and the server's certificate key is used only to sign the handshake. The TLS lesson walks through it.

Modes of Operation: Where Most Bugs Live#

AES encrypts one 16-byte block at a time. How you chain blocks together (the mode) decides whether the result is actually secure.

Mode Integrity built in? Notes
ECB No Identical plaintext blocks give identical ciphertext blocks. The famous "ECB penguin" image stays recognisable after encryption. Never use.
CBC No Needs a random, unpredictable IV. Without a separate MAC it is open to padding-oracle attacks.
CTR No Turns AES into a stream cipher. Reusing a nonce leaks the XOR of two plaintexts.
GCM Yes (AEAD) Modern default. 12-byte nonce, 16-byte tag. Preferred in TLS 1.3.
ChaCha20-Poly1305 Yes (AEAD) Same role as GCM, fast in software.

AEAD (authenticated encryption with associated data) means decryption fails loudly if even one bit of the ciphertext, the tag, or the "associated data" changed. Associated data is information you do not need to hide but want bound to the ciphertext, such as a record ID or a header, so an attacker cannot swap one user's encrypted field into another user's row:

import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag

key = AESGCM.generate_key(bit_length=256)
aead = AESGCM(key)

def encrypt_field(plaintext: bytes, record_id: str) -> bytes:
    nonce = os.urandom(12)                         # never reuse with the same key
    return nonce + aead.encrypt(nonce, plaintext, record_id.encode())

def decrypt_field(blob: bytes, record_id: str) -> bytes:
    return aead.decrypt(blob[:12], blob[12:], record_id.encode())

blob = encrypt_field(b"4111-1111-1111-1111", "user:42")
print(decrypt_field(blob, "user:42"))              # works
try:
    decrypt_field(blob, "user:43")                 # ciphertext copied to another row
except InvalidTag:
    print("rejected: bound to a different record")

Storing the nonce in front of the ciphertext is standard; the nonce is not secret, it just must be unique per key.

How Encryption Fails in Practice#

The algorithms are rarely broken. The usage is. The mistakes you will actually meet in code review:

  • Nonce reuse with GCM or CTR. Two messages encrypted with the same key and nonce leak the XOR of their plaintexts, and with GCM an attacker can also forge tags. Use random 12-byte nonces from os.urandom and rotate keys well before you have encrypted billions of messages under one key, or use a counter you can guarantee never repeats.
  • Using a password as the key. AESGCM(b"hunter2".ljust(32)) is guessable at GPU speed. Run the password through a slow key derivation function with a random salt first.
  • Weak randomness. Python's random module is predictable. Use os.urandom or the secrets module for keys, IVs and nonces.
  • Encryption without authentication. Hand-built AES-CBC without a MAC lets attackers modify ciphertext and, through error messages, sometimes decrypt it.
  • Hardcoded keys. A key committed to Git is published. Rotating it is the only fix, and old data encrypted under it must be re-encrypted.
  • Rolling your own. XOR "encryption", custom block chaining, or homemade padding. Use a vetted high-level API (Fernet, AESGCM, libsodium) and stop.

Deriving a key from a password correctly looks like this:

import os
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt

salt = os.urandom(16)                      # store next to the ciphertext
kdf = Scrypt(salt=salt, length=32, n=2**14, r=8, p=1)
key = kdf.derive(b"correct horse battery staple")
print(len(key))                            # 32 bytes -> usable as an AES-256 key

To debug a suspected misuse, grep the codebase for MODE_ECB, modes.ECB, random., fixed byte strings passed to cipher constructors, and any nonce or IV that is a constant.

Key Management Is the Real Battlefield#

Encryption moves the problem from "protect the data" to "protect the key". Keys that ship in source code, sit on the same disk as the data, or live in environment files copied between laptops protect nothing. Production systems:

  • Keep master keys in a KMS or HSM (hardware security module) that performs operations inside tamper-resistant hardware and never exports the key. The application sends a wrapped data key and gets back the unwrapped one, and every call is logged.
  • Use envelope encryption: a key-encryption key in the KMS wraps many data keys stored next to the data. Rotating the master key means re-wrapping small data keys, not re-encrypting terabytes.
  • Rotate on a schedule and after any suspected exposure. Fernet supports this directly: MultiFernet([Fernet(new_key), Fernet(old_key)]) decrypts with either key, and its rotate(token) method re-encrypts a token under the newest key.
  • Keep encryption keys and signing keys separate, and give each service only the keys it needs. The secrets management lesson covers storage and distribution.

Encrypting at Rest: Which Layer Protects Against What#

Layer Example Protects against Does not protect against
Full-disk BitLocker, LUKS, FileVault Stolen laptop or drive Anyone logged in or malware on the running machine
Storage / database Cloud volume encryption, database TDE Stolen backups, disks, snapshots A compromised app or SQL injection (the database decrypts for them)
Application-level AESGCM on specific fields before insert Database dumps, over-privileged DBAs A compromised app server that holds the key

Each layer sits in front of a different attacker, which is why sensitive systems stack them rather than choosing one.

Practice#

Take the encrypt_field example and build a small script that encrypts a CSV column of fake card numbers with the row ID as associated data. Then prove to yourself that swapping two encrypted cells between rows fails with InvalidTag, and that reusing a fixed nonce for two different plaintexts lets you XOR the two ciphertexts to get the XOR of the plaintexts.