rhdl_ed25519_scalable_core/sha_ring.rs
1//! Three-context interleaved SHA-512 ring.
2//!
3//! One SHA-512 round is split into three registered stages, and three
4//! independent compression contexts circulate through them, so the round
5//! logic completes one round every cycle (one block per ~80 cycles). A
6//! single-context three-phase unit would finish one round every three
7//! cycles; the ring triples throughput for the same round datapath.
8//!
9//! The message schedule is a 16-word shift register with fixed taps, so no
10//! word is ever selected by a dynamic index. A job may request a second,
11//! constant padding block (used for the 128-byte challenge `R || A || M`).
12//!
13//! # Stages
14//!
15//! ```text
16//! x ──A──> y ──B──> z ──C──> x
17//! ```
18//!
19//! - **A** reads context `x` and forms the round's partial sums
20//! (`h + K[t] + W[t]`, `Σ1(e) + Ch`, `Σ0(a) + Maj`) and the two halves of
21//! the next schedule word.
22//! - **B** adds them into `T1`, `T2` and `W[t+16]`.
23//! - **C** commits the round to the working variables and shifts the
24//! schedule. After round 79 it adds the chaining value (feed-forward) and
25//! then starts the padding block, emits the digest, or parks.
26//!
27//! Each stage boundary is a register, so a context completes a round every
28//! three cycles and each adder chain is at most two 64-bit additions deep.
29//!
30//! # Output and parking
31//!
32//! A finished digest goes to a single output register that the consumer
33//! drains with `out_ready`. If that register is full when a context
34//! finishes, the context *parks*: it keeps its slot in the ring, does no more
35//! rounds, and retries each time it passes stage C. A parked context blocks
36//! its slot, so a slow consumer throttles admission instead of losing a
37//! digest.
38
39use rhdl::prelude::*;
40use rhdl_fpga::core::dff::DFF;
41
42use crate::digest::{big_sigma0, big_sigma1, round_constant, small_sigma0, small_sigma1};
43
44/// Compression job: one message of one block, or of exactly 128 bytes.
45#[derive(Copy, Clone, Debug, PartialEq, Digital)]
46pub struct ShaJob {
47 /// A job is presented this cycle.
48 pub valid: bool,
49 /// Opaque tag returned with the digest. Callers pack routing data into
50 /// it (for example a context number and key slot).
51 pub tag: b16,
52 /// Big-endian message words of the first block (already padded if final).
53 /// The job starts from the SHA-512 IV.
54 pub block: [b64; 16],
55 /// Follow with the constant padding block of a 128-byte message
56 /// ([`pad_block_128`]); `block` then holds the full 128 message bytes.
57 pub second_pad: bool,
58}
59
60/// Completed digest; held until `out_ready`.
61#[derive(Copy, Clone, Debug, PartialEq, Digital)]
62pub struct ShaDone {
63 /// A digest is presented this cycle.
64 pub valid: bool,
65 /// Job tag, as given in [`ShaJob::tag`].
66 pub tag: b16,
67 /// Final chaining state `H0..H7` (big-endian words); see
68 /// [`digest_words`](crate::digest::digest_words) for the byte string.
69 pub state: [b64; 8],
70}
71
72/// Ring inputs.
73#[derive(Copy, Clone, Debug, PartialEq, Digital)]
74pub struct ShaRingIn {
75 /// New job; accepted when [`ShaRingOut::ready`] in the same cycle.
76 pub job: ShaJob,
77 /// Consumer accepts [`ShaRingOut::done`] this cycle.
78 pub out_ready: bool,
79}
80
81/// Ring outputs.
82#[derive(Copy, Clone, Debug, PartialEq, Digital)]
83pub struct ShaRingOut {
84 /// A job may be presented this cycle: the slot leaving stage C is empty
85 /// or retires now. Depends only on registers.
86 pub ready: bool,
87 /// Completed digest, from the output register.
88 pub done: ShaDone,
89}
90
91/// One compression context as it travels around the ring.
92#[derive(Copy, Clone, Debug, PartialEq, Digital)]
93#[doc(hidden)]
94pub struct ShaCtx {
95 /// The slot holds a job.
96 valid: bool,
97 /// The job has finished but the output register was full; the context
98 /// does no rounds and `chain` holds the final state.
99 parked: bool,
100 /// The constant padding block still follows the current block.
101 second_pad: bool,
102 /// Job tag.
103 tag: b16,
104 /// Round `t` (0..80) this context performs on its next pass.
105 round: b7,
106 /// Chaining value at the start of the current block (the IV, or the
107 /// first block's result), added back after round 79.
108 chain: [b64; 8],
109 /// Working variables `a..h`.
110 work: [b64; 8],
111 /// Message-schedule window: `w[j]` is `W[t + j]`, so `w[0]` is the
112 /// current round's word.
113 w: [b64; 16],
114}
115
116/// Registers of [`ShaRing`]: one context per stage plus the values each
117/// stage hands to the next.
118#[derive(Copy, Clone, Debug, PartialEq, Digital)]
119#[doc(hidden)]
120pub struct ShaRingRegs {
121 /// Context entering stage A.
122 x: ShaCtx,
123 /// Context entering stage B.
124 y: ShaCtx,
125 /// `h + K[t] + W[t]` of context `y`.
126 y_hk: b64,
127 /// `Σ1(e) + Ch(e, f, g)` of context `y`.
128 y_sig1ch: b64,
129 /// `Σ0(a) + Maj(a, b, c)` of context `y` (all of `T2`).
130 y_sig0maj: b64,
131 /// `σ1(W[t+14]) + W[t+9]`, first half of `W[t+16]`.
132 y_sa: b64,
133 /// `σ0(W[t+1]) + W[t]`, second half of `W[t+16]`.
134 y_sb: b64,
135 /// Context entering stage C.
136 z: ShaCtx,
137 /// `T1` of context `z`.
138 z_t1: b64,
139 /// `T2` of context `z`.
140 z_t2: b64,
141 /// `W[t+16]` of context `z`.
142 z_wnew: b64,
143 /// Stage C's context is empty (registered in stage B, so `ready` is a
144 /// function of registers and one LUT).
145 z_empty: bool,
146 /// Stage C's context finishes its last block this pass (round 79, no
147 /// padding block to follow).
148 z_last: bool,
149 /// Stage C's context is parked.
150 z_parked: bool,
151 /// Output register.
152 done: ShaDone,
153}
154
155/// An empty ring slot (reset value of every stage).
156fn empty_ctx() -> ShaCtx {
157 ShaCtx {
158 valid: false,
159 parked: false,
160 second_pad: false,
161 tag: b16(0),
162 round: b7(0),
163 chain: [b64(0); 8],
164 work: [b64(0); 8],
165 w: [b64(0); 16],
166 }
167}
168
169/// Interleaved SHA-512 compression ring.
170///
171/// Throughput is one block per about 80 cycles across the three contexts. A
172/// one-block job takes about 240 cycles (80 rounds of three cycles), a
173/// two-block job about 480.
174#[derive(Clone, Debug, Synchronous, SynchronousDQ)]
175#[rhdl(dq_no_prefix)]
176pub struct ShaRing {
177 /// All ring state; the ring has no memories.
178 regs: DFF<ShaRingRegs>,
179}
180
181/// Reset state: three empty slots and an empty output register.
182impl Default for ShaRing {
183 /// Builds the reset state.
184 fn default() -> Self {
185 Self {
186 regs: DFF::new(ShaRingRegs {
187 x: empty_ctx(),
188 y: empty_ctx(),
189 y_hk: b64(0),
190 y_sig1ch: b64(0),
191 y_sig0maj: b64(0),
192 y_sa: b64(0),
193 y_sb: b64(0),
194 z: empty_ctx(),
195 z_t1: b64(0),
196 z_t2: b64(0),
197 z_empty: true,
198 z_last: false,
199 z_parked: false,
200 z_wnew: b64(0),
201 done: ShaDone {
202 valid: false,
203 tag: b16(0),
204 state: [b64(0); 8],
205 },
206 }),
207 }
208 }
209}
210
211/// Ports and kernel of [`ShaRing`].
212impl SynchronousIO for ShaRing {
213 /// Input port bundle.
214 type I = ShaRingIn;
215 /// Output port bundle.
216 type O = ShaRingOut;
217 /// Kernel computing the outputs and the next state.
218 type Kernel = sha_ring_kernel;
219}
220
221/// SHA-512 initial chaining value `H(0)` (FIPS 180-4 section 5.3.5).
222#[kernel]
223pub fn sha512_iv() -> [b64; 8] {
224 [
225 b64(0x6a09e667f3bcc908),
226 b64(0xbb67ae8584caa73b),
227 b64(0x3c6ef372fe94f82b),
228 b64(0xa54ff53a5f1d36f1),
229 b64(0x510e527fade682d1),
230 b64(0x9b05688c2b3e6c1f),
231 b64(0x1f83d9abfb41bd6b),
232 b64(0x5be0cd19137e2179),
233 ]
234}
235
236/// Padding block that ends a message of exactly 128 bytes.
237///
238/// A 128-byte message fills its first block, so the `0x80` terminator and
239/// the 128-bit length field go into a second block that is the same for
240/// every such message.
241#[kernel]
242pub fn pad_block_128() -> [b64; 16] {
243 let mut block = [b64(0); 16];
244 // The single `1` bit that follows the message.
245 block[0] = b64(0x8000000000000000);
246 // Message length in bits: 128 * 8 = 1024.
247 block[15] = b64(0x400);
248 block
249}
250
251/// State transition for [`ShaRing`].
252///
253/// All three stages run every cycle on the three contexts `x`, `y`, `z`;
254/// stage C's result becomes the next `x`, or a new job takes its place.
255#[kernel]
256pub fn sha_ring_kernel(_cr: ClockReset, i: ShaRingIn, q: Q) -> (ShaRingOut, D) {
257 // `r` is this cycle's registers, `n` the next cycle's.
258 let r = q.regs;
259 let mut n = r;
260
261 // Output register handshake. A completion enters only an empty output
262 // register, so `ready` never depends combinationally on `out_ready`.
263 let done_free = !r.done.valid;
264 if i.out_ready {
265 n.done.valid = false;
266 }
267
268 // Stage A: partial sums for round t of context x. They are formed for an
269 // empty or parked context too; stage C ignores them.
270 let x = r.x;
271 let a = x.work[0];
272 let b = x.work[1];
273 let c = x.work[2];
274 let e = x.work[4];
275 let f = x.work[5];
276 let g = x.work[6];
277 n.y = x;
278 // h + K[t] + W[t]: the part of T1 that does not depend on e.
279 n.y_hk = x.work[7] + round_constant(x.round) + x.w[0];
280 // Ch(e, f, g) picks f where e is 1 and g where e is 0.
281 n.y_sig1ch = big_sigma1(e) + ((e & f) ^ ((!e) & g));
282 // Maj(a, b, c) is the bitwise majority.
283 n.y_sig0maj = big_sigma0(a) + ((a & b) ^ (a & c) ^ (b & c));
284 // W[t+16] = σ1(W[t+14]) + W[t+9] + σ0(W[t+1]) + W[t], split in two sums.
285 // The word is computed every round; after round 63 it is never used.
286 n.y_sa = small_sigma1(x.w[14]) + x.w[9];
287 n.y_sb = small_sigma0(x.w[1]) + x.w[0];
288
289 // Stage B: finish T1 and W[t+16]; T2 is complete already.
290 n.z = r.y;
291 // What stage C will decide about r.y, registered here so the ring's
292 // `ready` comes straight from flip-flops. (Until 2026-10-17 `ready` was
293 // decoded from z.round in stage C; in DSig's v9 routed chip that round
294 // bit drove the signer's ready and DSig's capture logic, -3.3 ns.)
295 n.z_empty = !r.y.valid;
296 n.z_last = r.y.valid && !r.y.parked && r.y.round == b7(79) && !r.y.second_pad;
297 n.z_parked = r.y.valid && r.y.parked;
298 n.z_t1 = r.y_hk + r.y_sig1ch;
299 n.z_t2 = r.y_sig0maj;
300 n.z_wnew = r.y_sa + r.y_sb;
301
302 // Stage C: commit the round; finish, chain, park, or retire.
303 let z = r.z;
304 let t1 = r.z_t1;
305 // The context that will be in stage A next cycle (unless a new job
306 // replaces it).
307 let mut next = z;
308 if z.valid && !z.parked {
309 // a' = T1 + T2, e' = d + T1; the others shift down by one.
310 next.work = [
311 t1 + r.z_t2,
312 z.work[0],
313 z.work[1],
314 z.work[2],
315 z.work[3] + t1,
316 z.work[4],
317 z.work[5],
318 z.work[6],
319 ];
320 // Slide the schedule window by one word: fixed taps, no indexing.
321 for k in 0..15 {
322 next.w[k] = z.w[k + 1];
323 }
324 next.w[15] = r.z_wnew;
325 next.round = z.round + b7(1);
326 if z.round == b7(79) {
327 // Last round of the block: feed-forward of the chaining value.
328 let mut state = [b64(0); 8];
329 for k in 0..8 {
330 state[k] = z.chain[k] + next.work[k];
331 }
332 if z.second_pad {
333 // Second block: chain from the first block's result and
334 // compress the constant padding block in the same slot.
335 next.chain = state;
336 next.work = state;
337 next.w = pad_block_128();
338 next.round = b7(0);
339 next.second_pad = false;
340 } else if done_free {
341 // Output register empty: emit the digest and free the slot.
342 n.done = ShaDone {
343 valid: true,
344 tag: z.tag,
345 state: state,
346 };
347 } else {
348 // Output register full: keep the digest in `chain` and park.
349 next.chain = state;
350 next.parked = true;
351 }
352 }
353 } else if z.valid && z.parked && done_free {
354 // A parked context passes stage C and finds the output register
355 // empty: emit its digest now.
356 n.done = ShaDone {
357 valid: true,
358 tag: z.tag,
359 state: z.chain,
360 };
361 }
362 // The slot passing stage C is empty or retires now, so a new job may take
363 // it. This is also `ready`: empty, or emitting its digest this cycle, from the
364 // flags registered in stage B and the output register.
365 let slot_free = r.z_empty || ((r.z_last || r.z_parked) && done_free);
366 if slot_free {
367 next.valid = false;
368 if i.job.valid {
369 // A new job enters stage A at round 0 from the IV.
370 next = ShaCtx {
371 valid: true,
372 parked: false,
373 second_pad: i.job.second_pad,
374 tag: i.job.tag,
375 round: b7(0),
376 chain: sha512_iv(),
377 work: sha512_iv(),
378 w: i.job.block,
379 };
380 }
381 }
382 n.x = next;
383 let out = ShaRingOut {
384 ready: slot_free,
385 done: r.done,
386 };
387 (out, D { regs: n })
388}
389
390/// Cycle tests of [`ShaRing`] against the `sha2` crate.
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use crate::simulation::CycleSim;
395 use sha2::{Digest, Sha512};
396
397 /// Standard SHA-512 padding of `message` split into big-endian blocks.
398 fn padded_blocks(message: &[u8]) -> Vec<[b64; 16]> {
399 let mut bytes = message.to_vec();
400 bytes.push(0x80);
401 while bytes.len() % 128 != 112 {
402 bytes.push(0);
403 }
404 bytes.extend_from_slice(&((message.len() as u128) * 8).to_be_bytes());
405 bytes
406 .chunks(128)
407 .map(|chunk| {
408 std::array::from_fn(|k| {
409 b64(u64::from_be_bytes(chunk[k * 8..k * 8 + 8].try_into().unwrap()) as u128)
410 })
411 })
412 .collect()
413 }
414
415 /// Digests match `sha2` for one- and two-block messages while the
416 /// consumer stalls, so contexts park; every tag arrives exactly once.
417 #[test]
418 fn ring_matches_sha2_with_backpressure() {
419 // Messages of 32, 96 (one block) and 128 bytes (second constant block).
420 let messages: Vec<Vec<u8>> = (0..12)
421 .map(|k| {
422 let len = [32, 96, 128][k % 3];
423 (0..len).map(|i| (i * 7 + k * 13) as u8).collect()
424 })
425 .collect();
426 let mut sim = CycleSim::new(ShaRing::default());
427 let mut next = 0;
428 let mut done = vec![None; messages.len()];
429 let mut out = sim.tick(ShaRingIn {
430 job: ShaJob {
431 valid: false,
432 tag: b16(0),
433 block: [b64(0); 16],
434 second_pad: false,
435 },
436 out_ready: false,
437 });
438 for cycle in 0..20_000 {
439 let mut job = ShaJob {
440 valid: false,
441 tag: b16(0),
442 block: [b64(0); 16],
443 second_pad: false,
444 };
445 if out.ready && next < messages.len() {
446 let blocks = padded_blocks(&messages[next]);
447 job = ShaJob {
448 valid: true,
449 tag: b16(next as u128),
450 block: blocks[0],
451 second_pad: blocks.len() == 2,
452 };
453 next += 1;
454 }
455 // Stall the consumer two cycles out of three.
456 let out_ready = cycle % 3 == 0;
457 if out.done.valid && out_ready {
458 let tag = out.done.tag.raw() as usize;
459 assert!(done[tag].is_none());
460 done[tag] = Some(out.done.state);
461 }
462 out = sim.tick(ShaRingIn { job, out_ready });
463 }
464 for (k, m) in messages.iter().enumerate() {
465 let want = Sha512::digest(m);
466 let got = done[k].expect("digest missing");
467 let bytes: Vec<u8> = got
468 .iter()
469 .flat_map(|w| (w.raw() as u64).to_be_bytes())
470 .collect();
471 assert_eq!(bytes.as_slice(), want.as_slice(), "message {k}");
472 }
473 }
474
475 /// With a consumer that never stalls and a job offered whenever `ready`,
476 /// the ring completes about one block per 80 cycles.
477 #[test]
478 fn ring_issues_one_round_per_cycle() {
479 let block = padded_blocks(&[0u8; 32])[0];
480 let mut sim = CycleSim::new(ShaRing::default());
481 let mut accepted = 0;
482 let mut completed = 0;
483 let mut out = sim.tick(ShaRingIn {
484 job: ShaJob {
485 valid: false,
486 tag: b16(0),
487 block,
488 second_pad: false,
489 },
490 out_ready: true,
491 });
492 let cycles = 3000;
493 for _ in 0..cycles {
494 let valid = out.ready;
495 accepted += usize::from(valid);
496 out = sim.tick(ShaRingIn {
497 job: ShaJob {
498 valid,
499 tag: b16(0),
500 block,
501 second_pad: false,
502 },
503 out_ready: true,
504 });
505 completed += usize::from(out.done.valid);
506 }
507 // Three contexts, 80 rounds each, three cycles per round: 3 blocks / 240 cycles.
508 assert!(completed * 81 >= cycles - 300, "completed {completed} of {accepted}");
509 }
510}