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 @@ + + + +hash_engine: SHA3-256 over host flash or processor written datahash_engine_regsCONTROL start / abortCONFIG source selectPREPEND / FLASH_ADDR / LENGTHSTATUS / PROGRESSWDATA software data inDIGEST0..7AXI-Lite, 8 bit addressAXIsoftware data FIFO32 bit in, 8 bit outwdata0xFF generatorPREPEND byteshash_feederIDLEPRIMECMD_ADDRCMD_LENRUNWAIT_DIGESTDRAINFLUSHsource mux, byte counter,last on the final byte0xFFbytesha3_256DOUBLE_BUFFER => falseaxi-stdigest[255:0] -> DIGEST0..7Flash fetch: the same command/response channel the eSPI flash reads usehash_feedercommand FIFO 32 bitresponse FIFO 8 bitraw_flash_txn_mgrsplits into <= 256 byte readsraw addresses, no remapspi_nor_top3 way arbitrationhubris / eSPI / hashQSPIflashaddr,lenbytesThe engine issues one command for the whole range. Chunking, and winning the shared SPI engine, happen on the far side.Two things worth knowingAbandoning a read. The transaction manager cannot be called off, so an abort or restart goes through DRAIN and discardsexactly the bytes still owed. busy stays set until that finishes, which is why software must poll it before reusing the engine.When the software FIFO is flushed. At the *end* of a run, not the start. Flushing at the start races with a processor thatpolls wfifo_full, sees it clear, and then writes: the write lands inside the flush window and disappears. Flushing at the endmeans a run always begins with a FIFO already known clean. 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 @@ + + + +sha3_256 -- one Keccak-f[1600] round per cyclefeed FSMIDLEABSORBPADFINISHINGbyte counter 0..135pad byte select 06 / 00 / 80 / 86msg_if (axi_st_pkt_t)valid / ready / data[7:0] / lastblock_sr[0] 1088 bits136-byte shift registerblock_sr[1]only when DOUBLE_BUFFERbyteShift right 8 per byte, insert at bit 1087. After136 shifts byte j sits at bits 8j+7:8j, which isexactly the little-endian lane packing.buf_full / buf_finalfill_idx drain_idxsetreleasepermute FSMWAIT_BLOCKABSORB_BLOCKRUN (24x)HOLDround counter 0..23RC_BITS[24][7] -> rc_lane (7 XOR gates)XORabsorb_blockkeccak_round1600 LUT6Keccak state 5 x 5 x 64 = 1600 FF1088 b2:1 select on the state D input: the round LUT6 issaturated so this costs its own LUT layer.digest[255:0]lanes 0..3digest_ofdigest[7:0] is hash byte 0, the leftmost byte of the conventionalhex string. Held until init or the next message starts.sidebandinit zero the sponge, abort a messagebusy first accepted byte until digest_valid 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 @@ + + + +Byte order: message bytes in, digest bits outMessage byte stream, in natural orderb0b1b2...b7b8...b135block_sr, after 136 shiftsbits 1087 .. 0byte j lands at bits 8j+7 downto 8jthe shift puts b0 at the bottom, bits 7:0State laneslane 0b0..b7lane 1b8..b15lane 2...lane 16lane k = state (x, y) with k = x + 5y, 17 rate lanes of 1088 bitsDigestlane 0lane 1lane 2lane 3digest[255:0]digest[7:0] is hash byte 0, ie the leftmost byte of the conventional hex string, sosha3-256("abc") = 3a985da7... has digest[7:0] = 0x3a and digest[255:248] = 0x32. 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 @@ + + + +Framing and throughput: a 272-byte message (two full blocks)DOUBLE_BUFFER= falseabsorb 136perm 25absorb 136perm 25pad 136perm 25ready drops for every permutation: 483 cycles, about 84% of line rate.DOUBLE_BUFFER= trueabsorb 136absorb 136pad 136perm 25permutations run underneath (25 << 136)each permutation hides inside the next block's absorb: 433 cycles, ready never drops mid-message.Padding: pad10*1 with the SHA3 domain separatorHow much room is left in the block after the last message byte decides which case applies:2 or more bytes06 00 .. 00 80exactly 1 byte86 (the two fuse)0 bytes, ie an exact multiple of 136a whole extra padding blockThe third case is the classic sponge bug: a message whose length is an exact multiple of the ratestill requires a full 136-byte block of padding. kat_136_bytes covers it.0x06 is SHA3-specific: SHAKE uses 0x1F and original Keccak uses 0x01. 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 @@ + + + +One Keccak-f[1600] round, and why it is one LUT6 per state bitstate A5 x 5 lanesof 64 bitstheta part 1C[x] = xor ofcolumn xtheta part 2D[x] = C[x-1] xorrotl(C[x+1], 1)A xor D[x]320 LUT5+ 320 LUT6rho + piconstant rotationper lane, then arelabelling0 gates -- pure wiringchiB xor (not B' and B'')along each rowiotalane (0,0) xor RC7 XOR gates: a round constantonly ever sets bits 2^j - 1.state A'clocked backinto the same1600 flopsWhy it collapsesrho is a constant rotation and pi is a relabelling, so neither survives synthesis as logic. That leavestheta's XOR feeding chi, and each output bit then depends on exactly six inputs -- three state bitsand three D bits:A'[x,y,z] = (a xor Dx) xor ( not (b xor Dx+1) and (c xor Dx+2) )Measured out of context on xc7s100: 1600 LUT6 for the fused term, 320 LUT6 for D, 320 LUT5 for C. 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())