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