ant_node/replication/commitment.rs
1//! Storage-bound audit via piggybacked commitments.
2//!
3//! Implements the v12 storage-bound audit design: it closes the
4//! storage-binding holes where a node could pass audits while holding chunk
5//! addresses (not bytes), or answer against a commitment it never gossiped.
6//!
7//! ## What this module provides
8//!
9//! - [`StorageCommitment`] — the wire type sent on neighbour-sync gossip
10//! and embedded in commitment-bound audit responses. `ML-DSA-65` signed
11//! over `(root, key_count, sender_peer_id)` with explicit domain separation.
12//! - [`MerkleTree`] — an in-memory Merkle tree over `(key, BLAKE3(bytes))`
13//! leaves. Rebuilt by the responder when its key set changes; produces
14//! inclusion paths used in audit responses.
15//! - [`commitment_hash`] — the auditor's pin: a `BLAKE3` digest over the
16//! full signed commitment blob. Audit challenges carry this; audit
17//! responses must include a commitment that hashes to the same value.
18//! - [`verify_path`] — auditor's per-key check: rebuilds the leaf from
19//! `(key, bytes_hash)` and verifies the inclusion path against the
20//! committed root.
21//!
22//! Nothing else (responder gossip loop, auditor verify path,
23//! reward-eligibility cache) lives here yet — that's the next phase.
24
25use blake3::Hasher;
26use saorsa_pqc::api::sig::{
27 ml_dsa_65, MlDsaPublicKey, MlDsaSecretKey, MlDsaSignature, MlDsaVariant,
28};
29use serde::{Deserialize, Serialize};
30
31use crate::ant_protocol::XorName;
32
33/// Domain-separation tag for the commitment signature.
34///
35/// Signed payload is BLAKE3 over (this tag || canonical commitment fields).
36pub const DOMAIN_COMMITMENT: &[u8] = b"autonomi.ant.replication.storage_commitment.v1";
37
38/// Domain-separation tag for the auditor's pin: BLAKE3 over (this tag ||
39/// canonical commitment blob).
40pub const DOMAIN_COMMITMENT_HASH: &[u8] = b"autonomi.ant.replication.commitment_hash.v1";
41
42/// Domain-separation tag for Merkle leaves: `BLAKE3(this || key || H(bytes))`.
43pub const DOMAIN_LEAF: &[u8] = b"autonomi.ant.replication.storage_leaf.v1";
44
45/// Domain-separation tag for Merkle internal nodes: `BLAKE3(this || left || right)`.
46pub const DOMAIN_NODE: &[u8] = b"autonomi.ant.replication.storage_node.v1";
47
48/// Maximum number of keys a single commitment may cover.
49///
50/// Bounds the Merkle path depth (audit responses carry `O(log2 key_count)`
51/// hashes per key) and the responder-side tree memory. A node storing more
52/// keys than this would need to split its claim — out of scope for v1.
53pub const MAX_COMMITMENT_KEY_COUNT: u32 = 1_000_000;
54
55/// Signed storage commitment.
56///
57/// Piggybacked on neighbour-sync gossip. The signature commits to the
58/// Merkle root, key count, sender peer ID, **and the sender's ML-DSA-65
59/// public key** under [`DOMAIN_COMMITMENT`].
60///
61/// Embedding the public key lets any receiver verify the signature
62/// without an external `PeerId → MlDsaPublicKey` lookup. Binding the
63/// public key in the signed payload prevents a key-swap attack where an
64/// adversary keeps the message body but re-signs it under a different key
65/// to claim a different identity. The peer-id binding (gate 2a in
66/// `verify_commitment_bound_response`) still ensures the embedded key
67/// belongs to the gossiping peer.
68///
69/// # Wire size
70///
71/// One commitment is approximately 5.3 KiB:
72/// - root: 32 B
73/// - `key_count`: 4 B
74/// - `sender_peer_id`: 32 B
75/// - `sender_public_key`: 1952 B (ML-DSA-65 public key)
76/// - signature: 3293 B (ML-DSA-65 signature)
77///
78/// Piggybacked on every `NeighborSyncRequest`/`Response` (~1 h interval
79/// per close-group peer at the neighbour-sync cooldown cadence). At a
80/// realistic close-group size of 8 with bidirectional sync, that's
81/// roughly 8 × 2 × 5.3 KiB / hour = ~85 KiB/h of additional gossip
82/// per node. Negligible against typical chunk-transfer bandwidth.
83#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
84pub struct StorageCommitment {
85 /// Merkle root over the responder's claimed keys.
86 pub root: [u8; 32],
87 /// Number of leaves committed over.
88 pub key_count: u32,
89 /// Sender peer ID, bound to the signature.
90 pub sender_peer_id: [u8; 32],
91 /// Sender's ML-DSA-65 public key bytes (1952 bytes). Embedded so
92 /// receivers can verify the signature without a separate pubkey
93 /// directory. Bound by the signature.
94 pub sender_public_key: Vec<u8>,
95 /// ML-DSA-65 signature over canonical commitment fields. 3293 bytes.
96 pub signature: Vec<u8>,
97}
98
99// ---------------------------------------------------------------------------
100// Hashing helpers
101// ---------------------------------------------------------------------------
102
103/// Compute the Merkle leaf hash for `(key, bytes_hash)`.
104///
105/// `bytes_hash` is BLAKE3 over the record bytes; the leaf binds the key to
106/// the content so an adversary cannot reuse a leaf for a different chunk.
107#[must_use]
108pub fn leaf_hash(key: &XorName, bytes_hash: &[u8; 32]) -> [u8; 32] {
109 let mut h = Hasher::new();
110 h.update(DOMAIN_LEAF);
111 h.update(key);
112 h.update(bytes_hash);
113 *h.finalize().as_bytes()
114}
115
116/// Combine two child hashes into a Merkle internal-node hash.
117#[must_use]
118pub fn node_hash(left: &[u8; 32], right: &[u8; 32]) -> [u8; 32] {
119 let mut h = Hasher::new();
120 h.update(DOMAIN_NODE);
121 h.update(left);
122 h.update(right);
123 *h.finalize().as_bytes()
124}
125
126/// The auditor's pin: `BLAKE3(DOMAIN_COMMITMENT_HASH || postcard(commitment))`.
127///
128/// Equal commitments produce equal hashes; any change to `root`, `key_count`,
129/// peer ID, or signature changes the hash because postcard's canonical
130/// encoding includes a length prefix for `signature`. The audit challenge
131/// carries this value; the audit response must include a commitment that
132/// hashes to the same value, defeating fresh-commitment substitution.
133///
134/// Postcard encoding is the same canonical wire form the rest of the
135/// replication protocol uses (`MessageCodec::encode`), so an encoded
136/// commitment from a `NeighborSyncRequest` produces the same hash as the
137/// same commitment received in an `AuditResponse`.
138///
139/// # Errors
140///
141/// Returns `None` only if postcard fails to serialize the commitment, which
142/// in practice means the signature is somehow `> isize::MAX` bytes — not
143/// reachable for ML-DSA-65 (3293 bytes). Callers may safely treat `None` as
144/// a malformed commitment and drop it.
145#[must_use]
146pub fn commitment_hash(c: &StorageCommitment) -> Option<[u8; 32]> {
147 let serialized = postcard::to_allocvec(c).ok()?;
148 let mut h = Hasher::new();
149 h.update(DOMAIN_COMMITMENT_HASH);
150 h.update(&serialized);
151 Some(*h.finalize().as_bytes())
152}
153
154/// Canonical bytes the ML-DSA signature covers: the commitment fields
155/// minus the signature itself.
156///
157/// `sender_public_key` is included so an adversary cannot keep the body
158/// and re-sign under a different key (the audit-time verifier would
159/// otherwise accept the swap because verification uses the embedded key).
160fn commitment_signed_payload(
161 root: &[u8; 32],
162 key_count: u32,
163 sender_peer_id: &[u8; 32],
164 sender_public_key: &[u8],
165) -> Vec<u8> {
166 let mut v = Vec::with_capacity(32 + 4 + 32 + 4 + sender_public_key.len());
167 v.extend_from_slice(root);
168 v.extend_from_slice(&key_count.to_le_bytes());
169 v.extend_from_slice(sender_peer_id);
170 // Length-prefix the pubkey so two different (key, suffix) splits cannot
171 // produce the same byte stream (canonical encoding).
172 let pk_len = u32::try_from(sender_public_key.len()).unwrap_or(u32::MAX);
173 v.extend_from_slice(&pk_len.to_le_bytes());
174 v.extend_from_slice(sender_public_key);
175 v
176}
177
178// ---------------------------------------------------------------------------
179// Merkle tree
180// ---------------------------------------------------------------------------
181
182/// In-memory Merkle tree over the responder's claimed keys.
183///
184/// Leaves are `BLAKE3(DOMAIN_LEAF || key || BLAKE3(bytes))`, sorted by
185/// `key`. Internal nodes are `BLAKE3(DOMAIN_NODE || left || right)`. When
186/// a level has an odd number of nodes, the last node is paired with
187/// **itself** — i.e. `node_hash(x, x)` — so the level above has
188/// `ceil(n/2)` nodes. This is a standard self-pair construction (NOT
189/// node promotion) and deterministically maps any non-empty key set to
190/// a single root.
191///
192/// Rebuilt by the responder whenever its key set changes meaningfully
193/// (debounced in the integration layer; not this module's concern).
194pub struct MerkleTree {
195 /// Sorted leaves, indexed by their position in the sorted key set.
196 ///
197 /// `leaves[i] = (key_i, leaf_hash(key_i, bytes_hash_i))`.
198 leaves: Vec<(XorName, [u8; 32])>,
199 /// Tree levels, level 0 is the leaves and the last level is the root.
200 ///
201 /// `levels[0].len() == leaves.len()`; `levels[L].len() == 1` where L
202 /// is the root level.
203 levels: Vec<Vec<[u8; 32]>>,
204}
205
206impl MerkleTree {
207 /// Build a Merkle tree over `(key, bytes_hash)` pairs.
208 ///
209 /// `entries` does not need to be sorted; this method sorts internally
210 /// so the produced root is deterministic per key set. Duplicate keys
211 /// are an error: the responder must deduplicate before calling.
212 ///
213 /// # Errors
214 ///
215 /// Returns an error if `entries` is empty (no commitment to make), if
216 /// `entries.len() > MAX_COMMITMENT_KEY_COUNT`, or if it contains
217 /// duplicate keys.
218 pub fn build(mut entries: Vec<(XorName, [u8; 32])>) -> Result<Self, CommitmentError> {
219 if entries.is_empty() {
220 return Err(CommitmentError::EmptyKeySet);
221 }
222 if entries.len() > MAX_COMMITMENT_KEY_COUNT as usize {
223 return Err(CommitmentError::TooManyKeys(entries.len()));
224 }
225
226 entries.sort_by_key(|a| a.0);
227 for w in entries.windows(2) {
228 if let [a, b] = w {
229 if a.0 == b.0 {
230 return Err(CommitmentError::DuplicateKey(a.0));
231 }
232 }
233 }
234
235 let leaves: Vec<(XorName, [u8; 32])> = entries
236 .into_iter()
237 .map(|(k, bh)| {
238 let lh = leaf_hash(&k, &bh);
239 (k, lh)
240 })
241 .collect();
242
243 let mut level: Vec<[u8; 32]> = leaves.iter().map(|(_, h)| *h).collect();
244 let mut levels = vec![level.clone()];
245 while level.len() > 1 {
246 level = build_next_level(&level);
247 levels.push(level.clone());
248 }
249
250 Ok(Self { leaves, levels })
251 }
252
253 /// The Merkle root of this tree.
254 ///
255 /// `unwrap`-free: `build` guarantees at least one level with at least
256 /// one entry, so `last().first()` is always `Some`.
257 #[must_use]
258 pub fn root(&self) -> [u8; 32] {
259 // SAFETY: build() enforces non-empty entries → non-empty leaves →
260 // non-empty levels → last level has exactly one hash.
261 self.levels
262 .last()
263 .and_then(|l| l.first())
264 .copied()
265 .unwrap_or([0u8; 32])
266 }
267
268 /// The number of leaves (== claimed keys).
269 #[must_use]
270 pub fn key_count(&self) -> u32 {
271 // Cast is safe because build() rejects > MAX_COMMITMENT_KEY_COUNT.
272 u32::try_from(self.leaves.len()).unwrap_or(u32::MAX)
273 }
274
275 /// Inclusion path for `key` from its leaf up to (but not including)
276 /// the root.
277 ///
278 /// Returns `None` if `key` is not in this tree.
279 #[must_use]
280 pub fn path_for(&self, key: &XorName) -> Option<Vec<[u8; 32]>> {
281 let idx = self.leaves.binary_search_by(|(k, _)| k.cmp(key)).ok()?;
282
283 let mut path = Vec::with_capacity(self.levels.len());
284 let mut i = idx;
285 for level in &self.levels[..self.levels.len().saturating_sub(1)] {
286 // Sibling is the *other* half of the pair containing `i`. If
287 // `i` is the unpaired last node at this level, its sibling is
288 // itself (matches the self-pair construction in
289 // `build_next_level`).
290 let sibling_idx = if i % 2 == 0 {
291 if i + 1 < level.len() {
292 i + 1
293 } else {
294 i
295 }
296 } else {
297 i - 1
298 };
299 path.push(level[sibling_idx]);
300 i /= 2;
301 }
302 Some(path)
303 }
304
305 /// Iterate over `(key, leaf_hash)` pairs in sorted order. Test-only.
306 #[cfg(test)]
307 pub(crate) fn iter_leaves(&self) -> impl Iterator<Item = &(XorName, [u8; 32])> {
308 self.leaves.iter()
309 }
310
311 /// The keys this tree commits to, in sorted order.
312 ///
313 /// `sorted_keys()[i]` is the key at leaf index `i`. Used by the
314 /// responder's audit-answer path to recover the `leaf_index` field
315 /// for a challenged key in `O(log n)` via binary search.
316 #[must_use]
317 pub fn sorted_keys(&self) -> Vec<XorName> {
318 self.leaves.iter().map(|(k, _)| *k).collect()
319 }
320
321 /// The key at sorted leaf index `idx`, if in range.
322 ///
323 /// Used by the subtree-proof builder to enumerate the keys of a
324 /// contiguous leaf range without cloning the whole key list.
325 #[must_use]
326 pub fn key_at(&self, idx: usize) -> Option<XorName> {
327 self.leaves.get(idx).map(|(k, _)| *k)
328 }
329
330 /// The sorted leaf index of `key`, if committed. `O(log n)` binary search
331 /// over the (key-sorted) leaves — no separate key list needed, so callers
332 /// don't have to keep a duplicate `sorted_keys` Vec alongside the tree.
333 #[must_use]
334 pub fn key_index(&self, key: &XorName) -> Option<usize> {
335 self.leaves.binary_search_by(|(k, _)| k.cmp(key)).ok()
336 }
337
338 /// Whether `key` is committed. Allocation-free membership check via the same
339 /// binary search as [`Self::key_index`].
340 #[must_use]
341 pub fn contains_key(&self, key: &XorName) -> bool {
342 self.key_index(key).is_some()
343 }
344
345 /// The node hash at `(level, index)`, where `level` counts up from the
346 /// leaves (`level == 0` is the leaf level, the last level is the root).
347 ///
348 /// Returns `None` if out of range. Used by the subtree-proof builder to
349 /// read sibling cut-hashes along the path from the root to the selected
350 /// subtree; honours the same left-packed self-pair construction as the
351 /// rest of the tree (a caller asking for an out-of-range sibling on an
352 /// odd-length level should substitute the node itself).
353 #[must_use]
354 pub fn node_at(&self, level: usize, index: u64) -> Option<[u8; 32]> {
355 let index = usize::try_from(index).ok()?;
356 self.levels.get(level).and_then(|l| l.get(index)).copied()
357 }
358
359 /// The number of levels in the tree (`1` for a single-leaf tree; the
360 /// last index is the root level). `depth == levels_count() - 1`.
361 #[must_use]
362 pub fn levels_count(&self) -> usize {
363 self.levels.len()
364 }
365}
366
367/// Build the next level up from `cur`. Odd-length levels pair the last
368/// node with itself (`node_hash(x, x)`) so the level above has
369/// `ceil(n/2)` nodes. Keeps the tree balanced without needing a dummy
370/// leaf domain.
371///
372/// `pub(crate)` so the subtree-proof verifier folds a contiguous leaf block to
373/// its subtree root with the EXACT same self-pair rule (§10 — previously
374/// duplicated as `fold_levels`'s inner loop), guaranteeing the rebuilt node
375/// matches the committed tree bit-for-bit.
376pub(crate) fn build_next_level(cur: &[[u8; 32]]) -> Vec<[u8; 32]> {
377 let mut next = Vec::with_capacity(cur.len().div_ceil(2));
378 let mut i = 0;
379 while i < cur.len() {
380 let left = &cur[i];
381 let right = if i + 1 < cur.len() { &cur[i + 1] } else { left };
382 next.push(node_hash(left, right));
383 i += 2;
384 }
385 next
386}
387
388/// Verify an inclusion path against a commitment of size `key_count`.
389///
390/// `leaf_index` is the responder's position of this leaf in the sorted
391/// leaf set; the commitment's `key_count` comes from
392/// `StorageCommitment.key_count`.
393/// At each level of the path, if the current index is even, the current
394/// hash is the left child and we compute `node_hash(self, sibling)`;
395/// otherwise it is the right child and we compute `node_hash(sibling, self)`.
396///
397/// Returns `true` iff:
398/// - `leaf_index < key_count` (rejects out-of-range claims), AND
399/// - `path.len() == ceil(log2(key_count))` for `key_count > 1`, or
400/// `path.is_empty()` for `key_count == 1` (rejects wrong-shape paths
401/// before doing any hashing), AND
402/// - the recomputed root equals `expected_root`.
403#[must_use]
404pub fn verify_path(
405 leaf: &[u8; 32],
406 path: &[[u8; 32]],
407 leaf_index: usize,
408 key_count: u32,
409 expected_root: &[u8; 32],
410) -> bool {
411 if key_count == 0
412 || key_count > MAX_COMMITMENT_KEY_COUNT
413 || (leaf_index as u64) >= u64::from(key_count)
414 {
415 return false;
416 }
417 // Tree depth = ceil(log2(key_count)). For a power-of-two `n`,
418 // `n.next_power_of_two() == n` so trailing_zeros == log2(n). For non
419 // powers-of-two, next_power_of_two rounds up so trailing_zeros gives
420 // ceil(log2). Special case: key_count == 1 → next_power_of_two == 1
421 // → trailing_zeros == 0 → empty path, which matches the single-leaf
422 // tree's root == leaf invariant.
423 //
424 // `checked_next_power_of_two` returns None on overflow; combined with
425 // the MAX_COMMITMENT_KEY_COUNT cap above it cannot fail in practice,
426 // but the explicit check is profile-independent (release vs debug
427 // would otherwise differ on overflow per Rust's primitive docs).
428 let Some(rounded) = key_count.checked_next_power_of_two() else {
429 return false;
430 };
431 let expected_path_len = rounded.trailing_zeros() as usize;
432 if path.len() != expected_path_len {
433 return false;
434 }
435
436 let mut cur = *leaf;
437 let mut i = leaf_index;
438 for sibling in path {
439 cur = if i % 2 == 0 {
440 node_hash(&cur, sibling)
441 } else {
442 node_hash(sibling, &cur)
443 };
444 i /= 2;
445 }
446 cur == *expected_root
447}
448
449// ---------------------------------------------------------------------------
450// Sign + verify
451// ---------------------------------------------------------------------------
452
453/// Sign a commitment's `(root, key_count, sender_peer_id, sender_public_key)`
454/// with `secret_key`.
455///
456/// The signature is over the canonical signed payload (see
457/// `commitment_signed_payload`) under [`DOMAIN_COMMITMENT`].
458///
459/// # Errors
460///
461/// Returns an error if the underlying ML-DSA-65 signer fails.
462pub fn sign_commitment(
463 secret_key: &MlDsaSecretKey,
464 root: &[u8; 32],
465 key_count: u32,
466 sender_peer_id: &[u8; 32],
467 sender_public_key: &[u8],
468) -> Result<Vec<u8>, CommitmentError> {
469 let payload = commitment_signed_payload(root, key_count, sender_peer_id, sender_public_key);
470 let dsa = ml_dsa_65();
471 let sig = dsa
472 .sign_with_context(secret_key, &payload, DOMAIN_COMMITMENT)
473 .map_err(|e| CommitmentError::SignatureFailed(e.to_string()))?;
474 Ok(sig.to_bytes())
475}
476
477/// Verify a commitment's signature using the embedded `sender_public_key`.
478///
479/// Returns `true` iff the signature is valid for `(root, key_count,
480/// sender_peer_id, sender_public_key)` under `c.sender_public_key` and
481/// [`DOMAIN_COMMITMENT`]. Returns `false` on key-format or signature-format
482/// errors so the caller can simply drop the gossip.
483///
484/// Verifying against the embedded key removes the need for an external
485/// `PeerId → MlDsaPublicKey` lookup. The peer-id binding gate in
486/// `ingest_peer_commitment` (and the auditor's `evaluate_subtree_structure`)
487/// still ensures the embedded key belongs to the claimed peer.
488#[must_use]
489pub fn verify_commitment_signature(c: &StorageCommitment) -> bool {
490 let Ok(public_key) = MlDsaPublicKey::from_bytes(MlDsaVariant::MlDsa65, &c.sender_public_key)
491 else {
492 return false;
493 };
494 verify_commitment_signature_with_key(c, &public_key)
495}
496
497/// Verify a commitment's signature against an externally provided key.
498///
499/// Test-helper variant. Production code should use [`verify_commitment_signature`]
500/// since the key is embedded in the commitment.
501#[must_use]
502pub fn verify_commitment_signature_with_key(
503 c: &StorageCommitment,
504 public_key: &MlDsaPublicKey,
505) -> bool {
506 let payload = commitment_signed_payload(
507 &c.root,
508 c.key_count,
509 &c.sender_peer_id,
510 &c.sender_public_key,
511 );
512 let Ok(sig) = MlDsaSignature::from_bytes(MlDsaVariant::MlDsa65, &c.signature) else {
513 return false;
514 };
515 let dsa = ml_dsa_65();
516 dsa.verify_with_context(public_key, &payload, &sig, DOMAIN_COMMITMENT)
517 .unwrap_or(false)
518}
519
520// ---------------------------------------------------------------------------
521// Errors
522// ---------------------------------------------------------------------------
523
524/// Errors from commitment construction or verification.
525#[derive(Debug, Clone, thiserror::Error)]
526pub enum CommitmentError {
527 /// `MerkleTree::build` was called with an empty key set.
528 #[error("cannot build commitment over empty key set")]
529 EmptyKeySet,
530 /// Key set exceeds [`MAX_COMMITMENT_KEY_COUNT`].
531 #[error("commitment key count {0} exceeds MAX_COMMITMENT_KEY_COUNT")]
532 TooManyKeys(usize),
533 /// `MerkleTree::build` received the same key twice.
534 #[error("duplicate key in commitment: {}", hex::encode(.0))]
535 DuplicateKey(XorName),
536 /// Underlying ML-DSA-65 signer failed.
537 #[error("commitment signing failed: {0}")]
538 SignatureFailed(String),
539}
540
541// ---------------------------------------------------------------------------
542// Tests
543// ---------------------------------------------------------------------------
544
545#[cfg(test)]
546#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
547mod tests {
548 use super::*;
549
550 fn xn(byte: u8) -> XorName {
551 [byte; 32]
552 }
553
554 fn bh(byte: u8) -> [u8; 32] {
555 [byte ^ 0x5A; 32]
556 }
557
558 #[test]
559 fn empty_key_set_rejected() {
560 let result = MerkleTree::build(vec![]);
561 assert!(matches!(result, Err(CommitmentError::EmptyKeySet)));
562 }
563
564 #[test]
565 fn duplicate_keys_rejected() {
566 let result = MerkleTree::build(vec![(xn(1), bh(1)), (xn(1), bh(2))]);
567 assert!(matches!(result, Err(CommitmentError::DuplicateKey(_))));
568 }
569
570 #[test]
571 fn single_leaf_tree_root_is_leaf_hash() {
572 let key = xn(1);
573 let bytes_hash = bh(1);
574 let tree = MerkleTree::build(vec![(key, bytes_hash)]).unwrap();
575 assert_eq!(tree.root(), leaf_hash(&key, &bytes_hash));
576 assert_eq!(tree.key_count(), 1);
577 assert_eq!(tree.path_for(&key), Some(vec![]));
578 // Empty path verifies trivially (root == leaf).
579 assert!(verify_path(
580 &leaf_hash(&key, &bytes_hash),
581 &[],
582 0,
583 1,
584 &tree.root()
585 ));
586 }
587
588 #[test]
589 fn two_leaf_tree_root_combines_both_leaves() {
590 let entries = vec![(xn(1), bh(1)), (xn(2), bh(2))];
591 let tree = MerkleTree::build(entries).unwrap();
592 // Sorted order: xn(1), xn(2).
593 let l1 = leaf_hash(&xn(1), &bh(1));
594 let l2 = leaf_hash(&xn(2), &bh(2));
595 assert_eq!(tree.root(), node_hash(&l1, &l2));
596 }
597
598 #[test]
599 fn root_is_deterministic_regardless_of_input_order() {
600 let mut a = vec![(xn(3), bh(3)), (xn(1), bh(1)), (xn(2), bh(2))];
601 let mut b = vec![(xn(2), bh(2)), (xn(3), bh(3)), (xn(1), bh(1))];
602 let tree_a = MerkleTree::build(a.clone()).unwrap();
603 let tree_b = MerkleTree::build(b.clone()).unwrap();
604 a.sort_by_key(|x| x.0);
605 b.sort_by_key(|x| x.0);
606 assert_eq!(tree_a.root(), tree_b.root());
607 }
608
609 fn xn_u32(i: u32) -> XorName {
610 let mut k = [0u8; 32];
611 k[..4].copy_from_slice(&i.to_le_bytes());
612 k
613 }
614
615 fn bh_u32(i: u32) -> [u8; 32] {
616 let mut h = [0u8; 32];
617 h[..4].copy_from_slice(&i.to_le_bytes());
618 h[4] = 0x5A;
619 h
620 }
621
622 #[test]
623 fn paths_verify_for_every_key_at_various_sizes() {
624 for n in [1u32, 2, 3, 4, 5, 7, 8, 16, 17, 100, 333] {
625 let entries: Vec<_> = (0..n).map(|i| (xn_u32(i), bh_u32(i))).collect();
626 let tree = MerkleTree::build(entries.clone()).unwrap();
627 let root = tree.root();
628 let key_count = tree.key_count();
629 for (idx, (k, _)) in tree.iter_leaves().enumerate() {
630 let path = tree.path_for(k).expect("path for present key");
631 let bytes_hash = entries.iter().find(|(kk, _)| kk == k).unwrap().1;
632 let lh = leaf_hash(k, &bytes_hash);
633 assert!(
634 verify_path(&lh, &path, idx, key_count, &root),
635 "path verify failed at n={n} idx={idx}",
636 );
637 }
638 }
639 }
640
641 #[test]
642 fn path_for_absent_key_is_none() {
643 let tree = MerkleTree::build(vec![(xn(1), bh(1)), (xn(2), bh(2))]).unwrap();
644 assert!(tree.path_for(&xn(99)).is_none());
645 }
646
647 #[test]
648 fn tampered_bytes_hash_breaks_path_verify() {
649 // Use 8 distinct sorted keys so the index in `entries` matches the
650 // sorted leaf index in the tree.
651 let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect();
652 let tree = MerkleTree::build(entries.clone()).unwrap();
653 let root = tree.root();
654 let (k, _) = &entries[3];
655 let path = tree.path_for(k).unwrap();
656
657 let wrong_bytes_hash = [0xFFu8; 32];
658 let lh = leaf_hash(k, &wrong_bytes_hash);
659 assert!(!verify_path(&lh, &path, 3, 8, &root));
660 }
661
662 #[test]
663 fn tampered_path_node_breaks_verify() {
664 let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect();
665 let tree = MerkleTree::build(entries.clone()).unwrap();
666 let root = tree.root();
667 let (k, _) = &entries[3];
668 let mut path = tree.path_for(k).unwrap();
669 path[0][0] ^= 0x01;
670 let lh = leaf_hash(k, &bh(4));
671 assert!(!verify_path(&lh, &path, 3, 8, &root));
672 }
673
674 #[test]
675 fn wrong_leaf_index_breaks_verify() {
676 let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect();
677 let tree = MerkleTree::build(entries.clone()).unwrap();
678 let root = tree.root();
679 let (k, _) = &entries[3];
680 let path = tree.path_for(k).unwrap();
681 let lh = leaf_hash(k, &bh(4));
682 // Correct index is 3; using 2 should fail because the left/right
683 // child ordering swaps.
684 assert!(!verify_path(&lh, &path, 2, 8, &root));
685 assert!(verify_path(&lh, &path, 3, 8, &root));
686 }
687
688 #[test]
689 fn out_of_range_leaf_index_rejected() {
690 let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect();
691 let tree = MerkleTree::build(entries.clone()).unwrap();
692 let root = tree.root();
693 let (k, _) = &entries[3];
694 let path = tree.path_for(k).unwrap();
695 let lh = leaf_hash(k, &bh(4));
696 // leaf_index >= key_count must be rejected without even hashing.
697 assert!(!verify_path(&lh, &path, 8, 8, &root));
698 assert!(!verify_path(&lh, &path, 99, 8, &root));
699 // Valid baseline.
700 assert!(verify_path(&lh, &path, 3, 8, &root));
701 }
702
703 #[test]
704 fn wrong_path_length_rejected_pre_hashing() {
705 let entries: Vec<_> = (1..=8u8).map(|i| (xn(i), bh(i))).collect();
706 let tree = MerkleTree::build(entries.clone()).unwrap();
707 let root = tree.root();
708 let (k, _) = &entries[3];
709 let path = tree.path_for(k).unwrap();
710 let lh = leaf_hash(k, &bh(4));
711 // For key_count=8 the expected path length is 3 (ceil(log2(8))=3).
712 assert_eq!(path.len(), 3);
713 // Truncating breaks structural check.
714 let short: Vec<_> = path.iter().take(2).copied().collect();
715 assert!(!verify_path(&lh, &short, 3, 8, &root));
716 // Padding too long also breaks structural check.
717 let mut long = path;
718 long.push([0; 32]);
719 assert!(!verify_path(&lh, &long, 3, 8, &root));
720 }
721
722 #[test]
723 fn zero_key_count_rejected() {
724 // Defensive: even with an empty path and correct-shape root, a
725 // commitment claiming zero keys is nonsensical.
726 let lh = [0u8; 32];
727 assert!(!verify_path(&lh, &[], 0, 0, &[0u8; 32]));
728 }
729
730 #[test]
731 fn out_of_protocol_key_count_rejected() {
732 // Wire-supplied key_count exceeding MAX_COMMITMENT_KEY_COUNT is
733 // refused before any hashing. Guards an overflow found in review:
734 // `next_power_of_two()` would otherwise panic in debug and wrap in
735 // release on key_count > 1 << 31.
736 let lh = [0u8; 32];
737 assert!(!verify_path(
738 &lh,
739 &[],
740 0,
741 MAX_COMMITMENT_KEY_COUNT + 1,
742 &[0u8; 32]
743 ));
744 assert!(!verify_path(&lh, &[], 0, u32::MAX, &[0u8; 32]));
745 }
746
747 fn pk_bytes(pk: &MlDsaPublicKey) -> Vec<u8> {
748 pk.to_bytes()
749 }
750
751 #[test]
752 fn sign_and_verify_roundtrip() {
753 let dsa = ml_dsa_65();
754 let (pk, sk) = dsa.generate_keypair().unwrap();
755 let entries: Vec<_> = (0..5u8).map(|i| (xn(i), bh(i))).collect();
756 let tree = MerkleTree::build(entries).unwrap();
757 let root = tree.root();
758 let key_count = tree.key_count();
759 let peer_id = [0xAB; 32];
760 let pk_b = pk_bytes(&pk);
761 let signature = sign_commitment(&sk, &root, key_count, &peer_id, &pk_b).unwrap();
762 let c = StorageCommitment {
763 root,
764 key_count,
765 sender_peer_id: peer_id,
766 sender_public_key: pk_b,
767 signature,
768 };
769 // Verifies via embedded key, no external lookup needed.
770 assert!(verify_commitment_signature(&c));
771 }
772
773 #[test]
774 fn signature_fails_when_root_tampered() {
775 let dsa = ml_dsa_65();
776 let (pk, sk) = dsa.generate_keypair().unwrap();
777 let root = [0u8; 32];
778 let pk_b = pk_bytes(&pk);
779 let signature = sign_commitment(&sk, &root, 1, &[0; 32], &pk_b).unwrap();
780 let c = StorageCommitment {
781 root: [1u8; 32], // tampered
782 key_count: 1,
783 sender_peer_id: [0; 32],
784 sender_public_key: pk_b,
785 signature,
786 };
787 assert!(!verify_commitment_signature(&c));
788 }
789
790 #[test]
791 fn signature_fails_under_swapped_public_key() {
792 let dsa = ml_dsa_65();
793 let (pk1, sk1) = dsa.generate_keypair().unwrap();
794 let (pk2, _sk2) = dsa.generate_keypair().unwrap();
795 let pk1_b = pk_bytes(&pk1);
796 let pk2_b = pk_bytes(&pk2);
797 // Sign under pk1 but embed pk2 — verification (using embedded key)
798 // should fail because pk2 didn't sign this payload AND because the
799 // signed payload binds pk1, not pk2.
800 let signature = sign_commitment(&sk1, &[0u8; 32], 1, &[0; 32], &pk1_b).unwrap();
801 let c = StorageCommitment {
802 root: [0u8; 32],
803 key_count: 1,
804 sender_peer_id: [0; 32],
805 sender_public_key: pk2_b,
806 signature,
807 };
808 assert!(!verify_commitment_signature(&c));
809 }
810
811 #[test]
812 fn signature_fails_with_garbage_bytes() {
813 let dsa = ml_dsa_65();
814 let (pk, _sk) = dsa.generate_keypair().unwrap();
815 let c = StorageCommitment {
816 root: [0u8; 32],
817 key_count: 1,
818 sender_peer_id: [0; 32],
819 sender_public_key: pk_bytes(&pk),
820 signature: vec![0u8; 100], // too short and zero-filled
821 };
822 assert!(!verify_commitment_signature(&c));
823 }
824
825 #[test]
826 fn signature_fails_with_garbage_public_key() {
827 // Embedded pubkey is wrong length / invalid → from_bytes fails →
828 // verify returns false. Defends against malformed gossip.
829 let c = StorageCommitment {
830 root: [0u8; 32],
831 key_count: 1,
832 sender_peer_id: [0; 32],
833 sender_public_key: vec![0u8; 100], // wrong length
834 signature: vec![0u8; 3293],
835 };
836 assert!(!verify_commitment_signature(&c));
837 }
838
839 #[test]
840 fn commitment_hash_differs_on_any_field_change() {
841 let dsa = ml_dsa_65();
842 let (pk, sk) = dsa.generate_keypair().unwrap();
843 let pk_b = pk_bytes(&pk);
844 let sig = sign_commitment(&sk, &[0; 32], 1, &[0; 32], &pk_b).unwrap();
845 let c1 = StorageCommitment {
846 root: [0; 32],
847 key_count: 1,
848 sender_peer_id: [0; 32],
849 sender_public_key: pk_b,
850 signature: sig,
851 };
852 let h1 = commitment_hash(&c1).unwrap();
853
854 let mut c2 = c1.clone();
855 c2.root = [1; 32];
856 assert_ne!(h1, commitment_hash(&c2).unwrap());
857
858 let mut c3 = c1.clone();
859 c3.key_count = 2;
860 assert_ne!(h1, commitment_hash(&c3).unwrap());
861
862 let mut c4 = c1.clone();
863 c4.sender_peer_id = [1; 32];
864 assert_ne!(h1, commitment_hash(&c4).unwrap());
865
866 let mut c5 = c1.clone();
867 c5.signature[0] ^= 1;
868 assert_ne!(h1, commitment_hash(&c5).unwrap());
869
870 let (pk_other, _) = dsa.generate_keypair().unwrap();
871 let mut c6 = c1;
872 c6.sender_public_key = pk_bytes(&pk_other);
873 assert_ne!(h1, commitment_hash(&c6).unwrap());
874 }
875
876 #[test]
877 fn commitment_hash_stable_for_identical_input() {
878 let dsa = ml_dsa_65();
879 let (pk, sk) = dsa.generate_keypair().unwrap();
880 let pk_b = pk_bytes(&pk);
881 let sig = sign_commitment(&sk, &[7; 32], 42, &[3; 32], &pk_b).unwrap();
882 let c = StorageCommitment {
883 root: [7; 32],
884 key_count: 42,
885 sender_peer_id: [3; 32],
886 sender_public_key: pk_b,
887 signature: sig,
888 };
889 assert_eq!(commitment_hash(&c), commitment_hash(&c));
890 }
891
892 #[test]
893 fn commitment_hash_signature_length_change_changes_hash() {
894 // Postcard's varint length prefix means hashing a 1-byte signature
895 // and a 2-byte signature whose first byte is the same produces
896 // different commitment hashes — a hash that omitted the serialized
897 // length prefix would let boundary-shifted fields collide.
898 let c1 = StorageCommitment {
899 root: [0; 32],
900 key_count: 1,
901 sender_peer_id: [0; 32],
902 sender_public_key: vec![0u8; 1952],
903 signature: vec![0xAB],
904 };
905 let c2 = StorageCommitment {
906 root: [0; 32],
907 key_count: 1,
908 sender_peer_id: [0; 32],
909 sender_public_key: vec![0u8; 1952],
910 signature: vec![0xAB, 0x00],
911 };
912 assert_ne!(commitment_hash(&c1).unwrap(), commitment_hash(&c2).unwrap());
913 }
914
915 #[test]
916 fn too_many_keys_rejected() {
917 let mut entries = Vec::with_capacity(MAX_COMMITMENT_KEY_COUNT as usize + 1);
918 for i in 0..=MAX_COMMITMENT_KEY_COUNT {
919 let mut k = [0u8; 32];
920 k[..4].copy_from_slice(&i.to_le_bytes());
921 entries.push((k, [0; 32]));
922 }
923 let result = MerkleTree::build(entries);
924 assert!(matches!(result, Err(CommitmentError::TooManyKeys(_))));
925 }
926}