rhdl_dsig_scalable_core/planner.rs
1//! DSig as a component of the shared chip planner and TUI
2//! ([`rhdl_fpga_planner`]), next to Ed25519 Sign/Verify and the shared SHA-512.
3//!
4//! # The problem
5//!
6//! DSig is not built alone. The comparison paper puts a DSig signer and
7//! verifier, an Ed25519 signer and verifier and a shared SHA-512 line on one
8//! chip, and asks "with this much area, how many operations per second does
9//! each get?" The shared chip planner (`rhdl_fpga_planner`, a sibling crate
10//! with a terminal UI, or TUI) answers that by giving each design a *share*
11//! of the chip (LUTs, DSPs, block RAM, UltraRAM) and asking it for a *plan*.
12//! So DSig must speak the planner's language: the [`Component`] trait.
13//!
14//! # The reasoning
15//!
16//! [`crate::config`] already knows what DSig costs and how fast it is. This
17//! module does not repeat those measurements; it imports them and adds the
18//! knobs the comparison wants to explore. Some of those choices have never
19//! been synthesized, so their costs are **estimates**, and every [`Plan`]
20//! says whether it rests on measurement (`measured`).
21//!
22//! Two lessons shaped the plan search:
23//!
24//! * **A share is not the device.** Moving S-boxes into spare RAM must use
25//! the RAM in DSig's share, not the whole chip's, or every partial share
26//! fails the RAM check ([`config::Build::fill_within`]).
27//! * **Small shares still deserve an answer.** The smallest *built* engine
28//! has two slices (~207k LUT with S-boxes in RAM). A one-slice engine is
29//! offered as a **model** so a small share still plans something.
30//!
31//! # The decision
32//!
33//! Two components:
34//!
35//! * [`Dsig`], the signer we build. Its plan is the largest slice count that
36//! fits the share.
37//! * [`DsigVerify`], a **model** of a verifier that is not built yet, so the
38//! paper can compare sign and verify on the same footing.
39//!
40//! What the verifier model counts, step by step (nothing here is built):
41//!
42#![doc = concat!("<div style=\"overflow-x:auto\">", include_str!("../docs/images/dsig-verify.svg"), "</div>")]
43//!
44//! ```text
45//! rhdl_fpga_planner (TUI)
46//! │ Share {lut, dsp, bram36, uram}, Context {mhz, ...}
47//! ▼
48//! Dsig::plan ── for slices in 1..=16 ─────▶ Dsig::cost(slices, share) ──▶ fits?
49//! │ │ yes: keep, try next size
50//! │ │ no : stop, record the limit
51//! ▼ ▼
52//! Plan {cost, rate = slices·f/period (own core: min with K·f/895), demands, ...}
53//! │
54//! ▼
55//! Dsig::build_flags ──▶ DSIG_SLICES, DSIG_CHAIN, DSIG_SBOX_PLACEMENT
56//! ```
57//!
58//! The cost model of one slice is taken apart into lanes so a knob can swap a
59//! lane:
60//!
61//! ```text
62//! slice = slice_rest + factory BLAKE3 lane + 3 × chain lane
63//! slice_rest = SLICE_LUT − 3 × HARAKA_LANE_LUT − BLAKE3_LANE_LUT
64//! kernel = slices × slice + glue (+ Ed25519 core when DSig has its own)
65//! ```
66//!
67//! # Knobs that the signer and verifier share
68//!
69//! A verifier can only check what the signer made, so the scheme knobs
70//! (HBSS, chain hash, SHA lanes, `w`, `K`) are [shared](Knob::shared): the
71//! TUI lists them once, under DSig sign, and sets both modules.
72//!
73//! # Where the certificates come from
74//!
75//! Every batch of `K` keys is certified by one Ed25519 signature. Two ways:
76//!
77//! * **the chip's Ed25519 sign module** (default): DSig sends its batch roots
78//! to the paper's Ed25519 signer, which the planner sizes for `1/K` of
79//! DSig's rate. DSig's own cost then has no Ed25519 core.
80//! * **inside DSig (as built)**: the hardware built today carries its own
81//! small Ed25519 signer (`ED25519_LUT`), capping DSig at `K × f / 895`.
82//! The plan [covers](Plan::covers) Ed25519 signing, so the chip's Ed25519
83//! sign module is not needed; counting both would count the signer twice.
84//!
85//! Knobs of [`Dsig`] (and, where they apply, [`DsigVerify`]):
86//!
87//! | Knob | Choices | What it changes | Status |
88//! |---|---|---|---|
89//! | HBSS | W-OTS+, HORS | the hash-based one-time scheme | W-OTS+ built; HORS **not implemented** (no plan) |
90//! | chain hash | Haraka-512, BLAKE3, SHA-512 | the lane in every slice (`chain-*` feature) | built, simulated |
91//! | SHA lanes | own, shared line | SHA-512 chains on DSig's own pipelines, or as clients of the shared line | own: built; shared: **model** |
92//! | Winternitz w | 4, 16 | the HBSS: chain length and chain count | 4 built; 16 **model** |
93//! | batch K | 128, 64, 256 | keys per Ed25519 certificate | 128 built; others **model** |
94//! | S-box RAM | fill, LUT only | Haraka S-boxes in the share's spare UltraRAM / block RAM | built |
95//! | BLAKE3 adders | LUT, DSP | 32-bit adders of every BLAKE3 lane in DSP48E2s | **planned**, estimated |
96//! | Ed25519 certificates | chip's Ed25519 sign module, inside DSig | who signs batch roots (above) | inside: built |
97//! | message | 16 B (profile 1), 64 B (profile 2) | signed message length, `DSIG_MESSAGE_BYTES`: profile 2 signs uBFT's whole 48-byte signed context; one more digest compression per signature (an idle timetable residue, so the same rate) | 16: built, measured; 64: simulated, cost **estimated** |
98//!
99//! Slice counts: any of 1 to 16. 2, 4 and 8 are built (`DSIG_SLICES=N`), and 2 is measured on the card (build v3: 786,432 signatures
100//! at 6,019,465/s, 34.06 cycles per signature); the others are a **model**
101//! with the same per-slice cost and `slices × f / key period` rate.
102//!
103//! Costs: measured (routed `slices-2` link and Vivado OOC) for the Haraka lane,
104//! the BLAKE3 lane, the slice and the Ed25519 core; **estimates** for the
105//! SHA-512 lane, the DSP-adder BLAKE3 lane, the one-slice build and S-box
106//! savings (see [`config`](crate::config)).
107
108use crate::config::{
109 self, DEVICE_URAM, KERNEL_BRAM36, ED25519_BRAM36, ED25519_CYCLES_PER_SIGN, ED25519_DSP, ED25519_LUT,
110 GLUE_BRAM36, GLUE_LUT, LANES_PER_SLICE, LUTS_PER_LUT_SBOX, SBOXES_PER_LANE, SLICE_BRAM36, SLICE_LUT,
111 SLICE_URAM,
112};
113use rhdl_fpga_planner::{BuildFlags, Component, Context, Cost, Flow, Knob, Plan, Role, SHA512_BLOCKS, Share};
114
115// ------------------------------------------------------------- lane costs
116//
117// A slice is split into lanes here so the "chain hash" knob can swap one lane
118// kind for another. Measured lanes come from Vivado out-of-context (OOC)
119// place-and-route; the rest are marked estimates.
120
121/// Haraka chain-step lane, Vivado OOC routed.
122///
123/// Unit: CLB LUTs per lane, all S-boxes in LUTs. A slice has three.
124pub const HARAKA_LANE_LUT: f64 = 23_902.0;
125/// BLAKE3 compression lane, Vivado OOC routed.
126///
127/// Unit: CLB LUTs per lane. Each slice's key factory has one, the
128/// certificate unit has one more, and with the BLAKE3 chain hash it also
129/// replaces each Haraka lane.
130pub const BLAKE3_LANE_LUT: f64 = 16_422.0;
131
132/// Kernel CLB LUTs of build v5 after **synthesis** (not routed): `slices-4`,
133/// T-tables in AES rounds 3–8 (`DSIG_TABLE_MASK=0x1f8`), Ed25519 inside.
134///
135/// For build v3, synthesis (301,465) and routing (296,868) agreed within 2 %.
136pub const V5_KERNEL_LUT_SYNTH: f64 = 358_614.0;
137
138/// One Haraka chain-step lane with T-tables in rounds 3–8 (**from
139/// synthesis**): what is left of a v5 slice after the parts a v3 slice also
140/// has.
141///
142/// ```text
143/// v5 slice = (358,614 − Ed25519 25,383 − glue 20,564) / 4 = 78,167
144/// rest = v3 slice 127,502 − 3 × 23,902 − BLAKE3 16,422 = 39,374
145/// lane = (78,167 − 39,374 − 16,422) / 3 ≈ 7,457
146/// ```
147///
148/// Unit: CLB LUTs per lane (versus 23,902 with LUT S-boxes).
149pub const HARAKA_TABLE_LANE_LUT: f64 =
150 ((V5_KERNEL_LUT_SYNTH - ED25519_LUT - GLUE_LUT) / 4.0 - (SLICE_LUT - 3.0 * HARAKA_LANE_LUT - BLAKE3_LANE_LUT)
151 - BLAKE3_LANE_LUT)
152 / 3.0;
153
154/// BRAM36 tiles of one T-table lane: 176 dual-port BRAM18 ROMs (32 per
155/// table round in rounds 3–6, 24 in rounds 7–8 where 16 bytes are never
156/// observed). Build v5 synthesized 2,135 RAMB18 for its 12 lanes and caches.
157pub const HARAKA_TABLE_LANE_BRAM36: f64 = 88.0;
158
159/// T-table lookups per AES round that can become a table round, rounds 3
160/// to 8 in order (rounds 7 and 8 have 16 bytes nobody observes).
161const TABLE_ROUND_LOOKUPS: [f64; 6] = [64.0, 64.0, 64.0, 64.0, 48.0, 48.0];
162
163/// Choices of the "table rounds" knob: how many AES rounds of every Haraka
164/// lane, counting up from round 3, use T-tables (`DSIG_TABLE_MASK`).
165const TABLE_ROUNDS: [&str; 8] = [
166 "0 (LUT S-boxes; v3, routed)",
167 "1: r3 (interp.)",
168 "2: r3–4 (interp.)",
169 "3: r3–5 (interp.)",
170 "4: r3–6 (interp.)",
171 "5: r3–7 (interp.)",
172 "6: r3–8 (v5, synth)",
173 "auto (fastest in share)",
174];
175
176/// The "table rounds" value meaning *auto*: [`Component::plan`] tries 0..=6
177/// inside its share and keeps the fastest.
178pub const AUTO_TABLE_ROUNDS: u32 = 7;
179
180/// The best of several fixed-table-rounds plans: highest rate, then fewer
181/// BRAM36 (block RAM is what a T-table chip runs out of), then fewer LUTs.
182///
183/// This is what *auto* does. Because each component chooses inside the share
184/// the planner gave it, lending LUTs or block RAM to DSig automatically moves
185/// the choice toward more or fewer table rounds.
186fn best_over_table_rounds(plans: impl Iterator<Item = Plan>) -> Option<Plan> {
187 plans.reduce(|best, p| {
188 let better = p.rate > best.rate
189 || (p.rate == best.rate
190 && (p.cost.bram36, p.cost.lut) < (best.cost.bram36, best.cost.lut));
191 if better { p } else { best }
192 })
193}
194
195/// Table lookups (S-boxes replaced by T-table reads) in one lane's first
196/// `rounds` table rounds, counting up from round 3.
197#[must_use]
198pub fn table_lookups(rounds: u32) -> f64 {
199 TABLE_ROUND_LOOKUPS.iter().take(rounds as usize).sum()
200}
201
202/// LUTs and BRAM36 of one Haraka lane with its first `rounds` table rounds
203/// (0..=6, from round 3).
204///
205/// The two ends are builds: 0 is the routed v3 lane ([`HARAKA_LANE_LUT`]),
206/// 6 the lane derived from v5's synthesis ([`HARAKA_TABLE_LANE_LUT`]). In
207/// between, the LUT saving is **interpolated** per table lookup (352 lookups
208/// save 16,445 LUTs, ~46.7 each) and the block RAM is exact: two lookups per
209/// dual-port BRAM18, so a quarter of a BRAM36 per lookup. This is the lever
210/// for a BRAM-bound chip: fewer table rounds per lane cost LUTs but free
211/// block RAM for more lanes.
212#[must_use]
213pub fn table_lane(rounds: u32) -> (f64, f64) {
214 let lookups: f64 = TABLE_ROUND_LOOKUPS.iter().take(rounds as usize).sum();
215 let saving_per_lookup = (HARAKA_LANE_LUT - HARAKA_TABLE_LANE_LUT) / TABLE_ROUND_LOOKUPS.iter().sum::<f64>();
216 (HARAKA_LANE_LUT - lookups * saving_per_lookup, lookups / 4.0)
217}
218
219/// Whether the cost of a lane with `rounds` table rounds comes from a build
220/// (0 = v3 routed, 6 = v5 synthesis) rather than the interpolation. Every
221/// count is buildable (`DSIG_TABLE_MASK`); only the cost is interpolated.
222fn table_rounds_built(rounds: u32) -> bool {
223 rounds == 0 || rounds == 6
224}
225
226/// Choices of the "table RAM" knob: where T-table lookups live.
227const TABLE_RAM: [&str; 2] = ["block RAM (built)", "block RAM, then URAM (model)"];
228
229/// Table lookups one UltraRAM serves: one per port of a dual-port
230/// 4,096 × 72 URAM (a 256 × 32 T-table fills 1/16 of it). Block RAM serves
231/// four per BRAM36 (two per BRAM18), so URAM is the poorer home per tile,
232/// but a slices-4 kernel leaves ~670 of the device's 960 unused while block
233/// RAM is the wall.
234pub const LOOKUPS_PER_URAM: f64 = 2.0;
235
236/// LUTs of the loader one lane needs when any of its T-tables are in URAM:
237/// **estimate**. UltraRAM cannot be preloaded by the bitstream, so after
238/// every reset an 8-bit counter walks the 256 entries and writes each table
239/// (as [`crate::aes_table::SboxLoader`] does for URAM S-boxes); the lane
240/// reports not-ready until it finishes. Not built for T-tables.
241pub const TABLE_LOADER_LUT: f64 = 200.0;
242
243/// Splits `bram36` worth of table block RAM into what fits in `free_bram36`
244/// and the rest moved to URAM (at most `free_uram`): returns (BRAM36 kept,
245/// URAM used). What fits in neither stays in block RAM, so an over-full
246/// share shows up as a block RAM limit.
247fn spill_tables(bram36: f64, free_bram36: f64, free_uram: f64) -> (f64, f64) {
248 let kept = bram36.min(free_bram36.max(0.0));
249 // Lookups that did not fit, at four per BRAM36.
250 let over = (bram36 - kept) * 4.0;
251 let uram = (over / LOOKUPS_PER_URAM).ceil().min(free_uram.max(0.0));
252 (bram36 - uram * LOOKUPS_PER_URAM / 4.0, uram)
253}
254/// BLAKE3 lane with its ~224 32-bit adders in DSPs: **estimate**
255/// (each adder ~32 LUTs → one DSP48E2).
256///
257/// Unit: CLB LUTs per lane. This knob is planned, not built.
258pub const BLAKE3_DSP_LANE_LUT: f64 = 16_422.0 - 224.0 * 32.0;
259/// DSPs of a BLAKE3 lane with DSP adders: **estimate**.
260///
261/// Unit: DSP48E2s per lane, one per adder.
262pub const BLAKE3_LANE_DSP: u32 = 224;
263/// SHA-512 lane (`rhdl_sha512_pipelined`, 80 unrolled rounds): **estimate**
264/// until synthesized (80 × ~420 + 64 × ~150 LUTs).
265///
266/// Unit: CLB LUTs per lane, used only when the chain hash is SHA-512 and
267/// the lanes are DSig's own.
268pub const SHA512_LANE_LUT: f64 = 43_200.0;
269/// A TOML string: the TUI writes flag values verbatim into
270/// `build-config.toml`, so strings carry their own quotes.
271fn quoted(value: &str) -> String {
272 format!("{value:?}")
273}
274
275/// The `buildable` flag, and `not_buildable` listing every reason a plan
276/// cannot be built as planned today; `measure/export_build.py` refuses such
277/// a table. Same keys as the Ed25519 planner.
278fn buildable<'a>(gaps: impl Iterator<Item = &'a str>) -> BuildFlags {
279 let gaps: Vec<&str> = gaps.collect();
280 let mut flags = vec![("buildable".into(), gaps.is_empty().to_string())];
281 if !gaps.is_empty() {
282 flags.push(("not_buildable".into(), quoted(&gaps.join("; "))));
283 }
284 flags
285}
286
287/// A chain lane that sends its blocks to the shared SHA-512 line instead of
288/// hashing them itself: block assembly, a tag FIFO and the truncation
289/// (**estimate**).
290///
291/// Unit: CLB LUTs per lane.
292pub const SHA512_CLIENT_LUT: f64 = 1_500.0;
293/// Latency of one chain step, in cycles, for Haraka-512, BLAKE3 and SHA-512
294/// (indexed by the chain-hash knob; measured in simulation).
295pub const CHAIN_LATENCY: [f64; 3] = [20.0, 28.0, 82.0];
296
297/// Choices of the verifier's "certificate checks" knob.
298const VERIFY_CERTS: [&str; 2] = ["chip's Ed25519 verify module", "own Ed25519 verify core (as built)"];
299
300/// Display names of the message-length choices (index 1 = profile 2).
301pub const MESSAGES: [&str; 2] = ["16 B (profile 1)", "64 B (profile 2, uBFT context)"];
302/// Profile 2's extra LUTs per slice (**estimate**, not synthesized): the
303/// signer's tail block and CV registers (24 words) with their muxes and
304/// the factory's chained-message request path.
305const MESSAGE64_SLICE_LUT: f64 = 1_200.0;
306/// Profile 2's extra LUTs per verify unit (**estimate**): the loader's tail
307/// row and the hasher's second digest step.
308const MESSAGE64_VERIFY_LUT: f64 = 400.0;
309
310/// Display names of the chain-hash choices, indexed by `chain_hash`.
311const HASHES: [&str; 3] = ["Haraka-512", "BLAKE3", "SHA-512"];
312/// Display names of the SHA-lane choices.
313const SHA_ROUTES: [&str; 2] = ["own lanes", "shared line (model)"];
314/// The Winternitz parameters offered; index 0 is the built one.
315const WINTERNITZ: [u32; 2] = [4, 16];
316/// The batch sizes offered; index 0 is the built one.
317const BATCHES: [u32; 3] = [128, 64, 256];
318
319// ------------------------------------------------------------------- HBSS
320
321/// Which hash-based one-time signature scheme DSig uses.
322#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
323pub enum Scheme {
324 /// W-OTS+ (Winternitz chains): what the hardware implements.
325 #[default]
326 WotsPlus,
327 /// HORS (few-time signatures from a set of revealed secrets): offered
328 /// so the TUI and build flags are ready, but **not implemented**, and
329 /// there is no cost model yet, so it plans nothing.
330 Hors,
331}
332
333/// The schemes in the order of the HBSS knob.
334const SCHEMES: [Scheme; 2] = [Scheme::WotsPlus, Scheme::Hors];
335
336/// What the planner says when the HBSS knob is set to HORS.
337const HORS_TODO: &str = "HORS is not implemented yet (no hardware, no cost model): set HBSS back to W-OTS+";
338
339/// The hash-based one-time signature scheme (HBSS) and its batching.
340///
341/// DSig signs with W-OTS+: a message digest is cut into base-`w` digits, one
342/// hash chain per digit plus a few checksum chains. Each chain is hashed
343/// `w − 1` times at key generation. Larger `w` means fewer, longer chains:
344/// shorter signatures, more hashing. The built hardware is `w = 4`, 128-bit
345/// digests (64 digits + 4 checksum digits = 68 chains of 3 steps) and
346/// `K = 128` keys per batch; every other combination is a **model**.
347#[derive(Clone, Copy, Debug, PartialEq, Eq)]
348pub struct Hbss {
349 /// The scheme; only W-OTS+ has hardware and a cost model.
350 pub scheme: Scheme,
351 /// Winternitz parameter: digits are base `w`, chains have `w − 1` steps.
352 pub w: u32,
353 /// Keys per batch, certified by one Ed25519 signature (`K`).
354 pub batch: u32,
355}
356
357impl Default for Hbss {
358 fn default() -> Self {
359 Self { scheme: Scheme::WotsPlus, w: 4, batch: 128 }
360 }
361}
362
363impl Hbss {
364 /// Bits of the message digest that the digits encode.
365 pub const DIGEST_BITS: u32 = 128;
366
367 /// The scheme the hardware implements.
368 #[must_use]
369 pub fn built(self) -> bool {
370 self.scheme == Scheme::WotsPlus && self.w == 4 && self.batch == 128
371 }
372
373 /// Message digits: `128 / log2 w`.
374 #[must_use]
375 pub fn message_digits(self) -> u32 {
376 Self::DIGEST_BITS / self.w.trailing_zeros()
377 }
378
379 /// Checksum digits: the fewest base-`w` digits that can hold the largest
380 /// checksum, `message_digits × (w − 1)`.
381 #[must_use]
382 pub fn checksum_digits(self) -> u32 {
383 // Largest possible checksum value.
384 let max = u64::from(self.message_digits()) * u64::from(self.w - 1);
385 // Count digits until w^d exceeds it.
386 let mut digits = 0;
387 let mut reach = 1_u64;
388 while reach <= max {
389 reach *= u64::from(self.w);
390 digits += 1;
391 }
392 digits
393 }
394
395 /// Hash chains per key (68 for the built scheme).
396 #[must_use]
397 pub fn chains(self) -> u32 {
398 self.message_digits() + self.checksum_digits()
399 }
400
401 /// Chain steps to make one key: every chain hashed `w − 1` times (204).
402 #[must_use]
403 pub fn steps_per_key(self) -> f64 {
404 f64::from(self.chains() * (self.w - 1))
405 }
406
407 /// BLAKE3 compressions to make one key: 3 for seeds and nonces, the XOF
408 /// that makes 18 secret bytes per chain, the commitment over 18 endpoint
409 /// bytes per chain (plus a 16-byte prefix), and 2 for the Merkle leaf.
410 #[must_use]
411 pub fn blake3_per_key(self) -> f64 {
412 // Bytes of chain material per key.
413 let bytes = f64::from(self.chains()) * 18.0;
414 3.0 + (bytes / 64.0).ceil() + ((bytes + 16.0) / 64.0).ceil() + 2.0
415 }
416
417 /// Cycles one slice needs per key: its three chain lanes do three steps
418 /// per clock, its BLAKE3 lane one compression per clock, and the chain
419 /// engine takes one chain per clock. The busiest of the three sets the
420 /// period. For the built scheme every term but BLAKE3 is 68.
421 #[must_use]
422 pub fn key_period(self) -> f64 {
423 (self.steps_per_key() / f64::from(LANES_PER_SLICE))
424 .ceil()
425 .max(self.blake3_per_key())
426 .max(f64::from(self.chains()))
427 }
428
429 /// Merkle levels from a key to its batch root: `log2 K`.
430 #[must_use]
431 pub fn merkle_height(self) -> u32 {
432 self.batch.trailing_zeros()
433 }
434
435 /// Signature bytes: one 18-byte node per chain, one 32-byte sibling per
436 /// Merkle level, and 194 bytes of fixed fields (nonces, indices, root,
437 /// certificate). 1,642 for the built scheme.
438 #[must_use]
439 pub fn signature_bytes(self) -> u32 {
440 self.chains() * 18 + self.merkle_height() * 32 + 194
441 }
442
443 /// Expected chain steps to finish one verification. The built scheme
444 /// uses [`VERIFY_CHAIN_STEPS`]; for other `w` each chain needs on average
445 /// half its length.
446 #[must_use]
447 pub fn verify_steps(self) -> f64 {
448 if self.w == 4 {
449 VERIFY_CHAIN_STEPS * f64::from(self.chains()) / 68.0
450 } else {
451 f64::from(self.chains() * (self.w - 1)) / 2.0
452 }
453 }
454
455 /// BLAKE3 compressions per verification: the endpoint commitment (its
456 /// blocks plus one parent per extra 1 KiB chunk), the message digest and
457 /// one per Merkle level. 21 + 1 + 7 = 29 for the built scheme, as the
458 /// hardware counts (native simulation: 29.00 per record).
459 #[must_use]
460 pub fn verify_blake3(self) -> f64 {
461 let bytes = f64::from(self.chains()) * 18.0 + 16.0;
462 (bytes / 64.0).ceil() + ((bytes / 1024.0).ceil() - 1.0) + 1.0 + f64::from(self.merkle_height())
463 }
464
465 /// A short label, e.g. `W-OTS+ w=4, 68 chains, K=128, 1642 B`.
466 #[must_use]
467 pub fn label(self) -> String {
468 format!(
469 "W-OTS+ w={}, {} chains, K={}, {} B sig",
470 self.w,
471 self.chains(),
472 self.batch,
473 self.signature_bytes()
474 )
475 }
476
477 /// Knobs for the scheme, `w` and `K`, all shared between DSig sign and
478 /// verify.
479 fn knobs(self) -> [Knob; 3] {
480 [
481 Knob::choice("HBSS", SCHEMES.iter().position(|&x| x == self.scheme).unwrap_or(0), &["W-OTS+", "HORS (not implemented)"]).shared(),
482 Knob::choice(
483 "Winternitz w",
484 WINTERNITZ.iter().position(|&w| w == self.w).unwrap_or(0),
485 &["4", "16 (model)"],
486 )
487 .shared(),
488 Knob::choice(
489 "batch K",
490 BATCHES.iter().position(|&k| k == self.batch).unwrap_or(0),
491 &["128", "64 (model)", "256 (model)"],
492 )
493 .shared(),
494 ]
495 }
496
497 /// Applies HBSS knob `index` (0 scheme, 1 `w`, 2 `K`) set to choice
498 /// `pick`.
499 fn set(&mut self, index: usize, pick: usize) {
500 match index {
501 0 => self.scheme = SCHEMES[pick.min(SCHEMES.len() - 1)],
502 1 => self.w = WINTERNITZ[pick.min(WINTERNITZ.len() - 1)],
503 _ => self.batch = BATCHES[pick.min(BATCHES.len() - 1)],
504 }
505 }
506}
507
508/// The certificate ceiling: one Ed25519 signature per batch of `K` keys,
509/// one Ed25519 signature every [`ED25519_CYCLES_PER_SIGN`] cycles.
510fn certificate_ceiling(hbss: Hbss, mhz: f64) -> f64 {
511 f64::from(hbss.batch) * mhz * 1e6 / ED25519_CYCLES_PER_SIGN
512}
513
514// ----------------------------------------------------------------- signer
515
516/// The DSig component.
517///
518/// The signer as the chip planner sees it. Its fields are the planner knobs;
519/// [`Component::knobs`] shows them and [`Component::set_knob`] changes them.
520/// Only the default chain hash (Haraka-512) with the default HBSS matches the
521/// preserved `DSIG_FPGA_W4_HARAKA_BLAKE3_V1` profile.
522#[derive(Clone, Debug)]
523pub struct Dsig {
524 /// 0 Haraka-512, 1 BLAKE3, 2 SHA-512.
525 ///
526 /// Index into the hash name list; picks the lane type of every slice
527 /// (the `DSIG_CHAIN` build variable).
528 pub chain_hash: usize,
529 /// SHA-512 chains only: hash on the shared SHA-512 line instead of
530 /// DSig's own pipelines (**model**). The line's blocks then go to DSig
531 /// as well as Ed25519, so the planner balances them.
532 pub sha_shared: bool,
533 /// The hash-based scheme and its batch size.
534 pub hbss: Hbss,
535 /// Profile 2: sign 64-byte messages (`DSIG_MESSAGE_BYTES=64`).
536 pub message64: bool,
537 /// Fill the share's spare RAM with Haraka S-boxes.
538 ///
539 /// `true` uses [`config::Build::fill_within`]; `false` keeps every
540 /// S-box in LUTs. Only matters for Haraka-512.
541 pub sbox_ram: bool,
542 /// BLAKE3 adders in DSPs.
543 ///
544 /// Planned and estimated only: trades LUTs for DSPs in every BLAKE3 lane.
545 pub blake3_dsp: bool,
546 /// Certify batches with DSig's own Ed25519 core (as built) instead of the
547 /// chip's Ed25519 sign module.
548 pub own_ed25519: bool,
549 /// AES rounds of every Haraka lane, from round 3 up, computed with
550 /// block-RAM T-tables (0..=6; see [`table_lane`]), or
551 /// [`AUTO_TABLE_ROUNDS`] (the default) to pick the fastest inside the
552 /// share, so block RAM the share leaves over becomes signing speed. 0 is
553 /// build v3 (LUT S-boxes, routed), 6 is build v5 (rounds 3–8, synthesis).
554 /// Any value above 0 overrides "S-box RAM": the remaining S-boxes stay in
555 /// LUTs.
556 pub table_rounds: u32,
557 /// T-table lookups that do not fit the share's block RAM move to its
558 /// spare UltraRAM ([`LOOKUPS_PER_URAM`], plus a [`TABLE_LOADER_LUT`]
559 /// loader per lane). **Model**: the URAM T-table and its loader are not
560 /// built. On by default (the user's choice: spare URAM should speed DSig
561 /// up); the detail line and `not_buildable` mark a plan that uses it.
562 pub table_uram: bool,
563}
564
565/// Haraka-512 chains, the built HBSS, auto table rounds (as many T-table
566/// rounds as the share's block RAM makes fastest), BLAKE3 adders in LUTs,
567/// certificates from the chip's Ed25519 sign module (the modular chip of the
568/// paper). Set "Ed25519 certificates" to "inside DSig" for the routed,
569/// measured kernel; turn "S-box RAM" to "fill" to explore the (estimated) RAM
570/// placement.
571impl Default for Dsig {
572 fn default() -> Self {
573 Self {
574 chain_hash: 0,
575 sha_shared: false,
576 hbss: Hbss::default(),
577 message64: false,
578 sbox_ram: false,
579 blake3_dsp: false,
580 own_ed25519: false,
581 table_rounds: AUTO_TABLE_ROUNDS,
582 table_uram: true,
583 }
584 }
585}
586
587impl Dsig {
588 /// Largest slice count the planner may choose. Every count from 1 is
589 /// tried; 2, 4 and 8 are built (`DSIG_SLICES=N`, see
590 /// [`Dsig::BUILT_SLICES`]); the rest are a **model** with the same
591 /// per-slice cost (1 needs 7-bit key numbers in a slice, today 6).
592 pub const MAX_SLICES: u32 = 16;
593 /// Slice counts `DSIG_SLICES` accepts.
594 pub const BUILT_SLICES: [u32; 3] = [2, 4, 8];
595
596 /// Whether the chain lanes hash on the shared SHA-512 line.
597 #[must_use]
598 pub fn uses_shared_line(&self) -> bool {
599 self.chain_hash == 2 && self.sha_shared
600 }
601
602 /// The S-box placement for `slices` slices inside `share`.
603 ///
604 /// Only Haraka lanes with "S-box RAM" on place S-boxes. The T-tables
605 /// (and their URAM spill) take RAM first; the S-boxes of the remaining
606 /// rounds then fill what is left, block RAM first (no loader), then
607 /// UltraRAM, in dual-port pairs: four per BRAM36, two per URAM.
608 #[must_use]
609 pub fn placement(&self, slices: u32, share: &Share) -> config::Build {
610 if self.chain_hash != 0 || !self.sbox_ram {
611 return config::Build::lut_only(slices);
612 }
613 let lanes = f64::from(slices * LANES_PER_SLICE);
614 let (_, table_bram, table_uram) = self.tables(slices, share);
615 let own = if self.own_ed25519 { 1.0 } else { 0.0 };
616 let fixed = f64::from(slices) * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
617 let free_bram = f64::from(share.bram36).min(KERNEL_BRAM36) - fixed - table_bram;
618 let free_uram = f64::from(share.uram.min(DEVICE_URAM)) - f64::from(slices * SLICE_URAM) - table_uram;
619 // Boxes a lane still has once its table rounds are tables.
620 let boxes = SBOXES_PER_LANE - table_lookups(self.table_rounds) as u32;
621 let bram = ((4.0 * free_bram / lanes).floor().max(0.0) as u32).min(boxes);
622 let uram = ((2.0 * free_uram / lanes).floor().max(0.0) as u32).min(boxes - bram);
623 config::Build { slices, uram_per_lane: uram, bram_per_lane: bram }
624 }
625
626 /// T-table RAM of `slices` slices inside `share`: (lookups per lane,
627 /// BRAM36, URAM). With "table RAM" on URAM, the lookups the share's
628 /// block RAM cannot hold move to its spare UltraRAM.
629 fn tables(&self, slices: u32, share: &Share) -> (f64, f64, f64) {
630 if self.chain_hash != 0 || self.table_rounds == 0 {
631 return (0.0, 0.0, 0.0);
632 }
633 let lanes = f64::from(slices * LANES_PER_SLICE);
634 let own = if self.own_ed25519 { 1.0 } else { 0.0 };
635 let fixed = f64::from(slices) * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
636 let bram = table_lane(self.table_rounds).1 * lanes;
637 let (bram, uram) = if self.table_uram {
638 spill_tables(
639 bram,
640 f64::from(share.bram36).min(KERNEL_BRAM36) - fixed,
641 f64::from(share.uram.min(DEVICE_URAM)) - f64::from(slices * SLICE_URAM),
642 )
643 } else {
644 (bram, 0.0)
645 };
646 (table_lookups(self.table_rounds), bram, uram)
647 }
648
649 /// Cost of `slices` slices inside `share` with the current knobs, plus a
650 /// description.
651 ///
652 /// The whole kernel: `slices` slices, the Ed25519 core and the glue. The
653 /// returned string is the one-line description shown in the planner.
654 #[must_use]
655 pub fn cost(&self, slices: u32, share: &Share) -> (Cost, String) {
656 // Chain lanes in the whole kernel (three per slice).
657 let lanes = f64::from(slices * LANES_PER_SLICE);
658 // LUTs of one factory BLAKE3 lane under the adder knob.
659 let factory_blake3 = if self.blake3_dsp { BLAKE3_DSP_LANE_LUT } else { BLAKE3_LANE_LUT };
660 // DSPs of one BLAKE3 lane under the adder knob (0 when off).
661 let blake3_dsp_per = if self.blake3_dsp { BLAKE3_LANE_DSP } else { 0 };
662 // A slice without its three chain lanes or its factory BLAKE3 lane.
663 let slice_rest = SLICE_LUT - 3.0 * HARAKA_LANE_LUT - BLAKE3_LANE_LUT;
664 // The S-box placement (all-LUT unless Haraka with S-box RAM).
665 let build = self.placement(slices, share);
666 // Per-lane LUTs and DSPs of the chosen chain hash.
667 let (lane_lut, lane_dsp) = match self.chain_hash {
668 // Haraka: the measured lane minus the LUTs freed by each S-box
669 // moved to RAM.
670 // Haraka with T-tables: the per-lane figure derived from build v5.
671 // Either way, each S-box placed in RAM frees its LUTs, and a
672 // lane with UltraRAM S-boxes adds its loader.
673 0 => {
674 let base = if self.table_rounds > 0 { table_lane(self.table_rounds).0 } else { HARAKA_LANE_LUT };
675 let placed = f64::from(build.uram_per_lane + build.bram_per_lane);
676 let loader = if build.uram_per_lane > 0 { config::LOADER_LUT } else { 0.0 };
677 (base - placed * LUTS_PER_LUT_SBOX + loader, 0)
678 }
679 // BLAKE3 chains: each chain lane is another BLAKE3 lane.
680 1 => (factory_blake3, blake3_dsp_per),
681 // SHA-512 chains on the shared line: only a small client per lane.
682 _ if self.sha_shared => (SHA512_CLIENT_LUT, 0),
683 // SHA-512 chains on DSig's own pipelines.
684 _ => (SHA512_LANE_LUT, 0),
685 };
686 // Slice count as f64 for the LUT sum.
687 let s = f64::from(slices);
688 // Kernel LUTs: per-slice rest and factory lane, every chain lane, the
689 // Ed25519 core and the glue (which contains the cert unit's BLAKE3
690 // lane, so the DSP-adder saving is applied to it once here).
691 // The Ed25519 certificate core, when DSig carries its own.
692 let own = if self.own_ed25519 { 1.0 } else { 0.0 };
693 let message = if self.message64 { MESSAGE64_SLICE_LUT } else { 0.0 };
694 let lut = s * (slice_rest + factory_blake3 + message) + lanes * lane_lut + own * ED25519_LUT + GLUE_LUT
695 + if self.blake3_dsp { -224.0 * 32.0 } else { 0.0 }; // the cert unit's BLAKE3 lane
696 // Kernel DSPs: Ed25519, then one BLAKE3 lane per slice plus the cert
697 // unit's (hence `slices + 1`), then any DSPs in the chain lanes.
698 let dsp = u32::from(self.own_ed25519) * ED25519_DSP + (slices + 1) * blake3_dsp_per + slices * LANES_PER_SLICE * lane_dsp;
699 // RAM taken by placed S-boxes: one URAM each, or half a BRAM36 each.
700 // UltraRAM S-boxes: one URAM per pair, rounded up per lane.
701 let uram_placed = build.uram_per_lane.div_ceil(2) * slices * LANES_PER_SLICE;
702 // Block RAM outside the tables and S-boxes: caches, Ed25519, glue.
703 let fixed_bram = s * SLICE_BRAM36 + own * ED25519_BRAM36 + GLUE_BRAM36;
704 // T-table RAM, spilled to URAM if "table RAM" allows it.
705 let (_, table_bram, table_uram) = self.tables(slices, share);
706 // A loader in every lane, once any table is in URAM.
707 let lut = lut + if table_uram > 0.0 { lanes * TABLE_LOADER_LUT } else { 0.0 };
708 // Block-RAM S-boxes: two per BRAM18, a quarter of a BRAM36 each.
709 let bram_placed = f64::from(build.bram_per_lane) * lanes / 4.0 + table_bram;
710 // The planner's integer cost; LUTs are clamped at zero so an
711 // aggressive estimate can never go negative.
712 let cost = Cost {
713 lut: lut.max(0.0) as u32,
714 dsp,
715 bram36: (fixed_bram + bram_placed).ceil() as u32,
716 uram: slices * SLICE_URAM + uram_placed + table_uram as u32,
717 };
718 // The human-readable line: hash, routing, S-box split, scheme.
719 let route = if self.uses_shared_line() { " on the shared line" } else { "" };
720 let tables = if self.chain_hash == 0 && self.table_rounds > 0 {
721 format!(
722 ", T-tables in {} rounds/lane ({}){}",
723 self.table_rounds,
724 if table_rounds_built(self.table_rounds) { "v5 synth" } else { "cost interpolated" },
725 if table_uram > 0.0 { format!(", {table_uram:.0} URAM of tables (model)") } else { String::new() }
726 )
727 } else {
728 String::new()
729 };
730 let sboxes = if build.uram_per_lane + build.bram_per_lane > 0 {
731 let left = SBOXES_PER_LANE - table_lookups(self.table_rounds) as u32;
732 format!(
733 "{tables}, S-boxes/lane {} URAM + {} BRAM + {} LUT (pairs, est.)",
734 build.uram_per_lane,
735 build.bram_per_lane,
736 left - build.uram_per_lane - build.bram_per_lane
737 )
738 } else {
739 tables
740 };
741 let status = if self.measured(slices) {
742 "built, card-measured 6.02M/s"
743 } else if Self::BUILT_SLICES.contains(&slices) {
744 "built"
745 } else {
746 "model"
747 };
748 let certs = if self.own_ed25519 { "own Ed25519 core" } else { "certs by Ed25519 sign module" };
749 let detail = format!(
750 "{slices} slice{} ({status}), {} chains{route}{sboxes}; {}; {certs}",
751 if slices == 1 { "" } else { "s" },
752 HASHES[self.chain_hash],
753 self.hbss.label()
754 );
755 (cost, detail)
756 }
757
758 /// Signatures per second of `slices` slices at `mhz`.
759 ///
760 /// The slice ceiling `slices × f / key period`; with DSig's own Ed25519
761 /// core also capped by the certificate ceiling `K × f / 895` (otherwise
762 /// the planner sizes the chip's Ed25519 signer for `1/K` of this rate). Unlike
763 /// [`config::Build::signatures_per_second`] it leaves out the frame-write
764 /// stage, which is never the limit at these slice counts in that model.
765 /// With the shared SHA-512 line, the line's supply is checked by the
766 /// planner through this plan's demand, not here.
767 #[must_use]
768 pub fn rate(&self, slices: u32, mhz: f64) -> f64 {
769 // The slower of "keys made" and "batches certified × K".
770 let keys = f64::from(slices) * mhz * 1e6 / self.hbss.key_period();
771 if self.own_ed25519 { keys.min(certificate_ceiling(self.hbss, mhz)) } else { keys }
772 }
773
774 /// Whether this configuration rests on measurement: the routed
775 /// all-LUT Haraka two-slice kernel with the built scheme and its own
776 /// Ed25519 core (build v3, measured on the card).
777 fn measured(&self, slices: u32) -> bool {
778 self.chain_hash == 0
779 && !self.blake3_dsp
780 && slices == 2
781 && !self.sbox_ram
782 // (No table rounds, so "table RAM" has nothing to place.)
783 && self.table_rounds == 0
784 && self.hbss.built()
785 && self.own_ed25519
786 }
787}
788
789/// How the chip planner talks to the DSig signer.
790impl Component for Dsig {
791 /// The row label in the planner.
792 fn name(&self) -> String {
793 "DSig sign".into()
794 }
795
796 /// Tells the planner this component supplies DSig signatures.
797 fn role(&self) -> Role {
798 Role::DsigSign
799 }
800
801 /// The knobs, in the order [`Component::set_knob`] indexes them:
802 /// 0 HBSS, 1 chain hash, 2 SHA lanes, 3 Winternitz w, 4 batch K (these
803 /// five shared with [`DsigVerify`]), 5 S-box RAM, 6 BLAKE3 adders,
804 /// 7 Ed25519 certificates, 8 table rounds, 9 table RAM (the last two
805 /// the signer's own; the verify unit has separate ones).
806 fn knobs(&self) -> Vec<Knob> {
807 let [scheme, w, batch] = self.hbss.knobs();
808 vec![
809 scheme,
810 Knob::choice("chain hash", self.chain_hash, &HASHES).shared(),
811 Knob::choice("SHA lanes", usize::from(self.sha_shared), &SHA_ROUTES).shared(),
812 w,
813 batch,
814 // Choice 0 is "fill", so the flag is inverted for the index.
815 Knob::choice("S-box RAM", usize::from(!self.sbox_ram), &["fill", "LUT only"]),
816 Knob::choice("BLAKE3 adders", usize::from(self.blake3_dsp), &["LUT", "DSP (est.)"]),
817 Knob::choice(
818 "Ed25519 certs",
819 usize::from(self.own_ed25519),
820 &["chip's Ed25519 sign module", "inside DSig (as built: own small signer)"],
821 ),
822 Knob::choice("table rounds", self.table_rounds as usize, &TABLE_ROUNDS),
823 Knob::choice("table RAM", usize::from(self.table_uram), &TABLE_RAM),
824 Knob::choice("certificate checks", usize::from(self.own_ed25519), &VERIFY_CERTS),
825 Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
826 ]
827 }
828
829 /// Applies a knob change from the planner. `value` is the chosen index
830 /// as a float; out-of-range indices are clamped to the last choice.
831 fn set_knob(&mut self, index: usize, value: f64) {
832 // The chosen index, never negative.
833 let pick = value.max(0.0) as usize;
834 match index {
835 0 => self.hbss.set(0, pick),
836 1 => self.chain_hash = pick.min(2),
837 2 => self.sha_shared = pick >= 1,
838 3 => self.hbss.set(1, pick),
839 4 => self.hbss.set(2, pick),
840 // Index 0 ("fill") means S-boxes in RAM.
841 5 => self.sbox_ram = pick == 0,
842 6 => self.blake3_dsp = pick >= 1,
843 7 => self.own_ed25519 = pick >= 1,
844 8 => self.table_rounds = pick.min(AUTO_TABLE_ROUNDS as usize) as u32,
845 9 => self.table_uram = pick >= 1,
846 10 => self.own_ed25519 = pick >= 1,
847 _ => self.message64 = pick >= 1,
848 }
849 }
850
851 /// HORS has no plan yet.
852 fn unavailable(&self) -> Option<String> {
853 (self.hbss.scheme == Scheme::Hors).then(|| HORS_TODO.into())
854 }
855
856 /// The largest slice count whose cost fits `share`, or `None` if even
857 /// one slice does not fit (or the HBSS is HORS, which has no model).
858 ///
859 /// Sizes are tried smallest first. The first size that does not fit stops
860 /// the search, and the resource it ran out of becomes the plan's `limit`,
861 /// so the planner can tell the user what to add.
862 fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
863 // Auto: plan every fixed table-round count and keep the best.
864 if self.table_rounds == AUTO_TABLE_ROUNDS {
865 return best_over_table_rounds((0..=6).filter_map(|rounds| {
866 Dsig { table_rounds: rounds, ..self.clone() }.plan(share, cx)
867 }));
868 }
869 // The clock the planner is evaluating at.
870 let mhz = cx.mhz;
871 // The best fitting plan so far.
872 let mut best: Option<Plan> = None;
873 if self.hbss.scheme != Scheme::WotsPlus {
874 return None;
875 }
876 for slices in 1..=Self::MAX_SLICES {
877 // Kernel cost of this size under the current knobs and share.
878 let (cost, detail) = self.cost(slices, share);
879 if !cost.fits(share) {
880 // Too big: say what ran out (if a smaller size fit) and stop.
881 if let Some(plan) = best.as_mut() {
882 plan.limit = cost.limit(share).into();
883 }
884 break;
885 }
886 // Fits: record it and try the next larger size.
887 let rate = self.rate(slices, mhz);
888 // Blocks the chain lanes take from the shared line, if they use it.
889 let demands = if self.uses_shared_line() {
890 vec![Flow {
891 service: SHA512_BLOCKS,
892 per_second: rate * self.hbss.steps_per_key(),
893 }]
894 } else {
895 Vec::new()
896 };
897 // What caps the rate: the slices or the certificate core.
898 let capped = self.own_ed25519 && certificate_ceiling(self.hbss, mhz) < f64::from(slices) * mhz * 1e6 / self.hbss.key_period();
899 best = Some(Plan {
900 cost,
901 rate,
902 unit: "signatures",
903 limit: if capped { "Ed25519 certificate core".into() } else { String::new() },
904 detail,
905 demands,
906 supplies: vec![Flow {
907 service: "dsig signatures",
908 per_second: rate,
909 }],
910 measured: self.measured(slices),
911 latency_cycles: SIGN_LATENCY_CYCLES,
912 // Little's law: requests in flight = rate × time in system.
913 in_flight: rate * SIGN_LATENCY_CYCLES / (mhz * 1e6),
914 // Keys per Ed25519 certificate: sets Ed25519 sign need (1/K)
915 // and the verify cache-hit fraction.
916 batch: Some(f64::from(self.hbss.batch)),
917 // With its own core DSig signs its certificates itself.
918 covers: if self.own_ed25519 { vec![Role::Ed25519Sign] } else { Vec::new() },
919 });
920 }
921 if let Some(plan) = best.as_mut() {
922 // Every size fit: the limit is the design itself, not the share.
923 if plan.limit.is_empty() {
924 plan.limit = format!("slice count ({} is the model's largest)", Self::MAX_SLICES);
925 }
926 }
927 best
928 }
929
930 /// The build variables that build `plan` (`DSIG_SLICES`, `DSIG_CHAIN`
931 /// and, for Haraka, `DSIG_TABLE_MASK` and `DSIG_SBOX_PLACEMENT`; see
932 /// `build.rs`).
933 ///
934 /// The slice count is recovered from the plan's detail line (it starts
935 /// with the count). Haraka builds also get the matching
936 /// `DSIG_SBOX_PLACEMENT`, which needs the share; the TUI writes these
937 /// for the plan it shows, so the placement is recomputed from the plan's
938 /// own RAM cost.
939 fn build_flags(&self, plan: &Plan) -> BuildFlags {
940 // Slice count: the first word of the detail line.
941 let slices: u32 = plan.detail.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(2);
942 // `DSIG_CHAIN` value of the chosen chain hash.
943 let chain = ["haraka", "blake3", "sha512"][self.chain_hash];
944 // `key = value` pairs the TUI writes out; keys are prefixed `dsig.`.
945 let mut flags = vec![
946 ("dsig.DSIG_SLICES".into(), slices.to_string()),
947 ("dsig.DSIG_CHAIN".into(), quoted(chain)),
948 ("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()),
949 ];
950 if !self.own_ed25519 {
951 flags.push(("dsig.certificates".into(), quoted("chip Ed25519 sign module (root port; not built)")));
952 }
953 // Table rounds the plan used (auto resolves to a fixed count per
954 // plan; the detail line names it), and whether any table is in URAM.
955 let rounds: u32 = plan
956 .detail
957 .split("T-tables in ")
958 .nth(1)
959 .and_then(|t| t.split_whitespace().next())
960 .and_then(|n| n.parse().ok())
961 .unwrap_or(0);
962 let uram_tables = plan.detail.contains("URAM of tables");
963 // S-box placement only exists for Haraka lanes; the plan's own RAM
964 // cost is exactly the RAM its placement was allowed to use.
965 if self.chain_hash == 0 {
966 // T-table rounds 3 .. 3 + rounds (`DSIG_TABLE_MASK`, bits 3..=8).
967 flags.push(("dsig.DSIG_TABLE_MASK".into(), quoted(&format!("{:#x}", ((1_u32 << rounds) - 1) << 3))));
968 let build = Dsig { table_rounds: rounds, ..self.clone() }.placement(slices, &plan.cost);
969 flags.push((
970 "dsig.DSIG_SBOX_PLACEMENT".into(),
971 quoted(&format!("uram={},bram={}", build.uram_per_lane, build.bram_per_lane)),
972 ));
973 }
974 // Every reason this plan cannot be built as planned today.
975 let slice_gap = format!("{slices} slice{} (DSIG_SLICES builds 2, 4 or 8)", if slices == 1 { "" } else { "s" });
976 let gaps = [
977 (!self.hbss.built(), "HBSS parameters other than W-OTS+ w=4, K=128"),
978 (!Self::BUILT_SLICES.contains(&slices), slice_gap.as_str()),
979 (self.uses_shared_line(), "chain lanes on the shared SHA-512 line"),
980 (self.message64, "64-byte messages (profile 2): simulated, not yet exported or card-built"),
981 (self.blake3_dsp, "BLAKE3 adders in DSPs"),
982 (!self.own_ed25519, "certificates from the chip's Ed25519 sign module"),
983 (uram_tables, "T-tables in UltraRAM"),
984 ];
985 flags.extend(buildable(gaps.iter().filter(|(gap, _)| *gap).map(|(_, why)| *why)));
986 flags
987 }
988}
989
990/// Idle signing latency: **native simulation** (cycle-accurate RHDL, not the
991/// card) of the slice signer behind the uBFT service's request stream
992/// (`tests/ubft_signer.rs`, 2 slices, 8 isolated requests after the first
993/// certificate: 171–188 cycles, mean 173.1, every frame the reference
994/// signature). A key is cached and its certificate made, so this is the
995/// message digest, the 68-cycle schedule slot it waits for, the node reads
996/// from the key cache, the 26-beat frame and the relay registers. It replaces
997/// an unmeasured 64-cycle estimate (28 BLAKE3 + 26 beats + ~10 registers)
998/// that left out the schedule wait and the cache reads. The batch kernel's
999/// HBM reader and writer add their own cycles (not measured).
1000///
1001/// Unit: clock cycles. The planner uses it for idle latency and, with
1002/// Little's law, for the number of signatures in flight.
1003pub const SIGN_LATENCY_CYCLES: f64 = 173.0;
1004
1005/// Idle signing latency of the **whole uBFT service kernel**
1006/// ([`crate::ubft::UbftEngine`]): native simulation, `tests/ubft_engine.rs`,
1007/// request beat to the frame's last beat, 6 isolated requests after the
1008/// first certificate. Profile 1: 207–217 cycles, mean 208.7 (1.01–1.06 µs
1009/// at 205 MHz); profile 2 (64-byte messages): 207–257, mean 215.3. It is
1010/// [`SIGN_LATENCY_CYCLES`] (the signer alone) plus the engine's stream
1011/// registers and arbitration, about 36 cycles. The uBFT signing component
1012/// uses this one, since a replica sees the whole kernel.
1013#[cfg(feature = "ubft")]
1014pub const UBFT_SIGN_LATENCY_CYCLES: f64 = 209.0;
1015
1016// ------------------------------------------------------------ verify model
1017//
1018// The verifier is built ([`crate::verify`]): units of three recirculating
1019// chain lanes and one BLAKE3 lane, plus (as built) the Ed25519 project's
1020// known-key verify core for certificates. A unit walks each chain from the
1021// revealed node to its end, rebuilds the endpoint commitment, then climbs
1022// the Merkle path to the batch root:
1023//
1024// revealed nodes ──(3 − digit) chain steps each──▶ 68 ends
1025// ──21 BLAKE3──▶ commitment ──7 BLAKE3──▶ root ──(cache miss)──▶ Ed25519
1026//
1027// Where each figure comes from:
1028//
1029// | Figure | Value | Provenance |
1030// |---|---|---|
1031// | core cost, Haraka, 4 table rounds | 89,627 LUT, 282 BRAM36 | verifier kernel build v1 routed (31e0144, mean of 3 cores); alone (OOC, fba2a3d) 80,211 LUT, 273 BRAM36, WNS +0.258 ns |
1032// | cycles per verification | 35.12 | native simulation, 256 records (91bebd0) |
1033// | idle latency of the hash path | 729 cycles | native simulation, one record, instant certificate |
1034// | unit I/O (reader, writer, its share of the cluster) | 6,083 LUT | build v1 routed |
1035// | Ed25519 verify core (fix14) | 79,532 LUT, 698 DSP, 43 BRAM36, 176 URAM | build v3 routed (OOC 007zzr: 84,435 LUT, WNS +0.072 ns) |
1036// | kernel timing at 205 MHz | met, WNS +0.016 ns, 368,713 LUT | build v3 routed |
1037// | kernel rate on the card | ≥ 16,959,195 verifications/s, 3 units | build v3, finite burst of 1,572,864 |
1038// | its rate and latency | 0.279M/s; 52,670 cycles isolated, 63,026 median loaded | Ed25519 project, native simulation |
1039
1040/// Expected chain steps to finish one DSig verification: 68 W-OTS+ chains
1041/// (w = 4) each need `3 − digit` steps; the 64 message digits are uniform
1042/// (mean 1.5 steps) and the 4 checksum digits skew low.
1043///
1044/// Unit: chain steps per verification (expected value). Native simulation
1045/// of 256 real records measured 101.4.
1046pub const VERIFY_CHAIN_STEPS: f64 = 105.0;
1047/// CLB LUTs of one routed verify core (Haraka lanes with
1048/// [`VERIFY_CORE_TABLE_ROUNDS`] table rounds) inside the kernel:
1049/// **measured**, mean of the three cores of verifier build v1 (Vivado
1050/// 2023.2 routed, 89,495–89,716). Routed alone (OOC) it was 80,211.
1051pub const VERIFY_CORE_LUT: f64 = 89_627.0;
1052/// Block-RAM tiles of that core in the kernel (88 RAMB36 + 388 RAMB18):
1053/// **measured**, build v1.
1054pub const VERIFY_CORE_BRAM36: f64 = 282.0;
1055/// Table rounds of the measured core's lanes (`DSIG_TABLE_MASK=0x078`).
1056pub const VERIFY_CORE_TABLE_ROUNDS: u32 = 4;
1057/// LUTs of a unit beyond its core: HBM reader and writer and its share of
1058/// the cluster (registered links, registers, arbiter). **Measured**, build
1059/// v1: (366,916 engine − 79,786 Ed25519) / 3 units − 89,627 per core.
1060pub const VERIFY_UNIT_IO_LUT: f64 = 6_083.0;
1061/// Cycles per verification of one core for the built scheme: **native
1062/// simulation** (two whole batches, steady state).
1063pub const VERIFY_CYCLES_MEASURED: f64 = 35.12;
1064/// Idle latency of the hash path for the built scheme (first beat to
1065/// verdict, certificate already checked): **native simulation**.
1066pub const VERIFY_LATENCY_MEASURED: f64 = 729.0;
1067/// The Ed25519 known-key verify core a verifier embeds (fix14, constant
1068/// banks as `ConstRom`): CLB LUTs inside the verifier kernel, **measured**
1069/// (build v3, routed, `engine/c1`: 79,532). Alone it routes at 84,435 with
1070/// WNS +0.072 ns (Ed25519 project, 007zzr; fix12 in build v1: 79,786 in
1071/// context, 82,184 alone).
1072pub const VERIFY_ED25519_LUT: f64 = 79_532.0;
1073/// The verifier kernel's card rate: **measured** (build v3, three units,
1074/// 1,572,864 valid signatures in 19,012,289 cycles at 205.00 MHz, finite
1075/// burst `vrun-clean-1`): 12.09 cycles per signature for the kernel, 36.3
1076/// per unit.
1077pub const VERIFY_CARD_RATE: f64 = 16_959_195.0;
1078/// Units in the card-measured verifier kernel.
1079pub const VERIFY_CARD_UNITS: u32 = 3;
1080/// Its DSP slices (**measured**, 007zzi).
1081pub const VERIFY_ED25519_DSP: u32 = 698;
1082/// Its block-RAM tiles (41 RAMB36 + 4 RAMB18; **measured**, 007zzi).
1083pub const VERIFY_ED25519_BRAM36: u32 = 43;
1084/// Its UltraRAMs (**measured**, 007zzi).
1085pub const VERIFY_ED25519_URAM: u32 = 176;
1086/// Its certificate checks per second at 205 MHz: **native simulation**
1087/// (Ed25519 project, fix12 dcb2e6e: 600 verifications, 735.25 cycles each).
1088pub const VERIFY_ED25519_RATE: f64 = 278_810.0;
1089/// Its latency per check, saturated median: **native simulation** (Ed25519
1090/// project: min 54,144, median 63,026, p90 81,738 cycles; isolated ~52k).
1091/// The verifier's verdict queue hides it after a run's first batch.
1092pub const VERIFY_ED25519_LATENCY: f64 = 63_026.0;
1093/// Its latency for an isolated check: **native simulation** (Ed25519
1094/// project, one request at a time, 4 samples: 52,642–52,672, median 52,670).
1095pub const VERIFY_ED25519_ISOLATED: f64 = 52_670.0;
1096
1097/// DSig verification (built: [`crate::verify`]).
1098///
1099/// A verify unit has three chain lanes and one BLAKE3 lane, like a slice.
1100/// Its rate is set by its busier side: chain steps over three lanes, or
1101/// BLAKE3 compressions on one lane. The Ed25519 certificate check (`1 − h`
1102/// of verifications) is done by the Ed25519 verify component, not here.
1103/// Its knobs must match the signer's for the two to verify each other.
1104#[derive(Clone, Debug)]
1105pub struct DsigVerify {
1106 /// 0 Haraka-512, 1 BLAKE3, 2 SHA-512 (must match the signer).
1107 pub chain_hash: usize,
1108 /// SHA-512 chains on the shared line instead of own pipelines.
1109 pub sha_shared: bool,
1110 /// The hash-based scheme (must match the signer).
1111 pub hbss: Hbss,
1112 /// Profile 2: 64-byte messages (must match the signer).
1113 pub message64: bool,
1114 /// Table rounds of the unit's three Haraka lanes (0..=6, see
1115 /// [`table_lane`], or [`AUTO_TABLE_ROUNDS`], the default). Independent of the signer's: the verify unit is
1116 /// separate hardware, so a BRAM-bound chip can give tables to one and
1117 /// LUT S-boxes to the other.
1118 pub table_rounds: u32,
1119 /// Table lookups beyond the share's block RAM go to its UltraRAM, as
1120 /// [`Dsig::table_uram`]. **Model.** On by default, like the signer's.
1121 pub table_uram: bool,
1122 /// Certificates are checked by the verifier's own Ed25519 verify core
1123 /// (as built) instead of the chip's Ed25519 verify module.
1124 pub own_ed25519: bool,
1125}
1126
1127/// Haraka-512 chains, the built HBSS and auto table rounds, matching the
1128/// default signer; certificates on the chip's Ed25519 verify module.
1129impl Default for DsigVerify {
1130 fn default() -> Self {
1131 Self {
1132 chain_hash: 0,
1133 sha_shared: false,
1134 hbss: Hbss::default(),
1135 message64: false,
1136 table_rounds: AUTO_TABLE_ROUNDS,
1137 table_uram: true,
1138 own_ed25519: false,
1139 }
1140 }
1141}
1142
1143impl DsigVerify {
1144 /// Whether the chain lanes hash on the shared SHA-512 line.
1145 #[must_use]
1146 pub fn uses_shared_line(&self) -> bool {
1147 self.chain_hash == 2 && self.sha_shared
1148 }
1149
1150 /// LUTs and block RAM of a verify core beyond its three chain lanes:
1151 /// the measured core minus its three lanes (as the lane model prices
1152 /// them). The BLAKE3 lane, scheduler, banks and cache are all in here.
1153 #[must_use]
1154 pub fn core_rest() -> (f64, f64) {
1155 let (lane_lut, lane_bram) = table_lane(VERIFY_CORE_TABLE_ROUNDS);
1156 (VERIFY_CORE_LUT - 3.0 * lane_lut, VERIFY_CORE_BRAM36 - 3.0 * lane_bram)
1157 }
1158
1159 /// Cost of one verify unit: three chain lanes of the chosen hash, the
1160 /// measured rest of the core, and the unit's HBM I/O.
1161 #[must_use]
1162 pub fn unit_cost(&self) -> Cost {
1163 // LUTs of one chain lane of the selected hash and routing.
1164 let lane = match self.chain_hash {
1165 0 => table_lane(self.table_rounds).0,
1166 1 => BLAKE3_LANE_LUT,
1167 _ if self.sha_shared => SHA512_CLIENT_LUT,
1168 _ => SHA512_LANE_LUT,
1169 };
1170 let (rest_lut, rest_bram) = Self::core_rest();
1171 Cost {
1172 lut: (3.0 * lane + rest_lut + VERIFY_UNIT_IO_LUT + if self.message64 { MESSAGE64_VERIFY_LUT } else { 0.0 }) as u32,
1173 dsp: 0,
1174 bram36: (rest_bram + if self.chain_hash == 0 { 3.0 * table_lane(self.table_rounds).1 } else { 0.0 }).ceil()
1175 as u32,
1176 uram: 0,
1177 }
1178 }
1179
1180 /// The embedded Ed25519 verify core, when certificates are checked inside.
1181 #[must_use]
1182 pub fn ed25519_cost(&self) -> Cost {
1183 if self.own_ed25519 {
1184 Cost {
1185 lut: VERIFY_ED25519_LUT as u32,
1186 dsp: VERIFY_ED25519_DSP,
1187 bram36: VERIFY_ED25519_BRAM36,
1188 uram: VERIFY_ED25519_URAM,
1189 }
1190 } else {
1191 Cost::default()
1192 }
1193 }
1194
1195 /// Whether this configuration is the one measured (costs from Vivado,
1196 /// period from simulation).
1197 #[must_use]
1198 pub fn is_measured(&self) -> bool {
1199 self.chain_hash == 0
1200 && self.table_rounds == VERIFY_CORE_TABLE_ROUNDS
1201 && self.hbss == Hbss::default()
1202 && !self.table_uram
1203 }
1204
1205 /// Cost of `units` units inside `share`, and the URAM their tables took.
1206 ///
1207 /// Without "table RAM" on URAM this is `units × unit_cost`. With it, the
1208 /// table block RAM that the share cannot hold moves to UltraRAM, and every
1209 /// lane gets a [`TABLE_LOADER_LUT`] loader.
1210 #[must_use]
1211 pub fn units_cost(&self, units: u32, share: &Share) -> (Cost, f64) {
1212 let total = Cost::default().add(self.unit_cost(), units).add(self.ed25519_cost(), 1);
1213 let table_bram = if self.chain_hash == 0 { 3.0 * table_lane(self.table_rounds).1 * f64::from(units) } else { 0.0 };
1214 if !self.table_uram || table_bram == 0.0 {
1215 return (total, 0.0);
1216 }
1217 let fixed = f64::from(total.bram36) - table_bram;
1218 let (kept, uram) = spill_tables(table_bram, f64::from(share.bram36) - fixed, f64::from(share.uram));
1219 let loaders = if uram > 0.0 { 3.0 * f64::from(units) * TABLE_LOADER_LUT } else { 0.0 };
1220 let cost = Cost {
1221 lut: total.lut + loaders as u32,
1222 bram36: (fixed + kept).ceil() as u32,
1223 uram: total.uram + uram as u32,
1224 ..total
1225 };
1226 (cost, uram)
1227 }
1228
1229 /// Cycles per verification of one unit: the busier of chain steps over
1230 /// three lanes and BLAKE3 compressions on one lane (35 for the built
1231 /// scheme: 105 / 3 versus 29). The built scheme uses the measured period.
1232 #[must_use]
1233 pub fn cycles_per_verify(&self) -> f64 {
1234 if self.hbss == Hbss::default() && self.chain_hash == 0 {
1235 VERIFY_CYCLES_MEASURED
1236 } else {
1237 (self.hbss.verify_steps() / 3.0).max(self.hbss.verify_blake3())
1238 }
1239 }
1240
1241 /// Idle latency of the hash path. The built scheme uses the measured
1242 /// figure; others scale it by their chain and commitment depth.
1243 #[must_use]
1244 pub fn latency_cycles(&self) -> f64 {
1245 let model = |v: &DsigVerify| {
1246 f64::from(v.hbss.w - 1) * (CHAIN_LATENCY[v.chain_hash] + 1.0) + v.hbss.verify_blake3() * 34.0
1247 };
1248 VERIFY_LATENCY_MEASURED * model(self) / model(&DsigVerify::default())
1249 }
1250}
1251
1252/// How the chip planner talks to the verifier model.
1253impl Component for DsigVerify {
1254 /// The row label.
1255 fn name(&self) -> String {
1256 "DSig verify".into()
1257 }
1258
1259 /// Tells the planner this component supplies DSig verifications.
1260 fn role(&self) -> Role {
1261 Role::DsigVerify
1262 }
1263
1264 /// Knobs: 0 HBSS, 1 chain hash, 2 SHA lanes, 3 Winternitz w, 4 batch K,
1265 /// 5 table rounds, 6 table RAM. The first five are shared with the
1266 /// signer (the TUI shows them under DSig sign); the last two are the
1267 /// unit's own.
1268 fn knobs(&self) -> Vec<Knob> {
1269 let [scheme, w, batch] = self.hbss.knobs();
1270 vec![
1271 scheme,
1272 Knob::choice("chain hash", self.chain_hash, &HASHES).shared(),
1273 Knob::choice("SHA lanes", usize::from(self.sha_shared), &SHA_ROUTES).shared(),
1274 w,
1275 batch,
1276 Knob::choice("table rounds", self.table_rounds as usize, &TABLE_ROUNDS),
1277 Knob::choice("table RAM", usize::from(self.table_uram), &TABLE_RAM),
1278 Knob::choice("certificate checks", usize::from(self.own_ed25519), &VERIFY_CERTS),
1279 Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
1280 ]
1281 }
1282
1283 /// Applies a knob change, clamping out-of-range indices.
1284 fn set_knob(&mut self, index: usize, value: f64) {
1285 let pick = value.max(0.0) as usize;
1286 match index {
1287 0 => self.hbss.set(0, pick),
1288 1 => self.chain_hash = pick.min(2),
1289 2 => self.sha_shared = pick >= 1,
1290 3 => self.hbss.set(1, pick),
1291 4 => self.hbss.set(2, pick),
1292 5 => self.table_rounds = pick.min(AUTO_TABLE_ROUNDS as usize) as u32,
1293 6 => self.table_uram = pick >= 1,
1294 7 => self.own_ed25519 = pick >= 1,
1295 _ => self.message64 = pick >= 1,
1296 }
1297 }
1298
1299 /// HORS has no plan yet.
1300 fn unavailable(&self) -> Option<String> {
1301 (self.hbss.scheme == Scheme::Hors).then(|| HORS_TODO.into())
1302 }
1303
1304 /// `DSIG_VERIFY_UNITS` and the lanes' table mask; what is not built yet
1305 /// is named.
1306 fn build_flags(&self, plan: &Plan) -> BuildFlags {
1307 let units: u32 = plan.detail.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(1);
1308 let mut gaps = Vec::new();
1309 if !self.own_ed25519 {
1310 gaps.push("certificates on the chip's Ed25519 verify module (built kernel embeds its own)");
1311 }
1312 if self.table_uram && plan.detail.contains("URAM of tables") {
1313 gaps.push("T-tables in URAM (model)");
1314 }
1315 if self.hbss != Hbss::default() {
1316 gaps.push("only W-OTS+ w=4, K=128 is built");
1317 }
1318 if self.message64 {
1319 gaps.push("64-byte messages (profile 2): simulated, not yet exported or card-built");
1320 }
1321 let mut flags = buildable(gaps.into_iter());
1322 flags.push(("dsig.DSIG_VERIFY_UNITS".into(), units.to_string()));
1323 flags.push(("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()));
1324 if self.chain_hash == 0 {
1325 let rounds = if self.table_rounds == AUTO_TABLE_ROUNDS { VERIFY_CORE_TABLE_ROUNDS } else { self.table_rounds };
1326 flags.push(("dsig.DSIG_TABLE_MASK".into(), quoted(&format!("{:#x}", ((1_u32 << rounds) - 1) << 3))));
1327 }
1328 flags.push(("dsig.DSIG_CHAIN".into(), quoted(["haraka", "blake3", "sha512"][self.chain_hash])));
1329 flags
1330 }
1331
1332 /// As many identical verify units as the share's LUTs and BRAM allow.
1333 ///
1334 /// Unlike the signer, units scale one by one, so the plan is a simple
1335 /// division. The Ed25519 check on cache misses is planned separately.
1336 fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1337 if self.hbss.scheme != Scheme::WotsPlus {
1338 return None;
1339 }
1340 // Auto: plan every fixed table-round count and keep the best.
1341 if self.table_rounds == AUTO_TABLE_ROUNDS {
1342 return best_over_table_rounds((0..=6).filter_map(|rounds| {
1343 DsigVerify { table_rounds: rounds, ..self.clone() }.plan(share, cx)
1344 }));
1345 }
1346 // One unit's cost.
1347 let unit = self.unit_cost();
1348 let ed = self.ed25519_cost();
1349 if ed.lut > share.lut || ed.dsp > share.dsp || ed.bram36 > share.bram36 || ed.uram > share.uram {
1350 return None;
1351 }
1352 // Whole units that fit next to the Ed25519 core (if any), limited by
1353 // LUTs or by BRAM, whichever is tighter (`max(1)` avoids dividing by
1354 // zero).
1355 let mut units = ((share.lut - ed.lut) / unit.lut.max(1)).min((share.bram36 - ed.bram36) / unit.bram36.max(1));
1356 // With tables allowed in URAM, add units while the spilled cost fits.
1357 if self.table_uram {
1358 while self.units_cost(units + 1, share).0.fits(share) {
1359 units += 1;
1360 }
1361 }
1362 if units == 0 {
1363 return None;
1364 }
1365 // Verifications per second of all units together; an own Ed25519
1366 // core caps them at one certificate check per K verifications.
1367 let mut rate = f64::from(units) * cx.mhz * 1e6 / self.cycles_per_verify();
1368 if self.own_ed25519 {
1369 rate = rate.min(VERIFY_ED25519_RATE * cx.mhz / 205.0 * f64::from(self.hbss.batch));
1370 }
1371 // Idle latency of one verification.
1372 // Idle latency of the hash path, plus (with an own Ed25519 core) the
1373 // certificate check for the share of verifications whose batch is
1374 // not already checked: (1 − h) × the isolated check latency.
1375 let mut latency = self.latency_cycles();
1376 if self.own_ed25519 {
1377 latency += (1.0 - cx.cache_hit(f64::from(self.hbss.batch))) * VERIFY_ED25519_ISOLATED;
1378 }
1379 // Total cost of `units` units, and URAM taken by their tables.
1380 let (cost, table_uram) = self.units_cost(units, share);
1381 // Blocks taken from the shared line, if the lanes use it.
1382 let demands = if self.uses_shared_line() {
1383 vec![Flow {
1384 service: SHA512_BLOCKS,
1385 per_second: rate * self.hbss.verify_steps(),
1386 }]
1387 } else {
1388 Vec::new()
1389 };
1390 Some(Plan {
1391 cost,
1392 rate,
1393 unit: "verifications",
1394 // What would run out first if one more unit were added.
1395 limit: self.units_cost(units + 1, share).0.limit(share).into(),
1396 detail: format!(
1397 "{units} units × 3 {} lanes{} + 1 BLAKE3 lane; {:.2} cycles/verify ({}); {}; hit rate {:.1} %{}",
1398 HASHES[self.chain_hash],
1399 if self.chain_hash != 0 || self.table_rounds == 0 {
1400 String::new()
1401 } else if table_uram > 0.0 {
1402 format!(" ({} table rounds, {table_uram:.0} URAM of tables)", self.table_rounds)
1403 } else {
1404 format!(" ({} table rounds)", self.table_rounds)
1405 },
1406 self.cycles_per_verify(),
1407 if self.hbss == Hbss::default() && self.chain_hash == 0 { "native sim" } else { "model" },
1408 self.hbss.label(),
1409 100.0 * cx.cache_hit(f64::from(self.hbss.batch)),
1410 if self.own_ed25519 { "; own Ed25519 verify core" } else { "" }
1411 ),
1412 demands,
1413 supplies: vec![Flow {
1414 service: "dsig verifies",
1415 per_second: rate,
1416 }],
1417 measured: self.is_measured(),
1418 latency_cycles: latency,
1419 in_flight: rate * latency / (cx.mhz * 1e6),
1420 batch: Some(f64::from(self.hbss.batch)),
1421 covers: if self.own_ed25519 { vec![Role::Ed25519Verify] } else { Vec::new() },
1422 })
1423 }
1424}
1425
1426/// Checks that the planner component agrees with measurement and behaves
1427/// sensibly as the budget grows.
1428// ------------------------------------------------------------ uBFT service
1429//
1430// DSig as uBFT's signature service (`crate::ubft`, feature `ubft`): the
1431// planner's uBFT modes (`rhdl_fpga_planner::ubft`) plan these two modules
1432// beside the uBFT replica. A slot needs one signature and 2f verifications,
1433// so the right build is the smallest one, and its idle latency is the
1434// headline: more slices or units would not shorten a lone request.
1435
1436/// Isolated verify latency of the uBFT service with its certificate already
1437/// checked: **native simulation** (`tests/ubft_blocks.rs`, instant Ed25519
1438/// model, header handshake to verdict: 732–736 cycles; the verify core alone
1439/// is [`VERIFY_LATENCY_MEASURED`]).
1440#[cfg(feature = "ubft")]
1441pub const UBFT_VERIFY_LATENCY_CYCLES: f64 = 734.0;
1442
1443/// Slice counts the uBFT signer can be built with (`DSIG_SLICES`).
1444#[cfg(feature = "ubft")]
1445pub const UBFT_SLICES: [u32; 3] = [2, 4, 8];
1446/// Their names in the Tuner.
1447#[cfg(feature = "ubft")]
1448const UBFT_SLICE_NAMES: [&str; 3] = ["2", "4", "8"];
1449/// The uBFT services' profile-1 gate in the Tuner.
1450#[cfg(feature = "ubft")]
1451const UBFT_PROFILE1: [&str; 2] = ["refused (U33)", "allowed (U33 open)"];
1452
1453/// The reason a uBFT plan in profile 1 is not buildable unless allowed:
1454/// profile 1 signs view, slot, k and m only, so the session, the role and
1455/// the CTB fields of uBFT's 48-byte context are not authenticated (audit
1456/// finding U33) and the CTB role cannot be signed at all.
1457#[cfg(feature = "ubft")]
1458const UBFT_PROFILE1_GAP: &str =
1459 "uBFT with 16-byte messages (profile 1) leaves U33 open and cannot sign CTB; set 'profile 1 for uBFT' to allowed";
1460
1461/// The uBFT services' message settings, shared by signer and verifier.
1462/// Profile 2 is the default: it signs uBFT's whole context.
1463#[cfg(feature = "ubft")]
1464#[derive(Clone, Copy, Debug)]
1465pub struct UbftMessage {
1466 /// Profile 2: 64-byte messages (`DSIG_MESSAGE_BYTES=64`).
1467 pub message64: bool,
1468 /// Accept profile 1 under uBFT anyway (`dsig.allow_profile1_ubft`).
1469 pub allow_profile1: bool,
1470}
1471
1472#[cfg(feature = "ubft")]
1473impl Default for UbftMessage {
1474 /// Profile 2, profile 1 refused.
1475 fn default() -> Self {
1476 Self { message64: true, allow_profile1: false }
1477 }
1478}
1479
1480#[cfg(feature = "ubft")]
1481impl UbftMessage {
1482 /// Its two knobs, both shared by the uBFT signer and verifier.
1483 fn knobs(self) -> [Knob; 2] {
1484 [
1485 Knob::choice("message", usize::from(self.message64), &MESSAGES).shared(),
1486 Knob::choice("profile 1 for uBFT", usize::from(self.allow_profile1), &UBFT_PROFILE1).shared(),
1487 ]
1488 }
1489 /// Sets knob `index` (0: message, 1: the profile-1 gate).
1490 fn set(&mut self, index: usize, value: f64) {
1491 match index {
1492 0 => self.message64 = value >= 1.0,
1493 1 => self.allow_profile1 = value >= 1.0,
1494 _ => {}
1495 }
1496 }
1497 /// Whether the plan can be built, the message length and the gate.
1498 fn flags(self) -> BuildFlags {
1499 let refused = !self.message64 && !self.allow_profile1;
1500 let mut flags = buildable(refused.then_some(UBFT_PROFILE1_GAP).into_iter());
1501 flags.push(("dsig.DSIG_MESSAGE_BYTES".into(), if self.message64 { "64" } else { "16" }.into()));
1502 flags.push(("dsig.allow_profile1_ubft".into(), self.allow_profile1.to_string()));
1503 flags
1504 }
1505}
1506
1507/// DSig signing as a uBFT service: the batch signer's slices and certificate
1508/// unit behind a request stream ([`crate::ubft::UbftSigner`]), with its own
1509/// Ed25519 certificate core and the T-table rounds of the measured builds.
1510#[cfg(feature = "ubft")]
1511#[derive(Clone, Debug)]
1512pub struct DsigUbftSign {
1513 /// Slices (2 is the smallest buildable signer).
1514 pub slices: u32,
1515 /// Message profile and the profile-1 gate.
1516 pub message: UbftMessage,
1517}
1518
1519#[cfg(feature = "ubft")]
1520impl Default for DsigUbftSign {
1521 /// Two slices: the smallest build; a uBFT slot needs one signature.
1522 /// Profile 2.
1523 fn default() -> Self {
1524 Self { slices: 2, message: UbftMessage::default() }
1525 }
1526}
1527
1528#[cfg(feature = "ubft")]
1529impl DsigUbftSign {
1530 /// The signer model it prices with.
1531 fn inner(&self) -> Dsig {
1532 Dsig {
1533 own_ed25519: true,
1534 table_rounds: 4,
1535 message64: self.message.message64,
1536 ..Dsig::default()
1537 }
1538 }
1539}
1540
1541#[cfg(feature = "ubft")]
1542impl Component for DsigUbftSign {
1543 fn name(&self) -> String {
1544 "DSig sign (uBFT service)".into()
1545 }
1546 fn role(&self) -> Role {
1547 Role::DsigSign
1548 }
1549 /// The slice count, a choice of exactly the counts `DSIG_SLICES`
1550 /// builds (2, 4, 8; its value is the index into [`UBFT_SLICES`]); then
1551 /// the message profile and the profile-1 gate ([`UbftMessage`]).
1552 fn knobs(&self) -> Vec<Knob> {
1553 let index = UBFT_SLICES.iter().position(|&n| n == self.slices).unwrap_or(0);
1554 let mut knobs = vec![Knob::choice("slices", index, &UBFT_SLICE_NAMES)];
1555 knobs.extend(self.message.knobs());
1556 knobs
1557 }
1558 fn set_knob(&mut self, index: usize, value: f64) {
1559 if index == 0 {
1560 self.slices = UBFT_SLICES[(value.max(0.0) as usize).min(UBFT_SLICES.len() - 1)];
1561 } else {
1562 self.message.set(index - 1, value);
1563 }
1564 }
1565 fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1566 let inner = self.inner();
1567 let (cost, detail) = inner.cost(self.slices, share);
1568 if !cost.fits(share) {
1569 return None;
1570 }
1571 let rate = inner.rate(self.slices, cx.mhz);
1572 Some(Plan {
1573 cost,
1574 rate,
1575 unit: "signatures",
1576 limit: "fixed size (a slot needs one signature)".into(),
1577 detail: format!(
1578 "uBFT service, {detail}; isolated latency {UBFT_SIGN_LATENCY_CYCLES:.0} cycles through the whole kernel (native sim); AXI4-Stream requests"
1579 ),
1580 demands: Vec::new(),
1581 supplies: vec![Flow {
1582 service: "dsig signatures",
1583 per_second: rate,
1584 }],
1585 measured: false,
1586 latency_cycles: UBFT_SIGN_LATENCY_CYCLES,
1587 in_flight: 1.0,
1588 batch: Some(f64::from(inner.hbss.batch)),
1589 covers: vec![Role::Ed25519Sign],
1590 })
1591 }
1592 fn build_flags(&self, _plan: &Plan) -> BuildFlags {
1593 // Every slice count it offers lowers (`DSIG_SLICES` = 2, 4 or 8),
1594 // in either message profile (the uBFT exporter builds both); the
1595 // 2-slice service is the one being routed.
1596 let mut flags = self.message.flags();
1597 flags.extend([
1598 ("dsig.DSIG_SLICES".into(), self.slices.to_string()),
1599 ("dsig.DSIG_TABLE_MASK".into(), quoted("0x78")),
1600 ("dsig.features".into(), quoted("ubft")),
1601 ]);
1602 flags
1603 }
1604 fn ubft(&self) -> bool {
1605 true
1606 }
1607}
1608
1609/// DSig verification as a uBFT service: one verify core behind a request
1610/// stream ([`crate::ubft::UbftVerifier`]) and the Ed25519 known-key verify
1611/// core with one key slot per peer replica.
1612#[cfg(feature = "ubft")]
1613#[derive(Clone, Debug, Default)]
1614pub struct DsigUbftVerify {
1615 /// Message profile and the profile-1 gate (shared with the signer).
1616 pub message: UbftMessage,
1617}
1618
1619#[cfg(feature = "ubft")]
1620impl DsigUbftVerify {
1621 /// The verifier model it prices with.
1622 fn inner(&self) -> DsigVerify {
1623 DsigVerify {
1624 own_ed25519: true,
1625 table_rounds: 4,
1626 table_uram: false,
1627 message64: self.message.message64,
1628 ..DsigVerify::default()
1629 }
1630 }
1631}
1632
1633#[cfg(feature = "ubft")]
1634impl Component for DsigUbftVerify {
1635 fn name(&self) -> String {
1636 "DSig verify (uBFT service)".into()
1637 }
1638 fn role(&self) -> Role {
1639 Role::DsigVerify
1640 }
1641 /// The message profile and the profile-1 gate ([`UbftMessage`]).
1642 fn knobs(&self) -> Vec<Knob> {
1643 self.message.knobs().to_vec()
1644 }
1645 fn set_knob(&mut self, index: usize, value: f64) {
1646 self.message.set(index, value);
1647 }
1648 fn plan(&self, share: &Share, cx: &Context) -> Option<Plan> {
1649 let inner = self.inner();
1650 let (cost, _) = inner.units_cost(1, share);
1651 if !cost.fits(share) {
1652 return None;
1653 }
1654 let batch = f64::from(inner.hbss.batch);
1655 let rate = (cx.mhz * 1e6 / inner.cycles_per_verify()).min(VERIFY_ED25519_RATE * cx.mhz / 205.0 * batch);
1656 // A miss waits for one Ed25519 check; hits do not.
1657 let latency = UBFT_VERIFY_LATENCY_CYCLES + (1.0 - cx.cache_hit(batch)) * VERIFY_ED25519_ISOLATED;
1658 Some(Plan {
1659 cost,
1660 rate,
1661 unit: "verifications",
1662 limit: "fixed size (a slot needs 2f verifications)".into(),
1663 detail: format!(
1664 "uBFT service, 1 unit; {:.0} cycles with the certificate cached (native sim), +{VERIFY_ED25519_ISOLATED:.0} on a miss; \
1665 8 trusted-key slots (capacity 8; max active membership 7); hit rate {:.1} %",
1666 UBFT_VERIFY_LATENCY_CYCLES,
1667 100.0 * cx.cache_hit(batch)
1668 ),
1669 demands: Vec::new(),
1670 supplies: Vec::new(),
1671 measured: false,
1672 latency_cycles: latency,
1673 in_flight: 1.0,
1674 batch: Some(batch),
1675 covers: vec![Role::Ed25519Verify],
1676 })
1677 }
1678 fn build_flags(&self, _plan: &Plan) -> BuildFlags {
1679 let mut flags = self.message.flags();
1680 flags.push(("dsig.features".into(), quoted("ubft")));
1681 flags
1682 }
1683 fn ubft(&self) -> bool {
1684 true
1685 }
1686}
1687
1688#[cfg(test)]
1689mod tests {
1690 use super::*;
1691
1692 /// The whole device as one share.
1693 fn device() -> Share {
1694 Share { lut: 1_303_680, dsp: 9_024, bram36: 2_016, uram: 960 }
1695 }
1696
1697 /// The lane split must add back up to the routed kernel (within 1,000
1698 /// LUTs) for the one build that was actually routed.
1699 #[test]
1700 fn the_haraka_two_slice_cost_matches_the_routed_link() {
1701 // Default knobs except all S-boxes in LUTs, as in the routed link.
1702 let dsig = Dsig { sbox_ram: false, own_ed25519: true, table_rounds: 0, ..Dsig::default() };
1703 let (cost, _) = dsig.cost(2, &device());
1704 // Routed slices-2 link: 301,037 CLB LUT for the kernel.
1705 assert!((f64::from(cost.lut) - 301_037.0).abs() < 1_000.0, "{cost:?}");
1706 // The card-measured build stays labelled measured with the default
1707 // "table RAM" (it has no tables to place).
1708 assert!(dsig.measured(2));
1709 }
1710
1711 /// With T-table lanes, the four-slice kernel adds back up to build v5's
1712 /// synthesized LUTs, and its block RAM fits the kernel region (1,776
1713 /// BRAM36), unlike build v4.
1714 #[test]
1715 fn the_table_four_slice_cost_matches_the_v5_synthesis() {
1716 let dsig = Dsig { own_ed25519: true, table_rounds: 6, ..Dsig::default() };
1717 let (cost, line) = dsig.cost(4, &device());
1718 assert!((f64::from(cost.lut) - V5_KERNEL_LUT_SYNTH).abs() < 10.0, "{cost:?}");
1719 assert!(cost.bram36 < 1_776, "{cost:?}");
1720 assert!(line.contains("T-tables"), "{line}");
1721 assert!(!dsig.measured(4) && !dsig.measured(2), "synthesis is not a measurement");
1722 // A table lane is about a third of a LUT S-box lane.
1723 assert!((7_000.0..8_000.0).contains(&HARAKA_TABLE_LANE_LUT));
1724 // The interpolation meets both builds and trades LUTs for BRAM.
1725 assert_eq!(table_lane(0), (HARAKA_LANE_LUT, 0.0));
1726 assert!((table_lane(6).0 - HARAKA_TABLE_LANE_LUT).abs() < 1e-6);
1727 assert_eq!(table_lane(6).1, HARAKA_TABLE_LANE_BRAM36);
1728 for n in 0..6 {
1729 assert!(table_lane(n + 1).0 < table_lane(n).0 && table_lane(n + 1).1 > table_lane(n).1);
1730 }
1731 assert!(table_rounds_built(6) && !table_rounds_built(3));
1732 }
1733
1734 /// Auto is never worse than any fixed table-round count, for the signer
1735 /// and the verify unit, in a LUT-rich and a BRAM-poor share.
1736 #[test]
1737 fn auto_table_rounds_is_the_best_fixed_count() {
1738 let cx = Context::default();
1739 let shares = [
1740 Share { lut: 600_000, dsp: 2_000, bram36: 600, uram: 900 },
1741 Share { lut: 300_000, dsp: 2_000, bram36: 1_700, uram: 900 },
1742 ];
1743 for share in shares {
1744 let rate = |p: Option<Plan>| p.map_or(0.0, |p| p.rate);
1745 let auto = rate(Dsig { table_rounds: AUTO_TABLE_ROUNDS, ..Dsig::default() }.plan(&share, &cx));
1746 let auto_v = rate(DsigVerify { table_rounds: AUTO_TABLE_ROUNDS, ..DsigVerify::default() }.plan(&share, &cx));
1747 for rounds in 0..=6 {
1748 assert!(auto >= rate(Dsig { table_rounds: rounds, ..Dsig::default() }.plan(&share, &cx)));
1749 assert!(auto_v >= rate(DsigVerify { table_rounds: rounds, ..DsigVerify::default() }.plan(&share, &cx)));
1750 }
1751 }
1752 }
1753
1754 /// The built scheme's derived numbers match the hardware constants.
1755 #[test]
1756 fn the_built_scheme_matches_the_hardware() {
1757 let h = Hbss::default();
1758 assert_eq!(h.chains(), crate::CHAINS as u32);
1759 assert_eq!(h.steps_per_key(), 204.0);
1760 assert_eq!(h.key_period(), config::KEY_PERIOD);
1761 assert_eq!(h.signature_bytes(), 1_642);
1762 assert_eq!(h.merkle_height(), crate::MERKLE_HEIGHT as u32);
1763 assert_eq!(Hbss { w: 16, ..Hbss::default() }.chains(), 35);
1764 }
1765
1766 /// Tables in URAM: on a share with little block RAM the signer and the
1767 /// verifier both get faster, never slower, and the plan still fits.
1768 #[test]
1769 fn table_uram_turns_spare_ultraram_into_speed() {
1770 let cx = Context::default();
1771 let rate = |p: Option<Plan>| p.map_or(0.0, |p| p.rate);
1772 let share = Share { lut: 600_000, dsp: 2_000, bram36: 900, uram: 900 };
1773 for rounds in [6, AUTO_TABLE_ROUNDS] {
1774 let bram = Dsig { table_rounds: rounds, table_uram: false, ..Dsig::default() };
1775 let uram = Dsig { table_uram: true, ..bram.clone() };
1776 let plan = uram.plan(&share, &cx).expect("a plan");
1777 assert!(plan.cost.fits(&share), "{plan:?}");
1778 assert!(plan.rate >= rate(bram.plan(&share, &cx)));
1779 let vbram = DsigVerify { table_rounds: rounds, table_uram: false, ..DsigVerify::default() };
1780 let vuram = DsigVerify { table_uram: true, ..vbram.clone() };
1781 let vplan = vuram.plan(&share, &cx).expect("a plan");
1782 assert!(vplan.cost.fits(&share), "{vplan:?}");
1783 assert!(vplan.rate >= rate(vbram.plan(&share, &cx)));
1784 }
1785 // Six table rounds per lane: block RAM caps the signer, URAM lifts it.
1786 let six = Dsig { table_rounds: 6, table_uram: false, ..Dsig::default() };
1787 let lifted = Dsig { table_uram: true, ..six.clone() }.plan(&share, &cx).unwrap();
1788 assert!(lifted.rate > rate(six.plan(&share, &cx)), "{}", lifted.detail);
1789 assert!(lifted.cost.uram > 0 && lifted.detail.contains("URAM of tables"));
1790 }
1791
1792 /// Monotonicity: more LUTs must not lower the planned rate.
1793 #[test]
1794 fn more_budget_never_gives_fewer_signatures() {
1795 let dsig = Dsig::default();
1796 let cx = Context::default();
1797 let mut last = 0.0;
1798 for lut in (100_000..=1_000_000).step_by(50_000) {
1799 let share = Share { lut, ..device() };
1800 let rate = dsig.plan(&share, &cx).map_or(0.0, |p| p.rate);
1801 assert!(rate >= last, "{lut}");
1802 last = rate;
1803 }
1804 }
1805
1806 /// A partial share of the chip's RAM still plans (the bug the TUI hit:
1807 /// S-box placement used to size itself for the whole device).
1808 #[test]
1809 fn a_quarter_of_the_chip_still_plans_a_signer() {
1810 let share = Share { lut: 230_000, dsp: 1_125, bram36: 504, uram: 240 };
1811 let plan = Dsig { sbox_ram: true, table_rounds: 0, ..Dsig::default() }.plan(&share, &Context::default()).expect("a plan");
1812 assert!(plan.cost.fits(&share), "{plan:?}");
1813 assert!(plan.rate > 0.0);
1814 }
1815
1816 /// The shared "message" knob reaches signer and verifier, adds its
1817 /// (estimated) LUTs, and keeps the signing rate.
1818 #[test]
1819 fn the_message_knob_is_shared_priced_and_flagged() {
1820 let mut signer = Dsig::default();
1821 let mut verify = DsigVerify::default();
1822 let share = Share { lut: 600_000, ..device() };
1823 let (base, _) = signer.cost(2, &share);
1824 let base_unit = verify.unit_cost();
1825 let at = |knobs: &[Knob]| knobs.iter().position(|k| k.name == "message").unwrap();
1826 let (si, vi) = (at(&signer.knobs()), at(&verify.knobs()));
1827 assert!(signer.knobs()[si].shared && verify.knobs()[vi].shared);
1828 // The "certificate checks" knob sets its own field (it used to fall
1829 // through to "table RAM").
1830 let ci = signer.knobs().iter().position(|k| k.name == "certificate checks").unwrap();
1831 signer.set_knob(ci, 1.0);
1832 assert!(signer.own_ed25519 && signer.table_uram);
1833 signer.set_knob(ci, 0.0);
1834 signer.set_knob(si, 1.0);
1835 verify.set_knob(vi, 1.0);
1836 assert!(signer.message64 && verify.message64);
1837 let (wide, _) = signer.cost(2, &share);
1838 assert_eq!(wide.lut, base.lut + 2 * MESSAGE64_SLICE_LUT as u32);
1839 assert_eq!(verify.unit_cost().lut, base_unit.lut + MESSAGE64_VERIFY_LUT as u32);
1840 assert_eq!(signer.rate(2, 205.0), Dsig::default().rate(2, 205.0), "same signing rate");
1841 }
1842
1843 /// The rate grows with every slice the share holds (not only at 2, 4,
1844 /// 8), and a cheaper chain hash buys more slices in the same LUTs.
1845 #[test]
1846 fn rate_scales_slice_by_slice_and_with_the_hash() {
1847 let cx = Context::default();
1848 let share = Share { lut: 600_000, ..device() };
1849 // LUT S-boxes on both sides: the comparison is about LUTs per slice.
1850 let haraka = Dsig { table_rounds: 0, ..Dsig::default() }.plan(&share, &cx).unwrap();
1851 let blake3 = Dsig { chain_hash: 1, table_rounds: 0, ..Dsig::default() }.plan(&share, &cx).unwrap();
1852 assert!(blake3.rate > haraka.rate, "{} vs {}", blake3.detail, haraka.detail);
1853 let slices = |p: &Plan| p.detail.split_whitespace().next().unwrap().parse::<u32>().unwrap();
1854 assert!((haraka.rate - f64::from(slices(&haraka)) * 205e6 / 68.0).abs() < 1.0);
1855 }
1856
1857 /// HORS plans nothing and says why; DSig's own Ed25519 core covers
1858 /// Ed25519 signing.
1859 #[test]
1860 fn hors_is_unavailable_and_own_core_covers_ed25519_sign() {
1861 let mut d = Dsig::default();
1862 d.set_knob(0, 1.0);
1863 assert!(d.plan(&device(), &Context::default()).is_none() && d.unavailable().is_some());
1864 let own = Dsig { own_ed25519: true, ..Dsig::default() };
1865 let plan = own.plan(&device(), &Context::default()).unwrap();
1866 assert_eq!(plan.covers, vec![Role::Ed25519Sign]);
1867 assert!(Dsig::default().plan(&device(), &Context::default()).unwrap().covers.is_empty());
1868 }
1869
1870 /// SHA-512 chains on the shared line demand 204 blocks per signature;
1871 /// on own lanes they demand none.
1872 #[test]
1873 fn shared_sha_chains_demand_line_blocks() {
1874 let cx = Context::default();
1875 let own = Dsig { chain_hash: 2, ..Dsig::default() };
1876 let shared = Dsig { sha_shared: true, ..own.clone() };
1877 let p = shared.plan(&device(), &cx).unwrap();
1878 assert_eq!(p.demands[0].per_second, p.rate * 204.0);
1879 assert!(own.plan(&device(), &cx).unwrap().demands.is_empty());
1880 }
1881
1882 /// The uBFT signer offers exactly the slice counts DSIG_SLICES builds.
1883 #[cfg(feature = "ubft")]
1884 #[test]
1885 fn ubft_signer_slices_are_the_buildable_counts() {
1886 let mut signer = DsigUbftSign::default();
1887 let knob = &signer.knobs()[0];
1888 assert_eq!(knob.choices, ["2", "4", "8"]);
1889 for (index, slices) in UBFT_SLICES.iter().enumerate() {
1890 signer.set_knob(0, index as f64);
1891 assert_eq!(signer.slices, *slices);
1892 assert_eq!(signer.knobs()[0].value, index as f64);
1893 }
1894 signer.set_knob(0, 9.0);
1895 assert_eq!(signer.slices, 8, "out of range clamps to the last choice");
1896 }
1897
1898 /// uBFT defaults to profile 2; profile 1 is buildable only when the
1899 /// gate allows it, in both services, and the flags say which.
1900 #[cfg(feature = "ubft")]
1901 #[test]
1902 fn ubft_defaults_to_profile_2_and_gates_profile_1() {
1903 let flag = |flags: &BuildFlags, key: &str| flags.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone());
1904 let cx = Context::default();
1905 let mut signer = DsigUbftSign::default();
1906 let mut verify = DsigUbftVerify::default();
1907 let components: [&mut dyn Component; 2] = [&mut signer, &mut verify];
1908 for c in components {
1909 let knobs = c.knobs();
1910 let at = knobs.iter().position(|k| k.name == "message").unwrap();
1911 assert!(knobs[at].shared && knobs[at + 1].shared && knobs[at + 1].name == "profile 1 for uBFT");
1912 assert_eq!(knobs[at].value, 1.0, "profile 2 by default");
1913 let plan = c.plan(&device(), &cx).unwrap();
1914 let f = c.build_flags(&plan);
1915 assert_eq!(flag(&f, "buildable").as_deref(), Some("true"));
1916 assert_eq!(flag(&f, "dsig.DSIG_MESSAGE_BYTES").as_deref(), Some("64"));
1917 assert_eq!(flag(&f, "dsig.allow_profile1_ubft").as_deref(), Some("false"));
1918 // Profile 1, refused: priced, but not buildable, with the reason.
1919 c.set_knob(at, 0.0);
1920 let plan = c.plan(&device(), &cx).unwrap();
1921 let f = c.build_flags(&plan);
1922 assert_eq!(flag(&f, "buildable").as_deref(), Some("false"));
1923 assert!(flag(&f, "not_buildable").unwrap().contains("U33"));
1924 assert_eq!(flag(&f, "dsig.DSIG_MESSAGE_BYTES").as_deref(), Some("16"));
1925 // Profile 1, allowed.
1926 c.set_knob(at + 1, 1.0);
1927 let f = c.build_flags(&plan);
1928 assert_eq!(flag(&f, "buildable").as_deref(), Some("true"));
1929 assert_eq!(flag(&f, "dsig.allow_profile1_ubft").as_deref(), Some("true"));
1930 }
1931 // Profile 2 prices its extra digest block.
1932 let p1 = DsigUbftSign { message: UbftMessage { message64: false, allow_profile1: true }, ..DsigUbftSign::default() };
1933 let lut = |c: &dyn Component| c.plan(&device(), &cx).unwrap().cost.lut;
1934 assert!(lut(&DsigUbftSign::default()) > lut(&p1));
1935 }
1936}