Alp Bolukbasi

M.Sc. Computer Science @ KIT : hardware-aware systems engineer

Karlsruhe, DE — somewhere between the debugger and the datasheet


AES-128 on an iCE40 FPGA

aes

For the second hardware security task, I implemented AES-128 on a Lattice iCE40-HX8K FPGA. The board receives one plaintext block over UART, encrypts it in hardware, and sends the ciphertext back to the host.

I already knew AES from the software side, where a function accepts a key and a block and returns a ciphertext. The FPGA removed that abstraction. The same operation became registers, lookup tables, byte permutations, finite-field arithmetic, a key schedule, and a controller that had to apply every step exactly once.

The equations were not where I spent most of the effort. I had to decide how bytes were ordered, which state owned each round operation, and when the block cipher had actually "finished." Toolchain behaviour also mattered, since a correct design is not useful if the simulation path is broken.

AES state byte layout
AES treats the 128-bit block as a 4x4 byte state. The bytes fill the matrix column by column, which matters a lot once the state becomes a Verilog register.

What the task does

The final design accepts one 128-bit plaintext block through the UART interface provided by the lab framework. The AES core encrypts the block with a fixed 128-bit key from the NIST FIPS-197 example, and the wrapper sends the 128-bit ciphertext back over the same serial link.

I kept the UART wrapper separate from the cipher core. The wrapper is responsible for collecting 16 bytes, starting encryption, waiting for the result, and sending 16 bytes back. The AES core only sees a complete plaintext block, a complete key, and a start/reset-style control signal.

WAIT_FOR_PLAIN -> ENCRYPT -> SEND_CIPHER

I made that separation so I could test the AES datapath as a block-level design in cocotb before involving serial timing. Once those tests passed, UART was only a transport layer around an already verified cipher core.

The full system looks roughly like this:

PC
  |
  | UART plaintext, 16 bytes
  v
top_level.v
  |
  +-- uart.v
  |
  +-- aes_module.v
        |
        +-- aes.v
              |
              +-- subbytes.v
              |     +-- sbox.v
              |
              +-- shiftrows.v
              |
              +-- mixcolumns.v
              |     +-- xtime.v
              |
              +-- keysched.v
                    +-- sbox.v
                    +-- rcon.v

Why AES worked as an FPGA exercise

AES is small enough to fit into a lab assignment, but it still forces real hardware trade-offs. The sixteen byte substitutions can be done with sixteen S-boxes in parallel, or one S-box can be reused over several cycles. The key schedule can run alongside the datapath or be precomputed. The round operations can be grouped into larger combinational blocks or split across more registers.

I used a direct implementation. It is not the smallest possible AES core, but it is readable. The structure follows the standard closely, and that made debugging easier. The cost is visible in the final FPGA report: sixteen S-box lookups and a direct round datapath are not free just because the source code looks compact.

This implementation stopped AES from being a black box for me. Each operation became a hardware block with explicit timing, resource, and byte-order contracts.

The AES round structure

AES always encrypts 128-bit blocks. This task used AES-128, so the key is also 128 bits and the cipher runs for ten rounds.

initial round:  AddRoundKey

rounds 1..9:    SubBytes
                ShiftRows
                MixColumns
                AddRoundKey

round 10:       SubBytes
                ShiftRows
                AddRoundKey

The final round intentionally skips MixColumns. That rule is easy to remember while reading the standard, but a round counter and FSM can still apply it at the wrong time.

In the integrated core, I already knew what each AES operation did. The open questions were which round key belonged to the current state, when the counter should advance, and whether an extra FSM cycle could apply an operation twice.

The AES FSM ended up following this structure:

IDLE
  initial AddRoundKey

for rounds 1..9:
  SUB_BYTES
  SHIFT_ROWS
  MIX_COLUMNS
  KEY_SCHED
  KEY_ADD

round 10:
  SUB_BYTES
  SHIFT_ROWS
  KEY_SCHED
  KEY_ADD
  DONE

The NIST vector and the byte-order contract

The reference test vector came from NIST FIPS-197. The plaintext, key, and expected ciphertext are:

plaintext:   3243f6a8885a308d313198a2e0370734
key:         2b7e151628aed2a6abf7158809cf4f3c
ciphertext:  3925841d02dc09fbdc118597196a0b32

These values are extremely useful, but only if the implementation agrees on how bytes are packed into the internal 128-bit state. In the wrapper, bytes arriving from UART are placed into the register with:

aes_din[bytecount*8 +: 8] <= uart_data_from_rx;

That means the first received byte goes into the least significant byte of the 128-bit register. The fixed key in the Verilog wrapper is therefore stored in reversed byte order:

.keyin(128'h3c4fcf098815f7aba6d2ae2816157e2b)

This looks strange until the whole path is written down. The key is the NIST key, but arranged to match the local register convention. Once the convention is fixed, every module has to respect it: UART packing, the AES state layout, the key schedule, the Python reference model, and the final board-level checker.

Byte-order bugs are difficult to spot because the circuit still produces 128 bits and the waveform can look reasonable. Those 128 bits are simply wrong, with no crash to point at the cause. I therefore used the exact test vector as the deciding check.

The state layout was the first real trap

AES describes its internal state as a 4x4 byte matrix. The slightly unintuitive part is that input bytes fill the matrix column-wise:

byte0   byte4   byte8    byte12
byte1   byte5   byte9    byte13
byte2   byte6   byte10   byte14
byte3   byte7   byte11   byte15

Inside the Verilog modules, byte i is selected with state[8*i +: 8]. That convention made local module code simple, but it also meant that the "visual" matrix and the packed register were always one mental translation apart.

This mattered most in ShiftRows and the key schedule. A row-major mental model combined with a column-major implementation gives perfectly valid Verilog and invalid AES.

SubBytes: sixteen lookup tables

SubBytes applies the AES S-box independently to every byte. The S-box itself comes from a multiplicative inverse in GF(2^8) followed by an affine transformation. The hardware does not recompute that derivation. It uses the resulting 256-entry mapping directly.

I instantiated one S-box per state byte, so all sixteen substitutions happen in parallel:

for each byte i:
  state_out[i] = sbox(state_in[i])

I chose this direct version because it was easy to inspect and test. It spends area, so sixteen parallel S-boxes were a real design decision on the small iCE40 FPGA.

Sixteen parallel AES S-boxes
The direct implementation pays for sixteen S-boxes so the entire state can be substituted in one step.

ShiftRows: just wiring, until the wiring is wrong

ShiftRows contains no arithmetic. Row zero is unchanged, row one is shifted by one byte, row two by two bytes, and row three by three bytes.

AES ShiftRows permutation
ShiftRows is a fixed byte permutation, but only after the state layout has been interpreted correctly.

This looked like the easiest module, so I used it to test whether I had understood the state representation. There is no arithmetic to hide a mapping mistake here. If one byte moves to the wrong position, the whole cipher is wrong.

MixColumns: finite-field arithmetic shows up

MixColumns operates on one four-byte state column at a time. Each column is multiplied by a fixed matrix over GF(2^8). Addition in this field is XOR. Multiplication is polynomial multiplication reduced by the AES irreducible polynomial:

m(x) = x^8 + x^4 + x^3 + x + 1

The useful primitive is xtime, which multiplies a byte by 02. In hardware, this becomes a left shift plus a conditional XOR with 0x1b when the original high bit was set.

mul2(x) = xtime(x)
mul3(x) = xtime(x) ^ x
AES xtime finite-field multiplication
Multiplication by two becomes a shift and a conditional reduction.

For a column [b0, b1, b2, b3], the forward AES transformation is:

out0 = 02*b0 ^ 03*b1 ^ 01*b2 ^ 01*b3
out1 = 01*b0 ^ 02*b1 ^ 03*b2 ^ 01*b3
out2 = 01*b0 ^ 01*b1 ^ 02*b2 ^ 03*b3
out3 = 03*b0 ^ 01*b1 ^ 01*b2 ^ 02*b3
AES MixColumns matrix
One state column multiplied by the fixed AES MixColumns matrix.

The equations are compact, but the datapath made their cost visible. Every multiply-by-two and multiply-by-three became gates and XOR paths.

AddRoundKey and the danger of simple operations

AddRoundKey is mathematically simple: XOR the 128-bit state with the current 128-bit round key.

AES AddRoundKey XOR
The state and round key are combined with a bitwise XOR.

In the controller, simple operations are still dangerous. XORing the same key twice removes it again. If the FSM accidentally remains in the key-add state for an extra cycle, a correct transformation can quietly undo itself.

I therefore gave AddRoundKey clear state ownership: enter the state, apply the key once, then advance. Without that rule, a valid operation could quietly undo itself in sequential hardware.

The key schedule is part of the datapath

AES-128 expands the original 128-bit cipher key into eleven 128-bit round keys: one for the initial AddRoundKey and one for each of the ten AES rounds.

K = w0 || w1 || w2 || w3

To generate the next key, the last word of the previous key goes through RotWord, SubWord, and an XOR with Rcon. The remaining words are generated through chained XORs:

RotWord([a0, a1, a2, a3]) = [a1, a2, a3, a0]
SubWord(word)             = apply S-box to each byte
Rcon[i]                   = [x^(i-1), 00, 00, 00] in GF(2^8)

temp = SubWord(RotWord(w3)) ^ Rcon[round]

nw0 = w0 ^ temp
nw1 = w1 ^ nw0
nw2 = w2 ^ nw1
nw3 = w3 ^ nw2
AES-128 key expansion flow
RotWord, SubWord, Rcon, and the chained XORs that produce the next AES-128 round key.

I initially treated key expansion as a helper around the cipher. That model failed once I integrated the controller. The key schedule is part of the datapath synchronization problem, since correct round operations still produce a wrong ciphertext with an off-by-one round key.

Once I fixed the byte order, I verified this module independently. Each generated word follows from the previous word, so the testbench could catch schedule mistakes without running the whole cipher.

Toolchain debugging is still debugging

Much of the work happened outside the AES equations. I first had to make sure the tools were observing the design I thought they were observing.

The project used cocotb, Icarus Verilog, vvp, and a Python AES reference package. On macOS, mixing the oss-cad-suite vvp binary with a Homebrew Python environment led to confusing Python embedding errors. The clean solution was to separate the flows:

simulation:
  Homebrew Icarus Verilog + Homebrew Python venv

FPGA build/programming:
  oss-cad-suite yosys, nextpnr, icetime, icepack, iceprog

This failure was in the simulation path, not the circuit. Separating the two flows let me distinguish a design bug from a broken way of observing the design.

Makefile flow for AES simulation and synthesis
The Makefile ended up documenting the working combination of simulation, synthesis, place-and-route, timing, and board test steps.

Verification

I verified each transformation before trusting the integrated AES core. This let me isolate a wrong ciphertext without reopening every equation from the beginning.

Passing cocotb AES tests
The final cocotb run covers the individual transformations, key expansion, AddRoundKey, and complete AES-128 encryption.
check_full_encryption   PASS
check_subbytes          PASS
check_shiftrow          PASS
check_mixcolumns        PASS
check_keysched          PASS
check_addkey            PASS

TESTS=6 PASS=6 FAIL=0 SKIP=0

The full encryption test uses the NIST FIPS-197 vector. The submodule tests made it possible to isolate bugs in the S-box mapping, row permutation, finite-field arithmetic, key schedule, or AddRoundKey logic before blaming the whole core.

FPGA result

The final design was synthesized and placed with the open-source iCE40 flow: Yosys, nextpnr-ice40, icetime, icepack, and iceprog.

target:        Lattice iCE40-HX8K, ct256
logic cells:   6390 / 7680 = 83%
block RAMs:    4
nextpnr fmax:  104.58 MHz
icetime fmax:  97.64 MHz
target clock:  12 MHz

At 83% logic utilization, this is not a tiny AES core. The direct structure and parallel S-boxes trade area for readability and a simple control structure. For this task, that was the right trade-off: the design still fits on the HX8K and comfortably meets the 12 MHz target used by the UART-based lab setup.

A smaller implementation could reuse fewer S-boxes across multiple cycles. A faster one could pipeline the round logic more aggressively. This version prioritizes a transparent mapping from the standard to hardware.

Board-level test

For the final check, I programmed the FPGA, sent the NIST plaintext over UART, and read the ciphertext back from the board.

Using UART device: /dev/cu.usbserial-21401
Sending plaintext...
Received ciphertext: 3925841d02dc09fbdc118597196a0b32
Correct ciphertext:  3925841d02dc09fbdc118597196a0b32
AES seems to be working correctly, congratulations!
Finished

The design passed module-level simulation, full encryption simulation, FPGA implementation, timing, bitstream generation, and the real UART path on hardware.

Where the implementation was fragile

I knew that AES contained SubBytes, ShiftRows, MixColumns, AddRoundKey, and a key schedule before writing the Verilog. I did not yet know how much state ownership and byte ordering sat behind those names.

Byte order became a contract between every layer of the project. The key schedule needed just as much attention as the visible round operations. A 128-bit XOR had to be protected from being applied twice. The simulator and synthesis tools needed a stable workflow before their output could be trusted.

The final design depended on the datapath, FSM, UART wrapper, Python reference model, FPGA, and toolchain agreeing on those contracts. A mismatch in any one of them was enough to return the wrong ciphertext.

back to notes