1use 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 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_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 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 (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 (Some(latest_sequence_id), None) => {
58 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 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 !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 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 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 #[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 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 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 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 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 #[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 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 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 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 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#[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 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
471pub(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}