Skip to main content

xmtp_mls/groups/mls_sync/
post_commit.rs

1//! Post-commit work: installations, welcomes, and HMAC keys.
2
3use super::publish::OutgoingPreparationError;
4use super::*;
5use xmtp_common::snippet::Snippet;
6use xmtp_mls_group_keys::group_keys::{GroupKeys, SealError, sign_group_message};
7
8fn log_missing_installations(
9    inbox_id: &str,
10    installation_id: &[u8],
11    intent_data: &UpdateGroupMembershipIntentData,
12) {
13    debug!(
14        inbox_id,
15        installation_id = installation_id.snippet(),
16        membership_update_count = intent_data.membership_updates.len(),
17        removed_member_count = intent_data.removed_members.len(),
18        failed_installation_count = intent_data.failed_installations.len(),
19        "Adding missing installations"
20    );
21}
22
23pub(crate) async fn membership_update_intent(
24    context: &impl XmtpSharedContext,
25    group_id: &GroupId,
26    existing_group_membership: &GroupMembership,
27    retry_leaves: Option<&[Vec<u8>]>,
28    inbox_ids_to_add: &[InboxIdRef<'_>],
29    inbox_ids_to_remove: &[InboxIdRef<'_>],
30) -> Result<UpdateGroupMembershipIntentData, GroupError> {
31    // TODO:nm prevent querying for updates on members who are being removed
32    let mut inbox_ids = existing_group_membership.inbox_ids();
33    inbox_ids.extend_from_slice(inbox_ids_to_add);
34    let conn = context.db();
35    // Load any missing updates from the network
36    load_identity_updates_for_client(context, &conn, &inbox_ids).await?;
37
38    let latest_sequence_id_map = conn.get_latest_sequence_id(&inbox_ids as &[&str])?;
39
40    // Get a list of all inbox IDs that have increased sequence_id for the group
41    let mut changed_inbox_ids =
42        inbox_ids
43            .iter()
44            .try_fold(HashMap::new(), |mut updates, inbox_id| {
45                match (
46                    latest_sequence_id_map.get(inbox_id as &str),
47                    existing_group_membership.get(inbox_id),
48                ) {
49                    // This is an update. We have a new sequence ID and an existing one
50                    (Some(latest_sequence_id), Some(current_sequence_id)) => {
51                        let latest_sequence_id_u64 = *latest_sequence_id as u64;
52                        if latest_sequence_id_u64.gt(current_sequence_id) {
53                            updates.insert(inbox_id.to_string(), latest_sequence_id_u64);
54                        }
55                    }
56                    // This is for new additions to the group
57                    (Some(latest_sequence_id), None) => {
58                        // This is the case for net new members to the group
59                        updates.insert(inbox_id.to_string(), *latest_sequence_id as u64);
60                    }
61                    (_, _) => {
62                        tracing::warn!(
63                            "Could not find existing sequence ID for inbox {}",
64                            inbox_id
65                        );
66                        return Err(GroupError::MissingSequenceId);
67                    }
68                }
69
70                Ok(updates)
71            })?;
72    let old_group_membership = existing_group_membership.clone();
73    let mut new_membership = old_group_membership.clone();
74    for (inbox_id, sequence_id) in changed_inbox_ids.iter() {
75        new_membership.add(inbox_id.clone(), *sequence_id);
76    }
77    for inbox_id in inbox_ids_to_remove {
78        new_membership.remove(inbox_id);
79    }
80
81    let changes_with_kps = calculate_membership_changes_with_keypackages(
82        context,
83        group_id,
84        &new_membership,
85        &old_group_membership,
86        retry_leaves,
87    )
88    .await?;
89
90    // A retry alone changes no entry: refresh the own entry at its current
91    // sequence so the intent is not empty. Apply keeps the higher sequence.
92    if changed_inbox_ids.is_empty()
93        && !changes_with_kps.new_installations.is_empty()
94        && let Some(&sequence_id) = existing_group_membership.get(context.inbox_id())
95    {
96        changed_inbox_ids.insert(context.inbox_id().to_string(), sequence_id);
97    }
98
99    // If we fail to fetch or verify all the added members' KeyPackage, return an error.
100    // skip if the inbox ids is 0 from the beginning
101    if !inbox_ids_to_add.is_empty()
102        && !changes_with_kps.failed_installations.is_empty()
103        && changes_with_kps.new_installations.is_empty()
104    {
105        return Err(GroupError::FailedToVerifyInstallations(
106            FailedInstallationIds(changes_with_kps.failed_installations.clone()),
107        ));
108    }
109
110    // Only an inbox the app asked to add may be inserted. Every other
111    // entry refreshes an existing member.
112    let refreshed_members = changed_inbox_ids
113        .keys()
114        .filter(|inbox_id| {
115            existing_group_membership.get(inbox_id.as_str()).is_some()
116                && !inbox_ids_to_add.contains(&inbox_id.as_str())
117        })
118        .cloned()
119        .collect();
120    Ok(UpdateGroupMembershipIntentData::new(
121        changed_inbox_ids,
122        inbox_ids_to_remove
123            .iter()
124            .map(|s| s.to_string())
125            .collect::<Vec<String>>(),
126        changes_with_kps.failed_installations,
127    )
128    .with_refreshed_members(refreshed_members))
129}
130
131impl<Context> MlsGroup<Context>
132where
133    Context: XmtpSharedContext,
134{
135    #[tracing::instrument(skip_all)]
136    pub(crate) async fn post_commit(&self) -> Result<(), GroupError> {
137        self.publish_required_welcomes().await
138    }
139
140    pub async fn maybe_update_installations(
141        &self,
142        update_interval_ns: Option<i64>,
143    ) -> Result<(), GroupError> {
144        let db = self.context.db();
145        let Some(stored_group) = db.find_group(&self.group_id)? else {
146            return Err(GroupError::NotFound(NotFound::GroupById(self.group_id)));
147        };
148        if stored_group.conversation_type.is_virtual() {
149            return Ok(());
150        }
151
152        // determine how long of an interval in time to use before updating list
153        let interval_ns = update_interval_ns.unwrap_or(SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS);
154
155        let now_ns = xmtp_common::time::now_ns();
156        let last_ns = db.get_installations_time_checked(&self.group_id)?;
157        let elapsed_ns = now_ns - last_ns;
158        if elapsed_ns > interval_ns && self.is_active()? {
159            self.add_missing_installations().await?;
160            db.update_installations_time_checked(&self.group_id)?;
161        }
162
163        Ok(())
164    }
165
166    /**
167     * Checks each member of the group for `IdentityUpdates` after their current sequence_id. If updates
168     * are found the method will construct an [`UpdateGroupMembershipIntentData`] and create a change
169     * to the [`GroupMembership`] that will add any missing installations.
170     *
171     * This is designed to handle cases where existing members have added a new installation to their inbox or revoked an installation
172     * and the group has not been updated to include it.
173     */
174    #[cfg_attr(any(test, feature = "test-utils"), tracing::instrument(level = "info", fields(inbox_id = %self.context.inbox_id()), skip_all))]
175    #[cfg_attr(
176        not(any(test, feature = "test-utils")),
177        tracing::instrument(level = "trace", skip_all)
178    )]
179    pub(crate) async fn add_missing_installations(&self) -> Result<(), GroupError> {
180        let intent_data = self.get_membership_update_intent(&[], &[]).await?;
181
182        // If there is nothing to do, stop here
183        if intent_data.is_empty() {
184            return Ok(());
185        }
186
187        log_missing_installations(
188            self.context.inbox_id(),
189            self.context.installation_id().as_slice(),
190            &intent_data,
191        );
192
193        let intent = QueueIntent::update_group_membership()
194            .data(intent_data)
195            .queue(self)?;
196
197        let _ = self.sync_until_intent_resolved(intent.id).await?;
198        Ok(())
199    }
200
201    /// Refresh membership, removing revoked installations but adding none.
202    // implements: SYNC-034
203    pub(crate) async fn remove_revoked_installations(&self) -> Result<(), GroupError> {
204        let mut intent_data = self.get_membership_update_intent(&[], &[]).await?;
205        if intent_data.is_empty() {
206            return Ok(());
207        }
208        intent_data.removal_only = true;
209        let intent = QueueIntent::update_group_membership()
210            .data(intent_data)
211            .queue(self)?;
212        self.sync_until_intent_resolved(intent.id).await?;
213        Ok(())
214    }
215
216    #[tracing::instrument(level = "trace", skip_all)]
217    /**
218     * get_membership_update_intent will query the network for any new [`IdentityUpdate`]s for any of the existing
219     * group members
220     *
221     * Callers may also include a list of added or removed inboxes
222     */
223    pub(crate) async fn get_membership_update_intent(
224        &self,
225        inbox_ids_to_add: &[InboxIdRef<'_>],
226        inbox_ids_to_remove: &[InboxIdRef<'_>],
227    ) -> Result<UpdateGroupMembershipIntentData, GroupError> {
228        let (existing_group_membership, retry_leaves) = self.with_group_snapshot(|group| {
229            Ok::<_, GroupError>((
230                extract_group_membership(group.extensions())?,
231                retry_leaves(group.extensions(), group.members()),
232            ))
233        })?;
234        membership_update_intent(
235            &self.context,
236            &self.group_id,
237            &existing_group_membership,
238            retry_leaves.as_deref(),
239            inbox_ids_to_add,
240            inbox_ids_to_remove,
241        )
242        .await
243    }
244
245    pub(crate) async fn initial_add(
246        context: &Context,
247        draft: &(
248            super::super::lifecycle::NewGroupId,
249            openmls::group::MlsGroupCreateConfig,
250        ),
251        ids: &[InboxIdRef<'_>],
252    ) -> Result<Option<UpdateGroupMembershipIntentData>, GroupError> {
253        use super::super::lifecycle::NewGroupId;
254        let id = match draft.0 {
255            NewGroupId::Keyed { group_id, .. } | NewGroupId::Placeholder(group_id) => group_id,
256        };
257        // The prepared dictionary supplies the stored group's membership and
258        // floor; its only leaf is this installation.
259        let extensions = draft.1.group_context_extensions();
260        let existing = extract_group_membership(extensions)?;
261        let leaves = retry_leaves(extensions, std::iter::empty())
262            .map(|_| vec![context.installation_id().to_vec()]);
263        let add =
264            membership_update_intent(context, &id, &existing, leaves.as_deref(), ids, &[]).await?;
265        if add.is_empty() {
266            return Ok(None);
267        }
268        let max_members = context
269            .server_configuration()
270            .configuration()
271            .mls
272            .max_group_members;
273        let resulting: HashSet<&str> = existing
274            .inbox_ids()
275            .into_iter()
276            .chain(add.membership_updates.keys().map(String::as_str))
277            .collect();
278        if resulting.len() > max_members {
279            return Err(GroupError::UserLimitExceeded);
280        }
281        Ok(Some(add))
282    }
283
284    pub(crate) fn intent_pending(&self, id: i32) -> Result<bool, GroupError> {
285        if self.membership_child_follow_up_failed(id)? {
286            return Ok(false);
287        }
288        let intent: Option<StoredGroupIntent> = self.context.db().fetch(&id)?;
289        match intent.map(|intent| intent.state) {
290            Some(IntentState::ToPublish | IntentState::Published | IntentState::Committed) => {
291                Ok(true)
292            }
293            Some(IntentState::Processed) => Ok(!self.pending_membership_children(id)?.is_empty()),
294            _ => Ok(false),
295        }
296    }
297
298    #[cfg(test)]
299    pub(in crate::groups) async fn send_welcomes(
300        &self,
301        action: SendWelcomesAction,
302        message_cursor: Option<i64>,
303    ) -> Result<(), GroupError> {
304        let message_cursor = u64::try_from(message_cursor.unwrap_or(0))
305            .map_err(|_| xmtp_proto::ConversionError::Unspecified("negative Welcome cursor"))?;
306        let units = crate::state_tx::state_write(self.context.mls_storage(), |_tx| {
307            self.prepare_welcome_envelopes(action, message_cursor)?
308                .into_iter()
309                .map(PublishUnit::single)
310                .collect::<Result<Vec<_>, _>>()
311                .map(Continue)
312                .map_err(GroupError::from)
313        })?
314        .into_continued();
315        self.context.api().publish_units(units).await?;
316        Ok(())
317    }
318
319    /// Provides hmac keys for a range of epochs around current epoch
320    /// `group.hmac_keys(-1..=1)`` will provide 3 keys consisting of last epoch, current epoch, and next epoch
321    /// `group.hmac_keys(0..=0) will provide 1 key, consisting of only the current epoch
322    #[tracing::instrument(level = "trace", skip_all)]
323    pub fn hmac_keys(
324        &self,
325        epoch_delta_range: RangeInclusive<i64>,
326    ) -> Result<Zeroizing<Vec<HmacKey>>, StorageError> {
327        crate::state_tx::state_write_with_events(
328            self.context.mls_storage(),
329            self.context.events(),
330            |tx, events| {
331                self.hmac_keys_in(tx.storage().db(), events, epoch_delta_range)
332                    .map(Continue)
333            },
334        )
335        .map(TransactionOutcome::into_continued)
336    }
337
338    /// Derive the group's HMAC key for each epoch in `epoch_delta_range`,
339    /// relative to the current HMAC epoch. Generates and stores a root key
340    /// when none is stored, and reports it to `events`, which the caller
341    /// emits once its transaction commits.
342    ///
343    /// The vector is wiped on drop, including slots that `pop()` vacated.
344    // implements: PUSH-258
345    pub(crate) fn hmac_keys_in(
346        &self,
347        conn: impl xmtp_db::DbQuery,
348        events: &impl xmtp_events::EventWriter<crate::subscriptions::internal::InternalEvent>,
349        epoch_delta_range: RangeInclusive<i64>,
350    ) -> Result<Zeroizing<Vec<HmacKey>>, StorageError> {
351        // implements: SYNC-026
352        let (root_key, made) = ensure_root_key(&conn)?;
353        if made {
354            crate::subscriptions::internal::emit_root_key_changed(events, &conn, true)?;
355        }
356        let root_key = root_key.as_slice();
357        let group_id = self.group_id.as_ref();
358        // Sized up front: growing would free an unwiped partial copy.
359        let mut ikm = Zeroizing::new(Vec::with_capacity(root_key.len() + group_id.len()));
360        ikm.extend_from_slice(root_key);
361        ikm.extend_from_slice(group_id);
362        let hkdf = Hkdf::<Sha256>::new(Some(HMAC_SALT), &ikm);
363
364        let current_epoch = hmac_epoch();
365        let mut result = Zeroizing::new(Vec::with_capacity(epoch_delta_range.size_hint().0));
366        for delta in epoch_delta_range {
367            let epoch = current_epoch + delta;
368
369            let mut info = group_id.to_vec();
370            info.extend(&epoch.to_le_bytes());
371
372            let mut key = HmacKey {
373                key: [0; 42],
374                epoch,
375            };
376            hkdf.expand(&info, &mut key.key).expect("Length is correct");
377
378            result.push(key);
379        }
380
381        Ok(result)
382    }
383
384    #[cfg(test)]
385    #[tracing::instrument(level = "trace", skip_all)]
386    pub(in crate::groups) fn prepare_group_messages(
387        &self,
388        payloads: Vec<(&[u8], bool)>,
389    ) -> Result<Vec<PublishUnit>, GroupError> {
390        crate::state_tx::state_write_with_events(
391            self.context.mls_storage(),
392            self.context.events(),
393            |tx, events| {
394                tx.with_group(self.group_id, |group, storage| {
395                    let envelopes =
396                        self.prepare_group_envelopes_in(storage.db(), events, group, payloads)?;
397                    Ok::<_, GroupError>(Continue(vec![PublishUnit::new(envelopes)?]))
398                })
399            },
400        )
401        .map(TransactionOutcome::into_continued)
402    }
403
404    /// One signed group message envelope per payload, for `group` as the
405    /// caller's writer loaded it. The keys come from the group's own
406    /// `GROUP_ROOT_KEY`, and the root must derive the group's id, so a group
407    /// without a root (a Restored placeholder or a legacy group) fails here,
408    /// before anything is saved or sent.
409    pub(super) fn prepare_group_envelopes_in(
410        &self,
411        conn: impl xmtp_db::DbQuery,
412        events: &impl xmtp_events::EventWriter<crate::subscriptions::internal::InternalEvent>,
413        group: &OpenMlsGroup,
414        payloads: Vec<(&[u8], bool)>,
415    ) -> Result<Vec<ClientEnvelope>, GroupError> {
416        let keys = xmtp_mls_group_state::app_data::group_root::check_group_root(
417            group.extensions(),
418            group.group_id().as_slice(),
419        )
420        .map_err(OutgoingPreparationError::GroupKeys)?;
421        let hmac_keys = self.hmac_keys_in(conn, events, 0..=0)?;
422        let hmac_key = hmac_keys.last().expect("Range of count 1 was provided.");
423        let signed = group_message_envelopes(hmac_key, &keys, payloads);
424        #[cfg(test)]
425        let signed = sign_fault::apply(group.group_id().as_slice(), signed);
426        signed.map_err(|error| OutgoingPreparationError::SignGroupMessage(error).into())
427    }
428}
429
430/// Test-only faults at the result of signing a group's messages, by group id.
431/// They let tests drive a failed self-check, or an envelope the local
432/// admission parse refuses, through preparation.
433#[cfg(test)]
434pub(crate) mod sign_fault {
435    use super::*;
436    use std::collections::HashMap;
437    use std::sync::{Arc, LazyLock, Mutex};
438
439    pub(crate) type SignFault = Arc<
440        dyn Fn(Result<Vec<ClientEnvelope>, SealError>) -> Result<Vec<ClientEnvelope>, SealError>
441            + Send
442            + Sync,
443    >;
444
445    static FAULTS: LazyLock<Mutex<HashMap<Vec<u8>, SignFault>>> = LazyLock::new(Default::default);
446
447    /// Pass every later signing result of `group_id` through `fault`.
448    pub(crate) fn set(group_id: &[u8], fault: SignFault) {
449        FAULTS
450            .lock()
451            .expect("sign faults lock")
452            .insert(group_id.to_vec(), fault);
453    }
454
455    pub(super) fn apply(
456        group_id: &[u8],
457        signed: Result<Vec<ClientEnvelope>, SealError>,
458    ) -> Result<Vec<ClientEnvelope>, SealError> {
459        let fault = FAULTS
460            .lock()
461            .expect("sign faults lock")
462            .get(group_id)
463            .cloned();
464        match fault {
465            Some(fault) => fault(signed),
466            None => signed,
467        }
468    }
469}
470
471/// One group message envelope per payload, whose `sender_hmac` is the
472/// HMAC-SHA256 of the payload under `hmac_key` and whose `signature` is the
473/// group signing key's signature over every other field. Signing runs after
474/// `sender_hmac` is set and passes the self-check before an envelope is
475/// returned, so the bytes a caller saves are the bytes the backend verifies.
476// implements: PUSH-258, GMOD-041
477pub(super) fn group_message_envelopes(
478    hmac_key: &HmacKey,
479    keys: &GroupKeys,
480    payloads: Vec<(&[u8], bool)>,
481) -> Result<Vec<ClientEnvelope>, SealError> {
482    let sender_hmac =
483        Hmac::<Sha256>::new_from_slice(&hmac_key.key).expect("HMAC can take key of any size");
484
485    let mut result = vec![];
486    for (payload, should_push) in payloads {
487        let mut sender_hmac = sender_hmac.clone();
488        sender_hmac.update(payload);
489        let sender_hmac = sender_hmac.finalize();
490
491        let mut message = BackendGroupMessage {
492            data: payload.to_vec(),
493            sender_hmac: sender_hmac.into_bytes().to_vec(),
494            should_push,
495            signature: Vec::new(),
496        };
497        sign_group_message(keys, &mut message)?;
498        result.push(ClientEnvelope {
499            payload: Some(Payload::GroupMessage(message)),
500        });
501    }
502    Ok(result)
503}
504
505#[cfg(all(test, not(target_arch = "wasm32")))]
506mod tests {
507    use super::*;
508    use xmtp_logging::{Level, test_logging::LogCapture};
509
510    #[xmtp_common::test(unwrap_try = true)]
511    fn missing_installation_log_omits_full_ids() {
512        let failed_id = (32_u8..64).collect::<Vec<_>>();
513        let local_id = (64_u8..96).collect::<Vec<_>>();
514        let intent = UpdateGroupMembershipIntentData::new(
515            HashMap::from([("updated-inbox".to_string(), 7)]),
516            vec!["removed-inbox".to_string()],
517            vec![failed_id.clone()],
518        );
519        let encoded: Vec<u8> = intent.into();
520        let restored = UpdateGroupMembershipIntentData::try_from(encoded)?;
521        assert_eq!(restored.failed_installations, vec![failed_id.clone()]);
522        let capture = LogCapture::new(Level::Debug);
523        tracing::dispatcher::with_default(&capture.dispatch(), || {
524            log_missing_installations("local-inbox", &local_id, &restored);
525        });
526        let output = capture.output();
527        assert!(!output.contains(&format!("{failed_id:?}")));
528        assert!(!output.contains(&hex::encode(&failed_id)));
529        assert!(!output.contains(&hex::encode(&local_id)));
530        let record: serde_json::Value = serde_json::from_str(output.trim())?;
531        assert_eq!(record["message"], "Adding missing installations");
532        assert_eq!(record["membership_update_count"], 1);
533        assert_eq!(record["removed_member_count"], 1);
534        assert_eq!(record["failed_installation_count"], 1);
535    }
536}