quantica/slh_dsa/fors.rs
1//! FORS: Forest of Random Subsets (FIPS 205, Algorithms 14-17).
2//!
3//! FORS is a few-time signature scheme that signs the message digest by selecting one
4//! leaf from each of `k` independent binary trees of height `a`. Each tree has `2^a`
5//! leaves; the message digest is split into `k` indices of `a` bits each, and the
6//! signature reveals the secret leaf value plus an authentication path for each tree.
7//!
8//! In the SLH-DSA hierarchy, FORS sits between the message hash and the hypertree:
9//! the FORS public key (a compression of the `k` tree roots) becomes the message
10//! that the hypertree signs. This two-level structure is what makes SLH-DSA stateless
11//! -- FORS absorbs the message entropy, and the hypertree provides many-time security.
12
13use super::SlhDsaError;
14use super::address::{Adrs, FORS_PRF, FORS_ROOTS, FORS_TREE};
15use super::hash;
16use super::params::Params;
17use crate::secret::SecretBytes;
18use alloc::vec::Vec;
19
20/// Generate a single FORS secret key value at leaf index `idx`.
21///
22/// Implements Algorithm 14 of FIPS 205. Derives the secret value deterministically
23/// from `SK.seed` using the PRF, domain-separated by a FORS_PRF address.
24fn fors_sk_gen<P: Params>(sk_seed: &[u8], pk_seed: &[u8], adrs: &mut Adrs, idx: u32) -> Vec<u8> {
25 let mut sk_adrs = adrs.clone();
26 sk_adrs.set_type_and_clear(FORS_PRF);
27 sk_adrs.set_key_pair_address(adrs.get_key_pair_address());
28 sk_adrs.set_tree_index(idx);
29 hash::prf::<P>(pk_seed, sk_seed, &sk_adrs)
30}
31
32/// Compute the root of a FORS subtree of height `z` at leaf index `i`.
33///
34/// Implements Algorithm 15 of FIPS 205 using an iterative **treehash**
35/// (BDS-style) stack traversal: leaves are produced in order, and any
36/// time the two nodes on top of the stack have the same height they
37/// are combined with `H` and pushed back as a single higher-up node.
38/// Peak live memory is `O(z)` nodes (at most `z + 1` stack entries),
39/// instead of the `2^z` materialized leaves of the naïve approach.
40///
41/// For `z = 0` this degenerates to a single `F` evaluation of the
42/// secret leaf value. Used during signing to compute authentication
43/// path nodes (sibling subtree roots).
44///
45/// # Side-channel / memory posture
46///
47/// The iterative treehash is also the SPA-hardening posture for FORS.
48/// A recursive implementation would expose a memory-access envelope
49/// that matches the tree geometry, indirectly leaking the FORS digit
50/// encoded into `i`. The iterative variant instead keeps a BDS stack
51/// of `z + 1` nodes and walks the tree with a loop counter that is
52/// independent of the secret. See
53/// `doc/sca/countermeasures/slh_dsa.rst`, section *Memory / stack-
54/// timing — iterative treehash + streaming signature*, for the full
55/// threat analysis.
56///
57/// RAM impact (SLH-DSA-SHAKE-256s, `z = a - 1 = 13`, `n = 32`):
58///
59/// * naïve: `2^13 * 32 = 256 KiB` leaf buffer, peaking higher with
60/// the transient `new_nodes` vector during the climb;
61/// * treehash: `14 * 32 ≈ 448 B` on the stack of node bytes.
62pub fn fors_node<P: Params>(sk_seed: &[u8], pk_seed: &[u8], i: u32, z: u32, adrs: &mut Adrs) -> Vec<u8> {
63 if z == 0 {
64 // Leaf: hash the secret key value
65 let sk = fors_sk_gen::<P>(sk_seed, pk_seed, adrs, i);
66 let kp = adrs.get_key_pair_address();
67 adrs.set_type_and_clear(FORS_TREE);
68 adrs.set_key_pair_address(kp);
69 adrs.set_tree_height(0);
70 adrs.set_tree_index(i);
71 return hash::f_hash::<P>(pk_seed, adrs, &sk);
72 }
73
74 // Iterative treehash: stream the `2^z` leaves, merge equal-height
75 // tops into the parent node immediately. Peak depth ≤ z + 1.
76 let num_leaves = 1u32 << z;
77 let base = i;
78 let kp = adrs.get_key_pair_address();
79
80 // (node_bytes, height) — nodes at the "left edge" of what still
81 // needs to be merged. Height 0 = leaf.
82 let mut stack: Vec<(Vec<u8>, u32)> = Vec::with_capacity((z + 1) as usize);
83
84 for j in 0..num_leaves {
85 let leaf_idx = base + j;
86
87 // Produce leaf node.
88 let sk = fors_sk_gen::<P>(sk_seed, pk_seed, adrs, leaf_idx);
89 adrs.set_type_and_clear(FORS_TREE);
90 adrs.set_key_pair_address(kp);
91 adrs.set_tree_height(0);
92 adrs.set_tree_index(leaf_idx);
93 let mut node = hash::f_hash::<P>(pk_seed, adrs, &sk);
94 let mut height = 0u32;
95 let mut tree_idx = leaf_idx;
96
97 // Merge while the top of stack is at the same height as `node`.
98 // Each merge pops the left sibling and produces the parent.
99 while let Some(&(_, top_h)) = stack.last() {
100 if top_h != height {
101 break;
102 }
103 let (left, _) = stack.pop().expect("stack non-empty in merge loop");
104 tree_idx >>= 1;
105 height += 1;
106 adrs.set_type_and_clear(FORS_TREE);
107 adrs.set_key_pair_address(kp);
108 adrs.set_tree_height(height);
109 adrs.set_tree_index(tree_idx);
110 node = hash::hash_h::<P>(pk_seed, adrs, &left, &node);
111 }
112 stack.push((node, height));
113 }
114
115 // With `2^z` leaves processed, the stack holds exactly one entry
116 // at height `z`: the subtree root.
117 stack.pop().expect("treehash produces exactly one root").0
118}
119
120/// Sign a message digest using FORS.
121///
122/// Implements Algorithm 16 of FIPS 205. The message digest `md` contains `k * a` bits,
123/// which are split into `k` indices of `a` bits each. For each of the `k` FORS trees,
124/// the signature includes:
125/// - The secret leaf value at the selected index (n bytes)
126/// - An authentication path of `a` sibling nodes (a * n bytes)
127///
128/// The total FORS signature is `k * (1 + a) * n` bytes.
129///
130/// `adrs_template` is read-only — a local clone is taken internally for the
131/// scratch state mutated during signing. Returns
132/// `Err(SlhDsaError::FaultDetected)` only when `sca-fors-indices-check`
133/// (T1-E) is enabled and the integrity check on the FORS index vector
134/// fails; otherwise always `Ok`.
135pub fn fors_sign<P: Params>(
136 md: &[u8],
137 sk_seed: &[u8],
138 pk_seed: &[u8],
139 adrs_template: &Adrs,
140) -> Result<Vec<u8>, SlhDsaError> {
141 let mut sig_fors = alloc::vec![0u8; P::K * (1 + P::A) * P::N];
142 fors_sign_into::<P>(md, sk_seed, pk_seed, adrs_template, &mut sig_fors)?;
143 Ok(sig_fors)
144}
145
146/// Streaming variant of [`fors_sign`] — writes the `K * (1 + A) * N`
147/// byte FORS signature directly into the start of `out` (which must
148/// be at least that size).
149///
150/// Each per-tree block is written as `sk (N) || auth path (A * N)`
151/// directly into its slot inside `out`, avoiding the transient
152/// per-call `Vec<u8>` heap buffer.
153///
154/// `adrs_template` is read-only — a local clone is taken internally so the
155/// caller's address state is not mutated. The save/restore of
156/// `key_pair_address` inside the per-tree loop already guarantees that the
157/// function does not depend on its own residual mutation across iterations,
158/// so the local clone is semantically equivalent to the previous in-place
159/// mutation pattern.
160///
161/// # Feature: `sca-fors-dummy-siblings` (T1-D)
162///
163/// When enabled, the inner per-FORS-tree loop is replaced by a single
164/// **full-tree BDS streaming traversal**: for each FORS tree `i`, all
165/// `2^A` leaves at positions `[base, base + 2^A)` are produced in
166/// fixed order (`base = i * 2^A`), regardless of the secret index
167/// `idx`. The leaf secret and the `A` auth-path sibling nodes are
168/// extracted **branchlessly** during the climb, via `silentops::ct_copy`
169/// guarded by `silentops::ct_eq_u32`. The address sequence absorbed by
170/// Keccak (`set_tree_index(base + 0..base + 2^A - 1)` and the matching
171/// internal-merge addresses) is then a deterministic function of `i`
172/// only — the per-bit template oracle of Kannwischer COSADE 2018
173/// (ePrint 2018/673) collapses, since no
174/// `idx`-dependent address ever reaches the PRF. Cost: roughly 2×
175/// the FORS hash count vs the FIPS-205 default path (one full
176/// tree vs the auth-path subtrees that sum to ~`2^A - 1` leaves).
177/// Output bytes are byte-identical to the default path on every
178/// input.
179///
180/// # Historical correction
181///
182/// The earlier SCA-annex draft described T1-D as "compute both
183/// possible siblings (s=0 and s=1) at fixed positions and select
184/// branchlessly". That framing is wrong: FIPS-205 Algorithm 16 has
185/// `s = floor(idx / 2^j) XOR 1` (multi-bit, taking values in
186/// `[0, 2^(A-j))` at level `j`), so the sibling sits at one of
187/// `2^(A-j)` `idx`-dependent positions, not at a fixed pair. The
188/// only mechanism that produces an `idx`-independent address
189/// sequence at the same asymptotic cost is the full-tree streaming
190/// traversal implemented here.
191pub fn fors_sign_into<P: Params>(
192 md: &[u8],
193 sk_seed: &[u8],
194 pk_seed: &[u8],
195 adrs_template: &Adrs,
196 out: &mut [u8],
197) -> Result<(), SlhDsaError> {
198 let entry_size = (1 + P::A) * P::N;
199 debug_assert!(out.len() >= P::K * entry_size);
200
201 let indices = message_to_indices::<P>(md);
202 let mut adrs = adrs_template.clone();
203 let kp = adrs.get_key_pair_address();
204
205 for i in 0..P::K {
206 let idx = indices[i];
207 let slot = &mut out[i * entry_size..(i + 1) * entry_size];
208 let (leaf_slot, auth_slot) = slot.split_at_mut(P::N);
209 let base = (i as u32) * (1u32 << P::A);
210
211 #[cfg(feature = "sca-fors-dummy-siblings")]
212 {
213 // T1-D — full-tree BDS streaming. Absorbed Keccak address
214 // sequence is `set_tree_index(base + k)` for `k ∈ [0, 2^A)`,
215 // plus the matching internal-merge addresses, all
216 // idx-independent. Leaf secret and auth-path siblings are
217 // extracted branchlessly via `ct_copy` + `ct_eq_u32`.
218 let mut stack: Vec<(Vec<u8>, u32)> = Vec::with_capacity(P::A + 1);
219 let num_leaves = 1u32 << P::A;
220
221 for k in 0..num_leaves {
222 let leaf_idx = base + k;
223
224 // Leaf secret value (absorbs FORS_PRF address with tree_index = leaf_idx).
225 let sk = fors_sk_gen::<P>(sk_seed, pk_seed, &mut adrs, leaf_idx);
226 // Branchlessly save sk to leaf_slot if k == idx.
227 silentops::ct_copy(leaf_slot, &sk, silentops::ct_eq_u32(k, idx));
228
229 // f_hash of the leaf at height 0 (absorbs FORS_TREE address).
230 adrs.set_type_and_clear(FORS_TREE);
231 adrs.set_key_pair_address(kp);
232 adrs.set_tree_height(0);
233 adrs.set_tree_index(leaf_idx);
234 let mut node = hash::f_hash::<P>(pk_seed, &mut adrs, &sk);
235 let mut height: u32 = 0;
236 let mut local_pos: u32 = k;
237
238 // Save height-0 node to auth_slot[0..N] if it is the
239 // sibling-at-level-0 of idx (i.e. local_pos == idx XOR 1).
240 silentops::ct_copy(&mut auth_slot[0..P::N], &node, silentops::ct_eq_u32(local_pos, idx ^ 1));
241
242 // BDS merge — combine same-height tops.
243 while let Some(&(_, top_h)) = stack.last() {
244 if top_h != height {
245 break;
246 }
247 let (left, _) = stack.pop().expect("non-empty");
248 local_pos >>= 1;
249 height += 1;
250
251 // Absolute tree_index for the merged node at height h
252 // is `(base + leftmost_leaf_offset) >> h`
253 // = i * 2^(A - h) + local_pos.
254 let absolute_pos = (i as u32) * (1u32 << (P::A - height as usize)) + local_pos;
255 adrs.set_type_and_clear(FORS_TREE);
256 adrs.set_key_pair_address(kp);
257 adrs.set_tree_height(height);
258 adrs.set_tree_index(absolute_pos);
259 node = hash::hash_h::<P>(pk_seed, &mut adrs, &left, &node);
260
261 // Save to auth_slot[h] if this merged node is the
262 // sibling-at-level-h of idx. The final merge produces
263 // the FORS root at height A — skipped (h == A => no
264 // auth-path slot).
265 if (height as usize) < P::A {
266 let sibling_pos = (idx >> height) ^ 1;
267 let slot_off = (height as usize) * P::N;
268 silentops::ct_copy(
269 &mut auth_slot[slot_off..slot_off + P::N],
270 &node,
271 silentops::ct_eq_u32(local_pos, sibling_pos),
272 );
273 }
274 }
275 stack.push((node, height));
276 }
277 // The stack now holds exactly one element — the FORS root at
278 // height A. The caller will re-derive the root from the
279 // produced signature via `fors_pk_from_sig`, so we drop it.
280 drop(stack);
281 }
282
283 #[cfg(not(feature = "sca-fors-dummy-siblings"))]
284 {
285 // FIPS-205 Algorithm 16 — single sibling per level.
286 // Leak surface: `set_tree_index((base + ((idx >> j) XOR 1) * 2^j) + k)` for
287 // each level j and each leaf k of the sibling subtree, idx-dependent.
288 let sk = fors_sk_gen::<P>(sk_seed, pk_seed, &mut adrs, base + idx);
289 leaf_slot.copy_from_slice(&sk);
290
291 for j in 0..P::A {
292 let s = (idx >> j) ^ 1;
293 let node = fors_node::<P>(sk_seed, pk_seed, base + s * (1 << j), j as u32, &mut adrs);
294 adrs.set_type_and_clear(FORS_TREE);
295 adrs.set_key_pair_address(kp);
296 auth_slot[j * P::N..(j + 1) * P::N].copy_from_slice(&node);
297 }
298 }
299 }
300
301 // T1-E — recompute the FORS-index vector from `md` and CT-compare to
302 // the one we just consumed. Catches a fault that flips a bit during
303 // the original `message_to_indices` call (or anywhere upstream in
304 // the digest extraction). Cost: one extra `base_2b` (~K * A / 8
305 // bytes of bit-extraction, no hashing).
306 #[cfg(feature = "sca-fors-indices-check")]
307 fors_indices_consistency_check::<P>(md, &indices)?;
308
309 Ok(())
310}
311
312/// T1-E — verify the FORS-index vector used during signing matches the
313/// one derivable from the digest. Re-derives `message_to_indices(md)`
314/// and compares to `used` under `silentops::ct_eq` on the little-endian
315/// serialisation. Returns `Err(SlhDsaError::FaultDetected)` on any
316/// mismatch (same error envelope as T1-C's recompute-and-compare).
317///
318/// `pub(crate)` so the `#[cfg(test)]` module can drive it with
319/// synthetic divergent buffers without needing real fault injection.
320#[cfg(feature = "sca-fors-indices-check")]
321pub(crate) fn fors_indices_consistency_check<P: Params>(md: &[u8], used: &[u32]) -> Result<(), SlhDsaError> {
322 let recomputed = message_to_indices::<P>(md);
323 if recomputed.len() != used.len() {
324 return Err(SlhDsaError::FaultDetected);
325 }
326 let used_bytes: Vec<u8> = used.iter().flat_map(|x| x.to_le_bytes()).collect();
327 let recomputed_bytes: Vec<u8> = recomputed.iter().flat_map(|x| x.to_le_bytes()).collect();
328 if silentops::ct_eq(&used_bytes, &recomputed_bytes) != 1 {
329 return Err(SlhDsaError::FaultDetected);
330 }
331 Ok(())
332}
333
334/// Compute a FORS public key from a FORS signature — constant-time.
335///
336/// Implements Algorithm 17 of FIPS 205. For each of the `k` FORS trees,
337/// hashes the revealed secret leaf, then walks up the authentication path
338/// to recover the tree root. The `k` roots are compressed into a single
339/// `n`-byte public key via `T_k`. If the signature is valid, the returned
340/// public key matches the one produced by the signer.
341///
342/// # Side-channel posture
343///
344/// The secret-dependent argument ordering of `hash_h` along each
345/// authentication-path level is resolved by a branchless byte-wise
346/// `silentops::ct_select_u8` cswap rather than a Rust `if`. Address bytes
347/// and the `tree_index` written into `adrs` are identical in both original
348/// branches, so they need no extra masking. The per-iteration scratch is
349/// drop-zeroized via `silentops::ct_zeroize` before return.
350///
351/// In the *verifier* path the FORS-tree digit `idx` is reconstructed from
352/// the publicly transmitted message digest, so a timing leak on the digit
353/// would be harmless. But the same routine is reused inside the *signer*
354/// post-signing redundancy check (item ``T1-C`` of the SLH-DSA roadmap —
355/// see [`fors_sign_into_redundant`]). At that point of the signing flow,
356/// `idx` is derived from values (the freshly sampled randomizer `R`, the
357/// secret `SK.prf`) that have **not yet** been published, so a control-flow
358/// leak on the digit would become a secret-key leak. A single CT
359/// implementation across both paths removes the foot-gun of a future call
360/// site picking a variable-time variant by autocomplete.
361///
362/// # References
363///
364/// * Castelnovi-Martinelli-Prest, *Grafting Trees: a Fault Attack against
365/// the SPHINCS framework*, ePrint 2018/102 — motivates T1-C, which in
366/// turn motivates this CT routine.
367/// * Adiletta et al., *SLasH-DSA: Breaking SLH-DSA Using an Extensible
368/// End-to-End Rowhammer Framework*, arXiv 2509.13048 (Aug 2025) —
369/// software realisation of the same attack class.
370/// * Genêt, *On Protecting SPHINCS+ Against Fault Attacks*, TCHES 2023(3)
371/// (ePrint 2023/042) — recommends the recompute-and-compare redundancy
372/// that consumes this routine.
373pub fn fors_pk_from_sig<P: Params>(sig_fors: &[u8], md: &[u8], pk_seed: &[u8], adrs_template: &Adrs) -> Vec<u8> {
374 let indices = message_to_indices::<P>(md);
375 let mut adrs = adrs_template.clone();
376 let kp = adrs.get_key_pair_address();
377
378 let entry_size = (1 + P::A) * P::N;
379 let mut roots = Vec::with_capacity(P::K * P::N);
380
381 // Per-iteration scratch for the CT argument-swap.
382 let mut left = alloc::vec![0u8; P::N];
383 let mut right = alloc::vec![0u8; P::N];
384
385 for i in 0..P::K {
386 let idx = indices[i];
387 let offset = i * entry_size;
388 let sk = &sig_fors[offset..offset + P::N];
389 let auth = &sig_fors[offset + P::N..offset + entry_size];
390
391 // Leaf from sk — same as the non-CT path.
392 adrs.set_type_and_clear(FORS_TREE);
393 adrs.set_key_pair_address(kp);
394 adrs.set_tree_height(0);
395 adrs.set_tree_index((i as u32) * (1u32 << P::A) + idx);
396 let mut node = hash::f_hash::<P>(pk_seed, &mut adrs, sk);
397
398 // Climb the authentication path — argument order to `hash_h` is
399 // selected by a branchless `ct_select_u8` cswap.
400 for j in 0..P::A {
401 let auth_j = &auth[j * P::N..(j + 1) * P::N];
402
403 // bit = 0 → `node` is the left child → hash_h(node, auth_j)
404 // bit = 1 → `node` is the right child → hash_h(auth_j, node)
405 let bit = ((idx >> j) & 1) as u8;
406 for k in 0..P::N {
407 // ct_select_u8(a, b, cond) returns a if cond != 0 else b.
408 left[k] = silentops::ct_select_u8(auth_j[k], node[k], bit);
409 right[k] = silentops::ct_select_u8(node[k], auth_j[k], bit);
410 }
411
412 // tree_index is identical in both original branches.
413 adrs.set_type_and_clear(FORS_TREE);
414 adrs.set_key_pair_address(kp);
415 adrs.set_tree_height((j + 1) as u32);
416 adrs.set_tree_index(((i as u32) * (1u32 << P::A) + idx) >> (j + 1));
417
418 node = hash::hash_h::<P>(pk_seed, &mut adrs, &left, &right);
419 }
420
421 roots.extend_from_slice(&node);
422 }
423
424 // Per-loop scratch is wiped — the cswap copies are derived from
425 // publish-ready inputs once the signer commits to the signature, but
426 // the contract of this CT routine is that nothing of `idx`-shape
427 // leaks. Zeroizing keeps that contract strict.
428 silentops::ct_zeroize(&mut left);
429 silentops::ct_zeroize(&mut right);
430
431 // Compress k roots into one public key — the roots themselves are not
432 // secret-shaped at this stage.
433 let mut fors_pk_adrs = adrs.clone();
434 fors_pk_adrs.set_type_and_clear(FORS_ROOTS);
435 fors_pk_adrs.set_key_pair_address(kp);
436
437 hash::t_l::<P>(pk_seed, &fors_pk_adrs, &roots)
438}
439
440/// Recompute-and-compare FORS signing — T1-C redundancy against
441/// single-fault grafting-tree forgery.
442///
443/// Signs the FORS component twice into independent scratch buffers,
444/// derives the FORS public key from each signature via the
445/// constant-time [`fors_pk_from_sig`], then compares **both
446/// signatures and both derived public keys** under
447/// `silentops::ct_eq`. On any mismatch the function returns
448/// [`SlhDsaError::FaultDetected`] *without* writing anything into
449/// `out` — the faulted signature never leaves the device. On a clean
450/// run, the validated signature is copied into the caller's `out`
451/// buffer and the FORS public key (reusable by the hypertree
452/// signing step) is returned.
453///
454/// # Threat addressed
455///
456/// Castelnovi-Martinelli-Prest *Grafting Trees* (PQCrypto 2018,
457/// ePrint 2018/102) showed that a single random fault during any FORS
458/// hash chain produces a valid-looking signature under a different
459/// FORS root; after collecting a handful of such signatures an
460/// attacker forges arbitrary messages. Genêt *On Protecting SPHINCS+
461/// Against Fault Attacks* (TCHES 2023(3), ePrint 2023/042) recommends
462/// recompute-and-compare at signing time as the canonical defence.
463/// The threat is no longer hypothetical-physical: Adiletta et al.
464/// *SLasH-DSA* (arXiv 2509.13048, Aug 2025) realised it on stock
465/// OpenSSL with software-only Rowhammer in 1–8 h.
466///
467/// # Design rationale
468///
469/// Comparing *both* the signature bytes and the derived public keys
470/// is intentional defence-in-depth. A fault that corrupts auth-path
471/// bytes might round-trip to the same FORS root under the verifier
472/// path; the byte-level `ct_eq` catches that case. Symmetrically, a
473/// fault that lands inside the second [`fors_pk_from_sig`]
474/// derivation would not be caught by a signature-bytes-only check;
475/// the pk `ct_eq` catches that case. Both checks together cost a
476/// single extra `ct_eq` and are paid only on the slow path that
477/// already runs the FORS signer twice.
478///
479/// # Abort posture (vs ML-KEM's CT-substitute)
480///
481/// Unlike ML-KEM's double-decaps + branchless fault-fallback
482/// (`quantica::ml_kem::kem::decaps`), this routine **aborts**
483/// rather than substituting a fault-derived key. The asymmetry is
484/// deliberate: a KEM must always return a shared secret (or the
485/// caller cannot proceed at all), while a signer that detects a
486/// fault is allowed — required, per Genêt 2023 — to refuse to
487/// emit, so the faulted signature does not propagate.
488///
489/// # Memory
490///
491/// One [`SecretBytes`] scratch of length `fors_sig_len = K * (1 + A)
492/// * N` (≈ 10 KiB for SLH-DSA-SHAKE-256s). Heap-allocated so the
493/// stack budget on the M0 baseline stays honest. The scratch is
494/// drop-zeroized on both the success and the abort path.
495pub fn fors_sign_into_redundant<P: Params>(
496 md: &[u8],
497 sk_seed: &[u8],
498 pk_seed: &[u8],
499 adrs_template: &Adrs,
500 out: &mut [u8],
501) -> Result<Vec<u8>, SlhDsaError> {
502 let sig_len = P::K * (1 + P::A) * P::N;
503 debug_assert!(out.len() >= sig_len);
504
505 // Two independent signings. `SecretBytes` ensures Drop-zeroize
506 // even if we return early via the `?`-less Err path below.
507 let mut sig1 = SecretBytes::from_vec(alloc::vec![0u8; sig_len]);
508 let mut sig2 = SecretBytes::from_vec(alloc::vec![0u8; sig_len]);
509
510 // Both FORS routines take an immutable address template and clone
511 // internally for their own scratch, so `adrs_template` is reused as-is
512 // across all four calls — four independent address-state pipelines
513 // remain inside the four hashings, with zero caller-side cloning.
514 fors_sign_into::<P>(md, sk_seed, pk_seed, adrs_template, &mut sig1)?;
515 fors_sign_into::<P>(md, sk_seed, pk_seed, adrs_template, &mut sig2)?;
516
517 let pk1 = fors_pk_from_sig::<P>(sig1.as_bytes(), md, pk_seed, adrs_template);
518 let pk2 = fors_pk_from_sig::<P>(sig2.as_bytes(), md, pk_seed, adrs_template);
519
520 // CT-compare both surfaces. Mismatch on either aborts.
521 fors_redundancy_compare(sig1.as_bytes(), sig2.as_bytes(), &pk1, &pk2)?;
522
523 // Commit the validated signature.
524 out[..sig_len].copy_from_slice(sig1.as_bytes());
525
526 Ok(pk1)
527}
528
529/// Internal — apply the two `silentops::ct_eq` checks of the T1-C
530/// redundancy and produce the verdict.
531///
532/// Factored out so the test suite can exercise the comparison logic
533/// with synthetically divergent inputs without injecting a fault into
534/// the FORS signing primitive itself.
535#[inline]
536fn fors_redundancy_compare(sig1: &[u8], sig2: &[u8], pk1: &[u8], pk2: &[u8]) -> Result<(), SlhDsaError> {
537 // `silentops::ct_eq` returns 1 on equality, 0 otherwise.
538 let sigs_equal = silentops::ct_eq(sig1, sig2) == 1;
539 let pks_equal = silentops::ct_eq(pk1, pk2) == 1;
540 if !sigs_equal || !pks_equal {
541 return Err(SlhDsaError::FaultDetected);
542 }
543 Ok(())
544}
545
546/// Split a message digest `md` (`k * a` bits) into `k` unsigned integers of `a` bits each.
547///
548/// Uses [`base_2b`](super::wots::base_2b) to extract the indices. Each index selects
549/// one leaf in its corresponding FORS tree.
550fn message_to_indices<P: Params>(md: &[u8]) -> Vec<u32> {
551 // Use base_2b with b=a to extract k values
552 // md contains ceil(k*a / 8) bytes; we extract k values of a bits each.
553 super::wots::base_2b(md, P::A, P::K)
554}
555
556#[cfg(test)]
557mod tests {
558 use super::super::address::{Adrs, FORS_TREE};
559 use super::super::params::{Params, Shake128f, Shake128s};
560 use super::*;
561
562 /// Sign FORS with deterministic seeds, then re-derive the FORS public
563 /// key from the produced signature; assert that two back-to-back
564 /// derivations yield byte-identical output and that the output has the
565 /// expected `N`-byte width. Smoke test for the sign / pk-from-sig
566 /// round trip on every parameter set we exercise.
567 fn round_trip<P: Params>(seed_byte: u8, message_byte: u8) {
568 let sk_seed = alloc::vec![seed_byte; P::N];
569 let pk_seed = alloc::vec![seed_byte.wrapping_add(0x80); P::N];
570
571 // Synthesise a `digest` long enough for `message_to_indices` to
572 // extract K * A bits worth of FORS indices.
573 let md_bytes = (P::K * P::A + 7) / 8;
574 let md: Vec<u8> = (0..md_bytes).map(|i| message_byte.wrapping_add(i as u8)).collect();
575
576 let mut sig_fors = alloc::vec![0u8; P::K * (1 + P::A) * P::N];
577 let mut adrs = Adrs::new();
578 adrs.set_type_and_clear(FORS_TREE);
579 adrs.set_key_pair_address(0);
580 // FORS routines take an immutable template; one address suffices
581 // for the whole test.
582 super::fors_sign_into::<P>(&md, &sk_seed, &pk_seed, &adrs, &mut sig_fors)
583 .expect("round-trip — clean signing must succeed");
584
585 let pk_a = super::fors_pk_from_sig::<P>(&sig_fors, &md, &pk_seed, &adrs);
586 let pk_b = super::fors_pk_from_sig::<P>(&sig_fors, &md, &pk_seed, &adrs);
587
588 assert_eq!(pk_a.len(), P::N, "FORS pk must be N bytes wide");
589 assert_eq!(pk_a, pk_b, "fors_pk_from_sig must be deterministic");
590 }
591
592 #[test]
593 fn fors_pk_from_sig_round_trip_shake128s() {
594 // Several seed/message combinations to exercise the per-tree
595 // bit-by-bit cswap (idx bits vary across runs).
596 round_trip::<Shake128s>(0x00, 0x00);
597 round_trip::<Shake128s>(0x55, 0xAA);
598 round_trip::<Shake128s>(0xFF, 0xFF);
599 round_trip::<Shake128s>(0x42, 0x13);
600 }
601
602 #[test]
603 fn fors_pk_from_sig_round_trip_shake128f() {
604 round_trip::<Shake128f>(0x00, 0x00);
605 round_trip::<Shake128f>(0x55, 0xAA);
606 round_trip::<Shake128f>(0xFF, 0xFF);
607 }
608
609 // ===========================================================
610 // T1-C — fors_sign_into_redundant equivalence + negative tests
611 // ===========================================================
612
613 /// Drive `fors_sign_into_redundant` on the same inputs as the
614 /// classic `fors_sign_into`, and check that:
615 /// 1. the validated signature is byte-equal to the non-redundant
616 /// signature (clean-run equivalence — no fault, no divergence);
617 /// 2. the returned FORS pk matches the standalone `fors_pk_from_sig`
618 /// derivation from the produced signature.
619 fn redundancy_equivalence<P: Params>(seed_byte: u8, message_byte: u8) {
620 let sk_seed = alloc::vec![seed_byte; P::N];
621 let pk_seed = alloc::vec![seed_byte.wrapping_add(0x80); P::N];
622
623 let md_bytes = (P::K * P::A + 7) / 8;
624 let md: Vec<u8> = (0..md_bytes).map(|i| message_byte.wrapping_add(i as u8)).collect();
625
626 let sig_len = P::K * (1 + P::A) * P::N;
627
628 // Reference: classic non-redundant path.
629 let mut sig_ref = alloc::vec![0u8; sig_len];
630 // FORS routines take an immutable template; one address suffices
631 // for both the reference and the redundant paths plus the pk-check.
632 let mut adrs = Adrs::new();
633 adrs.set_type_and_clear(FORS_TREE);
634 adrs.set_key_pair_address(0);
635
636 super::fors_sign_into::<P>(&md, &sk_seed, &pk_seed, &adrs, &mut sig_ref)
637 .expect("redundancy_equivalence — clean reference sign must succeed");
638
639 // Redundant path — must succeed and produce the same bytes.
640 let mut sig_red = alloc::vec![0u8; sig_len];
641 let pk_red = super::fors_sign_into_redundant::<P>(&md, &sk_seed, &pk_seed, &adrs, &mut sig_red)
642 .expect("clean run must succeed");
643
644 assert_eq!(
645 sig_red, sig_ref,
646 "redundant signature must equal the non-redundant signature on a clean run"
647 );
648
649 // pk returned by the redundant path must equal the standalone
650 // derivation from the produced signature.
651 let pk_classic = super::fors_pk_from_sig::<P>(&sig_red, &md, &pk_seed, &adrs);
652
653 assert_eq!(pk_red, pk_classic, "redundant-returned pk must match fors_pk_from_sig");
654 }
655
656 #[test]
657 fn fors_sign_into_redundant_matches_reference_shake128s() {
658 redundancy_equivalence::<Shake128s>(0x00, 0x00);
659 redundancy_equivalence::<Shake128s>(0x55, 0xAA);
660 redundancy_equivalence::<Shake128s>(0xFF, 0xFF);
661 }
662
663 #[test]
664 fn fors_sign_into_redundant_matches_reference_shake128f() {
665 redundancy_equivalence::<Shake128f>(0x00, 0x00);
666 redundancy_equivalence::<Shake128f>(0x55, 0xAA);
667 }
668
669 /// Negative test — drive `fors_redundancy_compare` (the internal
670 /// helper that produces the abort verdict) with synthetically
671 /// divergent buffers and check that each kind of mismatch
672 /// (signature only, pk only, both) surfaces as `FaultDetected`.
673 /// We do not need to inject a real fault into the FORS signer
674 /// for this — exercising the comparison logic in isolation is
675 /// what catches a regression in the abort path.
676 #[test]
677 fn fors_redundancy_compare_detects_divergence() {
678 let sig_a = alloc::vec![0x42u8; 32];
679 let sig_b_eq = sig_a.clone();
680 let mut sig_b_diff = sig_a.clone();
681 sig_b_diff[5] ^= 0x01;
682
683 let pk_a = alloc::vec![0xAAu8; 16];
684 let pk_b_eq = pk_a.clone();
685 let mut pk_b_diff = pk_a.clone();
686 pk_b_diff[3] ^= 0x80;
687
688 // 1. all four buffers agree → Ok.
689 assert!(super::fors_redundancy_compare(&sig_a, &sig_b_eq, &pk_a, &pk_b_eq).is_ok());
690
691 // 2. signature bytes diverge → FaultDetected.
692 assert!(matches!(
693 super::fors_redundancy_compare(&sig_a, &sig_b_diff, &pk_a, &pk_b_eq),
694 Err(SlhDsaError::FaultDetected)
695 ));
696
697 // 3. pk diverges → FaultDetected.
698 assert!(matches!(
699 super::fors_redundancy_compare(&sig_a, &sig_b_eq, &pk_a, &pk_b_diff),
700 Err(SlhDsaError::FaultDetected)
701 ));
702
703 // 4. both diverge → FaultDetected (same observable, but the
704 // short-circuit must not change the verdict).
705 assert!(matches!(
706 super::fors_redundancy_compare(&sig_a, &sig_b_diff, &pk_a, &pk_b_diff),
707 Err(SlhDsaError::FaultDetected)
708 ));
709 }
710
711 // ===========================================================
712 // T1-E — fors_indices_consistency_check (positive + negative)
713 // ===========================================================
714
715 /// Positive — feed the helper the indices that `message_to_indices`
716 /// produces from the same `md`, expect `Ok`. Exercised across the
717 /// `Shake128s` and `Shake128f` parameter sets so the K * A bit
718 /// extraction is non-trivial on both.
719 #[cfg(feature = "sca-fors-indices-check")]
720 fn indices_check_accepts<P: Params>(seed_byte: u8) {
721 let md_bytes = (P::K * P::A + 7) / 8;
722 let md: Vec<u8> = (0..md_bytes).map(|i| seed_byte.wrapping_add(i as u8)).collect();
723 let indices = super::message_to_indices::<P>(&md);
724 assert!(super::fors_indices_consistency_check::<P>(&md, &indices).is_ok());
725 }
726
727 #[test]
728 #[cfg(feature = "sca-fors-indices-check")]
729 fn fors_indices_check_accepts_correct_shake128s() {
730 indices_check_accepts::<Shake128s>(0x00);
731 indices_check_accepts::<Shake128s>(0x42);
732 indices_check_accepts::<Shake128s>(0xFF);
733 }
734
735 #[test]
736 #[cfg(feature = "sca-fors-indices-check")]
737 fn fors_indices_check_accepts_correct_shake128f() {
738 indices_check_accepts::<Shake128f>(0x00);
739 indices_check_accepts::<Shake128f>(0x55);
740 }
741
742 /// Negative — flip one bit of one index and expect `FaultDetected`.
743 /// We do not need to inject a fault into `message_to_indices`
744 /// itself; passing a deliberately corrupted `used` vector
745 /// exercises the same CT-compare path the integrity check runs.
746 #[test]
747 #[cfg(feature = "sca-fors-indices-check")]
748 fn fors_indices_check_rejects_flipped_index() {
749 type P = Shake128s;
750 let md_bytes = (P::K * P::A + 7) / 8;
751 let md: Vec<u8> = (0..md_bytes).map(|i| i as u8).collect();
752 let mut indices = super::message_to_indices::<P>(&md);
753
754 // Corrupt one index — flip its lowest bit.
755 indices[0] ^= 1;
756
757 assert!(matches!(
758 super::fors_indices_consistency_check::<P>(&md, &indices),
759 Err(SlhDsaError::FaultDetected)
760 ));
761
762 // Corrupt by changing length — expect FaultDetected too.
763 let mut short = super::message_to_indices::<P>(&md);
764 short.pop();
765 assert!(matches!(
766 super::fors_indices_consistency_check::<P>(&md, &short),
767 Err(SlhDsaError::FaultDetected)
768 ));
769 }
770}