diff --git a/hdl/ip/vhd/hash_engine/BUCK b/hdl/ip/vhd/hash_engine/BUCK
new file mode 100644
index 00000000..6fcd44d7
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/BUCK
@@ -0,0 +1,73 @@
+load("//tools:hdl.bzl", "sim_only_model", "vhdl_unit", "vunit_sim")
+load("//tools:rdl.bzl", "rdl_file")
+
+rdl_file(
+ name = "hash_engine_regs_rdl",
+ src = "hash_engine_regs.rdl",
+ outputs = [
+ "hash_engine_regs_pkg.vhd",
+ "hash_engine_regs.html",
+ "hash_engine_regs.json",
+ ],
+ visibility = ["PUBLIC"],
+)
+
+vhdl_unit(
+ name = "hash_engine_top",
+ srcs = glob(["*.vhd"]),
+ deps = [
+ ":hash_engine_regs_rdl",
+ "//hdl/ip/vhd/axi_blocks:axilite_if_2k19",
+ "//hdl/ip/vhd/axi_blocks:axist_if_2k19_pkg",
+ "//hdl/ip/vhd/common:calc_pkg",
+ "//hdl/ip/vhd/fifos:dcfifo_mixed_xpm",
+ "//hdl/ip/vhd/sha3:sha3_256",
+ ],
+ standard = "2019",
+ visibility = ["PUBLIC"],
+)
+
+sim_only_model(
+ name = "hash_engine_sim_pkg",
+ srcs = [
+ "sims/hash_engine_sim_pkg.vhd",
+ "sims/fake_flash_responder.vhd",
+ ],
+ deps = [":hash_engine_regs_rdl"],
+ visibility = ["PUBLIC"],
+)
+
+vunit_sim(
+ name = "hash_engine_tb",
+ srcs = [
+ "sims/hash_engine_th.vhd",
+ "sims/hash_engine_tb.vhd",
+ ],
+ deps = [
+ ":hash_engine_sim_pkg",
+ ":hash_engine_top",
+ "//hdl/ip/vhd/fifos:dcfifo_xpm",
+ "//hdl/ip/vhd/sha3:sha3_sim_pkg",
+ ],
+ visibility = ["PUBLIC"],
+)
+
+# End to end through the real spi_nor_top and QSPI link. There is no flash device
+# model, but the bus floats high so every fetched byte is 0xFF, which is enough to
+# check the chunking, arbitration and plumbing.
+vunit_sim(
+ name = "hash_spi_nor_tb",
+ srcs = [
+ "sims/hash_spi_nor_th.vhd",
+ "sims/hash_spi_nor_tb.vhd",
+ ],
+ deps = [
+ ":hash_engine_sim_pkg",
+ ":hash_engine_top",
+ "//hdl/ip/vhd/fifos:dcfifo_xpm",
+ "//hdl/ip/vhd/sha3:sha3_sim_pkg",
+ "//hdl/ip/vhd/spi_nor_controller:spi_nor_top",
+ "//hdl/ip/vhd/vunit_components:spi_nor_target_vc",
+ ],
+ visibility = ["PUBLIC"],
+)
diff --git a/hdl/ip/vhd/hash_engine/docs/hash_engine.adoc b/hdl/ip/vhd/hash_engine/docs/hash_engine.adoc
new file mode 100644
index 00000000..fda023e5
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/docs/hash_engine.adoc
@@ -0,0 +1,364 @@
+:showtitle:
+:toc: left
+:numbered:
+:icons: font
+:revision: 1.0
+:revdate: 2026-08-05
+
+= SHA3-256 hashing engine
+
+Wraps the `sha3_256` core in an AXI-Lite register interface and the state machines
+needed to feed it. It hashes either a range of the host QSPI flash or data written
+in by the processor, optionally prefixed with a run of `0xFF` bytes, and presents
+the result in eight read-only registers.
+
+The intended use is measurement: hashing a host flash image, or a manifest, so
+software can compare the result against something it trusts.
+
+== Design Overview:
+
+image::hash_engine_block.drawio.svg[align="center"]
+
+Flash bytes are fetched over a command/response FIFO channel in the same shape as
+the eSPI flash channel: a 32-bit command FIFO taking an address word then a length
+word, and an 8-bit response FIFO of data bytes. Those FIFOs belong to the
+integrating design, exactly as they do for eSPI, and the far end is a second
+client port on `spi_nor_top`.
+
+The engine issues a single command for the whole range. Splitting it into the
+256-byte reads the flash can actually service happens in `raw_flash_txn_mgr` on
+the far side and is invisible here.
+
+=== Why a second client port rather than sharing the eSPI one
+
+`espi_flash_txn_mgr` applies the SP5 image and APOB address translation and is
+gated by `spicr.sp5_owns_flash`. Both are right for the host's view of the flash
+and wrong for a client that was handed a physical address to measure. The new
+client uses raw addresses and works regardless of who owns the flash.
+
+The cost is that a hash read locks the SPI engine for its whole duration, which
+for a full image is a long time. Arbitration in `spi_nor_top` is a held grant: the
+hash client takes the engine only when it is idle and nothing else has work
+queued, and keeps it until the command completes. When the engine is not asking,
+the selection collapses to the original two-way `sp5_owns_flash` mux, so existing
+behaviour is unchanged. Measurement is expected to run while the host is not
+booting.
+
+== Interface
+
+An 8-bit (256-byte) responder window, which is what `resize_axil` supports.
+
+[cols="1,1,4"]
+|===
+|Offset |Register |Description
+
+|0x00 |`CONTROL` |`start[0]` and `abort[1]`, both self-clearing, so this reads back
+as zero. Writing `start` while a hash is running restarts it.
+|0x04 |`CONFIG` |`source[3:0]`: `LOCAL_REG` (0) or `HOST_QSPI` (1). Four bits so
+more sources can be added. An unsupported value decodes as `LOCAL_REG`.
+|0x08 |`PREPEND` |Number of `0xFF` bytes fed before any source data. Counts towards
+`LENGTH`.
+|0x0C |`FLASH_ADDR` |Raw flash byte address of the first fetched byte. Not
+remapped. `HOST_QSPI` only.
+|0x10 |`LENGTH` |Total bytes to hash, *including* the prepended `0xFF` bytes. Bytes
+taken from the source are `LENGTH - PREPEND`.
+|0x14 |`STATUS` |`busy[0]`, `done[1]`, `wfifo_full[2]`, `wfifo_empty[3]`,
+`aborted[4]`, `cfg_err[5]`.
+|0x18 |`WDATA` |Writing pushes four message bytes into the software data FIFO,
+least significant byte first.
+|0x1C |`PROGRESS` |Bytes fed into the core so far for the run in flight.
+|0x20-0x3C |`DIGEST0`..`DIGEST7` |The 256-bit digest.
+|===
+
+Configuration is sampled when a start is accepted, so changing `CONFIG`,
+`PREPEND`, `FLASH_ADDR` or `LENGTH` mid-hash does not affect the run in flight.
+
+=== Byte order
+
+`DIGESTn` is `digest(32n+31 downto 32n)`, so *`DIGEST0` bits 7:0 are hash byte 0* --
+the leftmost byte of the conventional hex string. Worked example, for
+`sha3-256("abc")`:
+
+----
+3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532
+----
+
+reads back as `DIGEST0 = 0xa75d983a` and `DIGEST7 = 0x32154311`.
+
+`WDATA` follows the same convention: `data(7 downto 0)` is consumed first.
+`LENGTH` decides where the message ends, so unused trailing bytes of the final
+word are simply never consumed and no byte-enable is needed.
+
+=== Software flow
+
+. Write `CONFIG`, `PREPEND`, `LENGTH` and, for `HOST_QSPI`, `FLASH_ADDR`.
+. Write `CONTROL.start`.
+. For `LOCAL_REG`, write `LENGTH - PREPEND` bytes to `WDATA`, polling
+ `STATUS.wfifo_full` before each write.
+. Poll `STATUS.done`, then read `DIGEST0`..`DIGEST7`.
+
+`done` means the engine is also idle and ready to run again, not merely that the
+digest exists.
+
+After an abort, poll `STATUS.busy` until it clears before starting anything else.
+An abort during a flash read is not instantaneous -- see below.
+
+=== Rejected configurations
+
+A start is refused, `cfg_err` is set and `busy` never asserts, if:
+
+* `LENGTH` is zero. AXI-Stream has no zero-beat packet, so the core cannot express
+ the empty message. Software needing `SHA3-256("")` should use the known constant
+ `a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a`.
+* `PREPEND` is greater than `LENGTH`.
+
+`cfg_err` clears on the next accepted start.
+
+== Two things that are not obvious
+
+=== Abandoning a flash read
+
+The transaction manager on the far side cannot be called off part way through, so
+the bytes it still owes would leak into whatever ran next. Rather than try to stop
+it, an abort or a restart goes through a `DRAIN` state that discards exactly the
+number of bytes outstanding, then continues.
+
+This is why `busy` stays asserted after an abort until the channel is
+resynchronised, and why software must poll it rather than assuming the abort took
+effect immediately.
+
+The engine does not flush the response FIFO. Draining is what resynchronises the
+channel, and resetting the FIFO instead would be worse than useless: on a restart
+the next read begins within a few cycles, and the backend's first bytes would land
+while the FIFO was still recovering from reset, where they are silently dropped
+and the hash hangs waiting for them. The integrator should tie that FIFO's reset
+to the global reset only.
+
+=== When the software data FIFO is flushed
+
+At the *end* of a run, not the start.
+
+Flushing as a run starts is the obvious choice and it is wrong. A processor that
+polls `wfifo_full`, sees it clear, and then writes can have its write land inside
+the flush window, where it is silently dropped. Flushing at the end means a run
+always begins with a FIFO that is already known clean, and there is no window to
+race with. It also means data written before the very first start is kept, so
+pre-loading works.
+
+`wfifo_full` reads as set during the flush, so a processor that polls is safe
+either way.
+
+== Sim Env
+
+Two testbenches.
+
+`hash_engine_tb`:: The engine on its own, with `fake_flash_responder` servicing the
+command/response channel out of a deterministic address-keyed pattern. This is
+where the behaviour lives: both sources, all the padding-relevant lengths, the
+prepend, abort, restart, back-to-back runs, the rejected configurations,
+backpressure, and the published vectors as `nist_vectors`. Seventeen cases.
+
+`hash_spi_nor_tb`:: The engine driving a real `spi_nor_top` over a real QSPI link
+into a modelled flash part (`spi_nor_target_vc`), with the same launch and capture
+delays `spi_nor_th` uses. The part is filled with `pattern_byte(addr)`, which is a
+bijection over any aligned 256-byte run, so the digest depends on exactly which
+addresses were fetched -- a chunk boundary that re-reads or skips a range changes
+the digest instead of going unnoticed. Covers the exact-256 boundary, one byte
+either side of it, unaligned bases, multi-chunk reads, and the sector-bypass
+configuration the hardware script drives.
+
+Two bugs came out of testing at this level that nothing else caught:
+
+* `raw_flash_txn_mgr` inherited the eSPI manager's mixed zero-indexed and
+ one-indexed counting, and decremented its remaining count by the zero-indexed
+ chunk size rather than by the bytes the chunk actually moves. That over-fetches
+ one byte per extra chunk -- 5000 bytes requested fetched 5019. It is invisible in
+ the eSPI manager because an eSPI flash read never needs a second chunk. Both
+ counts are now plain byte counts.
+* `spi_cmd.addr` and `spi_cmd.data_bytes` must hold still for the whole
+ transaction. `spi_txn_mgr` shifts the address out during the address phase and
+ re-reads `data_bytes` when it enters the data phase, so advancing either at
+ `go_flag` corrupts the transaction already in flight. The next chunk's address
+ is parked in `next_addr` until the current one retires.
+* `go_flag` has to be held until the controller actually takes it. Leaving
+ `issue_read` on `spi_hw_busy = '0'` looks right and is not: the controller
+ enforces a minimum `cs_n` high time between transactions and ignores `go_flag`
+ until it expires, whereas `spi_hw_busy` drops the moment `cs_n` rises. Exiting
+ on that turns `go_flag` into a one-cycle pulse inside the dead window, the
+ command is dropped, and every chunk after the first is stranded. The first
+ chunk survives because the controller has been idle long enough for the window
+ to have expired already, which is exactly why single-chunk reads passed
+ throughout.
+
+A third came out of the sector-bypass case specifically. The flash command used to
+be issued as soon as a run started, before any of the 0xFF prepend had been fed.
+The backend begins fetching the moment the command lands and has no way to be told
+to wait, so with a 4 KiB prepend the response FIFO filled while the engine was
+still feeding 0xFF and everything past its depth was dropped -- `wfull` on that
+FIFO is not wired anywhere. The hash then waited forever for data that had been
+thrown away. The command is now issued only once the prepend has been fed, which
+costs one flash latency and removes the window entirely. Note this is exactly the
+configuration the hardware script uses, so it was worth having a test for.
+
+[WARNING]
+====
+The `go_flag` problem is not confined to this block. `espi_flash_txn_mgr` has the same
+`if spi_hw_busy = '0'` exit and the same chunking arithmetic, so an eSPI flash
+read longer than 256 bytes would over-fetch and then strand itself. It is latent
+today only because hosts request small payloads -- the RTL accepts a 12 bit
+length, so up to 4096 bytes, which is sixteen chunks. Left alone here rather than
+changed blind on a boot path with no multi-chunk test to regress it against.
+====
+
+Every expected digest in both testbenches is computed by `sha3_sim_pkg`'s software
+sponge over a queue built to match, so none of them is a transcribed constant. The
+SHA3 core itself is not under test here; `keccak_pkg_tb` anchors that against
+published vectors.
+
+== Testing on hardware
+
+`tools/hash_engine_flash_test.py` drives the host-flash path on a real board over
+the FMC bus, using humility's `FmcDemo.peek32` and `poke32`. Give it the ROM image
+that is programmed into the host flash and it configures the engine, waits for the
+digest and compares it against one computed in software:
+
+[source,bash]
+----
+./tools/hash_engine_flash_test.py --rom cosmo-host.bin
+
+./tools/hash_engine_flash_test.py --rom img.bin --dry-run # show the pokes only
+./tools/hash_engine_flash_test.py --selftest # arithmetic, no board
+----
+
+`tools/hash_engine_vectors_test.py` runs the published SHA3-256 vectors from
+https://di-mgt.com.au/sha_testvectors.html through the manual path instead, with
+`CONFIG.source = LOCAL_REG` and the message written a word at a time into `WDATA`.
+That measures the engine against the standard rather than against our own model of
+it, and touches none of the flash machinery:
+
+[source,bash]
+----
+./tools/hash_engine_vectors_test.py
+./tools/hash_engine_vectors_test.py --selftest # table vs hashlib, no board
+./tools/hash_engine_vectors_test.py --include-million # hours: 250,000 writes
+----
+
+Three of the six vectors are special. The empty message cannot be expressed on an
+AXI stream, so the script checks the engine *rejects* `LENGTH = 0` with `cfg_err`
+rather than skipping it. The million byte vector is 250,000 register writes, each
+a separate humility process, so it is behind a flag. The ~1 GB vector is never run.
+
+The same three short vectors are also checked in simulation, as `nist_vectors` in
+`hash_engine_tb`, so the expectations the script carries are known good against the
+RTL before any board is involved.
+
+=== Sector bypass
+
+The first sector of the host flash is not part of what is being measured, so it is
+hashed as a run of `0xFF` instead of as whatever the part actually holds. There is
+no dedicated register for this -- it falls out of two that already exist:
+
+[source]
+----
+PREPEND = sector size feed this many 0xFF bytes first
+FLASH_ADDR = sector size then start fetching one sector in
+LENGTH = image size total, counting the 0xFF run
+----
+
+which makes the message `0xFF * sector` followed by the image from the second
+sector on. The script builds its expected digest the same way from the ROM file,
+so the two agree by construction. `--sector-size` defaults to 0x1000, matching
+`SECTOR_BYTES` in the SPI NOR verification component; override it if the part in
+use differs. `--no-bypass` hashes straight through from offset 0 for comparison.
+
+If `FmcDemo.peek32` wants absolute STM32 addresses rather than FPGA-relative ones,
+pass the window base with `--base`.
+
+=== Running
+
+[source,bash]
+----
+buck2 run //hdl/ip/vhd/hash_engine:hash_engine_tb
+buck2 run //hdl/ip/vhd/hash_engine:hash_spi_nor_tb
+
+# a single test case is selected positionally, not with --test-case
+buck2 run //hdl/ip/vhd/hash_engine:hash_engine_tb -- "*abort_midway*"
+
+# the SPI controller regression, which the new client port touches
+buck2 run //hdl/ip/vhd/spi_nor_controller:spi_nor_top_sim
+----
+
+== Integration
+
+Instantiated in `cosmo_seq` at *0x0700*, inside `sp5_espi_flash_subsystem` rather
+than at the top level, because that is where `spi_nor_top` and the eSPI flash
+FIFOs already live and the engine needs the same pattern. The subsystem gained a
+`hash_axi_if` port, its own pair of 256-deep command and response FIFOs, and the
+wiring to `spi_nor_top`'s second client port.
+
+Those FIFOs are reset from `reset_125m` only, deliberately not from the
+subsystem's `fifo_reset`. That one is pulsed on every eSPI reset, which happens at
+the start of every host boot and has nothing to do with a hash the SP may have in
+flight.
+
+At the top level `HASH_RESP_IDX` is appended to `config_array` as index 8. The
+fabric decodes each entry by address range, so index order is independent of
+address order and nothing else in the map moved:
+
+----
+0x0000 info 0x0500 dimms
+0x0100 spi_nor 0x0600 debug_ctrl
+0x0200 sequencer 0x0700 hash <- new
+0x0300 sp_i2c 0x8000 espi
+0x0400 fpga1_hotplug
+----
+
+Measured in the routed `cosmo_seq` bitstream on `xc7s100fgga484-1`:
+
+[cols="3,1,1,1"]
+|===
+|Instance |LUT |FF |RAMB18
+
+|`hash_engine_inst` (total) |4945 |3480 |1
+|`sha3_256_inst` |4433 |2979 |0
+|`hash_engine_regs_inst` |83 |134 |0
+|===
+
+That is about 4.8% of the part, taking the whole design to 29148 of 102400 LUTs.
+The single block RAM is XPM's choice for the mixed-width software data FIFO. The
+core comes in below its out-of-context figure because `busy` is left open here and
+the surrounding logic optimises away.
+
+Timing still closes with all constraints met. The two worst paths in the design are
+in the DIMM SPD proxy and the eSPI link layer, not in anything added here.
+
+Building `spi_nor_top`'s new client port into the design exposed a latent bug in
+`dcfifo_mixed_xpm`: its `PROG_FULL_THRESH` expression divides by a value that is
+larger than the numerator whenever the read port is narrower than the write port,
+so it computed an out-of-range threshold and Vivado refused to synthesise. Nothing
+had synthesised a narrowing mixed-width FIFO before, so simulation never saw it.
+Fixed in the wrapper.
+
+`grapefruit` has it too, also at *0x0700*. That board instantiates `spi_nor_top`
+directly at the top level rather than inside a subsystem, so the FIFOs and the
+engine sit alongside the eSPI pair in `grapefruit_top` instead. Its responder is
+appended as index 4. The offset deliberately matches cosmo_seq even though
+grapefruit had 0x0300 free, so one tooling default covers both boards.
+
+=== A caveat on arbitration
+
+The grant in `spi_nor_top` is taken whenever the SPI engine is idle and no eSPI
+command is pending, and it is then held for the whole hash command. Nothing
+prevents that from happening while the host is booting, and a hash of a large
+range would block eSPI flash reads for as long as it runs.
+
+The intended usage is to measure while the host is down, but that is a convention,
+not an interlock. If it needs to be enforced, the cheapest change is to add
+`spicr.sp5_owns_flash = '0'` to the grant condition, which would defer hashing
+whenever the host owns the flash. That has its own cost: `busy` would then stay
+asserted indefinitely while the host holds the flash, with no timeout for software
+to notice.
+
+A real SPI NOR device model would let `hash_spi_nor_tb` check content-dependent
+behaviour rather than a run of `0xFF`. The tree has wanted one for a while;
+`spi_nor_tb` says as much.
diff --git a/hdl/ip/vhd/hash_engine/docs/hash_engine_block.drawio.svg b/hdl/ip/vhd/hash_engine/docs/hash_engine_block.drawio.svg
new file mode 100644
index 00000000..381bc23f
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/docs/hash_engine_block.drawio.svg
@@ -0,0 +1,13 @@
+
+
+
+
diff --git a/hdl/ip/vhd/hash_engine/hash_engine_regs.rdl b/hdl/ip/vhd/hash_engine/hash_engine_regs.rdl
new file mode 100644
index 00000000..ac09dbe2
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/hash_engine_regs.rdl
@@ -0,0 +1,177 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this
+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
+//
+// SystemRDL description of the sw-accessible registers for the SHA3-256
+// hashing engine.
+
+addrmap hash_engine_regs {
+ name = "SHA3-256 hashing engine";
+ desc = "Hashes either a range of the host QSPI flash or data fed in by the
+ processor, optionally prefixed with a run of 0xFF bytes.";
+
+ default regwidth = 32;
+ default sw = rw;
+ default hw = r;
+
+ reg {
+ name = "Control Register";
+ desc = "";
+
+ field {
+ desc = "Write 1 to abort a hash in flight. Self clearing. The engine
+ returns to idle, clears busy and sets STATUS.aborted. Any digest from
+ a previous run is discarded.";
+ } abort[1:1] = 0;
+
+ field {
+ desc = "Write 1 to start a hash. Self clearing. Writing this while a
+ hash is already running restarts it from the beginning with the
+ currently programmed configuration. Configuration is sampled at start,
+ so changing CONFIG/PREPEND/FLASH_ADDR/LENGTH mid-hash has no effect on
+ the run in flight.";
+ } start[0:0] = 0;
+ } CONTROL;
+
+ reg {
+ name = "Configuration Register";
+ desc = "";
+
+ enum source_select {
+ LOCAL_REG = 4'h0 {desc = "Bytes come from processor writes to WDATA";};
+ HOST_QSPI = 4'h1 {desc = "Bytes are fetched from the host QSPI flash starting at FLASH_ADDR";};
+ };
+
+ field {
+ desc = "Selects where message bytes come from once any prepended 0xFF
+ bytes have been fed. 4 bits so more sources can be added later.";
+ encode = source_select;
+ } source[3:0] = 0;
+ } CONFIG;
+
+ reg {
+ name = "Prepend Byte Count";
+ desc = "Number of 0xFF bytes fed into the hash before any bytes from the
+ selected source. Counts towards LENGTH.";
+
+ field {
+ desc = "0xFF byte count";
+ } count[31:0] = 0;
+ } PREPEND;
+
+ reg {
+ name = "Flash Start Address";
+ desc = "Raw byte address in the flash device for the first fetched byte.
+ This is not remapped: unlike the eSPI flash read path there is no SP5 image
+ or APOB translation applied. Only used when CONFIG.source is HOST_QSPI.";
+
+ field {
+ desc = "Flash byte address";
+ } addr[31:0] = 0;
+ } FLASH_ADDR;
+
+ reg {
+ name = "Message Length";
+ desc = "Total number of bytes to hash, *including* the PREPEND 0xFF bytes.
+ The number of bytes taken from the selected source is LENGTH - PREPEND.
+ Must be non-zero and at least PREPEND, see STATUS.cfg_err.";
+
+ field {
+ desc = "Total message length in bytes";
+ } count[31:0] = 0;
+ } LENGTH;
+
+ reg {
+ name = "Status Register";
+ desc = "";
+ default sw = r;
+ default hw = w;
+
+ field {
+ desc = "Set when a start was refused because the configuration is
+ invalid: LENGTH of zero, or PREPEND greater than LENGTH. Cleared on the
+ next accepted start.";
+ } cfg_err[5:5] = 0;
+
+ field {
+ desc = "Set when a hash was ended early by CONTROL.abort. Cleared on
+ the next accepted start.";
+ } aborted[4:4] = 0;
+
+ field {
+ desc = "Software data FIFO is empty.";
+ } wfifo_empty[3:3] = 0;
+
+ field {
+ desc = "Software data FIFO is full. Writes to WDATA while this is set
+ are dropped, so poll it when feeding from LOCAL_REG.";
+ } wfifo_full[2:2] = 0;
+
+ field {
+ desc = "Set when a hash has completed and the DIGEST registers hold a
+ valid result. Cleared on the next accepted start or on abort.";
+ } done[1:1] = 0;
+
+ field {
+ desc = "Set from an accepted start until the digest is available or the
+ hash is aborted.";
+ } busy[0:0] = 0;
+ } STATUS;
+
+ reg {
+ name = "Software Data Register";
+ desc = "Writing pushes 4 message bytes into the software data FIFO, least
+ significant byte first, ie bits 7:0 are consumed before bits 15:8. Only used
+ when CONFIG.source is LOCAL_REG. LENGTH determines where the message ends,
+ so unused trailing bytes of the final word are ignored and no byte enable is
+ needed. Check STATUS.wfifo_full before writing.";
+
+ field {
+ desc = "Four message bytes";
+ } data[31:0] = 0;
+ } WDATA;
+
+ reg {
+ name = "Progress";
+ desc = "Number of bytes fed into the hash core so far for the run in
+ flight, including prepended 0xFF bytes. Reads as the final count after a
+ hash completes.";
+ default sw = r;
+ default hw = w;
+
+ field {
+ desc = "Bytes processed";
+ } bytes[31:0] = 0;
+ } PROGRESS;
+
+ // The digest is presented least significant word first, matching the core's
+ // byte order: DIGEST0 bits 7:0 are hash byte 0, the leftmost byte of the
+ // conventional hex string. Worked example, sha3-256("abc"):
+ // 3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532
+ // reads back as DIGEST0 = 0xa75d983a and DIGEST7 = 0x32154311.
+ reg digest_word {
+ default sw = r;
+ default hw = w;
+
+ field {
+ desc = "32 bits of the digest";
+ } data[31:0] = 0;
+ };
+
+ digest_word DIGEST0;
+ DIGEST0->desc = "Digest bytes 0 to 3";
+ digest_word DIGEST1;
+ DIGEST1->desc = "Digest bytes 4 to 7";
+ digest_word DIGEST2;
+ DIGEST2->desc = "Digest bytes 8 to 11";
+ digest_word DIGEST3;
+ DIGEST3->desc = "Digest bytes 12 to 15";
+ digest_word DIGEST4;
+ DIGEST4->desc = "Digest bytes 16 to 19";
+ digest_word DIGEST5;
+ DIGEST5->desc = "Digest bytes 20 to 23";
+ digest_word DIGEST6;
+ DIGEST6->desc = "Digest bytes 24 to 27";
+ digest_word DIGEST7;
+ DIGEST7->desc = "Digest bytes 28 to 31";
+};
diff --git a/hdl/ip/vhd/hash_engine/hash_engine_regs.vhd b/hdl/ip/vhd/hash_engine/hash_engine_regs.vhd
new file mode 100644
index 00000000..8624222d
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/hash_engine_regs.vhd
@@ -0,0 +1,145 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+use work.axil8x32_pkg.all;
+use work.hash_engine_regs_pkg.all;
+
+-- AXI-Lite register block for the hashing engine. The RDL tooling only
+-- generates types and constants, so all the behaviour lives here: this follows
+-- the same shape as spi_nor_regs.
+entity hash_engine_regs is
+ port (
+ clk : in std_logic;
+ reset : in std_logic;
+
+ -- axi interface
+ axi_if : view axil_target;
+
+ -- Control strobes. CONTROL.start and CONTROL.abort are self clearing, so
+ -- they leave here as single cycle pulses rather than as a register.
+ start_strobe : out std_logic;
+ abort_strobe : out std_logic;
+
+ -- Configuration, sampled by the feeder when it accepts a start
+ cfg : out config_type;
+ prepend : out prepend_type;
+ flash_addr : out flash_addr_type;
+ msg_length : out length_type;
+
+ -- Status back from the engine
+ status : in status_type;
+ progress : in progress_type;
+ -- Bit 7 downto 0 is hash byte 0, so DIGESTn is digest(32n+31 downto 32n)
+ digest : in std_logic_vector(255 downto 0);
+
+ -- Software data FIFO push port
+ wdata_fifo_wdata : out std_logic_vector(31 downto 0);
+ wdata_fifo_write : out std_logic
+ );
+end entity;
+
+architecture rtl of hash_engine_regs is
+
+ signal rdata : std_logic_vector(31 downto 0);
+ signal active_read : std_logic;
+ signal active_write : std_logic;
+
+begin
+
+ axil_target_txn_inst: entity work.axil_target_txn
+ port map (
+ clk => clk,
+ reset => reset,
+ arvalid => axi_if.read_address.valid,
+ arready => axi_if.read_address.ready,
+ awvalid => axi_if.write_address.valid,
+ awready => axi_if.write_address.ready,
+ wvalid => axi_if.write_data.valid,
+ wready => axi_if.write_data.ready,
+ bvalid => axi_if.write_response.valid,
+ bready => axi_if.write_response.ready,
+ bresp => axi_if.write_response.resp,
+ rvalid => axi_if.read_data.valid,
+ rready => axi_if.read_data.ready,
+ rresp => axi_if.read_data.resp,
+ active_read => active_read,
+ active_write => active_write
+ );
+
+ axi_if.read_data.data <= rdata;
+
+ -- The FIFO push is a decoded write strobe rather than a stored register. The
+ -- FIFO itself drops the write when full, and STATUS.wfifo_full is how software
+ -- is expected to avoid that.
+ wdata_fifo_wdata <= axi_if.write_data.data;
+ wdata_fifo_write <= '1' when active_write = '1' and
+ to_integer(axi_if.write_address.addr) = WDATA_OFFSET else '0';
+
+-- vsg_off
+write_logic: process(clk, reset)
+begin
+ if reset then
+ cfg <= rec_reset;
+ prepend <= rec_reset;
+ flash_addr <= rec_reset;
+ msg_length <= rec_reset;
+ start_strobe <= '0';
+ abort_strobe <= '0';
+ elsif rising_edge(clk) then
+ -- CONTROL bits are self clearing
+ start_strobe <= '0';
+ abort_strobe <= '0';
+ if active_write then
+ case to_integer(axi_if.write_address.addr) is
+ when CONTROL_OFFSET =>
+ start_strobe <= axi_if.write_data.data(0);
+ abort_strobe <= axi_if.write_data.data(1);
+ when CONFIG_OFFSET => cfg <= unpack(axi_if.write_data.data);
+ when PREPEND_OFFSET => prepend <= unpack(axi_if.write_data.data);
+ when FLASH_ADDR_OFFSET => flash_addr <= unpack(axi_if.write_data.data);
+ when LENGTH_OFFSET => msg_length <= unpack(axi_if.write_data.data);
+ when others => null;
+ end case;
+ end if;
+ end if;
+end process;
+
+read_logic: process(clk, reset)
+begin
+ if reset then
+ rdata <= (others => '0');
+ elsif rising_edge(clk) then
+ if active_read then
+ case to_integer(axi_if.read_address.addr) is
+ -- CONTROL always reads zero: both its bits self clear.
+ when CONTROL_OFFSET => rdata <= (others => '0');
+ when CONFIG_OFFSET => rdata <= pack(cfg);
+ when PREPEND_OFFSET => rdata <= pack(prepend);
+ when FLASH_ADDR_OFFSET => rdata <= pack(flash_addr);
+ when LENGTH_OFFSET => rdata <= pack(msg_length);
+ when STATUS_OFFSET => rdata <= pack(status);
+ -- WDATA is write only, the read side of the FIFO belongs to the feeder.
+ when WDATA_OFFSET => rdata <= (others => '0');
+ when PROGRESS_OFFSET => rdata <= pack(progress);
+ when DIGEST0_OFFSET => rdata <= digest(31 downto 0);
+ when DIGEST1_OFFSET => rdata <= digest(63 downto 32);
+ when DIGEST2_OFFSET => rdata <= digest(95 downto 64);
+ when DIGEST3_OFFSET => rdata <= digest(127 downto 96);
+ when DIGEST4_OFFSET => rdata <= digest(159 downto 128);
+ when DIGEST5_OFFSET => rdata <= digest(191 downto 160);
+ when DIGEST6_OFFSET => rdata <= digest(223 downto 192);
+ when DIGEST7_OFFSET => rdata <= digest(255 downto 224);
+ when others => rdata <= (others => '0');
+ end case;
+ end if;
+ end if;
+end process;
+-- vsg_on
+
+end rtl;
diff --git a/hdl/ip/vhd/hash_engine/hash_engine_top.vhd b/hdl/ip/vhd/hash_engine/hash_engine_top.vhd
new file mode 100644
index 00000000..a6b7ff13
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/hash_engine_top.vhd
@@ -0,0 +1,179 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+use work.axi_st8_pkg;
+use work.axil8x32_pkg.all;
+use work.hash_engine_regs_pkg.all;
+use work.keccak_pkg.all;
+
+-- SHA3-256 hashing engine with an AXI-Lite register interface.
+--
+-- Hashes either a range of the host QSPI flash or data written in through a
+-- register, optionally prefixed with a run of 0xFF bytes. The result appears in
+-- eight read-only digest registers.
+--
+-- Flash bytes are fetched over a command/response FIFO channel in the same shape
+-- as the eSPI flash channel: a 32-bit command FIFO taking an address word then a
+-- length word, and an 8-bit response FIFO of data bytes. Those FIFOs live in the
+-- integrating design, as they do for eSPI, and the far end is a second client
+-- port on spi_nor_top. Unlike the eSPI path these addresses are raw: no SP5 image
+-- or APOB translation is applied.
+--
+-- The integrator should hold the response FIFO in reset only from the global
+-- reset. This block never asks for it to be flushed: an abandoned read is dealt
+-- with by consuming the bytes still owed, see hash_feeder.
+entity hash_engine_top is
+ port (
+ clk : in std_logic;
+ reset : in std_logic;
+
+ -- Axilite interface
+ axi_if : view axil_target;
+
+ -- Flash read command FIFO: word 0 is a byte address, word 1 a byte count
+ cmd_fifo_wdata : out std_logic_vector(31 downto 0);
+ cmd_fifo_write : out std_logic;
+
+ -- Flash read response FIFO, showahead so rdack is a read acknowledge
+ rsp_fifo_rdata : in std_logic_vector(7 downto 0);
+ rsp_fifo_rdack : out std_logic;
+ rsp_fifo_rempty : in std_logic
+ );
+end entity;
+
+architecture rtl of hash_engine_top is
+
+ -- 64 words of 32 bits, ie 256 bytes, matching the spi_nor TX FIFO
+ constant SW_FIFO_DEPTH : integer := 64;
+
+ signal start_strobe : std_logic;
+ signal abort_strobe : std_logic;
+
+ signal cfg : config_type;
+ signal prepend : prepend_type;
+ signal flash_addr : flash_addr_type;
+ signal msg_length : length_type;
+
+ signal status : status_type;
+ signal progress : progress_type;
+
+ signal sw_fifo_wdata : std_logic_vector(31 downto 0);
+ signal sw_fifo_write : std_logic;
+ signal sw_fifo_rdata : std_logic_vector(7 downto 0);
+ signal sw_fifo_rdack : std_logic;
+ signal sw_fifo_rempty : std_logic;
+ signal sw_fifo_wfull : std_logic;
+ signal sw_fifo_reset : std_logic;
+
+ signal sw_clear : std_logic;
+
+ signal sha3_init : std_logic;
+ signal msg_stream : axi_st8_pkg.axi_st_pkt_t;
+ signal digest : digest_t;
+ signal digest_valid : std_logic;
+
+begin
+
+ hash_engine_regs_inst: entity work.hash_engine_regs
+ port map (
+ clk => clk,
+ reset => reset,
+ axi_if => axi_if,
+ start_strobe => start_strobe,
+ abort_strobe => abort_strobe,
+ cfg => cfg,
+ prepend => prepend,
+ flash_addr => flash_addr,
+ msg_length => msg_length,
+ status => status,
+ progress => progress,
+ digest => digest,
+ wdata_fifo_wdata => sw_fifo_wdata,
+ wdata_fifo_write => sw_fifo_write
+ );
+
+ -- Software data path. Written 32 bits at a time by the processor and read a
+ -- byte at a time by the feeder, least significant byte first.
+ sw_fifo_reset <= reset or sw_clear;
+
+ sw_data_fifo: entity work.dcfifo_mixed_xpm
+ generic map (
+ wfifo_write_depth => SW_FIFO_DEPTH,
+ wdata_width => 32,
+ rdata_width => 8,
+ showahead_mode => true
+ )
+ port map (
+ wclk => clk,
+ reset => sw_fifo_reset,
+ write_en => sw_fifo_write,
+ wdata => sw_fifo_wdata,
+ wfull => sw_fifo_wfull,
+ wusedwds => open,
+ rclk => clk,
+ rdata => sw_fifo_rdata,
+ rdreq => sw_fifo_rdack,
+ rempty => sw_fifo_rempty,
+ rusedwds => open
+ );
+
+ hash_feeder_inst: entity work.hash_feeder
+ port map (
+ clk => clk,
+ reset => reset,
+ start_strobe => start_strobe,
+ abort_strobe => abort_strobe,
+ cfg => cfg,
+ prepend => prepend,
+ flash_addr => flash_addr,
+ msg_length => msg_length,
+ busy => status.busy,
+ done => status.done,
+ aborted => status.aborted,
+ cfg_err => status.cfg_err,
+ bytes_fed => progress.bytes,
+ sha3_init => sha3_init,
+ msg_if => msg_stream,
+ digest_valid => digest_valid,
+ sw_fifo_rdata => sw_fifo_rdata,
+ sw_fifo_rdack => sw_fifo_rdack,
+ sw_fifo_rempty => sw_fifo_rempty,
+ sw_fifo_clear => sw_clear,
+ cmd_fifo_wdata => cmd_fifo_wdata,
+ cmd_fifo_write => cmd_fifo_write,
+ rsp_fifo_rdata => rsp_fifo_rdata,
+ rsp_fifo_rdack => rsp_fifo_rdack,
+ rsp_fifo_rempty => rsp_fifo_rempty
+ );
+
+ -- Also report full while the FIFO is being flushed at the tail of a run, so a
+ -- processor that polls before writing cannot push bytes into a FIFO that is in
+ -- reset. The flush only happens when a run ends, never as one starts, which is
+ -- what keeps this from racing with software. See hash_feeder.
+ status.wfifo_full <= sw_fifo_wfull or sw_clear;
+ status.wfifo_empty <= sw_fifo_rempty;
+
+ -- Single buffered on purpose. Quad rate flash delivers roughly a byte every
+ -- eight clocks and the register path is slower still, so the core's 25 cycle
+ -- permutation always hides inside the gap between bytes. The second 1088 bit
+ -- block register would be dead area here.
+ sha3_256_inst: entity work.sha3_256
+ generic map (
+ DOUBLE_BUFFER => false
+ )
+ port map (
+ clk => clk,
+ reset => reset,
+ init => sha3_init,
+ busy => open,
+ msg_if => msg_stream,
+ digest => digest,
+ digest_valid => digest_valid
+ );
+
+end rtl;
diff --git a/hdl/ip/vhd/hash_engine/hash_feeder.vhd b/hdl/ip/vhd/hash_engine/hash_feeder.vhd
new file mode 100644
index 00000000..2407f70a
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/hash_feeder.vhd
@@ -0,0 +1,359 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+use work.axi_st8_pkg;
+use work.hash_engine_regs_pkg.all;
+
+-- Feeds the SHA3-256 core: a run of 0xFF bytes, then bytes from either the
+-- software data FIFO or the host QSPI flash, then waits for the digest.
+--
+-- Flash fetching mirrors the eSPI flash channel: one command of (address,
+-- length) is pushed into a 32-bit command FIFO and the bytes come back on an
+-- 8-bit response FIFO. Splitting that into <= 256 byte reads is the transaction
+-- manager's job on the far side, so this block only counts bytes.
+--
+-- Two things here are less obvious than they look.
+--
+-- Abandoning a hash while a flash read is in flight. The transaction manager
+-- cannot be called off, so its remaining bytes would leak into whatever ran
+-- next. Rather than try to stop it, an abort or a restart goes through DRAIN and
+-- discards exactly the bytes still owed. That is why busy stays asserted after an
+-- abort until the channel is resynchronised.
+--
+-- When the software data FIFO gets flushed. It would be natural to flush it as a
+-- run starts, but that races with software: a processor that polls wfifo_full,
+-- sees it clear, and then writes can have its write land inside the flush window
+-- and silently disappear. So the flush happens at the *end* of a run instead, in
+-- FLUSH, when software is waiting on status rather than feeding. A run therefore
+-- begins with a FIFO that is already known clean and needs no flush at all, which
+-- removes the race entirely. It also means data written before the first start is
+-- kept, so pre-loading works.
+entity hash_feeder is
+ port (
+ clk : in std_logic;
+ reset : in std_logic;
+
+ -- Control strobes from the register block
+ start_strobe : in std_logic;
+ abort_strobe : in std_logic;
+
+ -- Configuration, sampled when a start is accepted
+ cfg : in config_type;
+ prepend : in prepend_type;
+ flash_addr : in flash_addr_type;
+ msg_length : in length_type;
+
+ -- Status
+ busy : out std_logic;
+ done : out std_logic;
+ aborted : out std_logic;
+ cfg_err : out std_logic;
+ bytes_fed : out std_logic_vector(31 downto 0);
+
+ -- SHA3 core
+ sha3_init : out std_logic;
+ msg_if : view axi_st8_pkg.axi_st_pkt_source;
+ digest_valid : in std_logic;
+
+ -- Software data FIFO, 8 bit read side, showahead
+ sw_fifo_rdata : in std_logic_vector(7 downto 0);
+ sw_fifo_rdack : out std_logic;
+ sw_fifo_rempty : in std_logic;
+
+ -- Hold the software data FIFO in reset. Asserted only in FLUSH, ie once a
+ -- run has finished or been abandoned.
+ --
+ -- Note this deliberately does not extend to the flash response FIFO. DRAIN
+ -- already leaves that channel synchronised by consuming exactly the bytes
+ -- still owed, so resetting it would be redundant, and it is actively
+ -- harmful: on a restart the next read begins within a few cycles of the
+ -- flush and the backend's first bytes land while the FIFO is still
+ -- recovering from reset, where they are silently dropped and the hash
+ -- hangs waiting for them.
+ sw_fifo_clear : out std_logic;
+
+ -- Flash command FIFO: word 0 is the byte address, word 1 the byte count
+ cmd_fifo_wdata : out std_logic_vector(31 downto 0);
+ cmd_fifo_write : out std_logic;
+
+ -- Flash response FIFO, showahead
+ rsp_fifo_rdata : in std_logic_vector(7 downto 0);
+ rsp_fifo_rdack : out std_logic;
+ rsp_fifo_rempty : in std_logic
+ );
+end entity;
+
+architecture rtl of hash_feeder is
+
+ -- The XPM FIFOs need their reset held for more than one cycle. The eSPI
+ -- subsystem stretches its clear the same way.
+ constant CLEAR_CYCLES : natural := 15;
+
+ type state_t is (IDLE, PRIME, CMD_ADDR, CMD_LEN, RUN, WAIT_DIGEST, DRAIN, FLUSH);
+
+ type reg_t is record
+ state : state_t;
+ total_len : unsigned(31 downto 0);
+ prepend_cnt : unsigned(31 downto 0);
+ -- Bytes asked of the flash for this run, and how many have come back
+ flash_req : unsigned(31 downto 0);
+ flash_rx : unsigned(31 downto 0);
+ -- Bytes still owed by an abandoned flash read
+ drain_left : unsigned(31 downto 0);
+ fed : unsigned(31 downto 0);
+ addr : std_logic_vector(31 downto 0);
+ src_qspi : std_logic;
+ clear_cnt : natural range 0 to CLEAR_CYCLES;
+ -- Set when the flush should be followed by a new run rather than idling
+ restart : std_logic;
+ -- Set when the flush is following a hash that actually completed
+ finished : std_logic;
+ -- Set once this run's flash command has been pushed
+ cmd_sent : std_logic;
+ busy : std_logic;
+ done : std_logic;
+ aborted : std_logic;
+ cfg_err : std_logic;
+ init : std_logic;
+ end record;
+
+ constant REG_RESET : reg_t := (
+ state => IDLE,
+ total_len => (others => '0'),
+ prepend_cnt => (others => '0'),
+ flash_req => (others => '0'),
+ flash_rx => (others => '0'),
+ drain_left => (others => '0'),
+ fed => (others => '0'),
+ addr => (others => '0'),
+ src_qspi => '0',
+ clear_cnt => 0,
+ restart => '0',
+ finished => '0',
+ cmd_sent => '0',
+ busy => '0',
+ done => '0',
+ aborted => '0',
+ cfg_err => '0',
+ init => '0'
+ );
+
+ signal r, rin : reg_t;
+
+ -- Combinational view of the byte we are currently offering the core
+ signal in_prepend : std_logic;
+ signal src_data : std_logic_vector(7 downto 0);
+ signal src_valid : std_logic;
+ signal beat : std_logic;
+
+begin
+
+ in_prepend <= '1' when r.fed < r.prepend_cnt else '0';
+
+ src_data <= x"FF" when in_prepend = '1' else
+ rsp_fifo_rdata when r.src_qspi = '1' else
+ sw_fifo_rdata;
+
+ src_valid <= '1' when in_prepend = '1' else
+ not rsp_fifo_rempty when r.src_qspi = '1' else
+ not sw_fifo_rempty;
+
+ msg_if.valid <= '1' when r.state = RUN and src_valid = '1' else '0';
+ msg_if.data <= src_data;
+ msg_if.last <= '1' when r.state = RUN and r.fed = r.total_len - 1 else '0';
+
+ beat <= '1' when r.state = RUN and src_valid = '1' and msg_if.ready = '1' else '0';
+
+ -- Pop the source FIFO on an accepted beat, and keep popping in DRAIN to throw
+ -- away the tail of an abandoned flash read.
+ sw_fifo_rdack <= '1' when beat = '1' and in_prepend = '0' and r.src_qspi = '0' else '0';
+
+ rsp_fifo_rdack <= '1' when (beat = '1' and in_prepend = '0' and r.src_qspi = '1') or
+ (r.state = DRAIN and rsp_fifo_rempty = '0' and r.drain_left > 0)
+ else '0';
+
+ cmd_fifo_wdata <= r.addr when r.state = CMD_ADDR else
+ std_logic_vector(r.flash_req);
+ cmd_fifo_write <= '1' when r.state = CMD_ADDR or r.state = CMD_LEN else '0';
+
+ busy <= r.busy;
+ done <= r.done;
+ aborted <= r.aborted;
+ cfg_err <= r.cfg_err;
+ bytes_fed <= std_logic_vector(r.fed);
+ sha3_init <= r.init;
+
+ sw_fifo_clear <= '1' when r.state = FLUSH else '0';
+
+ comb: process(all)
+
+ variable v : reg_t;
+ variable accepted : boolean;
+ variable do_begin : boolean;
+ variable stop_run : boolean;
+
+ begin
+ v := r;
+
+ v.init := '0';
+ do_begin := false;
+ stop_run := false;
+
+ -- A start is refused outright if the configuration cannot produce a
+ -- message: the core has no way to express a zero length one, and a prepend
+ -- longer than the message is simply nonsense.
+ accepted := start_strobe = '1' and
+ unsigned(msg_length.count) /= 0 and
+ unsigned(prepend.count) <= unsigned(msg_length.count);
+
+ if start_strobe = '1' and not accepted then
+ v.cfg_err := '1';
+ end if;
+
+ -- Latch configuration the moment a start is accepted, whether from idle or
+ -- as a restart part way through a run.
+ if accepted then
+ v.total_len := unsigned(msg_length.count);
+ v.prepend_cnt := unsigned(prepend.count);
+ v.flash_req := unsigned(msg_length.count) - unsigned(prepend.count);
+ v.addr := flash_addr.addr;
+ v.src_qspi := '1' when cfg.source = HOST_QSPI else '0';
+ v.cfg_err := '0';
+ v.aborted := '0';
+ v.done := '0';
+ v.busy := '1';
+
+ if r.state = IDLE then
+ -- Nothing to abandon and the FIFOs were flushed when the previous
+ -- run ended, so start straight away.
+ do_begin := true;
+ else
+ v.restart := '1';
+ stop_run := true;
+ end if;
+ elsif abort_strobe = '1' and r.state /= IDLE then
+ v.aborted := '1';
+ v.done := '0';
+ v.restart := '0';
+ stop_run := true;
+ end if;
+
+ if stop_run then
+ -- Work out what the flash still owes us so DRAIN can swallow it.
+ v.drain_left := r.flash_req - r.flash_rx;
+ v.finished := '0';
+ v.state := DRAIN;
+ else
+
+ case r.state is
+
+ when IDLE =>
+ null;
+
+ when PRIME =>
+ -- One cycle so the core sees sha3_init before we offer it a
+ -- byte. Without this the reset and the first beat land on the
+ -- same edge and the byte is swallowed.
+ v.state := RUN;
+
+ when CMD_ADDR =>
+ v.state := CMD_LEN;
+
+ when CMD_LEN =>
+ v.cmd_sent := '1';
+ v.state := RUN;
+
+ when RUN =>
+ -- Ask for the flash only once the prepend has been fed, never
+ -- before it. The backend starts fetching the moment the
+ -- command lands and has no way to be told to wait, so issuing
+ -- it up front means the response FIFO fills while we are still
+ -- feeding 0xFF and the bytes past its depth are dropped on the
+ -- floor. The hash then waits forever for data that was thrown
+ -- away. Deferring costs one flash latency and removes the
+ -- window entirely.
+ --
+ -- in_prepend has just gone low here, and the response FIFO is
+ -- empty, so no beat is being passed up by diverting.
+ if r.src_qspi = '1' and r.cmd_sent = '0' and
+ r.flash_req > 0 and in_prepend = '0' then
+ v.state := CMD_ADDR;
+ elsif beat = '1' then
+ v.fed := r.fed + 1;
+
+ if in_prepend = '0' and r.src_qspi = '1' then
+ v.flash_rx := r.flash_rx + 1;
+ end if;
+
+ if r.fed = r.total_len - 1 then
+ v.state := WAIT_DIGEST;
+ end if;
+ end if;
+
+ when WAIT_DIGEST =>
+ if digest_valid then
+ -- The digest registers are fed straight from the core, so
+ -- there is nothing to latch. Flush before reporting done so
+ -- that done also means "ready to run again".
+ v.finished := '1';
+ v.clear_cnt := CLEAR_CYCLES;
+ v.state := FLUSH;
+ end if;
+
+ when DRAIN =>
+ -- Discard the tail of an abandoned flash read. For a software
+ -- fed hash there is nothing owed and this falls straight
+ -- through.
+ if rsp_fifo_rdack = '1' then
+ v.drain_left := r.drain_left - 1;
+ end if;
+
+ if r.drain_left = 0 then
+ v.clear_cnt := CLEAR_CYCLES;
+ v.state := FLUSH;
+ end if;
+
+ when FLUSH =>
+ if r.clear_cnt = 0 then
+ if r.restart = '1' then
+ do_begin := true;
+ else
+ v.busy := '0';
+ v.done := r.finished;
+ v.state := IDLE;
+ end if;
+ else
+ v.clear_cnt := r.clear_cnt - 1;
+ end if;
+
+ end case;
+ end if;
+
+ if do_begin then
+ v.restart := '0';
+ v.finished := '0';
+ v.cmd_sent := '0';
+ v.init := '1';
+ v.fed := (others => '0');
+ v.flash_rx := (others => '0');
+ v.state := PRIME;
+ end if;
+
+ rin <= v;
+ end process;
+
+ reg: process(clk, reset)
+ begin
+ if reset then
+ r <= REG_RESET;
+ elsif rising_edge(clk) then
+ r <= rin;
+ end if;
+ end process;
+
+end rtl;
diff --git a/hdl/ip/vhd/hash_engine/sims/fake_flash_responder.vhd b/hdl/ip/vhd/hash_engine/sims/fake_flash_responder.vhd
new file mode 100644
index 00000000..8d53594a
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/fake_flash_responder.vhd
@@ -0,0 +1,99 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+use work.hash_engine_sim_pkg.all;
+
+-- Stands in for spi_nor_top's flash read client: pops the two command words then
+-- streams back that many bytes of flash_byte() content.
+--
+-- This deliberately does not model the QSPI wire protocol. What matters for the
+-- hash engine is the command/response FIFO contract and the fact that bytes
+-- arrive slowly and in gaps, which CYCLES_PER_BYTE reproduces. The real backend's
+-- chunking into 256 byte reads is invisible on this interface.
+entity fake_flash_responder is
+ generic (
+ -- Roughly what quad rate flash costs per byte, so the hasher spends most
+ -- of its time waiting, as it will in hardware.
+ CYCLES_PER_BYTE : positive := 8
+ );
+ port (
+ clk : in std_logic;
+ reset : in std_logic;
+
+ -- Command FIFO read side
+ cmd_rdata : in std_logic_vector(31 downto 0);
+ cmd_rdack : out std_logic;
+ cmd_rempty : in std_logic;
+
+ -- Response FIFO write side
+ rsp_wdata : out std_logic_vector(7 downto 0);
+ rsp_write : out std_logic;
+ rsp_wfull : in std_logic
+ );
+end entity;
+
+architecture model of fake_flash_responder is
+
+ type state_t is (GET_ADDR, GET_LEN, STREAM);
+
+ signal state : state_t := GET_ADDR;
+ signal addr : natural := 0;
+ signal remaining : natural := 0;
+ signal delay : natural := 0;
+
+begin
+
+ main: process(clk, reset)
+ begin
+ if reset then
+ state <= GET_ADDR;
+ addr <= 0;
+ remaining <= 0;
+ delay <= 0;
+ cmd_rdack <= '0';
+ rsp_write <= '0';
+ rsp_wdata <= (others => '0');
+ elsif rising_edge(clk) then
+ cmd_rdack <= '0';
+ rsp_write <= '0';
+
+ case state is
+
+ when GET_ADDR =>
+ if cmd_rempty = '0' and cmd_rdack = '0' then
+ addr <= to_integer(unsigned(cmd_rdata));
+ cmd_rdack <= '1';
+ state <= GET_LEN;
+ end if;
+
+ when GET_LEN =>
+ if cmd_rempty = '0' and cmd_rdack = '0' then
+ remaining <= to_integer(unsigned(cmd_rdata));
+ cmd_rdack <= '1';
+ delay <= CYCLES_PER_BYTE;
+ state <= STREAM;
+ end if;
+
+ when STREAM =>
+ if remaining = 0 then
+ state <= GET_ADDR;
+ elsif delay > 0 then
+ delay <= delay - 1;
+ elsif rsp_wfull = '0' then
+ rsp_wdata <= flash_byte(addr);
+ rsp_write <= '1';
+ addr <= addr + 1;
+ remaining <= remaining - 1;
+ delay <= CYCLES_PER_BYTE;
+ end if;
+
+ end case;
+ end if;
+ end process;
+
+end model;
diff --git a/hdl/ip/vhd/hash_engine/sims/hash_engine_sim_pkg.vhd b/hdl/ip/vhd/hash_engine/sims/hash_engine_sim_pkg.vhd
new file mode 100644
index 00000000..854199da
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/hash_engine_sim_pkg.vhd
@@ -0,0 +1,140 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.hash_engine_regs_pkg.all;
+
+package hash_engine_sim_pkg is
+
+ -- 8 bit address, so register offsets are the raw RDL offsets with no window
+ -- base to add. The DUT hangs off this directly, there is no interconnect in
+ -- the harness.
+ constant bus_handle : bus_master_t := new_bus(
+ data_length => 32,
+ address_length => 8
+ );
+
+ -- The contents the fake flash returns for a given byte address. 193 is odd so
+ -- this is a bijection modulo 256: every byte value appears and neighbouring
+ -- addresses always differ, which makes an off-by-one in the fetch path show up
+ -- as a wrong digest rather than an accidental match. The address is folded to
+ -- 16 bits first purely to keep the multiply inside an integer.
+ function flash_byte (
+ addr : natural
+ ) return std_logic_vector;
+
+ procedure write_reg (
+ signal net : inout network_t;
+ offset : natural;
+ data : std_logic_vector(31 downto 0)
+ );
+
+ procedure read_reg (
+ signal net : inout network_t;
+ offset : natural;
+ variable data : out std_logic_vector(31 downto 0)
+ );
+
+ -- Reassemble the digest from the eight registers into the same bit order the
+ -- core uses, ie bits 7 downto 0 are hash byte 0.
+ procedure read_digest (
+ signal net : inout network_t;
+ variable digest : out std_logic_vector(255 downto 0)
+ );
+
+ -- Poll STATUS until done sets. Preferred over waiting on busy for a normal
+ -- completion: done is monotonic within a run and is cleared by the start that
+ -- precedes this call, so there is no window where a poll can slip through
+ -- before the engine has picked the work up.
+ procedure wait_hash_done (
+ signal net : inout network_t;
+ variable status : out std_logic_vector(31 downto 0)
+ );
+
+ -- Poll STATUS until busy clears. Only safe when the engine is known to be busy
+ -- already, ie after an abort, where the drain keeps busy asserted.
+ procedure wait_not_busy (
+ signal net : inout network_t;
+ variable status : out std_logic_vector(31 downto 0)
+ );
+
+end package;
+
+package body hash_engine_sim_pkg is
+
+ function flash_byte (
+ addr : natural
+ ) return std_logic_vector is
+ begin
+ return To_StdLogicVector((((addr mod 65536) * 193) + 41) mod 256, 8);
+ end function;
+
+ procedure write_reg (
+ signal net : inout network_t;
+ offset : natural;
+ data : std_logic_vector(31 downto 0)
+ ) is
+ begin
+ write_bus(net, bus_handle, To_StdLogicVector(offset, bus_handle.p_address_length), data);
+ end procedure;
+
+ procedure read_reg (
+ signal net : inout network_t;
+ offset : natural;
+ variable data : out std_logic_vector(31 downto 0)
+ ) is
+ begin
+ read_bus(net, bus_handle, To_StdLogicVector(offset, bus_handle.p_address_length), data);
+ end procedure;
+
+ procedure read_digest (
+ signal net : inout network_t;
+ variable digest : out std_logic_vector(255 downto 0)
+ ) is
+ variable word : std_logic_vector(31 downto 0);
+ begin
+ for i in 0 to 7 loop
+ read_reg(net, DIGEST0_OFFSET + 4 * i, word);
+ digest(32 * i + 31 downto 32 * i) := word;
+ end loop;
+ end procedure;
+
+ procedure wait_hash_done (
+ signal net : inout network_t;
+ variable status : out std_logic_vector(31 downto 0)
+ ) is
+ variable rdata : std_logic_vector(31 downto 0);
+ begin
+ loop
+ read_reg(net, STATUS_OFFSET, rdata);
+ exit when (rdata and STATUS_DONE_MASK) /= (rdata'range => '0');
+ end loop;
+
+ status := rdata;
+ end procedure;
+
+ procedure wait_not_busy (
+ signal net : inout network_t;
+ variable status : out std_logic_vector(31 downto 0)
+ ) is
+ variable rdata : std_logic_vector(31 downto 0);
+ begin
+ loop
+ read_reg(net, STATUS_OFFSET, rdata);
+ exit when (rdata and STATUS_BUSY_MASK) = (rdata'range => '0');
+ end loop;
+
+ status := rdata;
+ end procedure;
+
+end package body;
diff --git a/hdl/ip/vhd/hash_engine/sims/hash_engine_tb.vhd b/hdl/ip/vhd/hash_engine/sims/hash_engine_tb.vhd
new file mode 100644
index 00000000..cc57506e
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/hash_engine_tb.vhd
@@ -0,0 +1,409 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.hash_engine_regs_pkg.all;
+use work.hash_engine_sim_pkg.all;
+use work.keccak_pkg.all;
+use work.sha3_sim_pkg.all;
+
+-- Tests the hashing engine through its register interface.
+--
+-- Every expected digest is computed with sha3_sim_pkg's software sponge over a
+-- queue built to match what the engine should have hashed, so nothing here is a
+-- transcribed constant. The SHA3 core itself is not under test: keccak_pkg_tb
+-- anchors that against published vectors.
+entity hash_engine_tb is
+ generic (
+ runner_cfg : string
+ );
+end entity;
+
+architecture tb of hash_engine_tb is
+
+ constant START_CMD : std_logic_vector(31 downto 0) :=
+ pack(control_type'(abort => '0', start => '1'));
+ constant ABORT_CMD : std_logic_vector(31 downto 0) :=
+ pack(control_type'(abort => '1', start => '0'));
+ constant CFG_LOCAL : std_logic_vector(31 downto 0) :=
+ pack(config_type'(source => LOCAL_REG));
+ constant CFG_QSPI : std_logic_vector(31 downto 0) :=
+ pack(config_type'(source => HOST_QSPI));
+
+begin
+
+ th: entity work.hash_engine_th;
+
+ bench: process
+ alias reset is << signal th.reset : std_logic >>;
+
+ variable status : std_logic_vector(31 downto 0);
+ variable rdata : std_logic_vector(31 downto 0);
+ variable dig : std_logic_vector(255 downto 0);
+ variable expected : digest_t;
+ variable msg : queue_t;
+
+ -- A byte of software supplied test data. Arbitrary, but a function of the
+ -- index so a misordered feed shows up as a wrong digest.
+ impure function sw_byte (
+ i : natural
+ ) return natural is
+ begin
+ return (i * 37 + 11) mod 256;
+ end function;
+
+ -- Build what the engine should end up hashing: the 0xFF run, then either
+ -- software bytes or flash contents.
+ impure function expected_msg (
+ prepend : natural;
+ nbytes : natural;
+ from_flash : boolean;
+ base_addr : natural
+ ) return queue_t is
+ variable q : queue_t := new_queue;
+ begin
+ for i in 1 to prepend loop
+ push_byte(q, 16#FF#);
+ end loop;
+
+ for i in 0 to nbytes - 1 loop
+ if from_flash then
+ push_byte(q, to_integer(unsigned(flash_byte(base_addr + i))));
+ else
+ push_byte(q, sw_byte(i));
+ end if;
+ end loop;
+
+ return q;
+ end function;
+
+ procedure configure (
+ source : std_logic_vector(31 downto 0);
+ prepend : natural;
+ total_len : natural;
+ base_addr : natural
+ ) is
+ begin
+ write_reg(net, CONFIG_OFFSET, source);
+ write_reg(net, PREPEND_OFFSET, To_StdLogicVector(prepend, 32));
+ write_reg(net, LENGTH_OFFSET, To_StdLogicVector(total_len, 32));
+ write_reg(net, FLASH_ADDR_OFFSET, To_StdLogicVector(base_addr, 32));
+ end procedure;
+
+ -- Push nbytes of software data, four at a time, respecting wfifo_full.
+ -- Trailing bytes of the final word are filled with 0xAA: LENGTH decides
+ -- where the message ends, so they must not reach the hash.
+ procedure feed_sw (
+ nbytes : natural
+ ) is
+ variable word : std_logic_vector(31 downto 0);
+ variable full : std_logic_vector(31 downto 0);
+ variable idx : natural := 0;
+ begin
+ while idx < nbytes loop
+ loop
+ read_reg(net, STATUS_OFFSET, full);
+ exit when (full and STATUS_WFIFO_FULL_MASK) = (full'range => '0');
+ end loop;
+
+ for b in 0 to 3 loop
+ if idx + b < nbytes then
+ word(8 * b + 7 downto 8 * b) := To_StdLogicVector(sw_byte(idx + b), 8);
+ else
+ word(8 * b + 7 downto 8 * b) := x"AA";
+ end if;
+ end loop;
+
+ write_reg(net, WDATA_OFFSET, word);
+ idx := idx + 4;
+ end loop;
+ end procedure;
+
+ -- Run a complete software fed hash and check the digest.
+ procedure run_local (
+ prepend : natural;
+ nbytes : natural;
+ name : string
+ ) is
+ variable q : queue_t;
+ variable e : digest_t;
+ variable d : std_logic_vector(255 downto 0);
+ variable s : std_logic_vector(31 downto 0);
+ begin
+ q := expected_msg(prepend, nbytes, false, 0);
+ e := sha3_256_digest(q);
+
+ configure(CFG_LOCAL, prepend, prepend + nbytes, 0);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ feed_sw(nbytes);
+ wait_hash_done(net, s);
+ read_digest(net, d);
+
+ check_equal(d, std_logic_vector(e), name);
+ end procedure;
+
+ -- Run a complete flash fed hash and check the digest.
+ procedure run_flash (
+ prepend : natural;
+ nbytes : natural;
+ base_addr : natural;
+ name : string
+ ) is
+ variable q : queue_t;
+ variable e : digest_t;
+ variable d : std_logic_vector(255 downto 0);
+ variable s : std_logic_vector(31 downto 0);
+ begin
+ q := expected_msg(prepend, nbytes, true, base_addr);
+ e := sha3_256_digest(q);
+
+ configure(CFG_QSPI, prepend, prepend + nbytes, base_addr);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ wait_hash_done(net, s);
+ read_digest(net, d);
+
+ check_equal(d, std_logic_vector(e), name);
+ end procedure;
+
+ -- Drive a literal message through the manual path and check it against a
+ -- published digest, rather than against the software sponge. This is the
+ -- one place the engine is measured against the standard instead of
+ -- against our own model of it.
+ procedure run_vector (
+ msg : string;
+ expected_hex : string;
+ name : string
+ ) is
+ variable word : std_logic_vector(31 downto 0);
+ variable full : std_logic_vector(31 downto 0);
+ variable idx : natural := 0;
+ variable d : std_logic_vector(255 downto 0);
+ variable s : std_logic_vector(31 downto 0);
+ begin
+ configure(CFG_LOCAL, 0, msg'length, 0);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+
+ while idx < msg'length loop
+ loop
+ read_reg(net, STATUS_OFFSET, full);
+ exit when (full and STATUS_WFIFO_FULL_MASK) = (full'range => '0');
+ end loop;
+
+ for b in 0 to 3 loop
+ if idx + b < msg'length then
+ word(8 * b + 7 downto 8 * b) :=
+ To_StdLogicVector(character'pos(msg(msg'low + idx + b)), 8);
+ else
+ word(8 * b + 7 downto 8 * b) := x"AA";
+ end if;
+ end loop;
+
+ write_reg(net, WDATA_OFFSET, word);
+ idx := idx + 4;
+ end loop;
+
+ wait_hash_done(net, s);
+ read_digest(net, d);
+
+ check_equal(d, std_logic_vector(hex_digest(expected_hex)), name);
+ end procedure;
+
+ begin
+ test_runner_setup(runner, runner_cfg);
+ wait until reset = '0';
+ wait for 500 ns;
+
+ while test_suite loop
+ if run("register_readback") then
+ -- Every configuration register holds what was written, and the
+ -- self clearing control bits read back as zero.
+ write_reg(net, CONFIG_OFFSET, CFG_QSPI);
+ write_reg(net, PREPEND_OFFSET, x"0000_1234");
+ write_reg(net, FLASH_ADDR_OFFSET, x"DEAD_BEEF");
+ write_reg(net, LENGTH_OFFSET, x"0000_ABCD");
+
+ read_reg(net, CONFIG_OFFSET, rdata);
+ check_equal(rdata, CFG_QSPI, "CONFIG readback");
+ read_reg(net, PREPEND_OFFSET, rdata);
+ check_equal(rdata, std_logic_vector'(x"0000_1234"), "PREPEND readback");
+ read_reg(net, FLASH_ADDR_OFFSET, rdata);
+ check_equal(rdata, std_logic_vector'(x"DEAD_BEEF"), "FLASH_ADDR readback");
+ read_reg(net, LENGTH_OFFSET, rdata);
+ check_equal(rdata, std_logic_vector'(x"0000_ABCD"), "LENGTH readback");
+
+ read_reg(net, CONTROL_OFFSET, rdata);
+ check_equal(rdata, std_logic_vector'(x"0000_0000"),
+ "CONTROL bits are self clearing so must read zero");
+
+ elsif run("nist_vectors") then
+ -- The published SHA3-256 vectors, from
+ -- https://di-mgt.com.au/sha_testvectors.html, driven through the
+ -- same LOCAL_REG path tools/hash_engine_vectors_test.py uses on
+ -- hardware, so the expectations that script checks are known good
+ -- against the RTL before anyone plugs a board in.
+ --
+ -- The empty message is in that set too but cannot be expressed on
+ -- an AXI stream; reject_zero_length covers what the engine does
+ -- with it instead. The million byte and 1 GB vectors are left to
+ -- the hardware script.
+ run_vector("abc",
+ "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532",
+ "vector: abc");
+
+ run_vector("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
+ "41c0dba2a9d6240849100376a8235e2c82e1b9998a999e21db32dd97496d3376",
+ "vector: 448 bit");
+
+ run_vector("abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn" &
+ "hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu",
+ "916f6061fe879741ca6469b43971dfdb28b1a32dc36cb3254e812be27aad1d18",
+ "vector: 896 bit");
+
+ elsif run("local_reg_source") then
+ run_local(0, 64, "sha3 of 64 software fed bytes");
+
+ elsif run("local_reg_ragged") then
+ -- Not a multiple of four, so the last word carries filler that
+ -- must be ignored.
+ run_local(0, 61, "sha3 of 61 software fed bytes");
+ run_local(0, 1, "sha3 of a single software fed byte");
+
+ elsif run("local_crosses_rate_block") then
+ -- 136 bytes is the sponge rate, so these straddle the block
+ -- boundary and force the padding corner cases in the core.
+ run_local(0, 135, "135 bytes");
+ run_local(0, 136, "136 bytes");
+ run_local(0, 137, "137 bytes");
+
+ elsif run("prepend_only") then
+ run_local(200, 0, "200 prepended 0xFF bytes and no source data");
+
+ elsif run("prepend_plus_local") then
+ run_local(16, 48, "16 prepended bytes then 48 software bytes");
+
+ elsif run("flash_source") then
+ run_flash(0, 100, 16#1000#, "sha3 of 100 flash bytes");
+
+ elsif run("prepend_plus_flash") then
+ -- The intended use: a fixed 0xFF header then a flash range.
+ run_flash(32, 200, 16#2040#, "32 prepended bytes then 200 flash bytes");
+
+ elsif run("flash_multi_chunk") then
+ -- Past 256 bytes the backend has to split the read, and past 4096
+ -- it exceeds anything the eSPI flash path could express, which is
+ -- why this client carries a 32 bit length.
+ run_flash(0, 300, 16#4000#, "300 flash bytes, two chunks");
+ run_flash(8, 5000, 16#8000#, "5000 flash bytes, twenty chunks");
+
+ elsif run("progress_advances") then
+ configure(CFG_QSPI, 0, 2000, 16#1000#);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+
+ -- Catch it mid flight: busy set and progress somewhere sensible.
+ wait for 40 us;
+ read_reg(net, STATUS_OFFSET, status);
+ check_equal((status and STATUS_BUSY_MASK) /= (status'range => '0'), true,
+ "should still be busy part way through 2000 bytes");
+ read_reg(net, PROGRESS_OFFSET, rdata);
+ check_equal(to_integer(unsigned(rdata)) > 0, true, "progress should have advanced");
+ check_equal(to_integer(unsigned(rdata)) < 2000, true, "progress should not be complete");
+
+ wait_hash_done(net, status);
+ read_reg(net, PROGRESS_OFFSET, rdata);
+ check_equal(to_integer(unsigned(rdata)), 2000, "progress should end at the length");
+
+ elsif run("reject_zero_length") then
+ configure(CFG_LOCAL, 0, 0, 0);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ wait for 2 us;
+
+ read_reg(net, STATUS_OFFSET, status);
+ check_equal((status and STATUS_CFG_ERR_MASK) /= (status'range => '0'), true,
+ "zero length should set cfg_err");
+ check_equal((status and STATUS_BUSY_MASK) = (status'range => '0'), true,
+ "zero length should never go busy");
+
+ -- and the engine still works afterwards
+ run_local(0, 32, "hash after a rejected start");
+
+ elsif run("reject_prepend_gt_length") then
+ configure(CFG_LOCAL, 100, 50, 0);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ wait for 2 us;
+
+ read_reg(net, STATUS_OFFSET, status);
+ check_equal((status and STATUS_CFG_ERR_MASK) /= (status'range => '0'), true,
+ "prepend longer than the message should set cfg_err");
+ check_equal((status and STATUS_BUSY_MASK) = (status'range => '0'), true,
+ "should never go busy");
+
+ elsif run("abort_midway") then
+ -- Abandon a flash read part way through. The engine has to swallow
+ -- the bytes the backend still owes before it can be reused, so busy
+ -- stays set until the channel is resynchronised.
+ configure(CFG_QSPI, 0, 3000, 16#1000#);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ wait for 30 us;
+
+ write_reg(net, CONTROL_OFFSET, ABORT_CMD);
+ wait_not_busy(net, status);
+ check_equal((status and STATUS_ABORTED_MASK) /= (status'range => '0'), true,
+ "abort should set aborted");
+ check_equal((status and STATUS_DONE_MASK) = (status'range => '0'), true,
+ "abort should not report done");
+
+ -- The real check: a stale byte left in the channel would corrupt
+ -- this digest.
+ run_flash(0, 128, 16#6000#, "flash hash after an abort");
+
+ elsif run("restart_midway") then
+ -- Restarting mid flight must discard the partial message and the
+ -- tail of the outstanding flash read.
+ configure(CFG_QSPI, 0, 3000, 16#1000#);
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+ wait for 30 us;
+
+ run_flash(0, 150, 16#7000#, "flash hash restarted over a running one");
+
+ elsif run("back_to_back") then
+ run_local(0, 40, "first message");
+ run_flash(4, 80, 16#3000#, "second message, flash sourced");
+ run_local(8, 40, "third message, back to software");
+
+ elsif run("wfifo_full_backpressure") then
+ -- Fill the software FIFO with the engine idle, so nothing drains
+ -- it. Starting a hash first would not work: the core consumes a
+ -- byte per clock, far faster than the bus can deliver four, so the
+ -- FIFO would never back up.
+ -- 64 words is the FIFO depth, so this must fill.
+ for i in 0 to 79 loop
+ write_reg(net, WDATA_OFFSET, To_StdLogicVector(i, 32));
+ end loop;
+
+ read_reg(net, STATUS_OFFSET, status);
+ check_equal((status and STATUS_WFIFO_FULL_MASK) /= (status'range => '0'), true,
+ "software FIFO should report full after overfilling it");
+
+ -- Leave the FIFO dirty on purpose: the next run's end of run flush
+ -- is what cleans it, and back_to_back covers that.
+ end if;
+ end loop;
+
+ wait for 2 us;
+ test_runner_cleanup(runner);
+ wait;
+ end process;
+
+ test_runner_watchdog(runner, 50 ms);
+
+end tb;
diff --git a/hdl/ip/vhd/hash_engine/sims/hash_engine_th.vhd b/hdl/ip/vhd/hash_engine/sims/hash_engine_th.vhd
new file mode 100644
index 00000000..e24c20fc
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/hash_engine_th.vhd
@@ -0,0 +1,139 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.axil8x32_pkg;
+use work.hash_engine_sim_pkg.all;
+
+-- The command and response FIFOs are real dcfifo_xpm instances here, not
+-- behavioural stand-ins, because an integrating design owns them exactly like the
+-- eSPI subsystem does. Neither is reset by the DUT: the engine resynchronises the
+-- response channel by draining it, not by flushing.
+entity hash_engine_th is
+end entity;
+
+architecture th of hash_engine_th is
+
+ signal clk : std_logic := '0';
+ signal reset : std_logic := '1';
+
+ signal axi_bus : axil8x32_pkg.axil_t;
+
+ signal cmd_fifo_wdata : std_logic_vector(31 downto 0);
+ signal cmd_fifo_write : std_logic;
+ signal cmd_fifo_rdata : std_logic_vector(31 downto 0);
+ signal cmd_fifo_rdack : std_logic;
+ signal cmd_fifo_empty : std_logic;
+
+ signal rsp_fifo_wdata : std_logic_vector(7 downto 0);
+ signal rsp_fifo_write : std_logic;
+ signal rsp_fifo_wfull : std_logic;
+ signal rsp_fifo_rdata : std_logic_vector(7 downto 0);
+ signal rsp_fifo_rdack : std_logic;
+ signal rsp_fifo_empty : std_logic;
+
+
+begin
+
+ clk <= not clk after 4 ns;
+ reset <= '0' after 200 ns;
+
+ axi_lite_master_inst: entity vunit_lib.axi_lite_master
+ generic map (
+ bus_handle => bus_handle
+ )
+ port map (
+ aclk => clk,
+ arready => axi_bus.read_address.ready,
+ arvalid => axi_bus.read_address.valid,
+ araddr => axi_bus.read_address.addr,
+ rready => axi_bus.read_data.ready,
+ rvalid => axi_bus.read_data.valid,
+ rdata => axi_bus.read_data.data,
+ rresp => axi_bus.read_data.resp,
+ awready => axi_bus.write_address.ready,
+ awvalid => axi_bus.write_address.valid,
+ awaddr => axi_bus.write_address.addr,
+ wready => axi_bus.write_data.ready,
+ wvalid => axi_bus.write_data.valid,
+ wdata => axi_bus.write_data.data,
+ wstrb => axi_bus.write_data.strb,
+ bvalid => axi_bus.write_response.valid,
+ bready => axi_bus.write_response.ready,
+ bresp => axi_bus.write_response.resp
+ );
+
+ dut: entity work.hash_engine_top
+ port map (
+ clk => clk,
+ reset => reset,
+ axi_if => axi_bus,
+ cmd_fifo_wdata => cmd_fifo_wdata,
+ cmd_fifo_write => cmd_fifo_write,
+ rsp_fifo_rdata => rsp_fifo_rdata,
+ rsp_fifo_rdack => rsp_fifo_rdack,
+ rsp_fifo_rempty => rsp_fifo_empty
+ );
+
+ cmd_fifo: entity work.dcfifo_xpm
+ generic map (
+ fifo_write_depth => 256,
+ data_width => 32,
+ showahead_mode => true
+ )
+ port map (
+ wclk => clk,
+ reset => reset,
+ write_en => cmd_fifo_write,
+ wdata => cmd_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk,
+ rdata => cmd_fifo_rdata,
+ rdreq => cmd_fifo_rdack,
+ rempty => cmd_fifo_empty,
+ rusedwds => open
+ );
+
+ rsp_fifo: entity work.dcfifo_xpm
+ generic map (
+ fifo_write_depth => 256,
+ data_width => 8,
+ showahead_mode => true
+ )
+ port map (
+ wclk => clk,
+ reset => reset,
+ write_en => rsp_fifo_write,
+ wdata => rsp_fifo_wdata,
+ wfull => rsp_fifo_wfull,
+ wusedwds => open,
+ rclk => clk,
+ rdata => rsp_fifo_rdata,
+ rdreq => rsp_fifo_rdack,
+ rempty => rsp_fifo_empty,
+ rusedwds => open
+ );
+
+ fake_flash: entity work.fake_flash_responder
+ port map (
+ clk => clk,
+ reset => reset,
+ cmd_rdata => cmd_fifo_rdata,
+ cmd_rdack => cmd_fifo_rdack,
+ cmd_rempty => cmd_fifo_empty,
+ rsp_wdata => rsp_fifo_wdata,
+ rsp_write => rsp_fifo_write,
+ rsp_wfull => rsp_fifo_wfull
+ );
+
+end th;
diff --git a/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_tb.vhd b/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_tb.vhd
new file mode 100644
index 00000000..a11d1c85
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_tb.vhd
@@ -0,0 +1,185 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.hash_engine_regs_pkg.all;
+use work.hash_engine_sim_pkg.all;
+use work.keccak_pkg.all;
+use work.sha3_sim_pkg.all;
+use work.spi_nor_target_vc_pkg.all;
+
+-- Integration test: the hashing engine fetching through the real spi_nor_top,
+-- the real QSPI link, and a modelled flash part.
+--
+-- The part is filled with pattern_byte(addr), which is a bijection over any
+-- aligned 256 byte run, so the digest depends on exactly which addresses were
+-- fetched. A chunk boundary that re-reads or skips a range changes the digest
+-- rather than going unnoticed, which is the whole reason for testing at this
+-- level rather than trusting hash_engine_tb's behavioural responder.
+entity hash_spi_nor_tb is
+ generic (
+ runner_cfg : string
+ );
+end entity;
+
+architecture tb of hash_spi_nor_tb is
+
+ constant START_CMD : std_logic_vector(31 downto 0) :=
+ pack(control_type'(abort => '0', start => '1'));
+ constant CFG_QSPI : std_logic_vector(31 downto 0) :=
+ pack(config_type'(source => HOST_QSPI));
+
+begin
+
+ th: entity work.hash_spi_nor_th;
+
+ bench: process
+ alias reset is << signal th.reset : std_logic >>;
+
+ constant flash_actor : actor_t := find("spi_nor_target");
+
+ variable status : std_logic_vector(31 downto 0);
+ variable rdata : std_logic_vector(31 downto 0);
+
+ -- What the part holds at a given address. Outside the modelled window
+ -- the VC returns erased data.
+ impure function flash_content (
+ addr : natural
+ ) return std_logic_vector is
+ begin
+ if addr < flash_window_bytes then
+ return pattern_byte(addr);
+ end if;
+
+ return x"FF";
+ end function;
+
+ -- The message the engine should end up hashing: the 0xFF run, then the
+ -- flash from base_addr on.
+ impure function expected_msg (
+ prepend : natural;
+ nbytes : natural;
+ base_addr : natural
+ ) return queue_t is
+ variable q : queue_t := new_queue;
+ begin
+ for i in 1 to prepend loop
+ push_byte(q, 16#FF#);
+ end loop;
+
+ for i in 0 to nbytes - 1 loop
+ push_byte(q, to_integer(unsigned(flash_content(base_addr + i))));
+ end loop;
+
+ return q;
+ end function;
+
+ procedure run_hash (
+ prepend : natural;
+ nbytes : natural;
+ base_addr : natural;
+ name : string
+ ) is
+ variable e : digest_t;
+ variable s : std_logic_vector(31 downto 0);
+ variable d : std_logic_vector(255 downto 0);
+ begin
+ e := sha3_256_digest(expected_msg(prepend, nbytes, base_addr));
+
+ write_reg(net, CONFIG_OFFSET, CFG_QSPI);
+ write_reg(net, PREPEND_OFFSET, To_StdLogicVector(prepend, 32));
+ write_reg(net, LENGTH_OFFSET, To_StdLogicVector(prepend + nbytes, 32));
+ write_reg(net, FLASH_ADDR_OFFSET, To_StdLogicVector(base_addr, 32));
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+
+ wait_hash_done(net, s);
+ read_digest(net, d);
+
+ check_equal(d, std_logic_vector(e), name);
+ end procedure;
+
+ begin
+ test_runner_setup(runner, runner_cfg);
+ wait until reset = '0';
+ wait for 500 ns;
+
+ -- Give the part known contents before anything reads it.
+ fill_pattern(net, flash_actor);
+
+ while test_suite loop
+ if run("single_chunk") then
+ run_hash(0, 64, 16#1000#, "64 bytes, inside one chunk");
+
+ elsif run("exact_chunk") then
+ -- Exactly one full chunk, the boundary the transaction manager
+ -- is most likely to get wrong.
+ run_hash(0, 256, 16#1000#, "256 bytes, exactly one chunk");
+
+ elsif run("chunk_boundary") then
+ -- One byte either side of the boundary, so an off-by-one in the
+ -- chunking arithmetic cannot hide.
+ run_hash(0, 255, 16#1000#, "255 bytes");
+ run_hash(0, 257, 16#2000#, "257 bytes, second chunk is one byte");
+
+ elsif run("multi_chunk") then
+ run_hash(0, 600, 16#1000#, "600 bytes, three chunks");
+ run_hash(0, 5000, 16#4000#, "5000 bytes, twenty chunks");
+
+ elsif run("unaligned_base") then
+ -- A base that is not a chunk multiple, so every chunk after the
+ -- first starts mid-pattern.
+ run_hash(0, 700, 16#1234#, "700 bytes from an unaligned base");
+
+ elsif run("sector_bypass") then
+ -- The configuration tools/hash_engine_flash_test.py drives on
+ -- hardware: the first sector is fed as 0xFF and the flash is read
+ -- from one sector in, so the message is the image with its first
+ -- sector blanked.
+ run_hash(16#1000#, 16#3000#, 16#1000#,
+ "sector bypass: 0x1000 of 0xFF then flash from 0x1000");
+
+ elsif run("prepend_plus_flash") then
+ run_hash(16, 300, 16#2000#, "16 prepended bytes then 300 fetched");
+
+ elsif run("progress_and_busy") then
+ write_reg(net, CONFIG_OFFSET, CFG_QSPI);
+ write_reg(net, PREPEND_OFFSET, To_StdLogicVector(0, 32));
+ write_reg(net, LENGTH_OFFSET, To_StdLogicVector(600, 32));
+ write_reg(net, FLASH_ADDR_OFFSET, To_StdLogicVector(16#1000#, 32));
+ write_reg(net, CONTROL_OFFSET, START_CMD);
+
+ read_reg(net, STATUS_OFFSET, status);
+ check_equal((status and STATUS_BUSY_MASK) /= (status'range => '0'), true,
+ "should be busy once a fetch is under way");
+
+ wait_hash_done(net, status);
+ read_reg(net, PROGRESS_OFFSET, rdata);
+ check_equal(to_integer(unsigned(rdata)), 600, "all 600 bytes accounted for");
+
+ elsif run("back_to_back") then
+ -- The second run is the one that catches a channel left out of
+ -- step by the first, e.g. bytes over-fetched and still queued.
+ run_hash(0, 128, 16#1000#, "first fetch");
+ run_hash(0, 300, 16#3000#, "second fetch, crossing a chunk boundary");
+ run_hash(0, 64, 16#5000#, "third fetch");
+ end if;
+ end loop;
+
+ wait for 2 us;
+ test_runner_cleanup(runner);
+ wait;
+ end process;
+
+ test_runner_watchdog(runner, 50 ms);
+
+end tb;
diff --git a/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_th.vhd b/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_th.vhd
new file mode 100644
index 00000000..e8827b89
--- /dev/null
+++ b/hdl/ip/vhd/hash_engine/sims/hash_spi_nor_th.vhd
@@ -0,0 +1,218 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.axil8x32_pkg;
+use work.hash_engine_sim_pkg.all;
+
+-- End to end harness: the hashing engine driving the real spi_nor_top over the
+-- command/response FIFO channel, through the actual QSPI link, into a modelled
+-- flash part.
+--
+-- This proves the whole chain: command FIFO, raw_flash_txn_mgr and its splitting
+-- of a long read into 256 byte chunks, arbitration for the shared SPI engine, the
+-- link, and the bytes finding their way back into the hash. Because the part is
+-- modelled rather than faked, the digest depends on the flash contents and on the
+-- addresses actually issued, so a chunk boundary that fetches the wrong range
+-- shows up as a wrong digest instead of passing unnoticed.
+--
+-- The launch and capture delays mirror spi_nor_th: RTL simulation has no notion
+-- of board delay, but it is a large share of an sclk period and the controller's
+-- sample point cannot be exercised honestly without it.
+entity hash_spi_nor_th is
+ generic (
+ -- Slow corner of the delay window the XDC allows, as in spi_nor_th.
+ out_delay : time := 3.7 ns;
+ in_delay : time := 1.5 ns
+ );
+end entity;
+
+architecture th of hash_spi_nor_th is
+
+ signal clk : std_logic := '0';
+ signal reset : std_logic := '1';
+
+ signal axi_bus : axil8x32_pkg.axil_t;
+ signal spinor_axi : axil8x32_pkg.axil_t;
+
+ signal cmd_fifo_wdata : std_logic_vector(31 downto 0);
+ signal cmd_fifo_write : std_logic;
+ signal cmd_fifo_rdata : std_logic_vector(31 downto 0);
+ signal cmd_fifo_rdack : std_logic;
+ signal cmd_fifo_empty : std_logic;
+
+ signal rsp_fifo_wdata : std_logic_vector(7 downto 0);
+ signal rsp_fifo_write : std_logic;
+ signal rsp_fifo_rdata : std_logic_vector(7 downto 0);
+ signal rsp_fifo_rdack : std_logic;
+ signal rsp_fifo_empty : std_logic;
+
+ signal cs_n : std_logic;
+ signal sclk : std_logic;
+ signal io : std_logic_vector(3 downto 0);
+ signal io_o : std_logic_vector(3 downto 0);
+ signal io_oe : std_logic_vector(3 downto 0);
+
+ signal flash_o : std_logic_vector(3 downto 0);
+ signal flash_oe : std_logic_vector(3 downto 0);
+ signal io_flash : std_logic_vector(3 downto 0);
+ signal sclk_flash : std_logic;
+ signal csn_flash : std_logic;
+
+begin
+
+ clk <= not clk after 4 ns;
+ reset <= '0' after 200 ns;
+
+ axi_lite_master_inst: entity vunit_lib.axi_lite_master
+ generic map (
+ bus_handle => bus_handle
+ )
+ port map (
+ aclk => clk,
+ arready => axi_bus.read_address.ready,
+ arvalid => axi_bus.read_address.valid,
+ araddr => axi_bus.read_address.addr,
+ rready => axi_bus.read_data.ready,
+ rvalid => axi_bus.read_data.valid,
+ rdata => axi_bus.read_data.data,
+ rresp => axi_bus.read_data.resp,
+ awready => axi_bus.write_address.ready,
+ awvalid => axi_bus.write_address.valid,
+ awaddr => axi_bus.write_address.addr,
+ wready => axi_bus.write_data.ready,
+ wvalid => axi_bus.write_data.valid,
+ wdata => axi_bus.write_data.data,
+ wstrb => axi_bus.write_data.strb,
+ bvalid => axi_bus.write_response.valid,
+ bready => axi_bus.write_response.ready,
+ bresp => axi_bus.write_response.resp
+ );
+
+ dut: entity work.hash_engine_top
+ port map (
+ clk => clk,
+ reset => reset,
+ axi_if => axi_bus,
+ cmd_fifo_wdata => cmd_fifo_wdata,
+ cmd_fifo_write => cmd_fifo_write,
+ rsp_fifo_rdata => rsp_fifo_rdata,
+ rsp_fifo_rdack => rsp_fifo_rdack,
+ rsp_fifo_rempty => rsp_fifo_empty
+ );
+
+ cmd_fifo: entity work.dcfifo_xpm
+ generic map (
+ fifo_write_depth => 256,
+ data_width => 32,
+ showahead_mode => true
+ )
+ port map (
+ wclk => clk,
+ reset => reset,
+ write_en => cmd_fifo_write,
+ wdata => cmd_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk,
+ rdata => cmd_fifo_rdata,
+ rdreq => cmd_fifo_rdack,
+ rempty => cmd_fifo_empty,
+ rusedwds => open
+ );
+
+ rsp_fifo: entity work.dcfifo_xpm
+ generic map (
+ fifo_write_depth => 256,
+ data_width => 8,
+ showahead_mode => true
+ )
+ port map (
+ wclk => clk,
+ reset => reset,
+ write_en => rsp_fifo_write,
+ wdata => rsp_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk,
+ rdata => rsp_fifo_rdata,
+ rdreq => rsp_fifo_rdack,
+ rempty => rsp_fifo_empty,
+ rusedwds => open
+ );
+
+ -- The SPI controller's own register interface is not exercised here, so park
+ -- its initiator side idle. sp5_owns_flash stays at its reset value of zero,
+ -- which means the hubris register path is nominally selected and the hash
+ -- client has to win the engine on its own.
+ spinor_axi.read_address.valid <= '0';
+ spinor_axi.read_address.addr <= (others => '0');
+ spinor_axi.read_data.ready <= '0';
+ spinor_axi.write_address.valid <= '0';
+ spinor_axi.write_address.addr <= (others => '0');
+ spinor_axi.write_data.valid <= '0';
+ spinor_axi.write_data.data <= (others => '0');
+ spinor_axi.write_data.strb <= (others => '0');
+ spinor_axi.write_response.ready <= '0';
+
+ spi_nor: entity work.spi_nor_top
+ port map (
+ clk => clk,
+ reset => reset,
+ axi_if => spinor_axi,
+ cs_n => cs_n,
+ sclk => sclk,
+ io => io,
+ io_o => io_o,
+ io_oe => io_oe,
+ sp5_owns_flash => open,
+ espi_cmd_fifo_rdata => (others => '0'),
+ espi_cmd_fifo_rdack => open,
+ espi_cmd_fifo_rempty => '1',
+ espi_data_fifo_wdata => open,
+ espi_data_fifo_write => open,
+ hash_cmd_fifo_rdata => cmd_fifo_rdata,
+ hash_cmd_fifo_rdack => cmd_fifo_rdack,
+ hash_cmd_fifo_rempty => cmd_fifo_empty,
+ hash_data_fifo_wdata => rsp_fifo_wdata,
+ hash_data_fifo_write => rsp_fifo_write
+ );
+
+ -- Everything the part sees is delayed by out_delay; everything the DUT
+ -- captures is delayed again by in_delay coming back.
+ sclk_flash <= sclk after out_delay;
+ csn_flash <= cs_n after out_delay;
+
+ flash: entity work.spi_nor_target_vc
+ generic map (
+ actor_name => "spi_nor_target"
+ )
+ port map (
+ cs_n => csn_flash,
+ sclk => sclk_flash,
+ io => io_flash,
+ io_o => flash_o,
+ io_oe => flash_oe
+ );
+
+ -- Both ends contribute to the resolved bus at the part, plus a weak pull-up
+ -- for the board's. If both drive a lane the resolution goes to 'X', which the
+ -- controller shifts in and the digest check then catches.
+ bus_gen: for i in io_flash'range generate
+ io_flash(i) <= io_o(i) after out_delay when io_oe(i) = '1' else 'Z' after out_delay;
+ io_flash(i) <= flash_o(i) when flash_oe(i) = '1' else 'Z';
+ io_flash(i) <= 'H';
+ end generate;
+
+ io <= io_flash after in_delay;
+
+end th;
diff --git a/hdl/ip/vhd/sha3/BUCK b/hdl/ip/vhd/sha3/BUCK
new file mode 100644
index 00000000..34d00ed0
--- /dev/null
+++ b/hdl/ip/vhd/sha3/BUCK
@@ -0,0 +1,52 @@
+load("//tools:hdl.bzl", "sim_only_model", "vhdl_unit", "vunit_sim")
+
+vhdl_unit(
+ name = "keccak_pkg",
+ srcs = ["keccak_pkg.vhd"],
+ visibility = ['PUBLIC'],
+)
+
+vhdl_unit(
+ name = "sha3_256",
+ srcs = ["sha3_256.vhd"],
+ deps = [
+ ":keccak_pkg",
+ "//hdl/ip/vhd/axi_blocks:axist_if_2k19_pkg",
+ "//hdl/ip/vhd/common:calc_pkg",
+ ],
+ standard = "2019",
+ visibility = ['PUBLIC'],
+)
+
+sim_only_model(
+ name = "sha3_sim_pkg",
+ srcs = ["sims/sha3_sim_pkg.vhd"],
+ deps = [":keccak_pkg"],
+ visibility = ['PUBLIC'],
+)
+
+# Unit tests for the permutation itself, no RTL involved. Kept separate from
+# sha3_256_tb because this is the layer that anchors the round function against
+# published vectors, while sha3_256_tb checks the sponge and the stream
+# interface around it.
+vunit_sim(
+ name = "keccak_pkg_tb",
+ srcs = ["sims/keccak_pkg_tb.vhd"],
+ deps = [":sha3_sim_pkg"],
+ visibility = ['PUBLIC'],
+)
+
+vunit_sim(
+ name = "sha3_256_tb",
+ srcs = [
+ "sims/sha3_256_th.vhd",
+ "sims/sha3_256_tb.vhd",
+ ],
+ deps = [
+ ":sha3_256",
+ ":sha3_sim_pkg",
+ "//hdl/ip/vhd/vunit_components:basic_stream",
+ "//hdl/ip/vhd/vunit_components:sim_gpio",
+ ],
+ visibility = ['PUBLIC'],
+)
diff --git a/hdl/ip/vhd/sha3/docs/sha3_256.adoc b/hdl/ip/vhd/sha3/docs/sha3_256.adoc
new file mode 100644
index 00000000..3e7abede
--- /dev/null
+++ b/hdl/ip/vhd/sha3/docs/sha3_256.adoc
@@ -0,0 +1,257 @@
+:showtitle:
+:toc: left
+:numbered:
+:icons: font
+:revision: 1.0
+:revdate: 2026-08-05
+
+= SHA3-256 hashing engine
+
+A synthesizable SHA3-256 (FIPS 202) core with a byte-wide AXI streaming input and
+a parallel 256-bit digest output. It is intended for things like measured boot,
+flash-image attestation and manifest verification, where an FPGA needs a hash of
+something it can already see on a bus.
+
+The sponge parameters are fixed: state `b` = 1600 bits, capacity `c` = 512 bits,
+so the rate is 1088 bits = 136 bytes = 17 lanes, and the permutation is 24 rounds
+of Keccak-f[1600]. The 256-bit digest is four lanes, which fits inside a single
+rate block, so this core never needs a squeeze permutation.
+
+== Design Overview:
+
+image::sha3_256_block.drawio.svg[align="center"]
+
+The permutation runs *one full Keccak round per cycle*. The 1600-bit state is
+therefore read and rewritten in its entirety every cycle, which means it lives in
+flops -- no on-chip RAM is used, or would help. That choice is deliberate: with a
+byte-wide input, absorbing a block costs 136 cycles against the permutation's 25,
+so the stream is the bottleneck and there is nothing to buy by making the
+permutation smaller and slower. A plane-serial datapath at five cycles per round
+would use roughly half the LUTs and still keep up, but it needs phase-aligned
+parity registers and is far easier to get subtly wrong; it is worth revisiting
+only if this ever has to fit a much smaller part.
+
+The core is two independent state machines that share nothing but a per-buffer
+handshake:
+
+feed FSM:: Owns `msg_if.ready`, the byte counter, pad-byte generation and
+`fill_idx`. It shifts bytes into a 136-byte block register and marks it full when
+complete.
+
+permute FSM:: Owns the Keccak state, the round counter and `drain_idx`. It waits
+for a full block, XORs it into the state, runs 24 rounds, and hands the buffer
+back.
+
+Splitting them this way is what makes `DOUBLE_BUFFER` a one-line change rather
+than a second code path. The buffer release is combinational rather than
+registered, because the feed side has to observe it in the same cycle the final
+round retires; if it were a cycle late, a single-buffered core would absorb the
+same block twice.
+
+=== Absorb path
+
+Incoming bytes are *shifted* into a 1088-bit register, not written to an addressed
+lane. Addressing would need a 17-way decode and byte enables across 1088 state
+bits for no saving, whereas the shift is pure wiring and the XOR into the state is
+then a flat slice with no muxing at all.
+
+The consequence is that a byte's final position is fixed by how many shifts follow
+it, so every block must receive exactly 136 shifts. That is why padding shifts
+filler bytes through one per cycle rather than writing the terminator directly. It
+costs up to 136 cycles once per message, which is irrelevant next to the 136
+cycles the message itself takes to arrive.
+
+== Interface
+
+[cols="1,1,4"]
+|===
+|Generic |Type |Description
+
+|`DOUBLE_BUFFER` |boolean
+|`false` (default) uses one block register and `ready` drops for each 25-cycle
+absorb-and-permute, giving about 84% of line rate. `true` adds a second block
+register (+1088 FF) so block N+1 absorbs while block N permutes; `ready` then
+never drops mid-message. The permutation cannot fall behind because 25 < 136.
+|===
+
+[cols="1,1,4"]
+|===
+|Port |Direction |Description
+
+|`clk` |in |Clock.
+|`reset` |in |Asynchronous, active high.
+|`init` |in |Synchronous, active high. Zeroes the sponge, abandons any message in
+flight and clears `digest_valid`. The core is held cleared for as long as it is
+asserted, so a pulse or a level both work. Not required between messages -- `last`
+already ends one cleanly.
+|`busy` |out |High from the first accepted byte until `digest_valid` asserts.
+|`msg_if` |view |`axi_st_pkt_sink` from `axi_st8_pkg`: `valid` / `ready` /
+`data[7:0]` / `last`. Message bytes in natural order, `last` marks the final byte.
+|`digest` |out |256-bit digest, see byte order below.
+|`digest_valid` |out |Level. Held until `init` or until the next message claims its
+first byte.
+|===
+
+This is the first user of `axi_st_pkt_t` in the tree; the record was defined in
+`axist_if_2k19_pkg` but had no users. Because it relies on VHDL-2019 mode views,
+`sha3_256` is declared `standard = "2019"`, which propagates to everything that
+depends on it.
+
+=== Byte order
+
+image::sha3_256_byte_order.drawio.svg[align="center"]
+
+Byte order is the easiest thing to get wrong when integrating a hash, so it is
+defined in exactly one place, `digest_of` in `keccak_pkg`:
+
+*`digest(7 downto 0)` is hash byte 0*, that is the leftmost byte of the
+conventional hex string. So `sha3-256("abc") = 3a985da7...1532` gives
+`digest(7 downto 0) = 0x3a` and `digest(255 downto 248) = 0x32`.
+
+On the input side, rate lane `k` absorbs message bytes `8k` through `8k+7`
+little-endian, per FIPS 202 B.1, and lane `k` is state element `(x, y)` with
+`k = x + 5y`.
+
+=== Limitations
+
+AXI streaming has no zero-beat packet, so *the empty message is not representable*
+on this interface -- there is no way to assert `last` without also presenting a
+byte. A consumer that needs `SHA3-256("")` should use the known constant
+`a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a`.
+
+There is no `enable` port. The stream's `valid` already gates all forward
+progress, and a real clock enable would fan out to every one of the ~3000 flops
+for no benefit.
+
+== Round function
+
+image::sha3_256_round.drawio.svg[align="center"]
+
+`keccak_round` in `keccak_pkg` is a pure function, so the same code is both the
+synthesizable next-state logic and the testbench's round primitive -- the same
+idea as `lfsr8_pkg`.
+
+The reason a full round fits in one cycle cheaply is that rho and pi cost nothing:
+rho is a constant rotation per lane and pi is a relabelling, so neither survives
+synthesis as logic. What remains is theta's XOR feeding chi, and each output bit
+then depends on exactly six inputs -- three state bits and three `D` bits:
+
+----
+A'[x,y,z] = (a xor Dx) xor ( not (b xor Dx+1) and (c xor Dx+2) )
+----
+
+which is one LUT6 per state bit.
+
+Both constant tables are generated at elaboration rather than transcribed:
+
+* *rho offsets* are walked out of the standard recurrence. Starting from `(1,0)`,
+ the `t`-th lane visited gets offset `(t+1)(t+2)/2 mod 64`, stepping by the same
+ pi permutation the round already uses. `keccak_pkg_tb` checks the result against
+ the published table.
+* *round constants* come from a Galois LFSR over `x^8 + x^6 + x^5 + x^4 + 1`. Only
+ bit positions `2^j - 1` for j in 0..6 can ever be set, so they are stored as a
+ 24x7 ROM and scattered into a lane. That turns a 64-bit 24:1 mux into about 35
+ LUTs and 7 XOR gates.
+
+=== Measured utilization
+
+Out of context on `xc7s100fgga484-1`, Vivado 2024.2:
+
+[cols="2,1,1,1,1"]
+|===
+|Configuration |LUT |FF |BRAM |DSP
+
+|`DOUBLE_BUFFER => false` |5349 |2989 |0 |0
+|`DOUBLE_BUFFER => true` |6187 |4077 |0 |0
+|===
+
+That is about 5% of an `xc7s100`. Timing closes comfortably: at a 5 ns period the
+worst negative slack is +1.11 ns, so the critical path is around 3.9 ns, or about
+257 MHz. It is three logic levels -- theta's 5-input XOR, then D, then the fused
+round LUT6.
+
+The FF delta between the two configurations is exactly 1088, the second block
+register, as expected. The 838 extra LUTs are the `fill_idx`/`drain_idx` selection
+across two 1088-bit buffers.
+
+Synthesizing the round on its own confirms the fusion argument above holds in
+practice: *1600 LUT6* for the fused theta-XOR, chi and iota term -- exactly one per
+state bit -- plus 320 LUT6 for `D` and 320 LUT5 for the column parities `C`. The
+rest of the core is the state register's input mux, which cannot fold into the
+saturated round LUT6, and the 1088-bit absorb XOR.
+
+If a future change pushes the LUT count well past this, the first thing to check
+is whether theta still fuses into chi.
+
+== Throughput and padding
+
+image::sha3_256_framing.drawio.svg[align="center"]
+
+Padding is pad10*1 with the SHA3 domain separator. How much room is left in the
+block after the last message byte decides which case applies, and all three fall
+out of writing `0x06` and then OR-ing `0x80` into the final byte:
+
+* two or more bytes left: `06 00 .. 00 80`
+* exactly one byte left: the two fuse into `86`
+* no bytes left, ie the message length is an exact multiple of 136: a whole extra
+ block of padding is required
+
+That last case is the classic sponge bug and is covered by `kat_136_bytes`. Note
+also that `0x06` is SHA3-specific -- SHAKE uses `0x1F` and original Keccak `0x01`.
+
+== Sim Env
+
+Two testbenches, deliberately separate, because they check different things.
+
+`keccak_pkg_tb`:: Unit tests for the permutation, no RTL involved. This is the
+layer that anchors the round function, the generated rho offsets and the generated
+round constants against published values -- most importantly Keccak-f[1600]
+applied to the all-zero state, which exercises every rotation offset and all 24
+round constants and avalanches on any error in either.
+
+`sha3_256_tb`:: The sponge and the stream interface. Expected digests come partly
+from hardcoded known-answer tests and partly from `sha3_sim_pkg`, a software
+sponge over a VUnit `queue_t` in the spirit of `crc_sim_pkg`.
+
+The split matters: `sha3_sim_pkg` shares `keccak_round` with the DUT, so it cannot
+detect a wrong rotation offset or round constant -- both sides would be wrong
+identically. It is there to check everything the sponge wraps around the
+permutation, at arbitrary message lengths that a fixed vector set cannot cover.
+The known-answer tests are what pin the permutation itself.
+
+Both buffering configurations are instantiated side by side in `sha3_256_th` and
+fed the same vectors, so they cannot silently diverge. They get separate stream
+sources because their backpressure differs by design; the single-buffered source
+is throttled to 60% valid to exercise gaps, and the double-buffered one runs flat
+out so `no_stall_is_real` can measure that it never pushes back. That test also
+asserts that the single-buffered core *does* stall, so it cannot pass vacuously on
+a broken observer.
+
+`init` is driven through a `sim_gpio` instance rather than an external name, per
+the usual convention in this tree.
+
+Stimulus uses `basic_pkt_source`, added to `hdl/ip/vhd/vunit_components/basic_stream`
+alongside the existing `basic_source`. It is the same component with a `last`
+port, since `basic_source` cannot drive `axi_st_pkt_t`.
+
+=== Running
+
+[source,bash]
+----
+buck2 run //hdl/ip/vhd/sha3:keccak_pkg_tb
+buck2 run //hdl/ip/vhd/sha3:sha3_256_tb
+
+# a single test case is selected positionally, not with --test-case
+buck2 run //hdl/ip/vhd/sha3:sha3_256_tb -- "*kat_136_bytes*"
+----
+
+Known-answer digests should be regenerated rather than copied by hand:
+
+[source,bash]
+----
+python3 -c "import hashlib; print(hashlib.sha3_256(b'abc').hexdigest())"
+----
+
+`hex_digest` in `sha3_sim_pkg` parses a digest string in the conventional order,
+so tests carry exactly what `sha3sum` prints rather than a hand byte-reversed
+constant.
diff --git a/hdl/ip/vhd/sha3/docs/sha3_256_block.drawio.svg b/hdl/ip/vhd/sha3/docs/sha3_256_block.drawio.svg
new file mode 100644
index 00000000..7fbb3929
--- /dev/null
+++ b/hdl/ip/vhd/sha3/docs/sha3_256_block.drawio.svg
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/hdl/ip/vhd/sha3/docs/sha3_256_byte_order.drawio.svg b/hdl/ip/vhd/sha3/docs/sha3_256_byte_order.drawio.svg
new file mode 100644
index 00000000..c2aa5ee8
--- /dev/null
+++ b/hdl/ip/vhd/sha3/docs/sha3_256_byte_order.drawio.svg
@@ -0,0 +1,6 @@
+
+
+
+
diff --git a/hdl/ip/vhd/sha3/docs/sha3_256_framing.drawio.svg b/hdl/ip/vhd/sha3/docs/sha3_256_framing.drawio.svg
new file mode 100644
index 00000000..38b73848
--- /dev/null
+++ b/hdl/ip/vhd/sha3/docs/sha3_256_framing.drawio.svg
@@ -0,0 +1,9 @@
+
+
+
+
diff --git a/hdl/ip/vhd/sha3/docs/sha3_256_round.drawio.svg b/hdl/ip/vhd/sha3/docs/sha3_256_round.drawio.svg
new file mode 100644
index 00000000..a0ad8c43
--- /dev/null
+++ b/hdl/ip/vhd/sha3/docs/sha3_256_round.drawio.svg
@@ -0,0 +1,19 @@
+
+
+
+
diff --git a/hdl/ip/vhd/sha3/keccak_pkg.vhd b/hdl/ip/vhd/sha3/keccak_pkg.vhd
new file mode 100644
index 00000000..882500ef
--- /dev/null
+++ b/hdl/ip/vhd/sha3/keccak_pkg.vhd
@@ -0,0 +1,299 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+-- Keccak-f[1600] and the SHA3-256 sponge parameters from FIPS 202.
+--
+-- Everything here is pure functions and elaboration-time constants so the same
+-- code is both the synthesizable next-state logic in sha3_256 and the golden
+-- model in the testbench, following the lfsr8_pkg precedent. The two constant
+-- tables are *generated* rather than transcribed: 25 rotation offsets and 24
+-- 64-bit round constants are a lot of hand-typed hex to get wrong, and the
+-- generators are short enough to read.
+--
+-- The state is the usual 5x5 array of 64-bit lanes indexed [x][y]. Where the
+-- sponge needs a flat lane index k (the rate block and the digest both run over
+-- lanes in order), the mapping is k = x + 5y, ie x = k mod 5 and y = k / 5.
+package keccak_pkg is
+
+ subtype lane_t is std_logic_vector(63 downto 0);
+
+ type state_t is array (0 to 4, 0 to 4) of lane_t;
+
+ constant NUM_ROUNDS : positive := 24;
+
+ -- SHA3-256: capacity 512 bits, so the rate is 1600 - 512 = 1088 bits.
+ constant RATE_LANES : positive := 17;
+ constant RATE_BITS : positive := RATE_LANES * 64; -- 1088
+ constant RATE_BYTES : positive := RATE_BITS / 8; -- 136
+
+ -- The digest is 4 lanes, which fits inside one rate block, so SHA3-256
+ -- never needs a squeeze permutation.
+ constant DIGEST_LANES : positive := 4;
+ constant DIGEST_BITS : positive := DIGEST_LANES * 64;
+
+ subtype rate_block_t is std_logic_vector(RATE_BITS - 1 downto 0);
+ subtype digest_t is std_logic_vector(DIGEST_BITS - 1 downto 0);
+
+ -- Domain separation and padding bytes for pad10*1. The 0x06 prefix is
+ -- SHA3-specific: SHAKE uses 0x1F and original Keccak uses 0x01.
+ constant PAD_FIRST : std_logic_vector(7 downto 0) := X"06";
+ constant PAD_LAST : std_logic_vector(7 downto 0) := X"80";
+ constant PAD_ONLY : std_logic_vector(7 downto 0) := X"86";
+
+ -- Left rotation, towards increasing z. This is the direction Keccak means
+ -- everywhere, including theta's rotate-by-one.
+ function rotl (
+ v : lane_t;
+ n : natural
+ ) return lane_t;
+
+ -- One round of Keccak-f[1600]: theta, rho, pi, chi, iota.
+ function keccak_round (
+ a : state_t;
+ rc : lane_t
+ ) return state_t;
+
+ -- All 24 rounds. Not used by the RTL, which runs one round per cycle, but
+ -- it is what the testbench golden model and the round-function known-answer
+ -- test are built on.
+ function keccak_f1600 (
+ a : state_t
+ ) return state_t;
+
+ -- XOR a full rate block into the state. Lane k takes block bytes 8k..8k+7
+ -- little-endian, per FIPS 202 B.1, so block byte j lives at bits
+ -- 8j+7 downto 8j of blk.
+ function absorb_block (
+ a : state_t;
+ blk : rate_block_t
+ ) return state_t;
+
+ -- The 256-bit digest. Bits 7 downto 0 are hash byte 0, ie the leftmost byte
+ -- of the conventional hex string. Keeping this in one place is deliberate:
+ -- byte order is the easiest thing to get wrong when integrating a hash.
+ function digest_of (
+ a : state_t
+ ) return digest_t;
+
+ -- Round constants, stored as the 7 bits that can actually be non-zero. In a
+ -- round constant only bit positions 2^j - 1 for j in 0..6 are ever set, so
+ -- indexing a 24x7 ROM by the round counter and scattering costs a handful of
+ -- LUTs where a 24-entry 64-bit ROM would cost a few hundred.
+ subtype rc_bits_t is std_logic_vector(6 downto 0);
+
+ type rc_table_t is array (0 to NUM_ROUNDS - 1) of rc_bits_t;
+
+ -- Deferred: the generator lives in the body, so the value cannot be
+ -- elaborated here.
+ constant RC_BITS : rc_table_t;
+
+ -- Expand a packed round constant back out to a full lane. Pure wiring.
+ function rc_lane (
+ b : rc_bits_t
+ ) return lane_t;
+
+ -- The rho rotation offset for lane (x,y). The table is generated, so this is
+ -- exposed to let a testbench check it against the published one.
+ function rho_offset (
+ x : natural range 0 to 4;
+ y : natural range 0 to 4
+ ) return natural;
+
+end package;
+
+package body keccak_pkg is
+
+ function rotl (
+ v : lane_t;
+ n : natural
+ ) return lane_t is
+ begin
+ if n = 0 then
+ return v;
+ end if;
+
+ return v(63 - n downto 0) & v(63 downto 64 - n);
+ end function;
+
+ type rho_table_t is array (0 to 4, 0 to 4) of natural range 0 to 63;
+
+ -- The rho offsets, walked out of the standard recurrence rather than typed
+ -- in as a table. Starting from (1,0), the t'th lane visited gets offset
+ -- (t+1)(t+2)/2 mod 64, and the walk steps by the same pi permutation used in
+ -- the round below. The 24 steps visit every lane except (0,0), whose offset
+ -- is zero. This reproduces the canonical table exactly.
+ function gen_rho return rho_table_t is
+
+ variable t : rho_table_t := (others => (others => 0));
+ variable x : natural range 0 to 4;
+ variable y : natural range 0 to 4;
+ variable old_x : natural range 0 to 4;
+
+ begin
+ x := 1;
+ y := 0;
+
+ for i in 0 to 23 loop
+ t(x, y) := ((i + 1) * (i + 2) / 2) mod 64;
+ old_x := x;
+ x := y;
+ y := (2 * old_x + 3 * y) mod 5;
+ end loop;
+
+ return t;
+ end function;
+
+ constant RHO : rho_table_t := gen_rho;
+
+ function rho_offset (
+ x : natural range 0 to 4;
+ y : natural range 0 to 4
+ ) return natural is
+ begin
+ return RHO(x, y);
+ end function;
+
+ -- The round constants come out of a Galois LFSR over x^8 + x^6 + x^5 + x^4 + 1,
+ -- seeded with 0x01. Seven steps per round, each contributing one bit. This is
+ -- the same polynomial lfsr8_pkg uses for the SerDes scrambler, but it is
+ -- pinned here by FIPS 202 rather than shared, so a future change to the
+ -- scrambler's polynomial cannot quietly break SHA3.
+ function gen_rc return rc_table_t is
+
+ variable t : rc_table_t;
+ variable lfsr : std_logic_vector(7 downto 0);
+ variable bits : rc_bits_t;
+
+ begin
+ lfsr := X"01";
+
+ for r in 0 to NUM_ROUNDS - 1 loop
+ bits := (others => '0');
+
+ for j in 0 to 6 loop
+ if lfsr(7) = '1' then
+ lfsr := (lfsr(6 downto 0) & '0') xor X"71";
+ else
+ lfsr := lfsr(6 downto 0) & '0';
+ end if;
+ bits(j) := lfsr(1);
+ end loop;
+
+ t(r) := bits;
+ end loop;
+
+ return t;
+ end function;
+
+ constant RC_BITS : rc_table_t := gen_rc;
+
+ function rc_lane (
+ b : rc_bits_t
+ ) return lane_t is
+
+ variable v : lane_t := (others => '0');
+
+ begin
+ for j in 0 to 6 loop
+ v(2 ** j - 1) := b(j);
+ end loop;
+
+ return v;
+ end function;
+
+ function keccak_round (
+ a : state_t;
+ rc : lane_t
+ ) return state_t is
+
+ type lane_row_t is array (0 to 4) of lane_t;
+
+ variable c : lane_row_t;
+ variable d : lane_row_t;
+ variable b : state_t;
+ variable res : state_t;
+
+ begin
+ -- theta, part one: parity of each column.
+ for x in 0 to 4 loop
+ c(x) := a(x, 0) xor a(x, 1) xor a(x, 2) xor a(x, 3) xor a(x, 4);
+ end loop;
+
+ -- theta, part two: the diffusion term applied to every lane of column x.
+ for x in 0 to 4 loop
+ d(x) := c((x + 4) mod 5) xor rotl(c((x + 1) mod 5), 1);
+ end loop;
+
+ -- rho and pi, with theta's XOR folded in. rho is a constant rotation per
+ -- lane and pi is a relabelling, so neither costs any gates: the only
+ -- logic here is the XOR with d, and that folds into the chi term below.
+ -- Each output bit then depends on exactly six inputs, three state bits
+ -- and three d bits, which is one LUT6 per state bit.
+ for x in 0 to 4 loop
+ for y in 0 to 4 loop
+ b(y, (2 * x + 3 * y) mod 5) := rotl(a(x, y) xor d(x), RHO(x, y));
+ end loop;
+ end loop;
+
+ -- chi, the only non-linear step, acting along rows.
+ for x in 0 to 4 loop
+ for y in 0 to 4 loop
+ res(x, y) := b(x, y) xor ((not b((x + 1) mod 5, y)) and b((x + 2) mod 5, y));
+ end loop;
+ end loop;
+
+ -- iota, breaking the symmetry that the other four steps preserve.
+ res(0, 0) := res(0, 0) xor rc;
+
+ return res;
+ end function;
+
+ function keccak_f1600 (
+ a : state_t
+ ) return state_t is
+
+ variable res : state_t := a;
+
+ begin
+ for r in 0 to NUM_ROUNDS - 1 loop
+ res := keccak_round(res, rc_lane(RC_BITS(r)));
+ end loop;
+
+ return res;
+ end function;
+
+ function absorb_block (
+ a : state_t;
+ blk : rate_block_t
+ ) return state_t is
+
+ variable res : state_t := a;
+
+ begin
+ for k in 0 to RATE_LANES - 1 loop
+ res(k mod 5, k / 5) := res(k mod 5, k / 5) xor blk(64 * k + 63 downto 64 * k);
+ end loop;
+
+ return res;
+ end function;
+
+ function digest_of (
+ a : state_t
+ ) return digest_t is
+
+ variable v : digest_t;
+
+ begin
+ for k in 0 to DIGEST_LANES - 1 loop
+ v(64 * k + 63 downto 64 * k) := a(k mod 5, k / 5);
+ end loop;
+
+ return v;
+ end function;
+
+end package body;
diff --git a/hdl/ip/vhd/sha3/sha3_256.vhd b/hdl/ip/vhd/sha3/sha3_256.vhd
new file mode 100644
index 00000000..cff277bc
--- /dev/null
+++ b/hdl/ip/vhd/sha3/sha3_256.vhd
@@ -0,0 +1,365 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+use work.axi_st8_pkg;
+use work.calc_pkg.all;
+use work.keccak_pkg.all;
+
+-- SHA3-256 (FIPS 202) over a byte-wide AXI stream.
+--
+-- The permutation runs one full Keccak-f[1600] round per cycle, so the 1600-bit
+-- state lives in flops and the whole round is a single layer of combinational
+-- logic. rho and pi are pure wiring and theta's XOR folds into chi, so each
+-- state bit's next value depends on exactly six inputs -- one LUT6 per bit.
+-- Nothing here wants a RAM: the state is read and rewritten in full every cycle.
+--
+-- Absorb costs 136 cycles per block against the permutation's 24, so the stream
+-- is the bottleneck, not the hashing. See DOUBLE_BUFFER for whether those 24
+-- cycles show up as backpressure.
+--
+-- The design is two independent state machines that share nothing but a
+-- per-buffer full/final handshake:
+--
+-- feed FSM owns msg_if.ready, the byte counter, padding, and fill_idx.
+-- It shifts bytes into a 136-byte block register and marks it
+-- full when complete.
+-- permute FSM owns the Keccak state, the round counter, and drain_idx. It
+-- waits for a full block, XORs it in, runs 24 rounds, and hands
+-- the buffer back.
+--
+-- Splitting them this way is what makes DOUBLE_BUFFER a one-line change rather
+-- than a second code path.
+entity sha3_256 is
+ generic (
+ -- false: one block register. ready deasserts for the 25-cycle absorb and
+ -- permute, so a long message runs at about 84% of line rate.
+ -- true: two block registers (+1088 FF). Block N+1 absorbs while block N
+ -- permutes, so ready never deasserts mid-message and the core
+ -- sustains one byte per cycle. The permutation cannot fall behind
+ -- because 25 < 136.
+ DOUBLE_BUFFER : boolean := false
+ );
+ port (
+ clk : in std_logic;
+ -- Asynchronous, active-high.
+ reset : in std_logic;
+
+ -- Synchronous, active-high. Zeroes the sponge, abandons any message in
+ -- flight and clears digest_valid; the core is held cleared for as long as
+ -- it is asserted, so either a pulse or a level works. Not required
+ -- between messages, last already ends one cleanly.
+ init : in std_logic;
+ -- High from the first accepted byte until digest_valid asserts.
+ busy : out std_logic;
+
+ -- Message bytes in natural order, last marks the final byte. Note that
+ -- AXI streaming has no zero-beat packet, so the empty message is not
+ -- representable here; a consumer needing SHA3-256("") should use the
+ -- known constant.
+ msg_if : view axi_st8_pkg.axi_st_pkt_sink;
+
+ -- digest(7 downto 0) is hash byte 0, ie the leftmost byte of the
+ -- conventional hex string. Held stable until init or the next message
+ -- starts.
+ digest : out digest_t;
+ digest_valid : out std_logic
+ );
+end entity;
+
+architecture rtl of sha3_256 is
+
+ constant NUM_BUFS : positive := sel(DOUBLE_BUFFER, 2, 1);
+
+ type block_buf_t is array (0 to NUM_BUFS - 1) of rate_block_t;
+
+ subtype buf_idx_t is natural range 0 to NUM_BUFS - 1;
+ subtype buf_flags_t is std_logic_vector(NUM_BUFS - 1 downto 0);
+
+ -- Constant-folds to 0 when there is only one buffer, which is what keeps the
+ -- single-buffer configuration from paying for the generic.
+ function next_idx (
+ i : buf_idx_t
+ ) return buf_idx_t is
+ begin
+ return (i + 1) mod NUM_BUFS;
+ end function;
+
+ -- FINISHING is where we sit between the final block being handed off and the
+ -- digest appearing. Holding ready low there stops a following message from
+ -- corrupting the one still being permuted.
+ type feed_state_t is (IDLE, ABSORB, PAD, FINISHING);
+
+ type feed_reg_t is record
+ state : feed_state_t;
+ buf : block_buf_t;
+ -- Bytes shifted into the current block so far.
+ cnt : natural range 0 to RATE_BYTES - 1;
+ fill_idx : buf_idx_t;
+ full : buf_flags_t;
+ final : buf_flags_t;
+ pad_first : std_logic;
+ started : std_logic;
+ busy : std_logic;
+ end record;
+
+ constant FEED_REG_RESET : feed_reg_t := (
+ state => IDLE,
+ buf => (others => (others => '0')),
+ cnt => 0,
+ fill_idx => 0,
+ full => (others => '0'),
+ final => (others => '0'),
+ pad_first => '0',
+ started => '0',
+ busy => '0'
+ );
+
+ type permute_state_t is (WAIT_BLOCK, ABSORB_BLOCK, RUN, HOLD);
+
+ type permute_reg_t is record
+ state : permute_state_t;
+ st : state_t;
+ round : natural range 0 to NUM_ROUNDS - 1;
+ drain_idx : buf_idx_t;
+ dig : digest_t;
+ dv : std_logic;
+ end record;
+
+ constant PERMUTE_REG_RESET : permute_reg_t := (
+ state => WAIT_BLOCK,
+ st => (others => (others => (others => '0'))),
+ round => 0,
+ drain_idx => 0,
+ dig => (others => '0'),
+ dv => '0'
+ );
+
+ signal feed_reg, feed_reg_next : feed_reg_t;
+ signal perm_reg, perm_reg_next : permute_reg_t;
+
+ signal ready_int : std_logic;
+ signal accept_byte : std_logic;
+
+ -- Combinational, not registered. The feed side has to see the release in the
+ -- same cycle the last round retires, otherwise it would still be showing the
+ -- buffer as full when the permute side next looks at it and, with a single
+ -- buffer, the same block would be absorbed twice.
+ signal buf_release : std_logic;
+ signal buf_release_idx : buf_idx_t;
+
+begin
+
+ -- The whole difference between the two buffering configurations. With one
+ -- buffer this naturally stalls for the absorb and permute; with two it only
+ -- stalls if the permute side falls behind, which it cannot.
+ ready_int <= '1' when (feed_reg.state = IDLE or feed_reg.state = ABSORB) and
+ feed_reg.full(feed_reg.fill_idx) = '0' else '0';
+
+ msg_if.ready <= ready_int;
+ accept_byte <= ready_int and msg_if.valid;
+
+ buf_release <= '1' when perm_reg.state = RUN and
+ perm_reg.round = NUM_ROUNDS - 1 else '0';
+ buf_release_idx <= perm_reg.drain_idx;
+
+ busy <= feed_reg.busy;
+ digest <= perm_reg.dig;
+ digest_valid <= perm_reg.dv;
+
+ -- Feed side ---------------------------------------------------------------
+
+ feed_next: process(all)
+
+ variable v : feed_reg_t;
+ variable pad_byte : std_logic_vector(7 downto 0);
+ variable at_end : boolean;
+
+ begin
+ v := feed_reg;
+
+ v.started := '0';
+
+ -- The permute side is done with a buffer, so take it back. This can never
+ -- collide with the set below: we only ever mark a buffer full when it is
+ -- currently clear, and only ever clear one that is currently full.
+ if buf_release then
+ v.full(buf_release_idx) := '0';
+ v.final(buf_release_idx) := '0';
+ end if;
+
+ -- Every block is filled by exactly RATE_BYTES shifts, so a byte's final
+ -- position is fixed by how many shifts follow it rather than by any
+ -- addressing. That is why padding has to shift filler through rather than
+ -- writing the last byte directly.
+ at_end := feed_reg.cnt = RATE_BYTES - 1;
+
+ case feed_reg.state is
+
+ when IDLE =>
+ if accept_byte then
+ v.buf(feed_reg.fill_idx) :=
+ msg_if.data & feed_reg.buf(feed_reg.fill_idx)(RATE_BITS - 1 downto 8);
+ v.started := '1';
+ v.busy := '1';
+ v.cnt := 1;
+ v.state := ABSORB;
+
+ -- A single-byte message: straight into padding.
+ if msg_if.last then
+ v.pad_first := '1';
+ v.state := PAD;
+ end if;
+ end if;
+
+ when ABSORB =>
+ if accept_byte then
+ v.buf(feed_reg.fill_idx) :=
+ msg_if.data & feed_reg.buf(feed_reg.fill_idx)(RATE_BITS - 1 downto 8);
+
+ if at_end then
+ -- Block complete. Not final even if this was the last
+ -- message byte: the spec still wants a whole block of
+ -- padding after an exact multiple of the rate.
+ v.full(feed_reg.fill_idx) := '1';
+ v.fill_idx := next_idx(feed_reg.fill_idx);
+ v.cnt := 0;
+ else
+ v.cnt := feed_reg.cnt + 1;
+ end if;
+
+ if msg_if.last then
+ v.pad_first := '1';
+ v.state := PAD;
+ end if;
+ end if;
+
+ when PAD =>
+ -- Same stall condition as ABSORB. With a single buffer this is
+ -- what makes us wait for an in-flight permutation before starting
+ -- the padding block.
+ if feed_reg.full(feed_reg.fill_idx) = '0' then
+ if feed_reg.pad_first = '1' and at_end then
+ -- Exactly one byte of room left, so the domain separator
+ -- and the terminator land on the same byte.
+ pad_byte := PAD_ONLY;
+ elsif feed_reg.pad_first = '1' then
+ pad_byte := PAD_FIRST;
+ elsif at_end then
+ pad_byte := PAD_LAST;
+ else
+ pad_byte := (others => '0');
+ end if;
+
+ v.buf(feed_reg.fill_idx) :=
+ pad_byte & feed_reg.buf(feed_reg.fill_idx)(RATE_BITS - 1 downto 8);
+ v.pad_first := '0';
+
+ if at_end then
+ v.full(feed_reg.fill_idx) := '1';
+ v.final(feed_reg.fill_idx) := '1';
+ v.fill_idx := next_idx(feed_reg.fill_idx);
+ v.cnt := 0;
+ v.state := FINISHING;
+ else
+ v.cnt := feed_reg.cnt + 1;
+ end if;
+ end if;
+
+ when FINISHING =>
+ if perm_reg.dv then
+ v.busy := '0';
+ v.state := IDLE;
+ end if;
+
+ end case;
+
+ if init then
+ v := FEED_REG_RESET;
+ end if;
+
+ feed_reg_next <= v;
+ end process;
+
+ -- Permute side ------------------------------------------------------------
+
+ perm_next: process(all)
+
+ variable v : permute_reg_t;
+
+ begin
+ v := perm_reg;
+
+ case perm_reg.state is
+
+ when WAIT_BLOCK =>
+ if feed_reg.full(perm_reg.drain_idx) then
+ v.state := ABSORB_BLOCK;
+ end if;
+
+ when ABSORB_BLOCK =>
+ -- Kept out of round 0 deliberately. Folding the XOR in would add a
+ -- logic level to the round's critical path to save a single cycle
+ -- out of the 136 the stream already costs.
+ v.st := absorb_block(perm_reg.st, feed_reg.buf(perm_reg.drain_idx));
+ v.round := 0;
+ v.state := RUN;
+
+ when RUN =>
+ v.st := keccak_round(perm_reg.st, rc_lane(RC_BITS(perm_reg.round)));
+
+ if perm_reg.round = NUM_ROUNDS - 1 then
+ -- Advance unconditionally, final block included, so drain_idx
+ -- stays in step with the feed side's fill_idx across a message
+ -- boundary.
+ v.drain_idx := next_idx(perm_reg.drain_idx);
+
+ if feed_reg.final(perm_reg.drain_idx) then
+ -- v.st is this round's result, so the digest is taken from
+ -- the fully permuted state.
+ v.dig := digest_of(v.st);
+ v.dv := '1';
+ v.state := HOLD;
+ else
+ v.state := WAIT_BLOCK;
+ end if;
+ else
+ v.round := perm_reg.round + 1;
+ end if;
+
+ when HOLD =>
+ -- Hold the digest until the next message claims its first byte.
+ if feed_reg.started then
+ v.st := (others => (others => (others => '0')));
+ v.dv := '0';
+ v.state := WAIT_BLOCK;
+ end if;
+
+ end case;
+
+ if init then
+ v := PERMUTE_REG_RESET;
+ end if;
+
+ perm_reg_next <= v;
+ end process;
+
+ -- Registers ---------------------------------------------------------------
+
+ regs: process(clk, reset)
+ begin
+ if reset then
+ feed_reg <= FEED_REG_RESET;
+ perm_reg <= PERMUTE_REG_RESET;
+ elsif rising_edge(clk) then
+ feed_reg <= feed_reg_next;
+ perm_reg <= perm_reg_next;
+ end if;
+ end process;
+
+end rtl;
diff --git a/hdl/ip/vhd/sha3/sims/keccak_pkg_tb.vhd b/hdl/ip/vhd/sha3/sims/keccak_pkg_tb.vhd
new file mode 100644
index 00000000..34eb55a5
--- /dev/null
+++ b/hdl/ip/vhd/sha3/sims/keccak_pkg_tb.vhd
@@ -0,0 +1,182 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.keccak_pkg.all;
+use work.sha3_sim_pkg.all;
+
+-- Unit tests for keccak_pkg, with no RTL involved.
+--
+-- This is the layer that anchors the round function, the generated rho offsets
+-- and the generated round constants against published values. The sponge tests
+-- in sha3_256_tb lean on sha3_sim_pkg, which shares keccak_round with the DUT
+-- and so cannot detect a wrong rotation offset or a wrong round constant -- both
+-- sides would be wrong identically. These known-answer tests can.
+entity keccak_pkg_tb is
+ generic (
+ runner_cfg : string
+ );
+end entity;
+
+architecture tb of keccak_pkg_tb is
+
+ -- Keccak-f[1600] applied to the all-zero state, from the reference
+ -- KeccakF-1600-IntermediateValues.txt. Indexed by the flat lane number
+ -- k = x + 5y. This is the strongest single check on the permutation: it
+ -- exercises every rotation offset and all 24 round constants, and any error
+ -- in either avalanches across the whole state.
+ type lane_array_t is array (0 to 24) of lane_t;
+
+ constant ZERO_STATE_PERMUTED : lane_array_t := (
+ x"F1258F7940E1DDE7", x"84D5CCF933C0478A", x"D598261EA65AA9EE",
+ x"BD1547306F80494D", x"8B284E056253D057", x"FF97A42D7F8E6FD4",
+ x"90FEE5A0A44647C4", x"8C5BDA0CD6192E76", x"AD30A6F71B19059C",
+ x"30935AB7D08FFC64", x"EB5AA93F2317D635", x"A9A6E6260D712103",
+ x"81A57C16DBCF555F", x"43B831CD0347C826", x"01F22F1A11A5569F",
+ x"05E5635A21D9AE61", x"64BEFEF28CC970F2", x"613670957BC46611",
+ x"B87C5A554FD00ECB", x"8C3EE88A1CCF32C8", x"940C7922AE3A2614",
+ x"1841F924A2C509E4", x"16F53526E70465C2", x"75F644E97F30A13B",
+ x"EAF1FF7B5CECA249"
+ );
+
+ -- Canonical rho offsets, indexed [x][y], from FIPS 202 table 2. gen_rho
+ -- walks these out of a recurrence instead of storing them, so this checks
+ -- the recurrence.
+ type rho_check_t is array (0 to 4, 0 to 4) of natural;
+
+ constant RHO_EXPECTED : rho_check_t := (
+ -- y=0 y=1 y=2 y=3 y=4
+ 0 => (0, 36, 3, 41, 18),
+ 1 => (1, 44, 10, 45, 2),
+ 2 => (62, 6, 43, 15, 61),
+ 3 => (28, 55, 25, 21, 56),
+ 4 => (27, 20, 39, 8, 14)
+ );
+
+ -- The only bit positions a round constant is allowed to occupy: 2^j - 1 for
+ -- j in 0..6. This invariant is what justifies storing the constants as 7 bits
+ -- and scattering them, so it is worth pinning.
+ function rc_allowed_mask return lane_t is
+
+ variable v : lane_t := (others => '0');
+
+ begin
+ for j in 0 to 6 loop
+ v(2 ** j - 1) := '1';
+ end loop;
+
+ return v;
+ end function;
+
+ constant RC_ALLOWED : lane_t := rc_allowed_mask;
+
+begin
+
+ bench: process
+ variable st : state_t := (others => (others => (others => '0')));
+ variable msg : queue_t;
+ begin
+ test_runner_setup(runner, runner_cfg);
+
+ while test_suite loop
+ if run("rho_offsets_match_fips202") then
+ -- gen_rho is elaboration-time, so a failure here is a compile-time
+ -- constant being wrong, not a simulation event.
+ for x in 0 to 4 loop
+ for y in 0 to 4 loop
+ check_equal(rho_offset(x, y), RHO_EXPECTED(x, y),
+ "rho offset at x=" & natural'image(x) & " y=" & natural'image(y));
+ end loop;
+ end loop;
+
+ elsif run("round_constants") then
+ -- First and last round constants, and the invariant that only
+ -- bit positions 2^j - 1 are ever set.
+ check_equal(rc_lane(RC_BITS(0)),
+ std_logic_vector'(x"0000000000000001"), "RC[0]");
+ check_equal(rc_lane(RC_BITS(23)),
+ std_logic_vector'(x"8000000080008008"), "RC[23]");
+
+ for r in 0 to NUM_ROUNDS - 1 loop
+ check_equal(rc_lane(RC_BITS(r)) and not RC_ALLOWED,
+ std_logic_vector'(lane_t'(others => '0')),
+ "RC[" & natural'image(r) & "] has bits outside 2^j-1 positions");
+ end loop;
+
+ elsif run("keccak_f1600_zero_state") then
+ st := keccak_f1600(st);
+
+ for k in 0 to 24 loop
+ check_equal(st(k mod 5, k / 5), ZERO_STATE_PERMUTED(k),
+ "permuted zero state lane " & natural'image(k));
+ end loop;
+
+ elsif run("sponge_known_answers") then
+ -- Digests generated with python3 hashlib.sha3_256. These cover the
+ -- padding corner cases: 135 bytes fuses 0x06 and 0x80 into a single
+ -- 0x86, and 136 bytes forces an entire extra block of padding.
+ msg := to_queue("abc");
+ check_equal(sha3_256_digest(msg),
+ hex_digest("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532"),
+ "sha3-256(""abc"")");
+
+ msg := new_queue;
+ check_equal(sha3_256_digest(msg),
+ hex_digest("a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"),
+ "sha3-256(empty)");
+
+ msg := repeat_byte(16#5A#, 135);
+ check_equal(sha3_256_digest(msg),
+ hex_digest("12fa8b3d366f54305d82b8eff1dae1df85046ee32ec82d6f6e290f8e9cae2f90"),
+ "sha3-256(135 x 0x5a), fused 0x86 pad");
+
+ msg := repeat_byte(16#5A#, 136);
+ check_equal(sha3_256_digest(msg),
+ hex_digest("89e699b3685be673ff90f26e215dd8140b5364e1f931f27c6000dc184ee0533c"),
+ "sha3-256(136 x 0x5a), full extra pad block");
+
+ msg := repeat_byte(16#5A#, 137);
+ check_equal(sha3_256_digest(msg),
+ hex_digest("50bf5cd6f1906058d863c6d02a7b7dd7bed531bdca5dab2b55b135d295cb72a1"),
+ "sha3-256(137 x 0x5a)");
+
+ msg := repeat_byte(16#A3#, 200);
+ check_equal(sha3_256_digest(msg),
+ hex_digest("79f38adec5c20307a98ef76e8324afbfd46cfd81b22e3973c65fa1bd9de31787"),
+ "sha3-256(200 x 0xa3), NIST 1600-bit vector");
+
+ msg := repeat_byte(16#5A#, 272);
+ check_equal(sha3_256_digest(msg),
+ hex_digest("2da5e8552b2fd944d850d3f4300fdb3054f7561c867fe6a748320760869f8bba"),
+ "sha3-256(272 x 0x5a), two full blocks plus pad block");
+
+ elsif run("hex_digest_byte_order") then
+ -- The digest hex string's leftmost byte must land in bits 7
+ -- downto 0. Getting this backwards would make every other check
+ -- in this file wrong in the same direction, so pin it directly.
+ check_equal(hex_digest("00112233445566778899aabbccddeeff" &
+ "00112233445566778899aabbccddee01")(7 downto 0),
+ std_logic_vector'(x"00"), "first hex byte lands in bits 7:0");
+ check_equal(hex_digest("00112233445566778899aabbccddeeff" &
+ "00112233445566778899aabbccddee01")(255 downto 248),
+ std_logic_vector'(x"01"), "last hex byte lands in bits 255:248");
+ end if;
+ end loop;
+
+ test_runner_cleanup(runner);
+ wait;
+ end process;
+
+ test_runner_watchdog(runner, 10 ms);
+
+end tb;
diff --git a/hdl/ip/vhd/sha3/sims/sha3_256_tb.vhd b/hdl/ip/vhd/sha3/sims/sha3_256_tb.vhd
new file mode 100644
index 00000000..afc311d3
--- /dev/null
+++ b/hdl/ip/vhd/sha3/sims/sha3_256_tb.vhd
@@ -0,0 +1,286 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library osvvm;
+use osvvm.RandomPkg.RandomPType;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.axi_st8_pkg;
+use work.basic_stream_pkg.all;
+use work.gpio_msg_pkg.all;
+use work.keccak_pkg.all;
+use work.sha3_sim_pkg.all;
+
+-- Sponge and stream-interface tests for sha3_256.
+--
+-- The round function is not under test here: keccak_pkg_tb anchors that against
+-- published vectors. What these tests cover is everything the sponge wraps
+-- around it -- block framing, the three padding cases, byte order, multi-block
+-- carry, backpressure, and message-to-message state clearing -- for both
+-- buffering configurations at once.
+entity sha3_256_tb is
+ generic (
+ runner_cfg : string
+ );
+end entity;
+
+architecture tb of sha3_256_tb is
+
+ constant CLK_PER_NS : positive := 8;
+
+ -- Deliberately different throttling on the two instances. The single
+ -- buffered core sees gaps in valid on top of its own backpressure, and the
+ -- double buffered one is driven flat out so no_stall_is_real can measure
+ -- that it never pushes back.
+ constant SRC_SINGLE : basic_source_t := new_basic_source(8, valid_high_probability => 0.6);
+ constant SRC_DOUBLE : basic_source_t := new_basic_source(8, valid_high_probability => 1.0);
+
+ -- Cycles where the source had a byte to give and the core refused it.
+ signal stalls_single : natural := 0;
+ signal stalls_double : natural := 0;
+
+begin
+
+ th: entity work.sha3_256_th
+ generic map (
+ CLK_PER_NS => CLK_PER_NS,
+ SRC_SINGLE => SRC_SINGLE,
+ SRC_DOUBLE => SRC_DOUBLE
+ );
+
+ -- Backpressure observer. Counting refused beats rather than sampling ready
+ -- directly keeps this meaningful under a throttled source: ready going low
+ -- while the source has nothing to send is not a stall.
+ stall_count: process
+ alias clk is << signal th.clk : std_logic >>;
+ alias msg_single is << signal th.msg_single : axi_st8_pkg.axi_st_pkt_t >>;
+ alias msg_double is << signal th.msg_double : axi_st8_pkg.axi_st_pkt_t >>;
+ begin
+ wait until rising_edge(clk);
+
+ if msg_single.valid = '1' and msg_single.ready = '0' then
+ stalls_single <= stalls_single + 1;
+ end if;
+
+ if msg_double.valid = '1' and msg_double.ready = '0' then
+ stalls_double <= stalls_double + 1;
+ end if;
+ end process;
+
+ bench: process
+ alias clk is << signal th.clk : std_logic >>;
+ alias reset is << signal th.reset : std_logic >>;
+ alias digest_single is << signal th.digest_single : digest_t >>;
+ alias digest_double is << signal th.digest_double : digest_t >>;
+ alias dv_single is << signal th.dv_single : std_logic >>;
+ alias dv_double is << signal th.dv_double : std_logic >>;
+ alias busy_single is << signal th.busy_single : std_logic >>;
+ alias busy_double is << signal th.busy_double : std_logic >>;
+
+ constant init_actor : actor_t := find("init_gpio");
+
+ variable rnd : RandomPType;
+
+ -- Push a message into both cores and check the digest each produces.
+ procedure check_message (
+ msg : queue_t;
+ expected : digest_t;
+ name : string
+ ) is
+ variable q : queue_t := copy(msg);
+ variable b : std_logic_vector(7 downto 0);
+ begin
+ assert not is_empty(q)
+ report "check_message: the empty message is not representable on an " &
+ "AXI stream, use the known constant instead"
+ severity failure;
+
+ -- Pop first, then ask whether anything is left: that identifies the
+ -- final byte without needing a count up front. Note length() on a
+ -- VUnit queue counts encoded bytes, not pushed items, so it is not
+ -- the byte count.
+ while not is_empty(q) loop
+ b := To_StdLogicVector(pop_byte(q), 8);
+ push_basic_pkt_stream(net, SRC_SINGLE, b, last => is_empty(q));
+ push_basic_pkt_stream(net, SRC_DOUBLE, b, last => is_empty(q));
+ end loop;
+
+ -- Wait for both cores to claim the message before looking at
+ -- digest_valid, otherwise a digest still being held from the
+ -- previous message would satisfy the wait below immediately.
+ wait until busy_single = '1' and busy_double = '1' and rising_edge(clk);
+ wait until dv_single = '1' and dv_double = '1' and rising_edge(clk);
+
+ check_equal(digest_single, expected, name & " [single buffer]");
+ check_equal(digest_double, expected, name & " [double buffer]");
+ end procedure;
+
+ -- Same, but the expected value comes from the software sponge rather
+ -- than a hardcoded digest.
+ procedure check_against_model (
+ msg : queue_t;
+ name : string
+ ) is
+ begin
+ check_message(msg, sha3_256_digest(msg), name);
+ end procedure;
+
+ procedure pulse_init is
+ variable data : std_logic_vector(GPIO_MESAGE_DATA_WDITH - 1 downto 0);
+ begin
+ data := (others => '0');
+ data(0) := '1';
+ set_gpio(net, init_actor, data);
+ wait for CLK_PER_NS * 4 * 1 ns;
+ data(0) := '0';
+ set_gpio(net, init_actor, data);
+ wait for CLK_PER_NS * 4 * 1 ns;
+ end procedure;
+
+ variable msg : queue_t;
+ variable n : natural;
+ variable before : natural;
+
+ begin
+ test_runner_setup(runner, runner_cfg);
+ wait until reset = '0';
+ wait for 500 ns;
+
+ while test_suite loop
+ if run("kat_abc") then
+ check_message(to_queue("abc"),
+ hex_digest("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe2451143" &
+ "1532"),
+ "sha3-256(""abc"")");
+
+ elsif run("kat_single_byte") then
+ check_message(repeat_byte(16#00#, 1),
+ hex_digest("5d53469f20fef4f8eab52b88044ede69c77a6a68a60728609fc4a65ff531" &
+ "e7d0"),
+ "sha3-256(0x00)");
+
+ elsif run("kat_135_bytes") then
+ -- One byte of room left after the message, so 0x06 and 0x80 fuse
+ -- into a single 0x86.
+ check_message(repeat_byte(16#5A#, 135),
+ hex_digest("12fa8b3d366f54305d82b8eff1dae1df85046ee32ec82d6f6e290f8e9cae" &
+ "2f90"),
+ "sha3-256(135 x 0x5a), fused 0x86 pad");
+
+ elsif run("kat_136_bytes") then
+ -- Exact multiple of the rate, so the spec demands a whole extra
+ -- block of padding. Getting this wrong is the classic sponge bug.
+ check_message(repeat_byte(16#5A#, 136),
+ hex_digest("89e699b3685be673ff90f26e215dd8140b5364e1f931f27c6000dc184ee0" &
+ "533c"),
+ "sha3-256(136 x 0x5a), full extra pad block");
+
+ elsif run("kat_137_bytes") then
+ check_message(repeat_byte(16#5A#, 137),
+ hex_digest("50bf5cd6f1906058d863c6d02a7b7dd7bed531bdca5dab2b55b135d295cb" &
+ "72a1"),
+ "sha3-256(137 x 0x5a)");
+
+ elsif run("kat_200_bytes_a3") then
+ check_message(repeat_byte(16#A3#, 200),
+ hex_digest("79f38adec5c20307a98ef76e8324afbfd46cfd81b22e3973c65fa1bd9de3" &
+ "1787"),
+ "sha3-256(200 x 0xa3), NIST 1600-bit vector");
+
+ elsif run("kat_272_bytes") then
+ check_message(repeat_byte(16#5A#, 272),
+ hex_digest("2da5e8552b2fd944d850d3f4300fdb3054f7561c867fe6a748320760869f" &
+ "8bba"),
+ "sha3-256(272 x 0x5a), two blocks plus a pad block");
+
+ elsif run("random_lengths") then
+ -- Arbitrary lengths against the software sponge, which is what
+ -- covers the block-boundary cases the fixed vectors above miss.
+ rnd.InitSeed(rnd'instance_name);
+
+ for i in 0 to 19 loop
+ n := rnd.RandInt(1, 400);
+ msg := new_queue;
+
+ for j in 1 to n loop
+ push_byte(msg, rnd.RandInt(0, 255));
+ end loop;
+
+ check_against_model(msg, "random message of " & natural'image(n) & " bytes");
+ end loop;
+
+ elsif run("back_to_back") then
+ -- No init between messages. If the sponge state or the buffer
+ -- flags were not cleared on completion, the second digest is
+ -- wrong.
+ check_message(to_queue("abc"),
+ hex_digest("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe2451143" &
+ "1532"),
+ "first message");
+ check_message(repeat_byte(16#A3#, 200),
+ hex_digest("79f38adec5c20307a98ef76e8324afbfd46cfd81b22e3973c65fa1bd9de3" &
+ "1787"),
+ "second message, no init between");
+ check_message(to_queue("abc"),
+ hex_digest("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe2451143" &
+ "1532"),
+ "third message, crossing a multi-block message");
+
+ elsif run("init_aborts") then
+ -- Abandon a message part way through, then check the core hashes
+ -- the next one correctly from a clean sponge.
+ for i in 1 to 50 loop
+ push_basic_pkt_stream(net, SRC_SINGLE, x"FF", last => false);
+ push_basic_pkt_stream(net, SRC_DOUBLE, x"FF", last => false);
+ end loop;
+
+ -- Let every queued beat drain into the cores before aborting, so
+ -- no stragglers get counted against the next message.
+ wait for CLK_PER_NS * 1000 * 1 ns;
+ pulse_init;
+
+ check_equal(dv_single, '0', "init should clear digest_valid [single]");
+ check_equal(dv_double, '0', "init should clear digest_valid [double]");
+ check_equal(busy_single, '0', "init should clear busy [single]");
+ check_equal(busy_double, '0', "init should clear busy [double]");
+
+ check_message(to_queue("abc"),
+ hex_digest("3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe2451143" &
+ "1532"),
+ "message after an aborted one");
+
+ elsif run("no_stall_is_real") then
+ -- The double buffered core should never refuse a byte mid-message
+ -- because the 25 cycle absorb and permute hides inside the 136
+ -- cycles the next block spends arriving. The single buffered core
+ -- must refuse bytes, otherwise this check is vacuous and would
+ -- pass on a broken observer.
+ before := stalls_single;
+ check_message(repeat_byte(16#5A#, 500), sha3_256_digest(repeat_byte(16#5A#, 500)),
+ "500 byte message");
+
+ check_equal(stalls_double, 0,
+ "double buffered core stalled the stream mid-message");
+ check_true(stalls_single > before,
+ "single buffered core never stalled, so this test proves nothing");
+ end if;
+ end loop;
+
+ wait for 2 us;
+ test_runner_cleanup(runner);
+ wait;
+ end process;
+
+ test_runner_watchdog(runner, 50 ms);
+
+end tb;
diff --git a/hdl/ip/vhd/sha3/sims/sha3_256_th.vhd b/hdl/ip/vhd/sha3/sims/sha3_256_th.vhd
new file mode 100644
index 00000000..0f09fa8a
--- /dev/null
+++ b/hdl/ip/vhd/sha3/sims/sha3_256_th.vhd
@@ -0,0 +1,120 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+
+library vunit_lib;
+ context vunit_lib.com_context;
+ context vunit_lib.vunit_context;
+ context vunit_lib.vc_context;
+
+use work.axi_st8_pkg;
+use work.basic_stream_pkg.all;
+use work.keccak_pkg.all;
+
+-- Both buffering configurations run side by side off the same test vectors, so
+-- they cannot silently diverge. They get separate stream sources because their
+-- backpressure differs by design: the single-buffered core stalls for each
+-- permutation and the double-buffered one does not.
+entity sha3_256_th is
+ generic (
+ CLK_PER_NS : positive := 8;
+ SRC_SINGLE : basic_source_t;
+ SRC_DOUBLE : basic_source_t
+ );
+end entity;
+
+architecture th of sha3_256_th is
+
+ constant CLK_PER_TIME : time := CLK_PER_NS * 1 ns;
+
+ signal clk : std_logic := '0';
+ signal reset : std_logic := '1';
+
+ signal init_gpio : std_logic_vector(0 downto 0);
+ signal init : std_logic;
+
+ signal msg_single : axi_st8_pkg.axi_st_pkt_t;
+ signal msg_double : axi_st8_pkg.axi_st_pkt_t;
+
+ signal digest_single : digest_t;
+ signal digest_double : digest_t;
+ signal dv_single : std_logic;
+ signal dv_double : std_logic;
+ signal busy_single : std_logic;
+ signal busy_double : std_logic;
+
+begin
+
+ clk <= not clk after CLK_PER_TIME / 2;
+ reset <= '0' after 200 ns;
+
+ init <= init_gpio(0);
+
+ init_gpios: entity work.sim_gpio
+ generic map (
+ out_num_bits => 1,
+ in_num_bits => 1,
+ actor_name => "init_gpio"
+ )
+ port map (
+ clk => clk,
+ gpio_out => init_gpio
+ );
+
+ dut_single: entity work.sha3_256
+ generic map (
+ DOUBLE_BUFFER => false
+ )
+ port map (
+ clk => clk,
+ reset => reset,
+ init => init,
+ busy => busy_single,
+ msg_if => msg_single,
+ digest => digest_single,
+ digest_valid => dv_single
+ );
+
+ dut_double: entity work.sha3_256
+ generic map (
+ DOUBLE_BUFFER => true
+ )
+ port map (
+ clk => clk,
+ reset => reset,
+ init => init,
+ busy => busy_double,
+ msg_if => msg_double,
+ digest => digest_double,
+ digest_valid => dv_double
+ );
+
+ src_single_vc: entity work.basic_pkt_source
+ generic map (
+ SOURCE => SRC_SINGLE
+ )
+ port map (
+ clk => clk,
+ ready => msg_single.ready,
+ valid => msg_single.valid,
+ last => msg_single.last,
+ data => msg_single.data
+ );
+
+ src_double_vc: entity work.basic_pkt_source
+ generic map (
+ SOURCE => SRC_DOUBLE
+ )
+ port map (
+ clk => clk,
+ ready => msg_double.ready,
+ valid => msg_double.valid,
+ last => msg_double.last,
+ data => msg_double.data
+ );
+
+end th;
diff --git a/hdl/ip/vhd/sha3/sims/sha3_sim_pkg.vhd b/hdl/ip/vhd/sha3/sims/sha3_sim_pkg.vhd
new file mode 100644
index 00000000..e1a0bd3e
--- /dev/null
+++ b/hdl/ip/vhd/sha3/sims/sha3_sim_pkg.vhd
@@ -0,0 +1,156 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+library vunit_lib;
+ context vunit_lib.vunit_context;
+ context vunit_lib.com_context;
+ context vunit_lib.vc_context;
+
+use work.keccak_pkg.all;
+
+-- Software SHA3-256 for testbench use: a plain byte-at-a-time sponge over a
+-- VUnit queue, in the spirit of crc_sim_pkg.
+--
+-- This shares keccak_round with the DUT, so it cannot catch a bug in the round
+-- function itself. It exists to check the sponge around it -- block framing,
+-- padding, byte order, multi-block carry -- at arbitrary message lengths, which
+-- a fixed set of known-answer tests cannot do. The round function is anchored
+-- separately by the KATs in keccak_pkg_tb.
+package sha3_sim_pkg is
+
+ impure function sha3_256_digest (
+ data : queue_t
+ ) return digest_t;
+
+ -- Parse a digest hex string in the conventional order, ie exactly what
+ -- sha3sum or hashlib.hexdigest() prints, into this core's bit order. The
+ -- leftmost byte of the string lands in bits 7 downto 0.
+ --
+ -- Worth having rather than writing a VHDL hex literal directly: in a literal
+ -- the leftmost digits are the most significant bits, so the constant would
+ -- have to be hand byte-reversed, which is 32 chances to make a silent
+ -- mistake in a value whose whole job is to be trustworthy.
+ function hex_digest (
+ s : string
+ ) return digest_t;
+
+ -- Convenience constructors for test messages.
+ impure function to_queue (
+ s : string
+ ) return queue_t;
+
+ impure function repeat_byte (
+ b : natural;
+ n : natural
+ ) return queue_t;
+
+end package;
+
+package body sha3_sim_pkg is
+
+ impure function sha3_256_digest (
+ data : queue_t
+ ) return digest_t is
+
+ -- Copy so we don't consume the caller's queue.
+ constant msg_queue : queue_t := copy(data);
+
+ variable st : state_t := (others => (others => (others => '0')));
+ variable blk : rate_block_t := (others => '0');
+ -- Bytes staged in the current block, always < RATE_BYTES on exit from
+ -- the absorb loop, which is what lets the padding below be unconditional.
+ variable n : natural := 0;
+
+ begin
+ while not is_empty(msg_queue) loop
+ blk(8 * n + 7 downto 8 * n) := To_StdLogicVector(pop_byte(msg_queue), 8);
+ n := n + 1;
+
+ if n = RATE_BYTES then
+ st := keccak_f1600(absorb_block(st, blk));
+ blk := (others => '0');
+ n := 0;
+ end if;
+ end loop;
+
+ -- pad10*1 with the SHA3 domain separator. Both awkward cases fall out of
+ -- writing 0x06 then OR-ing 0x80 into the top byte: when the message ends
+ -- one byte short of a block the two land on the same byte and fuse into
+ -- 0x86, and when the message length is an exact multiple of the rate we
+ -- get a full block of padding, which the spec requires.
+ blk(8 * n + 7 downto 8 * n) := PAD_FIRST;
+ blk(RATE_BITS - 1 downto RATE_BITS - 8) := blk(RATE_BITS - 1 downto RATE_BITS - 8) or PAD_LAST;
+
+ st := keccak_f1600(absorb_block(st, blk));
+
+ return digest_of(st);
+ end function;
+
+ function hex_digest (
+ s : string
+ ) return digest_t is
+
+ variable v : digest_t := (others => '0');
+ variable n : natural;
+
+ begin
+ assert s'length = DIGEST_BITS / 4
+ report "hex_digest: expected " & natural'image(DIGEST_BITS / 4) &
+ " hex digits, got " & natural'image(s'length)
+ severity failure;
+
+ for i in 0 to DIGEST_BITS / 8 - 1 loop
+ n := 0;
+
+ -- Two digits per byte, most significant nibble first.
+ for j in 0 to 1 loop
+ case s(s'low + 2 * i + j) is
+ when '0' to '9' => n := n * 16 + (character'pos(s(s'low + 2 * i + j)) - character'pos('0'));
+ when 'a' to 'f' => n := n * 16 + (character'pos(s(s'low + 2 * i + j)) - character'pos('a')) + 10;
+ when 'A' to 'F' => n := n * 16 + (character'pos(s(s'low + 2 * i + j)) - character'pos('A')) + 10;
+ when others => report "hex_digest: bad hex digit" severity failure;
+ end case;
+ end loop;
+
+ v(8 * i + 7 downto 8 * i) := To_StdLogicVector(n, 8);
+ end loop;
+
+ return v;
+ end function;
+
+ impure function to_queue (
+ s : string
+ ) return queue_t is
+
+ variable q : queue_t := new_queue;
+
+ begin
+ for i in s'range loop
+ push_byte(q, character'pos(s(i)));
+ end loop;
+
+ return q;
+ end function;
+
+ impure function repeat_byte (
+ b : natural;
+ n : natural
+ ) return queue_t is
+
+ variable q : queue_t := new_queue;
+
+ begin
+ for i in 1 to n loop
+ push_byte(q, b);
+ end loop;
+
+ return q;
+ end function;
+
+end package body;
diff --git a/hdl/ip/vhd/spi_nor_controller/BUCK b/hdl/ip/vhd/spi_nor_controller/BUCK
index c8b457bd..d0d0b5b1 100644
--- a/hdl/ip/vhd/spi_nor_controller/BUCK
+++ b/hdl/ip/vhd/spi_nor_controller/BUCK
@@ -20,6 +20,7 @@ vhdl_unit(
"link/*.vhd",
"spi_txn/*.vhd",
"espi_txn/*.vhd",
+ "hash_txn/*.vhd",
]),
standard = "2019",
deps = [
diff --git a/hdl/ip/vhd/spi_nor_controller/hash_txn/raw_flash_txn_mgr.vhd b/hdl/ip/vhd/spi_nor_controller/hash_txn/raw_flash_txn_mgr.vhd
new file mode 100644
index 00000000..cb7dd02f
--- /dev/null
+++ b/hdl/ip/vhd/spi_nor_controller/hash_txn/raw_flash_txn_mgr.vhd
@@ -0,0 +1,228 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+library ieee;
+use ieee.std_logic_1164.all;
+use ieee.numeric_std.all;
+use ieee.numeric_std_unsigned.all;
+
+use work.spi_nor_pkg.all;
+
+-- A second flash read client with the same command/response FIFO shape as
+-- espi_flash_txn_mgr, but reading raw addresses.
+--
+-- Two differences from the eSPI flavour, both deliberate:
+--
+-- * No address translation. The eSPI manager remaps the host's view onto the
+-- active image slot and the APOB window, which is exactly wrong for a client
+-- that was handed a physical flash address to hash.
+-- * A 32 bit length rather than the eSPI 12 bit one, so a whole flash image is
+-- expressible in a single command. Chunking into <= 256 byte reads still
+-- happens here and is invisible to the requester.
+--
+-- It also does not look at sp5_owns_flash. Ownership is settled by the req/grant
+-- handshake in spi_nor_top instead, so a read can proceed whether or not the host
+-- currently owns the flash.
+entity raw_flash_txn_mgr is
+ port (
+ clk : in std_logic;
+ reset : in std_logic;
+
+ -- Command FIFO: word 0 is a byte address, word 1 a byte count
+ cmd_fifo_rdata : in std_logic_vector(31 downto 0);
+ cmd_fifo_rdack : out std_logic;
+ cmd_fifo_rempty : in std_logic;
+
+ -- Ownership of the shared SPI engine. req is held for the whole command,
+ -- chunking included, so the engine is not handed to anyone else part way
+ -- through a multi chunk read.
+ req : out std_logic;
+ grant : in std_logic;
+
+ -- Command to the SPI transaction manager
+ cmd : out spi_nor_cmd_t;
+ spi_hw_busy : in std_logic;
+
+ -- Bytes back out to the requester's response FIFO
+ data_byte : out std_logic_vector(7 downto 0);
+ data_write : out std_logic;
+
+ -- Raw read data from the SPI link
+ flash_rdata : in std_logic_vector(7 downto 0);
+ flash_rdata_write : in std_logic
+ );
+end entity;
+
+architecture rtl of raw_flash_txn_mgr is
+
+ attribute mark_debug : string;
+
+ -- Actual bytes, not a zero indexed count. spi_txn_mgr loads data_bytes
+ -- straight into its counter and finishes when that reaches one, so it
+ -- transfers exactly data_bytes bytes.
+ constant max_chunk_bytes : natural := 256;
+ constant fast_read_dummy_cycles : natural := 8;
+
+ type state_t is (idle, read_cmd_addr, read_cmd_len, size_chunk, wait_idle,
+ issue_read, wait_for_data);
+
+ -- Everything here counts real bytes. The eSPI manager this was derived from
+ -- mixes zero indexed and one indexed counts, and decrements its remaining
+ -- count by the zero indexed chunk size rather than by the number of bytes
+ -- the chunk actually moves, so it over-fetches by one byte per extra chunk.
+ -- That is invisible over there because an eSPI flash read never needs more
+ -- than one chunk, but this client's reads are routinely megabytes.
+ type reg_t is record
+ state : state_t;
+ cmd_rdack : std_logic;
+ -- Address of the transaction in flight. This has to hold still for the
+ -- whole of it: spi_txn_mgr shifts spi_cmd.addr out during the address
+ -- phase and re-reads spi_cmd.data_bytes when it moves into the data
+ -- phase, so neither may be advanced at go_flag. The next chunk's address
+ -- is parked in next_addr until the current one retires.
+ cur_addr : std_logic_vector(31 downto 0);
+ next_addr : std_logic_vector(31 downto 0);
+ -- Bytes of the whole command still to be asked for
+ rem_bytes : unsigned(31 downto 0);
+ -- Bytes in the chunk currently being issued, and how many of them are
+ -- still to come back
+ chunk : natural range 0 to max_chunk_bytes;
+ left : natural range 0 to max_chunk_bytes;
+ end record;
+
+ constant reg_reset : reg_t := (
+ state => idle,
+ cmd_rdack => '0',
+ cur_addr => (others => '0'),
+ next_addr => (others => '0'),
+ rem_bytes => (others => '0'),
+ chunk => 0,
+ left => 0
+ );
+
+ signal r, rin : reg_t;
+
+ attribute mark_debug of r : signal is "TRUE";
+
+begin
+
+ cmd.addr <= r.cur_addr;
+ cmd.data_bytes <= To_Std_Logic_Vector(r.chunk, cmd.data_bytes'length);
+ cmd.dummy_cycles <= To_Std_Logic_Vector(fast_read_dummy_cycles, cmd.dummy_cycles'length);
+ cmd.instr <= FAST_READ_4BYTE_QUAD_OP;
+ -- Held for as long as we are in issue_read, and dropped only once the
+ -- controller has actually taken it. See the state for why that matters.
+ cmd.go_flag <= '1' when r.state = issue_read else '0';
+
+ data_byte <= flash_rdata;
+ data_write <= flash_rdata_write when r.state = wait_for_data else '0';
+
+ cmd_fifo_rdack <= r.cmd_rdack;
+
+ -- Ask for the engine as soon as there is a command waiting, and keep asking
+ -- until the whole thing has been serviced.
+ req <= '1' when r.state /= idle or cmd_fifo_rempty = '0' else '0';
+
+ sm: process(all)
+
+ variable v : reg_t;
+
+ begin
+ v := r;
+
+ v.cmd_rdack := '0';
+
+ case r.state is
+
+ when idle =>
+ -- Show-ahead FIFO, so the address word is already on rdata.
+ if cmd_fifo_rempty = '0' and grant = '1' then
+ v.cur_addr := cmd_fifo_rdata;
+ v.state := read_cmd_addr;
+ end if;
+
+ when read_cmd_addr =>
+ v.state := read_cmd_len;
+
+ when read_cmd_len =>
+ -- A real byte count, taken as-is.
+ v.rem_bytes := unsigned(cmd_fifo_rdata);
+ v.state := size_chunk;
+
+ when size_chunk =>
+ -- One cycle of arithmetic, shared by the first chunk and every
+ -- one after it so the two cannot drift apart.
+ if r.rem_bytes = 0 then
+ v.state := idle;
+ else
+ if r.rem_bytes > max_chunk_bytes then
+ v.chunk := max_chunk_bytes;
+ else
+ v.chunk := to_integer(r.rem_bytes);
+ end if;
+
+ v.state := wait_idle;
+ end if;
+
+ when wait_idle =>
+ -- The previous chunk's transaction has to be completely finished
+ -- before we start asking for the next one, otherwise the busy
+ -- rise we wait on below would be its cs_n, not ours.
+ if spi_hw_busy = '0' then
+ v.state := issue_read;
+ end if;
+
+ when issue_read =>
+ -- Leave only once the controller has actually started the
+ -- transaction, ie once it pulls cs_n low.
+ --
+ -- Leaving on "not busy" instead looks right and is not: the
+ -- controller enforces a minimum cs_n high time between
+ -- transactions and ignores go_flag until it expires, while
+ -- spi_hw_busy goes low the moment cs_n rises. Exiting on that
+ -- turns go_flag into a single cycle pulse inside the dead window
+ -- and the command is quietly dropped, which strands every chunk
+ -- after the first. The first chunk of a command survives because
+ -- the controller has been idle long enough for the window to have
+ -- expired already.
+ if spi_hw_busy = '1' then
+ v.left := r.chunk;
+ v.rem_bytes := r.rem_bytes - r.chunk;
+ -- Parked, not applied: cur_addr is still being shifted out.
+ v.next_addr := r.cur_addr + r.chunk;
+ v.state := wait_for_data;
+ end if;
+
+ when wait_for_data =>
+ if flash_rdata_write = '1' then
+ if r.left = 1 then
+ -- Last byte of this chunk, so the transaction is done
+ -- with cur_addr and the next one can have it.
+ v.cur_addr := r.next_addr;
+ v.state := size_chunk;
+ else
+ v.left := r.left - 1;
+ end if;
+ end if;
+
+ end case;
+
+ -- Pop one command word in each of these two states.
+ if v.state = read_cmd_addr or v.state = read_cmd_len then
+ v.cmd_rdack := '1';
+ end if;
+
+ rin <= v;
+ end process;
+
+ reg: process(clk, reset)
+ begin
+ if reset then
+ r <= reg_reset;
+ elsif rising_edge(clk) then
+ r <= rin;
+ end if;
+ end process;
+
+end rtl;
diff --git a/hdl/ip/vhd/spi_nor_controller/spi_nor_top.vhd b/hdl/ip/vhd/spi_nor_controller/spi_nor_top.vhd
index cf5dd8df..01e0f616 100644
--- a/hdl/ip/vhd/spi_nor_controller/spi_nor_top.vhd
+++ b/hdl/ip/vhd/spi_nor_controller/spi_nor_top.vhd
@@ -47,6 +47,17 @@ entity spi_nor_top is
espi_data_fifo_wdata : out std_logic_vector(7 downto 0);
espi_data_fifo_write : out std_logic;
+ -- Second flash read client, same command/response FIFO shape as the eSPI
+ -- one above. Used by the hashing engine. Addresses here are raw: none of
+ -- the SP5 image or APOB translation applied to the eSPI path happens, and
+ -- it is not gated by sp5_owns_flash. Tie the command FIFO empty and leave
+ -- the rest open if the design has no such client.
+ hash_cmd_fifo_rdata: in std_logic_vector(31 downto 0) := (others => '0');
+ hash_cmd_fifo_rdack: out std_logic;
+ hash_cmd_fifo_rempty: in std_logic := '1';
+ hash_data_fifo_wdata : out std_logic_vector(7 downto 0);
+ hash_data_fifo_write : out std_logic;
+
);
end entity;
@@ -99,7 +110,11 @@ architecture rtl of spi_nor_top is
signal apob_flash_offset : apobflashoffset_type;
signal espi_cmd : spi_nor_cmd_t;
signal hubris_cmd: spi_nor_cmd_t;
+ signal hash_cmd : spi_nor_cmd_t;
signal spi_cmd_if : spi_nor_cmd_t;
+ signal hash_req : std_logic;
+ signal hash_grant : std_logic;
+ signal hash_fifo_write : std_logic;
begin
@@ -174,14 +189,42 @@ begin
hubris_cmd.dummy_cycles <= dummy_cycles_reg.count;
hubris_cmd.instr <= instr_reg.opcode;
hubris_cmd.go_flag <= go_strobe;
- -- Mux between espi and register interface for read data (rx fifos)
- reg_fifo_write_allowed <= '1' when spicr_reg.sp5_owns_flash = '0' else '0';
- espi_fifo_write_allowed <= '1' when spicr_reg.sp5_owns_flash = '1' else '0';
+
+ -- The hash client takes the whole engine for the duration of one command,
+ -- chunking included, and only takes it when nothing else is mid transaction or
+ -- waiting. While it is not asking, everything below reduces to the original
+ -- two way sp5_owns_flash selection.
+ --
+ -- Note this does lock the eSPI path out for as long as a hash read runs, which
+ -- can be a whole flash image. That is the intended trade: measurement is
+ -- expected to happen while the host is not booting.
+ hash_grant_sm: process(clk, reset)
+ begin
+ if reset then
+ hash_grant <= '0';
+ elsif rising_edge(clk) then
+ if hash_grant = '0' then
+ if hash_req = '1' and cs_n = '1' and espi_cmd_fifo_rempty = '1' and
+ go_strobe = '0' then
+ hash_grant <= '1';
+ end if;
+ elsif hash_req = '0' then
+ hash_grant <= '0';
+ end if;
+ end if;
+ end process;
+
+ -- Mux between hash, espi and register interface for read data (rx fifos)
+ reg_fifo_write_allowed <= '1' when hash_grant = '0' and spicr_reg.sp5_owns_flash = '0' else '0';
+ espi_fifo_write_allowed <= '1' when hash_grant = '0' and spicr_reg.sp5_owns_flash = '1' else '0';
reg_fifo_write <= reg_fifo_write_allowed and rx_fifo_write8;
espi_fifo_write <= espi_fifo_write_allowed and rx_fifo_write8;
+ hash_fifo_write <= rx_fifo_write8 when hash_grant = '1' else '0';
+
+ spi_cmd_if <= hash_cmd when hash_grant = '1' else
+ hubris_cmd when spicr_reg.sp5_owns_flash = '0' else
+ espi_cmd;
- spi_cmd_if <= hubris_cmd when spicr_reg.sp5_owns_flash = '0' else espi_cmd;
-
sp5_owns_flash <= spicr_reg.sp5_owns_flash;
-- TODO: this would be more simple with a mixed width fifo
-- but this was faster than digging around making a new wrapper
@@ -297,6 +340,25 @@ begin
rx_fifo_read_ack => rx_fifo_read_ack_reg
);
+ -- Second read transaction manager, for the hashing engine. Raw addresses, and
+ -- a 32 bit length so a whole image fits in one command.
+ raw_flash_txn_mgr_inst: entity work.raw_flash_txn_mgr
+ port map(
+ clk => clk,
+ reset => reset,
+ cmd_fifo_rdata => hash_cmd_fifo_rdata,
+ cmd_fifo_rdack => hash_cmd_fifo_rdack,
+ cmd_fifo_rempty => hash_cmd_fifo_rempty,
+ req => hash_req,
+ grant => hash_grant,
+ cmd => hash_cmd,
+ spi_hw_busy => spisr_reg.busy,
+ data_byte => hash_data_fifo_wdata,
+ data_write => hash_data_fifo_write,
+ flash_rdata => rx_fifo_wdat8,
+ flash_rdata_write => hash_fifo_write
+ );
+
-- Read transaction manager to/from the espi block
espi_flash_txn_mgr_inst: entity work.espi_flash_txn_mgr
port map(
diff --git a/hdl/ip/vhd/vunit_components/basic_stream/basic_pkt_source.vhd b/hdl/ip/vhd/vunit_components/basic_stream/basic_pkt_source.vhd
new file mode 100644
index 00000000..824277ad
--- /dev/null
+++ b/hdl/ip/vhd/vunit_components/basic_stream/basic_pkt_source.vhd
@@ -0,0 +1,64 @@
+-- This Source Code Form is subject to the terms of the Mozilla Public
+-- License, v. 2.0. If a copy of the MPL was not distributed with this
+-- file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+-- basic_source with a last flag, for driving the axi_st_pkt_t flavour of the
+-- streaming interface in axist_if_2k19_pkg. Identical backpressure and
+-- valid-throttling behaviour, it just carries one more bit.
+
+library ieee;
+use ieee.std_logic_1164.all;
+
+library osvvm;
+use osvvm.RandomPkg.RandomPType;
+
+library vunit_lib;
+ context vunit_lib.vunit_context;
+ context vunit_lib.com_context;
+ context vunit_lib.vc_context;
+
+use work.basic_stream_pkg.all;
+
+entity basic_pkt_source is
+ generic (
+ source : basic_source_t
+ );
+ port (
+ clk : in std_logic;
+ ready : in std_logic;
+ valid : out std_logic := '0';
+ last : out std_logic := '0';
+ data : out std_logic_vector(data_length(source)-1 downto 0) := (others => '0')
+ );
+end entity;
+
+architecture model of basic_pkt_source is
+begin
+
+ main: process
+ variable msg : msg_t;
+ variable msg_type : msg_type_t;
+ variable rnd : RandomPType;
+ begin
+ receive(net, source.p_actor, msg);
+ msg_type := message_type(msg);
+
+ if msg_type = push_basic_pkt_stream_msg then
+ -- loop until there will be valid data
+ while rnd.Uniform(0.0, 1.0) > source.valid_high_probability loop
+ wait until rising_edge(clk);
+ end loop;
+ valid <= '1';
+ data <= pop_std_ulogic_vector(msg);
+ last <= '1' when pop_boolean(msg) else '0';
+
+ -- wait until data should be accepted
+ wait until (valid and ready) = '1' and rising_edge(clk);
+ valid <= '0';
+ last <= '0';
+ else
+ unexpected_msg_type(msg_type);
+ end if;
+ end process;
+
+end architecture;
diff --git a/hdl/ip/vhd/vunit_components/basic_stream/basic_stream_pkg.vhd b/hdl/ip/vhd/vunit_components/basic_stream/basic_stream_pkg.vhd
index a81500b6..0bfaf02c 100644
--- a/hdl/ip/vhd/vunit_components/basic_stream/basic_stream_pkg.vhd
+++ b/hdl/ip/vhd/vunit_components/basic_stream/basic_stream_pkg.vhd
@@ -52,6 +52,7 @@ package basic_stream_pkg is
constant push_basic_stream_msg : msg_type_t := new_msg_type("push basic stream");
constant pop_basic_stream_msg : msg_type_t := new_msg_type("pop basic stream");
+ constant push_basic_pkt_stream_msg : msg_type_t := new_msg_type("push basic pkt stream");
procedure push_basic_stream(
signal net : inout network_t;
@@ -59,6 +60,19 @@ package basic_stream_pkg is
data : std_logic_vector
);
+ -- Same as push_basic_stream but also carries a last flag, for driving the
+ -- axi_st_pkt_t flavour of the streaming interface. Consumed by
+ -- basic_pkt_source; a plain basic_source will reject the message type.
+ --
+ -- Like push_basic_stream this only queues the beat, it does not wait for it
+ -- to be accepted.
+ procedure push_basic_pkt_stream(
+ signal net : inout network_t;
+ basic_source : basic_source_t;
+ data : std_logic_vector;
+ last : boolean := false
+ );
+
procedure pop_basic_stream(
signal net : inout network_t;
basic_sink : basic_sink_t;
@@ -136,6 +150,19 @@ package body basic_stream_pkg is
send(net, basic_source.p_actor, msg);
end;
+ procedure push_basic_pkt_stream(
+ signal net : inout network_t;
+ basic_source : basic_source_t;
+ data : std_logic_vector;
+ last : boolean := false
+ ) is
+ variable msg : msg_t := new_msg(push_basic_pkt_stream_msg);
+ begin
+ push_std_ulogic_vector(msg, data);
+ push_boolean(msg, last);
+ send(net, basic_source.p_actor, msg);
+ end;
+
procedure pop_basic_stream(
signal net : inout network_t;
basic_sink : basic_sink_t;
diff --git a/hdl/projects/cosmo_seq/BUCK b/hdl/projects/cosmo_seq/BUCK
index dd5b0060..ed481ad6 100644
--- a/hdl/projects/cosmo_seq/BUCK
+++ b/hdl/projects/cosmo_seq/BUCK
@@ -12,6 +12,7 @@ rdl_file(
"//hdl/projects/cosmo_seq/sp_i2c_subsystem:sp_i2c_regs_rdl",
"//hdl/projects/cosmo_seq/sequencer:sequencer_regs_rdl",
"//hdl/ip/vhd/spi_nor_controller:spi_nor_regs_rdl",
+ "//hdl/ip/vhd/hash_engine:hash_engine_regs_rdl",
"//hdl/ip/vhd/espi:espi_regs_rdl",
"//hdl/ip/vhd/i2c/io_expanders/PCA9506ish:pca9506_regs_rdl",
],
diff --git a/hdl/projects/cosmo_seq/cosmo_seq_top.rdl b/hdl/projects/cosmo_seq/cosmo_seq_top.rdl
index 4737bf66..ee2382a5 100644
--- a/hdl/projects/cosmo_seq/cosmo_seq_top.rdl
+++ b/hdl/projects/cosmo_seq/cosmo_seq_top.rdl
@@ -19,5 +19,6 @@ addrmap cosmo_seq_top {
pca9506_axi_regs fpga1_hotplug @ 0x0400;
dimm_regs dimms @ 0x0500;
debug_regs debug_ctrl @ 0x0600;
+ hash_engine_regs hash @ 0x0700;
espi_regs espi @ 0x8000;
};
\ No newline at end of file
diff --git a/hdl/projects/cosmo_seq/cosmo_seq_top.vhd b/hdl/projects/cosmo_seq/cosmo_seq_top.vhd
index 45893855..94b8fbb4 100644
--- a/hdl/projects/cosmo_seq/cosmo_seq_top.vhd
+++ b/hdl/projects/cosmo_seq/cosmo_seq_top.vhd
@@ -343,6 +343,7 @@ architecture rtl of cosmo_seq_top is
constant SPD_PROXY_RESP_IDX : integer := 5;
constant DBG_CTRL_RESP_IDX : integer := 6;
constant ESPI_RESP_IDX: integer := 7;
+ constant HASH_RESP_IDX : integer := 8;
constant config_array : axil_responder_cfg_array_t :=
(INFO_RESP_IDX => resp_cfg(base_addr => x"00000000", addr_span_bits => 8),
@@ -355,7 +356,8 @@ architecture rtl of cosmo_seq_top is
-- eSPI is the largest register file and the most distant block, and it
-- owns the worst 125MHz path in the design, so give the fabric a cycle
-- in each direction to get there and back.
- ESPI_RESP_IDX => resp_cfg(base_addr => x"00008000", addr_span_bits => 15, pipe_stages => 1)
+ ESPI_RESP_IDX => resp_cfg(base_addr => x"00008000", addr_span_bits => 15, pipe_stages => 1),
+ HASH_RESP_IDX => resp_cfg(base_addr => x"00000700", addr_span_bits => 8)
);
signal fmc_axi_if : axil26x32_pkg.axil_t;
signal fabric_responders : axil32x32_pkg.axil_array_t(config_array'range);
@@ -526,6 +528,7 @@ begin
-- all the system interfaces run at 125MHz for common clocking
resize_axil(fabric_responders(ESPI_RESP_IDX), responders_15b(ESPI_RESP_IDX));
resize_axil(fabric_responders(SPINOR_RESP_IDX), responders_8b(SPINOR_RESP_IDX));
+ resize_axil(fabric_responders(HASH_RESP_IDX), responders_8b(HASH_RESP_IDX));
espi_spinor_ss: entity work.sp5_espi_flash_subsystem
port map(
clk_125m => clk_125m,
@@ -546,7 +549,8 @@ begin
spi_nor_clk => spi_fpga1_to_flash_clk,
spi_nor_dat => spi_fpga1_to_flash_dat,
spi_nor_dat_o => spinor_io_o,
- spi_nor_dat_oe => spinor_io_oe
+ spi_nor_dat_oe => spinor_io_oe,
+ hash_axi_if => responders_8b(HASH_RESP_IDX)
);
--Tristates for spi-nor flash pins and espi
spi_nor_espi_tris:process(all)
diff --git a/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/BUCK b/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/BUCK
index 0811433b..0027219d 100644
--- a/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/BUCK
+++ b/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/BUCK
@@ -11,6 +11,8 @@ vhdl_unit(
"//hdl/ip/vhd/axi_blocks:axist_if_2k19_pkg",
"//hdl/ip/vhd/axi_blocks:axil_interconnect",
"//hdl/ip/vhd/spi_nor_controller:spi_nor_top",
+ "//hdl/ip/vhd/hash_engine:hash_engine_top",
+ "//hdl/ip/vhd/fifos:dcfifo_xpm",
],
standard = "2019",
visibility = ["PUBLIC"],
diff --git a/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/sp5_espi_flash_subsystem.vhd b/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/sp5_espi_flash_subsystem.vhd
index 47c167a4..572059d0 100644
--- a/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/sp5_espi_flash_subsystem.vhd
+++ b/hdl/projects/cosmo_seq/sp5_espi_flash_subsystem/sp5_espi_flash_subsystem.vhd
@@ -35,7 +35,11 @@ entity sp5_espi_flash_subsystem is
spi_nor_dat : in std_logic_vector(3 downto 0);
spi_nor_dat_o : out std_logic_vector(3 downto 0);
spi_nor_dat_oe : out std_logic_vector(3 downto 0);
-
+
+ -- SHA3 hashing engine. It lives here rather than at the top level because
+ -- it reads the flash through spi_nor_top's second client port, so it needs
+ -- the same command/response FIFO pattern the eSPI flash channel uses.
+ hash_axi_if : view axil8x32_pkg.axil_target;
);
end entity;
@@ -55,6 +59,22 @@ architecture rtl of sp5_espi_flash_subsystem is
signal fifo_reset : std_logic;
signal rst_cnts : integer range 0 to 5 := 5;
+ -- Hashing engine <-> spi_nor_top, the same shape as the eSPI pair above.
+ -- Deliberately not tied to fifo_reset: that is flushed on every eSPI reset,
+ -- which happens at the start of every boot and has nothing to do with a hash
+ -- the SP may have in flight. The engine resynchronises its own channel by
+ -- draining it, so a global reset is the only thing that needs to clear these.
+ signal hash_cmd_fifo_wdata : std_logic_vector(31 downto 0);
+ signal hash_cmd_fifo_write : std_logic;
+ signal hash_cmd_fifo_rdata : std_logic_vector(31 downto 0);
+ signal hash_cmd_fifo_rdack : std_logic;
+ signal hash_cmd_fifo_rempty : std_logic;
+ signal hash_data_fifo_wdata : std_logic_vector(7 downto 0);
+ signal hash_data_fifo_write : std_logic;
+ signal hash_rsp_fifo_rdata : std_logic_vector(7 downto 0);
+ signal hash_rsp_fifo_rdack : std_logic;
+ signal hash_rsp_fifo_rempty : std_logic;
+
begin
@@ -119,6 +139,59 @@ begin
rusedwds => open
);
+ -- Hashing engine -> SPI NOR FIFO
+ hash_spinor_cmd_fifo: entity work.dcfifo_xpm
+ generic map(
+ fifo_write_depth => 256,
+ data_width => 32,
+ showahead_mode => true
+ )
+ port map(
+ wclk => clk_125m,
+ reset => reset_125m,
+ write_en => hash_cmd_fifo_write,
+ wdata => hash_cmd_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk_125m,
+ rdata => hash_cmd_fifo_rdata,
+ rdreq => hash_cmd_fifo_rdack,
+ rempty => hash_cmd_fifo_rempty,
+ rusedwds => open
+ );
+ -- SPI NOR -> hashing engine FIFO
+ hash_spinor_data_fifo: entity work.dcfifo_xpm
+ generic map(
+ fifo_write_depth => 256,
+ data_width => 8,
+ showahead_mode => true
+ )
+ port map(
+ wclk => clk_125m,
+ reset => reset_125m,
+ write_en => hash_data_fifo_write,
+ wdata => hash_data_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk_125m,
+ rdata => hash_rsp_fifo_rdata,
+ rdreq => hash_rsp_fifo_rdack,
+ rempty => hash_rsp_fifo_rempty,
+ rusedwds => open
+ );
+
+ hash_engine_inst: entity work.hash_engine_top
+ port map(
+ clk => clk_125m,
+ reset => reset_125m,
+ axi_if => hash_axi_if,
+ cmd_fifo_wdata => hash_cmd_fifo_wdata,
+ cmd_fifo_write => hash_cmd_fifo_write,
+ rsp_fifo_rdata => hash_rsp_fifo_rdata,
+ rsp_fifo_rdack => hash_rsp_fifo_rdack,
+ rsp_fifo_rempty => hash_rsp_fifo_rempty
+ );
+
-- eSPI block
-- Only the link layer runs at 200MHz, the remaining
-- logic runs at 125MHz so all the interfaces are synchronous
@@ -183,8 +256,13 @@ begin
espi_cmd_fifo_rdack => espi_cmd_fifo_rdack,
espi_cmd_fifo_rempty => espi_cmd_fifo_rempty,
espi_data_fifo_wdata => espi_data_fifo_wdata,
- espi_data_fifo_write => espi_data_fifo_write
-
+ espi_data_fifo_write => espi_data_fifo_write,
+ hash_cmd_fifo_rdata => hash_cmd_fifo_rdata,
+ hash_cmd_fifo_rdack => hash_cmd_fifo_rdack,
+ hash_cmd_fifo_rempty => hash_cmd_fifo_rempty,
+ hash_data_fifo_wdata => hash_data_fifo_wdata,
+ hash_data_fifo_write => hash_data_fifo_write
+
);
end rtl;
\ No newline at end of file
diff --git a/hdl/projects/grapefruit/BUCK b/hdl/projects/grapefruit/BUCK
index 931d9bc2..ff6f595b 100644
--- a/hdl/projects/grapefruit/BUCK
+++ b/hdl/projects/grapefruit/BUCK
@@ -29,6 +29,7 @@ rdl_file(
":gfruit_regs_rdl",
":gfruit_sgpio_regs_rdl",
"//hdl/ip/vhd/spi_nor_controller:spi_nor_regs_rdl",
+ "//hdl/ip/vhd/hash_engine:hash_engine_regs_rdl",
"//hdl/ip/vhd/espi:espi_regs_rdl",
],
outputs = [
@@ -79,6 +80,8 @@ vhdl_unit(
"//hdl/ip/vhd/uart:axi_fifo_uart",
"//hdl/ip/vhd/axi_blocks:axil_interconnect",
"//hdl/ip/vhd/spi_nor_controller:spi_nor_top",
+ "//hdl/ip/vhd/hash_engine:hash_engine_top",
+ "//hdl/ip/vhd/fifos:dcfifo_xpm",
"//hdl/ip/vhd/fmc_if:stm32h7_fmc_target",
"//hdl/ip/vhd/common:time_pkg",
"//hdl/ip/vhd/common:tristate_if_pkg",
diff --git a/hdl/projects/grapefruit/gfruit_top.rdl b/hdl/projects/grapefruit/gfruit_top.rdl
index 29e8a4f4..5830a14c 100644
--- a/hdl/projects/grapefruit/gfruit_top.rdl
+++ b/hdl/projects/grapefruit/gfruit_top.rdl
@@ -4,5 +4,7 @@ addrmap top_level_map {
gfruit_regs base @ 0x0;
spi_nor_regs spi_nor @ 0x0100;
gfruit_sgpio_regs sgpio @ 0x0200;
+ // 0x0700 to match cosmo_seq, so the same tooling offset works on both boards
+ hash_engine_regs hash @ 0x0700;
espi_regs espi @ 0x8000;
};
\ No newline at end of file
diff --git a/hdl/projects/grapefruit/grapefruit_top.vhd b/hdl/projects/grapefruit/grapefruit_top.vhd
index c0eaf1a1..fd6dd60d 100644
--- a/hdl/projects/grapefruit/grapefruit_top.vhd
+++ b/hdl/projects/grapefruit/grapefruit_top.vhd
@@ -208,11 +208,25 @@ architecture rtl of grapefruit_top is
(0 => resp_cfg(base_addr => x"00000000", addr_span_bits => 8),
1 => resp_cfg(base_addr => x"00000100", addr_span_bits => 8),
2 => resp_cfg(base_addr => x"00000200", addr_span_bits => 8),
- 3 => resp_cfg(base_addr => x"00008000", addr_span_bits => 15)
+ 3 => resp_cfg(base_addr => x"00008000", addr_span_bits => 15),
+ 4 => resp_cfg(base_addr => x"00000700", addr_span_bits => 8)
);
signal fabric_responders : axil32x32_pkg.axil_array_t(config_array'range);
signal responders_8b : axil8x32_pkg.axil_array_t(config_array'range);
signal responders_15b : axil15x32_pkg.axil_array_t(config_array'range);
+ -- Hashing engine <-> spi_nor_top, the same shape as the eSPI pair below.
+ -- Reset from reset_125m only: the engine resynchronises its own response
+ -- channel by draining it, so nothing else should be clearing these.
+ signal hash_cmd_fifo_wdata : std_logic_vector(31 downto 0);
+ signal hash_cmd_fifo_write : std_logic;
+ signal hash_cmd_fifo_rdata : std_logic_vector(31 downto 0);
+ signal hash_cmd_fifo_rdack : std_logic;
+ signal hash_cmd_fifo_rempty : std_logic;
+ signal hash_data_fifo_wdata : std_logic_vector(7 downto 0);
+ signal hash_data_fifo_write : std_logic;
+ signal hash_rsp_fifo_rdata : std_logic_vector(7 downto 0);
+ signal hash_rsp_fifo_rdack : std_logic;
+ signal hash_rsp_fifo_rempty : std_logic;
signal espi_cmd_fifo_rdata : std_logic_vector(31 downto 0);
signal espi_cmd_fifo_rdack : std_logic;
signal espi_cmd_fifo_rempty : std_logic;
@@ -358,10 +372,69 @@ begin
espi_cmd_fifo_rdack => espi_cmd_fifo_rdack,
espi_cmd_fifo_rempty => espi_cmd_fifo_rempty,
espi_data_fifo_wdata => espi_data_fifo_wdata,
- espi_data_fifo_write => espi_data_fifo_write
+ espi_data_fifo_write => espi_data_fifo_write,
+ hash_cmd_fifo_rdata => hash_cmd_fifo_rdata,
+ hash_cmd_fifo_rdack => hash_cmd_fifo_rdack,
+ hash_cmd_fifo_rempty => hash_cmd_fifo_rempty,
+ hash_data_fifo_wdata => hash_data_fifo_wdata,
+ hash_data_fifo_write => hash_data_fifo_write
);
+ -- Hashing engine -> SPI NOR
+ hash_spinor_cmd_fifo: entity work.dcfifo_xpm
+ generic map(
+ fifo_write_depth => 256,
+ data_width => 32,
+ showahead_mode => true
+ )
+ port map(
+ wclk => clk_125m,
+ reset => reset_125m,
+ write_en => hash_cmd_fifo_write,
+ wdata => hash_cmd_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk_125m,
+ rdata => hash_cmd_fifo_rdata,
+ rdreq => hash_cmd_fifo_rdack,
+ rempty => hash_cmd_fifo_rempty,
+ rusedwds => open
+ );
+ -- SPI NOR -> hashing engine
+ hash_spinor_data_fifo: entity work.dcfifo_xpm
+ generic map(
+ fifo_write_depth => 256,
+ data_width => 8,
+ showahead_mode => true
+ )
+ port map(
+ wclk => clk_125m,
+ reset => reset_125m,
+ write_en => hash_data_fifo_write,
+ wdata => hash_data_fifo_wdata,
+ wfull => open,
+ wusedwds => open,
+ rclk => clk_125m,
+ rdata => hash_rsp_fifo_rdata,
+ rdreq => hash_rsp_fifo_rdack,
+ rempty => hash_rsp_fifo_rempty,
+ rusedwds => open
+ );
+
+ resize_axil(fabric_responders(4), responders_8b(4));
+ hash_engine_inst: entity work.hash_engine_top
+ port map(
+ clk => clk_125m,
+ reset => reset_125m,
+ axi_if => responders_8b(4),
+ cmd_fifo_wdata => hash_cmd_fifo_wdata,
+ cmd_fifo_write => hash_cmd_fifo_write,
+ rsp_fifo_rdata => hash_rsp_fifo_rdata,
+ rsp_fifo_rdack => hash_rsp_fifo_rdack,
+ rsp_fifo_rempty => hash_rsp_fifo_rempty
+ );
+
-- eSPI block -> SPI NOR
espi_spinor_cmd_fifo: entity work.dcfifo_xpm
generic map(
@@ -407,7 +480,12 @@ begin
-- Only the link layer runs at 200MHz, the remaining
-- logic runs at 125MHz so all the interfaces are synchronous
-- to 125MHz
- resize_axil(fabric_responders(3), responders_8b(3));
+ -- The eSPI responder has a 15 bit span, so it has to be resized into the 15
+ -- bit array, which is what is wired to the block below. Resizing into the 8
+ -- bit one instead left responders_15b(3) undriven and the eSPI register
+ -- window at 0x8000 unreachable, while responders_8b(3) was written and never
+ -- read.
+ resize_axil(fabric_responders(3), responders_15b(3));
espi_target_top_inst: entity work.espi_target_top
port map(
clk_200m => clk_200m,
diff --git a/tools/hash_engine_flash_test.py b/tools/hash_engine_flash_test.py
new file mode 100755
index 00000000..918e7c2f
--- /dev/null
+++ b/tools/hash_engine_flash_test.py
@@ -0,0 +1,561 @@
+#!/usr/bin/env python3
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+"""Exercise the host-flash path of the FPGA SHA3-256 hashing engine.
+
+Point this at the ROM image that is already programmed into the host flash and
+it drives the hashing engine over the FMC bus, using humility's FmcDemo peek32
+and poke32, then compares the digest the hardware produced against one computed
+here in software.
+
+Sector bypass
+-------------
+The first sector of the host flash is not part of what we want to measure, so it
+is hashed as a run of 0xFF rather than as whatever the flash actually holds. The
+engine does that with two registers rather than a dedicated feature:
+
+ PREPEND = sector size -- feed this many 0xFF bytes first
+ FLASH_ADDR = sector size -- then start fetching one sector in
+ LENGTH = image size -- total, counting the 0xFF run
+
+so the message is `0xFF * sector` followed by the image from the second sector
+on, which is exactly the image with its first sector blanked. The expected
+digest computed here is built the same way, from the ROM file.
+
+Usage
+-----
+ ./tools/hash_engine_flash_test.py --rom cosmo-host.bin
+
+ # a board that maps the FPGA somewhere other than 0xc0000000
+ ./tools/hash_engine_flash_test.py --rom img.bin --hash-offset 0xd0000700
+
+ # see the humility invocations without touching hardware
+ ./tools/hash_engine_flash_test.py --rom img.bin --dry-run
+
+ # check the digest and register-packing arithmetic, no hardware needed
+ ./tools/hash_engine_flash_test.py --selftest
+"""
+
+import argparse
+import hashlib
+import re
+import shutil
+import struct
+import subprocess
+import sys
+import time
+
+# Byte offsets within the hashing engine's register window. These mirror
+# hdl/ip/vhd/hash_engine/hash_engine_regs.rdl; regenerate that package and
+# re-check here if the map ever changes.
+REG = {
+ "CONTROL": 0x00,
+ "CONFIG": 0x04,
+ "PREPEND": 0x08,
+ "FLASH_ADDR": 0x0C,
+ "LENGTH": 0x10,
+ "STATUS": 0x14,
+ "WDATA": 0x18,
+ "PROGRESS": 0x1C,
+}
+DIGEST0 = 0x20
+DIGEST_WORDS = 8
+
+# The engine as the SP sees it: FPGA offset 0x0700 (see cosmo_seq_top.rdl) inside
+# the FMC window the STM32 maps the FPGA into at 0xc0000000. FmcDemo.peek32 and
+# poke32 take absolute addresses, so this is already one and --base stays at 0.
+DEFAULT_HASH_OFFSET = 0xC0000700
+
+# CONTROL bits, both self-clearing.
+CTRL_START = 1 << 0
+CTRL_ABORT = 1 << 1
+
+# STATUS bits.
+ST_BUSY = 1 << 0
+ST_DONE = 1 << 1
+ST_WFIFO_FULL = 1 << 2
+ST_WFIFO_EMPTY = 1 << 3
+ST_ABORTED = 1 << 4
+ST_CFG_ERR = 1 << 5
+
+# CONFIG.source encoding.
+SRC_LOCAL_REG = 0
+SRC_HOST_QSPI = 1
+
+# 4 KiB, matching SECTOR_BYTES in the SPI NOR verification component. Override
+# with --sector-size if the part in question uses something else.
+DEFAULT_SECTOR_SIZE = 0x10000
+
+
+def status_str(value):
+ """Render a STATUS read as something readable in a log."""
+ bits = [
+ (ST_BUSY, "busy"),
+ (ST_DONE, "done"),
+ (ST_WFIFO_FULL, "wfifo_full"),
+ (ST_WFIFO_EMPTY, "wfifo_empty"),
+ (ST_ABORTED, "aborted"),
+ (ST_CFG_ERR, "cfg_err"),
+ ]
+ on = [name for mask, name in bits if value & mask]
+ return "0x%08x [%s]" % (value, " ".join(on) if on else "-")
+
+
+def expected_message(rom, sector_size):
+ """The bytes the engine should end up hashing, given the ROM image.
+
+ The first sector is replaced by 0xFF rather than dropped, so the message is
+ the same length as the image.
+ """
+ if len(rom) <= sector_size:
+ raise ValueError(
+ "ROM is %d bytes, which is not longer than the %d byte sector being "
+ "bypassed, so there would be nothing left to hash"
+ % (len(rom), sector_size)
+ )
+ return b"\xff" * sector_size + rom[sector_size:]
+
+
+def digest_from_words(words):
+ """Reassemble DIGEST0..7 into the conventional hex string.
+
+ Each register holds four digest bytes little-endian, and DIGEST0 bits 7:0
+ are hash byte 0, so the whole thing is just the words packed little-endian
+ end to end.
+ """
+ if len(words) != DIGEST_WORDS:
+ raise ValueError("expected %d digest words, got %d" % (DIGEST_WORDS, len(words)))
+ return b"".join(struct.pack("' is dropped so an address echoed back in
+ the arguments cannot be mistaken for the result, and the *first* number
+ after it is taken rather than the last. A byte-array result is
+ reassembled little-endian, which is how a 32-bit peek comes back if the
+ idl types it as a buffer.
+ """
+ tail = text.rsplit("=>", 1)[-1] if "=>" in text else text
+
+ # A list result, e.g. "Ok([0xef, 0xbe, 0xad, 0xde])".
+ listing = re.search(r"\[([^\]]*)\]", tail)
+ if listing:
+ items = [t for t in re.split(r"[,\s]+", listing.group(1)) if t]
+ if items:
+ try:
+ vals = [int(t, 16) if t.lower().startswith("0x") else int(t)
+ for t in items]
+ except ValueError:
+ vals = []
+ if vals and all(0 <= v <= 0xFF for v in vals):
+ return int.from_bytes(bytes(vals[:4]), "little")
+
+ found = cls._VALUE_RE.findall(tail)
+ if not found:
+ raise SystemExit(
+ "could not find a value in humility's output for: %s\n"
+ "raw output was:\n%s\n"
+ "If humility prints results in some other shape, _parse in this "
+ "script is the place to teach it."
+ % (" ".join(cmd or []), text)
+ )
+ token = found[0]
+ return int(token, 16) if token.lower().startswith("0x") else int(token)
+
+ def peek(self, addr):
+ return self._run("FmcDemo.peek32", [("addr", "0x%x" % (self.base + addr))])
+
+ def poke(self, addr, value):
+ self._run(
+ "FmcDemo.poke32",
+ [("addr", "0x%x" % (self.base + addr)), ("value", "0x%x" % value)],
+ )
+
+
+class HashEngine:
+ def __init__(self, fmc, window):
+ self.fmc = fmc
+ self.window = window
+
+ def _addr(self, reg):
+ return self.window + reg
+
+ def read(self, reg):
+ return self.fmc.peek(self._addr(reg))
+
+ def write(self, reg, value):
+ self.fmc.poke(self._addr(reg), value)
+
+ def status(self):
+ return self.read(REG["STATUS"])
+
+ def abort(self):
+ self.write(REG["CONTROL"], CTRL_ABORT)
+
+ def configure(self, source, prepend, flash_addr, length):
+ self.write(REG["CONFIG"], source)
+ self.write(REG["PREPEND"], prepend)
+ self.write(REG["FLASH_ADDR"], flash_addr)
+ self.write(REG["LENGTH"], length)
+
+ def start(self):
+ self.write(REG["CONTROL"], CTRL_START)
+
+ def wait_done(self, timeout_s, poll_s=0.05, progress_cb=None):
+ """Poll STATUS until done. Returns the final status word.
+
+ done also means the engine has finished flushing and is ready to run
+ again, so it is the right thing to wait on rather than busy going low.
+ """
+ deadline = time.time() + timeout_s
+ while True:
+ status = self.status()
+
+ if status & ST_CFG_ERR:
+ raise SystemExit(
+ "engine rejected the configuration: %s\n"
+ "That means LENGTH was zero or PREPEND was larger than LENGTH."
+ % status_str(status)
+ )
+ if status & ST_ABORTED:
+ raise SystemExit("hash was aborted: %s" % status_str(status))
+ if status & ST_DONE:
+ return status
+
+ if time.time() > deadline:
+ raise SystemExit(
+ "timed out after %gs waiting for the hash to finish.\n"
+ " status: %s\n"
+ " progress: %d bytes\n"
+ "If progress is not advancing, the engine is most likely not "
+ "getting flash data: check that the SPI NOR controller is idle "
+ "and that nothing else is holding the flash."
+ % (timeout_s, status_str(status), self.read(REG["PROGRESS"]))
+ )
+
+ if progress_cb:
+ progress_cb(self.read(REG["PROGRESS"]), status)
+ time.sleep(poll_s)
+
+ def digest(self):
+ return digest_from_words(
+ [self.fmc.peek(self._addr(DIGEST0 + 4 * i)) for i in range(DIGEST_WORDS)]
+ )
+
+
+def selftest():
+ """Check the pure arithmetic. No hardware involved."""
+ failures = []
+
+ def check(name, got, want):
+ if got != want:
+ failures.append("%s: got %r, want %r" % (name, got, want))
+ else:
+ print(" ok %s" % name)
+
+ # Digest word packing. sha3-256("abc") is a published value, and the engine
+ # presents it least significant word first with DIGEST0 bits 7:0 as hash
+ # byte 0.
+ abc = hashlib.sha3_256(b"abc").hexdigest()
+ words = [
+ int.from_bytes(bytes.fromhex(abc)[4 * i:4 * i + 4], "little")
+ for i in range(DIGEST_WORDS)
+ ]
+ check("DIGEST0 packing", "0x%08x" % words[0], "0xa75d983a")
+ check("DIGEST7 packing", "0x%08x" % words[7], "0x32154311")
+ check("digest_from_words round trip", digest_from_words(words), abc)
+
+ # Sector bypass: the message is the image with its first sector replaced by
+ # 0xFF, so it keeps the image's length.
+ rom = bytes(range(256)) * 64 # 16 KiB of non-0xFF data
+ msg = expected_message(rom, 0x1000)
+ check("bypassed message length", len(msg), len(rom))
+ check("first sector blanked", msg[:0x1000], b"\xff" * 0x1000)
+ check("remainder untouched", msg[0x1000:], rom[0x1000:])
+ check(
+ "digest matches a directly built message",
+ hashlib.sha3_256(msg).hexdigest(),
+ hashlib.sha3_256(b"\xff" * 0x1000 + rom[0x1000:]).hexdigest(),
+ )
+
+ # A ROM no longer than the sector leaves nothing to hash.
+ try:
+ expected_message(b"\x00" * 0x1000, 0x1000)
+ failures.append("short ROM should have been rejected")
+ except ValueError:
+ print(" ok short ROM rejected")
+
+ # STATUS decoding.
+ check("status_str", status_str(ST_BUSY | ST_DONE), "0x00000003 [busy done]")
+
+ # humility output parsing. The exact shape varies, and a value silently
+ # parsed from the wrong part of the line would be far worse than a hard
+ # failure, so pin the plausible ones.
+ cases = [
+ ("FmcDemo.peek32() => 0xdeadbeef", 0xDEADBEEF),
+ ("FmcDemo.peek32() => Ok(0xdeadbeef)", 0xDEADBEEF),
+ ("humility: attached to bar\nFmcDemo.peek32() => 0x00000003", 3),
+ # The address appears first, so a parser that grabbed the last number,
+ # or ignored the '=>', would get this wrong.
+ ("peek32(addr=0x700) => 0x1", 1),
+ ("FmcDemo.peek32() => 0", 0),
+ ("FmcDemo.peek32() => Ok([0xef, 0xbe, 0xad, 0xde])", 0xDEADBEEF),
+ ("FmcDemo.peek32() => 3735928559", 3735928559),
+ ]
+ for text, want in cases:
+ check("parse %r" % text.splitlines()[-1][:44], Fmc._parse(text), want)
+
+ if failures:
+ print("\nFAIL")
+ for f in failures:
+ print(" " + f)
+ return 1
+ print("\nall self-tests passed")
+ return 0
+
+
+def auto_int(text):
+ return int(text, 0)
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Exercise the host-flash path of the FPGA SHA3-256 hashing engine.",
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog=__doc__.split("Usage\n-----\n", 1)[-1],
+ )
+ ap.add_argument("--rom", help="ROM image already programmed into the host flash")
+ ap.add_argument(
+ "--sector-size", type=auto_int, default=DEFAULT_SECTOR_SIZE,
+ help="bytes in the bypassed first sector (default 0x%x)" % DEFAULT_SECTOR_SIZE,
+ )
+ ap.add_argument(
+ "--no-bypass", action="store_true",
+ help="hash the flash straight through from offset 0 with no 0xFF run. "
+ "The expected digest is then the ROM file as-is, which only matches "
+ "if the first sector on the part really does hold the image.",
+ )
+ ap.add_argument(
+ "--length", type=auto_int,
+ help="bytes to hash (default: the ROM file's size)",
+ )
+ ap.add_argument(
+ "--base", type=auto_int, default=0,
+ help="added to every address. --hash-offset already carries the FMC "
+ "window base, so this is only needed to shift the whole map "
+ "(default 0)",
+ )
+ ap.add_argument(
+ "--hash-offset", type=auto_int, default=DEFAULT_HASH_OFFSET,
+ help="absolute address of the engine's register window "
+ "(default 0x%x)" % DEFAULT_HASH_OFFSET,
+ )
+ ap.add_argument("--humility", default="humility", help="humility binary")
+ ap.add_argument(
+ "--humility-arg", action="append", default=[], metavar="ARG",
+ help="extra argument passed to humility before 'hiffy', repeatable "
+ "(e.g. --humility-arg -a --humility-arg /path/to/archive.zip)",
+ )
+ ap.add_argument(
+ "--timeout", type=float, default=120.0,
+ help="seconds to wait for the hash to finish (default 120)",
+ )
+ ap.add_argument("--dry-run", action="store_true",
+ help="print the humility commands instead of running them")
+ ap.add_argument("-v", "--verbose", action="store_true",
+ help="echo every humility invocation")
+ ap.add_argument("--selftest", action="store_true",
+ help="check the digest and packing arithmetic, then exit")
+ args = ap.parse_args()
+
+ if args.selftest:
+ return selftest()
+
+ if not args.rom:
+ ap.error("--rom is required (or use --selftest)")
+
+ with open(args.rom, "rb") as f:
+ rom = f.read()
+ if not rom:
+ raise SystemExit("%s is empty" % args.rom)
+
+ if args.no_bypass:
+ prepend = 0
+ flash_addr = 0
+ message = rom
+ else:
+ prepend = args.sector_size
+ flash_addr = args.sector_size
+ try:
+ message = expected_message(rom, args.sector_size)
+ except ValueError as exc:
+ raise SystemExit(str(exc))
+
+ length = args.length if args.length is not None else len(message)
+ if length > len(message):
+ raise SystemExit(
+ "--length %d is longer than the %d bytes the ROM file can account for"
+ % (length, len(message))
+ )
+ message = message[:length]
+ if length < prepend:
+ raise SystemExit(
+ "--length %d is smaller than the %d byte 0xFF run, which the engine "
+ "will reject" % (length, prepend)
+ )
+
+ expected = hashlib.sha3_256(message).hexdigest()
+
+ print("ROM file : %s (%d bytes)" % (args.rom, len(rom)))
+ if args.no_bypass:
+ print("Sector bypass : disabled, hashing flash from offset 0")
+ else:
+ print("Sector bypass : first 0x%x bytes fed as 0xFF, flash read from 0x%x"
+ % (args.sector_size, flash_addr))
+ print("Bytes to hash : %d" % length)
+ print("Engine window : 0x%x (base 0x%x)" % (args.hash_offset, args.base))
+ print("Expected digest : %s" % expected)
+ print()
+
+ fmc = Fmc(
+ base=args.base,
+ humility=args.humility,
+ extra_args=args.humility_arg,
+ dry_run=args.dry_run,
+ verbose=args.verbose,
+ )
+ if not args.dry_run and shutil.which(args.humility) is None:
+ raise SystemExit(
+ "%r not found on PATH. Use --humility to point at it, or --dry-run."
+ % args.humility
+ )
+
+ eng = HashEngine(fmc, args.hash_offset)
+
+ # Clear anything left over from a previous run. An abort on an idle engine
+ # is harmless, and if one was mid-flight this resynchronises the flash
+ # channel before we start.
+ print("Aborting any run in flight...")
+ eng.abort()
+ if not args.dry_run:
+ deadline = time.time() + 10
+ while eng.status() & ST_BUSY:
+ if time.time() > deadline:
+ raise SystemExit(
+ "engine still busy 10s after an abort: %s\n"
+ "An abort during a flash read has to drain the bytes the "
+ "controller still owes, but that should not take this long."
+ % status_str(eng.status())
+ )
+ time.sleep(0.05)
+
+ print("Configuring...")
+ eng.configure(SRC_HOST_QSPI, prepend, flash_addr, length)
+
+ print("Starting...")
+ eng.start()
+
+ if args.dry_run:
+ print("\n(dry run: reading STATUS, PROGRESS and DIGEST0..7 would follow)")
+ for i in range(DIGEST_WORDS):
+ fmc.peek(args.hash_offset + DIGEST0 + 4 * i)
+ return 0
+
+ last = [-1]
+
+ def show(progress, status):
+ if progress != last[0]:
+ last[0] = progress
+ pct = (100.0 * progress / length) if length else 0.0
+ print("\r %d/%d bytes (%.1f%%) %s"
+ % (progress, length, pct, status_str(status)), end="", flush=True)
+
+ started = time.time()
+ status = eng.wait_done(args.timeout, progress_cb=show)
+ elapsed = time.time() - started
+ print("\r %d/%d bytes (100.0%%) %s" % (length, length, status_str(status)))
+
+ progress = eng.read(REG["PROGRESS"])
+ actual = eng.digest()
+
+ print()
+ print("Elapsed : %.2fs (%.1f KiB/s)"
+ % (elapsed, (length / 1024.0 / elapsed) if elapsed > 0 else 0.0))
+ print("Bytes processed : %d" % progress)
+ print("Expected digest : %s" % expected)
+ print("Hardware digest : %s" % actual)
+ print()
+
+ ok = True
+ if progress != length:
+ print("MISMATCH: engine reports %d bytes processed, expected %d"
+ % (progress, length))
+ ok = False
+ if actual != expected:
+ print("MISMATCH: digest differs")
+ # A digest over a shorter or longer run is the usual cause, so say
+ # whether the flash contents or the framing is the more likely suspect.
+ if progress == length:
+ print(" Byte count matched, so the framing is right and the flash "
+ "contents differ from the ROM file.")
+ print(" Check the image really is programmed, and that "
+ "--sector-size (0x%x) matches the part." % args.sector_size)
+ ok = False
+
+ if ok:
+ print("PASS")
+ return 0
+ print("FAIL")
+ return 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/tools/hash_engine_vectors_test.py b/tools/hash_engine_vectors_test.py
new file mode 100755
index 00000000..095140ce
--- /dev/null
+++ b/tools/hash_engine_vectors_test.py
@@ -0,0 +1,342 @@
+#!/usr/bin/env python3
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at https://mozilla.org/MPL/2.0/.
+
+"""Run the published SHA3-256 test vectors against the FPGA hashing engine.
+
+Vectors are the ones from https://di-mgt.com.au/sha_testvectors.html, which are
+in turn the NIST/NESSIE standard set. They are fed through the engine's manual
+path: CONFIG.source = LOCAL_REG and the message written a word at a time into
+WDATA, so this exercises the core and the sponge without involving the flash.
+
+Every digest in the table below was checked against Python's hashlib before being
+committed, and --selftest re-checks them, so a failure here is the hardware
+disagreeing with the standard rather than a transcription slip.
+
+Three of the six vectors need comment:
+
+ empty The engine cannot hash a zero length message: AXI streaming has no
+ zero beat packet, so there is no way to mark a last byte without
+ also presenting one. Rather than skip it, this checks the engine
+ *rejects* LENGTH = 0 by setting STATUS.cfg_err, which is the
+ documented behaviour, and compares the known digest in software.
+
+ million-a 1,000,000 bytes is 250,000 WDATA writes, and every register access
+ is a separate humility process. That runs for hours, so it is off
+ by default behind --include-million.
+
+ extreme ~1 GB. At the same rate that is weeks. Never run against hardware;
+ the table keeps it only so --selftest covers it.
+
+Usage
+-----
+ ./tools/hash_engine_vectors_test.py # the four short vectors
+ ./tools/hash_engine_vectors_test.py --include-million
+ ./tools/hash_engine_vectors_test.py --dry-run
+ ./tools/hash_engine_vectors_test.py --selftest # no hardware needed
+"""
+
+import argparse
+import hashlib
+import os
+import sys
+import time
+
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+# Register map, humility plumbing and status decoding all live in the flash test
+# already; there is no reason for a second copy of any of it.
+from hash_engine_flash_test import ( # noqa: E402
+ DEFAULT_HASH_OFFSET,
+ DIGEST_WORDS,
+ REG,
+ SRC_LOCAL_REG,
+ ST_ABORTED,
+ ST_BUSY,
+ ST_CFG_ERR,
+ ST_DONE,
+ ST_WFIFO_FULL,
+ Fmc,
+ HashEngine,
+ auto_int,
+ status_str,
+)
+
+# The software data FIFO is 64 words deep (SW_FIFO_DEPTH in hash_engine_top).
+SW_FIFO_WORDS = 64
+
+# Poll wfifo_full once per this many writes rather than before every one. Safe by
+# construction: even if the engine consumed nothing at all, a batch this size
+# cannot overflow a FIFO four times as deep, and we would see full on the next
+# poll. In practice the engine drains at a byte per clock and the bus delivers
+# four bytes per humility process, so it is never remotely close.
+POLL_EVERY = SW_FIFO_WORDS // 4
+
+
+def _rep(block, count):
+ return block * count
+
+
+# name, message bytes (or a thunk for the big ones), published digest.
+# Messages that would cost real memory are built lazily.
+VECTORS = [
+ ("abc", lambda: b"abc",
+ "3a985da74fe225b2045c172d6bd390bd855f086e3e9d525b46bfe24511431532"),
+ ("empty", lambda: b"",
+ "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"),
+ ("448-bit", lambda: b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
+ "41c0dba2a9d6240849100376a8235e2c82e1b9998a999e21db32dd97496d3376"),
+ ("896-bit",
+ lambda: b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmn"
+ b"hijklmnoijklmnopjklmnopqklmnopqrlmnopqrsmnopqrstnopqrstu",
+ "916f6061fe879741ca6469b43971dfdb28b1a32dc36cb3254e812be27aad1d18"),
+ ("million-a", lambda: _rep(b"a", 1000000),
+ "5c8875ae474a3634ba4fd55ec85bffd661f32aca75c6d699d0cdcb6c115891c1"),
+ ("extreme",
+ lambda: _rep(b"abcdefghbcdefghicdefghijdefghijkefghijklfghijklmghijklmnhijklmno",
+ 16777216),
+ "ecbbc42cbf296603acb2c6bc0410ef4378bafb24b710357f12df607758b33e2b"),
+]
+
+# Vectors that are impractical or impossible to drive over the register interface.
+SKIP_ALWAYS = {"extreme"}
+SKIP_UNLESS_ASKED = {"million-a"}
+# Not a skip: run as a rejection check instead of a hash.
+REJECT_CASE = "empty"
+
+
+def to_words(data):
+ """Message bytes as the 32 bit words WDATA takes, least significant first.
+
+ A trailing partial word is zero filled. LENGTH bounds what the engine
+ consumes, so the padding is never part of the message.
+ """
+ return [
+ int.from_bytes(data[i:i + 4].ljust(4, b"\x00"), "little")
+ for i in range(0, len(data), 4)
+ ]
+
+
+def feed(eng, data, progress_cb=None):
+ """Write the message into WDATA, respecting the FIFO's full flag."""
+ words = to_words(data)
+
+ for i, word in enumerate(words):
+ if i % POLL_EVERY == 0:
+ while eng.status() & ST_WFIFO_FULL:
+ time.sleep(0.01)
+ if progress_cb and i:
+ progress_cb(i * 4, len(data))
+
+ eng.write(REG["WDATA"], word)
+
+ return len(words)
+
+
+def run_vector(eng, name, data, expected, timeout_s, verbose=False):
+ """Hash one vector through the manual path and check the digest."""
+ eng.abort()
+ deadline = time.time() + 10
+ while eng.status() & ST_BUSY:
+ if time.time() > deadline:
+ print(" engine still busy after abort: %s" % status_str(eng.status()))
+ return False
+ time.sleep(0.02)
+
+ eng.configure(SRC_LOCAL_REG, prepend=0, flash_addr=0, length=len(data))
+ eng.start()
+
+ started = time.time()
+
+ def show(done_bytes, total):
+ pct = 100.0 * done_bytes / total if total else 0.0
+ print("\r fed %d/%d bytes (%.1f%%)" % (done_bytes, total, pct),
+ end="", flush=True)
+
+ nwords = feed(eng, data, progress_cb=show if len(data) > 4096 else None)
+ if len(data) > 4096:
+ print("\r fed %d/%d bytes (100.0%%) " % (len(data), len(data)))
+
+ status = eng.wait_done(timeout_s)
+ elapsed = time.time() - started
+
+ progress = eng.read(REG["PROGRESS"])
+ actual = eng.digest()
+
+ ok = True
+ if progress != len(data):
+ print(" PROGRESS %d, expected %d" % (progress, len(data)))
+ ok = False
+ if actual != expected:
+ print(" expected %s" % expected)
+ print(" got %s" % actual)
+ ok = False
+
+ if verbose or not ok:
+ print(" %d words, %.1fs, status %s" % (nwords, elapsed, status_str(status)))
+
+ return ok
+
+
+def run_reject_case(eng, expected, verbose=False):
+ """The empty message: check the engine refuses it rather than hangs."""
+ eng.abort()
+ time.sleep(0.05)
+
+ eng.configure(SRC_LOCAL_REG, prepend=0, flash_addr=0, length=0)
+ eng.start()
+ time.sleep(0.2)
+
+ status = eng.status()
+ ok = True
+
+ if not status & ST_CFG_ERR:
+ print(" expected cfg_err for LENGTH = 0, got %s" % status_str(status))
+ ok = False
+ if status & ST_BUSY:
+ print(" engine went busy on a zero length message: %s" % status_str(status))
+ ok = False
+
+ if ok:
+ print(" correctly rejected (cfg_err), known digest %s..." % expected[:16])
+ if verbose:
+ print(" status %s" % status_str(status))
+
+ return ok
+
+
+def selftest():
+ """Check the vector table against hashlib. No hardware."""
+ failures = []
+
+ for name, build, expected in VECTORS:
+ got = hashlib.sha3_256(build()).hexdigest()
+ if got == expected:
+ print(" ok %-11s %d bytes" % (name, len(build())))
+ else:
+ failures.append("%s: table says %s, hashlib says %s" % (name, expected, got))
+
+ # The packing WDATA expects, checked end to end on a ragged length.
+ msg = b"abcde"
+ words = to_words(msg)
+ if words != [0x64636261, 0x00000065]:
+ failures.append("to_words(%r) = %r" % (msg, [hex(w) for w in words]))
+ else:
+ print(" ok WDATA word packing, least significant byte first")
+
+ if failures:
+ print("\nFAIL")
+ for f in failures:
+ print(" " + f)
+ return 1
+
+ print("\nvector table matches hashlib")
+ return 0
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Run the published SHA3-256 test vectors against the FPGA "
+ "hashing engine over its manual (LOCAL_REG) path.",
+ )
+ ap.add_argument("--include-million", action="store_true",
+ help="also run the one-million-'a' vector. 250,000 register "
+ "writes, so expect hours rather than minutes")
+ ap.add_argument("--only", metavar="NAME",
+ help="run just this vector (%s)"
+ % ", ".join(n for n, _, _ in VECTORS))
+ ap.add_argument("--base", type=auto_int, default=0,
+ help="added to every address (default 0)")
+ ap.add_argument("--hash-offset", type=auto_int, default=DEFAULT_HASH_OFFSET,
+ help="absolute address of the engine's register window "
+ "(default 0x%x)" % DEFAULT_HASH_OFFSET)
+ ap.add_argument("--humility", default="humility", help="humility binary")
+ ap.add_argument("--humility-arg", action="append", default=[], metavar="ARG",
+ help="extra argument passed to humility before 'hiffy'")
+ ap.add_argument("--timeout", type=float, default=300.0,
+ help="seconds to wait for a hash to finish (default 300)")
+ ap.add_argument("--dry-run", action="store_true",
+ help="print the humility commands instead of running them")
+ ap.add_argument("-v", "--verbose", action="store_true")
+ ap.add_argument("--selftest", action="store_true",
+ help="check the vector table against hashlib, then exit")
+ args = ap.parse_args()
+
+ if args.selftest:
+ return selftest()
+
+ fmc = Fmc(
+ base=args.base,
+ humility=args.humility,
+ extra_args=args.humility_arg,
+ dry_run=args.dry_run,
+ verbose=args.verbose,
+ )
+ eng = HashEngine(fmc, args.hash_offset)
+
+ print("Engine window : 0x%x (base 0x%x)" % (args.hash_offset, args.base))
+ print("Source : LOCAL_REG, message written through WDATA")
+ print()
+
+ results = []
+
+ for name, build, expected in VECTORS:
+ if args.only and name != args.only:
+ continue
+
+ if not args.only:
+ if name in SKIP_ALWAYS:
+ print("%-11s SKIP ~1 GB over the register interface is not "
+ "practical" % name)
+ results.append((name, None))
+ continue
+ if name in SKIP_UNLESS_ASKED and not args.include_million:
+ print("%-11s SKIP pass --include-million to run it "
+ "(250,000 register writes)" % name)
+ results.append((name, None))
+ continue
+
+ if name in SKIP_ALWAYS:
+ print("%-11s refusing: ~1 GB over the register interface would take "
+ "weeks" % name)
+ results.append((name, None))
+ continue
+
+ data = build()
+
+ if name == REJECT_CASE:
+ print("%-11s zero length, expecting a rejection" % name)
+ ok = True if args.dry_run else run_reject_case(eng, expected, args.verbose)
+ else:
+ n = len(data)
+ est = ""
+ if n > 100000:
+ est = " (~%d register writes)" % (n // 4)
+ print("%-11s %d bytes%s" % (name, n, est))
+ ok = True
+ if args.dry_run:
+ eng.configure(SRC_LOCAL_REG, prepend=0, flash_addr=0, length=n)
+ eng.start()
+ print(" (dry run: %d WDATA writes would follow)" % len(to_words(data)))
+ else:
+ ok = run_vector(eng, name, data, expected, args.timeout, args.verbose)
+
+ print("%-11s %s" % ("", "PASS" if ok else "FAIL"))
+ results.append((name, ok))
+
+ print()
+ ran = [r for _, r in results if r is not None]
+ skipped = len([r for _, r in results if r is None])
+ failed = len([r for r in ran if not r])
+
+ print("%d run, %d passed, %d failed, %d skipped"
+ % (len(ran), len(ran) - failed, failed, skipped))
+
+ if args.dry_run:
+ return 0
+ return 1 if failed else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())