libsignal_service/groups_v2/
manager.rs

1use std::{collections::HashMap, convert::TryInto};
2
3use crate::{
4    configuration::Endpoint,
5    groups_v2::{
6        model::{Group, GroupChanges},
7        operations::{GroupDecodingError, GroupOperations},
8    },
9    prelude::{PushService, ServiceError},
10    proto::GroupContextV2,
11    push_service::{HttpAuth, HttpAuthOverride, ReqwestExt, ServiceIds},
12    utils::BASE64_RELAXED,
13};
14
15use base64::prelude::*;
16use bytes::Bytes;
17use chrono::{Days, NaiveDate, NaiveTime, Utc};
18use futures::AsyncReadExt;
19use rand::{CryptoRng, Rng};
20use reqwest::Method;
21use serde::Deserialize;
22use zkgroup::{
23    auth::AuthCredentialWithPniResponse,
24    groups::{GroupMasterKey, GroupSecretParams},
25    ServerPublicParams,
26};
27
28#[derive(Debug, serde::Deserialize)]
29#[serde(rename_all = "camelCase")]
30pub struct TemporalCredential {
31    credential: String,
32    redemption_time: u64,
33}
34
35#[derive(Deserialize)]
36#[serde(rename_all = "camelCase")]
37pub struct CredentialResponse {
38    credentials: Vec<TemporalCredential>,
39}
40
41#[expect(clippy::result_large_err)]
42impl CredentialResponse {
43    pub fn parse(
44        self,
45    ) -> Result<HashMap<u64, AuthCredentialWithPniResponse>, ServiceError> {
46        self.credentials
47            .into_iter()
48            .map(|c| {
49                let bytes = BASE64_RELAXED.decode(c.credential)?;
50                let data = AuthCredentialWithPniResponse::new(&bytes)?;
51                Ok((c.redemption_time, data))
52            })
53            .collect::<Result<_, ServiceError>>()
54    }
55}
56
57#[derive(Debug, thiserror::Error)]
58pub enum CredentialsCacheError {
59    #[error("failed to read values from cache: {0}")]
60    ReadError(String),
61    #[error("failed to write values from cache: {0}")]
62    WriteError(String),
63}
64
65/// Global cache for groups v2 credentials, as demonstrated in the libsignal-service
66/// java library of Signal-Android.
67///
68/// A basic in-memory implementation is provided with `InMemoryCredentialsCache`.
69pub trait CredentialsCache {
70    fn clear(&mut self) -> Result<(), CredentialsCacheError>;
71
72    /// Get an entry of the cache, key usually represents the day number since EPOCH.
73    fn get(
74        &self,
75        key: &u64,
76    ) -> Result<Option<&AuthCredentialWithPniResponse>, CredentialsCacheError>;
77
78    /// Overwrite the entire contents of the cache with new data.
79    fn write(
80        &mut self,
81        map: HashMap<u64, AuthCredentialWithPniResponse>,
82    ) -> Result<(), CredentialsCacheError>;
83}
84
85#[derive(Default)]
86pub struct InMemoryCredentialsCache {
87    map: HashMap<u64, AuthCredentialWithPniResponse>,
88}
89
90impl CredentialsCache for InMemoryCredentialsCache {
91    fn clear(&mut self) -> Result<(), CredentialsCacheError> {
92        self.map.clear();
93        Ok(())
94    }
95
96    fn get(
97        &self,
98        key: &u64,
99    ) -> Result<Option<&AuthCredentialWithPniResponse>, CredentialsCacheError>
100    {
101        Ok(self.map.get(key))
102    }
103
104    fn write(
105        &mut self,
106        map: HashMap<u64, AuthCredentialWithPniResponse>,
107    ) -> Result<(), CredentialsCacheError> {
108        self.map = map;
109        Ok(())
110    }
111}
112
113impl<T: CredentialsCache> CredentialsCache for &mut T {
114    fn clear(&mut self) -> Result<(), CredentialsCacheError> {
115        (**self).clear()
116    }
117
118    fn get(
119        &self,
120        key: &u64,
121    ) -> Result<Option<&AuthCredentialWithPniResponse>, CredentialsCacheError>
122    {
123        (**self).get(key)
124    }
125
126    fn write(
127        &mut self,
128        map: HashMap<u64, AuthCredentialWithPniResponse>,
129    ) -> Result<(), CredentialsCacheError> {
130        (**self).write(map)
131    }
132}
133
134pub struct GroupsManager<C: CredentialsCache> {
135    service_ids: ServiceIds,
136    push_service: PushService,
137    credentials_cache: C,
138    server_public_params: ServerPublicParams,
139}
140
141impl<C: CredentialsCache> GroupsManager<C> {
142    pub fn new(
143        service_ids: ServiceIds,
144        push_service: PushService,
145        credentials_cache: C,
146        server_public_params: ServerPublicParams,
147    ) -> Self {
148        Self {
149            service_ids,
150            push_service,
151            credentials_cache,
152            server_public_params,
153        }
154    }
155
156    pub async fn get_authorization_for_today<R: Rng + CryptoRng>(
157        &mut self,
158        csprng: &mut R,
159        group_secret_params: GroupSecretParams,
160    ) -> Result<HttpAuth, ServiceError> {
161        let (today, today_plus_7_days) = current_days_seconds();
162
163        let auth_credential_response = if let Some(auth_credential_response) =
164            self.credentials_cache.get(&today)?
165        {
166            auth_credential_response
167        } else {
168            let path =
169            format!("/v1/certificate/auth/group?redemptionStartSeconds={}&redemptionEndSeconds={}&pniAsServiceId=true", today, today_plus_7_days);
170
171            let credentials_response: CredentialResponse = self
172                .push_service
173                .request(
174                    Method::GET,
175                    Endpoint::service(path),
176                    HttpAuthOverride::NoOverride,
177                )?
178                .send()
179                .await?
180                .service_error_for_status()
181                .await?
182                .json()
183                .await?;
184            self.credentials_cache
185                .write(credentials_response.parse()?)?;
186            self.credentials_cache.get(&today)?.ok_or({
187                ServiceError::InvalidFrame {
188                    reason:
189                        "credentials received did not contain requested day",
190                }
191            })?
192        };
193
194        self.get_authorization_string(
195            csprng,
196            group_secret_params,
197            auth_credential_response.clone(),
198            today,
199        )
200    }
201
202    #[expect(clippy::result_large_err)]
203    fn get_authorization_string<R: Rng + CryptoRng>(
204        &self,
205        csprng: &mut R,
206        group_secret_params: GroupSecretParams,
207        credential_response: AuthCredentialWithPniResponse,
208        today: u64,
209    ) -> Result<HttpAuth, ServiceError> {
210        let auth_credential = self
211            .server_public_params
212            .receive_auth_credential_with_pni_as_service_id(
213                self.service_ids.aci(),
214                self.service_ids.pni(),
215                zkgroup::Timestamp::from_epoch_seconds(today),
216                credential_response,
217            )
218            .map_err(|e| {
219                tracing::error!(
220                    "failed to receive auth credentials with PNI: {:?}",
221                    e
222                );
223                ServiceError::GroupsV2Error
224            })?;
225
226        let mut random_bytes = [0u8; 32];
227        csprng.fill_bytes(&mut random_bytes);
228
229        let auth_credential_presentation = self
230            .server_public_params
231            .create_auth_credential_with_pni_presentation(
232                random_bytes,
233                group_secret_params,
234                auth_credential,
235            );
236
237        // see simpleapi.rs GroupSecretParams_getPublicParams, everything is bincode encoded
238        // across the boundary of Rust/Java
239        let username = hex::encode(bincode::serialize(
240            &group_secret_params.get_public_params(),
241        )?);
242
243        let password =
244            hex::encode(bincode::serialize(&auth_credential_presentation)?);
245
246        Ok(HttpAuth { username, password })
247    }
248
249    pub async fn fetch_encrypted_group<R: Rng + CryptoRng>(
250        &mut self,
251        csprng: &mut R,
252        master_key_bytes: &[u8],
253    ) -> Result<crate::proto::Group, ServiceError> {
254        let group_master_key = GroupMasterKey::new(
255            master_key_bytes
256                .try_into()
257                .map_err(|_| ServiceError::GroupsV2Error)?,
258        );
259        let group_secret_params =
260            GroupSecretParams::derive_from_master_key(group_master_key);
261        let authorization = self
262            .get_authorization_for_today(csprng, group_secret_params)
263            .await?;
264        self.push_service.get_group(authorization).await
265    }
266
267    #[tracing::instrument(
268        skip(self, group_secret_params),
269        fields(path = %path[..4.min(path.len())]),
270    )]
271    pub async fn retrieve_avatar(
272        &mut self,
273        path: &str,
274        group_secret_params: GroupSecretParams,
275    ) -> Result<Option<Vec<u8>>, ServiceError> {
276        let mut encrypted_avatar = self
277            .push_service
278            .retrieve_groups_v2_profile_avatar(path)
279            .await?;
280        let mut result = Vec::with_capacity(10 * 1024 * 1024);
281        encrypted_avatar.read_to_end(&mut result).await?;
282        Ok(GroupOperations::new(group_secret_params).decrypt_avatar(&result))
283    }
284
285    pub fn decrypt_group_context(
286        &self,
287        group_context: GroupContextV2,
288    ) -> Result<Option<GroupChanges>, GroupDecodingError> {
289        match (group_context.master_key, group_context.group_change) {
290            (Some(master_key), Some(group_change)) => {
291                let master_key_bytes: [u8; 32] = master_key
292                    .try_into()
293                    .map_err(|_| GroupDecodingError::WrongBlob)?;
294                let group_master_key = GroupMasterKey::new(master_key_bytes);
295                let group_secret_params =
296                    GroupSecretParams::derive_from_master_key(group_master_key);
297                let encrypted_group_change =
298                    prost::Message::decode(Bytes::from(group_change))?;
299                let group_change = GroupOperations::new(group_secret_params)
300                    .decrypt_group_change(encrypted_group_change)?;
301                Ok(Some(group_change))
302            },
303            _ => Ok(None),
304        }
305    }
306}
307
308#[expect(clippy::result_large_err)]
309pub fn decrypt_group(
310    master_key_bytes: &[u8],
311    encrypted_group: crate::proto::Group,
312) -> Result<Group, ServiceError> {
313    let group_master_key = GroupMasterKey::new(
314        master_key_bytes
315            .try_into()
316            .expect("wrong master key bytes length"),
317    );
318    let group_secret_params =
319        GroupSecretParams::derive_from_master_key(group_master_key);
320
321    Ok(GroupOperations::new(group_secret_params)
322        .decrypt_group(encrypted_group)?)
323}
324
325fn current_days_seconds() -> (u64, u64) {
326    let days_seconds = |date: NaiveDate| {
327        date.and_time(NaiveTime::from_hms_opt(0, 0, 0).unwrap())
328            .and_utc()
329            .timestamp() as u64
330    };
331
332    let today = Utc::now().naive_utc().date();
333    let today_plus_7_days = today + Days::new(7);
334
335    (days_seconds(today), days_seconds(today_plus_7_days))
336}