Skip to main content

vid/
avidm_gf2.rs

1//! This module implements the AVID-M scheme over GF2
2
3use std::{ops::Range, vec};
4
5use anyhow::anyhow;
6use ark_serialize::{CanonicalDeserialize, CanonicalSerialize};
7use jf_merkle_tree::{MerkleTreeScheme, append_only::MerkleTree as JfMerkleTree};
8use p3_maybe_rayon::prelude::*;
9use serde::{Deserialize, Serialize};
10use tagged_base64::tagged;
11
12use crate::{
13    VidError, VidResult, VidScheme,
14    utils::blake3::{Blake3DigestAlgorithm, Blake3Node},
15};
16
17/// Namespaced AvidmGf2 scheme
18pub mod namespaced;
19/// Namespace proofs for AvidmGf2 scheme
20pub mod proofs;
21
22/// Merkle tree scheme used in the VID. Uses BLAKE3 directly via
23/// [`Blake3DigestAlgorithm`] rather than going through the
24/// `jf_merkle_tree::hasher::HasherDigest` blanket impl, which would pin
25/// `blake3` to a `digest 0.10`-compatible release line.
26pub(crate) type MerkleTree = JfMerkleTree<Blake3Node, Blake3DigestAlgorithm, u64, 4, Blake3Node>;
27type MerkleProof = <MerkleTree as MerkleTreeScheme>::MembershipProof;
28type MerkleCommit = <MerkleTree as MerkleTreeScheme>::Commitment;
29
30/// Dummy struct for AVID-M scheme over GF2
31pub struct AvidmGf2Scheme;
32
33/// VID Parameters
34#[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)]
35pub struct AvidmGf2Param {
36    /// Total weights of all storage nodes
37    pub total_weights: usize,
38    /// Minimum collective weights required to recover the original payload.
39    pub recovery_threshold: usize,
40}
41
42impl AvidmGf2Param {
43    /// Construct a new [`AvidmGf2Param`].
44    pub fn new(recovery_threshold: usize, total_weights: usize) -> VidResult<Self> {
45        if recovery_threshold == 0 || total_weights < recovery_threshold {
46            return Err(VidError::InvalidParam);
47        }
48        Ok(Self {
49            total_weights,
50            recovery_threshold,
51        })
52    }
53}
54
55/// VID Share type to be distributed among the parties.
56#[derive(Clone, Debug, Hash, Serialize, Deserialize, PartialEq, Eq)]
57pub struct AvidmGf2Share {
58    /// Range of this share in the encoded payload.
59    range: Range<usize>,
60    /// Actual share content.
61    #[serde(with = "nested_bytes")]
62    payload: Vec<Vec<u8>>,
63    /// Merkle proof of the content.
64    mt_proofs: Vec<MerkleProof>,
65}
66
67/// Optimised serialisation of a sequence of `Vec<u8>`s using `serde_bytes`.
68mod nested_bytes {
69    use serde::{Deserialize, Deserializer, Serializer, ser::SerializeSeq};
70    use serde_bytes::{ByteBuf, Bytes};
71
72    pub fn serialize<S>(v: &[Vec<u8>], s: S) -> Result<S::Ok, S::Error>
73    where
74        S: Serializer,
75    {
76        let mut seq = s.serialize_seq(Some(v.len()))?;
77        for inner in v {
78            seq.serialize_element(Bytes::new(inner))?;
79        }
80        seq.end()
81    }
82
83    pub fn deserialize<'de, D>(d: D) -> Result<Vec<Vec<u8>>, D::Error>
84    where
85        D: Deserializer<'de>,
86    {
87        let v: Vec<ByteBuf> = Deserialize::deserialize(d)?;
88        Ok(v.into_iter().map(ByteBuf::into_vec).collect())
89    }
90}
91
92impl AvidmGf2Share {
93    /// Get the weight of this share
94    pub fn weight(&self) -> usize {
95        self.range.len()
96    }
97
98    /// Range of this share in the encoded payload.
99    pub fn range(&self) -> &Range<usize> {
100        &self.range
101    }
102
103    /// Validate the share structure.
104    pub fn validate(&self) -> bool {
105        self.payload.len() == self.range.len() && self.mt_proofs.len() == self.range.len()
106    }
107}
108
109/// VID Commitment type
110#[derive(
111    Clone,
112    Copy,
113    Debug,
114    Default,
115    Hash,
116    CanonicalSerialize,
117    CanonicalDeserialize,
118    Eq,
119    PartialEq,
120    Ord,
121    PartialOrd,
122)]
123#[tagged("AvidmGf2Commit")]
124#[repr(C)]
125pub struct AvidmGf2Commit {
126    /// VID commitment is the Merkle tree root
127    pub commit: MerkleCommit,
128}
129
130impl AsRef<[u8]> for AvidmGf2Commit {
131    fn as_ref(&self) -> &[u8] {
132        self.commit.as_ref()
133    }
134}
135
136impl AsRef<[u8; 32]> for AvidmGf2Commit {
137    fn as_ref(&self) -> &[u8; 32] {
138        <Self as AsRef<[u8]>>::as_ref(self)
139            .try_into()
140            .expect("AvidmGf2Commit is always 32 bytes")
141    }
142}
143
144impl AvidmGf2Scheme {
145    /// Setup an instance for AVID-M scheme
146    pub fn setup(recovery_threshold: usize, total_weights: usize) -> VidResult<AvidmGf2Param> {
147        AvidmGf2Param::new(recovery_threshold, total_weights)
148    }
149
150    /// Build the `original_count` original shards directly from `payload`,
151    /// applying the AvidM-GF2 bit padding (one `0x01` byte at
152    /// `payload.len()` followed by zeros to fill the final shard).
153    ///
154    /// Writing the chunks straight out avoids allocating an intermediate
155    /// `shard_bytes * original_count`-byte buffer just to re-chunk it.
156    fn chunk_and_pad(
157        payload: &[u8],
158        shard_bytes: usize,
159        original_count: usize,
160    ) -> VidResult<Vec<Vec<u8>>> {
161        let padded_len = shard_bytes * original_count;
162        if padded_len < payload.len() + 1 {
163            return Err(VidError::Argument(
164                "Payload length is too large to fit in the given payload length".to_string(),
165            ));
166        }
167        let mut original: Vec<Vec<u8>> = Vec::with_capacity(original_count);
168        for i in 0..original_count {
169            let start = i * shard_bytes;
170            let mut chunk = vec![0u8; shard_bytes];
171            if start < payload.len() {
172                let end = ((i + 1) * shard_bytes).min(payload.len());
173                let take = end - start;
174                chunk[..take].copy_from_slice(&payload[start..end]);
175                if take < shard_bytes {
176                    // Pad byte falls inside this chunk.
177                    chunk[take] = 1u8;
178                }
179            } else if start == payload.len() {
180                // Payload ended exactly on a chunk boundary — pad byte is the
181                // first byte of this all-zero chunk.
182                chunk[0] = 1u8;
183            }
184            original.push(chunk);
185        }
186        Ok(original)
187    }
188
189    fn raw_disperse(
190        param: &AvidmGf2Param,
191        payload: &[u8],
192    ) -> VidResult<(MerkleTree, Vec<Vec<u8>>)> {
193        let original_count = param.recovery_threshold;
194        let recovery_count = param.total_weights - param.recovery_threshold;
195        let mut shard_bytes = (payload.len() + 1).div_ceil(original_count);
196        if shard_bytes % 2 == 1 {
197            shard_bytes += 1;
198        }
199        let original = Self::chunk_and_pad(payload, shard_bytes, original_count)?;
200        let recovery = if recovery_count == 0 {
201            vec![]
202        } else {
203            reed_solomon_simd::encode(original_count, recovery_count, &original)?
204        };
205
206        let shares = [original, recovery].concat();
207        let share_digests: Vec<Blake3Node> = shares
208            .par_iter()
209            .map(|share| Blake3Node::from(blake3::hash(share)))
210            .collect();
211        let mt = MerkleTree::from_elems(None, &share_digests)?;
212        Ok((mt, shares))
213    }
214
215    /// Test-only: disperse `payload` but corrupt the erasure-coded (recovery)
216    /// shards, so the committed shard set is **not** a valid codeword. Every
217    /// returned share still verifies against the returned commitment — its
218    /// merkle proofs are genuine — yet recovering from any threshold-covering
219    /// subset and re-committing yields a *different* commitment. This models a
220    /// Byzantine disperser that commits to a non-codeword, exercising the
221    /// unrecoverable reconstruction path.
222    ///
223    /// Requires `recovery_threshold < total_weights` so recovery shards exist
224    /// to corrupt.
225    #[cfg(any(test, feature = "testing"))]
226    pub fn disperse_non_codeword(
227        param: &AvidmGf2Param,
228        distribution: &[u32],
229        payload: &[u8],
230    ) -> VidResult<(AvidmGf2Commit, Vec<AvidmGf2Share>)> {
231        let total_weights = distribution.iter().map(|&w| w as usize).sum::<usize>();
232        if total_weights != param.total_weights {
233            return Err(VidError::Argument(
234                "Weight distribution is inconsistent with the given param".to_string(),
235            ));
236        }
237        if distribution.contains(&0u32) {
238            return Err(VidError::Argument("Weight cannot be zero".to_string()));
239        }
240        let original_count = param.recovery_threshold;
241        let (_, mut shards) = Self::raw_disperse(param, payload)?;
242        if shards.len() <= original_count {
243            return Err(VidError::Argument(
244                "Payload has no recovery shards to corrupt".to_string(),
245            ));
246        }
247        // Flip a byte in every recovery shard. The original shards are
248        // untouched, so each shard still verifies against the rebuilt tree, but
249        // the recovery shards no longer match the Reed-Solomon encoding of the
250        // originals: the committed set is not a codeword and cannot re-commit.
251        for shard in &mut shards[original_count..] {
252            shard[0] ^= 0xff;
253        }
254        let share_digests: Vec<Blake3Node> = shards
255            .iter()
256            .map(|shard| Blake3Node::from(blake3::hash(shard)))
257            .collect();
258        let mt = MerkleTree::from_elems(None, &share_digests)?;
259        let commit = AvidmGf2Commit {
260            commit: mt.commitment(),
261        };
262        let ranges: Vec<_> = distribution
263            .iter()
264            .scan(0usize, |sum, w| {
265                let prefix_sum = *sum;
266                *sum += *w as usize;
267                Some(prefix_sum..*sum)
268            })
269            .collect();
270        let mut shards_iter = shards.into_iter();
271        let payloads: Vec<Vec<Vec<u8>>> = ranges
272            .iter()
273            .map(|range| shards_iter.by_ref().take(range.len()).collect())
274            .collect();
275        let mut proofs_iter = mt
276            .collect_leaves_with_proofs()
277            .into_iter()
278            .map(|(_, _, proof)| proof);
279        let proof_groups: Vec<Vec<MerkleProof>> = ranges
280            .iter()
281            .map(|range| proofs_iter.by_ref().take(range.len()).collect())
282            .collect();
283        let shares: Vec<_> = ranges
284            .into_iter()
285            .zip(payloads)
286            .zip(proof_groups)
287            .map(|((range, payload), mt_proofs)| AvidmGf2Share {
288                range,
289                payload,
290                mt_proofs,
291            })
292            .collect();
293        Ok((commit, shares))
294    }
295}
296
297impl VidScheme for AvidmGf2Scheme {
298    type Param = AvidmGf2Param;
299    type Share = AvidmGf2Share;
300    type Commit = AvidmGf2Commit;
301
302    fn commit(param: &Self::Param, payload: &[u8]) -> VidResult<Self::Commit> {
303        let (mt, _) = Self::raw_disperse(param, payload)?;
304        Ok(Self::Commit {
305            commit: mt.commitment(),
306        })
307    }
308
309    fn disperse(
310        param: &Self::Param,
311        distribution: &[u32],
312        payload: &[u8],
313    ) -> VidResult<(Self::Commit, Vec<Self::Share>)> {
314        let total_weights = distribution.iter().map(|&w| w as usize).sum::<usize>();
315        if total_weights != param.total_weights {
316            return Err(VidError::Argument(
317                "Weight distribution is inconsistent with the given param".to_string(),
318            ));
319        }
320        if distribution.contains(&0u32) {
321            return Err(VidError::Argument("Weight cannot be zero".to_string()));
322        }
323        let (mt, shares) = Self::raw_disperse(param, payload)?;
324        let commit = AvidmGf2Commit {
325            commit: mt.commitment(),
326        };
327
328        let ranges: Vec<_> = distribution
329            .iter()
330            .scan(0usize, |sum, w| {
331                let prefix_sum = *sum;
332                *sum += *w as usize;
333                Some(prefix_sum..*sum)
334            })
335            .collect();
336        // Ranges partition `shares` and `proofs` in order. Consume both via
337        // owning iterators instead of `shares[range].to_vec()` /
338        // `proofs[range].to_vec()`, which would heap-clone every Vec<u8>
339        // payload and every per-leaf proof at high num_ns × total_weights.
340        //
341        // `mt.collect_leaves_with_proofs()` returns leaves in ascending
342        // position order (DFS over children 0..ARITY), so we can drain the
343        // iterator directly without an indexed placeholder Vec.
344        let mut shares_iter = shares.into_iter();
345        let payloads: Vec<Vec<Vec<u8>>> = ranges
346            .iter()
347            .map(|range| shares_iter.by_ref().take(range.len()).collect())
348            .collect();
349        let mut proofs_iter = mt
350            .collect_leaves_with_proofs()
351            .into_iter()
352            .map(|(_, _, proof)| proof);
353        let proof_groups: Vec<Vec<MerkleProof>> = ranges
354            .iter()
355            .map(|range| proofs_iter.by_ref().take(range.len()).collect())
356            .collect();
357        // The map body is just a struct construction over already-prepared
358        // owned components — sub-µs per item, smaller than rayon's
359        // per-item scheduling overhead. Stay sequential.
360        let shares: Vec<_> = ranges
361            .into_iter()
362            .zip(payloads)
363            .zip(proof_groups)
364            .map(|((range, payload), mt_proofs)| AvidmGf2Share {
365                range,
366                payload,
367                mt_proofs,
368            })
369            .collect();
370        Ok((commit, shares))
371    }
372
373    fn verify_share(
374        param: &Self::Param,
375        commit: &Self::Commit,
376        share: &Self::Share,
377    ) -> VidResult<crate::VerificationResult> {
378        if !share.validate() || share.range.is_empty() || share.range.end > param.total_weights {
379            return Err(VidError::InvalidShare);
380        }
381        // Each (i, leaf, proof) triple is independent. `find_any` short-
382        // circuits on the first failing position and avoids allocating any
383        // intermediate collection.
384        let start = share.range.start;
385        let len = share.range.end - start;
386        match (0..len)
387            .into_par_iter()
388            .map(|i| -> VidResult<crate::VerificationResult> {
389                let payload_digest = Blake3Node::from(blake3::hash(&share.payload[i]));
390                MerkleTree::verify(
391                    commit.commit,
392                    (start + i) as u64,
393                    payload_digest,
394                    &share.mt_proofs[i],
395                )
396                .map_err(VidError::from)
397            })
398            .find_any(|r| !matches!(r, Ok(Ok(()))))
399        {
400            None => Ok(Ok(())),
401            Some(Ok(v)) => Ok(v),
402            Some(Err(e)) => Err(e),
403        }
404    }
405
406    fn recover(
407        param: &Self::Param,
408        _commit: &Self::Commit,
409        shares: &[Self::Share],
410    ) -> VidResult<Vec<u8>> {
411        let original_count = param.recovery_threshold;
412        let recovery_count = param.total_weights - param.recovery_threshold;
413        // Find the first non-empty share
414        let Some(first_share) = shares.iter().find(|s| !s.payload.is_empty()) else {
415            return Err(VidError::InsufficientShares);
416        };
417        let shard_bytes = first_share.payload[0].len();
418
419        // Track references to input original shards; avoids the per-shard
420        // `.clone()` the previous version did to populate a
421        // `Vec<Option<Vec<u8>>>`. Reconstructed shards come from the decoder
422        // and are copied directly into the output buffer below.
423        let mut input_orig: Vec<Option<&[u8]>> = vec![None; original_count];
424
425        let mut recovered: Vec<u8> = Vec::with_capacity(original_count * shard_bytes);
426        if recovery_count == 0 {
427            // Edge case where there are no recovery shares: every original must
428            // be supplied as input.
429            for share in shares {
430                if !share.validate() || share.payload.iter().any(|p| p.len() != shard_bytes) {
431                    return Err(VidError::InvalidShare);
432                }
433                for (i, index) in share.range.clone().enumerate() {
434                    if index < original_count {
435                        input_orig[index] = Some(&share.payload[i]);
436                    }
437                }
438            }
439            for slot in &input_orig {
440                let shard = slot
441                    .ok_or_else(|| VidError::Internal(anyhow!("Failed to recover the payload.")))?;
442                recovered.extend_from_slice(shard);
443            }
444        } else {
445            let mut decoder = reed_solomon_simd::ReedSolomonDecoder::new(
446                original_count,
447                recovery_count,
448                shard_bytes,
449            )?;
450            for share in shares {
451                if !share.validate() || share.payload.iter().any(|p| p.len() != shard_bytes) {
452                    return Err(VidError::InvalidShare);
453                }
454                for (i, index) in share.range.clone().enumerate() {
455                    let shard = &share.payload[i];
456                    if index < original_count {
457                        input_orig[index] = Some(shard);
458                        decoder.add_original_shard(index, shard)?;
459                    } else {
460                        decoder.add_recovery_shard(index - original_count, shard)?;
461                    }
462                }
463            }
464
465            let result = decoder.decode()?;
466            for (i, shard) in input_orig.iter().enumerate().take(original_count) {
467                let shard: &[u8] = match shard {
468                    Some(data) => data,
469                    None => result.restored_original(i).ok_or_else(|| {
470                        VidError::Internal(anyhow!("Failed to recover the payload."))
471                    })?,
472                };
473                recovered.extend_from_slice(shard);
474            }
475        }
476        match recovered.iter().rposition(|&b| b != 0) {
477            Some(pad_index) if recovered[pad_index] == 1u8 => {
478                recovered.truncate(pad_index);
479                Ok(recovered)
480            },
481            _ => Err(VidError::Argument(
482                "Malformed payload, cannot find the padding position".to_string(),
483            )),
484        }
485    }
486}
487
488/// Unit tests
489#[cfg(test)]
490pub mod tests {
491    use rand::{RngCore, seq::SliceRandom};
492
493    use super::AvidmGf2Scheme;
494    use crate::VidScheme;
495
496    #[test]
497    fn round_trip() {
498        // play with these items
499        let num_storage_nodes_list = [4, 9, 16];
500        let payload_byte_lens = [1, 31, 32, 500];
501
502        // more items as a function of the above
503
504        let mut rng = jf_utils::test_rng();
505
506        for num_storage_nodes in num_storage_nodes_list {
507            let weights: Vec<u32> = (0..num_storage_nodes)
508                .map(|_| rng.next_u32() % 5 + 1)
509                .collect();
510            let total_weights: u32 = weights.iter().sum();
511            let recovery_threshold = total_weights.div_ceil(3) as usize;
512            let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights as usize).unwrap();
513
514            for payload_byte_len in payload_byte_lens {
515                let payload = {
516                    let mut bytes_random = vec![0u8; payload_byte_len];
517                    rng.fill_bytes(&mut bytes_random);
518                    bytes_random
519                };
520
521                let (commit, mut shares) =
522                    AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
523
524                assert_eq!(shares.len(), num_storage_nodes);
525
526                // verify shares
527                shares.iter().for_each(|share| {
528                    assert!(
529                        AvidmGf2Scheme::verify_share(&params, &commit, share)
530                            .is_ok_and(|r| r.is_ok())
531                    )
532                });
533
534                // test payload recovery on a random subset of shares
535                shares.shuffle(&mut rng);
536                let mut cumulated_weights = 0;
537                let mut cut_index = 0;
538                while cumulated_weights < recovery_threshold {
539                    cumulated_weights += shares[cut_index].weight();
540                    cut_index += 1;
541                }
542                let payload_recovered =
543                    AvidmGf2Scheme::recover(&params, &commit, &shares[..cut_index]).unwrap();
544                assert_eq!(payload_recovered, payload);
545            }
546        }
547    }
548
549    #[test]
550    fn round_trip_edge_case() {
551        // play with these items
552        let num_storage_nodes_list = [4, 9, 16];
553        let payload_byte_lens = [1, 31, 32, 500];
554
555        // more items as a function of the above
556
557        let mut rng = jf_utils::test_rng();
558
559        for num_storage_nodes in num_storage_nodes_list {
560            let weights: Vec<u32> = (0..num_storage_nodes)
561                .map(|_| rng.next_u32() % 5 + 1)
562                .collect();
563            let total_weights: u32 = weights.iter().sum();
564            let recovery_threshold = total_weights as usize;
565            let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights as usize).unwrap();
566
567            for payload_byte_len in payload_byte_lens {
568                let payload = {
569                    let mut bytes_random = vec![0u8; payload_byte_len];
570                    rng.fill_bytes(&mut bytes_random);
571                    bytes_random
572                };
573
574                let (commit, mut shares) =
575                    AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
576
577                assert_eq!(shares.len(), num_storage_nodes);
578
579                // verify shares
580                shares.iter().for_each(|share| {
581                    assert!(
582                        AvidmGf2Scheme::verify_share(&params, &commit, share)
583                            .is_ok_and(|r| r.is_ok())
584                    )
585                });
586
587                // test payload recovery on a random subset of shares
588                shares.shuffle(&mut rng);
589                let payload_recovered =
590                    AvidmGf2Scheme::recover(&params, &commit, &shares[..]).unwrap();
591                assert_eq!(payload_recovered, payload);
592            }
593        }
594    }
595
596    #[test]
597    fn disperse_rejects_inconsistent_distribution() {
598        let total_weights = 10usize;
599        let recovery_threshold = 4;
600        let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights).unwrap();
601        let payload = vec![1u8; 100];
602
603        // distribution sums to 12, but param says total_weights=10
604        let bad_weights = vec![3u32; 4];
605        assert!(
606            AvidmGf2Scheme::disperse(&params, &bad_weights, &payload).is_err(),
607            "disperse should reject distribution that doesn't sum to total_weights"
608        );
609
610        // distribution contains a zero weight
611        let zero_weight = vec![0u32, 5, 5];
612        assert!(
613            AvidmGf2Scheme::disperse(&params, &zero_weight, &payload).is_err(),
614            "disperse should reject zero-weight entries"
615        );
616
617        // correct distribution should succeed
618        let good_weights = vec![2u32; 5];
619        assert!(AvidmGf2Scheme::disperse(&params, &good_weights, &payload).is_ok());
620    }
621
622    #[test]
623    fn verify_share_rejects_out_of_range() {
624        let total_weights = 10usize;
625        let recovery_threshold = 4;
626        let params = AvidmGf2Scheme::setup(recovery_threshold, total_weights).unwrap();
627        let payload = vec![1u8; 100];
628        let weights = vec![2u32; 5];
629
630        let (commit, shares) = AvidmGf2Scheme::disperse(&params, &weights, &payload).unwrap();
631
632        // valid shares pass
633        for share in &shares {
634            assert!(AvidmGf2Scheme::verify_share(&params, &commit, share).is_ok_and(|r| r.is_ok()));
635        }
636
637        // a share verified against a smaller param should be rejected
638        let smaller_params = AvidmGf2Scheme::setup(2, 5).unwrap();
639        let last_share = shares.last().unwrap();
640        assert!(
641            AvidmGf2Scheme::verify_share(&smaller_params, &commit, last_share).is_err(),
642            "verify_share should reject share with range.end > param.total_weights"
643        );
644    }
645}