Symmetric Ciphers: XOR, AES, ChaCha20 & Beyond
🔐 Used in: VPNs, TLS (post-handshake), disk encryption, messaging apps
✅ Still foundational in modern cryptography.
What Are Symmetric Ciphers?
Symmetric ciphers use the same key for both encryption and decryption. Unlike public-key cryptography, they don’t offer key exchange—but they are much faster, making them ideal for bulk data encryption.
They are used everywhere: encrypted file systems, secure communications, and even inside protocols like TLS (after the handshake).
XOR Cipher — Simplicity That Teaches
⚠️ Insecure. Demonstration-only (used in educational demos, malware obfuscation )
Watch it on my Fearless in Rust channel: XOR Cipher in Rust - Step by Step
We first explored XOR encryption in Section 1.4: First Code — A Naive XOR Encryptor, where we built a full working example from scratch.
XOR is the simplest symmetric cipher: each byte of the message is XORed with a repeating key. Reversibility is built-in — XORing twice with the same key restores the original.
fn main() { let message = b"Hi, Rust!"; let key = b"key"; let encrypted = xor_encrypt(message, key); let decrypted = xor_encrypt(&encrypted, key); println!("Encrypted: {:x?}", encrypted); println!("Decrypted: {}", String::from_utf8_lossy(&decrypted)); } pub fn xor_encrypt(input: &[u8], key: &[u8]) -> Vec<u8> { input .iter() .enumerate() .map(|(i, &byte)| byte ^ key[i % key.len()]) .collect() }
🟢 Conclusion XOR encryption is reversible and stateless, which makes it simple and fast. But it lacks confusion and diffusion, so patterns in the input remain visible — offering no real resistance to cryptanalysis.
Feistel Networks — Foundation of Classic Block Ciphers
⚠️ Cryptographically obsolete, but conceptually important (used in DES1, 3DES2)
Feistel networks are a clever way to build reversible encryption using any basic function—even if that function itself can’t be reversed. That’s the key idea.
Each round applies a transformation to the data. Multiple rounds are chained to strengthen security.
Each round does the following:
- Takes two halves: Left (L) and Right (R)
- Computes a function f(R, key)
- Updates the pair as:
L₂ = R₁
R₂ = L₁ ⊕ f(R₁, key)
To encrypt, let’s see it in Rust:
fn feistel_round(l: u8, r: u8, k: u8) -> (u8, u8) { let f = r ^ k; (r, l ^ f) } fn main() { let left1: u8 = 0b1010_1010; // 170 let right1: u8 = 0b0101_0101; // 85 let key: u8 = 0b1111_0000; // 240 let (left2, right2) = feistel_round(left1, right1, key); println!("Encrypted: ({}, {})", left2, right2); }
Decryption reuses the same function f, simply reversing the round transformation:
fn feistel_round(l1: u8, r1: u8, k: u8) -> (u8, u8) { let f = r1 ^ k; (r1, l1 ^ f) } fn feistel_decrypt(l2: u8, r2: u8, k: u8) -> (u8, u8) { let f = l2 ^ k; let l1 = r2 ^ f; (l1, l2) } fn main() { let left1: u8 = 0b1010_1010; // 170 let right1: u8 = 0b0101_0101; // 85 let key: u8 = 0b1111_0000; // 240 let (left2, right2) = feistel_round(left1, right1, key); println!("Encrypted: ({}, {})", left2, right2); let (left_orig, right_orig) = feistel_decrypt(left2, right2, key); println!("Decrypted: ({}, {})", left_orig, right_orig); }
Because encryption produces :
Encrypted → (R, L ⊕ f(R, k))
Let’s define:
- L₁ and R₁ = original input
- L₂ = R₁ and R₂ = L₁ ⊕ f(R₁, k)
We receive (L₂, R₂) and want to recover (L₁, R₁):
-
From encryption, we know L₂ = R₁
- So: R₁ = L₂
-
And: R₂ = L₁ ⊕ f(R₁, k)
- Replace R₁ with L₂
- R₂ = L₁ ⊕ f(L₂, k)
-
Rearranging to get L₁:
- L₁ = R₂ ⊕ f(L₂, k)
So, decryption is
L₁ = R₂ ⊕ f(L₂, k)
R₁ = L₂
🟢 Conclusion
Reversibility comes from XOR being reversible and swapping the halves. Feistel networks let you build reversible encryption even with non-invertible functions. This idea shaped DES and similar ciphers.Not used today due to known vulnerabilities, but conceptually essential.
Substitution–Permutation Networks (SPN)
⚠️ Used in AES[^AES], Camellia3, and modern block ciphers. Still dominant in current cipher architectures
Substitution-Permutation Networks (SPNs) are a powerful way to build secure block ciphers by layering simple operations repeated across multiple rounds to build a secure cipher.
Each round does the following:
- Substitution – replace each byte using an S-box (non-linear mapping)
- Permutation – reorder bits or bytes to spread influence
- Key mixing – XOR the block with a round key
Decryption reverses these steps in reverse order.
💡 An S-box (substitution box) is a predefined table that maps each input byte to a new output byte. Its goal is to introduce non-linearity — meaning the output doesn’t follow any simple, predictable rule based on the input.
This non-linear mapping ensures that small changes in the input produce unpredictable changes in the output, making it impossible to reverse or model with linear equations — a key requirement for secure encryption.
Let’s walk through a simple encryption of a 4-byte block.
#![allow(unused)] fn main() { use std::convert::TryInto; // Manually defined "shuffled" S-box (shortened for demo) let s_box: [u8; 16] = [ 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, ]; // Step 1: Substitution with S-box // ⚠️ input and output (substituted) must have the same size // Otherwise, map() or indexing will panic at runtime let input: [u8; 4] = [0x00, 0x03, 0x07, 0x0F]; let substituted: [u8; 4] = input.map(|b| s_box[b as usize]); // Step 2: Permutation (custom byte reordering) let permuted: [u8; 4] = [ substituted[2], // byte 2 moves to pos 0 substituted[0], // byte 0 → pos 1 substituted[3], // byte 3 → pos 2 substituted[1], // byte 1 → pos 3 ]; // Step 3: XOR with round key let round_key: [u8; 4] = [0xF0, 0x0F, 0xAA, 0x55]; let encrypted: [u8; 4] = permuted .iter() .zip(round_key.iter()) .map(|(a, b)| a ^ b) .collect::<Vec<u8>>() .try_into() .unwrap(); println!("Step | Byte 0 | Byte 1 | Byte 2 | Byte 3"); println!("------------|--------|--------|--------|--------"); println!("Input | {:02X} | {:02X} | {:02X} | {:02X}", input[0], input[1], input[2], input[3]); println!("Substituted | {:02X} | {:02X} | {:02X} | {:02X}", substituted[0], substituted[1], substituted[2], substituted[3]); println!("Permuted | {:02X} | {:02X} | {:02X} | {:02X}", permuted[0], permuted[1], permuted[2], permuted[3]); println!("Encrypted | {:02X} | {:02X} | {:02X} | {:02X}", encrypted[0], encrypted[1], encrypted[2], encrypted[3]); }
To decrypt, reverse the steps in reverse order:
#![allow(unused)] fn main() { use std::convert::TryInto; // Same S-box used for encryption let s_box: [u8; 16] = [ 0x63, 0x7C, 0x77, 0x7B, 0xF2, 0x6B, 0x6F, 0xC5, 0x30, 0x01, 0x67, 0x2B, 0xFE, 0xD7, 0xAB, 0x76, ]; // Generate inverse S-box let mut inverse_s_box = [0u8; 256]; for (i, &val) in s_box.iter().enumerate() { inverse_s_box[val as usize] = i as u8; } // Encrypted block from the previous encryption output let encrypted: [u8; 4] = [0x35, 0x6C, 0xDC, 0x2E]; let round_key: [u8; 4] = [0xF0, 0x0F, 0xAA, 0x55]; // Step 1: Undo XOR with round key let xor_reversed: [u8; 4] = encrypted .iter() .zip(round_key.iter()) .map(|(a, b)| a ^ b) .collect::<Vec<u8>>() .try_into() .unwrap(); // Step 2: Reverse permutation // Remember: original permutation was [2, 0, 3, 1] // So now we must do: [1, 3, 0, 2] let permuted_reversed: [u8; 4] = [ xor_reversed[1], // was originally at index 0 xor_reversed[3], // was at index 1 xor_reversed[0], // was at index 2 xor_reversed[2], // was at index 3 ]; // Step 3: Inverse substitution using inverse_s_box let decrypted: [u8; 4] = permuted_reversed.map(|b| inverse_s_box[b as usize]); println!("Step | Byte 0 | Byte 1 | Byte 2 | Byte 3"); println!("------------|--------|--------|--------|--------"); println!("Encrypted | {:02X} | {:02X} | {:02X} | {:02X}", encrypted[0], encrypted[1], encrypted[2], encrypted[3]); println!("XOR Rev | {:02X} | {:02X} | {:02X} | {:02X}", xor_reversed[0], xor_reversed[1], xor_reversed[2], xor_reversed[3]); println!("Perm Rev | {:02X} | {:02X} | {:02X} | {:02X}", permuted_reversed[0], permuted_reversed[1], permuted_reversed[2], permuted_reversed[3]); println!("Decrypted | {:02X} | {:02X} | {:02X} | {:02X}", decrypted[0], decrypted[1], decrypted[2], decrypted[3]); }
Why it works
- Substitution = confusion → Hide relationships between plaintext and ciphertext
- Permutation = diffusion → Spread input influence across the block
These are Shannon’s two pillars of secure ciphers.
💡 Claude Shannon, widely considered the father of modern cryptography, introduced the concepts of confusion and diffusion in 1949 as the foundation of secure cipher design.
🟢 Conclusion
Substitution-Permutation Networks provide a simple yet powerful structure for building symmetric ciphers. They deliver the critical properties of confusion and diffusion, as first formalized by Claude Shannon in his foundational work on cryptographic security.
AES (Advanced Encryption Standard) — The Global Symmetric Standard
💡 Used in TLS4, LUKS5, SSH6, mobile apps, and FIPS-certified systems7.
Secure, fast, and hardware-accelerated
Crates used: aes, block_modes
AES is a symmetric-key block cipher developed by Belgian cryptographers Vincent Rijmen and Joan Daemen. It was selected by NIST in 2001 as the successor to DES and 3DES.
AES operates on 128-bit blocks and supports key sizes of 128, 192, or 256 bits. It is based on a Substitution–Permutation Network (SPN) and runs 10, 12, or 14 rounds depending on the key length.
It is standardized by FIPS-197, ISO/IEC8, and widely adopted in security protocols such as TLS, SSH, and IPsec9. AES is available in hardware on most modern CPUs, making it both fast and energy-efficient.
🧪 Code Example: AES-128-CBC Encryption & Decryption in Rust (source code)
We’ll use the aes and block-modes crates to encrypt and decrypt a message using AES-128 in CBC mode10 with PKCS711 padding.
#![allow(unused)] fn main() { use aes::Aes128; use block_padding::Pkcs7; use cbc::{Encryptor, Decryptor}; use cipher::{BlockEncryptMut, BlockDecryptMut, KeyIvInit}; pub fn run_aes_example() { let key = b"verysecretkey123"; let iv = b"uniqueinitvector"; let plaintext = b"Attack at dawn!"; let mut buffer = plaintext.to_vec(); let pos = buffer.len(); buffer.resize(pos + 16, 0u8); let mut encryptor = Encryptor::<Aes128>::new(key.into(), iv.into()); let ciphertext = encryptor .encrypt_padded_mut::<Pkcs7>(&mut buffer, pos) .expect("encryption failure"); println!("Ciphertext (hex): {}", hex::encode(ciphertext)); let mut decryptor = Decryptor::<Aes128>::new(key.into(), iv.into()); let mut ciphertext_buffer = ciphertext.to_vec(); // make it mutable let decrypted = decryptor .decrypt_padded_mut::<Pkcs7>(&mut ciphertext_buffer) .expect("decryption failure"); println!("Decrypted text: {}", String::from_utf8_lossy(decrypted)); assert_eq!(plaintext.to_vec(), decrypted); } }
#![allow(unused)] fn main() { Ciphertext (hex): 61b05644915a98fbd515e31b3a4e6d88 Decrypted text: Attack at dawn! }
✅ Use a unique IV (Initialization Vector) for every encryption, and never reuse a key/IV pair. Avoid ECB mode entirely, and prefer AEAD modes (e.g., AES-GCM) when available.
🟢 Conclusion AES is the modern standard for symmetric encryption. It is fast, secure, and hardware-accelerated — making it ideal for both embedded systems and high-throughput servers. When used correctly with a secure mode like CBC or GCM and proper key/IV management, AES provides strong resistance against all known practical attacks.
-
3DES — DES applied three times, better than DES but now deprecated. More ↩
-
Camellia — Japanese block cipher, secure & AES-comparable. More ↩
-
TLS — protocol securing data in transit (HTTPS, etc.). More ↩
-
FIPS — U.S. cryptographic standards for government/finance. More ↩
-
IPSec — protocol suite for securing IP communications. More ↩