diff --git a/zk-token-sdk/src/encryption/grouped_elgamal.rs b/zk-token-sdk/src/encryption/grouped_elgamal.rs new file mode 100644 index 00000000000000..2153dd8c20678e --- /dev/null +++ b/zk-token-sdk/src/encryption/grouped_elgamal.rs @@ -0,0 +1,290 @@ +//! The twisted ElGamal group encryption implementation. +//! +//! The message space consists of any number that is representable as a scalar (a.k.a. "exponent") +//! for Curve25519. +//! +//! A regular twisted ElGamal ciphertext consists of two components: +//! - A Pedersen commitment that encodes a message to be encrypted +//! - A "decryption handle" that binds the Pedersen opening to a specific public key +//! The ciphertext can be generalized to hold not a single decryption handle, but multiple handles +//! pertaining to multiple ElGamal public keys. These ciphertexts are referred to as a "grouped" +//! ElGamal ciphertext. +//! + +use { + crate::encryption::{ + discrete_log::DiscreteLog, + elgamal::{DecryptHandle, ElGamalCiphertext, ElGamalPubkey, ElGamalSecretKey}, + pedersen::{Pedersen, PedersenCommitment, PedersenOpening}, + }, + curve25519_dalek::scalar::Scalar, + thiserror::Error, +}; + +#[derive(Error, Clone, Debug, Eq, PartialEq)] +pub enum GroupedElGamalError { + #[error("index out of bounds")] + IndexOutOfBounds, +} + +/// Algorithm handle for the grouped ElGamal encryption +pub struct GroupedElGamal; +impl GroupedElGamal { + /// Encrypts an amount under an array of ElGamal public keys. + /// + /// This function is randomized. It internally samples a scalar element using `OsRng`. + pub fn encrypt>( + pubkeys: [&ElGamalPubkey; N], + amount: T, + ) -> GroupedElGamalCiphertext { + let (commitment, opening) = Pedersen::new(amount); + let handles: [DecryptHandle; N] = pubkeys + .iter() + .map(|handle| handle.decrypt_handle(&opening)) + .collect::>() + .try_into() + .unwrap(); + + GroupedElGamalCiphertext { + commitment, + handles, + } + } + + /// Encrypts an amount under an array of ElGamal public keys using a specified Pedersen + /// opening. + pub fn encrypt_with>( + pubkeys: [&ElGamalPubkey; N], + amount: T, + opening: &PedersenOpening, + ) -> GroupedElGamalCiphertext { + let commitment = Pedersen::with(amount, opening); + let handles: [DecryptHandle; N] = pubkeys + .iter() + .map(|handle| handle.decrypt_handle(opening)) + .collect::>() + .try_into() + .unwrap(); + + GroupedElGamalCiphertext { + commitment, + handles, + } + } + + /// Converts a grouped ElGamal ciphertext into a regular ElGamal ciphertext using the decrypt + /// handle at a specified index. + fn to_elgamal_ciphertext( + grouped_ciphertext: &GroupedElGamalCiphertext, + index: usize, + ) -> Result { + let handle = grouped_ciphertext + .handles + .get(index) + .ok_or(GroupedElGamalError::IndexOutOfBounds)?; + + Ok(ElGamalCiphertext { + commitment: grouped_ciphertext.commitment, + handle: *handle, + }) + } + + /// Decrypts a grouped ElGamal ciphertext using an ElGamal secret key pertaining to a + /// decryption handle at a specified index. + /// + /// The output of this function is of type `DiscreteLog`. To recover the originally encrypted + /// amount, use `DiscreteLog::decode`. + fn decrypt( + grouped_ciphertext: &GroupedElGamalCiphertext, + secret: &ElGamalSecretKey, + index: usize, + ) -> Result { + Self::to_elgamal_ciphertext(grouped_ciphertext, index) + .map(|ciphertext| ciphertext.decrypt(secret)) + } + + /// Decrypts a grouped ElGamal ciphertext to a number that is interpreted as a positive 32-bit + /// number (but still of type `u64`). + /// + /// If the originally encrypted amount is not a positive 32-bit number, then the function + /// Result contains `None`. + fn decrypt_u32( + grouped_ciphertext: &GroupedElGamalCiphertext, + secret: &ElGamalSecretKey, + index: usize, + ) -> Result, GroupedElGamalError> { + Self::to_elgamal_ciphertext(grouped_ciphertext, index) + .map(|ciphertext| ciphertext.decrypt_u32(secret)) + } +} + +/// A grouped ElGamal ciphertext. +/// +/// The type is defined with a generic constant parameter that specifies the number of +/// decryption handles that the ciphertext holds. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct GroupedElGamalCiphertext { + pub commitment: PedersenCommitment, + pub handles: [DecryptHandle; N], +} + +impl GroupedElGamalCiphertext { + /// Decrypts the grouped ElGamal ciphertext using an ElGamal secret key pertaining to a + /// specified index. + /// + /// The output of this function is of type `DiscreteLog`. To recover the originally encrypted + /// amount, use `DiscreteLog::decode`. + pub fn decrypt( + &self, + secret: &ElGamalSecretKey, + index: usize, + ) -> Result { + GroupedElGamal::decrypt(self, secret, index) + } + + /// Decrypts the grouped ElGamal ciphertext to a number that is interpreted as a positive 32-bit + /// number (but still of type `u64`). + /// + /// If the originally encrypted amount is not a positive 32-bit number, then the function + /// returns `None`. + pub fn decrypt_u32( + &self, + secret: &ElGamalSecretKey, + index: usize, + ) -> Result, GroupedElGamalError> { + GroupedElGamal::decrypt_u32(self, secret, index) + } + + /// The expected length of a serialized grouped ElGamal ciphertext. + /// + /// A grouped ElGamal ciphertext consists of a Pedersen commitment and an array of decryption + /// handles. The commitment and decryption handles are each a single Curve25519 group element + /// that is serialized as 32 bytes. Therefore, the total byte length of a grouped ciphertext is + /// `(N+1) * 32`. + fn expected_byte_length() -> usize { + N.checked_add(1) + .and_then(|length| length.checked_mul(32)) + .unwrap() + } + + pub fn to_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(Self::expected_byte_length()); + buf.extend_from_slice(&self.commitment.to_bytes()); + self.handles + .iter() + .for_each(|handle| buf.extend_from_slice(&handle.to_bytes())); + buf + } + + pub fn from_bytes(bytes: &[u8]) -> Option { + if bytes.len() != Self::expected_byte_length() { + return None; + } + + let mut iter = bytes.chunks(32); + let commitment = PedersenCommitment::from_bytes(iter.next()?)?; + + let mut handles = Vec::with_capacity(N); + for handle_bytes in iter { + handles.push(DecryptHandle::from_bytes(handle_bytes)?); + } + + Some(Self { + commitment, + handles: handles.try_into().unwrap(), + }) + } +} + +#[cfg(test)] +mod tests { + use {super::*, crate::encryption::elgamal::ElGamalKeypair}; + + #[test] + fn test_grouped_elgamal_encrypt_decrypt_correctness() { + let elgamal_keypair_0 = ElGamalKeypair::new_rand(); + let elgamal_keypair_1 = ElGamalKeypair::new_rand(); + let elgamal_keypair_2 = ElGamalKeypair::new_rand(); + + let amount: u64 = 10; + let grouped_ciphertext = GroupedElGamal::encrypt( + [ + &elgamal_keypair_0.public, + &elgamal_keypair_1.public, + &elgamal_keypair_2.public, + ], + amount, + ); + + assert_eq!( + Some(amount), + grouped_ciphertext + .decrypt_u32(&elgamal_keypair_0.secret, 0) + .unwrap() + ); + + assert_eq!( + Some(amount), + grouped_ciphertext + .decrypt_u32(&elgamal_keypair_1.secret, 1) + .unwrap() + ); + + assert_eq!( + Some(amount), + grouped_ciphertext + .decrypt_u32(&elgamal_keypair_2.secret, 2) + .unwrap() + ); + + assert_eq!( + GroupedElGamalError::IndexOutOfBounds, + grouped_ciphertext + .decrypt_u32(&elgamal_keypair_0.secret, 3) + .unwrap_err() + ); + } + + #[test] + fn test_grouped_ciphertext_bytes() { + let elgamal_keypair_0 = ElGamalKeypair::new_rand(); + let elgamal_keypair_1 = ElGamalKeypair::new_rand(); + let elgamal_keypair_2 = ElGamalKeypair::new_rand(); + + let amount: u64 = 10; + let grouped_ciphertext = GroupedElGamal::encrypt( + [ + &elgamal_keypair_0.public, + &elgamal_keypair_1.public, + &elgamal_keypair_2.public, + ], + amount, + ); + + let produced_bytes = grouped_ciphertext.to_bytes(); + assert_eq!(produced_bytes.len(), 128); + + let decoded_grouped_ciphertext = + GroupedElGamalCiphertext::<3>::from_bytes(&produced_bytes).unwrap(); + assert_eq!( + Some(amount), + decoded_grouped_ciphertext + .decrypt_u32(&elgamal_keypair_0.secret, 0) + .unwrap() + ); + + assert_eq!( + Some(amount), + decoded_grouped_ciphertext + .decrypt_u32(&elgamal_keypair_1.secret, 1) + .unwrap() + ); + + assert_eq!( + Some(amount), + decoded_grouped_ciphertext + .decrypt_u32(&elgamal_keypair_2.secret, 2) + .unwrap() + ); + } +} diff --git a/zk-token-sdk/src/encryption/mod.rs b/zk-token-sdk/src/encryption/mod.rs index a90b88a5faaabf..7cf53dd0f06167 100644 --- a/zk-token-sdk/src/encryption/mod.rs +++ b/zk-token-sdk/src/encryption/mod.rs @@ -13,4 +13,5 @@ pub mod auth_encryption; pub mod discrete_log; pub mod elgamal; +pub mod grouped_elgamal; pub mod pedersen; diff --git a/zk-token-sdk/src/zk_token_elgamal/pod/grouped_elgamal.rs b/zk-token-sdk/src/zk_token_elgamal/pod/grouped_elgamal.rs new file mode 100644 index 00000000000000..04477ddc92f587 --- /dev/null +++ b/zk-token-sdk/src/zk_token_elgamal/pod/grouped_elgamal.rs @@ -0,0 +1,73 @@ +//! Plain Old Data types for the Grouped ElGamal encryption scheme. + +#[cfg(not(target_os = "solana"))] +use crate::{encryption::grouped_elgamal::GroupedElGamalCiphertext, errors::ProofError}; +use { + crate::zk_token_elgamal::pod::{Pod, Zeroable}, + std::fmt, +}; + +/// The `GroupedElGamalCiphertext` type with two decryption handles as a `Pod` +#[derive(Clone, Copy, Pod, Zeroable, PartialEq, Eq)] +#[repr(transparent)] +pub struct GroupedElGamalCiphertext2Handles(pub [u8; 96]); + +impl fmt::Debug for GroupedElGamalCiphertext2Handles { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl Default for GroupedElGamalCiphertext2Handles { + fn default() -> Self { + Self::zeroed() + } +} +#[cfg(not(target_os = "solana"))] +impl From> for GroupedElGamalCiphertext2Handles { + fn from(decoded_ciphertext: GroupedElGamalCiphertext<2>) -> Self { + Self(decoded_ciphertext.to_bytes().try_into().unwrap()) + } +} + +#[cfg(not(target_os = "solana"))] +impl TryFrom for GroupedElGamalCiphertext<2> { + type Error = ProofError; + + fn try_from(pod_ciphertext: GroupedElGamalCiphertext2Handles) -> Result { + Self::from_bytes(&pod_ciphertext.0).ok_or(ProofError::CiphertextDeserialization) + } +} + +/// The `GroupedElGamalCiphertext` type with three decryption handles as a `Pod` +#[derive(Clone, Copy, Pod, Zeroable, PartialEq, Eq)] +#[repr(transparent)] +pub struct GroupedElGamalCiphertext3Handles(pub [u8; 128]); + +impl fmt::Debug for GroupedElGamalCiphertext3Handles { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{:?}", self.0) + } +} + +impl Default for GroupedElGamalCiphertext3Handles { + fn default() -> Self { + Self::zeroed() + } +} + +#[cfg(not(target_os = "solana"))] +impl From> for GroupedElGamalCiphertext2Handles { + fn from(decoded_ciphertext: GroupedElGamalCiphertext<3>) -> Self { + Self(decoded_ciphertext.to_bytes().try_into().unwrap()) + } +} + +#[cfg(not(target_os = "solana"))] +impl TryFrom for GroupedElGamalCiphertext<3> { + type Error = ProofError; + + fn try_from(pod_ciphertext: GroupedElGamalCiphertext3Handles) -> Result { + Self::from_bytes(&pod_ciphertext.0).ok_or(ProofError::CiphertextDeserialization) + } +} diff --git a/zk-token-sdk/src/zk_token_elgamal/pod/mod.rs b/zk-token-sdk/src/zk_token_elgamal/pod/mod.rs index eb60db315ecd54..eb719e4c3b2f06 100644 --- a/zk-token-sdk/src/zk_token_elgamal/pod/mod.rs +++ b/zk-token-sdk/src/zk_token_elgamal/pod/mod.rs @@ -1,5 +1,6 @@ mod auth_encryption; mod elgamal; +mod grouped_elgamal; mod instruction; mod pedersen; mod range_proof; @@ -14,6 +15,7 @@ pub use { auth_encryption::AeCiphertext, bytemuck::{Pod, Zeroable}, elgamal::{DecryptHandle, ElGamalCiphertext, ElGamalPubkey}, + grouped_elgamal::{GroupedElGamalCiphertext2Handles, GroupedElGamalCiphertext3Handles}, instruction::{ FeeEncryption, FeeParameters, TransferAmountEncryption, TransferPubkeys, TransferWithFeePubkeys,