rhdl_dsig_scalable_core/chain_step.rs
1//! A Haraka lane specialised to exactly one DSig chain step.
2//!
3//! # The problem
4//!
5//! A DSig key is 68 W-OTS+ chains (`w = 4`). Making a key walks every chain
6//! from its secret node three steps to its public end, and every step is one
7//! Haraka-512 hash of a 64-byte *chain block*. That is 204 chain steps per
8//! signature. The signer wants **one chain step per clock per lane** at
9//! 205 MHz, so the chain-step lane is replicated many times, and its size
10//! decides how many signers fit on the chip.
11//!
12//! Most of a lane's area is AES S-boxes. A plain Haraka-512 has 640 of them
13//! (10 AES rounds × 64 bytes). This module asks: *how many of those 640 does a
14//! DSig chain step really need?* The answer is 494, and this file is the
15//! hardware that uses only those.
16//!
17//! # The reasoning, step by step
18//!
19//! 1. The chain block is mostly fixed. Only the 16-byte salted nonce and the
20//! 18-byte node change; bytes 34..64 are always zero.
21//! 2. An S-box whose input is a known constant has a known constant output.
22//! It needs no hardware at all.
23//! 3. Haraka's AES lanes do not talk to each other until the first word mix,
24//! after the *second* AES round. So the constants survive for two rounds.
25//! 4. DSig keeps only 18 of the 32 digest bytes. Any S-box that cannot reach
26//! those 18 bytes is wasted work.
27//!
28//! The decision: compute, at compile time, which bytes are constant (going
29//! forwards) and which bytes are needed (going backwards), then build a
30//! pipeline in which the constant bytes are literal values. Synthesis does
31//! the rest: it deletes logic that drives nothing and folds logic whose
32//! inputs are constants.
33//!
34//! # How the constants fold away
35//!
36//! ```text
37//! live = varies per chain step · = compile-time constant
38//!
39//! AES lane: 0 1 2 3
40//! block bytes: 0..16 16..32 32..48 48..64
41//! round 0 in: live×16 live×16 live×2 (32,33) ·×16
42//! (salted nonce) (node 0..16) ·×14 (all zero)
43//! │ │ │ │
44//! rounds 0-1: all S-boxes all S-boxes only the S-boxes every S-box
45//! live live fed by bytes 32, constant:
46//! 33 and what they 32 boxes fold
47//! spread to
48//! └─────────────────┴─── word MIX after round 1 ──┴───────────────┘
49//! round 2: the mix has moved lane 3's still-constant words into other
50//! lanes; S-boxes on those 16 bytes still fold
51//! rounds 3-6: every byte varies and every byte is needed
52//! rounds 7-8: working backwards from the output, one lane's worth of
53//! bytes (16) can no longer reach the 18 kept bytes
54//! round 9: only the columns that feed the 18 kept bytes matter:
55//! 5 of 16 columns × 4 bytes = 20 of 64 S-boxes
56//! │
57//! output: 18 bytes = mid_haraka512(block)
58//!
59//! live S-boxes per AES round (constant ones folded, unobserved ones deleted):
60//!
61//! round: 0 1 2 3 4 5 6 7 8 9 total
62//! live: 34 40 48 64 64 64 64 48 48 20 = 494 (of 640)
63//! ```
64//!
65//! The per-round detail is not written down by hand in the code:
66//! [`LIVE_SBOX`] records it, [`live_sbox_evaluations`] counts it, and a test
67//! pins the total of 494. The per-round row above is that same analysis
68//! re-run by hand for this explanation; only the total is checked by a test.
69//!
70//! # Why a second Haraka pipeline?
71//!
72//! [`Haraka512Pipeline`](crate::haraka::Haraka512Pipeline) hashes *any* 64-byte
73//! block and returns the full 32-byte digest. DSig never asks for that. Every
74//! chain step hashes a block with a fixed shape
75//! ([`dsig_chain_block`](crate::haraka::dsig_chain_block)) and keeps only 18
76//! bytes of the answer:
77//!
78//! ```text
79//! byte 0 16 34 63
80//! ├──────┼─────────┼────────────────────────────────────┤
81//! block │nonce │ secret │ thirty zero bytes │
82//! └──────┴─────────┴────────────────────────────────────┘
83//! AES lane 0 = bytes 0..16 AES lane 2 = bytes 32..48
84//! AES lane 1 = bytes 16..32 AES lane 3 = bytes 48..64 (all zero)
85//! ```
86//!
87//! Haraka runs four independent AES lanes and only mixes them after every
88//! *second* AES round. So for the first two rounds AES lane 3 sees nothing but
89//! zeros: its 32 S-box evaluations always produce the same bytes. Lane 2 only
90//! has two live bytes, so most of its first-round S-boxes are also constant.
91//! At the far end, the 18 kept output bytes depend on only five of the sixteen
92//! 32-bit columns, so most of the final round is never observed.
93//!
94//! This module turns those facts into hardware savings:
95//!
96//! 1. A `const fn` ([`KNOWN`]) symbolically runs the pipeline over "known
97//! constant" / "live" bytes and records, for every stage, which state bytes
98//! are compile-time constants and what their values are.
99//! 2. The RHDL kernel pins those bytes to literals before each stage. Synthesis
100//! then folds their S-boxes into constants and deletes the registers that
101//! would have carried them.
102//! 3. The lane outputs only the 18 DSig bytes, so synthesis also deletes every
103//! S-box whose result can never reach an output.
104//!
105//! Counted by [`live_sbox_evaluations`], one chain step needs **494** S-box
106//! evaluations instead of Haraka's 640, about 23% less of the dominant area.
107//! The count is a structural planning number. Only an implemented-design report
108//! measures LUTs (see the validation ladder in the [crate documentation](crate)).
109//!
110//! # Where this sits
111//!
112//! ```text
113//! ChainEngine ── three steps in series (depth 0 → 1 → 2) ──▶ four cache nodes
114//! │
115//! └── each step is one ChainStepLane: 20 registered stages, II = 1
116//! ```
117//!
118//! "ChainEngine" is [`SliceChainEngine`](crate::slice_chain::SliceChainEngine)
119//! in [`crate::slice_chain`]. "II = 1" (initiation interval of one) means a
120//! new token can enter every clock. Which chain hash is compiled in, and so
121//! whether this lane is used at all, is decided in [`crate::chain_lane`].
122//!
123//! The neighbours this file leans on:
124//!
125//! | Module | What it gives this file |
126//! |---|---|
127//! | [`crate::haraka`] | round keys, word mix, truncation, `mix_key512_kernel`, the [`dsig_chain`](crate::haraka::dsig_chain) oracle |
128//! | [`crate::aes_table`] | the S-box stages ([`SboxStages`]), the UltraRAM loader, the T-table ROMs |
129//! | [`crate::sbox_placement`] | which of the 494 live S-boxes ([`LIVE_SBOX`]) go to UltraRAM or block RAM |
130//!
131//! # Reading order
132//!
133//! 1. [`KnownBytes`], [`KNOWN`]: the forward constant analysis.
134//! 2. [`live_sbox_evaluations`], [`LIVE_SBOX`]: the backward "is it needed" analysis.
135//! 3. [`ChainStepInput`], [`ChainStepOutput`]: the lane's ports.
136//! 4. [`ChainStepLane`], [`chain_step_kernel`]: the all-S-box lane.
137//! 5. [`TableChainStepLane`], [`table_chain_step_kernel`]: the same lane with
138//! some rounds done by T-table lookups instead.
139//!
140//! The lane has the same timing contract as the general pipeline: it accepts a
141//! token every cycle, has no backpressure, and returns it after exactly
142//! [`CHAIN_STEP_LATENCY`] registered transfers with its tag unchanged. Reset
143//! clears only the valid bits, so all in-flight tokens are discarded.
144//! Compare it bit-for-bit with [`dsig_chain`](crate::haraka::dsig_chain) using
145//! `tests/chain_step.rs`.
146
147use crate::aes_table::{DualTableRom, SboxLoader, SboxStages, SboxStagesIn, TableRead, TableWords};
148use crate::haraka::{
149 AES_ROUNDS, DIGEST_BYTES, MIX_WORDS, ROUND_CONSTANTS, ROUND_KEYS_RHDL, aes_sbox,
150 mix_key512_kernel, mix512_kernel,
151};
152use rhdl::prelude::*;
153use rhdl_fpga::core::dff::DFF;
154
155/// Registered stages per chain step (two per AES round, as in Haraka).
156///
157/// 20 clock cycles from input to output, the same as
158/// [`HARAKA_PIPELINE_LATENCY`](crate::haraka::HARAKA_PIPELINE_LATENCY).
159pub const CHAIN_STEP_LATENCY: usize = 2 * AES_ROUNDS;
160/// DSig keeps this many leading bytes of the standard 32-byte digest.
161///
162/// 18 bytes = 144 bits = one chain node.
163pub const STEP_OUTPUT_BYTES: usize = 18;
164/// First block byte that is always zero in a DSig chain block.
165///
166/// 16 nonce bytes + 18 node bytes = 34; bytes 34..64 are the zero tail.
167pub const ZERO_TAIL_START: usize = 34;
168
169/// Compile-time knowledge about the state entering one stage.
170///
171/// `mask[b]` is true when byte `b` is the same constant for every chain step;
172/// `value[b]` then holds that constant. Live bytes have `mask[b] == false`.
173///
174/// Think of it as a 64-byte state where each byte is either a number or a
175/// question mark. Only `const fn`s use it; it never becomes hardware.
176#[derive(Clone, Copy, Debug, PartialEq, Eq)]
177pub struct KnownBytes {
178 /// True for bytes that never vary.
179 pub mask: [bool; 64],
180 /// The constant value of each masked byte (zero for live bytes).
181 pub value: [u8; 64],
182}
183
184/// Multiply a byte by 2 in GF(2^8), for the compile-time MixColumns below.
185///
186/// Same as [`aes_xtime_kernel`](crate::haraka::aes_xtime_kernel), but a
187/// plain `const fn` on `u8` so it can run during compilation.
188const fn xtime(value: u8) -> u8 {
189 (value << 1) ^ if value & 0x80 == 0 { 0 } else { 0x1b }
190}
191
192/// Symbolic `SubBytes ∘ ShiftRows` for all four AES lanes.
193///
194/// An output byte is known exactly when its ShiftRows source byte is known,
195/// and then its value is `S(source)`.
196const fn known_sub_shift(input: KnownBytes) -> KnownBytes {
197 // Start with "nothing known"; known bytes are filled in below.
198 let mut out = KnownBytes {
199 mask: [false; 64],
200 value: [0; 64],
201 };
202 let mut lane = 0;
203 while lane < 4 {
204 let mut column = 0;
205 while column < 4 {
206 let mut row = 0;
207 while row < 4 {
208 // The input byte that ShiftRows moves to this output position.
209 let source = 16 * lane + 4 * ((column + row) & 3) + row;
210 // This output position: lane, column, row.
211 let target = 16 * lane + 4 * column + row;
212 if input.mask[source] {
213 out.mask[target] = true;
214 out.value[target] = aes_sbox(input.value[source]);
215 }
216 row += 1;
217 }
218 column += 1;
219 }
220 lane += 1;
221 }
222 out
223}
224
225/// Symbolic `MixColumns ∘ AddRoundKey`, then Haraka's word mix when `mix`.
226///
227/// A column comes out known only if all four of its input bytes are known
228/// (one live byte makes the whole column live). The word mix then just moves
229/// the knowledge around. `round` is the AES round number (0 to 9), used to
230/// pick the round key.
231const fn known_mix_key(input: KnownBytes, round: usize, mix: bool) -> KnownBytes {
232 // Start with "nothing known".
233 let mut out = KnownBytes {
234 mask: [false; 64],
235 value: [0; 64],
236 };
237 let mut lane = 0;
238 while lane < 4 {
239 // This AES lane's 16-byte round key for this round.
240 let key = ROUND_CONSTANTS[4 * round + lane].to_le_bytes();
241 let mut column = 0;
242 while column < 4 {
243 // Index of this column's first byte in the 64-byte state.
244 let base = 16 * lane + 4 * column;
245 // A MixColumns output byte depends on all four bytes of its column.
246 if input.mask[base]
247 && input.mask[base + 1]
248 && input.mask[base + 2]
249 && input.mask[base + 3]
250 {
251 // The column's four known byte values.
252 let s = [
253 input.value[base],
254 input.value[base + 1],
255 input.value[base + 2],
256 input.value[base + 3],
257 ];
258 // XOR of the column, as in aes_mix_key_kernel.
259 let total = s[0] ^ s[1] ^ s[2] ^ s[3];
260 let mut row = 0;
261 while row < 4 {
262 // Same formula as the hardware, evaluated now.
263 out.mask[base + row] = true;
264 out.value[base + row] =
265 s[row] ^ total ^ xtime(s[row] ^ s[(row + 1) & 3]) ^ key[4 * column + row];
266 row += 1;
267 }
268 }
269 column += 1;
270 }
271 lane += 1;
272 }
273 if mix {
274 // Snapshot, then permute both mask and value by MIX_WORDS.
275 let before = out;
276 let mut word = 0;
277 while word < 16 {
278 let mut byte = 0;
279 while byte < 4 {
280 out.mask[4 * word + byte] = before.mask[4 * MIX_WORDS[word] + byte];
281 out.value[4 * word + byte] = before.value[4 * MIX_WORDS[word] + byte];
282 byte += 1;
283 }
284 word += 1;
285 }
286 }
287 out
288}
289
290/// Stage `s` is a SubBytes/ShiftRows half-round when `s` is even.
291///
292/// Odd stages are MixColumns/AddRoundKey. Stage `s` belongs to AES round `s / 2`.
293const fn is_sub_stage(stage: usize) -> bool {
294 stage & 1 == 0
295}
296
297/// Haraka mixes the 32-bit words after every second AES round.
298///
299/// True for stages 3, 7, 11, 15 and 19: the MixColumns stage of AES rounds
300/// 1, 3, 5, 7 and 9.
301const fn mixes_after(stage: usize) -> bool {
302 stage & 3 == 3
303}
304
305/// Run the forward constant analysis over all 20 stages and build [`KNOWN`].
306///
307/// Entry 0 is the chain block: bytes `0..34` live, bytes `34..64` known zero.
308/// Entry `s + 1` is entry `s` pushed through stage `s` symbolically.
309const fn known_table() -> [KnownBytes; CHAIN_STEP_LATENCY + 1] {
310 // The chain block: everything live except the zero tail (value 0).
311 let mut input = KnownBytes {
312 mask: [false; 64],
313 value: [0; 64],
314 };
315 let mut byte = ZERO_TAIL_START;
316 while byte < 64 {
317 input.mask[byte] = true;
318 byte += 1;
319 }
320 // table[s] = knowledge entering stage s; filled in order.
321 let mut table = [input; CHAIN_STEP_LATENCY + 1];
322 let mut stage = 0;
323 while stage < CHAIN_STEP_LATENCY {
324 table[stage + 1] = if is_sub_stage(stage) {
325 known_sub_shift(table[stage])
326 } else {
327 known_mix_key(table[stage], stage >> 1, mixes_after(stage))
328 };
329 stage += 1;
330 }
331 table
332}
333
334/// `KNOWN[s]` describes the state *entering* stage `s`; `KNOWN[20]` is the
335/// final state before feed-forward. Index 0 is the DSig chain block itself.
336pub const KNOWN: [KnownBytes; CHAIN_STEP_LATENCY + 1] = known_table();
337
338/// Convert every [`KNOWN`] value from `u8` to RHDL's `b8`, so kernels can use it.
339const fn rhdl_known_values() -> [[b8; 64]; CHAIN_STEP_LATENCY + 1] {
340 // out[stage][byte], same shape as KNOWN.
341 let mut out = [[b8(0); 64]; CHAIN_STEP_LATENCY + 1];
342 let mut stage = 0;
343 while stage <= CHAIN_STEP_LATENCY {
344 let mut byte = 0;
345 while byte < 64 {
346 out[stage][byte] = b8(KNOWN[stage].value[byte] as u128);
347 byte += 1;
348 }
349 stage += 1;
350 }
351 out
352}
353
354/// Copy every [`KNOWN`] mask into a plain array that kernels can index.
355const fn known_masks() -> [[bool; 64]; CHAIN_STEP_LATENCY + 1] {
356 // out[stage][byte], same shape as KNOWN.
357 let mut out = [[false; 64]; CHAIN_STEP_LATENCY + 1];
358 let mut stage = 0;
359 while stage <= CHAIN_STEP_LATENCY {
360 out[stage] = KNOWN[stage].mask;
361 stage += 1;
362 }
363 out
364}
365
366/// RHDL-typed copy of the masks in [`KNOWN`], indexed `[stage][byte]`.
367///
368/// Why a separate copy: [`KnownBytes`] holds plain `u8` and is not an RHDL
369/// `Digital` type, so a kernel cannot use it directly.
370pub const KNOWN_MASK: [[bool; 64]; CHAIN_STEP_LATENCY + 1] = known_masks();
371/// RHDL-typed copy of the constant values in [`KNOWN`].
372///
373/// Indexed `[stage][byte]`; meaningful only where [`KNOWN_MASK`] is true.
374pub const KNOWN_VALUE: [[b8; 64]; CHAIN_STEP_LATENCY + 1] = rhdl_known_values();
375
376/// Bytes of the final AES state that reach the 18 kept digest bytes.
377///
378/// The starting point of the backward analysis: these 18 positions (from
379/// [`DIGEST_BYTES`]) are "needed"; nothing else in the final state is.
380const fn observed_final_bytes() -> [bool; 64] {
381 // observed[b]: final-state byte b reaches the output.
382 let mut observed = [false; 64];
383 let mut byte = 0;
384 while byte < STEP_OUTPUT_BYTES {
385 observed[DIGEST_BYTES[byte]] = true;
386 byte += 1;
387 }
388 observed
389}
390
391/// Count the S-box evaluations that are neither constant nor unobserved.
392///
393/// Works backwards from the 18 kept bytes: a MixColumns output needs its whole
394/// column, a SubBytes output needs its ShiftRows source, and the word mix is
395/// a permutation. Works forwards with [`KNOWN`] to drop constant S-boxes. The
396/// result is a structural planning count, not a synthesis measurement.
397#[must_use]
398pub const fn live_sbox_evaluations() -> usize {
399 // needed[s][b]: byte b of the state entering stage s is used downstream.
400 let mut needed = [[false; 64]; CHAIN_STEP_LATENCY + 1];
401 needed[CHAIN_STEP_LATENCY] = observed_final_bytes();
402 // Live S-boxes found so far.
403 let mut count = 0;
404 // Walk the stages from last (19) to first (0).
405 let mut stage = CHAIN_STEP_LATENCY;
406 while stage > 0 {
407 stage -= 1;
408 // What the stage's output must provide.
409 let after = needed[stage + 1];
410 // What the stage's input must provide; worked out below.
411 let mut before = [false; 64];
412 if is_sub_stage(stage) {
413 // SubBytes/ShiftRows: each needed output byte needs exactly one
414 // input byte, its ShiftRows source.
415 let mut lane = 0;
416 while lane < 4 {
417 let mut column = 0;
418 while column < 4 {
419 let mut row = 0;
420 while row < 4 {
421 let source = 16 * lane + 4 * ((column + row) & 3) + row;
422 let target = 16 * lane + 4 * column + row;
423 if after[target] {
424 // Needed downstream, so its source is needed too.
425 before[source] = true;
426 // A needed S-box is live unless its input is a
427 // compile-time constant.
428 if !KNOWN[stage].mask[source] {
429 count += 1;
430 }
431 }
432 row += 1;
433 }
434 column += 1;
435 }
436 lane += 1;
437 }
438 } else {
439 // Undo the optional word mix, then widen to whole columns.
440 let mut unmixed = after;
441 if mixes_after(stage) {
442 // The mix is a permutation: map each needed output byte
443 // back to the input byte it came from.
444 unmixed = [false; 64];
445 let mut word = 0;
446 while word < 16 {
447 let mut byte = 0;
448 while byte < 4 {
449 if after[4 * word + byte] {
450 unmixed[4 * MIX_WORDS[word] + byte] = true;
451 }
452 byte += 1;
453 }
454 word += 1;
455 }
456 }
457 // MixColumns: one needed byte makes its whole 4-byte column
458 // needed (16 columns across the four AES lanes).
459 let mut column = 0;
460 while column < 16 {
461 let base = 4 * column;
462 if unmixed[base] || unmixed[base + 1] || unmixed[base + 2] || unmixed[base + 3] {
463 before[base] = true;
464 before[base + 1] = true;
465 before[base + 2] = true;
466 before[base + 3] = true;
467 }
468 column += 1;
469 }
470 }
471 // Record and continue with the stage before.
472 needed[stage] = before;
473 }
474 count
475}
476
477/// Which S-boxes are live: `LIVE_SBOX[round][k]` is true when output byte `k`
478/// of round `round`'s SubBytes stage is both used downstream and not constant.
479///
480/// Same analysis as [`live_sbox_evaluations`], recorded per position so that
481/// S-boxes can be placed one by one (see [`sbox_placement`](crate::sbox_placement)).
482///
483/// Works backwards from the 18 kept bytes: a MixColumns output needs its whole
484/// column, a SubBytes output needs its ShiftRows source, and the word mix is
485/// a permutation. Works forwards with [`KNOWN`] to drop constant S-boxes. The
486/// result is a structural planning count, not a synthesis measurement.
487const fn live_sboxes() -> [[bool; 64]; AES_ROUNDS] {
488 // live[round][k]: the result, filled in as live S-boxes are found.
489 let mut live = [[false; 64]; AES_ROUNDS];
490 // needed[s][b]: byte b of the state entering stage s is used downstream.
491 let mut needed = [[false; 64]; CHAIN_STEP_LATENCY + 1];
492 needed[CHAIN_STEP_LATENCY] = observed_final_bytes();
493 // Walk the stages from last (19) to first (0).
494 let mut stage = CHAIN_STEP_LATENCY;
495 while stage > 0 {
496 stage -= 1;
497 // What the stage's output must provide.
498 let after = needed[stage + 1];
499 // What the stage's input must provide; worked out below.
500 let mut before = [false; 64];
501 if is_sub_stage(stage) {
502 // SubBytes/ShiftRows: each needed output byte needs exactly one
503 // input byte, its ShiftRows source.
504 let mut lane = 0;
505 while lane < 4 {
506 let mut column = 0;
507 while column < 4 {
508 let mut row = 0;
509 while row < 4 {
510 let source = 16 * lane + 4 * ((column + row) & 3) + row;
511 let target = 16 * lane + 4 * column + row;
512 if after[target] {
513 // Needed downstream, so its source is needed too.
514 before[source] = true;
515 // A needed S-box is live unless its input is a
516 // compile-time constant.
517 if !KNOWN[stage].mask[source] {
518 live[stage / 2][target] = true;
519 }
520 }
521 row += 1;
522 }
523 column += 1;
524 }
525 lane += 1;
526 }
527 } else {
528 // Undo the optional word mix, then widen to whole columns.
529 let mut unmixed = after;
530 if mixes_after(stage) {
531 // The mix is a permutation: map each needed output byte
532 // back to the input byte it came from.
533 unmixed = [false; 64];
534 let mut word = 0;
535 while word < 16 {
536 let mut byte = 0;
537 while byte < 4 {
538 if after[4 * word + byte] {
539 unmixed[4 * MIX_WORDS[word] + byte] = true;
540 }
541 byte += 1;
542 }
543 word += 1;
544 }
545 }
546 // MixColumns: one needed byte makes its whole 4-byte column
547 // needed (16 columns across the four AES lanes).
548 let mut column = 0;
549 while column < 16 {
550 let base = 4 * column;
551 if unmixed[base] || unmixed[base + 1] || unmixed[base + 2] || unmixed[base + 3] {
552 before[base] = true;
553 before[base + 1] = true;
554 before[base + 2] = true;
555 before[base + 3] = true;
556 }
557 column += 1;
558 }
559 }
560 // Record and continue with the stage before.
561 needed[stage] = before;
562 }
563 live
564}
565
566/// Per-position live S-boxes (computed by the private `live_sboxes`).
567///
568/// `LIVE_SBOX[round][k]` for `round` in `0..10`, `k` in `0..64` (output byte
569/// `16·lane + 4·column + row`). Exactly 494 entries are true.
570/// [`crate::sbox_placement`] walks it to decide where each live box lives.
571pub const LIVE_SBOX: [[bool; 64]; AES_ROUNDS] = live_sboxes();
572
573/// One chain-step request; an asserted `valid` is always accepted.
574///
575/// Only the two live parts of the chain block travel on the port. The zero
576/// tail is rebuilt inside the lane as literal zeros, which is what lets
577/// synthesis fold it.
578#[derive(Clone, Copy, Debug, PartialEq, Digital)]
579pub struct ChainStepInput {
580 /// Nonce with the chain salt already added to its low 32 bits.
581 ///
582 /// Build it with [`dsig_chain_block`](crate::haraka::dsig_chain_block):
583 /// the first 16 bytes of that block are exactly this field.
584 pub salted_nonce: [b8; 16],
585 /// The 18-byte chain node being advanced.
586 pub secret: [b8; 18],
587 /// Opaque caller identifier, returned unchanged.
588 ///
589 /// 32 bits. The lane never looks at it; the chain engine uses it to know
590 /// which key, chain and depth a result belongs to.
591 pub tag: b32,
592 /// Launch this token; false inserts a bubble.
593 pub valid: bool,
594}
595
596/// The idle input: all zeros with `valid` false.
597impl Default for ChainStepInput {
598 /// A bubble: nothing is launched.
599 fn default() -> Self {
600 Self {
601 salted_nonce: [b8(0); 16],
602 secret: [b8(0); 18],
603 tag: b32(0),
604 valid: false,
605 }
606 }
607}
608
609/// The next chain node; all fields except `valid` are meaningless when invalid.
610///
611/// Appears exactly [`CHAIN_STEP_LATENCY`] clocks after the matching input.
612#[derive(Clone, Copy, Debug, PartialEq, Digital)]
613pub struct ChainStepOutput {
614 /// `mid_haraka512` of the chain block: the next 18-byte node.
615 pub node: [b8; STEP_OUTPUT_BYTES],
616 /// Caller tag aligned with `node`.
617 pub tag: b32,
618 /// A result is present and was not invalidated by reset.
619 pub valid: bool,
620 /// Every UltraRAM S-box of the lane is loaded; until then outputs are
621 /// meaningless and the factory starts no keys.
622 pub ready: bool,
623}
624
625/// Builds the zero-tailed DSig chain block from its two live fields.
626///
627/// Inputs: the 16-byte salted nonce and the 18-byte node. Output: the
628/// 64-byte block of [`dsig_chain_block`](crate::haraka::dsig_chain_block).
629/// Pure wiring: no gates. The other chain hashes in [`crate::chain_lane`]
630/// reuse it.
631#[allow(clippy::needless_range_loop)] // Static byte wiring.
632#[kernel]
633pub fn chain_step_block_kernel(salted_nonce: [b8; 16], secret: [b8; 18]) -> [b8; 64] {
634 // Literal zeros: bytes 34..64 are never overwritten, so synthesis sees
635 // constants there.
636 let mut block = [b8(0); 64];
637 // Bytes 0..16: the salted nonce.
638 for byte in 0..16 {
639 block[byte] = salted_nonce[byte];
640 }
641 // Bytes 16..34: the node.
642 for byte in 0..18 {
643 block[16 + byte] = secret[byte];
644 }
645 block
646}
647
648/// Replace the bytes `mask` marks as compile-time constants by `value`.
649///
650/// Call it with a row of [`KNOWN_MASK`]/[`KNOWN_VALUE`]: the selects are then
651/// constants, the register feeding a pinned byte has no loads, and synthesis
652/// deletes it together with the logic that computed it.
653///
654/// Why this is needed at all: to the tools, a register's output is not a
655/// constant. Its power-up value is "don't care", so they cannot assume it
656/// always equals the constant computed into it. Pinning states the value
657/// directly, so the S-boxes behind it fold to constants. Combinational: 64
658/// selects whose controls are all constants.
659#[allow(clippy::needless_range_loop)] // Static byte wiring.
660#[kernel]
661pub fn pin_known_kernel(state: [b8; 64], mask: [bool; 64], value: [b8; 64]) -> [b8; 64] {
662 // Start from the register values; constant bytes are replaced below.
663 let mut pinned = state;
664 for byte in 0..64 {
665 // `mask` is a compile-time constant at every call site, so this `if`
666 // becomes a plain wire (live byte) or a literal (constant byte).
667 if mask[byte] {
668 pinned[byte] = value[byte];
669 }
670 }
671 pinned
672}
673
674/// Final feed-forward: the 18 kept digest bytes of a finished state.
675///
676/// Inputs: the 64-byte state after the last stage and the 18 saved input
677/// bytes. Output: the next 18-byte node, `state[DIGEST_BYTES[i]] ⊕ feed[i]`.
678/// Combinational: one XOR per output bit (18 × 8). Because only 18 bytes
679/// are read, the S-boxes that feed only the other 46 bytes have no load and
680/// are deleted.
681#[allow(clippy::needless_range_loop)] // Static byte wiring.
682#[kernel]
683pub fn chain_step_digest_kernel(
684 state: [b8; 64],
685 feed_forward: [b8; STEP_OUTPUT_BYTES],
686) -> [b8; STEP_OUTPUT_BYTES] {
687 // Pin whatever is still constant in the final state.
688 let last = pin_known_kernel(
689 state,
690 KNOWN_MASK[CHAIN_STEP_LATENCY],
691 KNOWN_VALUE[CHAIN_STEP_LATENCY],
692 );
693 // The output node, byte by byte: truncate, then XOR the saved input.
694 let mut node = [b8(0); STEP_OUTPUT_BYTES];
695 for byte in 0..STEP_OUTPUT_BYTES {
696 node[byte] = last[DIGEST_BYTES[byte]] ^ feed_forward[byte];
697 }
698 node
699}
700
701/// The 18 input bytes that the feed-forward XORs back into the digest.
702///
703/// Input: the 64-byte chain block. Output: `block[DIGEST_BYTES[i]]` for
704/// `i` in `0..18`. Pure wiring. The lane saves these 18 bytes (not all 64)
705/// and carries them down the pipeline next to the state, 20 stages deep.
706#[allow(clippy::needless_range_loop)] // Static byte wiring.
707#[kernel]
708pub fn chain_step_feed_kernel(block: [b8; 64]) -> [b8; STEP_OUTPUT_BYTES] {
709 // The saved bytes, in digest order.
710 let mut feed = [b8(0); STEP_OUTPUT_BYTES];
711 for byte in 0..STEP_OUTPUT_BYTES {
712 feed[byte] = block[DIGEST_BYTES[byte]];
713 }
714 feed
715}
716
717/// Twenty-stage, II = 1 DSig chain step with compile-time constant folding.
718///
719/// Each AES round `r` is two registered half-rounds:
720///
721/// ```text
722/// mixed[r-1] ─pin─▶ SboxStage subs[r] ─pin─▶ MixColumns+key(+word mix) ─▶ mixed[r]
723/// (S-box table, reg) (reg)
724/// ```
725///
726/// [`SboxStages`] keeps the generated netlist compact. The `pin` steps apply
727/// [`KNOWN`]: registers that only ever hold constants, or that never reach an
728/// output, have no loads, and synthesis removes them.
729///
730/// The whole lane, stage by stage (`S` = S-box stage register, `M` = mixed
731/// register; both are 64 bytes wide):
732///
733/// ```text
734/// block ─▶ S0 ─▶ M0 ─▶ S1 ─▶ M1 ─▶ S2 ─▶ ... ─▶ S9 ─▶ M9 ─▶ digest ─▶ node
735/// stage: 0 1 2 3 4 18 19
736/// +MIX +MIX
737/// side band: feed_forward (18 B), tag (32 bit), valid — 20 registers each
738/// ```
739///
740/// Timing contract: one token per clock in, the result exactly 20 clocks
741/// later, no backpressure. The S-box register `S` lives inside
742/// [`SboxStages`] (a ROM's registered read); the `M` registers are `mixed`.
743#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
744pub struct ChainStepLane {
745 /// The ten S-box stages (SubBytes + ShiftRows), one per AES round.
746 /// Their ROM output registers are the even pipeline stages.
747 subs: SboxStages,
748 /// Fills any UltraRAM S-boxes after reset and drives `ready`.
749 loader: SboxLoader,
750 /// The odd pipeline stages: the 64-byte state after each AES round's
751 /// MixColumns, AddRoundKey and (every second round) word mix.
752 mixed: [DFF<[b8; 64]>; AES_ROUNDS],
753 /// The 18 input bytes needed by the final XOR, delayed 20 stages.
754 feed_forward: [DFF<[b8; STEP_OUTPUT_BYTES]>; CHAIN_STEP_LATENCY],
755 /// The caller's tag, delayed 20 stages.
756 tag: [DFF<b32>; CHAIN_STEP_LATENCY],
757 /// "This stage holds a real token", delayed 20 stages. The only
758 /// registers here that reset (to false).
759 valid: [DFF<bool>; CHAIN_STEP_LATENCY],
760}
761
762/// Build a lane with S-boxes placed as [`crate::sbox_placement`] decided.
763impl Default for ChainStepLane {
764 /// Data registers start as "don't care"; every valid bit starts false.
765 fn default() -> Self {
766 Self {
767 subs: SboxStages::default(),
768 loader: SboxLoader::default(),
769 mixed: core::array::from_fn(|_| DFF::resetless()),
770 feed_forward: core::array::from_fn(|_| DFF::resetless()),
771 tag: core::array::from_fn(|_| DFF::resetless()),
772 valid: core::array::from_fn(|_| DFF::new(false)),
773 }
774 }
775}
776
777/// Ports of [`ChainStepLane`]: one chain step in, one node out per clock.
778impl SynchronousIO for ChainStepLane {
779 /// Salted nonce, node, tag and valid.
780 type I = ChainStepInput;
781 /// Next node, tag, valid and ready, 20 clocks later.
782 type O = ChainStepOutput;
783 /// The stage wiring below.
784 type Kernel = chain_step_kernel;
785}
786
787/// Stage wiring for [`ChainStepLane`].
788///
789/// Inputs: `clock_reset`, the new request `input`, and `q` (every child's
790/// current output). Outputs: the [`ChainStepOutput`] leaving stage 19 and
791/// `d` (every child's next input). Per AES round `r`:
792///
793/// - S-box stage `r` reads `mixed[r - 1]` (or the new block for `r = 0`),
794/// with the constant bytes pinned;
795/// - `mixed[r]` reads S-box stage `r`'s output, pinned again, through
796/// MixColumns + AddRoundKey (+ word mix when `r` is odd).
797///
798/// Every loop bound and index is a constant, so RHDL unrolls all of it into
799/// fixed wiring.
800#[allow(clippy::needless_range_loop)] // Compile-time pipeline elaboration.
801#[kernel]
802pub fn chain_step_kernel(
803 clock_reset: ClockReset,
804 input: ChainStepInput,
805 q: ChainStepLaneQ,
806) -> (ChainStepOutput, ChainStepLaneD) {
807 // True while reset is asserted; hides the output during reset.
808 let resetting = clock_reset.reset.any();
809 // The 64-byte chain block, with its zero tail as literals.
810 let block = chain_step_block_kernel(input.salted_nonce, input.secret);
811 // Defaults for every child: hold the registers, give every S-box stage
812 // the block (only round 0 keeps it; rounds 1 to 9 are overwritten
813 // below), pass the loader's write to every UltraRAM box.
814 let mut d = ChainStepLaneD {
815 subs: SboxStagesIn {
816 bytes: [block; AES_ROUNDS],
817 load: q.loader.load,
818 },
819 // The loader has no input.
820 loader: (),
821 mixed: q.mixed,
822 feed_forward: q.feed_forward,
823 tag: q.tag,
824 valid: q.valid,
825 };
826 // The block's zero tail is literal, so round 0's S-boxes fold by themselves.
827 // Even stages of rounds 1 to 9: the previous round's state, pinned.
828 for round in 1..AES_ROUNDS {
829 d.subs.bytes[round] = pin_known_kernel(
830 q.mixed[round - 1],
831 KNOWN_MASK[2 * round],
832 KNOWN_VALUE[2 * round],
833 );
834 }
835 // Odd stages: MixColumns + key on each S-box stage's output.
836 for round in 0..AES_ROUNDS {
837 // The S-box output with its still-constant bytes pinned
838 // (KNOWN index 2r + 1 = the state entering stage 2r + 1).
839 let source = pin_known_kernel(
840 q.subs[round],
841 KNOWN_MASK[2 * round + 1],
842 KNOWN_VALUE[2 * round + 1],
843 );
844 // Word mix after odd AES rounds (1, 3, 5, 7, 9).
845 d.mixed[round] = mix_key512_kernel(source, ROUND_KEYS_RHDL[round], round & 1 == 1);
846 }
847 // Side band: save the feed-forward bytes, tag and valid at stage 0 ...
848 d.feed_forward[0] = chain_step_feed_kernel(block);
849 d.tag[0] = input.tag;
850 d.valid[0] = input.valid;
851 // ... and shift them one stage along every clock.
852 for stage in 1..CHAIN_STEP_LATENCY {
853 d.feed_forward[stage] = q.feed_forward[stage - 1];
854 d.tag[stage] = q.tag[stage - 1];
855 d.valid[stage] = q.valid[stage - 1];
856 }
857 // Output: the finished node from the last mixed register and the
858 // matching feed-forward bytes. `valid` is forced low during reset;
859 // `ready` comes straight from the loader.
860 (
861 ChainStepOutput {
862 node: chain_step_digest_kernel(
863 q.mixed[AES_ROUNDS - 1],
864 q.feed_forward[CHAIN_STEP_LATENCY - 1],
865 ),
866 tag: q.tag[CHAIN_STEP_LATENCY - 1],
867 valid: q.valid[CHAIN_STEP_LATENCY - 1] && !resetting,
868 ready: q.loader.done,
869 },
870 d,
871 )
872}
873
874/// AES rounds that may use block-RAM T-tables: every byte is live and used.
875///
876/// The first such round is AES round 3 (0-based). Rounds 0-2 still have
877/// constant bytes, which a table cannot fold, and round 9 needs only 20 of
878/// its 64 S-boxes. (Rounds 7 and 8 also have 16 unobserved S-boxes each;
879/// there the table ROMs whose words are never used have no loads and are
880/// removed the same way, so nothing is wasted.)
881pub const TABLE_ROUND_FIRST: usize = 3;
882/// Number of table-capable rounds (rounds 3 through 8).
883pub const TABLE_ROUND_SLOTS: usize = 6;
884/// Dual-port ROMs per table round: 64 S-boxes, two per ROM.
885///
886/// Each [`DualTableRom`] is one true dual-port `T0` ROM (one BRAM18) serving
887/// two state bytes, so a table round needs 32 of them for its 64 bytes.
888pub const ROMS_PER_TABLE_ROUND: usize = 32;
889
890/// Whether `mask` is a legal [`TableChainStepLane`] table selection.
891///
892/// Legal means: only bits 3..=8 may be set.
893#[must_use]
894pub const fn valid_table_mask(mask: usize) -> bool {
895 // Six ones shifted up to bits 3..=8: binary 1_1111_1000.
896 let allowed = ((1 << TABLE_ROUND_SLOTS) - 1) << TABLE_ROUND_FIRST;
897 mask & !allowed == 0
898}
899
900/// Present ShiftRows-ordered state bytes to a round's 32 dual-port ROMs.
901///
902/// Input: the 64-byte state entering the round. Output: 32 address pairs.
903/// Table `port = 16·lane + 4·column + row` reads the byte ShiftRows would put
904/// at that position, so ShiftRows costs nothing here either. Port `p` is
905/// side `a` of ROM `p / 2` when `p` is even and side `b` when odd. Pure wiring.
906#[allow(clippy::needless_range_loop)] // Static byte wiring.
907#[kernel]
908pub fn table_addresses_kernel(state: [b8; 64]) -> [TableRead; ROMS_PER_TABLE_ROUND] {
909 // The 32 address pairs; every port is assigned below.
910 let mut reads = [TableRead::default(); ROMS_PER_TABLE_ROUND];
911 for lane in 0..4 {
912 for column in 0..4 {
913 for row in 0..4 {
914 // ShiftRows source byte for this position.
915 let byte = state[16 * lane + 4 * ((column + row) & 3) + row];
916 // Which of the 64 table ports serves this position.
917 let port = 16 * lane + 4 * column + row;
918 // Even ports use side a, odd ports side b; `>> 1` is `/ 2`.
919 if port & 1 == 0 {
920 reads[port >> 1].a = byte;
921 } else {
922 reads[port >> 1].b = byte;
923 }
924 }
925 }
926 }
927 reads
928}
929
930/// Combine one round's 64 table words: rotated XOR per column plus the key.
931///
932/// Row `out` of `rotl(T0[x], 8·row)` is byte `(out − row) mod 4` of `T0[x]`.
933///
934/// Inputs: the 64 table words read last cycle, the round's 64-byte key, and
935/// `mix` (apply the word mix after this round). Output: the 64-byte state
936/// after the whole AES round. Combinational: each output byte is a 5-input
937/// XOR (four table bytes and one key byte). The "rotation" is only a choice
938/// of which byte of each word to wire in; it costs nothing.
939///
940/// ```text
941/// column c of lane l, output row o:
942/// out[o] = key[o] ⊕ T0[x0].byte(o) ⊕ T0[x1].byte(o−1) ⊕ T0[x2].byte(o−2) ⊕ T0[x3].byte(o−3)
943/// (row offsets taken mod 4)
944/// ```
945#[allow(clippy::needless_range_loop)] // Static byte wiring.
946#[kernel]
947pub fn table_combine_kernel(
948 words: [TableWords; ROMS_PER_TABLE_ROUND],
949 key: [b8; 64],
950 mix: bool,
951) -> [b8; 64] {
952 // The round's output state; every byte is assigned below.
953 let mut mixed = [b8(0); 64];
954 for lane in 0..4 {
955 for column in 0..4 {
956 // Index of this column's first byte (and first table port).
957 let base = 16 * lane + 4 * column;
958 for out_row in 0..4 {
959 // Accumulator for this output byte, starting with the key.
960 let mut value = key[base + out_row];
961 // Add the contribution of each of the column's four bytes.
962 for row in 0..4 {
963 // The table port that looked up input row `row`.
964 let port = base + row;
965 // Its 32-bit word, from side a or b of the ROM pair.
966 let word = if port & 1 == 0 {
967 words[port >> 1].a
968 } else {
969 words[port >> 1].b
970 };
971 // Which byte of the word lands in `out_row`:
972 // (out_row − row) mod 4, written without a subtraction
973 // that could go negative.
974 let pick = (out_row + 4 - row) & 3;
975 // Select that byte (all constants, so plain wiring).
976 let byte: b8 = if pick == 0 {
977 word.resize()
978 } else if pick == 1 {
979 (word >> 8).resize()
980 } else if pick == 2 {
981 (word >> 16).resize()
982 } else {
983 (word >> 24).resize()
984 };
985 value = value ^ byte;
986 }
987 mixed[base + out_row] = value;
988 }
989 }
990 }
991 // Word mix after odd AES rounds, as in the LUT path.
992 if mix { mix512_kernel(mixed) } else { mixed }
993}
994
995/// [`ChainStepLane`] with selected AES rounds moved from LUTs into T-table ROMs.
996///
997/// Bit `r` of `TABLE_MASK` moves AES round `r` (0-based) into
998/// [`DualTableRom`]s. Only rounds 3–8 are allowed: there [`KNOWN`] proves
999/// nothing constant and every output byte is needed, so a table never replaces
1000/// logic synthesis would have deleted anyway. Each table round uses 32 ROMs,
1001/// normally 32 BRAM18s.
1002///
1003/// A precision note: by the same backward analysis as [`LIVE_SBOX`], rounds
1004/// 7 and 8 each have 16 positions that cannot reach the output (see the
1005/// per-round table in the module docs). In those rounds the ROM pairs for
1006/// the unobserved positions have no loads and are removed like the S-boxes
1007/// they replace, so the table still never pays for deleted work; it just
1008/// uses fewer ROMs than 32.
1009///
1010/// ```text
1011/// LUT round: mixed[r-1] ─▶ SboxStage (reg) ─▶ MixColumns+key (LUT) ─▶ mixed[r]
1012/// table round: mixed[r-1] ─▶ 32 × ROM (reg) ─▶ 4-way XOR+key (LUT) ─▶ mixed[r]
1013/// ```
1014///
1015/// Latency, II and tag behaviour equal [`ChainStepLane`]; the tests check
1016/// several masks against the same reference.
1017///
1018/// Why offer this at all: block RAM is a separate budget from LUTs. Moving a
1019/// round into tables trades LUTs (the binding limit) for block RAMs that
1020/// would otherwise sit unused. `TABLE_MASK = 0` is exactly [`ChainStepLane`].
1021/// This is the lane type the Haraka profile instantiates (see
1022/// [`crate::chain_lane`]).
1023///
1024/// Every round keeps both a LUT path and, if it is a table slot, a table
1025/// path in the source. The constant `TABLE_MASK` picks one per round, and
1026/// synthesis deletes the other: unused ROMs have no loads, and an S-box stage
1027/// fed with zeros folds to constants.
1028#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
1029pub struct TableChainStepLane<const TABLE_MASK: usize> {
1030 /// The ten S-box stages, as in [`ChainStepLane`]. A table round's stage
1031 /// is fed constants and so disappears.
1032 subs: SboxStages,
1033 /// Fills any UltraRAM S-boxes after reset and drives `ready`.
1034 loader: SboxLoader,
1035 /// `roms[slot]` holds the 32 ROM pairs of AES round
1036 /// `TABLE_ROUND_FIRST + slot`; unused when that round's mask bit is 0.
1037 roms: [[DualTableRom; ROMS_PER_TABLE_ROUND]; TABLE_ROUND_SLOTS],
1038 /// The state after each AES round, as in [`ChainStepLane`].
1039 mixed: [DFF<[b8; 64]>; AES_ROUNDS],
1040 /// The 18 input bytes needed by the final XOR, delayed 20 stages.
1041 feed_forward: [DFF<[b8; STEP_OUTPUT_BYTES]>; CHAIN_STEP_LATENCY],
1042 /// The caller's tag, delayed 20 stages.
1043 tag: [DFF<b32>; CHAIN_STEP_LATENCY],
1044 /// "This stage holds a real token", delayed 20 stages; resets to false.
1045 valid: [DFF<bool>; CHAIN_STEP_LATENCY],
1046}
1047
1048/// Build a lane, rejecting masks outside rounds 3..=8.
1049impl<const TABLE_MASK: usize> Default for TableChainStepLane<TABLE_MASK> {
1050 /// Data registers start as "don't care"; every valid bit starts false.
1051 ///
1052 /// # Panics
1053 /// Panics if `TABLE_MASK` sets a bit outside rounds 3..=8.
1054 fn default() -> Self {
1055 assert!(
1056 valid_table_mask(TABLE_MASK),
1057 "table rounds must lie within rounds 3..=8"
1058 );
1059 Self {
1060 subs: SboxStages::default(),
1061 loader: SboxLoader::default(),
1062 roms: core::array::from_fn(|_| core::array::from_fn(|_| DualTableRom::default())),
1063 mixed: core::array::from_fn(|_| DFF::resetless()),
1064 feed_forward: core::array::from_fn(|_| DFF::resetless()),
1065 tag: core::array::from_fn(|_| DFF::resetless()),
1066 valid: core::array::from_fn(|_| DFF::new(false)),
1067 }
1068 }
1069}
1070
1071/// Same ports as [`ChainStepLane`].
1072impl<const TABLE_MASK: usize> SynchronousIO for TableChainStepLane<TABLE_MASK> {
1073 /// Salted nonce, node, tag and valid.
1074 type I = ChainStepInput;
1075 /// Next node, tag, valid and ready, 20 clocks later.
1076 type O = ChainStepOutput;
1077 /// The stage wiring below.
1078 type Kernel = table_chain_step_kernel<TABLE_MASK>;
1079}
1080
1081/// Stage wiring for [`TableChainStepLane`].
1082///
1083/// Inputs and outputs as [`chain_step_kernel`]. First it wires every round
1084/// the LUT way, exactly like [`chain_step_kernel`]. Then, for each table
1085/// round selected by `TABLE_MASK`, it overrides that round: the S-box stage
1086/// gets zeros, the ROMs get the (pinned) state, and `mixed[r]` takes the
1087/// table result. Both paths have one register (S-box ROM or table ROM)
1088/// followed by one `mixed` register, so latency stays 20.
1089#[allow(clippy::needless_range_loop)] // Compile-time pipeline elaboration.
1090#[kernel]
1091pub fn table_chain_step_kernel<const TABLE_MASK: usize>(
1092 clock_reset: ClockReset,
1093 input: ChainStepInput,
1094 q: TableChainStepLaneQ<TABLE_MASK>,
1095) -> (ChainStepOutput, TableChainStepLaneD<TABLE_MASK>) {
1096 // True while reset is asserted; hides the output during reset.
1097 let resetting = clock_reset.reset.any();
1098 // The 64-byte chain block, with its zero tail as literals.
1099 let block = chain_step_block_kernel(input.salted_nonce, input.secret);
1100 // Defaults: as in chain_step_kernel, plus every table ROM reading
1101 // address 0 (overridden for selected table rounds).
1102 let mut d = TableChainStepLaneD::<TABLE_MASK> {
1103 subs: SboxStagesIn {
1104 bytes: [block; AES_ROUNDS],
1105 load: q.loader.load,
1106 },
1107 // The loader has no input.
1108 loader: (),
1109 roms: [[TableRead::default(); ROMS_PER_TABLE_ROUND]; TABLE_ROUND_SLOTS],
1110 mixed: q.mixed,
1111 feed_forward: q.feed_forward,
1112 tag: q.tag,
1113 valid: q.valid,
1114 };
1115 // LUT path for every round; table rounds are overridden below.
1116 for round in 1..AES_ROUNDS {
1117 d.subs.bytes[round] = pin_known_kernel(
1118 q.mixed[round - 1],
1119 KNOWN_MASK[2 * round],
1120 KNOWN_VALUE[2 * round],
1121 );
1122 }
1123 // Odd stages: MixColumns + key on each S-box stage's output.
1124 for round in 0..AES_ROUNDS {
1125 // The S-box output with its still-constant bytes pinned
1126 // (KNOWN index 2r + 1 = the state entering stage 2r + 1).
1127 let source = pin_known_kernel(
1128 q.subs[round],
1129 KNOWN_MASK[2 * round + 1],
1130 KNOWN_VALUE[2 * round + 1],
1131 );
1132 // Word mix after odd AES rounds (1, 3, 5, 7, 9).
1133 d.mixed[round] = mix_key512_kernel(source, ROUND_KEYS_RHDL[round], round & 1 == 1);
1134 }
1135 // Every index below stays in range for every mask, so RHDL can unroll both
1136 // arms of the constant `if`.
1137 for slot in 0..TABLE_ROUND_SLOTS {
1138 // The AES round this slot can replace (3..=8).
1139 let round = TABLE_ROUND_FIRST + slot;
1140 // Compile-time test: is this round a table round in this build?
1141 if (TABLE_MASK >> round) & 1 == 1 {
1142 // The table round's S-box stage is unused: feed it a constant.
1143 d.subs.bytes[round] = [b8(0); 64];
1144 // The ROMs read the state the S-box stage would have read.
1145 d.roms[slot] = table_addresses_kernel(pin_known_kernel(
1146 q.mixed[round - 1],
1147 KNOWN_MASK[2 * round],
1148 KNOWN_VALUE[2 * round],
1149 ));
1150 // One cycle later, combine the table words into the round result.
1151 d.mixed[round] =
1152 table_combine_kernel(q.roms[slot], ROUND_KEYS_RHDL[round], round & 1 == 1);
1153 }
1154 }
1155 // Side band: save the feed-forward bytes, tag and valid at stage 0 ...
1156 d.feed_forward[0] = chain_step_feed_kernel(block);
1157 d.tag[0] = input.tag;
1158 d.valid[0] = input.valid;
1159 // ... and shift them one stage along every clock.
1160 for stage in 1..CHAIN_STEP_LATENCY {
1161 d.feed_forward[stage] = q.feed_forward[stage - 1];
1162 d.tag[stage] = q.tag[stage - 1];
1163 d.valid[stage] = q.valid[stage - 1];
1164 }
1165 // Output: the finished node from the last mixed register and the
1166 // matching feed-forward bytes. `valid` is forced low during reset;
1167 // `ready` comes straight from the loader.
1168 (
1169 ChainStepOutput {
1170 node: chain_step_digest_kernel(
1171 q.mixed[AES_ROUNDS - 1],
1172 q.feed_forward[CHAIN_STEP_LATENCY - 1],
1173 ),
1174 tag: q.tag[CHAIN_STEP_LATENCY - 1],
1175 valid: q.valid[CHAIN_STEP_LATENCY - 1] && !resetting,
1176 ready: q.loader.done,
1177 },
1178 d,
1179 )
1180}