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 DES, 3DES)
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, Camellia, 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.