diff --git a/Cargo.lock b/Cargo.lock index 490568cfeb..22c9d628bc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -63,6 +63,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.12" @@ -566,6 +577,12 @@ dependencies = [ "hex-conservative", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -1425,8 +1442,8 @@ dependencies = [ "hex", "itertools 0.10.5", "rand 0.8.5", + "reed-solomon-erasure", "serde", - "serde-big-array", "sha3", "tap", "tracing", @@ -1753,13 +1770,22 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] + [[package]] name = "hashbrown" version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" dependencies = [ - "ahash", + "ahash 0.8.12", ] [[package]] @@ -2086,6 +2112,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if 1.0.4", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2214,12 +2249,30 @@ version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999beba7b6e8345721bd280141ed958096a2e4abdf74f67ff4ce49b4b54e47a" +dependencies = [ + "hashbrown 0.12.3", +] + [[package]] name = "lru" version = "0.16.3" @@ -2391,7 +2444,7 @@ checksum = "b285c575532a33ef6fdd3a57640d0b1c70e6ca48644d6df7bbd4b7a0cfbbb12d" dependencies = [ "bitvec", "either", - "lru", + "lru 0.16.3", "num-bigint 0.4.6", "num-integer", "num-modular", @@ -2475,6 +2528,31 @@ dependencies = [ "group 0.13.0", ] +[[package]] +name = "parking_lot" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d17b78036a60663b797adeaee46f5c9dfebb86948d1255007a1d6be0271ff99" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a2cfe6f0ad2bfc16aefa463b497d5c7a5ecd44a23efa72aa342d90177356dc" +dependencies = [ + "cfg-if 1.0.4", + "instant", + "libc", + "redox_syscall", + "smallvec", + "winapi", +] + [[package]] name = "pasta_curves" version = "0.5.1" @@ -2693,7 +2771,7 @@ checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", - "bitflags", + "bitflags 2.11.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -2919,6 +2997,28 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "redox_syscall" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "reed-solomon-erasure" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7263373d500d4d4f505d43a2a662d475a894aa94503a1ee28e9188b5f3960d4f" +dependencies = [ + "libm", + "lru 0.7.8", + "parking_lot", + "smallvec", + "spin", +] + [[package]] name = "regex" version = "1.12.3" @@ -3080,7 +3180,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.0", "errno", "libc", "linux-raw-sys", @@ -3179,6 +3279,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "sec1" version = "0.3.0" @@ -3776,7 +3882,7 @@ version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ - "bitflags", + "bitflags 2.11.0", "bytes", "futures-util", "http", @@ -4084,7 +4190,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", @@ -4119,6 +4225,22 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + [[package]] name = "winapi-util" version = "0.1.11" @@ -4128,6 +4250,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + [[package]] name = "windows-link" version = "0.2.1" @@ -4348,7 +4476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", diff --git a/fastcrypto-tbls/Cargo.toml b/fastcrypto-tbls/Cargo.toml index 6ce99b7a31..9a9f2bc20e 100644 --- a/fastcrypto-tbls/Cargo.toml +++ b/fastcrypto-tbls/Cargo.toml @@ -23,7 +23,7 @@ zeroize.workspace = true itertools = "0.10.5" hex = "0.4.3" tap = { version = "1.0.1", features = [] } -serde-big-array = "0.5.1" +reed-solomon-erasure = "6.0.0" [dev-dependencies] criterion = "0.5.1" diff --git a/fastcrypto-tbls/benches/batch_avss.rs b/fastcrypto-tbls/benches/batch_avss.rs index ac3c8485be..c30ae03d80 100644 --- a/fastcrypto-tbls/benches/batch_avss.rs +++ b/fastcrypto-tbls/benches/batch_avss.rs @@ -23,9 +23,11 @@ pub fn generate_ecies_keys( .collect() } +#[allow(clippy::too_many_arguments)] pub fn setup_receiver( id: PartyId, dealer_id: PartyId, + f: u16, threshold: u16, weight: u16, // Per node keys: &[(PartyId, ecies_v1::PrivateKey, ecies_v1::PublicKey)], @@ -43,6 +45,7 @@ pub fn setup_receiver( Nodes::new(nodes).unwrap(), id, dealer_id, + f, threshold, b"avss".to_vec(), keys.get(id as usize).unwrap().1.clone(), @@ -53,6 +56,7 @@ pub fn setup_receiver( pub fn setup_dealer( dealer_id: u16, + f: u16, threshold: u16, weight: u16, // Per node keys: &[(PartyId, ecies_v1::PrivateKey, ecies_v1::PublicKey)], @@ -69,6 +73,7 @@ pub fn setup_dealer( batch_avss::Dealer::new( Nodes::new(nodes).unwrap(), dealer_id, + f, threshold, b"avss".to_vec(), batch_size_per_weight, @@ -100,8 +105,9 @@ mod batch_avss_benches { let w = total_w / n; let total_w = w * n; let t = total_w / 3 - 1; + let f = t.saturating_sub(1); let keys = generate_ecies_keys(*n); - let d0 = setup_dealer(0, t, w, &keys, batch_size_per_weight); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); create.bench_function( format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), |b| b.iter(|| d0.create_message(&mut thread_rng())), @@ -109,6 +115,71 @@ mod batch_avss_benches { } } + { + let mut verify_common: BenchmarkGroup<_> = c.benchmark_group(format!( + "BATCH_AVSS (batch_size_per_weight = {batch_size_per_weight}) verify_common_message" + )); + for (n, total_w) in iproduct!(SIZES.iter(), TOTAL_WEIGHTS.iter()) { + let w = total_w / n; + let total_w = w * n; + let t = total_w / 3 - 1; + let f = t.saturating_sub(1); + let keys = generate_ecies_keys(*n); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); + let r1 = setup_receiver(1, 0, f, t, w, &keys, batch_size_per_weight); + let messages = d0.create_message(&mut thread_rng()).unwrap(); + let common = messages[1].common.clone(); + verify_common.bench_function( + format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), + |b| b.iter(|| r1.verify_common_message(common.clone()).unwrap()), + ); + } + } + + { + let mut echo: BenchmarkGroup<_> = c.benchmark_group(format!( + "BATCH_AVSS (batch_size_per_weight = {batch_size_per_weight}) echo" + )); + for (n, total_w) in iproduct!(SIZES.iter(), TOTAL_WEIGHTS.iter()) { + let w = total_w / n; + let total_w = w * n; + let t = total_w / 3 - 1; + let f = t.saturating_sub(1); + let keys = generate_ecies_keys(*n); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); + let r1 = setup_receiver(1, 0, f, t, w, &keys, batch_size_per_weight); + let messages = d0.create_message(&mut thread_rng()).unwrap(); + let message = &messages[1]; + echo.bench_function( + format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), + |b| b.iter(|| r1.echo(message).unwrap()), + ); + } + } + + { + let mut verify_echo: BenchmarkGroup<_> = c.benchmark_group(format!( + "BATCH_AVSS (batch_size_per_weight = {batch_size_per_weight}) verify_echo" + )); + for (n, total_w) in iproduct!(SIZES.iter(), TOTAL_WEIGHTS.iter()) { + let w = total_w / n; + let total_w = w * n; + let t = total_w / 3 - 1; + let f = t.saturating_sub(1); + let keys = generate_ecies_keys(*n); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); + let r0 = setup_receiver(0, 0, f, t, w, &keys, batch_size_per_weight); + let r1 = setup_receiver(1, 0, f, t, w, &keys, batch_size_per_weight); + let messages = d0.create_message(&mut thread_rng()).unwrap(); + let (vcm, echoes_from_r0) = r0.echo(&messages[0]).unwrap(); + let echo_for_r1 = echoes_from_r0[1].clone(); + verify_echo.bench_function( + format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), + |b| b.iter(|| r1.verify_echo(echo_for_r1.clone(), &vcm).unwrap()), + ); + } + } + { let mut process: BenchmarkGroup<_> = c.benchmark_group(format!( "BATCH_AVSS (batch_size_per_weight = {batch_size_per_weight}) process_message" @@ -117,14 +188,80 @@ mod batch_avss_benches { let w = total_w / n; let total_w = w * n; let t = total_w / 3 - 1; + let f = t.saturating_sub(1); let keys = generate_ecies_keys(*n); - let d0 = setup_dealer(0, t, w, &keys, batch_size_per_weight); - let r1 = setup_receiver(1, 0, t, w, &keys, batch_size_per_weight); - let message = d0.create_message(&mut thread_rng()).unwrap(); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); + let receivers: Vec = (0..*n) + .map(|id| setup_receiver(id, 0, f, t, w, &keys, batch_size_per_weight)) + .collect(); + let messages = d0.create_message(&mut thread_rng()).unwrap(); + let mut vcm = None; + let echoes: Vec> = receivers + .iter() + .enumerate() + .map(|(i, r)| { + let (v, e) = r.echo(&messages[i]).unwrap(); + if i == 1 { + vcm = Some(v); + } + e + }) + .collect(); + let vcm = vcm.unwrap(); + let echoes_for_party_1: Vec = echoes + .iter() + .map(|em| receivers[1].verify_echo(em[1].clone(), &vcm).unwrap()) + .collect(); + let r1 = &receivers[1]; process.bench_function( format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), - |b| b.iter(|| r1.process_message(&message).unwrap()), + |b| b.iter(|| r1.decode_ciphertext(&echoes_for_party_1, &vcm).unwrap()), + ); + } + } + + { + let mut verify_decrypt: BenchmarkGroup<_> = c.benchmark_group(format!( + "BATCH_AVSS (batch_size_per_weight = {batch_size_per_weight}) verify_and_decrypt" + )); + for (n, total_w) in iproduct!(SIZES.iter(), TOTAL_WEIGHTS.iter()) { + let w = total_w / n; + let total_w = w * n; + let t = total_w / 3 - 1; + let f = t.saturating_sub(1); + let keys = generate_ecies_keys(*n); + let d0 = setup_dealer(0, f, t, w, &keys, batch_size_per_weight); + let receivers: Vec = (0..*n) + .map(|id| setup_receiver(id, 0, f, t, w, &keys, batch_size_per_weight)) + .collect(); + let messages = d0.create_message(&mut thread_rng()).unwrap(); + let mut vcm = None; + let echoes: Vec> = receivers + .iter() + .enumerate() + .map(|(i, r)| { + let (v, e) = r.echo(&messages[i]).unwrap(); + if i == 1 { + vcm = Some(v); + } + e + }) + .collect(); + let vcm = vcm.unwrap(); + let echoes_for_party_1: Vec = echoes + .iter() + .map(|em| receivers[1].verify_echo(em[1].clone(), &vcm).unwrap()) + .collect(); + let r1 = &receivers[1]; + let pem = match r1.decode_ciphertext(&echoes_for_party_1, &vcm).unwrap() { + batch_avss::DecodeOutcome::Decoded(d) => d, + _ => panic!("expected Decoded outcome"), + }; + + verify_decrypt.bench_function( + format!("n={}, total_weight={}, t={}, w={}", n, total_w, t, w).as_str(), + |b| b.iter(|| r1.verify_and_decrypt(&pem, &vcm).unwrap()), ); } } @@ -136,19 +273,55 @@ mod batch_avss_benches { let w = total_w / n; let total_w = w * n; let t = total_w / 3 - 1; + let f = t.saturating_sub(1); let keys = generate_ecies_keys(*n); let quorum = (2 * n / 3 + 1) as usize; let dealers: Vec = (0..quorum) - .map(|id| setup_dealer(id as u16, t, w, &keys, batch_size_per_weight)) + .map(|id| setup_dealer(id as u16, f, t, w, &keys, batch_size_per_weight)) .collect(); let outputs = dealers .iter() .enumerate() .map(|(dealer_id, d)| { - let message = d.create_message(&mut thread_rng()).unwrap(); - let r = - setup_receiver(1, dealer_id as u16, t, w, &keys, batch_size_per_weight); - assert_valid_batch(r.process_message(&message).unwrap()) + let messages = d.create_message(&mut thread_rng()).unwrap(); + let receivers: Vec = (0..*n) + .map(|id| { + setup_receiver( + id, + dealer_id as u16, + f, + t, + w, + &keys, + batch_size_per_weight, + ) + }) + .collect(); + let mut vcm = None; + let echoes: Vec> = receivers + .iter() + .enumerate() + .map(|(i, r)| { + let (v, e) = r.echo(&messages[i]).unwrap(); + if i == 1 { + vcm = Some(v); + } + e + }) + .collect(); + let vcm = vcm.unwrap(); + let echoes_for_party_1: Vec = echoes + .iter() + .map(|em| receivers[1].verify_echo(em[1].clone(), &vcm).unwrap()) + .collect(); + let pem = match receivers[1] + .decode_ciphertext(&echoes_for_party_1, &vcm) + .unwrap() + { + batch_avss::DecodeOutcome::Decoded(d) => d, + _ => panic!("expected Decoded outcome"), + }; + assert_valid_batch(receivers[1].verify_and_decrypt(&pem, &vcm).unwrap()) }) .collect_vec(); @@ -196,12 +369,9 @@ mod batch_avss_benches { criterion_main!(batch_avss_benches::batch_avss_benches); -fn assert_valid_batch( - processed_message: batch_avss::ProcessedMessage, -) -> batch_avss::ReceiverOutput { - if let batch_avss::ProcessedMessage::Valid(output) = processed_message { - output - } else { - panic!("Expected valid message"); +fn assert_valid_batch(outcome: batch_avss::DecryptionOutcome) -> batch_avss::ReceiverOutput { + match outcome { + batch_avss::DecryptionOutcome::Valid { output, .. } => output, + _ => panic!("Expected valid outcome"), } } diff --git a/fastcrypto-tbls/src/ecies_v1.rs b/fastcrypto-tbls/src/ecies_v1.rs index 40b2232951..63303e649c 100644 --- a/fastcrypto-tbls/src/ecies_v1.rs +++ b/fastcrypto-tbls/src/ecies_v1.rs @@ -8,6 +8,7 @@ use fastcrypto::error::{FastCryptoError, FastCryptoResult}; use fastcrypto::groups::{FiatShamirChallenge, GroupElement, HashToGroupElement, Scalar}; use fastcrypto::traits::{AllowedRng, ToFromBytes}; use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use std::marker::PhantomData; use typenum::consts::{U16, U32}; use typenum::Unsigned; use zeroize::{Zeroize, ZeroizeOnDrop}; @@ -45,10 +46,23 @@ pub const AES_KEY_LENGTH: usize = 32; pub struct MultiRecipientEncryption { c: G, c_hat: G, - encs: Vec>, + pub(crate) encs: Vec>, proof: DdhTupleNizk, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SharedComponents { + c: G, + c_hat: G, + proof: DdhTupleNizk, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct EncryptedPart { + enc: Vec, + _g: PhantomData, +} + impl MultiRecipientEncryption where ::ScalarType: FiatShamirChallenge + Zeroize, @@ -194,6 +208,24 @@ where &self.proof } + pub fn into_parts(self) -> (SharedComponents, Vec>) { + let MultiRecipientEncryption { + c, + c_hat, + encs, + proof, + } = self; + (SharedComponents { c, c_hat, proof }, encs) + } + + pub fn shared(&self) -> SharedComponents { + SharedComponents { + c: self.c, + c_hat: self.c_hat, + proof: self.proof.clone(), + } + } + fn encs_random_oracle(encryption_random_oracle: &RandomOracle) -> RandomOracle { encryption_random_oracle.extend("encs") } @@ -229,6 +261,92 @@ fn sym_cipher(k: &[u8; 64]) -> Aes256Ctr { ) } +impl SharedComponents +where + ::ScalarType: FiatShamirChallenge + Zeroize, + G: HashToGroupElement, +{ + pub fn decrypt( + &self, + enc: &[u8], + sk: &PrivateKey, + encryption_random_oracle: &RandomOracle, + receiver_index: usize, + ) -> Vec { + let enc_ro = MultiRecipientEncryption::::encs_random_oracle(encryption_random_oracle); + let ephemeral_key = self.c * sk.0; + let k = enc_ro.evaluate(&(receiver_index, ephemeral_key)); + let cipher = sym_cipher(&k); + cipher + .decrypt(&fixed_zero_nonce(), enc) + .expect("Decrypt should never fail for CTR mode") + } + + pub fn ephemeral_key(&self) -> &G { + &self.c + } + + pub fn verify(&self, encryption_random_oracle: &RandomOracle) -> FastCryptoResult<()> { + let g_hat = G::hash_to_group_element( + &MultiRecipientEncryption::::g_hat_random_oracle(encryption_random_oracle) + .evaluate(&self.c), + ); + self.proof.verify( + &g_hat, + &self.c, + &self.c_hat, + &MultiRecipientEncryption::::zk_random_oracle(encryption_random_oracle), + ) + } + + pub fn create_recovery_package( + &self, + sk: &PrivateKey, + recovery_random_oracle: &RandomOracle, + rng: &mut R, + ) -> RecoveryPackage { + let pk = G::generator() * sk.0; + let ephemeral_key = self.c * sk.0; + + let proof = DdhTupleNizk::::create( + &sk.0, + &self.c, + &pk, + &ephemeral_key, + recovery_random_oracle, + rng, + ); + + RecoveryPackage { + ephemeral_key, + proof, + } + } + + pub fn decrypt_with_recovery_package( + &self, + enc: &[u8], + pkg: &RecoveryPackage, + recovery_random_oracle: &RandomOracle, + encryption_random_oracle: &RandomOracle, + receiver_pk: &PublicKey, + receiver_index: usize, + ) -> FastCryptoResult> { + pkg.proof.verify( + &self.c, + &receiver_pk.0, + &pkg.ephemeral_key, + recovery_random_oracle, + )?; + let encs_ro = MultiRecipientEncryption::::encs_random_oracle(encryption_random_oracle); + let k = encs_ro.evaluate(&(receiver_index, pkg.ephemeral_key)); + let cipher = sym_cipher(&k); + Ok(cipher + .decrypt(&fixed_zero_nonce(), enc) + .expect("Decrypt should never fail for CTR mode")) + } +} + impl PrivateKey where G: GroupElement + Serialize, diff --git a/fastcrypto-tbls/src/nodes.rs b/fastcrypto-tbls/src/nodes.rs index 1053cd669b..e7db9d0fd8 100644 --- a/fastcrypto-tbls/src/nodes.rs +++ b/fastcrypto-tbls/src/nodes.rs @@ -6,6 +6,7 @@ use crate::types::ShareIndex; use fastcrypto::error::{FastCryptoError, FastCryptoResult}; use fastcrypto::groups::GroupElement; use fastcrypto::hash::{Blake2b256, Digest, HashFunction}; +use itertools::Itertools; use serde::{Deserialize, Serialize}; use tracing::debug; @@ -157,6 +158,28 @@ impl Nodes { hash.finalize() } + /// Given an iterator over a set of items, one per share index, this function groups them into + /// a vector of vectors, one per node, according to the share ids of the nodes. + /// Returns error if the number of items does not match the total weight. + pub fn collect_to_nodes( + &self, + items: impl ExactSizeIterator, + ) -> FastCryptoResult>> { + if items.len() != self.total_weight as usize { + return Err(FastCryptoError::InvalidInput); + } + let mut items = items; + Ok(self + .node_ids_iter() + .map(|id| { + items + .by_ref() + .take(self.weight_of(id).unwrap() as usize) + .collect_vec() + }) + .collect_vec()) + } + /// Create a new set of nodes. Nodes must have consecutive ids starting from 0. /// Reduces weights up to an allowed delta in the original total weight. /// Finds the largest d such that: diff --git a/fastcrypto-tbls/src/tests/ecies_v1_tests.rs b/fastcrypto-tbls/src/tests/ecies_v1_tests.rs index c54fa75ff5..22c9cb3e81 100644 --- a/fastcrypto-tbls/src/tests/ecies_v1_tests.rs +++ b/fastcrypto-tbls/src/tests/ecies_v1_tests.rs @@ -59,6 +59,13 @@ mod point_tests { assert_eq!(msg.as_bytes(), &decrypted); } + let (common, parts) = mr_enc.clone().into_parts(); + assert!(common.verify(&ro).is_ok()); + for (i, (part, (sk, _, msg))) in parts.iter().zip(keys_and_msg.iter()).enumerate() { + // Using parts should work as well + assert_eq!(msg.as_bytes(), common.decrypt(part, sk, &ro, i)); + } + // test empty messages let mr_enc2 = MultiRecipientEncryption::encrypt( &keys_and_msg diff --git a/fastcrypto-tbls/src/threshold_schnorr/avss.rs b/fastcrypto-tbls/src/threshold_schnorr/avss.rs index 57fb81ea2b..8ebe46de04 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/avss.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/avss.rs @@ -14,7 +14,7 @@ use crate::nodes::{Nodes, PartyId}; use crate::polynomial::{Eval, Poly}; use crate::random_oracle::RandomOracle; use crate::threshold_schnorr::bcs::BCSSerialized; -use crate::threshold_schnorr::complaint::{Complaint, ComplaintResponse}; +use crate::threshold_schnorr::recovery_proof::RecoveryProof; use crate::threshold_schnorr::Extensions::Encryption; use crate::threshold_schnorr::{random_oracle_from_sid, EG, G, S}; use crate::types; @@ -63,6 +63,22 @@ pub enum ProcessedMessage { Complaint(Complaint), } +/// A complaint by a receiver who could not decrypt or verify its shares from the dealer's +/// broadcast. Given enough responses, the accuser can recover its shares. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Complaint { + pub accuser_id: PartyId, + pub proof: RecoveryProof, +} + +/// A response to a [Complaint], containing the responder's shares so the accuser can +/// Lagrange-interpolate their own. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ComplaintResponse { + pub responder_id: PartyId, + pub shares: SharesForNode, +} + /// The output of a receiver after a single instance of AVSS: The shares for each nonce + commitments for the next round. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PartialOutput { @@ -284,13 +300,16 @@ impl Receiver { my_shares, feldman_commitment: message.feldman_commitment.clone(), })), - Err(_) => Ok(ProcessedMessage::Complaint(Complaint::create( - self.id, - &message.ciphertext, - &self.enc_secret_key, - &self.random_oracle(), - &mut rand::thread_rng(), - ))), + Err(_) => Ok(ProcessedMessage::Complaint(Complaint { + accuser_id: self.id, + proof: RecoveryProof::create( + self.id, + &message.ciphertext.shared(), + &self.enc_secret_key, + &self.random_oracle(), + &mut rand::thread_rng(), + ), + })), } } @@ -300,10 +319,12 @@ impl Receiver { message: &Message, complaint: &Complaint, my_output: &PartialOutput, - ) -> FastCryptoResult> { - complaint.check( + ) -> FastCryptoResult { + complaint.proof.check( + complaint.accuser_id, &self.nodes.node_id_to_node(complaint.accuser_id)?.pk, - &message.ciphertext, + &message.ciphertext.encs[complaint.accuser_id as usize], + &message.ciphertext.shared(), &self.random_oracle(), |shares: &SharesForNode| { verify_shares(shares, &self.nodes, complaint.accuser_id, message) @@ -320,7 +341,7 @@ impl Receiver { pub fn recover( &self, message: &Message, - responses: Vec>, + responses: Vec, ) -> FastCryptoResult { // Sanity check that we have enough responses (by weight) to recover the shares. let total_response_weight = self @@ -549,11 +570,11 @@ mod tests { use crate::ecies_v1::{MultiRecipientEncryption, PublicKey}; use crate::nodes::{Node, Nodes, PartyId}; use crate::polynomial::Poly; + use crate::threshold_schnorr::avss::Complaint; use crate::threshold_schnorr::avss::{Dealer, Message, Receiver}; use crate::threshold_schnorr::avss::{PartialOutput, ProcessedMessage}; use crate::threshold_schnorr::avss::{ReceiverOutput, SharesForNode}; use crate::threshold_schnorr::bcs::BCSSerialized; - use crate::threshold_schnorr::complaint::Complaint; use crate::threshold_schnorr::tests::restrict; use crate::threshold_schnorr::Extensions::Encryption; use crate::threshold_schnorr::{EG, G, S}; diff --git a/fastcrypto-tbls/src/threshold_schnorr/batch_avss.rs b/fastcrypto-tbls/src/threshold_schnorr/batch_avss.rs index 94e2f6be04..efb7f68a97 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/batch_avss.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/batch_avss.rs @@ -1,37 +1,113 @@ // Copyright (c) 2022, Mysten Labs, Inc. // SPDX-License-Identifier: Apache-2.0 -//! Implementation of an asynchronous verifiable secret sharing (AVSS) protocol to distribute secret shares for a batch of random nonces. -//! The size of the batch is proportional to the [Dealer]'s weight. +//! Asynchronous verifiable secret sharing (AVSS) for a batch of random nonces. //! -//! Before the protocol starts, the following setup is needed: -//! * Each receiver has a encryption key pair (ECIES) and these public keys are known to all parties. -//! * The public keys along with the weights of each receiver are known to all parties and defined in the [Nodes] structure. -//! * Define a new [Dealer] with the secrets who begins by calling [Dealer::create_message]. +//! # What it does +//! +//! A single dealer commits to a batch of `L` random nonces `r_1, …, r_L` and distributes +//! shares to `n` weighted receivers forming a `t`-of-`W` threshold (with `W = Σ_j w_j` total +//! weight, `f` the Byzantine bound by weight, and `L = w_dealer · BATCH_SIZE`). In case of a honest dealer, every honest +//! receiver `j` ends up with `p_l(i_{j,1}), …, p_l(i_{j,w_j})` for every secret `r_l`, where +//! `p_l` is a degree-`(t−1)` polynomial with `p_l(0) = r_l`. Any `≥ t` valid shares reconstruct +//! `r_l`. +//! +//! # Two layers +//! +//! The dealer's broadcast (the [CommonMessage]) carries the public nonces +//! `c_l = g^{r_l}`, the blinding commitment `c' = g^{r'}`, the *response polynomial* `p''(X)`, +//! and the per-recipient Merkle roots `r_1, …, r_n`. +//! +//! **AVID layer.** The dealer encrypts each receiver's shares under multi-recipient ECIES, +//! RS-encodes the per-recipient ciphertexts under a `(W, W−2f)` code, and Merkle-commits each +//! ciphertext's shards into the root `r_i`. Receivers exchange small [Echo]s so any quorum can +//! reconstruct a ciphertext even if the dealer didn't reach them directly. +//! +//! **AVSS layer.** Each receiver decrypts their own ciphertext to get their shares. The +//! response polynomial `p''(X) = p'(X) + Σ_l γ_l · p_l(X)` — a degree-`(t−1)` linear +//! combination of all `L` sharing polynomials plus a blinding `p'`, where `γ_l` is a +//! Fiat-Shamir challenge over *all* dealer commitments — lets the receiver verify their shares +//! with one polynomial identity (construction from [eprint/2023/536](https://eprint.iacr.org/2023/536)). +//! Because `γ` binds to every public root, the dealer can't equivocate later. +//! +//! # Happy path +//! +//! 1. **Dealer.** Build a [Message] per receiver and send it point-to-point. +//! 2. **Echo.** Each receiver verifies their dispersal entry and sends an [Echo] to every other +//! recipient with their shard for that recipient's ciphertext. +//! 3. **Decode.** Collect `≥ W−2f` valid echoes for the same [CommonMessage] and run +//! [Receiver::decode_ciphertext]. +//! 4. **Verify-and-decrypt.** Run the polynomial commitment check +//! `g^{p''(0)} = c' · ∏ c_l^{γ_l}`, decrypt the ciphertext, and verify each share pointwise +//! against `p''`. +//! 5. **Vote.** Once enough valid echoes have been collected in step 2 and step 4 succeeds, the +//! receiver sends a [Vote] to the dealer. +//! 6. The dealer collects `≥ W−f` votes (by weight) into a certificate posted on the TOB. The +//! broadcast is now *certified* — every party agrees on `common_message_hash`. +//! 7. A receiver that saw the certificate but missed the original [Message] or enough echoes +//! fetches [CommonMessage] / echoes from a voter, then runs steps 3–4 (without sending a +//! [Vote]). +//! +//! Receivers should retain the [VerifiedCommonMessage] for the lifetime of the session — it is +//! required to validate complaints and build a [ComplaintResponse]. The [Echo]s and the decoded +//! ciphertext should also be kept so laggards (step 7) can fetch them. +//! +//! # Complaint paths +//! +//! Complaints are broadcast only after the certificate is in place; the certificate is what +//! pins down the [CommonMessage] every validation hinges on. +//! +//! - **[RevealComplaint]** (encryption-layer fault, raised in step 4). Decryption fails or the shares +//! don't satisfy `p''`. The accuser publishes a `RevealComplaint` with their ciphertext and an ECIES +//! recovery package; verifiers re-bind the ciphertext to the dealer's broadcast and use the +//! recovery package to confirm decryption yields invalid shares. +//! - **[BlameComplaint]** (dispersal-layer fault, raised in step 3). When [Receiver::decode_ciphertext] +//! returns [DecodeOutcome::InvalidDispersal], **hold** the [BlameComplaint] — do not broadcast yet. +//! If a certificate for the same `common_message_hash` later lands on the TOB, publish it; if +//! a different [CommonMessage] gets certified instead, discard the held [BlameComplaint] and re-decode +//! against echoes for the certified common. Verifiers re-run the same decode-and-re-encode +//! check on the carried shards. +//! +//! Verifiers respond to a valid complaint with a [ComplaintResponse] carrying their own +//! ciphertext plus a recovery package. The accuser AVID-binds each responder's ciphertext, +//! decrypts via the recovery package, verifies the shares against `p''`, and +//! Lagrange-interpolates once `≥ t` weight of valid responses has accrued +//! (see [Receiver::recover]). -use crate::ecies_v1::{MultiRecipientEncryption, PrivateKey}; +use crate::ecies_v1::{MultiRecipientEncryption, PrivateKey, RecoveryPackage, SharedComponents}; use crate::nodes::{Nodes, PartyId}; use crate::polynomial::{create_secret_sharing, Eval, Poly}; use crate::random_oracle::RandomOracle; use crate::threshold_schnorr::bcs::BCSSerialized; -use crate::threshold_schnorr::complaint::{Complaint, ComplaintResponse}; -use crate::threshold_schnorr::Extensions::{Challenge, Encryption}; +use crate::threshold_schnorr::recovery_proof; +use crate::threshold_schnorr::reed_solomon::{ErasureCoder, Shard}; +use crate::threshold_schnorr::Extensions::{Challenge, Encryption, Recovery}; use crate::threshold_schnorr::{random_oracle_from_sid, EG, G, S}; use crate::types::{get_uniform_value, ShareIndex}; -use fastcrypto::error::FastCryptoError::{InvalidInput, InvalidMessage}; +use fastcrypto::error::FastCryptoError::{ + InvalidInput, InvalidMessage, InvalidProof, NotEnoughWeight, +}; use fastcrypto::error::{FastCryptoError, FastCryptoResult}; +use fastcrypto::groups::secp256k1::SCALAR_SIZE_IN_BYTES; use fastcrypto::groups::{GroupElement, MultiScalarMul, Scalar}; -use fastcrypto::hash::{HashFunction, Sha3_512}; +use fastcrypto::hash::{Blake2b256, HashFunction}; +use fastcrypto::merkle; +use fastcrypto::merkle::MerkleTree; use fastcrypto::traits::AllowedRng; use itertools::Itertools; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::fmt::Debug; use std::iter::repeat_with; +/// Blake2b digest used to bind echoes/complaints to a specific [CommonMessage]. +pub type Digest = fastcrypto::hash::Digest<{ Blake2b256::OUTPUT_SIZE }>; + /// This represents a Dealer in the AVSS. /// There is exactly one dealer who creates the shares and broadcasts the encrypted shares. #[allow(dead_code)] pub struct Dealer { + f: u16, t: u16, nodes: Nodes, sid: Vec, @@ -42,31 +118,134 @@ pub struct Dealer { /// This represents a Receiver in the AVSS who receives shares from the [Dealer]. #[allow(dead_code)] pub struct Receiver { - pub(crate) id: PartyId, + pub id: PartyId, enc_secret_key: PrivateKey, nodes: Nodes, sid: Vec, + f: u16, t: u16, /// The total number of nonces that the receiver expects to receive from the dealer. batch_size: usize, } -/// The message broadcast by the dealer. +/// The dealer's per-recipient message: the shared [CommonMessage] plus the receiver's own +/// [AuthenticatedShards] entries (one per ciphertext, indexed by recipient id). #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Message { + pub common: CommonMessage, + dispersal: Vec, +} + +/// The shared part of the dealer's broadcast — identical for every receiver. Receivers must run +/// it through [Receiver::verify_common_message] before any further step; the resulting +/// [VerifiedCommonMessage] is what every later API consumes. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CommonMessage { full_public_keys: Vec, blinding_commit: G, - ciphertext: MultiRecipientEncryption, + ciphertext_shared: SharedComponents, response_polynomial: Poly, + recipient_roots: Vec, } -/// The result of processing a message by a receiver: either valid shares or a complaint. +/// A [CommonMessage] that has been validated against the dealer's commitments. Receivers +/// should keep it around for the lifetime of the session. +// TODO: We can cache the hash and challenge here if it makes sense. +#[derive(Clone, Debug)] +pub struct VerifiedCommonMessage(pub CommonMessage); + +/// One recipient's shards for one ciphertext, with a Merkle proof verifying against the +/// corresponding `recipient_root` from [CommonMessage]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct AuthenticatedShards { + shards: Vec, + proof: merkle::MerkleProof, +} + +/// One sender's echo to a single recipient: their shard for the recipient's ciphertext, with a +/// proof that verifies against the recipient's [CommonMessage::recipient_roots] entry, plus a +/// hash binding the echo to a specific [CommonMessage]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Echo { + sender: PartyId, + authenticated_shards: AuthenticatedShards, + pub common_message_hash: Digest, +} + +/// An [Echo] that has been verified by [Receiver::verify_echo] against a specific +/// [CommonMessage]: the sender's shard count matches their weight, the Merkle proof checks out +/// against the receiver's `recipient_root`, and the echo's `common_message_hash` matches. +#[derive(Clone, Debug)] +pub struct VerifiedEcho(Echo); + +/// The result of [Receiver::decode_ciphertext]: either a successfully reconstructed +/// ciphertext whose AVID dispersal is consistent, or a [BlameComplaint] when the collected shards either +/// fail to RS-decode or decode to a ciphertext whose re-encoding disagrees with the dealer's +/// `r_i`. +/// +/// On `InvalidDispersal`, do **not** broadcast the [BlameComplaint] immediately: hold it until the same +/// `common_message_hash` is certified on the TOB, then publish. If a *different* [CommonMessage] +/// gets certified instead, discard the held [BlameComplaint] and re-decode against the echoes for the +/// certified common. +#[allow(clippy::large_enum_variant)] +pub enum DecodeOutcome { + Decoded(Vec), + InvalidDispersal(BlameComplaint), +} + +/// The result of [Receiver::verify_and_decrypt]. #[allow(clippy::large_enum_variant)] -pub enum ProcessedMessage { - Valid(ReceiverOutput), - Complaint(Complaint), +pub enum DecryptionOutcome { + Valid { output: ReceiverOutput, vote: Vote }, + Invalid(RevealComplaint), +} + +/// An endorsement of the dealer's broadcast. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Vote { + pub common_message_hash: Digest, +} + +/// A complaint by a receiver who could not decrypt or verify its shares. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RevealComplaint { + pub accuser_id: PartyId, + pub proof: recovery_proof::RecoveryProof, + pub ciphertext: Vec, + pub common_message_hash: Digest, +} + +/// A complaint by a receiver who found the AVID dispersal inconsistent. Self-contained: carries +/// the accuser's collected per-sender [AuthenticatedShards] so verifiers can re-run the AVID +/// check without needing to observe echoes addressed to the accuser. The map keys are sender +/// ids, which both deduplicates contributions and gives O(log n) lookup during reconstruction. +/// +/// Do not broadcast a [BlameComplaint] until the matching `common_message_hash` has been certified on the +/// TOB; see [DecodeOutcome::InvalidDispersal]. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct BlameComplaint { + pub accuser_id: PartyId, + pub shards: BTreeMap, + pub common_message_hash: Digest, +} + +/// A responder's reply to a [RevealComplaint] / [BlameComplaint] complaint. Carries the responder's own dealer- +/// encrypted ciphertext together with an ECIES recovery package, so the accuser can +/// independently authenticate the responder's shares against the dealer's broadcast and +/// extract them via decryption. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ComplaintResponse { + pub responder_id: PartyId, + pub ciphertext: Vec, + pub recovery_package: RecoveryPackage, } +/// A [ComplaintResponse] that has been verified by [Receiver::verify_complaint_response]: the +/// carried ciphertext is AVID-bound to the dealer's broadcast, decryption via the recovery +/// package yielded shares satisfying the dealer's response polynomial. +#[derive(Clone, Debug)] +pub struct VerifiedComplaintResponse(SharesForNode); + /// The output of a receiver which is a batch of shares and public keys for all nonces. #[derive(Debug, Clone)] pub struct ReceiverOutput { @@ -78,7 +257,8 @@ pub struct ReceiverOutput { /// If we say that node i has a weight `W_i`, we have /// `indices().len() == shares_for_secret(i).len() == weight() = W_i` /// -/// These can be created either by decrypting the shares from the dealer (see [Receiver::process_message]) or by recovering them from complaint responses. +/// Produced by [Receiver::verify_and_decrypt] on the happy path, or by [Receiver::recover] +/// from complaint responses. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SharesForNode { pub shares: Vec, @@ -97,110 +277,6 @@ pub struct ShareBatch { pub blinding_share: S, } -impl ShareBatch { - /// Verify a batch of shares using the given challenge. - fn verify(&self, message: &Message, challenge: &[S]) -> FastCryptoResult<()> { - if challenge.len() != self.batch_size() { - return Err(InvalidInput); - } - - // Verify that r' + sum_l r_l * gamma_l == p''(i) - if self - .batch - .iter() - .zip_eq(challenge) - .fold(self.blinding_share, |acc, (r_l, gamma_l)| { - acc + r_l * gamma_l - }) - != message.response_polynomial.eval(self.index).value - { - return Err(InvalidInput); - } - Ok(()) - } - - fn batch_size(&self) -> usize { - self.batch.len() - } -} - -impl SharesForNode { - /// Get the weight of this node (number of shares it has). - pub fn weight(&self) -> u16 { - self.shares.len() as u16 - } - - /// If all shares have the same batch size, return that. - /// Otherwise, return an InvalidInput error. - pub fn try_uniform_batch_size(&self) -> FastCryptoResult { - // TODO: Should we cache this? It's called twice per dealer -- once when verifying shares received from a dealer and then again during presigning. - get_uniform_value(self.shares.iter().map(ShareBatch::batch_size)).ok_or(InvalidInput) - } - - /// Get all shares this node has for the i-th secret/nonce in the batch. - /// This panics if `i` is larger than or equal to the batch size. - pub fn shares_for_secret(&self, i: usize) -> impl Iterator> + '_ { - self.shares.iter().map(move |s| Eval { - index: s.index, - value: s.batch[i], - }) - } - - fn verify(&self, message: &Message, challenge: &[S]) -> FastCryptoResult<()> { - for shares in &self.shares { - shares.verify(message, challenge)?; - } - Ok(()) - } - - /// Recover the shares for this node. - /// - /// Fails if `other_shares` is empty or if the batch sizes of all shares in `other_shares` are not equal to the expected batch size. - fn recover(receiver: &Receiver, other_shares: &[Self]) -> FastCryptoResult { - if other_shares.is_empty() { - return Err(InvalidInput); - } - - let shares = receiver - .my_indices() - .into_iter() - .map(|index| { - let batch = (0..receiver.batch_size) - .map(|i| { - let evaluations = other_shares - .iter() - .flat_map(|s| s.shares_for_secret(i)) - .collect_vec(); - Poly::recover_at(index, &evaluations).unwrap().value - }) - .collect_vec(); - - let blinding_share = Poly::recover_at( - index, - &other_shares - .iter() - .flat_map(|s| &s.shares) - .map(|share| Eval { - index: share.index, - value: share.blinding_share, - }) - .collect_vec(), - )? - .value; - - Ok(ShareBatch { - index, - batch, - blinding_share, - }) - }) - .collect::>>()?; - Ok(Self { shares }) - } -} - -impl BCSSerialized for SharesForNode {} - impl Dealer { /// Create a new dealer. /// @@ -217,16 +293,16 @@ impl Dealer { pub fn new( nodes: Nodes, dealer_id: PartyId, + f: u16, t: u16, sid: Vec, batch_size_per_weight: u16, ) -> FastCryptoResult { - if t > nodes.total_weight() { - return Err(InvalidInput); - } + validate_parameters(t, f, nodes.total_weight())?; // Each dealer deals a number of nonces proportional to their weight. let batch_size = nodes.weight_of(dealer_id)? as usize * batch_size_per_weight as usize; Ok(Self { + f, t, nodes, sid, @@ -234,8 +310,23 @@ impl Dealer { }) } - /// 1. The Dealer generates shares for the secrets and broadcasts the encrypted shares. - pub fn create_message(&self, rng: &mut impl AllowedRng) -> FastCryptoResult { + /// 1. Build one [Message] per receiver. Each carries a shared [CommonMessage] (with the + /// public commitments and the per-recipient Merkle roots) and the recipient's own + /// [AuthenticatedShards] entries. Sent point-to-point to the corresponding receiver. + pub fn create_message(&self, rng: &mut impl AllowedRng) -> FastCryptoResult> { + self.create_message_with_mutation(rng, |_| {}, |_| {}) + } + + /// Like [Self::create_message] but exposes mutation hooks for tests: `mutate_plaintexts` runs + /// before encryption, and `mutate_shards` runs after RS-encoding (and before the per-recipient + /// Merkle trees are built), so tests can simulate a faulty dealer at either layer. + #[cfg_attr(not(test), allow(unused_variables, unused_mut))] + fn create_message_with_mutation( + &self, + rng: &mut impl AllowedRng, + mutate_plaintexts: impl FnOnce(&mut [(crate::ecies_v1::PublicKey, Vec)]), + mutate_shards: impl FnOnce(&mut Vec>>), + ) -> FastCryptoResult> { let secrets = repeat_with(|| S::rand(rng)) .take(self.batch_size) .collect_vec(); @@ -257,7 +348,7 @@ impl Dealer { .collect_vec(); // Encrypt all shares to the receivers - let pk_and_msgs = self + let mut pk_and_msgs = self .nodes .iter() .map(|node| (node.pk.clone(), self.nodes.share_ids_of(node.id).unwrap())) @@ -279,18 +370,61 @@ impl Dealer { }) .collect_vec(); + #[cfg(test)] + mutate_plaintexts(&mut pk_and_msgs); + let ciphertext = MultiRecipientEncryption::encrypt( &pk_and_msgs, &self.random_oracle().extend(&Encryption.to_string()), rng, ); + let (shared, ciphertexts) = ciphertext.clone().into_parts(); + let code = get_coder(&self.nodes, self.f); + + let mut shards: Vec>> = ciphertexts + .iter() + .map(|c| { + // Every node has positive weight, so each per-recipient ciphertext is non-empty + // and `code.encode` cannot return `InvalidInput`. + let shards = code.encode(c).expect("non-empty ciphertext"); // One shard per weight + self.nodes.collect_to_nodes(shards.into_iter()) // Grouped to nodes by weight + }) + .collect::>>()?; + + #[cfg(test)] + mutate_shards(&mut shards); + + let recipient_trees = shards + .iter() + .map(recipient_tree) + .collect::>>()?; + let recipient_roots = recipient_trees.iter().map(MerkleTree::root).collect_vec(); + + let dispersals: Vec> = self + .nodes + .node_ids_iter() + .map(|id| { + shards + .iter() + .zip(&recipient_trees) + .map(|(s, tree)| { + Ok(AuthenticatedShards { + shards: s[id as usize].clone(), + proof: tree.get_proof(id as usize)?, + }) + }) + .collect::>>() + }) + .collect::>>()?; + // "response" polynomials from https://eprint.iacr.org/2023/536.pdf let challenge = compute_challenge( &self.random_oracle(), &full_public_keys, &blinding_commit, - &ciphertext, + &shared, + &recipient_roots, ); // Get the first t evaluations for the response polynomial and use these to compute the coefficients @@ -306,12 +440,21 @@ impl Dealer { .to_vec(), )?; - Ok(Message { + let common = CommonMessage { full_public_keys, - blinding_commit, - ciphertext, + ciphertext_shared: shared, response_polynomial, - }) + blinding_commit, + recipient_roots, + }; + + Ok(dispersals + .into_iter() + .map(|dispersal| Message { + common: common.clone(), + dispersal, + }) + .collect_vec()) } fn random_oracle(&self) -> RandomOracle { @@ -325,16 +468,19 @@ impl Receiver { /// * `nodes` defines the set of receivers and what shares they should receive. /// * `id` is the id of this receiver. /// * `dealer_id` is the id of the dealer. + /// * `f` is the maximum number of Byzantine parties counted by weight. /// * `t` is the number of shares that are needed to reconstruct the full key/signature. /// * `sid` is a session identifier that should be unique for each invocation, but the same for all parties. /// * `enc_secret_key` is this Receivers' secret key for the distribution of nonces. The corresponding public key is defined in `nodes`. /// * `batch_size_per_weight` is the number of secrets a dealer should deal per weight it has. /// /// Returns an `InvalidInput` error if the `id` or `dealer_id` is invalid. + #[allow(clippy::too_many_arguments)] pub fn new( nodes: Nodes, id: PartyId, dealer_id: PartyId, + f: u16, t: u16, sid: Vec, enc_secret_key: PrivateKey, @@ -346,234 +492,763 @@ impl Receiver { // The dealer is expected to deal a number of nonces proportional to it's weight let batch_size = nodes.weight_of(dealer_id)? as usize * batch_size_per_weight as usize; + validate_parameters(t, f, nodes.total_weight())?; + Ok(Self { id, enc_secret_key, nodes, sid, + f, t, batch_size, }) } - /// 2. Each receiver processes the message, verifies and decrypts its shares. - /// - /// If this works, the receiver can store the shares and contribute a signature on the message to a certificate. - /// - /// This returns an [InvalidMessage] error if the ciphertext cannot be verified or if the commitments are invalid. - /// All honest receivers will reject such a message with the same error, and such a message should be ignored. - /// - /// If the message is valid but contains invalid shares for this receiver, the call will succeed but will return a [Complaint]. - /// - /// 3. When f+t signatures have been collected in the certificate, the receivers can now verify the certificate and finish the protocol. - pub fn process_message(&self, message: &Message) -> FastCryptoResult { - let Message { - full_public_keys, - blinding_commit, - ciphertext, - response_polynomial, - } = message; - - if full_public_keys.len() != self.batch_size - || response_polynomial.degree() != self.t as usize - 1 + /// 2. Verify the dispersal entries against `recipient_roots` and emit one [Echo] per + /// recipient (indexed by recipient id) for the receiver to broadcast — including one + /// addressed to the receiver itself. Also returns the [VerifiedCommonMessage] that the + /// receiver should keep around for the rest of the session. + pub fn echo(&self, message: &Message) -> FastCryptoResult<(VerifiedCommonMessage, Vec)> { + let n = self.nodes.num_nodes(); + if message.dispersal.len() != n || message.common.recipient_roots.len() != n { + return Err(InvalidMessage); + } + if message + .dispersal + .iter() + .zip(&message.common.recipient_roots) + .any(|(auth, root)| auth.verify(self.id as usize, root).is_err()) { return Err(InvalidMessage); } - // Verify that g^{p''(0)} == c' * prod_l c_l^{gamma_l} - let challenge = compute_challenge_from_message(&self.random_oracle(), message); - if G::generator() * response_polynomial.c0() - != blinding_commit - + G::multi_scalar_mul(&challenge, full_public_keys) - .expect("Inputs have constant lengths") + let verified_common_message = self.verify_common_message(message.common.clone())?; + let common_message_hash = verified_common_message.0.hash(); + let echoes = message + .dispersal + .iter() + .cloned() + .map(|authenticated_shards| Echo { + sender: self.id, + authenticated_shards, + common_message_hash, + }) + .collect(); + Ok((verified_common_message, echoes)) + } + + /// Run the dealer's commitments through [CommonMessage::verify] using this receiver's `t`, + /// `batch_size`, and session id. Returns the resulting [VerifiedCommonMessage]. + pub fn verify_common_message( + &self, + common_message: CommonMessage, + ) -> FastCryptoResult { + common_message.verify(self.t, self.batch_size, &self.random_oracle()) + } + + /// Verify an [Echo] addressed to this receiver against `common_message`: the sender's shard + /// count matches their advertised weight, the Merkle proof checks against the receiver's + /// `recipient_root`, and the echo's `common_message_hash` matches. Returns a [VerifiedEcho] + /// suitable for [Self::decode_ciphertext]. + pub fn verify_echo( + &self, + echo: Echo, + common_message: &VerifiedCommonMessage, + ) -> FastCryptoResult { + let weight = self.nodes.weight_of(echo.sender)?; + echo.verify(weight, self.id, &common_message.0) + } + + /// 3. Reconstruct this receiver's ciphertext from a quorum of [VerifiedEcho]s. Returns + /// [DecodeOutcome::Decoded] when the AVID dispersal is consistent with the dealer's + /// `r_{self.id}`, or [DecodeOutcome::InvalidDispersal] (a [BlameComplaint]) when it isn't. + /// + /// Echoes must already be validated via [Self::verify_echo] and must come from distinct + /// senders — duplicates yield [InvalidInput]. Returns [NotEnoughWeight] if the senders + /// contribute `< W − 2f` weight. + pub fn decode_ciphertext( + &self, + echos: &[VerifiedEcho], + common_message: &VerifiedCommonMessage, + ) -> FastCryptoResult { + if !echos.iter().map(|e| e.0.sender).all_unique() { + return Err(InvalidInput); + } + + let required_weight = self.nodes.total_weight() - 2 * self.f; + if self + .nodes + .total_weight_of(echos.iter().map(|e| &e.0.sender))? + < required_weight { - return Err(InvalidMessage); + return Err(NotEnoughWeight(required_weight as usize)); } + // Try to RS-decode the ciphertext and re-encode it. The dispersal is consistent iff + // both succeed and the re-encoded root matches `r_{self.id}`. Otherwise the dealer's + // dispersal is inconsistent — package the collected shards into a self-contained [BlameComplaint]. + let shards: BTreeMap = echos + .iter() + .cloned() + .map(|e| (e.0.sender, e.0.authenticated_shards)) + .collect(); + + Ok(self + .reconstruct_ciphertext(self.id, &shards) + .and_then(|ct| { + self.check_avid_consistency(&ct, common_message.0.recipient_root(self.id)?)?; + Ok(DecodeOutcome::Decoded(ct)) + }) + .unwrap_or(DecodeOutcome::InvalidDispersal(BlameComplaint { + accuser_id: self.id, + shards, + common_message_hash: common_message.0.hash(), + }))) + } + + /// 4. Decrypt and verify the receiver's own shares from a successfully decoded ciphertext. + /// Yields [DecryptionOutcome::Valid] (with a [Vote] to broadcast) when shares verify, or + /// [DecryptionOutcome::Invalid] (a [RevealComplaint]) otherwise. + pub fn verify_and_decrypt( + &self, + ciphertext: &[u8], + common_message: &VerifiedCommonMessage, + ) -> FastCryptoResult { + let common_message = &common_message.0; + let challenge = + compute_challenge_from_common_message(&self.random_oracle(), common_message); + let CommonMessage { + full_public_keys, + ciphertext_shared: shared, + .. + } = &common_message; + let random_oracle_encryption = self.random_oracle().extend(&Encryption.to_string()); - ciphertext + shared .verify(&random_oracle_encryption) .map_err(|_| InvalidMessage)?; - - // Decrypt my shares - let plaintext = ciphertext.decrypt( + let plaintext = shared.decrypt( + ciphertext, &self.enc_secret_key, &random_oracle_encryption, self.id as usize, ); - match SharesForNode::from_bytes(&plaintext).and_then(|my_shares| { - // If there is an error in this scope, we create a complaint instead of returning an error - verify_shares( - &my_shares, - &self.nodes, - self.id, - message, - &challenge, - self.batch_size, - )?; - Ok(my_shares) - }) { - Ok(my_shares) => Ok(ProcessedMessage::Valid(ReceiverOutput { - my_shares, - public_keys: full_public_keys.clone(), - })), - Err(_) => Ok(ProcessedMessage::Complaint(Complaint::create( - self.id, - ciphertext, - &self.enc_secret_key, - &self.random_oracle(), - &mut rand::thread_rng(), - ))), - } + let common_message_hash = common_message.hash(); + SharesForNode::from_bytes(plaintext) + .and_then(|my_shares| { + my_shares.verify( + common_message, + &challenge, + self.nodes.weight_of(self.id)?, + self.batch_size, + )?; + Ok(my_shares) + }) + .map(|my_shares| DecryptionOutcome::Valid { + output: ReceiverOutput { + my_shares, + public_keys: full_public_keys.clone(), + }, + vote: Vote { + common_message_hash, + }, + }) + .or_else(|_| { + Ok(DecryptionOutcome::Invalid(RevealComplaint { + accuser_id: self.id, + proof: recovery_proof::RecoveryProof::create( + self.id, + shared, + &self.enc_secret_key, + &self.random_oracle(), + &mut rand::thread_rng(), + ), + ciphertext: ciphertext.to_vec(), + common_message_hash, + })) + }) } - /// 4. Upon receiving a complaint, a receiver verifies it and responds with its shares. - pub fn handle_complaint( + /// 5a. Validate a [RevealComplaint] complaint and respond with this party's own shares so the + /// accuser can recover. Accepts iff the ciphertext is bound to the dealer's broadcast + /// (re-encodes to `recipient_roots[accuser_id]`) and the recovery package decrypts it + /// to invalid shares. + pub fn handle_reveal( &self, - message: &Message, - complaint: &Complaint, - my_output: &ReceiverOutput, - ) -> FastCryptoResult> { - let challenge = compute_challenge_from_message(&self.random_oracle(), message); - complaint.check( - &self.nodes.node_id_to_node(complaint.accuser_id)?.pk, - &message.ciphertext, + reveal: &RevealComplaint, + common_message: &VerifiedCommonMessage, + ciphertext: Vec, + ) -> FastCryptoResult { + let common_message = &common_message.0; + let challenge = + compute_challenge_from_common_message(&self.random_oracle(), common_message); + + let RevealComplaint { + accuser_id, + proof, + ciphertext: reveal_ciphertext, + common_message_hash, + } = reveal; + + if *common_message_hash != common_message.hash() { + return Err(InvalidProof); + } + let recipient_root = common_message.recipient_root(*accuser_id)?; + self.check_avid_consistency(reveal_ciphertext, recipient_root) + .map_err(|_| InvalidProof)?; + let accuser = self.nodes.node_id_to_node(*accuser_id)?; + proof.check( + *accuser_id, + &accuser.pk, + reveal_ciphertext, + &common_message.ciphertext_shared, &self.random_oracle(), |shares: &SharesForNode| { - verify_shares( - shares, - &self.nodes, - complaint.accuser_id, - message, - &challenge, - self.batch_size, - ) + shares.verify(common_message, &challenge, accuser.weight, self.batch_size) }, )?; - Ok(ComplaintResponse { - responder_id: self.id, - shares: my_output.my_shares.clone(), - }) + + Ok(self.build_complaint_response(common_message, ciphertext)) } - /// 5. Upon receiving t valid responses to a complaint, the accuser can recover its shares. - /// Fails if there are not enough valid responses to recover the shares or if any of the responses come from an invalid party. - pub fn recover( + /// 5b. Validate a [BlameComplaint] complaint and respond with this party's own shares. Accepts iff + /// each entry in `blame.shards` authenticates under + /// `common_message.recipient_roots[accuser_id]` at its sender's leaf, the senders + /// contribute `≥ W − 2f` weight, and the resulting set of shards either fails to + /// RS-decode or decodes to a ciphertext whose re-encoding doesn't match the accuser's + /// `r_i`. + pub fn handle_blame( &self, - message: &Message, - responses: Vec>, - ) -> FastCryptoResult { - // TODO: This fails if one of the responses has an invalid responder_id. We could probably just ignore those instead. - - // Sanity check that we have enough responses (by weight) to recover the shares. - let total_response_weight = self - .nodes - .total_weight_of(responses.iter().map(|response| &response.responder_id))?; - if total_response_weight < self.t { - return Err(FastCryptoError::InputTooShort(self.t as usize)); + blame: &BlameComplaint, + common_message: &VerifiedCommonMessage, + ciphertext: Vec, + ) -> FastCryptoResult { + let common_message = &common_message.0; + + let BlameComplaint { + accuser_id, + shards, + common_message_hash, + } = blame; + + if *common_message_hash != common_message.hash() { + return Err(InvalidProof); } + let recipient_root = common_message.recipient_root(*accuser_id)?; - let challenge = compute_challenge_from_message(&self.random_oracle(), message); - let response_shares = responses - .into_iter() - .filter_map(|response| { - response - .shares - .verify(message, &challenge) - .ok() - .map(|_| response.shares) - }) - .collect_vec(); + if shards + .iter() + .any(|(sender, auth)| auth.verify(*sender as usize, recipient_root).is_err()) + { + return Err(InvalidProof); + } - // Compute the total weight of the valid responses - let response_weight: u16 = response_shares.iter().map(SharesForNode::weight).sum(); - if response_weight < self.t { - return Err(FastCryptoError::InputTooShort(self.t as usize)); + let weight_of_shards = self.nodes.total_weight_of(shards.keys())?; + if weight_of_shards < self.nodes.total_weight() - 2 * self.f { + return Err(InvalidProof); } - let my_shares = SharesForNode::recover(self, &response_shares)?; - my_shares.verify(message, &challenge)?; + // The blame is valid iff the contributed shards either fail to RS-decode (they don't + // lie on a single codeword) or decode to a ciphertext whose re-encoding doesn't match + // the accuser's `r_i`. + if self + .reconstruct_ciphertext(*accuser_id, shards) + .ok() + .is_some_and(|ct| self.check_avid_consistency(&ct, recipient_root).is_ok()) + { + return Err(InvalidProof); + } - Ok(ReceiverOutput { - my_shares, - public_keys: message.full_public_keys.clone(), - }) + Ok(self.build_complaint_response(common_message, ciphertext)) } - pub fn my_indices(&self) -> Vec { - self.nodes.share_ids_of(self.id).unwrap() + /// Build a [ComplaintResponse] for an answered [RevealComplaint] / [BlameComplaint]: package this party's own + /// dealer-encrypted ciphertext together with an ECIES recovery package, so the accuser can + /// decrypt and authenticate the responder's shares. + fn build_complaint_response( + &self, + common_message: &CommonMessage, + ciphertext: Vec, + ) -> ComplaintResponse { + let recovery_package = common_message.ciphertext_shared.create_recovery_package( + &self.enc_secret_key, + &self.random_oracle().extend(&Recovery(self.id).to_string()), + &mut rand::thread_rng(), + ); + ComplaintResponse { + responder_id: self.id, + ciphertext, + recovery_package, + } + } + + /// Verify a [ComplaintResponse] against `common_message`: confirm that the responder's + /// ciphertext is the one the dealer broadcast to them, that the recovery package decrypts + /// it, and that the recovered shares are the ones the dealer dealt. Returns a + /// [VerifiedComplaintResponse] suitable for [Self::recover]. + pub fn verify_complaint_response( + &self, + response: ComplaintResponse, + common_message: &VerifiedCommonMessage, + ) -> FastCryptoResult { + let common_message = &common_message.0; + let challenge = + compute_challenge_from_common_message(&self.random_oracle(), common_message); + + let ComplaintResponse { + responder_id, + ciphertext, + recovery_package, + } = response; + + self.check_avid_consistency(&ciphertext, common_message.recipient_root(responder_id)?)?; + let responder = self.nodes.node_id_to_node(responder_id)?; + let shares = common_message + .ciphertext_shared + .decrypt_with_recovery_package( + &ciphertext, + &recovery_package, + &self + .random_oracle() + .extend(&Recovery(responder_id).to_string()), + &self.random_oracle().extend(&Encryption.to_string()), + &responder.pk, + responder_id as usize, + ) + .and_then(SharesForNode::from_bytes)?; + + shares.verify( + common_message, + &challenge, + responder.weight, + self.batch_size, + )?; + + Ok(VerifiedComplaintResponse(shares)) + } + + /// 6. Recover the accuser's own shares from a quorum of [VerifiedComplaintResponse]s. + /// Responses must already be validated via [Self::verify_complaint_response]. Fails if + /// `common_message` is malformed, the responses contribute `< t` weight, or the + /// interpolated shares fail final verification. + pub fn recover( + &self, + common_message: &VerifiedCommonMessage, + responses: Vec, + ) -> FastCryptoResult { + let response_shares = responses.into_iter().map(|v| v.0).collect_vec(); + let response_weight: u16 = response_shares.iter().map(SharesForNode::weight).sum(); + if response_weight < self.t { + return Err(FastCryptoError::InputTooShort(self.t as usize)); + } + + let common_message = &common_message.0; + let challenge = + compute_challenge_from_common_message(&self.random_oracle(), common_message); + let my_shares = SharesForNode::recover(self, &response_shares)?; + my_shares.verify( + common_message, + &challenge, + self.nodes.weight_of(self.id)?, + self.batch_size, + )?; + + Ok(ReceiverOutput { + my_shares, + public_keys: common_message.full_public_keys.clone(), + }) + } + + pub fn my_indices(&self) -> Vec { + self.nodes.share_ids_of(self.id).unwrap() } fn random_oracle(&self) -> RandomOracle { random_oracle_from_sid(&self.sid) } + + /// Reed-Solomon decode the ciphertext for `accuser_id` from a set of authenticated shard + /// contributions, keyed by sender id. Missing senders and senders whose shard count + /// doesn't match their weight are treated as erasures, so RS decoding fails if those + /// account for more than `2f` of the total weight. The caller is responsible for having + /// authenticated the shards via their Merkle proofs. + fn reconstruct_ciphertext( + &self, + accuser_id: PartyId, + shards: &BTreeMap, + ) -> FastCryptoResult> { + let shards_matrix = self + .nodes + .node_ids_iter() + .flat_map(|id| -> Vec> { + let weight = self.nodes.weight_of(id).expect("valid party id") as usize; + match shards.get(&id) { + // If the shards exist and are consistent with the weight, put them in the matrix. Otherwise, add a None, corresponding to an erasure. + Some(auth) if auth.shards.len() == weight => { + auth.shards.iter().cloned().map(Some).collect_vec() + } + _ => vec![None; weight], + } + }) + .collect_vec(); + + // The encryption used, counter-mode, is length-preserving, so the length of the ciphertext is equal to the length of the plaintext. + let expected_length = SharesForNode::bcs_serialized_size( + self.nodes.weight_of(accuser_id)? as usize, + self.batch_size, + ); + get_coder(&self.nodes, self.f).decode(shards_matrix, expected_length) + } + + /// RS-encode `ciphertext`, rebuild the per-recipient Merkle tree, and check its root matches + /// the dealer's `expected_root`. Errors with [InvalidMessage] on mismatch. + fn check_avid_consistency( + &self, + ciphertext: &[u8], + expected_root: &merkle::Node, + ) -> FastCryptoResult<()> { + let new_shards = self.nodes.collect_to_nodes( + get_coder(&self.nodes, self.f) + .encode(ciphertext)? + .into_iter(), + )?; + if recipient_tree(&new_shards)?.root() != *expected_root { + return Err(InvalidMessage); + } + Ok(()) + } +} + +impl CommonMessage { + /// Verify the dealer's commitments: the lengths/degree of the published values are + /// well-formed and `g^{p''(0)} = c' · ∏ c_l^{γ_l}`. Consumes `self` and returns a + /// [VerifiedCommonMessage] on success. + fn verify( + self, + t: u16, + batch_size: usize, + random_oracle: &RandomOracle, + ) -> FastCryptoResult { + if self.full_public_keys.len() != batch_size + || self.response_polynomial.degree() != t as usize - 1 + { + return Err(InvalidMessage); + } + let challenge = compute_challenge_from_common_message(random_oracle, &self); + if G::generator() * self.response_polynomial.c0() + != self.blinding_commit + + G::multi_scalar_mul(&challenge, &self.full_public_keys) + .expect("Inputs have constant lengths") + { + return Err(InvalidMessage); + } + Ok(VerifiedCommonMessage(self)) + } + + /// Blake2b hash of the BCS-serialized [CommonMessage]. Used to bind echoes and complaints + /// to a specific dealer broadcast. + fn hash(&self) -> Digest { + let mut hasher = Blake2b256::new(); + hasher.update( + bcs::to_bytes(&( + &self.ciphertext_shared, + &self.full_public_keys, + &self.blinding_commit, + &self.response_polynomial, + &self.recipient_roots, + )) + .unwrap(), + ); + hasher.finalize() + } + + /// The dealer's per-recipient Merkle root for `id`. Returns [InvalidProof] if `id` is + /// out of range. + fn recipient_root(&self, id: PartyId) -> FastCryptoResult<&merkle::Node> { + self.recipient_roots.get(id as usize).ok_or(InvalidProof) + } +} + +impl ShareBatch { + /// Verify a batch of shares using the given challenge. + fn verify(&self, message: &CommonMessage, challenge: &[S]) -> FastCryptoResult<()> { + if challenge.len() != self.batch_size() { + return Err(InvalidInput); + } + + // Verify that r' + sum_l r_l * gamma_l == p''(i) + if self + .batch + .iter() + .zip_eq(challenge) + .fold(self.blinding_share, |acc, (r_l, gamma_l)| { + acc + r_l * gamma_l + }) + != message.response_polynomial.eval(self.index).value + { + return Err(InvalidInput); + } + Ok(()) + } + + fn batch_size(&self) -> usize { + self.batch.len() + } } -/// Verify a set of shares receiver from a Dealer -fn verify_shares( - shares: &SharesForNode, - nodes: &Nodes, - receiver: PartyId, - message: &Message, - challenge: &[S], - expected_batch_size: usize, -) -> FastCryptoResult<()> { - if shares.weight() != nodes.weight_of(receiver)? - || shares.try_uniform_batch_size()? != expected_batch_size - { - return Err(InvalidMessage); - } - shares.verify(message, challenge) +impl SharesForNode { + /// Get the weight of this node (number of shares it has). + pub fn weight(&self) -> u16 { + self.shares.len() as u16 + } + + /// If all shares have the same batch size, return that. + /// Otherwise, return an InvalidInput error. + pub fn try_uniform_batch_size(&self) -> FastCryptoResult { + get_uniform_value(self.shares.iter().map(ShareBatch::batch_size)).ok_or(InvalidInput) + } + + /// Get all shares this node has for the i-th secret/nonce in the batch. + /// This panics if `i` is larger than or equal to the batch size. + pub fn shares_for_secret(&self, i: usize) -> impl Iterator> + '_ { + self.shares.iter().map(move |s| Eval { + index: s.index, + value: s.batch[i], + }) + } + + fn verify( + &self, + message: &CommonMessage, + challenge: &[S], + weight: u16, + expected_batch_size: usize, + ) -> FastCryptoResult<()> { + if self.weight() != weight || self.try_uniform_batch_size()? != expected_batch_size { + return Err(InvalidMessage); + } + for shares in &self.shares { + shares.verify(message, challenge)?; + } + Ok(()) + } + + /// Recover the shares for this node. + /// + /// Fails if `other_shares` is empty or if the batch sizes of all shares in `other_shares` are not equal to the expected batch size. + fn recover(receiver: &Receiver, other_shares: &[Self]) -> FastCryptoResult { + if other_shares.is_empty() { + return Err(InvalidInput); + } + + let shares = receiver + .my_indices() + .into_iter() + .map(|index| { + let batch = (0..receiver.batch_size) + .map(|i| { + let evaluations = other_shares + .iter() + .flat_map(|s| s.shares_for_secret(i)) + .collect_vec(); + Poly::recover_at(index, &evaluations).unwrap().value + }) + .collect_vec(); + + let blinding_share = Poly::recover_at( + index, + &other_shares + .iter() + .flat_map(|s| &s.shares) + .map(|share| Eval { + index: share.index, + value: share.blinding_share, + }) + .collect_vec(), + )? + .value; + + Ok(ShareBatch { + index, + batch, + blinding_share, + }) + }) + .collect::>>()?; + Ok(Self { shares }) + } + + /// BCS-serialized length of a `SharesForNode` for a node of the given weight at the given + /// batch size. + fn bcs_serialized_size(weight: usize, batch_size: usize) -> usize { + // Layout: + // SharesForNode = Vec + // = ULEB128(weight) + weight × ShareBatch + // ShareBatch + // = NonZeroU16 (= 2 bytes) + Vec + S + // = 2 + ULEB128(batch_size) + (batch_size + 1) × SCALAR_SIZE_IN_BYTES + + // TODO: A bit of a hack — this hardcodes the BCS layout of `SharesForNode` + uleb128_len(weight) + + weight * (2 + uleb128_len(batch_size) + (batch_size + 1) * SCALAR_SIZE_IN_BYTES) + } +} + +impl BCSSerialized for SharesForNode {} + +impl AuthenticatedShards { + /// Verify that `shards` are the leaf at `leaf_index` under `recipient_root`. + fn verify(&self, leaf_index: usize, recipient_root: &merkle::Node) -> FastCryptoResult<()> { + self.proof + .verify_proof_with_unserialized_leaf(recipient_root, &self.shards, leaf_index) + } +} + +impl Echo { + /// Verify this echo against `common_message` for the recipient `receiver_id`: the sender's + /// shard count matches `sender_weight`, the Merkle proof checks against + /// `recipient_roots[receiver_id]`, and the echo's `common_message_hash` matches. + fn verify( + self, + sender_weight: u16, + receiver_id: PartyId, + common_message: &CommonMessage, + ) -> FastCryptoResult { + if self.authenticated_shards.shards.len() != sender_weight as usize { + return Err(InvalidMessage); + } + self.authenticated_shards.verify( + self.sender as usize, + common_message.recipient_root(receiver_id)?, + )?; + if self.common_message_hash != common_message.hash() { + return Err(InvalidMessage); + } + Ok(VerifiedEcho(self)) + } +} + +/// Reed-Solomon `(W, W − 2f)` coder over the per-receiver ciphertexts. Requires the parameters +/// to have been validated via [validate_parameters]. +fn get_coder(nodes: &Nodes, f: u16) -> ErasureCoder { + ErasureCoder::new( + nodes.total_weight() as usize, + (nodes.total_weight() - 2 * f) as usize, + ) + .expect("parameters were validated by validate_parameters") +} + +/// Validate the protocol parameters `(t, f, W)`. +/// * It is possible to create a Reed-Solomon `(W, W − 2f)` coder +/// * `1 ≤ t ≤ W` — recovery threshold is well-defined and reachable by the total weight. +fn validate_parameters(t: u16, f: u16, total_weight: u16) -> FastCryptoResult<()> { + if f == 0 || total_weight <= 2 * f || t == 0 || t > total_weight { + return Err(InvalidInput); + } + ErasureCoder::check_parameters(total_weight as usize, (total_weight - 2 * f) as usize)?; + Ok(()) +} + +/// Build the per-recipient Merkle tree over `shards` (per-node grouped shard chunks of one +/// ciphertext). The root of this tree is the per-recipient `recipient_root`. +#[allow(clippy::ptr_arg)] +fn recipient_tree(shards: &Vec>) -> FastCryptoResult> { + MerkleTree::::build_from_unserialized(shards.iter()) +} + +/// Number of bytes BCS uses to encode `x` as an unsigned LEB128 length prefix. +fn uleb128_len(x: usize) -> usize { + let mut len = 1; + let mut v = x >> 7; + while v != 0 { + len += 1; + v >>= 7; + } + len } fn compute_challenge( random_oracle: &RandomOracle, c: &[G], c_prime: &G, - e: &MultiRecipientEncryption, + shared: &SharedComponents, + recipient_roots: &[merkle::Node], ) -> Vec { let random_oracle = random_oracle.extend(&Challenge.to_string()); - let inner_hash = Sha3_512::digest(bcs::to_bytes(&(c.to_vec(), c_prime, e)).unwrap()).digest; + let inner_hash = + Blake2b256::digest(bcs::to_bytes(&(c.to_vec(), c_prime, shared, recipient_roots)).unwrap()) + .digest; (0..c.len()) .map(|l| random_oracle.evaluate_to_group_element(&(l, inner_hash.to_vec()))) .collect() } -fn compute_challenge_from_message(random_oracle: &RandomOracle, message: &Message) -> Vec { +fn compute_challenge_from_common_message( + random_oracle: &RandomOracle, + message: &CommonMessage, +) -> Vec { compute_challenge( random_oracle, &message.full_public_keys, &message.blinding_commit, - &message.ciphertext, + &message.ciphertext_shared, + &message.recipient_roots, ) } #[cfg(test)] mod tests { use super::{ - compute_challenge, Complaint, Dealer, Message, ProcessedMessage, Receiver, ReceiverOutput, - ShareBatch, SharesForNode, + Dealer, DecodeOutcome, DecryptionOutcome, Message, Receiver, ReceiverOutput, ShareBatch, + SharesForNode, }; use crate::ecies_v1; - use crate::ecies_v1::{MultiRecipientEncryption, PublicKey}; + use crate::ecies_v1::PublicKey; use crate::nodes::{Node, Nodes}; use crate::polynomial::{Eval, Poly}; use crate::threshold_schnorr::bcs::BCSSerialized; - use crate::threshold_schnorr::Extensions::Encryption; - use crate::threshold_schnorr::{EG, G}; + use crate::threshold_schnorr::EG; use crate::types::ShareIndex; use fastcrypto::error::FastCryptoResult; - use fastcrypto::groups::GroupElement; use fastcrypto::traits::AllowedRng; use itertools::Itertools; use std::collections::HashMap; - use std::iter::repeat_with; + + #[test] + fn test_bcs_serialized_size_matches_serialization() { + // For every (weight, batch_size) in the matrix, build a real `SharesForNode` and BCS- + // serialize it; the byte length must agree with `SharesForNode::bcs_serialized_size`. Cases + // straddle the ULEB128 single-byte/two-byte boundary at 128 in both dimensions. + use crate::threshold_schnorr::S; + use fastcrypto::groups::GroupElement; + + let dummy_index = ShareIndex::try_from(1u16).unwrap(); + let zero_scalar = S::zero(); + for &weight in &[1usize, 2, 5, 10, 100, 127, 128, 200] { + for &batch_size in &[1usize, 2, 3, 7, 50, 127, 128, 200] { + let shares_for_node = SharesForNode { + shares: (0..weight) + .map(|_| ShareBatch { + index: dummy_index, + batch: vec![zero_scalar; batch_size], + blinding_share: zero_scalar, + }) + .collect(), + }; + let actual = shares_for_node.to_bytes().len(); + let formula = SharesForNode::bcs_serialized_size(weight, batch_size); + assert_eq!(actual, formula, "weight={weight}, batch_size={batch_size}"); + } + } + } #[test] fn test_happy_path() { // No complaints, all honest. All have weight 1 let t = 3; + let f = 2; let n = 7; let batch_size_per_weight = 3; @@ -598,6 +1273,7 @@ mod tests { let dealer: Dealer = Dealer::new( nodes.clone(), dealer_id, + f, t, sid.clone(), batch_size_per_weight, @@ -612,6 +1288,7 @@ mod tests { nodes.clone(), id as u16, dealer_id, + f, t, sid.clone(), secret_key, @@ -621,15 +1298,44 @@ mod tests { }) .collect_vec(); - let message = dealer.create_message(&mut rng).unwrap(); + let messages = dealer.create_message(&mut rng).unwrap(); + + let (verified_commons, echoes_by_sender): (Vec<_>, Vec<_>) = receivers + .iter() + .map(|receiver| receiver.echo(&messages[receiver.id as usize]).unwrap()) + .unzip(); + + let echoes_by_recipient = receivers + .iter() + .enumerate() + .map(|(i, _)| { + echoes_by_sender + .iter() + .map(|em| em[i].clone()) + .collect_vec() + }) + .collect_vec(); + + let decoded_ciphertext = receivers + .iter() + .zip(verified_commons.iter()) + .zip(echoes_by_recipient.iter()) + .map(|((receiver, vcm), echoes)| { + let verified = echoes + .iter() + .map(|e| receiver.verify_echo(e.clone(), vcm).unwrap()) + .collect_vec(); + assert_decoded(receiver.decode_ciphertext(&verified, vcm).unwrap()) + }) + .collect_vec(); let all_shares = receivers .iter() - .map(|receiver| { - ( - receiver.id, - assert_valid(receiver.process_message(&message).unwrap()), - ) + .zip(verified_commons.iter()) + .zip(decoded_ciphertext) + .map(|((receiver, vcm), pem)| { + let output = assert_valid(receiver.verify_and_decrypt(&pem, vcm).unwrap()); + (receiver.id, output) }) .collect::>(); @@ -657,38 +1363,39 @@ mod tests { assert_eq!(secrets, secrets); } - #[test] - #[allow(clippy::single_match)] - fn test_happy_path_non_equal_weights() { - // No complaints, all honest - let t = 4; - let weights: Vec = vec![1, 2, 3, 4]; - let batch_size_per_weight = 3; + fn test_share_recovery() { + // Dealer is honest at the AVID layer (consistent dispersal) but flips a byte in + // receiver 0's plaintext, so receiver 0's decryption succeeds but the resulting + // SharesForNode fails verification — triggering an Invalid complaint. The other receivers + // verify the complaint and respond with their own shares; receiver 0 reconstructs. + let t = 3; + let f = 2; + let n = 7; + let batch_size_per_weight: u16 = 3; let mut rng = rand::thread_rng(); - let sks = weights - .iter() + let sks = (0..n) .map(|_| ecies_v1::PrivateKey::::new(&mut rng)) .collect::>(); let nodes = Nodes::new( - weights - .into_iter() + sks.iter() .enumerate() - .map(|(i, weight)| Node { - id: i as u16, - pk: PublicKey::from_private_key(&sks[i]), - weight, + .map(|(id, sk)| Node { + id: id as u16, + pk: PublicKey::from_private_key(sk), + weight: 1, }) - .collect_vec(), + .collect::>(), ) .unwrap(); - let dealer_id = 2; let sid = b"tbls test".to_vec(); - let dealer: Dealer = Dealer::new( + let dealer_id = 1; + let dealer = Dealer::new( nodes.clone(), dealer_id, + f, t, sid.clone(), batch_size_per_weight, @@ -698,11 +1405,12 @@ mod tests { let receivers = sks .into_iter() .enumerate() - .map(|(i, secret_key)| { + .map(|(id, secret_key)| { Receiver::new( nodes.clone(), - i as u16, + id as u16, dealer_id, + f, t, sid.clone(), secret_key, @@ -712,36 +1420,102 @@ mod tests { }) .collect_vec(); - let message = dealer.create_message(&mut rng).unwrap(); + let messages = dealer.create_message_cheating(&mut rng).unwrap(); - let all_shares = receivers + // Echo phase + let (verified_commons, echos): (Vec<_>, Vec<_>) = receivers + .iter() + .map(|r| r.echo(&messages[r.id as usize]).unwrap()) + .unzip(); + let echoes_per_recipient = (0..n) + .map(|i| echos.iter().map(|em| em[i].clone()).collect_vec()) + .collect_vec(); + + // Process echoes + verify_and_decrypt. AVID is consistent for everyone in this test, so + // every decode yields a Decoded outcome. + let mut ciphertexts: HashMap> = HashMap::new(); + let outcomes: HashMap = receivers .iter() - .flat_map(|receiver| { - assert_valid(receiver.process_message(&message).unwrap()) - .my_shares - .shares + .zip(verified_commons.iter()) + .zip(echoes_per_recipient.iter()) + .map(|((r, vcm), echoes)| { + let verified = echoes + .iter() + .map(|e| r.verify_echo(e.clone(), vcm).unwrap()) + .collect_vec(); + let pem = assert_decoded(r.decode_ciphertext(&verified, vcm).unwrap()); + ciphertexts.insert(r.id, pem.clone()); + (r.id, r.verify_and_decrypt(&pem, vcm).unwrap()) }) - .collect::>(); + .collect(); + + // Receiver 0 (the targeted victim) emits an Invalid complaint. + let victim_id = 0u16; + let mut outcomes = outcomes; + let reveal = match outcomes.remove(&victim_id).unwrap() { + DecryptionOutcome::Invalid(r) => r, + ref other => panic!( + "expected Invalid from victim, got {:?}", + outcome_kind(other) + ), + }; + + // The other receivers each get a Valid output. + let mut outputs: HashMap = outcomes + .into_iter() + .map(|(id, o)| match o { + DecryptionOutcome::Valid { output, .. } => (id, output), + ref other => panic!( + "expected Valid from honest receiver {id}, got {:?}", + outcome_kind(other) + ), + }) + .collect(); - let secrets = (0..dealer.batch_size) - .map(|l| { - Poly::recover_c0( - t, - all_shares.iter().take(t as usize).map(|s| Eval { - index: s.index, - value: s.batch[l], - }), - ) - .unwrap() + // Each non-victim verifies the complaint and returns their own ciphertext + recovery package. + let responses = receivers + .iter() + .zip(verified_commons.iter()) + .filter(|(r, _)| r.id != victim_id) + .map(|(r, vcm)| { + r.handle_reveal(&reveal, vcm, ciphertexts.get(&r.id).unwrap().clone()) + .unwrap() }) - .collect::>(); + .collect_vec(); - assert_eq!(secrets, secrets); + // Victim verifies and then recovers via interpolation across t responses. + let victim = &receivers[victim_id as usize]; + let vcm = &verified_commons[victim_id as usize]; + let verified_responses = responses + .into_iter() + .map(|r| victim.verify_complaint_response(r, vcm).unwrap()) + .collect_vec(); + let recovered = victim.recover(vcm, verified_responses).unwrap(); + outputs.insert(victim_id, recovered); + + // Sanity: every receiver now holds verifiable shares for every secret. + for l in 0..dealer.batch_size { + let shares = receivers + .iter() + .take(t as usize) + .map(|r| Eval { + index: ShareIndex::try_from(r.id + 1).unwrap(), + value: outputs.get(&r.id).unwrap().my_shares.shares[0].batch[l], + }) + .collect_vec(); + Poly::recover_c0(t, shares.into_iter()).unwrap(); + } } #[test] - fn test_share_recovery() { + fn test_share_recovery_blame() { + // Dealer is honest at the share layer (decryption yields valid shares) but corrupts the + // last f senders' shards for receiver 0's ciphertext. Receiver 0 collects the W - f + // unaffected echoes, decodes the original ciphertext, decrypts valid shares, but + // re-encoding the recovered ciphertext yields a tree root different from the dealer's + // r_0 — triggering an InvalidDispersal complaint. let t = 3; + let f = 2; let n = 7; let batch_size_per_weight: u16 = 3; @@ -762,11 +1536,11 @@ mod tests { .unwrap(); let sid = b"tbls test".to_vec(); - let dealer_id = 1; - let dealer: Dealer = Dealer::new( + let dealer = Dealer::new( nodes.clone(), dealer_id, + f, t, sid.clone(), batch_size_per_weight, @@ -781,6 +1555,7 @@ mod tests { nodes.clone(), id as u16, dealer_id, + f, t, sid.clone(), secret_key, @@ -788,142 +1563,166 @@ mod tests { ) .unwrap() }) - .collect::>(); + .collect_vec(); - let message = dealer.create_message_cheating(&mut rng).unwrap(); + let messages = dealer.create_message_cheating_dispersal(&mut rng).unwrap(); + let victim_id = 0u16; - let mut all_shares = receivers + // Echo phase + let (verified_commons, echos): (Vec<_>, Vec<_>) = receivers .iter() - .map(|receiver| (receiver.id, receiver.process_message(&message).unwrap())) - .collect::>(); + .map(|r| r.echo(&messages[r.id as usize]).unwrap()) + .unzip(); + + // Bundle echoes per recipient. For the victim, simulate the last f senders being silent + // (their corrupted shards would otherwise make the receiver's decode fail outright). + let echoes_per_recipient = (0..n) + .map(|i| { + let take = if i == victim_id as usize { + n - f as usize + } else { + n + }; + echos + .iter() + .take(take) + .map(|em| em[i].clone()) + .collect_vec() + }) + .collect_vec(); - let complaint = assert_complaint(all_shares.remove(&receivers[0].id).unwrap()); - let mut all_shares = all_shares + // Decode each receiver's ciphertext. The victim hits the AVID inconsistency at the + // decode stage and gets a [DecodeOutcome::InvalidDispersal] directly; everyone else + // gets a [DecodeOutcome::Decoded] that they can pass through `verify_and_decrypt`. + let mut decode_outcomes: HashMap = receivers + .iter() + .zip(verified_commons.iter()) + .zip(echoes_per_recipient.iter()) + .map(|((r, vcm), echoes)| { + let verified = echoes + .iter() + .map(|e| r.verify_echo(e.clone(), vcm).unwrap()) + .collect_vec(); + (r.id, r.decode_ciphertext(&verified, vcm).unwrap()) + }) + .collect(); + + let blame = match decode_outcomes.remove(&victim_id).unwrap() { + DecodeOutcome::InvalidDispersal(blame) => blame, + DecodeOutcome::Decoded(_) => panic!("expected InvalidDispersal from victim"), + }; + // The other receivers each get a Valid output. + let mut ciphertexts: HashMap> = HashMap::new(); + let mut outputs: HashMap = decode_outcomes .into_iter() - .map(|(id, pm)| (id, assert_valid(pm))) - .collect::>(); + .map(|(id, decoded)| { + let pem = assert_decoded(decoded); + ciphertexts.insert(id, pem.clone()); + let outcome = receivers[id as usize] + .verify_and_decrypt(&pem, &verified_commons[id as usize]) + .unwrap(); + let output = match outcome { + DecryptionOutcome::Valid { output, .. } => output, + ref other => panic!( + "expected Valid from honest receiver {id}, got {:?}", + outcome_kind(other) + ), + }; + (id, output) + }) + .collect(); + // Each non-victim verifies the complaint and returns their own ciphertext + recovery package. let responses = receivers .iter() - .skip(1) - .map(|r| { - r.handle_complaint(&message, &complaint, all_shares.get(&r.id).unwrap()) + .zip(verified_commons.iter()) + .filter(|(r, _)| r.id != victim_id) + .map(|(r, vcm)| { + r.handle_blame(&blame, vcm, ciphertexts.get(&r.id).unwrap().clone()) .unwrap() }) - .collect::>(); - let shares = receivers[0].recover(&message, responses).unwrap(); - all_shares.insert(receivers[0].id, shares); - - // Recover with the first f+1 shares, including the reconstructed - let secrets = (0..dealer.batch_size) - .map(|l| { - let shares = all_shares - .iter() - .map(|(id, s)| (*id, s.my_shares.shares[0].batch[l])) - .collect::>(); - Poly::recover_c0( - t, - shares.iter().take(t as usize).map(|(id, v)| Eval { - index: ShareIndex::try_from(id + 1).unwrap(), - value: *v, - }), - ) - .unwrap() - }) - .collect::>(); - - assert_eq!(secrets, secrets); - } - - impl Dealer { - /// 1. The Dealer samples L nonces, generates shares and broadcasts the encrypted shares. This also returns the nonces to be secret shared along with their corresponding public keys. - pub fn create_message_cheating( - &self, - rng: &mut impl AllowedRng, - ) -> FastCryptoResult { - let polynomials = repeat_with(|| Poly::rand(self.t - 1, rng)) - .take(self.batch_size) - .collect_vec(); - - // Compute the (full) public keys for all secrets - let full_public_keys = polynomials - .iter() - .map(|p| G::generator() * p.c0()) - .collect_vec(); + .collect_vec(); - // "blinding" polynomial as defined in https://eprint.iacr.org/2023/536.pdf. - let blinding_poly = Poly::rand(self.t - 1, rng); - let blinding_commit = G::generator() * blinding_poly.c0(); + // Victim verifies and then recovers via interpolation across t responses. + let victim = &receivers[victim_id as usize]; + let vcm = &verified_commons[victim_id as usize]; + let verified_responses = responses + .into_iter() + .map(|r| victim.verify_complaint_response(r, vcm).unwrap()) + .collect_vec(); + let recovered = victim.recover(vcm, verified_responses).unwrap(); + outputs.insert(victim_id, recovered); - // Encrypt all shares to the receivers - let mut pk_and_msgs = self - .nodes + // Sanity: every receiver now holds verifiable shares for every secret. + for l in 0..dealer.batch_size { + let shares = receivers .iter() - .map(|node| (node.pk.clone(), self.nodes.share_ids_of(node.id).unwrap())) - .map(|(public_key, share_ids)| { - ( - public_key, - SharesForNode { - shares: share_ids - .into_iter() - .map(|index| ShareBatch { - index, - batch: polynomials - .iter() - .map(|p_l| p_l.eval(index).value) - .collect_vec(), - blinding_share: blinding_poly.eval(index).value, - }) - .collect_vec(), - }, - ) + .take(t as usize) + .map(|r| Eval { + index: ShareIndex::try_from(r.id + 1).unwrap(), + value: outputs.get(&r.id).unwrap().my_shares.shares[0].batch[l], }) - .map(|(pk, shares_for_node)| (pk, shares_for_node.to_bytes())) .collect_vec(); + Poly::recover_c0(t, shares.into_iter()).unwrap(); + } + } - // Modify the first share of the first receiver to simulate a cheating dealer - pk_and_msgs[0].1[7] ^= 1; - - let ciphertext = MultiRecipientEncryption::encrypt( - &pk_and_msgs, - &self.random_oracle().extend(&Encryption.to_string()), - rng, - ); + fn assert_valid(outcome: DecryptionOutcome) -> ReceiverOutput { + match outcome { + DecryptionOutcome::Valid { output, .. } => output, + ref other => panic!("expected valid outcome, got {:?}", outcome_kind(other)), + } + } - // "response" polynomials from https://eprint.iacr.org/2023/536.pdf - let challenge = compute_challenge( - &self.random_oracle(), - &full_public_keys, - &blinding_commit, - &ciphertext, - ); - let mut response_polynomial = blinding_poly; - for (p_l, gamma_l) in polynomials.into_iter().zip_eq(&challenge) { - response_polynomial += &(p_l * gamma_l); + fn assert_decoded(outcome: DecodeOutcome) -> Vec { + match outcome { + DecodeOutcome::Decoded(c) => c, + DecodeOutcome::InvalidDispersal { .. } => { + panic!("expected Decoded outcome, got InvalidDispersal") } - - Ok(Message { - full_public_keys, - blinding_commit, - ciphertext, - response_polynomial, - }) } } - fn assert_valid(processed_message: ProcessedMessage) -> ReceiverOutput { - if let ProcessedMessage::Valid(output) = processed_message { - output - } else { - panic!("Expected valid message"); + fn outcome_kind(outcome: &DecryptionOutcome) -> &'static str { + match outcome { + DecryptionOutcome::Valid { .. } => "Valid", + DecryptionOutcome::Invalid(_) => "Invalid", } } - fn assert_complaint(processed_message: ProcessedMessage) -> Complaint { - if let ProcessedMessage::Complaint(complaint) = processed_message { - complaint - } else { - panic!("Expected complaint"); + impl Dealer { + /// Test-only: produce a [Message] in which receiver 0's plaintext has one byte flipped + /// before encryption. AVID dispersal stays consistent (so the AVID checks pass for + /// everyone), but receiver 0's BCS-deserialized [SharesForNode] fails verification. + fn create_message_cheating( + &self, + rng: &mut impl AllowedRng, + ) -> FastCryptoResult> { + self.create_message_with_mutation( + rng, + |pk_and_msgs| { + pk_and_msgs[0].1[7] ^= 1; + }, + |_| {}, + ) + } + + fn create_message_cheating_dispersal( + &self, + rng: &mut impl AllowedRng, + ) -> FastCryptoResult> { + let f = self.f as usize; + let n = self.nodes.total_weight() as usize; + self.create_message_with_mutation( + rng, + |_| {}, + |shards| { + // Flip a byte in the shards held by the last `f` senders for ciphertext 0. + for sender_shards in shards[0].iter_mut().skip(n - f) { + sender_shards[0].0[0] ^= 1; + } + }, + ) } } } diff --git a/fastcrypto-tbls/src/threshold_schnorr/bcs.rs b/fastcrypto-tbls/src/threshold_schnorr/bcs.rs index 596755e607..558c226041 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/bcs.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/bcs.rs @@ -11,10 +11,10 @@ pub trait BCSSerialized: Serialize + for<'de> Deserialize<'de> { bcs::to_bytes(self).unwrap() } - fn from_bytes(bytes: &[u8]) -> FastCryptoResult + fn from_bytes(bytes: impl AsRef<[u8]>) -> FastCryptoResult where Self: Sized, { - bcs::from_bytes(bytes).map_err(|_| InvalidInput) + bcs::from_bytes(bytes.as_ref()).map_err(|_| InvalidInput) } } diff --git a/fastcrypto-tbls/src/threshold_schnorr/mod.rs b/fastcrypto-tbls/src/threshold_schnorr/mod.rs index 9ac4f13bda..2a440afa15 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/mod.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/mod.rs @@ -32,10 +32,10 @@ use std::fmt::{Display, Formatter}; pub mod avss; pub mod batch_avss; mod bcs; -pub mod complaint; pub mod key_derivation; mod pascal_matrix; pub mod presigning; +pub mod recovery_proof; pub mod reed_solomon; pub mod signing; @@ -92,7 +92,6 @@ mod tests { use itertools::Itertools; use std::collections::HashMap; use std::hash::Hash; - #[test] fn test_e2e() { // No complaints, all honest @@ -130,7 +129,6 @@ mod tests { dkg_outputs.insert(id, HashMap::new()); }); - let mut messages = Vec::new(); for dealer_id in nodes.node_ids_iter() { let sid = format!("dkg-test-session-{}", dealer_id).into_bytes(); let dealer: avss::Dealer = @@ -152,7 +150,6 @@ mod tests { // Each dealer creates a message let message = dealer.create_message(&mut rng); - messages.push(message.clone()); // Each receiver processes the message. In this case, we assume all are honest and there are no complaints. receivers.iter().for_each(|receiver| { @@ -212,6 +209,7 @@ mod tests { let dealer: batch_avss::Dealer = batch_avss::Dealer::new( nodes.clone(), dealer_id, + f, t, sid.clone(), batch_size_per_weight, @@ -225,6 +223,7 @@ mod tests { nodes.clone(), id as u16, dealer_id, + f, t, sid.clone(), enc_secret_key.clone(), @@ -235,17 +234,37 @@ mod tests { .collect::>(); // Each dealer creates a message - let message = dealer.create_message(&mut rng).unwrap(); + let messages = dealer.create_message(&mut rng).unwrap(); + + // Each receiver produces echoes addressed to every party. + let (verified_commons, echoes): (Vec<_>, Vec>) = receivers + .iter() + .map(|r| r.echo(&messages[r.id as usize]).unwrap()) + .unzip(); + + // Bundle echoes per recipient: echoes_per_recipient[i] = echoes addressed to party i. + let echoes_per_recipient: Vec> = (0..n) + .map(|i| echoes.iter().map(|em| em[i].clone()).collect()) + .collect(); // Each receiver processes the message. // In this case, we assume all are honest and there are no complaints. - receivers.iter().for_each(|receiver| { - let output = assert_valid_batch(receiver.process_message(&message).unwrap()); - presigning_outputs - .get_mut(&receiver.id) - .unwrap() - .push(output); - }); + for ((r, echoes), vcm) in receivers + .iter() + .zip(&echoes_per_recipient) + .zip(&verified_commons) + { + let verified = echoes + .iter() + .map(|e| r.verify_echo(e.clone(), vcm).unwrap()) + .collect::>(); + let pem = match r.decode_ciphertext(&verified, vcm).unwrap() { + batch_avss::DecodeOutcome::Decoded(d) => d, + _ => panic!("expected Decoded outcome"), + }; + let output = assert_valid_batch(r.verify_and_decrypt(&pem, vcm).unwrap()); + presigning_outputs.get_mut(&r.id).unwrap().push(output); + } } // Each party can process their presigs locally from the secret shared nonces @@ -325,7 +344,6 @@ mod tests { // Here, each party will act as dealer multiple times -- once per share they have. let mut dkg_outputs_after_rotation = HashMap::<(PartyId, ShareIndex), avss::PartialOutput>::new(); - let mut messages = HashMap::<(PartyId, ShareIndex), avss::Message>::new(); for dealer_id in nodes.node_ids_iter() { for share_index in nodes.share_ids_of(dealer_id).unwrap() { @@ -349,9 +367,7 @@ mod tests { let commitment = merged_shares .get(&(id as u16)) .unwrap() - .commitments - .iter() - .find(|c| c.index == share_index) + .commitment_for_index(share_index) .unwrap() .value; avss::Receiver::new( @@ -367,7 +383,6 @@ mod tests { // Each dealer creates a message let message = dealer.create_message(&mut rng); - messages.insert((dealer_id, share_index), message.clone()); // Each receiver processes the message. In this case, we assume all are honest and there are no complaints. receivers.iter().for_each(|receiver| { @@ -385,7 +400,7 @@ mod tests { .collect_vec(); // Now, each party has collected their outputs from all dealers and can form their new shares from the ones in the certificate. - let merged_shares_after_rotation = nodes + let merged_shares = nodes .node_ids_iter() .map(|receiver_id| { let my_shares_from_cert = share_indices_in_cert @@ -415,13 +430,13 @@ mod tests { .collect::>(); // The verifying key should be the same as before - for output in merged_shares_after_rotation.values() { + for output in merged_shares.values() { assert_eq!(output.vk, vk); } // For testing, we now recover the secret key from t shares and check that the secret key matches the verification key. // In practice, the parties should never do this... - let shares = merged_shares_after_rotation + let shares = merged_shares .values() .flat_map(|output| output.my_shares.shares.clone()) .take(t as usize); @@ -429,13 +444,8 @@ mod tests { assert_eq!(G::generator() * sk, vk); // Check commitments on the reshared secret from the first dealer - let commitment_1 = merged_shares_after_rotation - .get(&0) - .unwrap() - .commitments - .first() - .unwrap(); - let secret_1 = merged_shares_after_rotation + let commitment_1 = merged_shares.get(&0).unwrap().commitments.first().unwrap(); + let secret_1 = merged_shares .get(&0) .unwrap() .share_for_index(commitment_1.index) @@ -460,10 +470,7 @@ mod tests { message_2, presigs.get_mut(&node.id).unwrap().next().unwrap(), &beacon_value, - &merged_shares_after_rotation - .get(&node.id) - .unwrap() - .my_shares, + &merged_shares.get(&node.id).unwrap().my_shares, &vk, None, ) @@ -501,21 +508,17 @@ mod tests { .unwrap(); } - fn assert_valid_batch( - processed_message: batch_avss::ProcessedMessage, - ) -> batch_avss::ReceiverOutput { - if let batch_avss::ProcessedMessage::Valid(output) = processed_message { - output - } else { - panic!("Expected valid message"); + fn assert_valid(pm: avss::ProcessedMessage) -> avss::PartialOutput { + match pm { + avss::ProcessedMessage::Valid(po) => po, + avss::ProcessedMessage::Complaint(_) => panic!("expected valid avss output"), } } - fn assert_valid(processed_message: avss::ProcessedMessage) -> avss::PartialOutput { - if let avss::ProcessedMessage::Valid(output) = processed_message { - output - } else { - panic!("Expected valid message"); + fn assert_valid_batch(outcome: batch_avss::DecryptionOutcome) -> batch_avss::ReceiverOutput { + match outcome { + batch_avss::DecryptionOutcome::Valid { output, .. } => output, + _ => panic!("expected valid batch_avss output"), } } diff --git a/fastcrypto-tbls/src/threshold_schnorr/complaint.rs b/fastcrypto-tbls/src/threshold_schnorr/recovery_proof.rs similarity index 56% rename from fastcrypto-tbls/src/threshold_schnorr/complaint.rs rename to fastcrypto-tbls/src/threshold_schnorr/recovery_proof.rs index 9ab77447c3..9e2828b297 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/complaint.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/recovery_proof.rs @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 use crate::ecies_v1; -use crate::ecies_v1::RecoveryPackage; +use crate::ecies_v1::{RecoveryPackage, SharedComponents}; use crate::nodes::PartyId; use crate::random_oracle::RandomOracle; use crate::threshold_schnorr::bcs::BCSSerialized; @@ -14,36 +14,39 @@ use fastcrypto::traits::AllowedRng; use serde::{Deserialize, Serialize}; use tracing::debug; -/// A complaint by an accuser that it could not decrypt or verify its shares. -/// Given enough responses to the complaint, the accuser can recover its shares. +/// Cryptographic proof attached to a complaint: an ECIES recovery package that opens the +/// dealer's shared ciphertext with the accuser's private key and produces shares that fail a +/// supplied verifier. #[derive(Clone, Debug, Serialize, Deserialize)] -pub struct Complaint { - pub(crate) accuser_id: PartyId, - pub(crate) proof: RecoveryPackage, -} +pub struct RecoveryProof(RecoveryPackage); -impl Complaint { - /// Try to decrypt the shares for the accuser. +impl RecoveryProof { + /// Verify the proof for the given `accuser_id`: decrypt `ciphertext` via the recovery + /// package and confirm the resulting shares fail `verifier`. The caller supplies + /// `accuser_id` from their protocol context — it is *not* carried inside the proof. pub fn check( &self, + accuser_id: PartyId, enc_pk: &ecies_v1::PublicKey, - ciphertext: &ecies_v1::MultiRecipientEncryption, + ciphertext: &[u8], + shared: &SharedComponents, random_oracle: &RandomOracle, verifier: impl Fn(&S) -> FastCryptoResult<()>, ) -> FastCryptoResult<()> { // Check that the recovery package is valid, and if not, return an error since the complaint is invalid. - let buffer = ciphertext.decrypt_with_recovery_package( - &self.proof, - &random_oracle.extend(&Recovery(self.accuser_id).to_string()), + let buffer = shared.decrypt_with_recovery_package( + ciphertext, + &self.0, + &random_oracle.extend(&Recovery(accuser_id).to_string()), &random_oracle.extend(&Encryption.to_string()), enc_pk, - self.accuser_id as usize, + accuser_id as usize, )?; let Ok(shares) = S::from_bytes(&buffer) else { debug!( "Complaint by party {} is valid: Failed to deserialize shares", - self.accuser_id + accuser_id ); return Ok(()); }; @@ -51,13 +54,13 @@ impl Complaint { if verifier(&shares).is_ok() { debug!( "Complaint by party {} is invalid: Shares verify correctly", - self.accuser_id + accuser_id ); Err(InvalidProof) } else { debug!( "Complaint by party {} is valid: Shares do not verify correctly", - self.accuser_id + accuser_id ); Ok(()) } @@ -65,26 +68,15 @@ impl Complaint { pub fn create( accuser_id: PartyId, - ciphertext: &ecies_v1::MultiRecipientEncryption, + ciphertext: &ecies_v1::SharedComponents, enc_sk: &ecies_v1::PrivateKey, random_oracle: &RandomOracle, rng: &mut impl AllowedRng, ) -> Self { - Self { - accuser_id, - proof: ciphertext.create_recovery_package( - enc_sk, - &random_oracle.extend(&Recovery(accuser_id).to_string()), - rng, - ), - } + Self(ciphertext.create_recovery_package( + enc_sk, + &random_oracle.extend(&Recovery(accuser_id).to_string()), + rng, + )) } } - -/// A response to a complaint, containing the responder's shares. Constructed only via -/// `Receiver::handle_complaint`, which gates on `Complaint::check`. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ComplaintResponse { - pub(crate) responder_id: PartyId, - pub(crate) shares: S, -} diff --git a/fastcrypto-tbls/src/threshold_schnorr/reed_solomon.rs b/fastcrypto-tbls/src/threshold_schnorr/reed_solomon.rs index 4af8ce4e21..12db6a215b 100644 --- a/fastcrypto-tbls/src/threshold_schnorr/reed_solomon.rs +++ b/fastcrypto-tbls/src/threshold_schnorr/reed_solomon.rs @@ -7,6 +7,8 @@ use crate::types::{to_scalar, ShareIndex}; use fastcrypto::error::FastCryptoError::{InputLengthWrong, InvalidInput, TooManyErrors}; use fastcrypto::error::FastCryptoResult; use itertools::Itertools; +use reed_solomon_erasure::galois_16::ReedSolomon; +use serde::{Deserialize, Serialize}; /// Decoder for Reed-Solomon codes. /// This can correct up to (d-1)/2 errors, where d is the distance of the code. @@ -123,6 +125,125 @@ impl RSDecoder { } } +/// A wrapper struct for the Reed-Solomon erasure coding library. +pub struct ErasureCoder(ReedSolomon); + +/// An element of `GF(2^16)` as represented by the underlying coder. +type Element = [u8; ELEMENT_SIZE_IN_BYTES]; + +/// Size in bytes of one `GF(2^16)` element. +const ELEMENT_SIZE_IN_BYTES: usize = 2; + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Shard(pub(crate) Vec); + +impl ErasureCoder { + /// Create a new erasure encoder/decoder. + /// + /// # Parameters + /// - `n`: Total number of shards. + /// - `k`: Number of data shards. + /// + /// # Errors + /// Returns [`FastCryptoError::InvalidInput`] if `k == 0`, `n <= k` or `n > 65536`. + pub fn new(n: usize, k: usize) -> FastCryptoResult { + // The code is defined over GF(2^16), which has 2^16 = 65536 elements; n cannot exceed + // that or the evaluation points would collide. + if k == 0 || n <= k || n > 65536 { + return Err(InvalidInput); + } + ReedSolomon::new(k, n - k) + .map_err(|_| InvalidInput) + .map(Self) + } + + pub fn check_parameters(n: usize, k: usize) -> FastCryptoResult<()> { + if k == 0 || n <= k || n > 65536 { + return Err(InvalidInput); + } + Ok(()) + } + + /// Encode `data` into `n` shards of equal size, the first `k` of which hold the (zero-padded) + /// data and the remaining `n - k` parity. Any `k` shards suffice to reconstruct the data. + pub fn encode(&self, data: &[u8]) -> FastCryptoResult> { + if data.is_empty() { + return Err(InvalidInput); + } + // Size each shard to a whole number of field elements. + let shard_size = data + .len() + .div_ceil(ELEMENT_SIZE_IN_BYTES * self.0.data_shard_count()); + let bytes_per_shard = ELEMENT_SIZE_IN_BYTES * shard_size; + let mut data = data.to_vec(); + data.resize(bytes_per_shard * self.0.total_shard_count(), 0); + let mut shards: Vec> = data + .chunks_exact(bytes_per_shard) + .map(bytes_to_elements) + .collect::>()?; + self.0.encode(&mut shards).map_err(|_| InvalidInput)?; + Ok(shards.into_iter().map(|s| Shard(s.concat())).collect_vec()) + } + + /// Reconstruct the original data from `n` (possibly missing) shards, returning the first + /// `expected_len` bytes. Fails if more than `n - k` shards are missing or if the present + /// shards are inconsistent with any single codeword. + pub fn decode( + &self, + shards: Vec>, + expected_len: usize, + ) -> FastCryptoResult> { + if shards.len() != self.0.total_shard_count() { + return Err(InputLengthWrong(self.0.total_shard_count())); + } + + let mut shards: Vec>> = shards + .into_iter() + .map(|opt| { + opt.map(|Shard(bytes)| bytes_to_elements(&bytes)) + .transpose() + }) + .collect::>()?; + self.0.reconstruct(&mut shards).map_err(|_| InvalidInput)?; + let shards = shards + .into_iter() + .map(|s| s.ok_or(InvalidInput)) + .collect::>>()?; + + // Ensure the reconstructed shards are consistent + if !self.0.verify(&shards).map_err(|_| InvalidInput)? { + return Err(TooManyErrors(0)); // This is just an erasure code, so we can't correct errors. + } + + let mut data: Vec = shards + .into_iter() + .take(self.0.data_shard_count()) + .flatten() + .flatten() + .collect(); + // The bytes past `expected_len` are zero-padding inserted by `encode`; reject anything + // that doesn't match. + if data.len() < expected_len || data[expected_len..].iter().any(|&b| b != 0) { + return Err(InvalidInput); + } + data.truncate(expected_len); + Ok(data) + } +} + +/// Reinterpret `bytes` as a sequence of [Element]s. Fails with [`InvalidInput`] if the input +/// length is not a multiple of [`ELEMENT_SIZE_IN_BYTES`]. +fn bytes_to_elements(bytes: &[u8]) -> FastCryptoResult> { + if !bytes.len().is_multiple_of(ELEMENT_SIZE_IN_BYTES) { + return Err(InvalidInput); + } + Ok(bytes + .chunks_exact(ELEMENT_SIZE_IN_BYTES) + .map(|p| p.try_into().expect("chunk has ELEMENT_SIZE_IN_BYTES bytes")) + .collect()) +} + #[cfg(test)] mod tests { use super::*; @@ -164,4 +285,99 @@ mod tests { .unwrap(); assert_eq!(decoded_message, message); } + + #[test] + fn test_erasure_coder_new_rejects_invalid_parameters() { + assert!(matches!(ErasureCoder::new(10, 0), Err(InvalidInput))); + assert!(matches!(ErasureCoder::new(10, 10), Err(InvalidInput))); + assert!(matches!(ErasureCoder::new(9, 10), Err(InvalidInput))); + assert!(matches!(ErasureCoder::new(65537, 1), Err(InvalidInput))); + } + + #[test] + fn test_erasure_coder_roundtrip() { + let n = 10; + let k = 6; + let coder = ErasureCoder::new(n, k).unwrap(); + + for len in [1usize, 2, 3, 7, 8, 31, 32, 33, 100, 255] { + let data: Vec = (0..len) + .map(|i| (i as u8).wrapping_mul(31).wrapping_add(7)) + .collect(); + let shards = coder.encode(&data).unwrap(); + assert_eq!(shards.len(), n); + + // Remove up to `parity` shards (erasures) and reconstruct. + let mut opt_shards: Vec> = shards.into_iter().map(Some).collect(); + for shard in opt_shards.iter_mut().take(n - k) { + *shard = None; + } + + let coder = ErasureCoder::new(n, k).unwrap(); + let recovered = coder.decode(opt_shards, len).unwrap(); + assert_eq!(recovered, data); + } + } + + #[test] + fn test_erasure_coder_decode_rejects_too_many_missing_shards() { + let n = 9; + let k = 5; + let coder = ErasureCoder::new(n, k).unwrap(); + let data: Vec = (0..123).map(|i| i as u8).collect(); + let shards = coder.encode(&data).unwrap(); + + // Parity is `n - k` -- remove more shards than that. + let mut opt_shards: Vec> = shards.into_iter().map(Some).collect(); + for shard in opt_shards.iter_mut().take(n - k + 1) { + *shard = None; + } + + assert!(matches!(coder.decode(opt_shards, 123), Err(InvalidInput))); + } + + #[test] + fn test_erasure_coder_detects_corrupted_shard() { + let n = 8; + let k = 5; + let coder = ErasureCoder::new(n, k).unwrap(); + let data: Vec = (0..200).map(|i| (i as u8) ^ 0xAA).collect(); + let mut shards = coder.encode(&data).unwrap(); + + // Corrupt one shard (without declaring it missing). Reconstruction will succeed, + // but verification should fail. + shards[0].0[0] ^= 1; + let opt_shards = shards.into_iter().map(Some).collect_vec(); + + assert!(matches!( + coder.decode(opt_shards, 200), + Err(TooManyErrors(_)) + )); + } + + #[test] + fn test_erasure_coder_encode_shard_lengths() { + // Each GF(2^16) element is 2 bytes; shards are sized to a whole number of pairs, with + // pair count ⌈data_len / (2 · k)⌉. + for &(n, k, data_len, expected_shard_bytes) in &[ + (10, 6, 1, 2), // ⌈ 1 / 12⌉ = 1 pair + (10, 6, 11, 2), // ⌈11 / 12⌉ = 1 pair + (10, 6, 12, 2), // ⌈12 / 12⌉ = 1 pair + (10, 6, 13, 4), // ⌈13 / 12⌉ = 2 pairs + (10, 6, 100, 18), // ⌈100 / 12⌉ = 9 pairs + (800, 268, 2028, 8), // ⌈2028 / 536⌉ = 4 pairs + ] { + let coder = ErasureCoder::new(n, k).unwrap(); + let data: Vec = (0..data_len).map(|i| i as u8).collect(); + let shards = coder.encode(&data).unwrap(); + assert_eq!(shards.len(), n, "shard count"); + for shard in &shards { + assert_eq!( + shard.0.len(), + expected_shard_bytes, + "shard byte length for n={n}, k={k}, data_len={data_len}" + ); + } + } + } }