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 kk. Encryption turns a plaintext mm into a ciphertext cc, and decryption turns it back:

c=Enck(m),m=Deck(c)c = \mathrm{Enc}_k(m), \qquad m = \mathrm{Dec}_k(c)

The ciphertext can travel over any network or sit on any disk. Without kk it should reveal nothing about mm 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 ⊕\oplus) 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.

(m⊕k)⊕k=m(m \oplus k) \oplus k = m

In a one-time pad, the key is a string of truly random bytes as long as the message. The ciphertext is c=m⊕kc = m \oplus k. 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:

c1⊕c2=(p1⊕k)⊕(p2⊕k)=p1⊕p2c_1 \oplus c_2 = (p_1 \oplus k) \oplus (p_2 \oplus k) = p_1 \oplus p_2
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
00000000000000000806190b

The first eight bytes of c1⊕c2c_1 \oplus c_2 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 21282^{128} 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")   # 3

Output:

1 distinct ciphertext blocks out of 3
3 distinct ciphertext blocks out of 3

A mode of operation fixes this by making every block’s encryption different. The two you will meet most often:

ModeHow it worksStatus
ECBEach block encrypted on its ownDo not use: leaks patterns
CBCEach plaintext block is XORed with the previous ciphertext block before encryption; needs a random IV and paddingLegacy; encryption only, no integrity
CTREncrypts a nonce plus a counter to make a keystream, then XORs it with the message, like a one-time padGood core, but no integrity on its own
GCMCTR encryption plus a 128-bit authentication tagRecommended (AEAD)

CTR mode shows how the one-time pad idea survives in modern form. AES, keyed with kk, encrypts the values nonce ∥ 1\text{nonce} \,\|\, 1, nonce ∥ 2\text{nonce} \,\|\, 2, … 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 2322^{32} 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 match

ChaCha20-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'
AES-GCM in your browser

Encrypt a message with a real AES-256-GCM key from Web Crypto, then tamper with the ciphertext or reuse the nonce.

key
256 bits
nonce
96 bits
ciphertext
—
tag
—

Generating a 256-bit key…

What to notice: The tag makes any change to the ciphertext or the associated data detectable, so a modified message is rejected instead of decrypting to something wrong. The nonce is not secret, but it must never repeat under one key: reusing it cancels the keystream and hands out the XOR of the two plaintexts.

Key sizes and quantum computers

A 128-bit key has 2128≈3.4×10382^{128} \approx 3.4 \times 10^{38} possible values, far beyond any classical brute-force search. A quantum computer running Grover’s algorithm could search a key space of size 2n2^n in about 2n/22^{n/2} 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

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

Check Your Understanding

  1. Q1. Two messages were encrypted with the same one-time-pad key. What can someone who sees both ciphertexts compute without knowing the key?

  2. Q2. Why is ECB mode a bad choice even though it uses a strong cipher like AES?

  3. Q3. An attacker flips a bit in an AES-GCM ciphertext and forwards it. What does the recipient’s decrypt call do?

  4. 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?