Skip to main content

zkgroup/api/groups/
group_send_endorsement.rs

1//
2// Copyright 2024 Signal Messenger, LLC.
3// SPDX-License-Identifier: AGPL-3.0-only
4//
5
6//! Provides GroupSendEndorsement and related types.
7//!
8//! GroupSendEndorsement is a MAC over:
9//! - a ServiceId (computed from the ciphertexts on the group server at issuance, passed decrypted
10//!   to the chat server for verification)
11//! - an expiration timestamp, truncated to day granularity (chosen by the group server at issuance,
12//!   passed publicly to the chat server for verification)
13
14use std::fmt::Debug;
15
16use derive_where::derive_where;
17use partial_default::PartialDefault;
18use poksho::ShoApi;
19use rayon::iter::{IndexedParallelIterator as _, ParallelIterator as _};
20use serde::{Deserialize, Serialize};
21use zkcredential::attributes::Attribute as _;
22
23use crate::api::endorsement_expiration;
24use crate::common::array_utils;
25use crate::common::serialization::ReservedByte;
26use crate::crypto::uid_encryption;
27use crate::groups::{GroupSecretParams, UuidCiphertext};
28use crate::{
29    RandomnessBytes, Timestamp, ZkGroupDeserializationFailure, ZkGroupVerificationFailure, crypto,
30};
31
32/// A key pair used to sign endorsements for a particular expiration.
33///
34/// These are intended to be cheaply cached -- it's not a problem to regenerate them, but they're
35/// expected to be reused frequently enough that they're *worth* caching, given that they're only
36/// rotated every 24 hours.
37#[derive(Clone, Serialize, Deserialize, PartialDefault)]
38pub struct GroupSendDerivedKeyPair {
39    reserved: ReservedByte,
40    key_pair: zkcredential::endorsements::ServerDerivedKeyPair,
41    expiration: Timestamp,
42}
43
44impl GroupSendDerivedKeyPair {
45    /// Encapsulates the "tag info", or public attributes, of an endorsement, which is used to derive
46    /// the appropriate signing key.
47    fn tag_info(expiration: Timestamp) -> impl poksho::ShoApi + Clone {
48        let mut sho = poksho::ShoHmacSha256::new(b"20240215_Signal_GroupSendEndorsement");
49        sho.absorb_and_ratchet(&expiration.to_be_bytes());
50        sho
51    }
52
53    /// Derives the appropriate key pair for the given expiration.
54    pub fn for_expiration(
55        expiration: Timestamp,
56        root: impl AsRef<zkcredential::endorsements::ServerRootKeyPair>,
57    ) -> Self {
58        Self {
59            reserved: ReservedByte::default(),
60            key_pair: root.as_ref().derive_key(Self::tag_info(expiration)),
61            expiration,
62        }
63    }
64}
65
66/// The response issued from the group server, containing endorsements for all of a group's members.
67///
68/// The group server may cache this for a particular group as long as the group membership does not
69/// change (being careful of expiration, of course). It is the same for every requesting member.
70#[derive(Clone, Serialize, Deserialize, PartialDefault, Debug)]
71pub struct GroupSendEndorsementsResponse {
72    reserved: ReservedByte,
73    endorsements: zkcredential::endorsements::EndorsementResponse,
74    expiration: Timestamp,
75}
76
77impl GroupSendEndorsementsResponse {
78    pub fn default_expiration(current_time: Timestamp) -> Timestamp {
79        endorsement_expiration::default_expiration(current_time)
80    }
81
82    /// Sorts `points` in *some* deterministic order based on the contents of each `RistrettoPoint`.
83    ///
84    /// Changing this order is a breaking change, since the issuing server and client must agree on
85    /// it.
86    ///
87    /// The `usize` in each pair must be the original index of the point.
88    fn sort_points(points: &mut [(usize, curve25519_dalek::RistrettoPoint)]) {
89        debug_assert!(points.iter().enumerate().all(|(i, (j, _))| i == *j));
90        let sort_keys = curve25519_dalek::RistrettoPoint::double_and_compress_batch(
91            points.iter().map(|(_i, point)| point),
92        );
93        points.sort_unstable_by_key(|(i, _point)| sort_keys[*i].as_bytes());
94    }
95
96    /// Issues new endorsements, one for each of `member_ciphertexts`.
97    ///
98    /// `expiration` must match the expiration used to derive `key_pair`;
99    pub fn issue(
100        member_ciphertexts: impl IntoIterator<Item = UuidCiphertext>,
101        key_pair: &GroupSendDerivedKeyPair,
102        randomness: RandomnessBytes,
103    ) -> Self {
104        // Note: we could save some work here by pulling the single point we need out of the
105        // serialized bytes, and operating directly on that. However, we'd have to remember to
106        // update that if the serialization format ever changes.
107        let mut points_to_sign: Vec<(usize, curve25519_dalek::RistrettoPoint)> = member_ciphertexts
108            .into_iter()
109            .map(|ciphertext| ciphertext.ciphertext.as_points()[0])
110            .enumerate()
111            .collect();
112        Self::sort_points(&mut points_to_sign);
113
114        let endorsements = zkcredential::endorsements::EndorsementResponse::issue(
115            points_to_sign.iter().map(|(_i, point)| *point),
116            &key_pair.key_pair,
117            randomness,
118        );
119
120        // We don't bother to "un-sort" the endorsements back to the original order of the points,
121        // because clients don't keep track of that order anyway. Instead, we return the
122        // endorsements in the sorted order we computed above.
123
124        Self {
125            reserved: ReservedByte::default(),
126            endorsements,
127            expiration: key_pair.expiration,
128        }
129    }
130
131    /// Returns the expiration for all endorsements in the response.
132    pub fn expiration(&self) -> Timestamp {
133        self.expiration
134    }
135
136    /// Validates `self.expiration` against `now` and derives the appropriate signing key (using
137    /// [`GroupSendDerivedKeyPair::tag_info`]).
138    ///
139    /// Note that if a client expects to receive endorsements from many different groups in one day
140    /// it *could* be worth caching this, but the operation is pretty cheap compared to the rest of
141    /// verifying responses, so we don't think it would make that much of a difference.
142    fn derive_public_signing_key_from_expiration(
143        &self,
144        now: Timestamp,
145        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
146    ) -> Result<zkcredential::endorsements::ServerDerivedPublicKey, ZkGroupVerificationFailure>
147    {
148        endorsement_expiration::validate_expiration(self.expiration, now)?;
149
150        Ok(root_public_key
151            .as_ref()
152            .derive_key(GroupSendDerivedKeyPair::tag_info(self.expiration)))
153    }
154
155    /// Same as [`Self::receive_with_service_ids`], but without parallelizing the zkgroup-specific
156    /// parts of the operation.
157    ///
158    /// Only interesting for benchmarking. The zkcredential part of the operation may still be
159    /// parallelized.
160    pub fn receive_with_service_ids_single_threaded(
161        self,
162        user_ids: impl IntoIterator<Item = libsignal_core::ServiceId>,
163        now: Timestamp,
164        group_params: &GroupSecretParams,
165        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
166    ) -> Result<Vec<ReceivedEndorsement>, ZkGroupVerificationFailure> {
167        let derived_key = self.derive_public_signing_key_from_expiration(now, root_public_key)?;
168
169        // The endorsements are sorted by the serialized *ciphertext* representations.
170        // We have to compute the ciphertexts (expensive), but we can skip the second point (which
171        // would be much more expensive).
172        // We zip the results together with a set of indexes so we can un-sort the results later.
173        let uid_sho_seed = crypto::uid_struct::UidStruct::seed_M1();
174        let mut member_points: Vec<(usize, curve25519_dalek::RistrettoPoint)> = user_ids
175            .into_iter()
176            .map(|user_id| {
177                group_params.uid_enc_key_pair.a1
178                    * crypto::uid_struct::UidStruct::calc_M1(uid_sho_seed.clone(), user_id)
179            })
180            .enumerate()
181            .collect();
182        Self::sort_points(&mut member_points);
183
184        let endorsements = self
185            .endorsements
186            .receive(member_points.iter().map(|(_i, point)| *point), &derived_key)
187            .map_err(|_| ZkGroupVerificationFailure)?;
188
189        Ok(array_utils::collect_permutation(
190            endorsements
191                .compressed
192                .into_iter()
193                .zip(endorsements.decompressed)
194                .map(|(compressed, decompressed)| ReceivedEndorsement {
195                    compressed: GroupSendEndorsement {
196                        reserved: ReservedByte::default(),
197                        endorsement: compressed,
198                    },
199                    decompressed: GroupSendEndorsement {
200                        reserved: ReservedByte::default(),
201                        endorsement: decompressed,
202                    },
203                })
204                .zip(member_points.iter().map(|(i, _)| *i)),
205        ))
206    }
207
208    /// Validates and returns the endorsements issued by the server.
209    ///
210    /// The result will be in the same order as `user_ids`. `user_ids` should contain the current
211    /// user as well.
212    ///
213    /// If you already have the member ciphertexts for the group available,
214    /// [`Self::receive_with_ciphertexts`] will be faster than this method.
215    pub fn receive_with_service_ids<T>(
216        self,
217        user_ids: T,
218        now: Timestamp,
219        group_params: &GroupSecretParams,
220        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
221    ) -> Result<Vec<ReceivedEndorsement>, ZkGroupVerificationFailure>
222    where
223        T: rayon::iter::IntoParallelIterator<
224                Item = libsignal_core::ServiceId,
225                Iter: rayon::iter::IndexedParallelIterator,
226            >,
227    {
228        let derived_key = self.derive_public_signing_key_from_expiration(now, root_public_key)?;
229
230        // The endorsements are sorted based on the *ciphertext* representations.
231        // We have to compute the ciphertexts (expensive), but we can skip the second point (which
232        // would be much more expensive).
233        // We zip the results together with a set of indexes so we can un-sort the results later.
234        let uid_sho_seed = crypto::uid_struct::UidStruct::seed_M1();
235        let mut member_points: Vec<(usize, curve25519_dalek::RistrettoPoint)> = user_ids
236            .into_par_iter()
237            .map(|user_id| {
238                group_params.uid_enc_key_pair.a1
239                    * crypto::uid_struct::UidStruct::calc_M1(uid_sho_seed.clone(), user_id)
240            })
241            .enumerate()
242            .collect();
243        Self::sort_points(&mut member_points);
244
245        let endorsements = self
246            .endorsements
247            .receive(member_points.iter().map(|(_i, point)| *point), &derived_key)
248            .map_err(|_| ZkGroupVerificationFailure)?;
249
250        Ok(array_utils::collect_permutation(
251            endorsements
252                .compressed
253                .into_iter()
254                .zip(endorsements.decompressed)
255                .map(|(compressed, decompressed)| ReceivedEndorsement {
256                    compressed: GroupSendEndorsement {
257                        reserved: ReservedByte::default(),
258                        endorsement: compressed,
259                    },
260                    decompressed: GroupSendEndorsement {
261                        reserved: ReservedByte::default(),
262                        endorsement: decompressed,
263                    },
264                })
265                .zip(member_points.iter().map(|(i, _)| *i)),
266        ))
267    }
268
269    /// Validates and returns the endorsements issued by the server.
270    ///
271    /// The result will be in the same order as `member_ciphertexts`. `member_ciphertexts` should
272    /// contain the current user as well.
273    ///
274    /// If you don't already have the member ciphertexts for the group available,
275    /// [`Self::receive_with_service_ids`] will be faster than computing them separately, using
276    /// this method, and then throwing the ciphertexts away.
277    pub fn receive_with_ciphertexts(
278        self,
279        member_ciphertexts: impl IntoIterator<Item = UuidCiphertext>,
280        now: Timestamp,
281        root_public_key: impl AsRef<zkcredential::endorsements::ServerRootPublicKey>,
282    ) -> Result<Vec<ReceivedEndorsement>, ZkGroupVerificationFailure> {
283        let derived_key = self.derive_public_signing_key_from_expiration(now, root_public_key)?;
284
285        // Note: we could save some work here by pulling the single point we need out of the
286        // serialized form of UuidCiphertext, and operating directly on that. However, we'd have to
287        // remember to update that if the serialization format ever changes.
288        let mut points_to_check: Vec<_> = member_ciphertexts
289            .into_iter()
290            .map(|ciphertext| ciphertext.ciphertext.as_points()[0])
291            .enumerate()
292            .collect();
293        Self::sort_points(&mut points_to_check);
294
295        let endorsements = self
296            .endorsements
297            .receive(
298                points_to_check.iter().map(|(_i, point)| *point),
299                &derived_key,
300            )
301            .map_err(|_| ZkGroupVerificationFailure)?;
302
303        Ok(array_utils::collect_permutation(
304            endorsements
305                .compressed
306                .into_iter()
307                .zip(endorsements.decompressed)
308                .map(|(compressed, decompressed)| ReceivedEndorsement {
309                    compressed: GroupSendEndorsement {
310                        reserved: ReservedByte::default(),
311                        endorsement: compressed,
312                    },
313                    decompressed: GroupSendEndorsement {
314                        reserved: ReservedByte::default(),
315                        endorsement: decompressed,
316                    },
317                })
318                .zip(points_to_check.iter().map(|(i, _)| *i)),
319        ))
320    }
321}
322
323/// A single endorsement, for one or multiple group members.
324///
325/// `Storage` is usually [`curve25519_dalek::RistrettoPoint`], but the `receive` APIs on
326/// [`GroupSendEndorsementsResponse`] produce "compressed" endorsements, since they are usually
327/// immediately serialized.
328#[derive(Serialize, Deserialize, PartialDefault, Clone, Copy)]
329#[partial_default(bound = "Storage: curve25519_dalek::traits::Identity")]
330#[derive_where(PartialEq; Storage: subtle::ConstantTimeEq)]
331pub struct GroupSendEndorsement<Storage = curve25519_dalek::RistrettoPoint> {
332    reserved: ReservedByte,
333    endorsement: zkcredential::endorsements::Endorsement<Storage>,
334}
335
336impl Debug for GroupSendEndorsement<curve25519_dalek::RistrettoPoint> {
337    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
338        f.debug_struct("GroupSendEndorsement")
339            .field("reserved", &self.reserved)
340            .field("endorsement", &self.endorsement)
341            .finish()
342    }
343}
344
345impl Debug for GroupSendEndorsement<curve25519_dalek::ristretto::CompressedRistretto> {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.debug_struct("GroupSendEndorsement")
348            .field("reserved", &self.reserved)
349            .field("endorsement", &self.endorsement)
350            .finish()
351    }
352}
353
354/// An endorsement as extracted from a [`GroupSendEndorsementsResponse`].
355///
356/// The `receive` process has to work with the endorsements in both compressed and decompressed
357/// forms, so it might as well provide both to the caller. The compressed form is appropriate for
358/// serialization (in fact it is essentially already serialized), while the decompressed form
359/// supports further operations. Depending on what a client wants to do with the endorsements,
360/// either or both could be useful.
361///
362/// The fields are public to support deconstruction one field at a time.
363#[allow(missing_docs)]
364#[derive(Clone, Copy, PartialDefault)]
365pub struct ReceivedEndorsement {
366    // Why does this zip together the compressed and decompressed endorsements, while zkcredential
367    // uses two separate Vecs? Because the zkcredential processing has two Vecs already constructed,
368    // and keeping them in that format can save on memory usage and copies (even though they *could*
369    // be zipped together). zkgroup adds a version byte to every endorsement, which means the
370    // existing memory allocation isn't sufficient anyway, and thus we're better off constructing a
371    // single big Vec rather than two smaller ones, especially since we have to un-permute the
372    // results. (It's close, though, only a 3-6% difference at the largest group sizes.)
373    pub compressed: GroupSendEndorsement<curve25519_dalek::ristretto::CompressedRistretto>,
374    pub decompressed: GroupSendEndorsement,
375}
376
377impl GroupSendEndorsement<curve25519_dalek::ristretto::CompressedRistretto> {
378    /// Attempts to decompress the GroupSendEndorsement.
379    ///
380    /// Produces [`ZkGroupDeserializationFailure`] if the compressed storage isn't a valid
381    /// representation of a point.
382    ///
383    /// Deserializing an `GroupSendEndorsement<RistrettoPoint>` is equivalent to deserializing an
384    /// `GroupSendEndorsement<CompressedRistretto>` and then calling `decompress`.
385    pub fn decompress(
386        self,
387    ) -> Result<GroupSendEndorsement<curve25519_dalek::RistrettoPoint>, ZkGroupDeserializationFailure>
388    {
389        Ok(GroupSendEndorsement {
390            reserved: self.reserved,
391            endorsement: self
392                .endorsement
393                .decompress()
394                .map_err(|_| ZkGroupDeserializationFailure::new::<Self>())?,
395        })
396    }
397}
398
399impl GroupSendEndorsement<curve25519_dalek::RistrettoPoint> {
400    /// Compresses the GroupSendEndorsement for storage.
401    ///
402    /// Serializing an `GroupSendEndorsement<RistrettoPoint>` is equivalent to calling `compress` and
403    /// serializing the resulting `GroupSendEndorsement<CompressedRistretto>`.
404    pub fn compress(
405        self,
406    ) -> GroupSendEndorsement<curve25519_dalek::ristretto::CompressedRistretto> {
407        GroupSendEndorsement {
408            reserved: self.reserved,
409            endorsement: self.endorsement.compress(),
410        }
411    }
412}
413
414impl GroupSendEndorsement {
415    /// Combines several endorsements into one.
416    ///
417    /// All endorsements must have been generated from the same issuance, or the resulting
418    /// endorsement will not produce a valid token.
419    ///
420    /// This is a set-like operation: order does not matter.
421    pub fn combine(
422        endorsements: impl IntoIterator<Item = GroupSendEndorsement>,
423    ) -> GroupSendEndorsement {
424        let mut endorsements = endorsements.into_iter();
425        let Some(mut result) = endorsements.next() else {
426            // If we ever have multiple versions, it's not obvious which version to default to here,
427            // since we normally require the versions to match when calling `combine` or `remove`.
428            // But for now it's okay.
429            return GroupSendEndorsement {
430                reserved: ReservedByte::default(),
431                endorsement: Default::default(),
432            };
433        };
434        for next in endorsements {
435            assert_eq!(
436                result.reserved, next.reserved,
437                "endorsements must all have the same version"
438            );
439            result.endorsement = result.endorsement.combine_with(&next.endorsement);
440        }
441        result
442    }
443
444    /// Removes endorsements from a previously-combined endorsement.
445    ///
446    /// Removing endorsements not present in `self` will result in an endorsement that will not
447    /// produce a valid token.
448    ///
449    /// This is a set-like operation: order does not matter. Multiple endorsements can be removed by
450    /// calling this method repeatedly, or by removing a single combined endorsement.
451    pub fn remove(&self, unwanted_endorsements: &GroupSendEndorsement) -> GroupSendEndorsement {
452        assert_eq!(
453            self.reserved, unwanted_endorsements.reserved,
454            "endorsements must have the same version"
455        );
456        GroupSendEndorsement {
457            reserved: self.reserved,
458            endorsement: self.endorsement.remove(&unwanted_endorsements.endorsement),
459        }
460    }
461
462    /// Generates a bearer token from the endorsement.
463    ///
464    /// This can be cached by the client for repeatedly sending to the same recipient,
465    /// but must be converted to a GroupSendFullToken before sending it to the server.
466    pub fn to_token<T: AsRef<uid_encryption::KeyPair>>(&self, key_pair: T) -> GroupSendToken {
467        let client_key =
468            zkcredential::endorsements::ClientDecryptionKey::for_first_point_of_attribute(
469                key_pair.as_ref(),
470            );
471        let raw_token = self.endorsement.to_token(&client_key);
472        GroupSendToken {
473            reserved: ReservedByte::default(),
474            raw_token,
475        }
476    }
477}
478
479/// A token representing an endorsement.
480///
481/// This can be cached by the client for repeatedly sending to the same recipient,
482/// but must be converted to a GroupSendFullToken before sending it to the server.
483#[derive(Clone, Serialize, Deserialize, PartialDefault)]
484pub struct GroupSendToken {
485    reserved: ReservedByte,
486    raw_token: Box<[u8]>,
487}
488
489impl Debug for GroupSendToken {
490    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
491        f.debug_struct("GroupSendToken")
492            .field("reserved", &self.reserved)
493            .field("raw_token", &zkcredential::PrintAsHex(&*self.raw_token))
494            .finish()
495    }
496}
497
498impl GroupSendToken {
499    /// Attaches the expiration to this token to create a GroupSendFullToken.
500    ///
501    /// If the incorrect expiration is used, the token will fail verification.
502    pub fn into_full_token(self, expiration: Timestamp) -> GroupSendFullToken {
503        GroupSendFullToken {
504            reserved: self.reserved,
505            raw_token: self.raw_token,
506            expiration,
507        }
508    }
509}
510
511/// A token representing an endorsement, along with its expiration.
512///
513/// This will be serialized and sent to the chat server for verification.
514#[derive(Clone, Serialize, Deserialize, PartialDefault)]
515pub struct GroupSendFullToken {
516    reserved: ReservedByte,
517    raw_token: Box<[u8]>,
518    expiration: Timestamp,
519}
520
521impl Debug for GroupSendFullToken {
522    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
523        f.debug_struct("GroupSendFullToken")
524            .field("reserved", &self.reserved)
525            .field("raw_token", &zkcredential::PrintAsHex(&*self.raw_token))
526            .field("expiration", &self.expiration)
527            .finish()
528    }
529}
530
531impl GroupSendFullToken {
532    pub fn expiration(&self) -> Timestamp {
533        self.expiration
534    }
535
536    /// Checks whether the token is (still) valid for sending to `user_ids` at `now` according to
537    /// `key_pair`.
538    pub fn verify(
539        &self,
540        user_ids: impl IntoIterator<Item = libsignal_core::ServiceId>,
541        now: Timestamp,
542        key_pair: &GroupSendDerivedKeyPair,
543    ) -> Result<(), ZkGroupVerificationFailure> {
544        if now > self.expiration {
545            return Err(ZkGroupVerificationFailure);
546        }
547        assert_eq!(
548            self.expiration, key_pair.expiration,
549            "wrong key pair used for this token"
550        );
551
552        let uid_sho_seed = crypto::uid_struct::UidStruct::seed_M1();
553        let user_id_sum: curve25519_dalek::RistrettoPoint = user_ids
554            .into_iter()
555            .map(|user_id| crypto::uid_struct::UidStruct::calc_M1(uid_sho_seed.clone(), user_id))
556            .sum();
557
558        key_pair
559            .key_pair
560            .verify(&user_id_sum, &self.raw_token)
561            .map_err(|_| ZkGroupVerificationFailure)
562    }
563}