AES (Advanced Encryption Standard) is a widely used symmetric encryption algorithm. In this tutorial, we will implement AES encryption and decryption in Rust using a 128 bit key, and CBC (Cipher Block Chaining). By the end of this guide, you will have a solid understanding of AES and how to use it in Rust for secure data encryption.
Before we begin, make sure you have the following:
Create a new folder and use cargo init to initialise a new rust project. You can then use cargo run and you should see the "Hello World" message in your command line.
A plaintext block is a 4-by-4 matrix of bytes (represented in rust by an array of 16 8-bit integers [u8, 16]). It looks like this:
// insert image here
There are 4 transformations that we need to operate on the matrix.
The SubBytes function in AES is crucial for introducing non-linearity and confusion into the encryption process. It applies a non-linear transformation to each byte of the state matrix using a substitution box (S-Box). This helps thwart linear cryptanalysis attacks by making the relationship between the plaintext, ciphertext, and key complex.
We will start by populating the S-Box. This can be done by adding a constant in the main.rs file.
const SBOX: [u8; 256] = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
];
We will then write a function that takes a mutable reference to a block and replaces each item with its corresponding value in the S-Box.
fn sub_bytes(state: &mut [u8; 16]) {
for byte in state.iter_mut() {
*byte = SBOX[*byte as usize];
}
}
Now in we can update our main function to call sub_bytes with some test data.
fn main() {
let mut state: [u8; 16] = [
0x19, 0xa0, 0x9a, 0xe9,
0x3d, 0xf4, 0xc6, 0xf8,
0xe3, 0xe2, 0x8d, 0x48,
0xbe, 0x2b, 0x2a, 0x08,
];
let expected_sub_bytes: [u8; 16] = [
0xd4, 0xe0, 0xb8, 0x1e,
0x27, 0xbf, 0xb4, 0x41,
0x11, 0x98, 0x5d, 0x52,
0xae, 0xf1, 0xe5, 0x30,
];
sub_bytes(&mut state);
if state == expected_sub_bytes {
println!("It works!")
} else {
println!("Bytes substituted incorrectly")
}
}
And cargo run to make sure that it is working.
cargo run
Compiling aes-blog v0.1.0 (A:\Coding\chat app\aes-blog)
Finished dev [unoptimized + debuginfo] target(s) in 0.30s
Running `target\debug\aes-blog.exe`
It works!
Now it works, we can move onto the next transformation
The ShiftRows function in AES provides diffusion, so that the influence of each byte is spread across multiple columns.
This transformation turns the matrix:

into the matrix:

by rotating the nth row n bits to the left (row 0 does not change).
fn shift_rows(state: &mut [u8; 16]) {
let mut temp = [0u8; 16];
temp.copy_from_slice(state);
// column 0
state[0] = temp[0];
state[1] = temp[5];
state[2] = temp[10];
state[3] = temp[15];
// column 1
state[4] = temp[4];
state[5] = temp[9];
state[6] = temp[14];
state[7] = temp[3];
// column 2
state[8] = temp[8];
state[9] = temp[13];
state[10] = temp[2];
state[11] = temp[7];
// column 3
state[12] = temp[12];
state[13] = temp[1];
state[14] = temp[6];
state[15] = temp[11];
}
Was the function I used. We can then write a similar test combining both functions in fn main
fn main() {
let mut state: [u8; 16] = [
0x19, 0xa0, 0x9a, 0xe9,
0x3d, 0xf4, 0xc6, 0xf8,
0xe3, 0xe2, 0x8d, 0x48,
0xbe, 0x2b, 0x2a, 0x08,
];
let expected_state: [u8; 16] = [
0xd4, 0xbf, 0x5d, 0x30,
0x27, 0x98, 0xe5, 0x1e,
0x11, 0xf1, 0xb8, 0x41,
0xae, 0xe0, 0xb4, 0x52,
];
sub_bytes(&mut state);
shift_rows(&mut state);
if state == expected_state {
println!("It works!")
} else {
println!("It doesn't work.")
}
}
The test passed so we can continue
Operations performed on bytes in our matrix must have an inverse, and must be closed under the Galois field $GF(2^8)$. It follows that the addition (⊕) and multiplication (•) operations are somewhat different to those familiar to us.
a ⊕ b is just a simple bitwise XOR on a and b. For the purpose of our program this is the function that I have written.
fn add_blocks(state: &mut [u8; 16], b: &[u8]) {
for i in 0..16 {
state[i] ^= b[i];
}
}
This function won't be limited for use with two [u8; 16] arrays so b can be an array of any size greater than 16 bytes. More on this will be explained in AddRoundKey().
Suppose we want to multiply two bytes, a and b, in $GF(2^8)$.
Polynomial Representation:
Polynomial Multiplication:
Modulo Reduction:
Result:
In rust Galois multiplication looks like this
fn gal_mul (a: u8, b: u8) -> u8 {
let mut result: u8 = 0; // Result of the multiplication
let mut a = a; // Copy of the first operand
let mut b = b; // Copy of the second operand
// Irreducible polynomial for GF(2^8)
const IRREDUCIBLE_POLY: u8 = 0x1b; // (x^8) + x^4 + x^3 + x + 1
// Process each bit of the second operand
while b != 0 {
// If the least significant bit of b is 1, add the current a to the result
if (b & 1) != 0 {
result ^= a; // XOR is used instead of addition in GF(2^8)
}
// Shift a to the left, which corresponds to multiplying by x in GF(2^8)
let high_bit_set = (a & 0x80) != 0; // Check if the high bit (x^7) is set
a <<= 1; // Multiply a by x
// If the high bit was set before shifting, reduce a modulo the irreducible polynomial
if high_bit_set {
a ^= IRREDUCIBLE_POLY; // Perform the reduction
}
// Shift b to the right, moving to the next bit
b >>= 1;
}
result
}
Finally, for each column $c$, MixColumns() performs the following matrix multiplication:
$$ \left(\begin{array}{cc} 02 & 03 & 01 & 01\ 01 & 02 & 03 & 01\ 01 & 01 & 02 & 03\ 03 & 01 & 01 & 02\ \end{array}\right) \left(\begin{array}{cc} S_0,c\ S_1,c\ S_2,c\ S_3,c\ \end{array}\right) $$
in rust:
fn mix_columns(state: &mut [u8; 16]) {
let temp = *state;
// column 0
state[0] = gal_mul(temp[0], 0x02) ^ gal_mul(temp[1], 0x03) ^ temp[2] ^ temp[3];
state[1] = temp[0] ^ gal_mul(temp[1], 0x02) ^ gal_mul(temp[2], 0x03) ^ temp[3];
state[2] = temp[0] ^ temp[1] ^ gal_mul(temp[2], 0x02) ^ gal_mul(temp[3], 0x03);
state[3] = gal_mul(temp[0], 0x03) ^ temp[1] ^ temp[2] ^ gal_mul(temp[3], 0x02);
// column 1
state[4] = gal_mul(temp[4], 0x02) ^ gal_mul(temp[5], 0x03) ^ temp[6] ^ temp[7];
state[5] = temp[4] ^ gal_mul(temp[5], 0x02) ^ gal_mul(temp[6], 0x03) ^ temp[7];
state[6] = temp[4] ^ temp[5] ^ gal_mul(temp[6], 0x02) ^ gal_mul(temp[7], 0x03);
state[7] = gal_mul(temp[4], 0x03) ^ temp[5] ^ temp[6] ^ gal_mul(temp[7], 0x02);
// column 2
state[8] = gal_mul(temp[8], 0x02) ^ gal_mul(temp[9], 0x03) ^ temp[10] ^ temp[11];
state[9] = temp[8] ^ gal_mul(temp[9], 0x02) ^ gal_mul(temp[10], 0x03) ^ temp[11];
state[10] = temp[8] ^ temp[9] ^ gal_mul(temp[10], 0x02) ^ gal_mul(temp[11], 0x03);
state[11] = gal_mul(temp[8], 0x03) ^ temp[9] ^ temp[10] ^ gal_mul(temp[11], 0x02);
// column 3
state[12] = gal_mul(temp[12], 0x02) ^ gal_mul(temp[13], 0x03) ^ temp[14] ^ temp[15];
state[13] = temp[12] ^ gal_mul(temp[13], 0x02) ^ gal_mul(temp[14], 0x03) ^ temp[15];
state[14] = temp[12] ^ temp[13] ^ gal_mul(temp[14], 0x02) ^ gal_mul(temp[15], 0x03);
state[15] = gal_mul(temp[12], 0x03) ^ temp[13] ^ temp[14] ^ gal_mul(temp[15], 0x02);
}
Now in main.rs we can test this function.
FIPS 197, Advanced Encryption Standard (AES)
Daemen, J., & Rijmen, V. (2002). The Design of Rijndael: AES