Building AES Encryption from Scratch in Rust

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.

Prerequisites

Before we begin, make sure you have the following:

Setting Up the Project

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.

Encryption

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.

SubBytes()

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

ShiftRows()

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

MixColumns()

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.

Addition

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().

Multiplication

Suppose we want to multiply two bytes, a and b, in $GF(2^8)$.

  1. Polynomial Representation:

  2. Polynomial Multiplication:

  3. Modulo Reduction:

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

Transformation

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.

AddRoundKey()

References

FIPS 197, Advanced Encryption Standard (AES)
Daemen, J., & Rijmen, V. (2002). The Design of Rijndael: AES