CMPUT-331 · Lesson 3 of 12 · 34 min

Symmetric cryptography: block ciphers, modes and authenticated encryption

Symmetric cryptography, where both parties share a secret key, does almost all of the bulk encryption in the world: your laptop's disk, the Wi-Fi link, every HTTPS connection after its handshake, messaging apps and cloud storage. It is fast, well understood and, when used correctly, extremely strong. It is also easy to misuse. This lesson explains how a block cipher works, why a block cipher alone is not an encryption scheme, how modes of operation turn it into one, and why modern systems insist on authenticated encryption. All examples use the Python cryptography library, which wraps OpenSSL, alongside small toy constructions that make the patterns visible.

CISSP-112 surveyed the algorithm landscape for managers, including key management policy and compliance. This lesson stays with the mechanics. The keys in the examples are fixed demonstration values so that the printed output is reproducible; real code generates keys with a cryptographically secure generator and never writes them into source files.

Stream ciphers and block ciphers

There are two families of symmetric ciphers. A stream cipher generates a long pseudorandom keystream from a short key and a nonce (a number used once), and XORs it with the plaintext, approximating a one-time pad with a short key. ChaCha20, designed by Daniel Bernstein, is the most widely used today; RC4, once ubiquitous in SSL and early Wi-Fi, is now prohibited because its keystream has measurable biases. A block cipher is a keyed permutation on fixed-size blocks: for each key, it maps every 128-bit input block to a distinct 128-bit output block, and without the key the mapping should be indistinguishable from a random permutation. The Advanced Encryption Standard (AES), Rijndael by Joan Daemen and Vincent Rijmen, won an open NIST competition and was standardized in 2001. It has a 128-bit block and 128-, 192- or 256-bit keys, and modern processors have dedicated AES instructions that encrypt several gigabytes per second per core.

Inside a block cipher: confusion and diffusion

Shannon identified two properties a good cipher needs. Confusion makes the relationship between the key and the ciphertext complex; diffusion spreads the influence of each plaintext bit across many ciphertext bits, so that statistical structure is dissipated. AES achieves both with a substitution–permutation network repeated for 10, 12 or 14 rounds depending on key size. Each round applies SubBytes (a fixed nonlinear substitution box on each byte, derived from inversion in the finite field with 256 elements), ShiftRows and MixColumns (linear mixing across the 4 by 4 byte state), and AddRoundKey (XOR with a round key derived from the main key). After a few rounds every output bit depends on every input bit and every key bit. The avalanche effect is easy to see: change one bit of input and about half of the 128 output bits change.

The avalanche effect: flipping one plaintext bit changes about half of AES's output bits
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = bytes(range(16))                              # demo key
def aes_block(block):
    e = Cipher(algorithms.AES(key), modes.ECB()).encryptor()   # one raw block, for illustration only
    return e.update(block) + e.finalize()
p1 = b"exam room 2-115."                            # exactly 16 bytes
p2 = bytes([p1[0] ^ 0x01]) + p1[1:]                 # differs in a single bit
c1, c2 = aes_block(p1), aes_block(p2)
diff = sum(bin(a ^ b).count("1") for a, b in zip(c1, c2))
print("c1:", c1.hex())
print("c2:", c2.hex())
print(f"one input bit changed -> {diff} of 128 output bits changed")
Output
c1: c1055ead3ae98780c613c9fe3c5f275f
c2: eb34d4f453a957eb33cb551ae64ebe32
one input bit changed -> 60 of 128 output bits changed

Older block ciphers used a Feistel network, in which each round splits the block in halves, applies a keyed function to one half and XORs the result into the other; DES and Blowfish are Feistel ciphers, and the structure is still used in some lightweight designs. Whatever the internal design, application code should never call the raw block operation directly, as the example above does only for illustration. A block cipher encrypts exactly one block; real messages are longer, and how the blocks are chained together makes the difference between strong and broken encryption.

Modes of operation

The simplest mode, electronic codebook (ECB), encrypts each block independently with the same key. ECB is deterministic: equal plaintext blocks always produce equal ciphertext blocks, so patterns in the plaintext survive. The famous demonstration encrypts a bitmap image of the Linux penguin mascot with ECB and shows that the outline remains clearly visible. Our toy version below uses a keyed hash as a stand-in for AES on 4-byte blocks, so the effect is easy to see in text.

ECB leaks the picture; counter mode hides it (toy cipher on 4-byte blocks)
import hashlib
# A toy "image": 8 rows of 32 pixels; each pixel is a byte (0 = background, 1 = logo)
art = ["................................",
       "....######..........######......",
       "...#......#........#......#.....",
       "...#......#........#......#.....",
       "...#......#........#......#.....",
       "....######..........######......",
       "................................",
       "................................"]
data = bytes(1 if ch == "#" else 0 for row in art for ch in row)
KEY = b"demo key - never hard-code real keys"
def block_encrypt(block):                       # stand-in for AES: a keyed pseudorandom function
    return hashlib.sha256(KEY + block).digest()[:len(block)]
def ecb(data, n=4):
    return b"".join(block_encrypt(data[i:i+n]) for i in range(0, len(data), n))
def ctr(data, nonce, n=4):
    out = bytearray()
    for i in range(0, len(data), n):
        pad = hashlib.sha256(KEY + nonce + (i // n).to_bytes(8, "big")).digest()[:n]
        out += bytes(a ^ b for a, b in zip(data[i:i+n], pad))
    return bytes(out)
def show(name, ct, n=4):
    symbols = " .:-=+*#%@"
    print(name)
    for r in range(8):
        row = ct[r*32:(r+1)*32]
        # draw each 4-byte block as one symbol chosen from its first byte
        print("   " + "".join(symbols[row[i] % len(symbols)] * n for i in range(0, 32, n)))
show("ECB: equal plaintext blocks give equal ciphertext blocks", ecb(data))
show("CTR: every block looks different", ctr(data, b"nonce-01"))
Output
ECB: equal plaintext blocks give equal ciphertext blocks
   @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
   @@@@----%%%%@@@@@@@@----%%%%@@@@
   @@@@@@@@****@@@@@@@@@@@@****@@@@
   @@@@@@@@****@@@@@@@@@@@@****@@@@
   @@@@@@@@****@@@@@@@@@@@@****@@@@
   @@@@----%%%%@@@@@@@@----%%%%@@@@
   @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
   @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
CTR: every block looks different
   ----        ::::%%%%::::++++    
       ####::::....++++::::****::::
   ====%%%%%%%%%%%%++++****%%%%....
   ....@@@@::::----%%%%    ....++++
   ::::++++....::::::::****----####
   ++++****%%%%....    ####%%%%@@@@
   @@@@====@@@@....    ----        
   ====****    ::::....::::####%%%%

Every other mode adds per-message randomness or state so that encrypting the same plaintext twice gives different ciphertexts, a property cryptographers call semantic security under chosen-plaintext attack. Cipher block chaining (CBC) XORs each plaintext block with the previous ciphertext block before encrypting, starting with a random initialization vector (IV); it needs padding to a whole number of blocks, which has caused a famous family of attacks. Counter mode (CTR) encrypts successive values of a nonce and counter to produce a keystream and XORs it with the plaintext, turning a block cipher into a stream cipher; it needs no padding and is parallelizable. Galois/Counter Mode (GCM) is CTR encryption plus a polynomial MAC over the ciphertext, giving authenticated encryption.

Two diagrams side by side. Left, CBC: each plaintext block is XORed with the previous ciphertext block, starting with an IV, then encrypted with the key. Right, CTR: the nonce and counter values 1, 2, 3 are each encrypted with the key to make keystream blocks, which are XORed with the plaintext blocks.
Cipher block chaining and counter mode; CTR turns a block cipher into a stream cipher

Why confidentiality is not enough

Encryption without integrity protection is dangerous because many modes are malleable: an attacker who cannot read the plaintext can still change it in predictable ways. In CTR mode, flipping a bit of ciphertext flips exactly the same bit of plaintext. If the attacker knows or guesses the message format, they can change a payment amount without knowing the key. The same program shows the other classic failure: reusing a nonce with the same key produces the same keystream, so XORing the two ciphertexts gives the XOR of the two plaintexts, exactly the two-time pad of Lesson 2.

Two failures of unauthenticated counter mode: bit-flipping and nonce reuse
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
key = bytes(range(16)); nonce = bytes(16)          # demo values only
def ctr_encrypt(pt, nonce=nonce):
    e = Cipher(algorithms.AES(key), modes.CTR(nonce)).encryptor()
    return e.update(pt) + e.finalize()
ctr_decrypt = ctr_encrypt                           # CTR decryption is the same XOR operation

# 1. Malleability: without a MAC, flipping ciphertext bits flips the same plaintext bits
pt = b"PAY 0100 DOLLARS TO BOB"
ct = bytearray(ctr_encrypt(pt))
for i, (old, new) in enumerate(zip(b"0100", b"9900"), start=4):
    ct[i] ^= old ^ new                               # attacker knows the format, not the key
print("attacker-modified plaintext:", ctr_decrypt(bytes(ct)))

# 2. Nonce reuse: two messages under the same key and nonce
a, b = b"meet at the north gate", b"the password is maple7"
ca, cb = ctr_encrypt(a), ctr_encrypt(b)
x = bytes(p ^ q for p, q in zip(ca, cb))
print("ca XOR cb equals a XOR b:", x == bytes(p ^ q for p, q in zip(a, b)))
print("guessing a reveals b:", bytes(p ^ q for p, q in zip(x, a)))
Output
attacker-modified plaintext: b'PAY 9900 DOLLARS TO BOB'
ca XOR cb equals a XOR b: True
guessing a reveals b: b'the password is maple7'

CBC has its own version of the problem. Serge Vaudenay showed in 2002 that if a server reveals, even indirectly through an error message or a timing difference, whether a decrypted message had valid padding, an attacker can decrypt any ciphertext one byte at a time by sending modified versions and watching the responses. This padding oracle attack, and its descendants such as the Lucky Thirteen timing attack and POODLE against SSL 3.0, pushed TLS 1.3 to drop CBC entirely. The lesson cryptographers drew is to stop asking developers to combine encryption and integrity themselves.

Authenticated encryption

An authenticated encryption scheme provides confidentiality and integrity in one operation. Encryption produces a ciphertext and a short authentication tag; decryption checks the tag first and refuses to return any plaintext if the check fails. Most schemes are authenticated encryption with associated data (AEAD): you can also supply associated data, such as a header, record number or user ID, that is authenticated but not encrypted, binding the ciphertext to its context so that it cannot be cut and pasted elsewhere. AES-GCM and ChaCha20-Poly1305 are the two AEAD schemes used by TLS 1.3, SSH and most modern protocols.

AES-GCM encrypts and authenticates; a single flipped bit or a changed header is rejected
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.exceptions import InvalidTag
key = bytes(range(32))            # fixed demo key so the output is reproducible; real keys come from AESGCM.generate_key()
nonce = bytes(12)                 # demo nonce; in real use, never repeat a nonce under the same key
aead = AESGCM(key)
msg = b"grade:A student:1042"
header = b"record-v1"              # associated data: authenticated but not encrypted
ct = aead.encrypt(nonce, msg, header)
print("plaintext ", len(msg), "bytes; ciphertext+tag", len(ct), "bytes")
print("ciphertext", ct[:-16].hex())
print("tag       ", ct[-16:].hex())
print("decrypts to", aead.decrypt(nonce, ct, header))
bad = bytearray(ct); bad[6] ^= 0x01            # flip one bit of the ciphertext
for label, c, h in [("flipped bit", bytes(bad), header), ("changed header", ct, b"record-v2")]:
    try:
        aead.decrypt(nonce, c, h)
    except InvalidTag:
        print(label + ": rejected, authentication tag does not match")
Output
plaintext  20 bytes; ciphertext+tag 36 bytes
ciphertext 69ced4bad016c29d7bdcdc517d42e5a3e3736261
tag        5b719e43dabe63262e679c8922ccfb5c
decrypts to b'grade:A student:1042'
flipped bit: rejected, authentication tag does not match
changed header: rejected, authentication tag does not match

The 16-byte tag adds a small overhead and catches any modification with overwhelming probability. AEAD does not remove the nonce rule: with GCM, reusing a nonce under the same key both leaks the XOR of plaintexts and lets an attacker recover the internal authentication key and forge messages. Systems that cannot guarantee unique nonces, for example because many machines share a key with no coordination, can use a nonce-misuse-resistant mode such as AES-GCM-SIV, or random 192-bit nonces with XChaCha20-Poly1305, where collisions are negligible. If you must build encryption from parts, the safe generic composition is encrypt-then-MAC: encrypt, then compute a MAC over the ciphertext (and the IV and associated data), and verify the MAC before decrypting. Lesson 4 explains MACs.

Key management in practice

Algorithms are rarely the weak point; keys are. Keys must be generated from a CSPRNG, stored where application bugs cannot leak them, rotated, and destroyed when no longer needed. Large systems use envelope encryption: each file or record is encrypted with its own data key, and the data keys are themselves encrypted under a key-encryption key held in a key management service or hardware security module (HSM) that never releases it. Rotating the master key then means re-encrypting small data keys, not terabytes of data. Disk encryption on laptops and phones derives the key from a user password combined with a secret held in a hardware chip (a trusted platform module or secure enclave), which limits the number of password guesses an attacker with the stolen device can make. A key that ends up in a source repository is a classic failure: automated scanners search public code hosting sites for exposed cloud credentials and abuse them within minutes.

Worked problem

Choosing a mode for encrypting database fields

A clinic wants to encrypt the diagnosis field of each patient record in its database. A developer proposes AES in ECB mode so that equal diagnoses encrypt equally and the field can still be searched. Evaluate the proposal and recommend an alternative.

Show solution
  1. ECB is deterministic, so equal diagnoses produce equal ciphertexts. An attacker with database access learns which patients share a diagnosis, and a rare diagnosis plus one known patient reveals everyone else with it. Long values also leak repeated blocks.
  2. Use an AEAD scheme such as AES-GCM with a fresh random nonce per record, and include the record ID as associated data so a ciphertext cannot be copied into another patient's row.
  3. If exact-match search is essential, store a separate keyed hash (an HMAC of the normalized diagnosis code under a different key) as a search index, accepting that it reveals equality; better, search on decrypted data inside the application or use a vetted searchable encryption design.

Answer: Reject ECB. Encrypt each field with AES-GCM, a random nonce and the record ID as associated data; if equality search is required, add a separate HMAC index and document the equality leak.

Worked problem

How many messages before random GCM nonces collide?

A service encrypts messages under one AES-GCM key with random 96-bit nonces. By the birthday bound, about how many messages can it encrypt before the chance of a nonce collision reaches roughly one in a billion? NIST limits random-nonce GCM to 2^32 messages per key; is that consistent?

Show solution
  1. For n random values from N = 2^96 possibilities, the collision probability is about n^2 / (2N).
  2. Set n^2 / 2^97 = 10^-9, about 2^-30: n^2 = 2^67, so n is about 2^33.5, roughly 12 billion messages.
  3. NIST's limit of 2^32 (about 4.3 billion) keeps the probability below about 2^-33, so it is consistent with a one-in-several-billion risk target; beyond that, rotate the key.

Answer: About 2^33 messages give a one-in-a-billion collision chance; rotating the key after 2^32 messages, as NIST recommends, keeps the risk below that.

Worked problem

Spotting a padding oracle

An API decrypts a CBC token from a cookie and returns HTTP 500 with the message "bad padding" when the padding is invalid, but 403 "invalid user" when padding is fine and the user ID is unknown. Why is this a vulnerability, and how should it be fixed?

Show solution
  1. The different responses tell an attacker whether any modified ciphertext decrypts to valid padding: a padding oracle.
  2. By changing bytes of the preceding block and watching for the 403 response, the attacker learns the plaintext byte by byte, needing at most 256 requests per byte, without the key.
  3. Fix: replace CBC with an AEAD scheme (AES-GCM) and verify the tag before doing anything else, returning one uniform error; also rate-limit and log decryption failures.

Answer: Distinguishable padding errors create a decryption oracle. Use authenticated encryption, check the tag first, and return a single generic error.

Common mistakes

  • MistakeECB mode is fine for short or random-looking data.

    InsteadAvoid ECB for data; it is deterministic and leaks equality of blocks and messages.

  • MistakeEncryption guarantees that the message was not changed.

    InsteadUse authenticated encryption; unauthenticated modes such as CTR and CBC are malleable.

  • MistakeA random IV or nonce can be reused if the data is different.

    InsteadNever reuse a nonce under the same key in CTR, GCM or ChaCha20; it leaks plaintext XORs and in GCM allows forgery.

  • MistakeDecrypting first and checking the MAC afterwards is equivalent.

    InsteadVerify the tag before processing any plaintext and return one generic error, or you may build an oracle.

  • MistakeWriting my own cipher or mode is a good way to be safe.

    InsteadUse standard AEAD constructions from a maintained library; custom designs almost always contain flaws.

Summary and key terms

  • Block ciphers such as AES are keyed permutations on 128-bit blocks; stream ciphers such as ChaCha20 XOR a keystream with the data.
  • Confusion and diffusion make every output bit depend on every key and input bit, producing the avalanche effect.
  • ECB leaks patterns; CBC, CTR and GCM add an IV or nonce so equal plaintexts encrypt differently.
  • Unauthenticated modes are malleable, and nonce reuse leaks the XOR of plaintexts.
  • Padding oracles turn error messages into decryption tools, one reason TLS 1.3 dropped CBC.
  • AEAD schemes (AES-GCM, ChaCha20-Poly1305) give confidentiality, integrity and binding to associated data in one step.
  • Key management, including envelope encryption and HSMs, is usually harder than choosing the algorithm.
Block cipher
A keyed permutation on fixed-size blocks, such as AES on 128-bit blocks.
Stream cipher
A cipher that XORs a pseudorandom keystream with the plaintext.
Nonce
A number used once per key, ensuring each encryption is unique.
Mode of operation
A method of using a block cipher to encrypt messages longer than one block.
ECB
Electronic codebook mode, which encrypts each block independently and leaks patterns.
CTR
Counter mode, which encrypts successive counters to produce a keystream.
Malleability
The ability to change a ciphertext so the plaintext changes in a predictable way.
Padding oracle
Any signal revealing whether decrypted padding is valid, allowing decryption without the key.
AEAD
Authenticated encryption with associated data: confidentiality plus integrity and context binding.
Envelope encryption
Encrypting data with data keys that are themselves encrypted under a master key.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. What block size and key sizes does AES use?

    Show answer

    A 128-bit block with 128-, 192- or 256-bit keys.

  2. Why does ECB reveal the outline of an image?

    Show answer

    Identical blocks of background pixels encrypt to identical ciphertext blocks, so regions of equal colour stay visible.

  3. In CTR mode, what happens to the plaintext if one ciphertext bit is flipped?

    Show answer

    Exactly the same bit of plaintext flips, and nothing else changes.

  4. What does associated data in AEAD protect?

    Show answer

    Its integrity: it is authenticated with the ciphertext but not encrypted, binding the ciphertext to its context.

  5. Why did TLS 1.3 remove CBC cipher suites?

    Show answer

    Because of repeated padding oracle and timing attacks; it allows only AEAD ciphers.

  6. State the safe order for combining encryption and a MAC.

    Show answer

    Encrypt-then-MAC: MAC the ciphertext, and verify before decrypting.

  7. What is the danger of reusing a GCM nonce?

    Show answer

    It leaks the XOR of the two plaintexts and lets the attacker recover the authentication key and forge messages.

  8. Give one advantage of ChaCha20-Poly1305 over AES-GCM.

    Show answer

    It is fast and constant-time in software on devices without AES hardware instructions.

  9. What is a key-encryption key?

    Show answer

    A master key that encrypts data keys, usually kept in a KMS or HSM.

  10. How large is the GCM authentication tag normally?

    Show answer

    128 bits (16 bytes).

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz