0.3 Symmetric encryption
One shared secret key, used to lock and unlock data: from the one-time pad to AES-GCM.
What encryption is for
You want to send or store data so that only people holding a secret can read it. In symmetric encryption the sender and the receiver share the same secret key . Encryption turns a plaintext into a ciphertext , and decryption turns it back:
The ciphertext can travel over any network or sit on any disk. Without it should reveal nothing about except its length. Encoding, such as hex or base64, is different: it changes the format of data but anyone can reverse it, because there is no key.
Kerckhoffs’s principle
A cipher must stay secure even if everything about it is public except the key. AES, ChaCha20 and every modern cipher are published, analysed for years, and standardized. Security comes from the key alone, which is why keys must be long, random and kept secret, and why home-made “secret algorithms” are a warning sign rather than a protection.
XOR and the one-time pad
The simplest cipher uses XOR (exclusive or, written ) on bits: the result is 1 when the two input bits differ and 0 when they are equal. XOR has one property that makes it perfect for encryption: applying the same key twice undoes it.
In a one-time pad, the key is a string of truly random bytes as long as the message. The ciphertext is . Claude Shannon proved in 1949 that this is perfectly secret: every plaintext of the same length is equally likely given the ciphertext, so even unlimited computing power learns nothing. The catch is in the conditions. The key must be as long as all the data you will ever send, it must be truly random, and it must never be used twice.
Here is what goes wrong when a pad is reused. XOR the two ciphertexts, and the key disappears:
p1 = b"MEET AT NOON"
p2 = b"MEET AT FIVE"
key = bytes.fromhex("3a91c4077e5b12d8f06a2c9e") # 12 random bytes, used twice (the mistake)
xor = lambda a, b: bytes(x ^ y for x, y in zip(a, b))
c1, c2 = xor(p1, key), xor(p2, key)
print(c1.hex())
print(c2.hex())
print(xor(c1, c2) == xor(p1, p2)) # the key cancels out
print(xor(c1, c2).hex())Output:
77d481535e1a46f8be2563d0
77d481535e1a46f8b6237adb
True
00000000000000000806190bThe first eight bytes of are zero, which tells anyone watching that both messages start with the same eight characters. With a little guessing (“MEET AT …”), both messages fall out. This is the single most important lesson of the one-time pad, and it returns with every modern cipher as the rule that a nonce must never repeat.
Block ciphers: AES
Carrying a key as long as your data is impractical, so real systems use a short key, typically 128 or 256 bits, and a block cipher that stretches it. AES (the Advanced Encryption Standard, FIPS 197) is the block cipher almost everything uses. It takes a 16-byte (128-bit) block and a 128-, 192- or 256-bit key and produces a 16-byte ciphertext block. With a fixed key it behaves like a secret shuffle of all possible blocks: without the key the output looks random, and with it the shuffle can be undone.
Inside, AES repeats a round of byte substitution, row shifting, column mixing and key mixing 10, 12 or 14 times, depending on the key size. You never need to implement it: modern CPUs have dedicated AES instructions, and every mainstream language exposes it through a vetted library.
Why you never use raw blocks: modes of operation
A message is usually longer than 16 bytes. The naive approach, called ECB (electronic codebook), cuts the message into blocks and encrypts each one separately with the same key. The problem is that the same plaintext block always gives the same ciphertext block, so patterns survive encryption:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = os.urandom(16)
block = b"SIXTEEN BYTE BLK" # exactly one 16-byte AES block
msg = block * 3 # the same block three times
ecb = Cipher(algorithms.AES(key), modes.ECB()).encryptor()
ct = ecb.update(msg) + ecb.finalize()
chunks = [ct[i:i + 16].hex() for i in range(0, 48, 16)]
print(len(set(chunks)), "distinct ciphertext blocks out of 3") # 1: the pattern survives
ctr = Cipher(algorithms.AES(key), modes.CTR(os.urandom(16))).encryptor()
ct = ctr.update(msg) + ctr.finalize()
chunks = [ct[i:i + 16].hex() for i in range(0, 48, 16)]
print(len(set(chunks)), "distinct ciphertext blocks out of 3") # 3Output:
1 distinct ciphertext blocks out of 3
3 distinct ciphertext blocks out of 3A mode of operation fixes this by making every block’s encryption different. The two you will meet most often:
| Mode | How it works | Status |
|---|---|---|
| ECB | Each block encrypted on its own | Do not use: leaks patterns |
| CBC | Each plaintext block is XORed with the previous ciphertext block before encryption; needs a random IV and padding | Legacy; encryption only, no integrity |
| CTR | Encrypts a nonce plus a counter to make a keystream, then XORs it with the message, like a one-time pad | Good core, but no integrity on its own |
| GCM | CTR encryption plus a 128-bit authentication tag | Recommended (AEAD) |
CTR mode shows how the one-time pad idea survives in modern form. AES, keyed with , encrypts the values , , … to produce a long pseudo-random keystream, and the message is XORed with it. The key is short, but the keystream is as long as you need.
Nonces and IVs: public, but never repeated
A nonce (“number used once”) or IV (initialization vector) is the extra input that makes two encryptions of the same message under the same key look different. It is not secret: it is sent next to the ciphertext. But under one key it must be unique. In CTR and GCM a repeated nonce produces the same keystream twice, which is exactly the reused one-time pad from above. With GCM it also weakens the authentication.
For AES-GCM the nonce is 96 bits. Choosing it at random is fine as long as one key encrypts no more than about messages (NIST SP 800-38D); a counter that never resets is the other safe option. When in doubt, rotate keys well before that limit.
Stream ciphers: ChaCha20
A stream cipher generates a keystream directly instead of building one from a block cipher. ChaCha20 (RFC 8439) takes a 256-bit key and a 96-bit nonce and uses only additions, rotations and XORs, so it is fast and runs in constant time on phones and small devices that lack AES instructions. TLS 1.3, WireGuard and SSH all offer it.
Authenticated encryption
Encryption alone hides data, but it does not stop someone from changing it. Because CTR mode is just XOR, flipping a bit of the ciphertext flips the same bit of the plaintext. An attacker who knows the format of a message can change it without knowing the key:
import os
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key, nonce = os.urandom(16), os.urandom(16)
aes_ctr = Cipher(algorithms.AES(key), modes.CTR(nonce))
ct = bytearray(aes_ctr.encryptor().update(b"pay 100 EUR"))
ct[4] ^= ord("1") ^ ord("9") # change one byte without knowing the key
d = aes_ctr.decryptor()
print(d.update(bytes(ct)) + d.finalize()) # b'pay 900 EUR'This property is called malleability, and it has broken real protocols. The fix is authenticated encryption with associated data (AEAD). An AEAD scheme encrypts the message and also computes a short tag, a keyed checksum over the nonce, the ciphertext and any associated data: headers or metadata that must not be changed but do not need to be secret. Decryption recomputes the tag and refuses to return anything if it does not match. The tag is built from the same idea as a MAC.
The two AEADs to use
- AES-256-GCM: AES in CTR mode plus the GHASH authenticator. Fastest on hardware with AES instructions.
- ChaCha20-Poly1305: ChaCha20 plus the Poly1305 authenticator. Fast everywhere, simple to implement in constant time.
Both use a 256-bit key, a 96-bit nonce and a 128-bit tag.
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = AESGCM.generate_key(bit_length=256) # 32 random bytes; keep it secret
aead = AESGCM(key)
nonce = os.urandom(12) # 96-bit nonce: never reuse with the same key
header = b"invoice-42" # associated data: authenticated, not encrypted
ct = aead.encrypt(nonce, b"pay 100 EUR to Alice", header)
print(len(ct)) # 20-byte message + 16-byte tag = 36
print(aead.decrypt(nonce, ct, header)) # b'pay 100 EUR to Alice'
tampered = bytearray(ct)
tampered[4] ^= 0x01 # flip one bit of the ciphertext
try:
aead.decrypt(nonce, bytes(tampered), header)
except InvalidTag:
print("rejected: authentication tag does not match")Output:
36
b'pay 100 EUR to Alice'
rejected: authentication tag does not matchChaCha20-Poly1305 has the same interface:
import os
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
key = ChaCha20Poly1305.generate_key()
nonce = os.urandom(12)
box = ChaCha20Poly1305(key)
ct = box.encrypt(nonce, b"same API, different cipher", None)
print(box.decrypt(nonce, ct, None)) # b'same API, different cipher'Key sizes and quantum computers
A 128-bit key has possible values, far beyond any classical brute-force search. A quantum computer running Grover’s algorithm could search a key space of size in about steps. That is a square-root speed-up, not a break, and it is hard to run in parallel. NIST still uses a key search on AES-128 as the benchmark for its lowest post-quantum security category. For data that must stay secret for decades, the usual advice is AES-256, which keeps about 128 bits of security even against Grover. Public-key cryptography is affected far more severely; that story is in 0.7 Quantum computers and KEMs.
Common mistakes
- Reusing a nonce with the same key in CTR, GCM or ChaCha20. Use a counter or a fresh random nonce, and rotate keys.
- Using ECB, or any mode without authentication. Pick an AEAD.
- Using a password as the key. Passwords are not random enough; derive keys with a slow password hash (0.4).
- Treating base64 or hex as encryption. Encoding has no key; anyone can reverse it.
- Implementing AES yourself. Use a maintained library; hand-written code tends to leak timing information.
- Returning partial plaintext before the tag is checked. Only act on data after decryption succeeds.
Next
Encryption needs both sides to already share a key. The next page covers message authentication codes and how to derive keys safely: 0.4 MACs and key derivation. How two strangers agree on a key over an open network is the subject of 0.5 Public-key cryptography.
Further reading
- NIST FIPS 197, Advanced Encryption Standard (AES), and NIST SP 800-38D, Galois/Counter Mode (GCM) and GMAC.
- RFC 8439, ChaCha20 and Poly1305 for IETF Protocols.
- Jean-Philippe Aumasson, Serious Cryptography (No Starch Press), chapters on block ciphers, stream ciphers and authenticated encryption. Cited from memory.
Check Your Understanding
Q1. Two messages were encrypted with the same one-time-pad key. What can someone who sees both ciphertexts compute without knowing the key?
Q2. Why is ECB mode a bad choice even though it uses a strong cipher like AES?
Q3. An attacker flips a bit in an AES-GCM ciphertext and forwards it. What does the recipient’s decrypt call do?
Q4. Grover’s algorithm speeds up brute-force key search on a quantum computer. Roughly what is the effort to search a 256-bit AES key space with it?