diff --git a/rust/catalyst-contest/Cargo.toml b/rust/catalyst-contest/Cargo.toml index a6b75a75c4..ac453dffb5 100644 --- a/rust/catalyst-contest/Cargo.toml +++ b/rust/catalyst-contest/Cargo.toml @@ -15,7 +15,14 @@ workspace = true [dependencies] anyhow = "1.0.100" async-trait = "0.1.89" -catalyst-signed-doc = { version = "0.0.10", path = "../signed_doc" } futures = "0.3.31" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.145" +minicbor = { version = "0.25.1", features = ["alloc", "derive", "half"] } +proptest = { version = "1.6.0", features = ["attr-macro"] } +proptest-derive = "0.5.1" + +cbork-utils = { version = "0.0.2", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "cbork-utils-v0.0.2" } +# TODO: Use tag instead of path. +catalyst-signed-doc = { version = "0.0.10", path = "../signed_doc" } +catalyst-voting = { version = "0.0.2", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-voting/v0.0.2" } diff --git a/rust/catalyst-contest/src/contest_ballot/ballot.rs b/rust/catalyst-contest/src/contest_ballot/ballot.rs new file mode 100644 index 0000000000..af0422846c --- /dev/null +++ b/rust/catalyst-contest/src/contest_ballot/ballot.rs @@ -0,0 +1,159 @@ +//! An individual Ballot cast in a Contest by a registered user. + +use std::collections::BTreeMap; + +use cbork_utils::decode_helper::decode_map_len; +use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write}; + +use crate::{Choices, EncryptedChoices}; + +/// An individual Ballot cast in a Contest by a registered user. +/// +/// The CDDL schema: +/// ```cddl +/// contest-ballot-payload = { +/// + uint => choices +/// ? "column-proof" : column-proof +/// ? "matrix-proof" : matrix-proof +/// ? "voter-choice" : voter-choice +/// } +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ContentBallotPayload { + /// A map of voters choices. + pub choices: BTreeMap, + /// A universal encrypted column proof. + /// + /// This is a placeholder for now and should always be `None`. + pub column_proof: Option<()>, + /// A universal encrypted matrix proof. + /// + /// This is a placeholder for now and should always be `None`. + pub matrix_proof: Option<()>, + /// An encrypted voter choice payload. + pub voter_choices: Option, +} + +impl Decode<'_, ()> for ContentBallotPayload { + fn decode( + d: &mut Decoder<'_>, + ctx: &mut (), + ) -> Result { + use minicbor::data::Type; + + let len = decode_map_len(d, "content ballot payload")?; + + let mut choices = BTreeMap::new(); + let column_proof = None; + let matrix_proof = None; + let mut voter_choices = None; + for _ in 0..len { + match d.datatype()? { + Type::U8 | Type::U16 | Type::U32 | Type::U64 => { + let key = d.u64()?; + let val = Choices::decode(d, ctx)?; + choices.insert(key, val); + }, + Type::String => { + match d.str()? { + "column-proof" => { + return Err(minicbor::decode::Error::message( + "column-proof is a placeholder and shouldn't be used", + )); + }, + "matrix-proof" => { + return Err(minicbor::decode::Error::message( + "matrix-proof is a placeholder and shouldn't be used", + )); + }, + "voter-choices" => voter_choices = Some(EncryptedChoices::decode(d, ctx)?), + key => { + return Err(minicbor::decode::Error::message(format!( + "Unexpected content ballot payload key value: {key:?}" + ))); + }, + } + }, + t => { + return Err(minicbor::decode::Error::message(format!( + "Unexpected content ballot payload key type: {t:?}" + ))); + }, + } + } + + Ok(Self { + choices, + column_proof, + matrix_proof, + voter_choices, + }) + } +} + +impl Encode<()> for ContentBallotPayload { + fn encode( + &self, + e: &mut Encoder, + _ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + let len = u64::try_from(self.choices.len()) + .map_err(minicbor::encode::Error::message)? + .checked_add(u64::from(self.column_proof.is_some())) + .and_then(|v| v.checked_add(u64::from(self.matrix_proof.is_some()))) + .and_then(|v| v.checked_add(u64::from(self.voter_choices.is_some()))) + .ok_or_else(|| { + minicbor::encode::Error::message("contest ballot payload map length overflow") + })?; + e.map(len)?; + + for (&key, val) in &self.choices { + e.u64(key)?.encode(val)?; + } + if let Some(column_proof) = self.column_proof.as_ref() { + e.str("column-proof")?.encode(column_proof)?; + } + if let Some(matrix_proof) = self.matrix_proof.as_ref() { + e.str("matrix-proof")?.encode(matrix_proof)?; + } + if let Some(voter_choices) = self.voter_choices.as_ref() { + e.str("voter-choices")?.encode(voter_choices)?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use catalyst_voting::crypto::elgamal::Ciphertext; + + use super::*; + use crate::contest_ballot::encrypted_block::EncryptedBlock; + + #[test] + fn roundtrip() { + let original = ContentBallotPayload { + choices: [ + (1, Choices::Clear(vec![1, 2, 3, 4, 5])), + (2, Choices::Encrypted { + choices: vec![Ciphertext::zero()], + row_proof: None, + }), + ] + .into(), + column_proof: None, + matrix_proof: None, + voter_choices: Some(EncryptedChoices(vec![ + EncryptedBlock([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]), + EncryptedBlock([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]), + ])), + }; + let mut buffer = Vec::new(); + original + .encode(&mut Encoder::new(&mut buffer), &mut ()) + .unwrap(); + let decoded = ContentBallotPayload::decode(&mut Decoder::new(&buffer), &mut ()).unwrap(); + assert_eq!(original, decoded); + } +} diff --git a/rust/catalyst-contest/src/contest_ballot/choices.rs b/rust/catalyst-contest/src/contest_ballot/choices.rs new file mode 100644 index 0000000000..b530e58e16 --- /dev/null +++ b/rust/catalyst-contest/src/contest_ballot/choices.rs @@ -0,0 +1,157 @@ +//! Voters Choices. + +use catalyst_voting::crypto::{elgamal::Ciphertext, zk_unit_vector::UnitVectorProof}; +use cbork_utils::decode_helper::decode_array_len; +use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write}; + +/// A clear choice indicator. See the `Choices` CBOR schema for the details. +const CLEAR_CHOICE: u8 = 0; + +/// An encrypted choice indicator. See the `Choices` CBOR schema for the details. +const ENCRYPTED_CHOICE: u8 = 1; + +/// Voters Choices. +/// +/// The CDDL schema: +/// ```cddl +/// choices = [ 0, clear-choices ] / +/// [ 1, elgamal-ristretto255-encrypted-choices ] +/// +/// clear-choices = ( +clear-choice ) +/// +/// clear-choice = uint +/// +/// elgamal-ristretto255-encrypted-choices = [ +/// [+ elgamal-ristretto255-encrypted-choice] +/// ? row-proof +/// ] +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum Choices { + /// A universal unencrypted set of choices. + Clear(Vec), + /// ElGamal/Ristretto255 encrypted choices. + Encrypted { + /// A list of ElGamal/Ristretto255 encrypted choices. + choices: Vec, + /// A universal encrypted row proof. + row_proof: Option, + }, +} + +impl Decode<'_, ()> for Choices { + fn decode( + d: &mut Decoder<'_>, + ctx: &mut (), + ) -> Result { + let len = decode_array_len(d, "choices")?; + if len < 2 { + return Err(minicbor::decode::Error::message(format!( + "Unexpected choices array length {len}, expected at least 2" + ))); + } + match u8::decode(d, ctx)? { + CLEAR_CHOICE => { + let mut values = Vec::with_capacity( + len.checked_sub(1) + .ok_or_else(|| { + minicbor::decode::Error::message("Choices array length underflow") + })? + .try_into() + .map_err(minicbor::decode::Error::message)?, + ); + for _ in 1..len { + values.push(u64::decode(d, ctx)?); + } + Ok(Self::Clear(values)) + }, + ENCRYPTED_CHOICE => { + if len > 2 { + return Err(minicbor::decode::Error::message(format!( + "Unexpected encrypted choices array length {len}, expected 2" + ))); + } + + let len = decode_array_len(d, "elgamal-ristretto255-encrypted-choices")?; + if !(1..=2).contains(&len) { + return Err(minicbor::decode::Error::message(format!( + "Unexpected elgamal-ristretto255-encrypted-choices array length {len}, expected 1 or 2" + ))); + } + let choices = >::decode(d, ctx)?; + let mut row_proof = None; + if len == 2 { + row_proof = Some(UnitVectorProof::decode(d, ctx)?); + } + Ok(Self::Encrypted { choices, row_proof }) + }, + val => { + Err(minicbor::decode::Error::message(format!( + "Unexpected choices value: {val}" + ))) + }, + } + } +} + +impl Encode<()> for Choices { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + match self { + Choices::Clear(choices) => { + e.array((choices.len() as u64).checked_add(1).ok_or_else(|| { + minicbor::encode::Error::message("Clear choices array length overflow") + })?)?; + 0.encode(e, ctx)?; + for choice in choices { + choice.encode(e, ctx)?; + } + }, + Choices::Encrypted { choices, row_proof } => { + e.array(2)?; + 1.encode(e, ctx)?; + // Allowed because 1 + 1 will never result in overflow. + #[allow(clippy::arithmetic_side_effects)] + e.array(1 + u64::from(row_proof.is_some()))?; + choices.encode(e, ctx)?; + if let Some(row_proof) = row_proof { + row_proof.encode(e, ctx)?; + } + }, + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn clear_roundtrip() { + let original = Choices::Clear(vec![1, 2, 3]); + let mut buffer = Vec::new(); + original + .encode(&mut Encoder::new(&mut buffer), &mut ()) + .unwrap(); + let decoded = Choices::decode(&mut Decoder::new(&buffer), &mut ()).unwrap(); + assert_eq!(original, decoded); + } + + #[test] + fn encrypted_roundtrip() { + let original = Choices::Encrypted { + choices: vec![Ciphertext::zero(), Ciphertext::zero()], + row_proof: None, + }; + let mut buffer = Vec::new(); + original + .encode(&mut Encoder::new(&mut buffer), &mut ()) + .unwrap(); + let decoded = Choices::decode(&mut Decoder::new(&buffer), &mut ()).unwrap(); + assert_eq!(original, decoded); + } +} diff --git a/rust/catalyst-contest/src/contest_ballot/encrypted_block.rs b/rust/catalyst-contest/src/contest_ballot/encrypted_block.rs new file mode 100644 index 0000000000..876d349bd1 --- /dev/null +++ b/rust/catalyst-contest/src/contest_ballot/encrypted_block.rs @@ -0,0 +1,52 @@ +//! An AES-CTR encrypted data block. + +use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write}; + +/// A length of the encrypted block byte array. +const ENCRYPTED_BLOCK_LEN: usize = 16; + +/// An AES-CTR encrypted data block. +/// +/// The CDDL schema: +/// ```cddl +/// aes-ctr-encrypted-block = bytes .size 16 +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(test, derive(proptest_derive::Arbitrary))] +pub struct EncryptedBlock(pub [u8; ENCRYPTED_BLOCK_LEN]); + +impl Decode<'_, ()> for EncryptedBlock { + fn decode( + d: &mut Decoder<'_>, + ctx: &mut (), + ) -> Result { + <[u8; ENCRYPTED_BLOCK_LEN]>::decode(d, ctx).map(Self) + } +} + +impl Encode<()> for EncryptedBlock { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + self.0.encode(e, ctx) + } +} + +#[cfg(test)] +mod tests { + use proptest::property_test; + + use super::*; + + #[property_test] + fn roundtrip(original: EncryptedBlock) { + let mut buffer = Vec::new(); + original + .encode(&mut Encoder::new(&mut buffer), &mut ()) + .unwrap(); + let decoded = EncryptedBlock::decode(&mut Decoder::new(&buffer), &mut ()).unwrap(); + assert_eq!(original, decoded); + } +} diff --git a/rust/catalyst-contest/src/contest_ballot/encrypted_choices.rs b/rust/catalyst-contest/src/contest_ballot/encrypted_choices.rs new file mode 100644 index 0000000000..e33a1a6f19 --- /dev/null +++ b/rust/catalyst-contest/src/contest_ballot/encrypted_choices.rs @@ -0,0 +1,86 @@ +//! Encrypted voter choices. + +use cbork_utils::decode_helper::decode_array_len; +use minicbor::{Decode, Decoder, Encode, Encoder, encode::Write}; + +use crate::contest_ballot::encrypted_block::EncryptedBlock; + +/// A CBOR version of the `EncryptedChoices`. +const VERSION: u64 = 0; + +/// Encrypted voter choices. +/// +/// The CDDL schema: +/// ```cddl +/// voter-choice = [ 0, aes-ctr-encrypted-choices ] +/// +/// aes-ctr-encrypted-choices = +aes-ctr-encrypted-block +/// ``` +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct EncryptedChoices(pub Vec); + +impl Decode<'_, ()> for EncryptedChoices { + fn decode( + d: &mut Decoder<'_>, + ctx: &mut (), + ) -> Result { + let len: usize = decode_array_len(d, "EncryptedChoices")? + .try_into() + .map_err(minicbor::decode::Error::message)?; + if len < 2 { + return Err(minicbor::decode::Error::message(format!( + "Unexpected EncryptedChoices array length: {len}, expected at least 2" + ))); + } + let version = u64::decode(d, ctx)?; + if version != VERSION { + return Err(minicbor::decode::Error::message(format!( + "Unexpected EncryptedChoices version value: {version}, expected {VERSION}" + ))); + } + + // This is allowed because of the `len < 2` check above. + #[allow(clippy::arithmetic_side_effects)] + let mut blocks = Vec::with_capacity(len - 1); + for _ in 1..len { + blocks.push(EncryptedBlock::decode(d, ctx)?); + } + + Ok(Self(blocks)) + } +} + +impl Encode<()> for EncryptedChoices { + fn encode( + &self, + e: &mut Encoder, + ctx: &mut (), + ) -> Result<(), minicbor::encode::Error> { + e.array((self.0.len() as u64).checked_add(1).ok_or_else(|| { + minicbor::encode::Error::message("EncryptedChoices length overflow") + })?)?; + VERSION.encode(e, ctx)?; + for block in &self.0 { + block.encode(e, ctx)?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use proptest::property_test; + + use super::*; + + #[property_test] + fn roundtrip(block: EncryptedBlock) { + let original = EncryptedChoices(vec![block]); + let mut buffer = Vec::new(); + original + .encode(&mut Encoder::new(&mut buffer), &mut ()) + .unwrap(); + let decoded = EncryptedChoices::decode(&mut Decoder::new(&buffer), &mut ()).unwrap(); + assert_eq!(original, decoded); + } +} diff --git a/rust/catalyst-contest/src/contest_ballot/mod.rs b/rust/catalyst-contest/src/contest_ballot/mod.rs new file mode 100644 index 0000000000..cbb08f50a2 --- /dev/null +++ b/rust/catalyst-contest/src/contest_ballot/mod.rs @@ -0,0 +1,11 @@ +//! An individual Ballot cast in a Contest by a registered user. + +mod ballot; +mod choices; +mod encrypted_block; +mod encrypted_choices; + +pub use self::{ + ballot::ContentBallotPayload, choices::Choices, encrypted_block::EncryptedBlock, + encrypted_choices::EncryptedChoices, +}; diff --git a/rust/catalyst-contest/src/lib.rs b/rust/catalyst-contest/src/lib.rs index a12e1ebfa9..6e3ff4e38f 100644 --- a/rust/catalyst-contest/src/lib.rs +++ b/rust/catalyst-contest/src/lib.rs @@ -5,3 +5,7 @@ //! [documentation]: https://docs.dev.projectcatalyst.io/libs/main/architecture/08_concepts/signed_doc/docs/contest_ballot/ pub mod contest_delegation; + +mod contest_ballot; + +pub use crate::contest_ballot::{Choices, ContentBallotPayload, EncryptedBlock, EncryptedChoices};