Skip to main content

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}