A Lightweight AES-128 Implementation in C

A Lightweight AES-128 Implementation in C

Explains a fixed-memory, table-based AES-128 core implemented in C. State layout, key expansion, round operations, test vectors, and side-channel limitations are evaluated.

The easiest mistake in an AES implementation is not necessarily entering the S-box table incorrectly. The main risk is interpreting the 16-byte state matrix incorrectly in memory, applying round keys in a different order, or overlooking the absence of MixColumns in the final round. The code can appear to work consistently, and encryption and decryption can reverse each other, while still producing a transformation that is incompatible with standard AES.

When developing the C code I shared, my objective was to implement the AES-128 core without an external cryptographic library, with fixed memory use and in a form simple enough for embedded systems. The code encrypts or decrypts one 128-bit block in place. It performs no dynamic allocation. The S-box, inverse S-box, and finite-field multiplications use precomputed tables.

This work differed from using an existing API. Constructing every AES step at byte level required converting the standard's algebraic structure into programming decisions. Experience I later gained in algebraic cryptanalysis allowed me to view the same S-box from another direction. On the implementation side, the S-box is a fast table lookup. On the cryptanalysis side, it is the finite-field transformation carrying the nonlinearity of the entire cipher.

Constructing the state matrix

The source code targets only AES-128. Its constants show this boundary directly:

Block size = 16 bytes Key size = 16 bytes Number of columns = 4 Number of rounds = 10 Expanded key size = 176 bytes

AES operates on 128-bit blocks. AES-128, AES-192, and AES-256 use 128-, 192-, and 256-bit keys respectively. The shared implementation is fixed to the ten-round AES-128 variant.

The AES state is mathematically a 4 x 4 byte matrix. Input bytes are placed in column-major order:

state[row, column] = input[row + 4 * column]

Instead of constructing a separate 16-byte state array, the code uses four pointers addressing four sections of the input buffer. The first index represents the column and the second the row:

state[column][row]

At first sight, this appears to reverse the notation in the standard. Examination of memory layout shows that it represents the same structure. Each pointer corresponds to one four-byte AES column in the buffer.

This design has two consequences. First, encryption is performed in place, so separate input and output buffers are unnecessary. Second, ShiftRows moves bytes among four columns instead of shifting rows in one contiguous array. The direct assignments in the row-shifting section follow naturally from this data layout.

I revalidated the source code with the known AES-128 vector in FIPS 197. The key 000102030405060708090a0b0c0d0e0f and plaintext 00112233445566778899aabbccddeeff produced the expected output 69c4e0d86a7b0430d8cdb78070b4c55a. The inverse operation restored the original plaintext. This test shows not only that encryption and decryption reverse each other, but also that the implementation is compatible with standard AES-128.

Key expansion

The AES-128 key schedule produces 11 separate round keys from the 16-byte key, including the initial key. Each round key is 16 bytes, so the total space is:

11 x 16 = 176 bytes

The source code copies the first 16 bytes directly into the expanded-key area. The remaining 160 bytes are produced as four-byte words. Each new word is the XOR of the word four positions earlier and a temporary value derived from the preceding word.

Every fourth word receives three additional transformations:

  1. RotWord rotates four bytes one position to the left.
  1. SubWord passes each byte through the S-box.
  1. The relevant round constant is applied to the first byte.

For other words, the preceding word is used directly. This structure is sufficient only for AES-128. The additional SubWord condition in the AES-256 key schedule is absent. The BLOCK_LENGTH constant is used for both the number of block columns and the number of words in a 128-bit key. These values are equal in AES-128, but they are separate concepts in the general Rijndael model.

The first element, 0x8d, of the RCon array is not used by the algorithm. The actual round constants are elements 1 through 10. This arrangement allows direct use of the expression RCon[i / 4]. The same layout appears in many small AES implementations. Identical constant tables alone, however, are not evidence of common code origin. The S-box, inverse S-box, and round constants are common data defined by the AES standard.

Key expansion is repeated on every Encrypt and Decrypt call. This keeps the API stateless. The caller does not need to retain a context object or expanded key. It provides simple use for infrequent single-block operations.

When many blocks are processed with the same key, this approach performs unnecessary computation. The key can be expanded once and reused for subsequent blocks. In that design, the lifecycle of the 176-byte key schedule must be managed separately. An expanded key that remains in memory longer should be securely cleared after use. The current code does not explicitly erase its local RoundKey array.

Constructing the round operations

Encryption begins by XORing the plaintext with the initial round key. It then executes nine normal rounds:

SubBytes ShiftRows MixColumns AddRoundKey

MixColumns is omitted from the tenth round:

SubBytes ShiftRows AddRoundKey

The code does not use a separate final-round function for this exception. The loop completes SubBytes and ShiftRows, then checks the round number. At round ten, it exits the loop and applies the final AddRoundKey outside. This avoids duplicating the operations shared by the nine normal rounds and the final round.

SubBytes passes all 16 state bytes through the 256-element S-box table. Each input byte is used directly as a table index. The operation consists of a fixed 16 table accesses.

ShiftRows is performed without a separate buffer. The first row is rotated by one position, the second by two, and the third by three. A two-position rotation requires only two pairwise swaps. The other rows use one temporary byte each.

MixColumns multiplies each column by a fixed matrix over GF(2^8). The first output byte corresponds to:

s'0 = 02.s0 + 03.s1 + s2 + s3

Addition is XOR. Instead of calculating multiplication by 02 and 03 in the finite field at runtime, the code reads from two separate 256-byte tables:

s'0 = Mul2[s0] XOR Mul3[s1] XOR s2 XOR s3

The remaining outputs use rotated coefficients from the same matrix. The column is first copied into four temporary bytes so that earlier values are not overwritten while new values are calculated.

Decryption uses the inverse structure. It begins with the final round key and walks rounds backward:

InvShiftRows InvSubBytes AddRoundKey InvMixColumns

The function returns after applying the zeroth round key, so InvMixColumns is not executed in the final inverse round. Although the loop counter is uint8_t, the function returns when it reaches zero and no underflow occurs.

Inverse column mixing uses coefficients 09, 0B, 0D, and 0E. A separate table is stored for each. Decryption therefore performs more table accesses than encryption.

Including key expansion, single-block encryption contains approximately the following table reads:

Key-expansion S-box accesses 40 SubBytes accesses 160 MixColumns accesses 288 Total 488

The number is higher for decryption:

Key-expansion S-box accesses 40 InvSubBytes accesses 160 InvMixColumns accesses 576 Total 776

These are algorithmic access counts independent of processor pipelining, cache behavior, and compiler optimization. The inverse-mixing matrix requires four finite-field multiplications for every output, so the table load of the decryption path is greater.

Memory and complexity

The implementation contains eight 256-byte tables:

S-box

Inverse S-box

Multiplication tables for 02, 03, 09, 0B, 0D, and 0E

Including round constants, read-only table data totals 2059 bytes:

8 x 256 + 11 = 2059 bytes

This design trades memory for computation. Finite-field multiplication could be calculated at runtime with shifts, conditional reduction, and XOR operations. Precomputed tables reduce those operations to a single indexed read. Operation count decreases at the cost of approximately 2 KB of fixed data.

Two kilobytes are negligible on desktop systems. On small microcontrollers, program memory and RAM placement must be considered together. A static const declaration in standard C does not guarantee that every architecture automatically places the table in the desired read-only memory. Actual placement depends on the compiler, linker script, and microcontroller memory model.

The primary runtime memory cost is the 176-byte expanded key. Four state pointers and several temporary bytes are added to this. No heap is used, and the input buffer is not copied separately.

Because AES-128 parameters are fixed, the asymptotic complexity of a single-block operation is technically O(1). A more explanatory model that treats round count and block size as variables is:

Time = O(Nr x B) Space = O(Nr x B) + O(T)

Nr is the number of rounds, B the number of bytes in the block, and T the total table size. For AES-128, Nr=10, B=16, and T=2059 bytes.

Comments at the beginning of the source file mention comparative benchmark success. The shared version, however, contains no timing code. Variables named mean, elapsed, and iteration are declared but unused. printf calls within every loop would also completely dominate encryption cost. The implementation choices can therefore be analyzed, but historical benchmark results cannot be reproduced from this file.

Limits of the test code

The main function at the end of the file continuously generates random plaintext and keys. It encrypts and then decrypts the data. This is useful as a development experiment, but it performs no automatic validation. The original data is not compared with the decrypted data, and the program does not stop on error.

rand() is suitable here only for producing test input. It is not suitable for generating cryptographic keys or nonces. The function also requires the <stdlib.h> header. Because the shared file omits this header, strict C compilation can produce an implicit-function-declaration warning or error.

Keeping the library core and test program in the same file is convenient during early development. A production version should more appropriately be divided into three parts:

  1. An internal header defining constants and data types
  1. A C file containing the AES core
  1. A test program executing known-answer vectors

The public API should specify buffer length explicitly. Current functions assume that supplied pointers refer to valid areas of at least 16 bytes. They perform no null-pointer, short-buffer, or overlap checking. Such a contract can be acceptable in closed and controlled embedded code. In a general-purpose library, the boundaries should be visible through the API.

Security boundary

This code is an AES-128 block-cipher core. It does not define a format for encrypting a file or message. It contains no IV, nonce, padding, authentication tag, key derivation, or key-storage mechanism.

Applying the function independently to consecutive blocks effectively creates ECB behavior. Under the same key, ECB transforms equal plaintext blocks into equal ciphertext blocks and therefore does not hide data patterns. NIST defines modes such as CBC, CFB, OFB, and CTR for block ciphers and explicitly describes this structural leakage of ECB.

Encryption alone is insufficient when integrity is also required. Authenticated-encryption modes such as GCM add an authentication tag to ciphertext. GCM security depends on correct management of nonce values. The current core does not provide this upper layer.

The second limitation of the table-based design is side-channel security. Loop count and branching are independent of the key, but indices into the S-box and multiplication tables depend on secret state. On processors with caches, this access pattern can become a timing or cache side channel. Practical cache attacks against table-based software AES implementations have been demonstrated in academic research.

A small cacheless microcontroller does not carry the same attack class in the same way. Against attackers with physical access, power consumption, electromagnetic emission, and fault injection are separate threats. This code contains no masking, redundant computation, or fault-detection countermeasures.

A distinction must be made for live use in a critical system. Compatibility with the standard is necessary but not sufficient. Mode of operation, key management, nonce policy, side-channel model, error behavior, and test coverage must be validated together. In a regulated environment, a custom AES implementation does not become a validated cryptographic module merely by implementing FIPS 197 correctly. NIST likewise states explicitly that implementing an approved algorithm and obtaining FIPS 140 validation are not the same thing.

The bridge between implementation and cryptanalysis

In this code, the S-box is a fixed 256-byte table. From the processor's perspective, the only operation is to use a state byte as an index and read the corresponding value. The multiplicative inverse and affine transformation inside the S-box are not visible at runtime.

Algebraic cryptanalysis opens the same operation in the opposite direction. Bekir Ünlü's work on a combined S-box representation expresses the nonlinear table transformation through the multiplicative inverse of the input, four bits, and finite-field constants. This representation is intended to make different algebraic operations possible on AES equations.

On one side, the algebraic structure is compressed into tables for fast execution. On the other, the same structure is expanded into equations for cryptanalysis. This is the most interesting connection between the implementation I wrote in C and the cryptanalysis work in which I later participated.

The engineering value of the code does not come only from running AES. State layout, in-place transformation, table precomputation, and fixed memory use are combined in one design. Its security boundary is equally clear. When used correctly, this core produces the standard AES-128 transformation. A secure cryptographic system emerges only through the mode, authentication, key management, and side-channel protections constructed around it.

QR code for this page