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