diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 6656a1e9..620fc2af 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -21,6 +21,9 @@ jobs: - name: Install protobuf compiler run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Cache cargo build + uses: Swatinem/rust-cache@v2 + - name: Check code format run: cargo fmt --all -- --check @@ -28,4 +31,6 @@ jobs: run: cargo clippy --all-targets --all-features -- -D warnings - name: Run tests - run: cargo test --all-features + # Release profile: one compile pass, and the trBFV secure-preset e2e + # tests (tests/trbfv_secure_e2e.rs) run in seconds instead of minutes. + run: cargo test --release --all-features diff --git a/crates/fhe/src/trbfv/config.rs b/crates/fhe/src/trbfv/config.rs index 5b03ed78..6d48c502 100644 --- a/crates/fhe/src/trbfv/config.rs +++ b/crates/fhe/src/trbfv/config.rs @@ -12,6 +12,7 @@ pub fn validate_threshold_config(n: usize, threshold: usize) -> Result<(), Error if n == 0 { return Err(Error::invalid_party_count(n, 1)); } + // TODO: make stronger assumptions on minimum requirement (and / or) exact requirements. if threshold > (n - 1) / 2 { return Err(Error::threshold_too_large(threshold, n)); } diff --git a/crates/fhe/src/trbfv/errors.rs b/crates/fhe/src/trbfv/errors.rs index 92570f33..92da0683 100644 --- a/crates/fhe/src/trbfv/errors.rs +++ b/crates/fhe/src/trbfv/errors.rs @@ -34,7 +34,15 @@ impl Error { #[must_use] pub fn invalid_party_id(party_id: usize, max_party_id: usize) -> Self { Error::UnspecifiedInput(format!( - "Invalid party ID: {party_id}, must be between 0 and {max_party_id}" + "Invalid party ID: {party_id}, must be between 1 and {max_party_id}" + )) + } + + /// Create a duplicate party ID error. + #[must_use] + pub fn duplicate_party_id(party_id: usize) -> Self { + Error::UnspecifiedInput(format!( + "Duplicate party ID {party_id} in reconstructing parties" )) } @@ -133,7 +141,13 @@ mod tests { let error = Error::invalid_party_id(5, 3); assert_eq!( error.to_string(), - "Invalid party ID: 5, must be between 0 and 3" + "Invalid party ID: 5, must be between 1 and 3" + ); + + let error = Error::duplicate_party_id(2); + assert_eq!( + error.to_string(), + "Duplicate party ID 2 in reconstructing parties" ); let error = Error::secret_sharing("Test secret sharing error"); diff --git a/crates/fhe/src/trbfv/shamir.rs b/crates/fhe/src/trbfv/shamir.rs index b872d771..19fba3a4 100644 --- a/crates/fhe/src/trbfv/shamir.rs +++ b/crates/fhe/src/trbfv/shamir.rs @@ -1,3 +1,4 @@ +use crate::Error; use fhe_util::rng08; /// Shamir Secret Sharing implementation for threshold BFV. /// @@ -40,7 +41,7 @@ use rayon::prelude::*; /// let shares = sss.split(secret.clone()); /// /// println!("shares: {:?}", shares); -/// assert_eq!(secret, sss.recover(&shares[0..sss.threshold +1])); +/// assert_eq!(secret, sss.recover(&shares[0..sss.threshold +1]).unwrap()); /// } /// /// Fork a full-entropy ChaCha20 seed from the caller's RNG. @@ -175,28 +176,40 @@ impl ShamirSecretSharing { /// /// The reconstructed secret value. /// - /// # Panics + /// # Errors /// - /// Panics if the number of shares provided is not equal to the threshold + one. - #[must_use] - pub fn recover(&self, shares: &[(usize, BigInt)]) -> BigInt { - assert!(shares.len() == (self.threshold + 1), "wrong shares number"); + /// Returns an error if the number of shares provided is not equal to + /// threshold + 1, or if a Lagrange denominator is not invertible + /// (e.g., duplicate share indices). + pub fn recover(&self, shares: &[(usize, BigInt)]) -> Result { + if shares.len() != self.threshold + 1 { + return Err(Error::secret_sharing(format!( + "wrong shares number: expected {}, got {}", + self.threshold + 1, + shares.len() + ))); + } let (xs, ys): (Vec, Vec) = shares.iter().cloned().unzip(); - let result = self.lagrange_interpolation(Zero::zero(), xs, ys); + let result = self.lagrange_interpolation(Zero::zero(), xs, ys)?; if result < Zero::zero() { - result + &self.prime + Ok(result + &self.prime) } else { - result + Ok(result) } } // indices i and item iterate 0..len, same as xs_bigint.len() and ys.len() #[allow(clippy::indexing_slicing)] - fn lagrange_interpolation(&self, x: BigInt, xs: Vec, ys: Vec) -> BigInt { + fn lagrange_interpolation( + &self, + x: BigInt, + xs: Vec, + ys: Vec, + ) -> Result { let len = xs.len(); let xs_bigint: Vec = xs.iter().map(|x| BigInt::from(*x as i64)).collect(); - (0..len) + let terms: Result, Error> = (0..len) .into_par_iter() .map(|item| { let numerator = (0..len).fold(One::one(), |product: BigInt, i| { @@ -214,20 +227,28 @@ impl ShamirSecretSharing { } }); // Calculate this Lagrange term - (numerator * self.mod_reverse(denominator) * &ys[item]) % &self.prime + Ok((numerator * self.mod_reverse(denominator)? * &ys[item]) % &self.prime) }) - .reduce(Zero::zero, |sum, term| (sum + term) % &self.prime) + .collect(); + + Ok(terms? + .into_iter() + .fold(Zero::zero(), |sum: BigInt, term| (sum + term) % &self.prime)) } - fn mod_reverse(&self, num: BigInt) -> BigInt { + fn mod_reverse(&self, num: BigInt) -> Result { let num1 = if num < Zero::zero() { num + &self.prime } else { num }; - let (_gcd, _, inv) = self.extend_euclid_algo(num1); - // println!("inv:{}", inv); - inv + let (gcd, _, inv) = self.extend_euclid_algo(num1); + if !gcd.is_one() { + return Err(Error::secret_sharing( + "non-invertible Lagrange denominator (duplicate or invalid share indices)", + )); + } + Ok(inv) } /** @@ -304,10 +325,34 @@ mod tests { (1, BigInt::from(1494)), (2, BigInt::from(329)), (3, BigInt::from(965)) - ]), + ]) + .unwrap(), BigInt::from(1234) ); } + #[test] + fn test_recover_rejects_bad_shares() { + let sss = ShamirSecretSharing { + threshold: 2, + share_amount: 6, + prime: BigInt::from(1613), + }; + // Wrong share count + assert!( + sss.recover(&[(1, BigInt::from(1494)), (2, BigInt::from(329))]) + .is_err() + ); + // Duplicate share indices -> non-invertible Lagrange denominator + assert!( + sss.recover(&[ + (1, BigInt::from(1494)), + (1, BigInt::from(1494)), + (3, BigInt::from(965)) + ]) + .is_err() + ); + } + #[test] fn test_large_prime() { let sss = ShamirSecretSharing { @@ -322,6 +367,6 @@ mod tests { }; let secret = BigInt::parse_bytes(b"ffffffffffffffffffffffffffffffffffffff", 16).unwrap(); let shares = sss.split(secret.clone(), &mut rand::rng()); - assert_eq!(secret, sss.recover(&shares[0..sss.threshold + 1])); + assert_eq!(secret, sss.recover(&shares[0..sss.threshold + 1]).unwrap()); } } diff --git a/crates/fhe/src/trbfv/shares.rs b/crates/fhe/src/trbfv/shares.rs index 5a36e7bc..272e71dc 100644 --- a/crates/fhe/src/trbfv/shares.rs +++ b/crates/fhe/src/trbfv/shares.rs @@ -261,8 +261,10 @@ impl ShareManager { reconstructing_parties: Vec, ciphertext: Arc, ) -> Result { - // Validate we have enough shares - if d_share_polys.len() < (self.threshold + 1) { + // Reconstruction consumes exactly threshold + 1 shares; requiring + // exactness (rather than truncating extras) avoids silently depending + // on the order of the provided shares. + if d_share_polys.len() != self.threshold + 1 { return Err(Error::insufficient_shares( d_share_polys.len(), self.threshold + 1, @@ -274,9 +276,22 @@ impl ShareManager { "reconstructing_parties length must match d_share_polys length".to_string(), )); } - let m_data: Vec = (0..self.params.moduli().len()) + // Shamir x-coordinates are 1-based, bounded by n, and must be distinct: + // index 0 would evaluate the sharing polynomial at the secret itself, + // and duplicates make the Lagrange denominators non-invertible. + let mut seen = vec![false; self.n + 1]; + for &idx in &reconstructing_parties { + if idx == 0 || idx > self.n { + return Err(Error::invalid_party_id(idx, self.n)); + } + if seen[idx] { + return Err(Error::duplicate_party_id(idx)); + } + seen[idx] = true; + } + let recovered: Result>, Error> = (0..self.params.moduli().len()) .into_par_iter() - .flat_map(|m| { + .map(|m| { let shamir_ss = ShamirSecretSharing::new( self.threshold, self.n, @@ -286,13 +301,11 @@ impl ShareManager { // Parallelize coefficient recovery within each modulus (0..self.params.degree()) .into_par_iter() - .map(|i| { + .map(|i| -> Result { let mut shamir_open_vec_mod: Vec<(usize, BigInt)> = - Vec::with_capacity(self.params.degree()); - for (party_idx, d_share_poly) in reconstructing_parties - .iter() - .zip(d_share_polys.iter()) - .take(self.threshold + 1) + Vec::with_capacity(self.threshold + 1); + for (party_idx, d_share_poly) in + reconstructing_parties.iter().zip(d_share_polys.iter()) { let coeffs = d_share_poly.coefficients(); let coeff_arr = coeffs.row(m); @@ -301,13 +314,17 @@ impl ShareManager { let coeff_formatted = (*party_idx, coeff.to_bigint().unwrap()); shamir_open_vec_mod.push(coeff_formatted); } - let shamir_result = - shamir_ss.recover(&shamir_open_vec_mod[0..self.threshold + 1]); - shamir_result.to_u64().unwrap() + let shamir_result = shamir_ss.recover(&shamir_open_vec_mod)?; + shamir_result.to_u64().ok_or_else(|| { + Error::DefaultError( + "recovered Shamir coefficient does not fit in u64".to_string(), + ) + }) }) - .collect::>() + .collect::, Error>>() }) .collect(); + let m_data: Vec = recovered?.into_iter().flatten().collect(); // scale result poly let arr_matrix = @@ -850,6 +867,45 @@ mod tests { ); } + #[test] + fn test_decrypt_from_shares_rejects_invalid_party_indices() { + let mut rng = rng(); + let params = test_params(); + let n = 5; + let threshold = 2; // needs exactly 3 shares + let manager = ShareManager::new(n, threshold, params.clone()); + + let sk = SecretKey::random(¶ms, &mut rng); + let pk = PublicKey::new(&sk, &mut rng); + let pt = Plaintext::try_encode(&[1u64], Encoding::poly(), ¶ms).unwrap(); + let ct = Arc::new(pk.try_encrypt(&pt, &mut rng).unwrap()); + + let ctx = params.context_at_level(0).unwrap(); + let shares: Vec> = (0..3).map(|_| Poly::::zero(ctx)).collect(); + + // Duplicate index + let result = manager.decrypt_from_shares(shares.clone(), vec![1, 2, 2], ct.clone()); + assert!(result.is_err()); + + // Index 0 (would evaluate the sharing polynomial at the secret) + let result = manager.decrypt_from_shares(shares.clone(), vec![0, 1, 2], ct.clone()); + assert!(result.is_err()); + + // Index > n + let result = manager.decrypt_from_shares(shares.clone(), vec![1, 2, 6], ct.clone()); + assert!(result.is_err()); + + // Wrong share count: more than threshold + 1 is rejected + let four: Vec> = (0..4).map(|_| Poly::::zero(ctx)).collect(); + let result = manager.decrypt_from_shares(four, vec![1, 2, 3, 4], ct.clone()); + assert!(result.is_err()); + + // Fewer than threshold + 1 is rejected + let two: Vec> = (0..2).map(|_| Poly::::zero(ctx)).collect(); + let result = manager.decrypt_from_shares(two, vec![1, 2], ct); + assert!(result.is_err()); + } + #[test] fn test_threshold_decryption_random_party_order() { let mut rng = rng(); diff --git a/crates/fhe/tests/trbfv_secure_e2e.rs b/crates/fhe/tests/trbfv_secure_e2e.rs new file mode 100644 index 00000000..60ce7e35 --- /dev/null +++ b/crates/fhe/tests/trbfv_secure_e2e.rs @@ -0,0 +1,329 @@ +//! End-to-end threshold BFV tests with the production secure_8192 presets. + +#![allow(clippy::indexing_slicing, clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use fhe::bfv::{self, BfvParameters, Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; +use fhe::mbfv::{AggregateIter, CommonRandomPoly, PublicKeyShare}; +use fhe::trbfv::smudging::SmudgingNoiseGenerator; +use fhe::trbfv::{ShareManager, SmudgingBoundCalculator, SmudgingBoundCalculatorConfig, TRBFV}; +use fhe_math::rq::{Poly, PowerBasis}; +use fhe_traits::{FheDecoder, FheDecrypter, FheEncoder, FheEncrypter}; +use ndarray::{Array, Array2, ArrayView}; +use num_bigint::BigInt; +use rayon::prelude::*; + +// Secure preset (degree 8192), as used in production (enclave). +const DEGREE: usize = 8192; +const NUM_PARTIES: usize = 20; +const THRESHOLD: usize = 9; // max for n = 20: (n - 1) / 2 +const LAMBDA: usize = 50; +const NUM_SUMMED: usize = 50; + +// Threshold BFV parameters. +const TRBFV_PLAINTEXT_MODULUS: u64 = 1_000_000; +const TRBFV_MODULI: &[u64] = &[0x02000000015a0001, 0x0200000001460001, 0x0200000001210001]; +const TRBFV_ERROR1_VARIANCE: &str = "18148392902450051384713312396360971277653333"; + +// DKG parameters: BFV instance for encrypted Shamir share transport. The +// plaintext modulus equals the largest trBFV modulus (0x02000000015a0001). +const DKG_PLAINTEXT_MODULUS: u64 = 144115188098531329; +const DKG_MODULI: &[u64] = &[0x0800000000004001, 0x0800000000044001]; + +fn trbfv_params() -> Arc { + bfv::BfvParametersBuilder::new() + .set_degree(DEGREE) + .set_plaintext_modulus(TRBFV_PLAINTEXT_MODULUS) + .set_moduli(TRBFV_MODULI) + .set_variance(10) + .set_error1_variance_str(TRBFV_ERROR1_VARIANCE) + .unwrap() + .build_arc() + .unwrap() +} + +fn dkg_params() -> Arc { + bfv::BfvParametersBuilder::new() + .set_degree(DEGREE) + .set_plaintext_modulus(DKG_PLAINTEXT_MODULUS) + .set_moduli(DKG_MODULI) + .set_variance(10) + .build_arc() + .unwrap() +} + +enum NoiseMode { + /// Each party samples its smudging contribution uniformly in [-B_sm, B_sm]. + Random, + /// Each party's smudging contribution is +B_sm on every coefficient, so + /// the aggregated noise is exactly n * B_sm: the correctness boundary. + WorstCase, +} + +fn run_threshold_sum_e2e(noise_mode: NoiseMode) { + let params_trbfv = trbfv_params(); + let params_dkg = dkg_params(); + let trbfv = TRBFV::new(NUM_PARTIES, THRESHOLD, params_trbfv.clone()).unwrap(); + + // Worst-case noise needs the bound itself. + let smudging_bound = match noise_mode { + NoiseMode::Random => None, + NoiseMode::WorstCase => { + let config = SmudgingBoundCalculatorConfig::new( + params_trbfv.clone(), + NUM_PARTIES, + NUM_SUMMED, + LAMBDA, + ); + let bound = SmudgingBoundCalculator::new(config) + .calculate_sm_bound() + .expect("secure_8192 parameters must admit a smudging bound"); + Some(BigInt::from(bound)) + } + }; + + struct Party { + pk_share: PublicKeyShare, + sk_sss: Vec>, + esi_sss: Vec>, + sk_sss_collected: Vec>, + es_sss_collected: Vec>, + sk_poly_sum: Poly, + es_poly_sum: Poly, + // Per-party BFV keys (DKG preset) for encrypted share transport. + sk_dkg: SecretKey, + pk_dkg: PublicKey, + } + + let mut rng = rand::rng(); + let crp = CommonRandomPoly::new(¶ms_trbfv, &mut rng).unwrap(); + + let mut parties: Vec = (0..NUM_PARTIES) + .into_par_iter() + .map(|_| { + let mut rng = rand::rng(); + + let sk_share = SecretKey::random(¶ms_trbfv, &mut rng); + let pk_share = PublicKeyShare::new(&sk_share, crp.clone(), &mut rng).unwrap(); + + let mut share_manager = ShareManager::new(NUM_PARTIES, THRESHOLD, params_trbfv.clone()); + let sk_poly = share_manager + .coeffs_to_poly_level0(sk_share.coeffs.clone().as_ref()) + .unwrap(); + let sk_sss = trbfv + .clone() + .generate_secret_shares_from_poly(sk_poly, &mut rng) + .unwrap(); + + let esi_coeffs: Vec = match &smudging_bound { + None => trbfv + .clone() + .generate_smudging_error(NUM_SUMMED, LAMBDA, &mut rng) + .unwrap(), + Some(bound) => vec![bound.clone(); DEGREE], + }; + let esi_poly = share_manager.bigints_to_poly(&esi_coeffs).unwrap(); + let esi_sss = share_manager + .generate_secret_shares_from_poly(esi_poly, &mut rng) + .unwrap(); + + let sk_dkg = SecretKey::random(¶ms_dkg, &mut rng); + let pk_dkg = PublicKey::new(&sk_dkg, &mut rng); + + let ctx = params_trbfv.context_at_level(0).unwrap(); + Party { + pk_share, + sk_sss, + esi_sss, + sk_sss_collected: Vec::with_capacity(NUM_PARTIES), + es_sss_collected: Vec::with_capacity(NUM_PARTIES), + sk_poly_sum: Poly::::zero(ctx), + es_poly_sum: Poly::::zero(ctx), + sk_dkg, + pk_dkg, + } + }) + .collect(); + + // Encrypted share transport: sender encrypts each receiver's share rows + // under the receiver's DKG public key. + let pk_dkg_list: Vec = parties.iter().map(|p| p.pk_dkg.clone()).collect(); + + // encrypted_shares[sender][receiver] = (sk share cts, esi share cts), one ct per modulus. + let encrypted_shares: Vec, Vec)>> = parties + .par_iter() + .map(|party| { + pk_dkg_list + .iter() + .enumerate() + .map(|(receiver_idx, receiver_pk)| { + let mut rng = rand::rng(); + let mut encrypt_rows = |sss: &[Array2]| -> Vec { + sss.iter() + .map(|share_matrix| { + let share_vec: Vec = share_matrix.row(receiver_idx).to_vec(); + let pt = Plaintext::try_encode( + &share_vec, + Encoding::poly(), + ¶ms_dkg, + ) + .unwrap(); + receiver_pk.try_encrypt(&pt, &mut rng).unwrap() + }) + .collect() + }; + (encrypt_rows(&party.sk_sss), encrypt_rows(&party.esi_sss)) + }) + .collect() + }) + .collect(); + + // Each receiver decrypts the share rows addressed to it and collects them. + parties + .par_iter_mut() + .enumerate() + .for_each(|(receiver_idx, party)| { + for sender_encrypted in encrypted_shares.iter() { + let (encrypted_sk_shares, encrypted_esi_shares) = &sender_encrypted[receiver_idx]; + + let decrypt_rows = |cts: &[Ciphertext], sk: &SecretKey| -> Array2 { + let mut rows = Array::zeros((0, DEGREE)); + for ct in cts { + let pt = sk.try_decrypt(ct).unwrap(); + let decrypted: Vec = + Vec::::try_decode(&pt, Encoding::poly()).unwrap(); + rows.push_row(ArrayView::from(&decrypted)).unwrap(); + } + rows + }; + + let sk_rows = decrypt_rows(encrypted_sk_shares, &party.sk_dkg); + let es_rows = decrypt_rows(encrypted_esi_shares, &party.sk_dkg); + party.sk_sss_collected.push(sk_rows); + party.es_sss_collected.push(es_rows); + } + }); + + parties.par_iter_mut().for_each(|party| { + party.sk_poly_sum = trbfv + .clone() + .aggregate_collected_shares(&party.sk_sss_collected) + .unwrap(); + party.es_poly_sum = trbfv + .clone() + .aggregate_collected_shares(&party.es_sss_collected) + .unwrap(); + }); + + let pk: PublicKey = parties + .iter() + .map(|p| p.pk_share.clone()) + .aggregate() + .unwrap(); + + // Encrypt NUM_SUMMED ones and sum them homomorphically. + let numbers: Vec = vec![1; NUM_SUMMED]; + let numbers_encrypted: Vec = numbers + .par_iter() + .map(|&number| { + let mut rng = rand::rng(); + let pt = Plaintext::try_encode(&[number], Encoding::poly(), ¶ms_trbfv).unwrap(); + pk.try_encrypt(&pt, &mut rng).unwrap() + }) + .collect(); + let mut sum = Ciphertext::zero(¶ms_trbfv); + for ct in &numbers_encrypted { + sum += ct; + } + let tally = Arc::new(sum); + + // Threshold decryption with an arbitrary (non-prefix) subset of parties: + // 1-based indices {2, 4, ..., 20}, i.e. threshold + 1 = 10 parties. + let reconstructing: Vec = (1..=NUM_PARTIES).filter(|i| i % 2 == 0).collect(); + assert_eq!(reconstructing.len(), THRESHOLD + 1); + + let d_share_polys: Vec> = reconstructing + .iter() + .map(|&party_id| { + let party = &parties[party_id - 1]; + trbfv + .clone() + .decryption_share( + tally.clone(), + party.sk_poly_sum.clone().into_ntt(), + party.es_poly_sum.clone(), + ) + .unwrap() + }) + .collect(); + + let decrypted = trbfv.decrypt(d_share_polys, reconstructing, tally).unwrap(); + let result_vec = Vec::::try_decode(&decrypted, Encoding::poly()).unwrap(); + + let expected: u64 = numbers.iter().sum(); + assert_eq!( + result_vec[0], expected, + "threshold decryption returned a wrong sum" + ); +} + +#[test] +fn trbfv_e2e_secure_8192_random_smudging_noise() { + run_threshold_sum_e2e(NoiseMode::Random); +} + +#[test] +fn trbfv_e2e_secure_8192_worst_case_smudging_noise() { + run_threshold_sum_e2e(NoiseMode::WorstCase); +} + +/// The DKG plaintext space must contain every possible Shamir share value, +/// i.e. every trBFV modulus. This pins the relation between the two presets. +#[test] +fn dkg_plaintext_modulus_covers_trbfv_moduli() { + let max_trbfv_modulus = *TRBFV_MODULI.iter().max().unwrap(); + assert!( + DKG_PLAINTEXT_MODULUS >= max_trbfv_modulus, + "DKG plaintext modulus must be >= every trBFV modulus so shares fit in transport plaintexts" + ); +} + +/// Pins the smudging bound formula to the trBFV paper (eprint 2024/1285): +/// B_sm = 2^lambda * B_C with B_C = m * (B_fresh + (Q mod t)) and +/// B_fresh = d*||e_ek|| + B_enc + d*B_e*||sk|| (Eq. 25/26/8), subject to +/// B_C + n*B_sm <= Q/(2t) (Eq. 31). A failure here means the implemented +/// formula diverged from the paper, even if decryption still succeeds. +#[test] +fn trbfv_smudging_bound_matches_paper_formula() { + use num_bigint::BigUint; + + let params = trbfv_params(); + let config = + SmudgingBoundCalculatorConfig::new(params.clone(), NUM_PARTIES, NUM_SUMMED, LAMBDA); + let calculator = SmudgingBoundCalculator::new(config.clone()); + let bound = calculator.calculate_sm_bound().unwrap(); + + let d = BigUint::from(params.degree()); + let b_e = BigUint::from(config.b_e); + let e_norm = BigUint::from(config.public_key_error); + let sk_norm = BigUint::from(config.secret_key_bound); + let b_fresh = &d * &e_norm + &config.b_enc + &d * &b_e * &sk_norm; + + let q_full: BigUint = params.moduli().iter().map(|&m| BigUint::from(m)).product(); + let t = BigUint::from(params.plaintext()); + let b_c = BigUint::from(NUM_SUMMED) * (&b_fresh + &q_full % &t); + + let expected = BigUint::from(2u64).pow(LAMBDA as u32) * &b_c; + assert_eq!(bound, expected, "B_sm formula diverged from 2^lambda * B_C"); + + // Correctness budget, Eq. (31): B_C + n * B_sm <= Q / (2t). + let q_over_2t = &q_full / (BigUint::from(2u64) * &t); + assert!( + &b_c + BigUint::from(NUM_PARTIES) * &bound <= q_over_2t, + "secure_8192 parameters violate the Eq. (31) correctness budget" + ); + + let generator = SmudgingNoiseGenerator::new(params, bound.clone()); + assert_eq!(generator.smudging_bound(), &bound); +}