AES Algorithm for beginners

Published: (November 30, 2025 at 05:43 PM EST)
6 min read
Source: Dev.to

Source: Dev.to

Overview

AES is a symmetric‑key encryption algorithm that is considered extremely secure, very easy to implement, and used in the real world.
This guide teaches you how to implement its 128‑bit CTR mode variant in C.

The procedure:

  1. Randomly generate a 128‑bit cipher key.
  2. Secretly share it with your friend (e.g., via Diffie‑Hellman – not covered here).
  3. Encrypt your message with the key.
  4. Send the encrypted message.
  5. Your friend decrypts it with the same key.

Test data is available at YouTube.


Implementing AES128

The core function encrypts a single 16‑byte block using a 16‑byte key:

void AES128( uint8_t* block, uint8_t* cipherKey) { }

It is a pipeline of several helper functions that operate on the block sequentially.

AES overview

The 11 “Apply Key” steps each use a different round key, derived from the main cipher key via getNthRoundKey.

The second half of the guide defines higher‑level helpers that repeatedly call AES128:

uint8_t* encrypt(uint8_t* plainText, uint64_t lengthInBytes, uint8_t* cipherKey) { }
uint8_t* decrypt(uint8_t* encryptedText, uint64_t lengthInBytes, uint8_t* cipherKey) { }

The Block

Blocks are 4 × 4 matrices of bytes (0‑255). In C they are stored as a flat array uint8_t[16] (stack) or uint8_t* (heap). Example block: {0,1,2,…,15}.

4x4 block

Note: The block is stored column‑major (the second element is below the first).

Step 1 – Print a block

void printBlock(uint8_t* block) { }

Step 2 – Set an element

void setElement(uint8_t* block, int nthRow, int nthCol, uint8_t element) { }

Rows and columns are flat arrays of four bytes. The first element of a row is the leftmost; the first element of a column is the topmost.

Step 3 – Extract a row / column

uint8_t* getRow(uint8_t* block, int nthRow) {
    // malloc storage for a new row
    // copy values from block to the new storage
    // return the newly allocated storage
}

uint8_t* getCol(uint8_t* block, int nthCol) {
    // same as above for a column
}

Step 4 – Print a row / column

void printRow(uint8_t* row) { }
void printCol(uint8_t* col) { }

Important: Functions that allocate memory (getRow, getCol) must have their returned pointers freed after use.

Step 5 – Rotate operations

Rotate illustration

void rotateLeftRow(uint8_t* row) { }
void rotateUpCol(uint8_t* col) { }
// Operate in‑place; do not allocate new memory.

Step 6 – Write a row / column back into a block

void setRow(uint8_t* block, uint8_t* row, int nthRow) {
    // copy row into the block (no allocations)
}
void setCol(uint8_t* block, uint8_t* col, int nthCol) {
    // copy column into the block (no allocations)
}

These primitives form the basis of the AES‑128 pipeline; verify them before proceeding.


subBytes Function

The S‑box constants (index 0 → 255) are:

{99,124,119,123,242,107,111,197,48,1,103,43,254,215,171,118,
202,130,201,125,250,89,71,240,173,212,162,175,156,164,114,192,
183,253,147,38,54,63,247,204,52,165,229,241,113,216,49,21,
4,199,35,195,24,150,5,154,7,18,128,226,235,39,178,117,
9,131,44,26,27,110,90,160,82,59,214,179,41,227,47,132,
83,209,0,237,32,252,177,91,106,203,190,57,74,76,88,207,
208,239,170,251,67,77,51,133,69,249,2,127,80,60,159,168,
81,163,64,143,146,157,56,245,188,182,218,33,16,255,243,210,
205,12,19,236,95,151,68,23,196,167,126,61,100,93,25,115,
96,129,79,220,34,42,144,136,70,238,184,20,222,94,11,219,
224,50,58,10,73,6,36,92,194,211,172,98,145,149,228,121,
231,200,55,109,141,213,78,169,108,86,244,234,101,122,174,8,
186,120,37,46,28,166,180,198,232,221,116,31,75,189,139,138,
112,62,181,102,72,3,246,14,97,53,87,185,134,193,29,158,
225,248,152,17,105,217,142,148,155,30,135,233,206,85,40,223,
140,161,137,13,191,230,66,104,65,153,45,15,176,84,187,22}

Step 7 – S‑box accessor

uint8_t sbox(uint8_t i) { }

Step 8 – Apply S‑box to a state

void subBytes(uint8_t* state) {
    // replace each byte with sbox(byte)
    // operate in‑place, no allocations
}

shiftRows Function

ShiftRows diagram

Step 9 – Implement shiftRows

void shiftRows(uint8_t* state) {
    // For each row:
    //   1. getRow()
    //   2. rotateLeftRow() the appropriate number of positions
    //   3. setRow()
    // Free any allocated rows.
}

mixColumns Function

First we need a Galois‑field multiplication helper. Pre‑computed tables are provided.

Tables

Table for b = 1 – values 0‑255 in order.
Table for b = 2 – see original content.
Table for b = 3 – see original content.

Step 10 – gfmul

uint8_t gfmul(uint8_t a, uint8_t b) {
    // a ∈ [0,255], b ∈ {1,2,3}
    // return the corresponding entry from the appropriate table
}

Step 11 – mixColumns

MixColumns diagram

void mixColumns(uint8_t* state) {
    // 1. Allocate a temporary 4×4 block for the output.
    // 2. For each output element (row r, col c):
    //      - fetch row r of the multiplication matrix,
    //      - fetch column c of the input state,
    //      - compute Σ gfmul(matrix_elem, state_elem) (xor the results),
    //      - store in the temporary block.
    // 3. Copy the temporary block back into `state`.
    // 4. Free the temporary block.
}

useRoundKey Function

Step 12 – XOR state with round key

void useRoundKey(uint8_t* state, uint8_t* roundKey) {
    // for i = 0..15: state[i] ^= roundKey[i];
}

Key Expansion

The 11 round keys are 4 × 4 blocks derived from the original cipher key.

Key expansion overview

Columns are denoted w0, w1, …, w43.
Round key 0 is a direct copy of the cipher key.

Step 13 – Get a column of a round key

uint8_t* getNthColOfRoundKey(int nthCol, uint8_t* cipherKey) {
    uint8_t rcon[] = {1,2,4,8,16,32,64,128,27,54};

    if (/* nthCol is 0‑3 */) {
        // return the corresponding column from cipherKey using getCol()
    } else if (/* nthCol ≡ 1 (mod 4) */) {
        // allocate new column
        // col = getNthColOfRoundKey(nthCol-1) XOR getNthColOfRoundKey(nthCol-4)
        // return col
    } else {
        // allocate new column
        // tmp = getNthColOfRoundKey(nthCol-1)
        // rotateUpCol(tmp)
        // col = tmp XOR getNthColOfRoundKey(nthCol-4)
        // col[0] ^= rcon[nthCol/4 - 1]; // apply rcon
        // return col
    }
}

Step 14 – Get an entire round key

uint8_t* getNthRoundKey(int n, uint8_t* cipherKey) {
    // allocate 16‑byte block
    // for each column i = 0..3:
    //     copy getNthColOfRoundKey(4*n + i) into the block
    // return the block
}

Step 15 – Complete AES128

void AES128(uint8_t* block, uint8_t* cipherKey) {
    // 1. Initial AddRoundKey (round 0)
    // 2. For rounds 1‑9:
    //      subBytes()
    //      shiftRows()
    //      mixColumns()
    //      useRoundKey() with roundKey = getNthRoundKey(round, cipherKey)
    // 3. Final round (10):
    //      subBytes()
    //      shiftRows()
    //      useRoundKey() with roundKey = getNthRoundKey(10, cipherKey)
    // Free any allocated round keys.
}

Turning Cipher Key into a Keystream (CTR Mode)

CTR mode diagram

Encryption steps:

  1. Generate a random 64‑bit nonce.
  2. Split the plaintext into 16‑byte blocks.
  3. For each block i, build a 16‑byte keystream:
    • first 8 bytes = nonce
    • last 8 bytes = i (as 8‑byte little‑endian)
    • run AES128 on this keystream with the cipher key.
  4. XOR the keystream with the plaintext block.
  5. Append the nonce to the ciphertext.

Decryption mirrors the process, extracting the nonce from the last 8 bytes.

Step 16 – Extract a byte from a 64‑bit number

uint8_t getNthByte(uint64_t number, int n) {
    // returns the n‑th least‑significant byte (0‑based)
    return (number >> (n * 8)) & 0xFF;
}

Step 17 – Generate a keystream for a block

uint8_t* getKeystream(uint8_t* cipherKey, uint64_t nthBlock, uint8_t* nonce) {
    // allocate 16‑byte array `keystream`
    // copy nonce (8 bytes) into keystream[0..7]
    // convert nthBlock to 8‑byte array and copy into keystream[8..15]
    // AES128(keystream, cipherKey);
    // return keystream;
}

Step 18 – Encrypt a message

uint8_t* encrypt(uint8_t* plainText, uint64_t lengthInBytes, uint8_t* cipherKey) {
    // 1. allocate 8‑byte nonce and fill with random data
    // 2. allocate output buffer of size lengthInBytes + 8 (nonce)
    // 3. for each 16‑byte block:
    //        keystream = getKeystream(cipherKey, blockIndex, nonce);
    //        xor plaintext block with keystream → ciphertext block
    //        free keystream
    // 4. copy nonce to the last 8 bytes of the output
    // 5. return the output buffer
}

Step 19 – Decrypt a message

uint8_t* decrypt(uint8_t* encryptedText, uint64_t lengthInBytes, uint8_t* cipherKey) {
    // 1. allocate 8‑byte nonce
    // 2. copy the last 8 bytes of encryptedText into nonce
    // 3. allocate output buffer of size lengthInBytes - 8
    // 4. for each 16‑byte block:
    //        keystream = getKeystream(cipherKey, blockIndex, nonce);
    //        xor ciphertext block with keystream → plaintext block
    //        free keystream
    // 5. return the plaintext buffer
}

Step 20 – (Optional) File‑based utility

Adapt the program to read a plaintext file, write the encrypted output (including the nonce), and vice‑versa for decryption.

Back to Blog

Related posts

Read more »

Day 1276 : Career Climbing

Saturday Before heading to the station, I did some coding on my current side project. Made some pretty good progress and then it was time to head out. Made i...

JWT Token Validator Challenge

Overview In 2019 Django’s session management framework contained a subtle but catastrophic vulnerability CVE‑2019‑11358. The framework failed to properly inv...