Skip to content

Toy Cipher

julianspeith edited this page Aug 17, 2026 · 6 revisions

The Toy Cipher example project is a small — and deliberately insecure — block cipher. It is the first project too large to understand by staring at the graph view, which makes it the right place for the two techniques you will reach for most often on real designs: dataflow analysis to recover the register structure, and simulation to watch the circuit run.

Both are shown twice on this page, first as the script that ships with the project and then as the GUI route that does the same thing. Which one you reach for is a matter of whether you are exploring or scripting.

Requirements

Requirement Type Needed for Availability
dataflow plugin recovering the register structure opt-in
netlist_simulator_controller plugin simulating the circuit opt-in
waveform_viewer plugin the simulation wizard and the waveform display opt-in
Verilator dependency compiling the simulation model must be installed

None of the plugins are built by default. If you have not done so before, rebuild HAL with -DBUILD_ALL_PLUGINS=ON and activate them in the Plugin Manager (main menu > Utilities). See Building HAL.

Opening the project

The project ships as the zipped HAL project hal/examples/toy_cipher.zip. Do not unpack it by hand — choose File > Import Project, point it at the archive, and pick where it should end up. See import project for the individual fields.

Two scripts are already open in the Python Editor, dataflow.py and simulation.py. Each step below walks through the script first — you can run it as it is — and then shows where the same settings live in the GUI.

The circuit

The design (CIPHER) was synthesized for a Xilinx FPGA and comprises 135 gates and 169 nets using the Xilinx UNISIM gate library:

  • InputsCLK, START, KEY (16 bit), PLAINTEXT (16 bit)
  • OutputsDONE, OUTPUT (16 bit)
  • Sequential logic — 24× FDRE
  • Combinational logic — 35× LUT4, 18× LUT2, 3× LUT3, 1× LUT1
  • I/O and clocking — 34× IBUF, 17× OBUF, 1× BUFG
  • Constants — 1× GND, 1× VCC

The interface already tells you the shape of the cipher. A 16-bit key, a 16-bit plaintext, and a START/DONE handshake mean a multi-cycle, round-based cipher: it is handed its inputs, chews on them for some number of clock cycles, and raises a flag when the result is ready. What it does during those cycles is what the rest of this page is about.

One thing it does not tell you is the internal structure. The netlist is flat — everything sits in top_module, and the 24 flip-flops are 24 individual gates with nothing to say which of them belong together.

Note: The interface has already been prepared for you. KEY, PLAINTEXT and OUTPUT are not just sixteen loose wires each: they are module pin groups, named and ordered from bit 0 to bit 15, and that is what lets the steps below address them as KEY rather than as sixteen nets, and lets the waveform viewer show them as one 16-bit value. This project ships that way on purpose so the scripts work out of the box. A netlist recovered from a real-world target is different: you would get the individual nets and have to work out which belong together and in what order first.

Reverse engineering the toy cipher

What you are looking for

A round-based cipher has a state register that holds the value being encrypted, usually a key register beside it, and some control logic counting the rounds. None of that is visible yet, because a flip-flop in a netlist is just a gate. The two steps below recover it from two different directions:

  • statically, by grouping flip-flops that are wired and controlled alike into registers
  • dynamically, by running the circuit and watching what those registers actually hold

The order is deliberate but not enforced: the two steps are technically independent, and simulation works perfectly well on the flat netlist. Recovering the registers first simply means that when you look at the waveforms you already know which signals are worth looking at.

Step 1 — Recover the registers

Dataflow analysis, DANA for short, groups flip-flops into candidate multi-bit registers by comparing how they are connected and controlled — same clock, same enable, similar neighbors in the datapath.

In Python. dataflow.py does it in five lines:

from hal_plugins import dataflow

config = dataflow.Configuration(netlist)
config = config.with_flip_flops()

res = dataflow.analyze(config)
res.create_modules()

Line by line:

  • dataflow.Configuration(netlist) creates the configuration for one analysis run. netlist is the netlist currently open in HAL, which the Python editor hands you as a global. Everything you can tell DANA lives on this object — expected register sizes, the minimum group size (8 by default), whether to identify register stages — and the toy cipher needs none of it.
  • with_flip_flops() says what to group and by what: all flip-flop gate types, with clock, enable, set and reset treated as control pins. It is shorthand for a with_gate_types()/with_control_pin_types() pair and overwrites whatever was configured before. It returns the updated configuration, which is why the result is assigned back to config. Setting those two functions directly is how you group something other than flip-flops — multiplexers by their select inputs, say; see Dataflow Analysis.
  • dataflow.analyze(config) runs the analysis and returns a dataflow.Result holding the groups it found and how they are connected to each other. The netlist itself is untouched at this point: a result is a proposal, and you can read the groups out of it, merge or split them by hand, or write them to a .txt or .dot file before you commit to anything.
  • create_modules() is the commit, turning every group into a module named DANA_module_<group id>. Called without arguments it takes all groups; create_modules(group_ids={...}) restricts it to the ones you believe. Re-running it is safe: it deletes the DANA_ modules of a previous run first.

In the GUI. Open main menu > Plugins > Dataflow. The dialog exposes the same options the API has: expected register sizes as a hint, a minimum group size, an output directory for the reports, and switches for register stage identification, writing a .txt or .dot report, and creating modules. Leave the defaults — they amount to the with_flip_flops() configuration above — pick an output directory, and press Execute dataflow analysis. The create modules switch is what create_modules() does.

Either way you get nine groups out of the 24 flip-flops: one group of 16 and eight groups of one. That result is already the answer to the first question about this design. Sixteen flip-flops that share their control signals and move together are the state register — the same width as PLAINTEXT and OUTPUT, which is what you would expect of a cipher operating on a 16-bit block. The eight singletons are not a failure of the analysis; they are the leftovers that genuinely do not belong to a datapath register, such as the round counter and the handshake logic behind DONE.

Writing each group back into the netlist as a module is the payoff: the Modules Widget now has a structure where there was none, and the graph view becomes readable, because a recovered register folds into one box instead of sixteen loose flip-flops.

Note: DANA names the pins of the register it creates o_Q(15) down to o_Q(0), but those indices do not come from any knowledge of the design. It can tell you that sixteen flip-flops belong together; it cannot tell you which one is bit 0. You do not need that for anything below, because the top-level groups were prepared for you as described above. If you later want to read a recovered register as a number, that is what bit-order propagation is for — it takes the orders you do know, such as those top-level groups, and propagates them.

Step 2 — Simulate the cipher

Simulation runs the netlist with concrete inputs and records every net over time. HAL drives an external engine for this; the project is set up for Verilator.

In Python. simulation.py is four things in sequence: the setup, the stimulus, the run, and the grouping of the results. Setting it up:

from hal_plugins import netlist_simulator_controller

pl_sim_ctrl = hal_py.plugin_manager.get_plugin_instance("netlist_simulator_controller")
ctrl_sim = pl_sim_ctrl.create_simulator_controller()
eng = ctrl_sim.create_simulation_engine("verilator")
ctrl_sim.add_gates(netlist.get_gates())

top_mod = netlist.get_top_module()
clk_net = top_mod.get_pin_by_name("CLK").get_net()
ctrl_sim.add_clock_period(clk_net, 1000)
  • create_simulator_controller() creates a controller, and a controller is one simulation run: its own working directory, its own input data, its own results. Several controllers can exist side by side.
  • create_simulation_engine("verilator") selects the external engine and returns a handle. That handle is what engine properties are set on — the shipped script also sets num_of_threads to 4.
  • add_gates() defines the simulation set, here the whole design. This is also what decides what you have to drive: every net that enters the set from outside is an input, which for all 135 gates means CLK, START and the 32 KEY and PLAINTEXT nets. Handing it a subset of the gates of a netlist is allowed and then the inputs are the nets crossing that boundary.
  • The last two lines look up the top module's CLK pin and take the net behind it, because the controller addresses signals as nets rather than as pins, and declare a generated clock on that net: a period of 1000 picoseconds, starting at 0. Every other duration in the script is in the same unit.

Then the stimulus. The script drives five phases of 30 clock periods each — idle, encrypt, idle, encrypt again under a different key, idle — as pairs of "set the inputs" and "let time pass". The first two phases:

ctrl_sim.set_input(plaintext_pg, hal_py.BooleanFunction.Const(0, 16).get_constant_value())
ctrl_sim.set_input(key_pg, hal_py.BooleanFunction.Const(0, 16).get_constant_value())
ctrl_sim.set_input(start_net, hal_py.BooleanFunction.Value.ZERO)
ctrl_sim.simulate(30 * period)

ctrl_sim.set_input(start_net, hal_py.BooleanFunction.Value.ONE)
ctrl_sim.simulate(30 * period)

start_net, key_pg and plaintext_pg come from the same kind of lookup as clk_net above: top_mod.get_pin_by_name("START").get_net() for the single net, top_mod.get_pin_group_by_name("KEY") and its PLAINTEXT counterpart for the buses. set_input then takes either a single net and a single value — that is the START line — or a pin group and a list of values, one per pin, which is what the two 16-bit inputs use. BooleanFunction.Const(0, 16).get_constant_value() is just a way of writing that list: a 16-bit constant, unpacked into its 16 bit values, whose i-th entry lands on the i-th pin of the group. This is where the prepared pin groups pay off — without them you would be assigning to sixteen nets by hand.

set_input does not step time, though; simulate() does. Each call extends the recorded window by the given duration with whatever inputs are currently set, which is how the phases are built up. The remaining three phases follow the same pattern: START back to zero, then START high again after ctrl_sim.set_input(key_pg, hal_py.BooleanFunction.Const(0xFFFF, 16).get_constant_value()), then START back to zero one last time. That key change is what makes the two encryptions comparable later.

Finally the run, and the grouping of individual nets into readable multi-bit signals:

ctrl_sim.initialize()
ctrl_sim.run_simulation()

ctrl_sim.get_results()

ctrl_sim.add_waveform_group("KEY", key_pg)
ctrl_sim.add_waveform_group("PLAINTEXT", plaintext_pg)
ctrl_sim.add_waveform_group("CIPHERTEXT", output_pg)
  • initialize() is meant to close the setup phase — no gates and no clocks after this point.
  • run_simulation() writes the input data out, starts Verilator on it, and returns as soon as that process is up — not when it is finished. The version of the script in the project therefore waits in a loop on eng.get_state() (2 preparing, 1 running, 0 done, -1 failed) before it touches the results.
  • get_results() reads the recorded waveforms back from the engine into the controller. Until it runs, the results only exist as engine output in the working directory.
  • add_waveform_group() combines nets into one displayed multi-bit signal. Without those groups the viewer shows 16 separate one-bit traces per bus, which is unreadable; with them you get one 16-bit value per bus. Note that the third group is named CIPHERTEXT although the pin group is OUTPUT — the name is yours to choose, and this one records what you have understood about the design.

In the GUI. Open the waveform viewer and press Invoke simulation wizard in its toolbar. Its steps are the parts of the script, in the same order:

Wizard step What to set In the script
1 — Select Gates which gates to simulate, here all of them (All gates) add_gates(netlist.get_gates())
2 — Clock settings CLK as the clock net, period 1000, start value 0 add_clock_period(clk_net, 1000)
3 — Engine settings verilator, plus engine properties such as the thread count create_simulation_engine("verilator")
4 — Simulation Input Data what to drive and when — see below the set_input/simulate block
5 — Run Simulation starts the engine and reports its progress initialize() and run_simulation()

The wizard finishes on Load Simulation Results, which pulls the recorded waveforms into the viewer. You do not have to reproduce the add_waveform_group calls: the wizard reads the module pin groups itself and groups KEY and PLAINTEXT when you leave step 1, and OUTPUT when you load the results.

The input data. Step 4 is the one step where you have to spell the stimulus out yourself, and it is worth going through, because it is not obvious how the five phases of the script look as a table. Pick Enter simulation input manually and fill in the table underneath.

The table has one column per simulation input. START gets a column of its own, and KEY and PLAINTEXT get one column each — KEY[15:0] and PLAINTEXT[15:0] — rather than sixteen single-bit columns, because they are module pin groups; this is the same preparation that lets the script address them as key_pg and plaintext_pg. CLK has no column at all, because the clock generator from step 2 drives it. Each row is a point in time at which inputs change, and values hold until the next row. Times are in the same unit as the clock period, so one clock cycle is 1000 and one 30-cycle phase is 30000.

The five phases of the script are therefore six rows:

Time START KEY[15:0] PLAINTEXT[15:0] Phase
0 0 0 0 idle
30000 1 0 0 encrypt under the all-zero key
60000 0 0 0 idle
90000 1 0xffff 0 encrypt under the all-one key
120000 0 0xffff 0 idle
150000 0 0xffff 0 end of the recorded window

Three things about entering this:

  • The first row is prefilled with zeros and its time is fixed at 0, so the first idle phase is already there. Type the next time into the last row of the table and the editor appends a fresh row and copies the values of the row above into it — you only ever edit the cells that actually change, which for this run is START in every row and KEY once.
  • Display values as hex numbers is on by default, which is why KEY reads 0xffff above. Both notations are accepted on input: 0xffff, 16'hFFFF and 65535 all mean the same thing.
  • The last row carries no new values. It is there because the largest time in the table is the total length of the run — it is what turns the final START = 0 into a 30-cycle phase instead of an instant, the same way the script's last simulate(30 * period) does. The generated clock is extended to that length automatically, whatever Duration said in step 2.

If your stimulus comes from somewhere else — a testbench dump, a logic analyzer — take the other radio button, Simulation input from file, which reads .vcd, .csv and saleae.json instead.

What the waveforms tell you

The run was set up to be read, not just produced. Two things fall out of it immediately:

How many rounds the cipher takes. Count the clock edges between START going high and DONE going high. That number is the round count, and it is the first hard fact about the algorithm that no amount of looking at gates would have given you.

Which register is the state. The script encrypts the same plaintext twice under two different keys, all-zeros and all-ones. Add the register you recovered in step 1 to the waveform view and watch it across both runs: the value that changes every round and differs between the two runs is the cipher state being transformed. A register that changes only once, or that is identical in both runs, is doing something else.

That combination — recover a register statically, then watch it move — is the standard way into an unknown cipher. It is how you separate state from key schedule from control, and it is exactly what the Crypto Trojan project does at a realistic scale.

Where to go from here

  • Change the stimulus. Encrypt two plaintexts under the same key instead, and see which registers now differ — that separates key-dependent state from plaintext-dependent state.
  • Try UART next. It ships without scripts on purpose: a realistic circuit whose specification you already know, so you can check your conclusions against what a UART is supposed to do.

Clone this wiki locally