Skip to main content

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}