solana_zk_sdk/encryption/
grouped_elgamal.rs

1//! The twisted ElGamal group encryption implementation.
2//!
3//! The message space consists of any number that is representable as a scalar (a.k.a. "exponent")
4//! for Curve25519.
5//!
6//! A regular twisted ElGamal ciphertext consists of two components:
7//! - A Pedersen commitment that encodes a message to be encrypted
8//! - A "decryption handle" that binds the Pedersen opening to a specific public key
9//!
10//! The ciphertext can be generalized to hold not a single decryption handle, but multiple handles
11//! pertaining to multiple ElGamal public keys. These ciphertexts are referred to as a "grouped"
12//! ElGamal ciphertext.
13//!
14
15#[cfg(not(target_arch = "wasm32"))]
16use crate::encryption::{discrete_log::DiscreteLog, elgamal::ElGamalSecretKey};
17#[cfg(target_arch = "wasm32")]
18pub use grouped_elgamal_wasm::*;
19#[cfg(target_arch = "wasm32")]
20use wasm_bindgen::prelude::*;
21use {
22    crate::{
23        encryption::{
24            elgamal::{DecryptHandle, ElGamalCiphertext, ElGamalPubkey},
25            pedersen::{Pedersen, PedersenCommitment, PedersenOpening},
26        },
27        RISTRETTO_POINT_LEN,
28    },
29    curve25519_dalek::scalar::Scalar,
30    thiserror::Error,
31};
32
33#[derive(Error, Clone, Debug, Eq, PartialEq)]
34pub enum GroupedElGamalError {
35    #[error("index out of bounds")]
36    IndexOutOfBounds,
37}
38
39/// Algorithm handle for the grouped ElGamal encryption
40pub struct GroupedElGamal<const N: usize>;
41impl<const N: usize> GroupedElGamal<N> {
42    /// Encrypts an amount under an array of ElGamal public keys.
43    ///
44    /// This function is randomized. It internally samples a scalar element using `OsRng`.
45    pub fn encrypt<T: Into<Scalar>>(
46        pubkeys: [&ElGamalPubkey; N],
47        amount: T,
48    ) -> GroupedElGamalCiphertext<N> {
49        let (commitment, opening) = Pedersen::new(amount);
50        let handles: [DecryptHandle; N] = pubkeys
51            .iter()
52            .map(|handle| handle.decrypt_handle(&opening))
53            .collect::<Vec<DecryptHandle>>()
54            .try_into()
55            .unwrap();
56
57        GroupedElGamalCiphertext {
58            commitment,
59            handles,
60        }
61    }
62
63    /// Encrypts an amount under an array of ElGamal public keys using a specified Pedersen
64    /// opening.
65    pub fn encrypt_with<T: Into<Scalar>>(
66        pubkeys: [&ElGamalPubkey; N],
67        amount: T,
68        opening: &PedersenOpening,
69    ) -> GroupedElGamalCiphertext<N> {
70        let commitment = Pedersen::with(amount, opening);
71        let handles: [DecryptHandle; N] = pubkeys
72            .iter()
73            .map(|handle| handle.decrypt_handle(opening))
74            .collect::<Vec<DecryptHandle>>()
75            .try_into()
76            .unwrap();
77
78        GroupedElGamalCiphertext {
79            commitment,
80            handles,
81        }
82    }
83
84    /// Converts a grouped ElGamal ciphertext into a regular ElGamal ciphertext using the decrypt
85    /// handle at a specified index.
86    fn to_elgamal_ciphertext(
87        grouped_ciphertext: &GroupedElGamalCiphertext<N>,
88        index: usize,
89    ) -> Result<ElGamalCiphertext, GroupedElGamalError> {
90        let handle = grouped_ciphertext
91            .handles
92            .get(index)
93            .ok_or(GroupedElGamalError::IndexOutOfBounds)?;
94
95        Ok(ElGamalCiphertext {
96            commitment: grouped_ciphertext.commitment,
97            handle: *handle,
98        })
99    }
100}
101
102#[cfg(not(target_arch = "wasm32"))]
103impl<const N: usize> GroupedElGamal<N> {
104    /// Decrypts a grouped ElGamal ciphertext using an ElGamal secret key pertaining to a
105    /// decryption handle at a specified index.
106    ///
107    /// The output of this function is of type `DiscreteLog`. To recover the originally encrypted
108    /// amount, use `DiscreteLog::decode`.
109    fn decrypt(
110        grouped_ciphertext: &GroupedElGamalCiphertext<N>,
111        secret: &ElGamalSecretKey,
112        index: usize,
113    ) -> Result<DiscreteLog, GroupedElGamalError> {
114        Self::to_elgamal_ciphertext(grouped_ciphertext, index)
115            .map(|ciphertext| ciphertext.decrypt(secret))
116    }
117
118    /// Decrypts a grouped ElGamal ciphertext to a number that is interpreted as a positive 32-bit
119    /// number (but still of type `u64`).
120    ///
121    /// If the originally encrypted amount is not a positive 32-bit number, then the function
122    /// Result contains `None`.
123    ///
124    /// NOTE: This function is not constant time.
125    fn decrypt_u32(
126        grouped_ciphertext: &GroupedElGamalCiphertext<N>,
127        secret: &ElGamalSecretKey,
128        index: usize,
129    ) -> Result<Option<u64>, GroupedElGamalError> {
130        Self::to_elgamal_ciphertext(grouped_ciphertext, index)
131            .map(|ciphertext| ciphertext.decrypt_u32(secret))
132    }
133}
134
135/// A grouped ElGamal ciphertext.
136///
137/// The type is defined with a generic constant parameter that specifies the number of
138/// decryption handles that the ciphertext holds.
139#[derive(Clone, Copy, Debug, Eq, PartialEq)]
140pub struct GroupedElGamalCiphertext<const N: usize> {
141    pub commitment: PedersenCommitment,
142    pub handles: [DecryptHandle; N],
143}
144
145impl<const N: usize> GroupedElGamalCiphertext<N> {
146    /// Converts a grouped ElGamal ciphertext into a regular ElGamal ciphertext using the decrypt
147    /// handle at a specified index.
148    pub fn to_elgamal_ciphertext(
149        &self,
150        index: usize,
151    ) -> Result<ElGamalCiphertext, GroupedElGamalError> {
152        GroupedElGamal::to_elgamal_ciphertext(self, index)
153    }
154
155    /// The expected length of a serialized grouped ElGamal ciphertext.
156    ///
157    /// A grouped ElGamal ciphertext consists of a Pedersen commitment and an array of decryption
158    /// handles. The commitment and decryption handles are each a single Curve25519 group element
159    /// that is serialized as 32 bytes. Therefore, the total byte length of a grouped ciphertext is
160    /// `(N+1) * 32`.
161    fn expected_byte_length() -> usize {
162        N.checked_add(1)
163            .and_then(|length| length.checked_mul(RISTRETTO_POINT_LEN))
164            .unwrap()
165    }
166
167    pub fn to_bytes(&self) -> Vec<u8> {
168        let mut buf = Vec::with_capacity(Self::expected_byte_length());
169        buf.extend_from_slice(&self.commitment.to_bytes());
170        self.handles
171            .iter()
172            .for_each(|handle| buf.extend_from_slice(&handle.to_bytes()));
173        buf
174    }
175
176    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
177        if bytes.len() != Self::expected_byte_length() {
178            return None;
179        }
180
181        let mut iter = bytes.chunks(RISTRETTO_POINT_LEN);
182        let commitment = PedersenCommitment::from_bytes(iter.next()?)?;
183
184        let mut handles = Vec::with_capacity(N);
185        for handle_bytes in iter {
186            handles.push(DecryptHandle::from_bytes(handle_bytes)?);
187        }
188
189        Some(Self {
190            commitment,
191            handles: handles.try_into().unwrap(),
192        })
193    }
194}
195
196#[cfg(not(target_arch = "wasm32"))]
197impl<const N: usize> GroupedElGamalCiphertext<N> {
198    /// Decrypts the grouped ElGamal ciphertext using an ElGamal secret key pertaining to a
199    /// specified index.
200    ///
201    /// The output of this function is of type `DiscreteLog`. To recover the originally encrypted
202    /// amount, use `DiscreteLog::decode`.
203    pub fn decrypt(
204        &self,
205        secret: &ElGamalSecretKey,
206        index: usize,
207    ) -> Result<DiscreteLog, GroupedElGamalError> {
208        GroupedElGamal::decrypt(self, secret, index)
209    }
210
211    /// Decrypts the grouped ElGamal ciphertext to a number that is interpreted as a positive 32-bit
212    /// number (but still of type `u64`).
213    ///
214    /// If the originally encrypted amount is not a positive 32-bit number, then the function
215    /// returns `None`.
216    ///
217    /// NOTE: This function is not constant time.
218    pub fn decrypt_u32(
219        &self,
220        secret: &ElGamalSecretKey,
221        index: usize,
222    ) -> Result<Option<u64>, GroupedElGamalError> {
223        GroupedElGamal::decrypt_u32(self, secret, index)
224    }
225}
226
227// Define specific grouped ElGamal ciphertext types for 2 and 3 handles since
228// `wasm_bindgen` do not yet support the export of types that take on generic
229// type parameters.
230#[cfg(target_arch = "wasm32")]
231mod grouped_elgamal_wasm {
232    use super::*;
233
234    #[wasm_bindgen]
235    pub struct GroupedElGamalCiphertext2Handles(pub(crate) GroupedElGamalCiphertext<2>);
236
237    #[wasm_bindgen]
238    impl GroupedElGamalCiphertext2Handles {
239        #[wasm_bindgen(js_name = encryptU64)]
240        pub fn encrypt_u64(
241            first_pubkey: &ElGamalPubkey,
242            second_pubkey: &ElGamalPubkey,
243            amount: u64,
244        ) -> Self {
245            Self(GroupedElGamal::<2>::encrypt(
246                [first_pubkey, second_pubkey],
247                amount,
248            ))
249        }
250
251        #[wasm_bindgen(js_name = encryptWithU64)]
252        pub fn encrypt_with_u64(
253            first_pubkey: &ElGamalPubkey,
254            second_pubkey: &ElGamalPubkey,
255            amount: u64,
256            opening: &PedersenOpening,
257        ) -> Self {
258            Self(GroupedElGamal::<2>::encrypt_with(
259                [first_pubkey, second_pubkey],
260                amount,
261                opening,
262            ))
263        }
264    }
265
266    #[wasm_bindgen]
267    pub struct GroupedElGamalCiphertext3Handles(pub(crate) GroupedElGamalCiphertext<3>);
268
269    #[wasm_bindgen]
270    impl GroupedElGamalCiphertext3Handles {
271        #[wasm_bindgen(js_name = encryptU64)]
272        pub fn encrypt_u64(
273            first_pubkey: &ElGamalPubkey,
274            second_pubkey: &ElGamalPubkey,
275            third_pubkey: &ElGamalPubkey,
276            amount: u64,
277        ) -> Self {
278            Self(GroupedElGamal::<3>::encrypt(
279                [first_pubkey, second_pubkey, third_pubkey],
280                amount,
281            ))
282        }
283
284        #[wasm_bindgen(js_name = encryptWithU64)]
285        pub fn encrypt_with_u64(
286            first_pubkey: &ElGamalPubkey,
287            second_pubkey: &ElGamalPubkey,
288            third_pubkey: &ElGamalPubkey,
289            amount: u64,
290            opening: &PedersenOpening,
291        ) -> Self {
292            Self(GroupedElGamal::<3>::encrypt_with(
293                [first_pubkey, second_pubkey, third_pubkey],
294                amount,
295                opening,
296            ))
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use {super::*, crate::encryption::elgamal::ElGamalKeypair};
304
305    #[test]
306    fn test_grouped_elgamal_encrypt_decrypt_correctness() {
307        let elgamal_keypair_0 = ElGamalKeypair::new_rand();
308        let elgamal_keypair_1 = ElGamalKeypair::new_rand();
309        let elgamal_keypair_2 = ElGamalKeypair::new_rand();
310
311        let amount: u64 = 10;
312        let grouped_ciphertext = GroupedElGamal::encrypt(
313            [
314                elgamal_keypair_0.pubkey(),
315                elgamal_keypair_1.pubkey(),
316                elgamal_keypair_2.pubkey(),
317            ],
318            amount,
319        );
320
321        assert_eq!(
322            Some(amount),
323            grouped_ciphertext
324                .decrypt_u32(elgamal_keypair_0.secret(), 0)
325                .unwrap()
326        );
327
328        assert_eq!(
329            Some(amount),
330            grouped_ciphertext
331                .decrypt_u32(elgamal_keypair_1.secret(), 1)
332                .unwrap()
333        );
334
335        assert_eq!(
336            Some(amount),
337            grouped_ciphertext
338                .decrypt_u32(elgamal_keypair_2.secret(), 2)
339                .unwrap()
340        );
341
342        assert_eq!(
343            GroupedElGamalError::IndexOutOfBounds,
344            grouped_ciphertext
345                .decrypt_u32(elgamal_keypair_0.secret(), 3)
346                .unwrap_err()
347        );
348    }
349
350    #[test]
351    fn test_grouped_ciphertext_bytes() {
352        let elgamal_keypair_0 = ElGamalKeypair::new_rand();
353        let elgamal_keypair_1 = ElGamalKeypair::new_rand();
354        let elgamal_keypair_2 = ElGamalKeypair::new_rand();
355
356        let amount: u64 = 10;
357        let grouped_ciphertext = GroupedElGamal::encrypt(
358            [
359                elgamal_keypair_0.pubkey(),
360                elgamal_keypair_1.pubkey(),
361                elgamal_keypair_2.pubkey(),
362            ],
363            amount,
364        );
365
366        let produced_bytes = grouped_ciphertext.to_bytes();
367        assert_eq!(produced_bytes.len(), 128);
368
369        let decoded_grouped_ciphertext =
370            GroupedElGamalCiphertext::<3>::from_bytes(&produced_bytes).unwrap();
371        assert_eq!(
372            Some(amount),
373            decoded_grouped_ciphertext
374                .decrypt_u32(elgamal_keypair_0.secret(), 0)
375                .unwrap()
376        );
377
378        assert_eq!(
379            Some(amount),
380            decoded_grouped_ciphertext
381                .decrypt_u32(elgamal_keypair_1.secret(), 1)
382                .unwrap()
383        );
384
385        assert_eq!(
386            Some(amount),
387            decoded_grouped_ciphertext
388                .decrypt_u32(elgamal_keypair_2.secret(), 2)
389                .unwrap()
390        );
391    }
392
393    #[test]
394    fn test_decrypt_with_wrong_key_at_valid_index() {
395        let keypair_0 = ElGamalKeypair::new_rand();
396        let keypair_1 = ElGamalKeypair::new_rand();
397        let amount: u64 = 50;
398
399        let grouped_ciphertext =
400            GroupedElGamal::encrypt([keypair_0.pubkey(), keypair_1.pubkey()], amount);
401
402        // Attempt to decrypt handle 1 with secret key 0. This must fail.
403        let result = grouped_ciphertext
404            .decrypt_u32(keypair_0.secret(), 1)
405            .unwrap();
406        assert!(result.is_none());
407    }
408
409    #[test]
410    fn test_zero_sized_group() {
411        let amount: u64 = 42;
412        let grouped_ciphertext = GroupedElGamal::<0>::encrypt([], amount);
413
414        // Check byte serialization
415        let bytes = grouped_ciphertext.to_bytes();
416        assert_eq!(bytes.len(), 32); // Only the commitment
417
418        // Check roundtrip
419        let decoded_ciphertext = GroupedElGamalCiphertext::<0>::from_bytes(&bytes).unwrap();
420        assert_eq!(grouped_ciphertext, decoded_ciphertext);
421
422        // Decryption should fail as there are no handles
423        let keypair = ElGamalKeypair::new_rand();
424        assert_eq!(
425            grouped_ciphertext
426                .decrypt_u32(keypair.secret(), 0)
427                .unwrap_err(),
428            GroupedElGamalError::IndexOutOfBounds
429        );
430    }
431
432    #[test]
433    fn test_malformed_bytes_deserialization() {
434        let amount: u64 = 42;
435
436        // Case 1: Bytes too short
437        let short_bytes = vec![0; 63]; // Expected 64 for N=1
438        assert!(GroupedElGamalCiphertext::<1>::from_bytes(&short_bytes).is_none());
439
440        // Case 2: Bytes too long
441        let long_bytes = vec![0; 65]; // Expected 64 for N=1
442        assert!(GroupedElGamalCiphertext::<1>::from_bytes(&long_bytes).is_none());
443
444        // Case 3: Correct length, but invalid point data for the commitment
445        let mut malformed_commitment = vec![0; 64];
446        // This is the compressed form of an invalid point (order 4)
447        malformed_commitment[0] = 1;
448        assert!(GroupedElGamalCiphertext::<1>::from_bytes(&malformed_commitment).is_none());
449
450        // Case 4: Correct length, but invalid point data for a handle
451        let keypair = ElGamalKeypair::new_rand();
452        let ciphertext = GroupedElGamal::<1>::encrypt([keypair.pubkey()], amount);
453        let mut bytes = ciphertext.to_bytes();
454        // Invalidate the handle part
455        bytes[32] = 1;
456        assert!(GroupedElGamalCiphertext::<1>::from_bytes(&bytes).is_none());
457    }
458}