aethel_core/htss.rs
1//! # Threshold Secret Sharing with a Hypercube Routing Simulation (HTSS)
2//!
3//! ## What this module actually does
4//!
5//! [`SecretSharer`] is local, in-process Shamir 3-of-5 threshold secret sharing
6//! over a single `u64` scalar (`split_secret`/`reconstruct_secret`): a degree-2
7//! polynomial over `F_q`, evaluated at 5 points to produce shares, reconstructed
8//! from any 3 via Lagrange interpolation. That's it — there is no network
9//! transport, no socket, no remote party, and no adversary who ever observes
10//! anything, which is also why this can run fully offline (see the crate
11//! README's "Offline generation" claim, CI-proven by network denial).
12//!
13//! [`HypercubeNetwork`]/[`HypercubePacket`](crate::htss::HypercubePacket)/[`NodeAddress`] are real, tested
14//! code (not dead code — exercised by `tests/plp_tests.rs`), but what they
15//! implement is a **local simulation** of dimension-disjoint path assignment
16//! across a modeled Q_5 graph (32 nodes, 80 edges): `route_payload_shares`
17//! computes which sequence of graph nodes each share *would* traverse and
18//! walks that sequence in-process. Nothing is transmitted anywhere, so terms
19//! like "eavesdropper" or "metadata leakage" don't apply to what this code
20//! does today — there is no channel to eavesdrop on.
21//!
22//! ## Aspirational: a future distributed deployment
23//!
24//! The graph-routing simulation is a plausible building block for an actual
25//! distributed HTSS deployment (real nodes, real transport, an adversary who
26//! can observe some subset of real network paths) — that's presumably why it
27//! was built this way rather than as a flat array shuffle. But that
28//! deployment does not exist in this crate: no networking code, no consensus,
29//! no notion of a validator. Treat any claim about eavesdropper resistance,
30//! fault tolerance against real node failures, or metadata protection as a
31//! design target for that future system, not a property of the code here.
32//!
33//! ## Who runs this (A-3)
34//!
35//! **HTSS is a backup/recovery ritual performed by the agent's principal or an
36//! operator over the sealed identity's key material — it is not a hosted
37//! service.** 8gentz does not run the 32-node cube; the hypercube here is a
38//! routing *model* for how a principal might distribute shares to distinct
39//! custodians, not a network anyone operates on the agent's behalf. What
40//! [`SecretSharer::split_key_material`] splits is material the caller already
41//! holds (typically an [`crate::signing::Identity`]'s sealing key, never the
42//! raw entropy) — this module never reaches into an identity and splits it
43//! unasked. See [`docs/HTSS-TOPOLOGY.md`](../docs/HTSS-TOPOLOGY.md)'s "Who runs
44//! HTSS" section for the full ritual (seal → split → distribute → reconstruct
45//! → import) and its current status: doc-first, composed from the primitives
46//! already shipping here rather than a new combined API.
47//!
48//! ## Key Structures
49//!
50//! - [`NodeAddress`] — 5-bit hypercube node coordinate (a modeled graph vertex)
51//! - [`ZkProofSegment`] — one Shamir share plus a path authentication tag
52//! - [`HypercubePacket`](crate::htss::HypercubePacket) — simulated in-process routing state for one segment
53//! - [`SecretSharer`] — Shamir 3-of-5 split and Lagrange reconstruction (the real work)
54//! - [`HypercubeNetwork`] — the modeled 32-node Q_5 graph and its local routing simulation
55//!
56//! ## Parameters
57//!
58//! - HYPERCUBE_DIM=5, NUM_NODES=32, THRESHOLD_K=3, MODULUS_Q=8380417
59//!
60//! ## What's actually guaranteed
61//!
62//! 1. **Threshold reconstruction**: any 3 of the 5 shares reconstruct the
63//! secret via Lagrange interpolation. Fewer than 3 shares reveal nothing
64//! about it **only on the [`SecretSharer::split_key_material`] path**, where
65//! the sharing polynomial's coefficients are derived from the secret itself
66//! via SHAKE-256. On the deprecated [`SecretSharer::split_secret`] path the
67//! coefficients come from a non-cryptographic function of a caller-supplied
68//! `u64`, so one share plus that seed recovers the secret and the threshold
69//! is not enforced (P3-12; `tests/htss_key_material.rs`).
70//! 2. **Dimension-disjoint path assignment**: the 5 simulated routes share no
71//! intermediate node with each other, by construction of
72//! `compute_orthogonal_paths` — a graph-theoretic property of the modeled
73//! routing, not a live security boundary.
74//! 3. **Share authentication, to a root the caller already trusts**: every
75//! share [`SecretSharer::reconstruct_key_material`] is given is checked
76//! against a caller-supplied Merkle root before interpolation runs, so a
77//! well-formed share that was never part of the sharing it claims to be
78//! (distinct index, right width, wrong tree) is refused rather than
79//! silently interpolated (0X3-105). This closes the gap
80//! `InvalidShareSet`'s duplicate/cardinality checks (P3-12) could not: a
81//! share list can pass every one of those and still not be genuine. It
82//! does **not** authenticate the root itself — see the doc comment on
83//! [`SecretSharer::reconstruct_key_material`] for what that means in
84//! practice.
85
86extern crate alloc;
87
88use alloc::vec::Vec;
89use sha3::{
90 digest::{ExtendableOutput, XofReader},
91 Digest, Sha3_256, Shake256,
92};
93use zeroize::Zeroize;
94
95use crate::identity_error::IdentityError;
96
97const HYPERCUBE_DIM: usize = 5;
98const NUM_NODES: usize = 1 << HYPERCUBE_DIM; // 2^5 = 32 nodes
99const THRESHOLD_K: usize = 3; // 3-of-5 threshold scheme
100const MODULUS_Q: u64 = 8380417;
101const TOTAL_SHARES: usize = 5; // n in the 3-of-5 scheme
102
103/// A 5-bit hypercube node coordinate (0..31).
104#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
105pub struct NodeAddress(pub u8);
106
107impl NodeAddress {
108 /// Return the neighbor of this node along dimension `dim`.
109 pub fn neighbor(&self, dim: usize) -> Self {
110 NodeAddress(self.0 ^ (1 << dim))
111 }
112
113 /// Hamming distance between two node addresses.
114 pub fn hamming_distance(&self, other: &Self) -> usize {
115 (self.0 ^ other.0).count_ones() as usize
116 }
117}
118
119/// A single Shamir share with a path authentication tag.
120///
121/// `share_val` is genuinely safe to expose and print (P3-03): it's one of the
122/// `n` deliberately-split Shamir shares this module's whole job is to hand
123/// out and route — the split is the point, not a leak. A lone share below
124/// the `THRESHOLD_K`-of-`n` threshold carries no usable information about the
125/// underlying secret (that's what makes it Shamir sharing and not a copy).
126#[derive(Clone, Debug)]
127pub struct ZkProofSegment {
128 /// Share index (1-based).
129 pub share_id: u8,
130 /// Share value in Z_q.
131 pub share_val: u64,
132 /// SHA3-256 path authentication tag.
133 pub path_tag: [u8; 32],
134}
135
136/// A routed packet carrying one proof segment through the hypercube.
137#[derive(Clone, Debug)]
138pub struct HypercubePacket {
139 /// Node the packet started from.
140 pub source: NodeAddress,
141 /// Node it has to reach.
142 pub destination: NodeAddress,
143 /// Node holding it now.
144 pub current_node: NodeAddress,
145 /// Dimensions to cross, in order. Each hop flips that bit of the address.
146 pub dimension_route: Vec<usize>,
147 /// Position in `dimension_route` of the next hop.
148 pub route_index: usize,
149 /// The proof segment being carried.
150 pub payload: ZkProofSegment,
151}
152
153/// Shamir 3-of-5 secret sharing over Z_q.
154pub struct SecretSharer;
155
156/// A single threshold share of split key material.
157///
158/// Mirrors the `htss-share` record in the `aethel:core` WIT world. `value`
159/// carries one big-endian-indexed `u32` per 16-bit limb of the shared payload,
160/// so its length is a function of the secret's length, not of the secret.
161///
162/// Safe to serialize and transport: a share below the threshold is
163/// information-theoretically independent of the secret, provided the
164/// coefficients were derived by [`SecretSharer::split_key_material`] rather
165/// than by the deprecated [`SecretSharer::split_secret`].
166///
167/// `path` proves this share's membership in the tree committed to by the
168/// [`SecretSharer::split_key_material`] root, without disclosing anything
169/// about the other shares beyond their hashes. It is a public value — it
170/// carries hash outputs, not raw share data — and it authenticates `(index,
171/// value)` only against a root the verifier already trusts. See
172/// [`SecretSharer::reconstruct_key_material`] for what that trust boundary is
173/// and is not.
174#[derive(Clone, Debug, PartialEq, Eq)]
175pub struct HtssShare {
176 /// Evaluation point x, in `1..=n`. Never zero — `f(0)` is the secret.
177 pub index: u8,
178 /// Per-limb evaluations `f_limb(index)`, little-endian `u32` each.
179 pub value: Vec<u8>,
180 /// Merkle inclusion path for `(index, value)` against the sharing's root.
181 /// Flattened 32-byte hashes, concatenated: `32 * merkle_proof_len(index)`
182 /// bytes. See `build_share_tree` for the fixed tree shape this proves
183 /// membership in.
184 pub path: Vec<u8>,
185}
186
187/// Bytes of the shared payload carried by each limb.
188///
189/// Two, not four: a limb value must stay below `MODULUS_Q` (~2^23), and 16 bits
190/// gives a comfortable margin while keeping the limb count low. Three bytes
191/// would be 2^24 > q and would truncate — the same defect this replaces.
192const LIMB_BYTES: usize = 2;
193
194/// Serialized width of one limb evaluation inside a share's `value`.
195const LIMB_EVAL_BYTES: usize = 4;
196
197// ── Share-set authentication (AETHEL-P8-19 / 0X3-105) ──────────────────────────
198//
199// PR #21 (0X3-95's sibling on the HTSS side) made `htss-reconstruct` refuse a
200// share list that cannot determine any secret: a repeated index, or more
201// shares than the scheme issues. It could not and did not decide whether the
202// shares it does accept are *genuine* — three well-formed shares at distinct
203// indices interpolate whether or not any of them ever came out of a real
204// `split_key_material` call. Lagrange interpolation cannot tell the
205// difference; nothing about the algebra is wrong with a fabricated point.
206//
207// What follows binds every share to a single 32-byte root at split time, and
208// checks every share against that root at reconstruction time, using a fixed
209// 5-leaf Merkle tree. Fabricating a `(index, value)` pair that passes
210// verification against a root you did not build requires a second preimage
211// of SHA3-256 — not a weakness in the sharing scheme.
212//
213// # This authenticates shares to a root. It does not authenticate the root.
214//
215// `htss-reconstruct` verifies that the shares it is given are members of the
216// tree named by the `root` it is also given. It has no way to know whether
217// that `root` is the genuine one from a real split, because the root arrives
218// as ordinary caller input like everything else. A caller who receives
219// `(shares, root)` as one untrusted bundle and passes both straight through
220// gets no protection: an attacker can always mint a self-consistent bundle,
221// fabricated shares and a root built to match. The property this buys is only
222// as strong as the channel the caller used to obtain `root` — the same
223// caveat that applies to verifying a signature against an unauthenticated
224// public key. See the doc comment on `root` at each call site for what an
225// honest channel looks like.
226
227/// Number of siblings in a share's inclusion path, given its index.
228///
229/// The tree in [`build_share_tree`] has a fixed, asymmetric shape: shares 1-4
230/// sit three levels deep, share 5 hangs directly off the root at one level.
231/// Path length is therefore a pure function of `index`, so the wire format
232/// (see [`SecretSharer::split_key_material_bytes`]) never needs to carry it.
233fn merkle_proof_len(index: u8) -> Option<usize> {
234 match index {
235 1..=4 => Some(3),
236 5 => Some(1),
237 _ => None,
238 }
239}
240
241/// Domain-separated leaf hash: commits `index` and `value` together.
242///
243/// Binding both into one hash is what stops a valid proof for one share being
244/// replayed against a different index or a different value — an attacker
245/// changing either changes the leaf, and the fixed tree above it no longer
246/// folds to the same root.
247fn merkle_leaf_hash(index: u8, value: &[u8]) -> [u8; 32] {
248 let mut hasher = Sha3_256::new();
249 hasher.update(b"AETHEL_HTSS_MERKLE_LEAF_V1");
250 hasher.update([index]);
251 hasher.update((value.len() as u32).to_le_bytes());
252 hasher.update(value);
253 let mut out = [0u8; 32];
254 out.copy_from_slice(&hasher.finalize());
255 out
256}
257
258/// Domain-separated internal-node hash.
259///
260/// A distinct domain separator from [`merkle_leaf_hash`] rather than a shared
261/// one is what stops a leaf being passed off as an internal node (or the
262/// reverse) — the classic Merkle second-preimage class of bug. `left` and
263/// `right` are ordered and fixed by position in [`build_share_tree`], not
264/// sorted, so there is no ambiguity to exploit there either.
265fn merkle_node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
266 let mut hasher = Sha3_256::new();
267 hasher.update(b"AETHEL_HTSS_MERKLE_NODE_V1");
268 hasher.update(left);
269 hasher.update(right);
270 let mut out = [0u8; 32];
271 out.copy_from_slice(&hasher.finalize());
272 out
273}
274
275/// Build the fixed 5-leaf tree over one sharing's shares and return the root
276/// plus each share's inclusion path, in index order (`paths[0]` is index 1).
277///
278/// `TOTAL_SHARES` is 5 crate-wide and is not a runtime parameter, so this is
279/// the concrete 5-leaf shape rather than a generic n-ary builder — a reader
280/// can see the whole tree without tracing recursion:
281///
282/// ```text
283/// ROOT
284/// / \
285/// N0123 L4 (share 5 hangs directly off the root)
286/// / \
287/// N01 N23
288/// / \ / \
289/// L0 L1 L2 L3 (L_i = leaf for share index i+1)
290/// ```
291///
292/// The shape is unbalanced by construction (RFC 6962's method, specialized to
293/// n=5) rather than padded to a power of two with a duplicated leaf, which is
294/// the construction behind CVE-2012-2459-class Merkle malleability bugs.
295fn build_share_tree(leaves: &[[u8; 32]; TOTAL_SHARES]) -> ([u8; 32], [Vec<u8>; TOTAL_SHARES]) {
296 let (l0, l1, l2, l3, l4) = (leaves[0], leaves[1], leaves[2], leaves[3], leaves[4]);
297
298 let n01 = merkle_node_hash(&l0, &l1);
299 let n23 = merkle_node_hash(&l2, &l3);
300 let n0123 = merkle_node_hash(&n01, &n23);
301 let root = merkle_node_hash(&n0123, &l4);
302
303 let flat = |chunks: &[[u8; 32]]| -> Vec<u8> {
304 let mut out = Vec::with_capacity(chunks.len() * 32);
305 for c in chunks {
306 out.extend_from_slice(c);
307 }
308 out
309 };
310
311 let paths = [
312 flat(&[l1, n23, l4]), // index 1: sibling leaf, sibling subtree, far leaf
313 flat(&[l0, n23, l4]), // index 2
314 flat(&[l3, n01, l4]), // index 3
315 flat(&[l2, n01, l4]), // index 4
316 flat(&[n0123]), // index 5: one level, straight off the root
317 ];
318
319 (root, paths)
320}
321
322/// Verify that `(index, value)` is a genuine leaf of the tree named by `root`,
323/// using `path` as produced by [`build_share_tree`].
324///
325/// Rejects before hashing anything if `path`'s length does not match what
326/// [`merkle_proof_len`] expects for `index` — a wrong-length path cannot be a
327/// valid proof for this fixed tree shape, so there is nothing to gain by
328/// hashing it anyway.
329fn verify_share_in_tree(index: u8, value: &[u8], path: &[u8], root: &[u8; 32]) -> bool {
330 let Some(expected_siblings) = merkle_proof_len(index) else {
331 return false;
332 };
333 if path.len() != expected_siblings * 32 {
334 return false;
335 }
336
337 let sibling = |i: usize| -> [u8; 32] {
338 let mut out = [0u8; 32];
339 out.copy_from_slice(&path[i * 32..i * 32 + 32]);
340 out
341 };
342
343 let leaf = merkle_leaf_hash(index, value);
344
345 let computed = match index {
346 1 | 3 => {
347 // This share's leaf is the LEFT child of its pair.
348 let pair = merkle_node_hash(&leaf, &sibling(0));
349 let quad = if index == 1 {
350 merkle_node_hash(&pair, &sibling(1))
351 } else {
352 merkle_node_hash(&sibling(1), &pair)
353 };
354 merkle_node_hash(&quad, &sibling(2))
355 }
356 2 | 4 => {
357 // This share's leaf is the RIGHT child of its pair.
358 let pair = merkle_node_hash(&sibling(0), &leaf);
359 let quad = if index == 2 {
360 merkle_node_hash(&pair, &sibling(1))
361 } else {
362 merkle_node_hash(&sibling(1), &pair)
363 };
364 merkle_node_hash(&quad, &sibling(2))
365 }
366 5 => merkle_node_hash(&sibling(0), &leaf),
367 _ => return false,
368 };
369
370 // Constant-time comparison, consistent with how this crate compares every
371 // other recomputed-vs-supplied value (plp::Verifier's challenge check,
372 // saap's constant-time confirmations). Not defending a secret here — root
373 // and path are public — but there is no cost to staying consistent.
374 let mut mismatch = 0u8;
375 for i in 0..32 {
376 mismatch |= computed[i] ^ root[i];
377 }
378 mismatch == 0
379}
380
381/// Largest secret [`SecretSharer::split_key_material`] will split.
382///
383/// The previous bound was `u32::MAX`, which is a representational limit of the
384/// payload's length prefix rather than a statement about what this operation is
385/// for. This is key material: an ML-DSA-65 signing key is a few KiB, and nothing
386/// this scheme is meant to protect is a file.
387///
388/// 64 KiB is deliberately generous against that, so the ceiling is a contract
389/// rather than a limit a legitimate caller meets. It also caps the work a single
390/// unauthenticated `htss-split` call can ask for, which matters because `secret`
391/// arrives from outside the component (see `COMPONENT_SPLIT_NONCE` in
392/// `src/component.rs`).
393const MAX_SECRET_BYTES: usize = 64 * 1024;
394
395impl SecretSharer {
396 /// Split arbitrary-length key material into `TOTAL_SHARES` shares with a
397 /// `THRESHOLD_K` threshold.
398 ///
399 /// This is the sound path. Two things differ from [`Self::split_secret`]:
400 ///
401 /// 1. **Coefficients derive from the secret itself**, via SHAKE-256 with
402 /// domain separation, not from a caller-supplied `u64`. Security rests on
403 /// the secret being secret — which it is by definition — rather than on
404 /// the caller having supplied an unguessable seed. `nonce` only separates
405 /// independent sharings of the same secret; it is not required to be
406 /// secret and the threshold property does not depend on it.
407 /// 2. **The payload is decomposed into 16-bit limbs**, each shared under its
408 /// own independent polynomial, so a secret of any length survives intact
409 /// instead of being reduced mod q.
410 ///
411 /// The secret's length is carried in the payload so reconstruction can trim
412 /// padding, which means share size reveals the secret's length (rounded up
413 /// to a limb) and nothing else.
414 ///
415 /// **L1-internal**: `secret` and every derived intermediate are zeroized
416 /// before returning. Only the shares leave.
417 ///
418 /// Refuses a secret larger than `MAX_SECRET_BYTES` with
419 /// `InvalidInputLength`.
420 pub fn split_key_material(
421 secret: &[u8],
422 nonce: &[u8],
423 ) -> Result<(Vec<HtssShare>, [u8; 32]), IdentityError> {
424 if secret.is_empty() || secret.len() > MAX_SECRET_BYTES {
425 return Err(IdentityError::InvalidInputLength);
426 }
427
428 // payload = len(secret) ‖ secret, padded up to a whole number of limbs.
429 let mut payload = Vec::with_capacity(4 + secret.len() + 1);
430 payload.extend_from_slice(&(secret.len() as u32).to_le_bytes());
431 payload.extend_from_slice(secret);
432 while payload.len() % LIMB_BYTES != 0 {
433 payload.push(0);
434 }
435 let limb_count = payload.len() / LIMB_BYTES;
436
437 let mut shares: Vec<HtssShare> = (1..=TOTAL_SHARES as u8)
438 .map(|index| HtssShare {
439 index,
440 value: Vec::with_capacity(limb_count * LIMB_EVAL_BYTES),
441 path: Vec::new(), // filled in below, once every value exists
442 })
443 .collect();
444
445 // Absorbed once, here, rather than once per coefficient. See
446 // `derive_coeff_key`.
447 let mut coeff_key = Self::derive_coeff_key(secret, nonce);
448
449 for limb_idx in 0..limb_count {
450 let lo = payload[limb_idx * LIMB_BYTES] as u64;
451 let hi = payload[limb_idx * LIMB_BYTES + 1] as u64;
452 let limb_value = lo | (hi << 8);
453
454 // f(0) = limb, and the remaining coefficients come from the secret,
455 // by way of the key derived from it above.
456 let mut coefficients = Vec::with_capacity(THRESHOLD_K);
457 coefficients.push(limb_value % MODULUS_Q);
458 for coeff_idx in 1..THRESHOLD_K {
459 coefficients.push(Self::derive_coeff_from_key(&coeff_key, limb_idx, coeff_idx));
460 }
461
462 for share in shares.iter_mut() {
463 let mut y = 0u64;
464 let mut x_pow = 1u64;
465 for &coeff in &coefficients {
466 y = (y + coeff * x_pow) % MODULUS_Q;
467 x_pow = (x_pow * share.index as u64) % MODULUS_Q;
468 }
469 share.value.extend_from_slice(&(y as u32).to_le_bytes());
470 }
471
472 coefficients.zeroize();
473 }
474
475 coeff_key.zeroize();
476 payload.zeroize();
477
478 // Every share's value is final; bind the set together. Leaves commit
479 // (index, value), so the tree — and therefore the root — depends on
480 // both. See the module note above `merkle_proof_len`.
481 let mut leaves = [[0u8; 32]; TOTAL_SHARES];
482 for (i, share) in shares.iter().enumerate() {
483 leaves[i] = merkle_leaf_hash(share.index, &share.value);
484 }
485 let (root, paths) = build_share_tree(&leaves);
486 for (share, path) in shares.iter_mut().zip(paths) {
487 share.path = path;
488 }
489
490 Ok((shares, root))
491 }
492
493 /// Reconstruct key material from at least `THRESHOLD_K` shares.
494 ///
495 /// Returns `ThresholdNotMet` below the threshold rather than a wrong answer.
496 /// [`Self::reconstruct_secret`] interpolates through however many points it
497 /// is given; this is the entry point that decides which point sets are
498 /// admissible in the first place.
499 ///
500 /// # Why the shares are validated as a *set*
501 ///
502 /// Lagrange interpolation is only defined over distinct evaluation points.
503 /// Given two shares carrying the same index, every basis polynomial for
504 /// those two points has a zero denominator, so both terms drop out and the
505 /// interpolation answers from whatever points remain: a value that is not
506 /// the shared secret. Because the payload's length prefix is recovered from
507 /// that same wrong value, the resulting `len` is usually implausible and the
508 /// call fails as `SerializationError` by accident. That accident is not a
509 /// guard. A caller who chooses the share values can make the length prefix
510 /// decode, and reconstruction then returns `Ok(attacker-chosen bytes)`. The
511 /// index-uniqueness check below is what actually forecloses it.
512 ///
513 /// The cardinality ceiling is the same argument from the other side. The
514 /// scheme issues `TOTAL_SHARES` shares, so a longer list cannot be a valid
515 /// share set whatever it contains, and interpolation is O(k**2) per limb, so
516 /// accepting an unbounded one is quadratic work on unauthenticated input.
517 ///
518 /// # `root` authenticates the shares, not itself
519 ///
520 /// Every share is checked against `root` before interpolation runs (see
521 /// `merkle_proof_len` and `verify_share_in_tree`): a `(index, value, path)`
522 /// that does not fold to `root` is refused as `InvalidShareSet`, whatever
523 /// its index and width look like. That closes the gap the set-validation
524 /// above cannot: a share list can pass every check here and still not be
525 /// the shares [`Self::split_key_material`] actually produced, because
526 /// nothing about valid indices, widths or uniqueness distinguishes a real
527 /// share from a fabricated one at a free index.
528 ///
529 /// What this does **not** do is vouch for `root` itself. `root` is caller
530 /// input like everything else in this function's signature; reconstruction
531 /// has no independent way to know it is the genuine value from a real
532 /// split rather than one an attacker minted to match their own fabricated
533 /// shares. The guarantee is only as strong as wherever the caller got
534 /// `root` from — the same trust requirement verifying a signature places
535 /// on the public key. A caller who receives `(shares, root)` as one
536 /// untrusted bundle and passes both straight through gets nothing extra
537 /// from this check.
538 pub fn reconstruct_key_material(
539 shares: &[HtssShare],
540 root: &[u8; 32],
541 ) -> Result<Vec<u8>, IdentityError> {
542 if shares.len() < THRESHOLD_K {
543 return Err(IdentityError::ThresholdNotMet);
544 }
545 if shares.len() > TOTAL_SHARES {
546 return Err(IdentityError::InvalidShareSet);
547 }
548
549 let width = shares[0].value.len();
550 if width == 0 || width % LIMB_EVAL_BYTES != 0 {
551 return Err(IdentityError::SerializationError);
552 }
553 if shares
554 .iter()
555 .any(|s| s.value.len() != width || s.index == 0)
556 {
557 return Err(IdentityError::SerializationError);
558 }
559
560 // Index uniqueness. `index` is a `u8`, so a fixed 256-entry stack table
561 // decides it in one pass with no allocation and no sort.
562 let mut seen = [false; 256];
563 for share in shares {
564 if seen[share.index as usize] {
565 return Err(IdentityError::InvalidShareSet);
566 }
567 seen[share.index as usize] = true;
568 }
569
570 // Authenticate every share against the root before trusting any of
571 // them enough to interpolate. Cheap relative to interpolation itself,
572 // and it means a fabricated share never reaches the arithmetic below.
573 for share in shares {
574 if !verify_share_in_tree(share.index, &share.value, &share.path, root) {
575 return Err(IdentityError::InvalidShareSet);
576 }
577 }
578
579 let limb_count = width / LIMB_EVAL_BYTES;
580
581 let mut payload = Vec::with_capacity(limb_count * LIMB_BYTES);
582 for limb_idx in 0..limb_count {
583 let points: Vec<(u8, u64)> = shares
584 .iter()
585 .map(|s| {
586 let off = limb_idx * LIMB_EVAL_BYTES;
587 let mut b = [0u8; LIMB_EVAL_BYTES];
588 b.copy_from_slice(&s.value[off..off + LIMB_EVAL_BYTES]);
589 (s.index, u32::from_le_bytes(b) as u64)
590 })
591 .collect();
592
593 let limb = Self::reconstruct_secret(&points)?;
594 payload.push((limb & 0xFF) as u8);
595 payload.push(((limb >> 8) & 0xFF) as u8);
596 }
597
598 if payload.len() < 4 {
599 return Err(IdentityError::SerializationError);
600 }
601 let mut len_bytes = [0u8; 4];
602 len_bytes.copy_from_slice(&payload[..4]);
603 let len = u32::from_le_bytes(len_bytes) as usize;
604
605 if len == 0 || 4 + len > payload.len() {
606 payload.zeroize();
607 return Err(IdentityError::SerializationError);
608 }
609
610 let secret = payload[4..4 + len].to_vec();
611 payload.zeroize();
612 Ok(secret)
613 }
614
615 /// Split key material and serialize the root and shares for a
616 /// byte-oriented boundary.
617 ///
618 /// Wire format, so a caller in another language can parse it without this
619 /// crate:
620 ///
621 /// ```text
622 /// [root: 32 bytes][count: u8][width: u32 LE]
623 /// [ index: u8, value: width bytes, path: merkle_proof_len(index)*32 bytes ] × count
624 /// ```
625 ///
626 /// `path`'s length is not itself carried on the wire: for this crate's
627 /// fixed 5-share tree it is a pure function of `index` (see
628 /// `merkle_proof_len`), so writing it would be redundant and a mismatch
629 /// would just be one more thing to validate for no benefit.
630 ///
631 /// The root travels in the same blob as the shares here for convenience.
632 /// See [`Self::reconstruct_key_material`] for why that means this
633 /// particular wire format authenticates shares to each other but not to
634 /// an attacker who controls the whole blob — a caller who needs that
635 /// guarantee keeps the root out of band and uses [`HtssShare`] directly.
636 ///
637 /// Not gated behind any target-specific feature: this is the boundary a
638 /// non-Rust caller parses, so it needs to be reachable and tested by
639 /// ordinary native tests rather than only by building for a specific
640 /// target.
641 pub fn split_key_material_bytes(secret: &[u8], nonce: &[u8]) -> Result<Vec<u8>, IdentityError> {
642 let (shares, root) = Self::split_key_material(secret, nonce)?;
643 let width = shares[0].value.len();
644
645 let path_bytes: usize = shares.iter().map(|s| s.path.len()).sum();
646 let mut out = Vec::with_capacity(32 + 1 + 4 + shares.len() * (1 + width) + path_bytes);
647 out.extend_from_slice(&root);
648 out.push(shares.len() as u8);
649 out.extend_from_slice(&(width as u32).to_le_bytes());
650 for share in &shares {
651 out.push(share.index);
652 out.extend_from_slice(&share.value);
653 out.extend_from_slice(&share.path);
654 }
655 Ok(out)
656 }
657
658 /// Parse the wire format written by [`Self::split_key_material_bytes`] and
659 /// reconstruct.
660 ///
661 /// Returns `ThresholdNotMet` below the threshold, `SerializationError` for
662 /// malformed input, and `InvalidShareSet` for a well-formed share set that
663 /// fails the root check — never a sentinel value that a caller could
664 /// mistake for a recovered secret.
665 pub fn reconstruct_key_material_bytes(bytes: &[u8]) -> Result<Vec<u8>, IdentityError> {
666 if bytes.len() < 37 {
667 return Err(IdentityError::SerializationError);
668 }
669 let mut root = [0u8; 32];
670 root.copy_from_slice(&bytes[..32]);
671
672 let count = bytes[32] as usize;
673 let mut width_bytes = [0u8; 4];
674 width_bytes.copy_from_slice(&bytes[33..37]);
675 let width = u32::from_le_bytes(width_bytes) as usize;
676
677 if count == 0 || width == 0 {
678 return Err(IdentityError::SerializationError);
679 }
680
681 let mut shares = Vec::with_capacity(count);
682 let mut off = 37;
683 for _ in 0..count {
684 if off >= bytes.len() {
685 return Err(IdentityError::SerializationError);
686 }
687 let index = bytes[off];
688 off += 1;
689
690 // Path length is derived from `index`, not read from the wire —
691 // see `split_key_material_bytes`'s doc comment. An index outside
692 // 1..=TOTAL_SHARES has no defined path length, so the blob is
693 // malformed rather than merely carrying a share that will later
694 // fail the root check.
695 let Some(sibling_count) = merkle_proof_len(index) else {
696 return Err(IdentityError::SerializationError);
697 };
698 let path_len = sibling_count * 32;
699
700 if off + width + path_len > bytes.len() {
701 return Err(IdentityError::SerializationError);
702 }
703 let value = bytes[off..off + width].to_vec();
704 off += width;
705 let path = bytes[off..off + path_len].to_vec();
706 off += path_len;
707
708 shares.push(HtssShare { index, value, path });
709 }
710
711 if off != bytes.len() {
712 return Err(IdentityError::SerializationError);
713 }
714
715 Self::reconstruct_key_material(&shares, &root)
716 }
717
718 /// Absorb the secret once, into a fixed-size coefficient key.
719 ///
720 /// # Why this stage exists
721 ///
722 /// The coefficients used to be derived by absorbing the **whole secret**
723 /// into a fresh SHAKE-256 instance per coefficient. The limb loop runs
724 /// `limb_count * (THRESHOLD_K - 1)` times, which with `LIMB_BYTES = 2` and
725 /// `THRESHOLD_K = 3` is one call per byte of secret, each absorbing every
726 /// byte of secret: n**2 bytes of absorption in total. Measured, a 4x larger
727 /// input cost 14-16x the time, and a 64 KiB secret meant roughly 4.3 GB of
728 /// absorption and over ten seconds of wall clock for a single call.
729 ///
730 /// Splitting the derivation in two makes the secret-dependent work happen
731 /// exactly once and the per-coefficient work constant, so the whole split is
732 /// linear in the secret's length.
733 ///
734 /// # What it must not change
735 ///
736 /// The security property is unchanged, and deliberately so: the secret is
737 /// still the entropy source. That is the whole difference from
738 /// [`Self::derive_coeff`] and the entire justification for the "3-of-5"
739 /// claim — an attacker who does not already know the secret cannot predict
740 /// the coefficients, so shares below the threshold reveal nothing. Deriving
741 /// a key from the secret and expanding that keeps it intact: predicting any
742 /// coefficient still requires either the secret or a preimage of SHAKE-256.
743 ///
744 /// The key is secret-derived and is zeroized by the caller.
745 fn derive_coeff_key(secret: &[u8], nonce: &[u8]) -> [u8; 32] {
746 let mut hasher = Shake256::default();
747 sha3::digest::Update::update(&mut hasher, b"AETHEL_HTSS_COEFF_V2");
748 sha3::digest::Update::update(&mut hasher, &(secret.len() as u32).to_le_bytes());
749 sha3::digest::Update::update(&mut hasher, secret);
750 sha3::digest::Update::update(&mut hasher, &(nonce.len() as u32).to_le_bytes());
751 sha3::digest::Update::update(&mut hasher, nonce);
752 let mut xof = hasher.finalize_xof();
753
754 let mut key = [0u8; 32];
755 xof.read(&mut key);
756 key
757 }
758
759 /// Derive one sharing-polynomial coefficient from the coefficient key.
760 ///
761 /// Absorbs a fixed 32-byte key and two indices, so its cost does not depend
762 /// on the secret's length. Rejection-samples into `[0, MODULUS_Q)` so the
763 /// coefficient is uniform over the field rather than biased by a modular
764 /// reduction of a wider value — unchanged from the previous derivation, and
765 /// not the part that was slow.
766 ///
767 /// The `V2` domain separator is what makes this a different function rather
768 /// than a faster spelling of the old one. Every coefficient, and therefore
769 /// every share value, differs from what `V1` produced for the same
770 /// `(secret, nonce)`. Shares from the two derivations must not be mixed
771 /// within one reconstruction. Reconstruction itself is unaffected: it is
772 /// Lagrange interpolation over the share values and never re-derives a
773 /// coefficient, so shares produced by `V1` still reconstruct correctly.
774 fn derive_coeff_from_key(key: &[u8; 32], limb_idx: usize, coeff_idx: usize) -> u64 {
775 let mut hasher = Shake256::default();
776 sha3::digest::Update::update(&mut hasher, b"AETHEL_HTSS_COEFF_V2_EXPAND");
777 sha3::digest::Update::update(&mut hasher, key);
778 sha3::digest::Update::update(&mut hasher, &(limb_idx as u64).to_le_bytes());
779 sha3::digest::Update::update(&mut hasher, &(coeff_idx as u64).to_le_bytes());
780 let mut xof = hasher.finalize_xof();
781
782 // Rejection sampling: draw 4 bytes, mask to 24 bits, accept if < q.
783 loop {
784 let mut buf = [0u8; 4];
785 xof.read(&mut buf);
786 let candidate = (u32::from_le_bytes(buf) & 0x00FF_FFFF) as u64;
787 if candidate < MODULUS_Q {
788 return candidate;
789 }
790 }
791 }
792 /// Split `secret` into `n` shares using a degree-(k-1) polynomial over Z_q.
793 /// **L1-internal**: `secret` is taken by value and used to build the
794 /// sharing polynomial's constant term (`coefficients[0] = secret`) — that
795 /// intermediate `Vec` is secret-derived and is explicitly zeroized before
796 /// returning, since dropping a `Vec` does not clear its backing memory.
797 /// Only the `n` output shares (safe to expose — see [`ZkProofSegment`])
798 /// leave this function.
799 ///
800 /// Uses a deterministic seed derived from the secret and a nonce counter
801 /// to avoid OS randomness (WASM-compatible).
802 ///
803 /// # The threshold property does not hold here
804 ///
805 /// The non-constant coefficients come from `derive_coeff`, which is
806 /// not a cryptographic derivation and is a pure function of the
807 /// caller-supplied `seed`. Anyone who knows or brute-forces `seed` (64 bits)
808 /// recovers the secret from a **single** share. This also shares only
809 /// `secret % MODULUS_Q` (~23 bits), silently discarding anything above it.
810 ///
811 /// Use [`Self::split_key_material`]. See P3-12 and
812 /// `tests/htss_key_material.rs`.
813 #[deprecated(
814 since = "0.1.1",
815 note = "threshold not enforced: coefficients derive non-cryptographically from a \
816 caller-supplied u64 seed, so one share plus that seed recovers the secret. \
817 Also truncates any secret above ~2^23. Use split_key_material."
818 )]
819 pub fn split_secret(secret: u64, k: usize, n: usize, seed: u64) -> Vec<(u8, u64)> {
820 // Build polynomial coefficients: f(0) = secret, rest derived from seed
821 let mut coefficients = Vec::with_capacity(k);
822 coefficients.push(secret % MODULUS_Q);
823 for i in 1..k {
824 // Deterministic coefficient derivation using a simple LCG-style mix
825 let coeff = Self::derive_coeff(seed, i as u64) % MODULUS_Q;
826 coefficients.push(coeff);
827 }
828 let mut shares = Vec::with_capacity(n);
829 for x in 1..=(n as u8) {
830 let mut y = 0u64;
831 let mut x_pow = 1u64;
832 for &coeff in &coefficients {
833 y = (y + coeff.wrapping_mul(x_pow)) % MODULUS_Q;
834 x_pow = x_pow.wrapping_mul(x as u64) % MODULUS_Q;
835 }
836 shares.push((x, y));
837 }
838 coefficients.zeroize();
839 shares
840 }
841
842 /// Derive a pseudo-random coefficient from a seed and index.
843 fn derive_coeff(seed: u64, idx: u64) -> u64 {
844 // Simple mixing function (not cryptographic — used only for share polynomial)
845 let mut v = seed.wrapping_add(idx.wrapping_mul(0x9e3779b97f4a7c15));
846 v ^= v >> 30;
847 v = v.wrapping_mul(0xbf58476d1ce4e5b9);
848 v ^= v >> 27;
849 v = v.wrapping_mul(0x94d049bb133111eb);
850 v ^= v >> 31;
851 v
852 }
853
854 /// Reconstruct the secret from `shares`, validating the threshold first.
855 ///
856 /// Mirrors the `htss-reconstruct` operation in the `aethel:core` WIT
857 /// world: fewer than `THRESHOLD_K` (3) shares is not "a slightly worse
858 /// answer", it's not a real reconstruction — [`Self::reconstruct_secret`]
859 /// happily interpolates through however many points it's given and
860 /// returns a wrong answer silently. This is the entry point that refuses
861 /// to do that.
862 pub fn reconstruct_secret_checked(shares: &[(u8, u64)]) -> Result<u64, IdentityError> {
863 if shares.len() < THRESHOLD_K {
864 return Err(IdentityError::ThresholdNotMet);
865 }
866 Self::reconstruct_secret(shares)
867 }
868
869 /// Reconstruct the secret from at least `k` shares using Lagrange interpolation.
870 /// **L1-internal by design, not a leak**: returning the reconstructed
871 /// secret is the entire point of secret *reconstruction* — the caller
872 /// holding `≥ THRESHOLD_K` shares is, by definition, authorized to
873 /// recover it (that's what distinguishes reconstruction from a leak via
874 /// a sub-threshold share, which [`ZkProofSegment`]'s doc comment covers).
875 ///
876 /// Returns `InvalidShareSet` if the points are not interpolable. For
877 /// distinct indices in `1..q` they always are, so this is a defence in
878 /// depth rather than a case a correct caller meets. See
879 /// `mod_inverse` for why it is reported rather than absorbed.
880 // Left as index loops so the compiled component is byte-identical.
881 #[allow(clippy::needless_range_loop)]
882 pub fn reconstruct_secret(shares: &[(u8, u64)]) -> Result<u64, IdentityError> {
883 let q = MODULUS_Q as i64;
884 let mut secret = 0i64;
885 for i in 0..shares.len() {
886 let xi = shares[i].0 as i64;
887 let yi = shares[i].1 as i64;
888 let mut num = 1i64;
889 let mut den = 1i64;
890 for j in 0..shares.len() {
891 if i != j {
892 let xj = shares[j].0 as i64;
893 // num *= (0 - xj) = -xj (evaluating at x=0)
894 num = ((num % q) * ((-xj % q + q) % q)) % q;
895 // den *= (xi - xj)
896 den = ((den % q) * ((xi - xj % q + q) % q)) % q;
897 }
898 }
899 let den_inv = Self::mod_inverse(den, q).ok_or(IdentityError::InvalidShareSet)?;
900 let lagrange = (num % q * den_inv % q) % q;
901 let term = (yi % q * lagrange % q) % q;
902 secret = (secret + term) % q;
903 }
904 Ok(((secret % q) + q) as u64 % MODULUS_Q)
905 }
906
907 /// Modular inverse of `a` mod `m`, or `None` when `a` has none.
908 ///
909 /// `None` rather than the `0` sentinel this returned before. Zero is a value
910 /// the extended Euclidean algorithm can legitimately be asked about and is
911 /// never a legitimate inverse, so a `0` return was indistinguishable from
912 /// success; multiplying by it turned "there is no inverse" into "this
913 /// Lagrange term contributes nothing", which is the mechanism that let a
914 /// share set with a repeated index reconstruct to a wrong secret inside an
915 /// `Ok`.
916 ///
917 /// The uniqueness check in [`Self::reconstruct_key_material`] is what makes
918 /// the failing case unreachable. This is the second line, so a future caller
919 /// that does reach it gets an error rather than a plausible number.
920 fn mod_inverse(a: i64, m: i64) -> Option<i64> {
921 let mut t = 0i64;
922 let mut newt = 1i64;
923 let mut r = m;
924 let mut newr = a % m;
925 while newr != 0 {
926 let quotient = r / newr;
927 let temp_t = t - quotient * newt;
928 t = newt;
929 newt = temp_t;
930 let temp_r = r - quotient * newr;
931 r = newr;
932 newr = temp_r;
933 }
934 if r > 1 {
935 return None;
936 }
937 if t < 0 {
938 t += m;
939 }
940 Some(t)
941 }
942}
943
944/// 32-node Q_5 hypercube network with dimension-disjoint routing.
945pub struct HypercubeNetwork {
946 /// Every node address in the hypercube.
947 pub nodes: Vec<NodeAddress>,
948}
949
950impl HypercubeNetwork {
951 /// Create a new 32-node Q_5 hypercube network.
952 pub fn new() -> Self {
953 let nodes = (0..NUM_NODES).map(|i| NodeAddress(i as u8)).collect();
954 Self { nodes }
955 }
956
957 /// Compute 5 orthogonal dimension-disjoint routing paths from `src` to `dst`.
958 pub fn compute_orthogonal_paths(_src: NodeAddress, _dst: NodeAddress) -> Vec<Vec<usize>> {
959 let mut paths = Vec::with_capacity(HYPERCUBE_DIM);
960 for d_start in 0..HYPERCUBE_DIM {
961 let mut dim_order = Vec::with_capacity(HYPERCUBE_DIM);
962 for i in 0..HYPERCUBE_DIM {
963 dim_order.push((d_start + i) % HYPERCUBE_DIM);
964 }
965 paths.push(dim_order);
966 }
967 paths
968 }
969
970 /// Route proof shares from `src` to `dst` along orthogonal paths.
971 pub fn route_payload_shares(
972 &self,
973 src: NodeAddress,
974 dst: NodeAddress,
975 shares: &[(u8, u64)],
976 ) -> Vec<HypercubePacket> {
977 let routes = Self::compute_orthogonal_paths(src, dst);
978 let mut packets = Vec::new();
979 for (i, share) in shares.iter().enumerate() {
980 let route_dim_sequence = routes[i % HYPERCUBE_DIM].clone();
981 let mut hasher = Sha3_256::new();
982 hasher.update(src.0.to_le_bytes());
983 hasher.update(dst.0.to_le_bytes());
984 hasher.update([share.0]);
985 let mut path_tag = [0u8; 32];
986 path_tag.copy_from_slice(&hasher.finalize());
987 let packet = HypercubePacket {
988 source: src,
989 destination: dst,
990 current_node: src,
991 dimension_route: route_dim_sequence,
992 route_index: 0,
993 payload: ZkProofSegment {
994 share_id: share.0,
995 share_val: share.1,
996 path_tag,
997 },
998 };
999 packets.push(packet);
1000 }
1001 let mut delivered_packets = Vec::new();
1002 for mut pkt in packets {
1003 while pkt.current_node != pkt.destination {
1004 let next_dim = pkt.dimension_route[pkt.route_index];
1005 let next_node = pkt.current_node.neighbor(next_dim);
1006 pkt.current_node = next_node;
1007 pkt.route_index += 1;
1008 }
1009 delivered_packets.push(pkt);
1010 }
1011 delivered_packets
1012 }
1013}
1014
1015impl Default for HypercubeNetwork {
1016 fn default() -> Self {
1017 Self::new()
1018 }
1019}
1020
1021#[cfg(test)]
1022mod tests {
1023 /// `mod_inverse` must report "no inverse exists" rather than returning it.
1024 ///
1025 /// Zero has no inverse mod q. The function used to return `0` for that,
1026 /// which is also a perfectly ordinary value to multiply by, so the caller
1027 /// could not tell the two apart and a Lagrange term with a zero denominator
1028 /// silently evaluated to nothing. That is what turned a share set with a
1029 /// repeated index into a wrong secret inside an `Ok`.
1030 #[test]
1031 fn mod_inverse_reports_a_non_invertible_input() {
1032 assert_eq!(
1033 SecretSharer::mod_inverse(0, MODULUS_Q as i64),
1034 None,
1035 "zero has no inverse mod q, and saying so with 0 is indistinguishable from success"
1036 );
1037 }
1038
1039 /// The negative test above is only meaningful if the function still computes
1040 /// real inverses.
1041 #[test]
1042 fn mod_inverse_still_inverts() {
1043 let q = MODULUS_Q as i64;
1044 for a in [1i64, 2, 3, 7, 1234, q - 1] {
1045 let inv = SecretSharer::mod_inverse(a, q)
1046 .expect("a non-zero residue mod a prime is invertible");
1047 assert_eq!(
1048 (a * inv).rem_euclid(q),
1049 1,
1050 "mod_inverse({a}) is not an inverse"
1051 );
1052 }
1053 }
1054
1055 use super::*;
1056
1057 // ── Merkle tree math (0X3-105) ──────────────────────────────────────────
1058 //
1059 // `build_share_tree` and `verify_share_in_tree` are hand-derived: five
1060 // explicit code paths, one per index, rather than a generic recursive
1061 // walk. These pin that the two sides actually agree, rather than trusting
1062 // the derivation by inspection.
1063
1064 /// Every index's path, built by `build_share_tree`, must verify against
1065 /// the root `build_share_tree` returned — for every index, not just one,
1066 /// since the five verification branches are independent code paths and a
1067 /// mistake in any one of them would not show up in the others.
1068 #[test]
1069 fn every_share_verifies_against_the_tree_it_was_built_from() {
1070 let leaves: [[u8; 32]; TOTAL_SHARES] =
1071 core::array::from_fn(|i| merkle_leaf_hash((i + 1) as u8, &[i as u8; 4]));
1072 let (root, paths) = build_share_tree(&leaves);
1073
1074 for (i, path) in paths.iter().enumerate().take(TOTAL_SHARES) {
1075 let index = (i + 1) as u8;
1076 let value = [i as u8; 4];
1077 assert!(
1078 verify_share_in_tree(index, &value, path, &root),
1079 "index {index}'s own path did not verify against the tree it came from"
1080 );
1081 }
1082 }
1083
1084 /// The path length `merkle_proof_len` predicts must match what
1085 /// `build_share_tree` actually produces, for every index — this is the
1086 /// invariant the wire format in `split_key_material_bytes` depends on to
1087 /// avoid carrying an explicit length.
1088 #[test]
1089 fn proof_length_matches_what_the_tree_builder_produces() {
1090 let leaves = [[0u8; 32]; TOTAL_SHARES];
1091 let (_, paths) = build_share_tree(&leaves);
1092 for (i, path) in paths.iter().enumerate().take(TOTAL_SHARES) {
1093 let index = (i + 1) as u8;
1094 let expected = merkle_proof_len(index).expect("1..=5 is always Some") * 32;
1095 assert_eq!(
1096 path.len(),
1097 expected,
1098 "merkle_proof_len({index}) disagrees with build_share_tree's own output"
1099 );
1100 }
1101 }
1102
1103 /// Changing any single field — the index, the value, or one byte of the
1104 /// path — must break verification. This is what "authenticates (index,
1105 /// value) together" actually means, checked rather than asserted in prose.
1106 #[test]
1107 fn verification_is_sensitive_to_every_field() {
1108 let leaves: [[u8; 32]; TOTAL_SHARES] =
1109 core::array::from_fn(|i| merkle_leaf_hash((i + 1) as u8, &[i as u8; 4]));
1110 let (root, paths) = build_share_tree(&leaves);
1111
1112 // Baseline: index 2's own data verifies.
1113 let index = 2u8;
1114 let value = [1u8; 4];
1115 assert!(verify_share_in_tree(index, &value, &paths[1], &root));
1116
1117 // Wrong index, same value and path.
1118 assert!(!verify_share_in_tree(3, &value, &paths[1], &root));
1119
1120 // Wrong value, same index and path.
1121 assert!(!verify_share_in_tree(index, &[9u8; 4], &paths[1], &root));
1122
1123 // Wrong path (another share's), same index and value.
1124 assert!(!verify_share_in_tree(index, &value, &paths[0], &root));
1125
1126 // Corrupted path (one flipped byte), same index and value.
1127 let mut tampered_path = paths[1].clone();
1128 tampered_path[0] ^= 0x01;
1129 assert!(!verify_share_in_tree(index, &value, &tampered_path, &root));
1130
1131 // Corrupted root, everything else genuine.
1132 let mut tampered_root = root;
1133 tampered_root[0] ^= 0x01;
1134 assert!(!verify_share_in_tree(
1135 index,
1136 &value,
1137 &paths[1],
1138 &tampered_root
1139 ));
1140 }
1141
1142 /// Index 0 and index 6 are outside the scheme; a path of any length must
1143 /// be refused for them rather than accepted or panicking on an unmapped
1144 /// case.
1145 #[test]
1146 fn verification_refuses_indices_outside_the_scheme() {
1147 assert!(!verify_share_in_tree(0, &[0u8; 4], &[0u8; 96], &[0u8; 32]));
1148 assert!(!verify_share_in_tree(6, &[0u8; 4], &[0u8; 96], &[0u8; 32]));
1149 assert_eq!(merkle_proof_len(0), None);
1150 assert_eq!(merkle_proof_len(6), None);
1151 }
1152
1153 #[test]
1154 #[allow(deprecated)] // exercises the deprecated split_secret on purpose
1155 fn test_htss_split_reconstruct() {
1156 let secret: u64 = 5234123;
1157 let seed: u64 = 0xdeadbeef_cafebabe;
1158 let shares = SecretSharer::split_secret(secret, THRESHOLD_K, HYPERCUBE_DIM, seed);
1159 assert_eq!(shares.len(), HYPERCUBE_DIM);
1160 // Reconstruct from first 3 shares
1161 let reconstructed = SecretSharer::reconstruct_secret(&shares[0..THRESHOLD_K])
1162 .expect("distinct indices interpolate");
1163 assert_eq!(reconstructed, secret);
1164 }
1165
1166 #[test]
1167 #[allow(deprecated)] // routing takes any share set; split_secret is the short way to get one
1168 fn test_hypercube_routing() {
1169 let network = HypercubeNetwork::new();
1170 let src = NodeAddress(0b00000);
1171 let dst = NodeAddress(0b11111);
1172 let seed: u64 = 0x1234567890abcdef;
1173 let shares = SecretSharer::split_secret(42u64, THRESHOLD_K, HYPERCUBE_DIM, seed);
1174 let delivered = network.route_payload_shares(src, dst, &shares);
1175 assert_eq!(delivered.len(), HYPERCUBE_DIM);
1176 for pkt in &delivered {
1177 assert_eq!(pkt.current_node, dst);
1178 }
1179 }
1180}