1#[cfg(test)]
2mod identity_tests;
3use crate::{
4 context::XmtpSharedContext,
5 identity_updates::{
6 IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
7 MissingProofs, get_installation_diff_local, require_association_state,
8 required_leaf_removals,
9 },
10};
11use openmls::{
12 extensions::Extensions,
13 group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit},
14 messages::proposals::Proposal,
15 prelude::{LeafNodeIndex, Sender},
16};
17
18use super::traits::FromWith;
19use serde::Serialize;
20use std::collections::{HashMap, HashSet};
21use thiserror::Error;
22use xmtp_common::{retry::RetryableError, retryable, snippet::Snippet};
23use xmtp_db::local_commit_log::CommitType;
24use xmtp_db::{DbQuery, StorageError};
25#[cfg(doc)]
26use xmtp_id::associations::AssociationState;
27use xmtp_id::{InboxId, associations::MemberIdentifier};
28use xmtp_mls_group_state::{
29 app_data::component_source::{ComponentSourceError, read_from_app_data_dict},
30 group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
31 group_mutable_metadata::{GroupMutableMetadata, GroupMutableMetadataError},
32 libxmtp_version::{InvalidVersionFormat, LibXMTPVersion},
33};
34use xmtp_mls_validation::{
35 ForkActors,
36 commit::{
37 CommitParticipant, CommitRuleError, Inbox, MembershipValidationInfo, MetadataChanges,
38 ProposalChanges, build_inbox_with_proposer, extract_commit_participant,
39 extract_committer_and_proposers, extract_group_membership_with_failures,
40 extract_readded_installations, get_proposal_changes, inbox_id_from_credential, leaf_owners,
41 metadata_changes_between, read_committed_metadata, read_post_commit_mutable_metadata,
42 validate_app_data_update_proposals_in_commit, validate_committed_proposals,
43 validate_identity_sequence_order, validate_membership_diff, validate_one_app_data_update,
44 validate_sync_membership,
45 },
46 group_membership::{FailedInstallations, GroupMembership},
47 group_permissions::{GroupMutablePermissionsError, MembershipPolicy, PolicySet},
48};
49use xmtp_proto::xmtp::mls::message_contents::{
50 GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
51 group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
52};
53
54#[derive(Debug, Error)]
57pub enum CommitValidationError {
58 #[error("Committed group state is invalid: {0}")]
60 InstalledState(#[source] Box<CommitValidationError>),
61 #[error(transparent)]
63 IdentityDependency(#[from] IdentityDependencyError),
64 #[error(transparent)]
65 InstallationDiff(#[from] InstallationDiffError),
66 #[error(transparent)]
67 StorageError(#[from] StorageError),
68 #[error(transparent)]
70 Rule(#[from] CommitRuleError),
71}
72
73impl crate::worker::NeedsDbReconnect for CommitValidationError {
74 #[deny(clippy::wildcard_enum_match_arm)]
75 fn needs_db_reconnect(&self) -> bool {
76 match self {
77 Self::InstalledState(error) => error.needs_db_reconnect(),
78 Self::IdentityDependency(error) => error.needs_db_reconnect(),
79 Self::InstallationDiff(error) => error.needs_db_reconnect(),
80 Self::StorageError(error) => error.db_needs_connection(),
81 Self::Rule(_) => false,
82 }
83 }
84}
85
86impl RetryableError for CommitValidationError {
87 fn is_retryable(&self) -> bool {
88 match self {
89 CommitValidationError::IdentityDependency(error) => retryable!(error),
90 CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
91 CommitValidationError::Rule(error) => retryable!(error),
92 _ => false,
93 }
94 }
95
96 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
97 match self {
98 CommitValidationError::IdentityDependency(error) => error.rate_limit_delay(),
99 CommitValidationError::InstallationDiff(diff_error) => diff_error.rate_limit_delay(),
100 _ => None,
101 }
102 }
103}
104
105impl CommitValidationError {
106 pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
108 Self::InstalledState(Box::new(error.into()))
109 }
110
111 pub(crate) fn is_safe_rejection(&self) -> bool {
115 match self {
116 Self::IdentityDependency(
117 IdentityDependencyError::MissingReference(_)
118 | IdentityDependencyError::InvalidSequence(_),
119 ) => true,
120 Self::Rule(error) => error.is_safe_rejection(),
121 Self::InstalledState(_)
122 | Self::IdentityDependency(_)
123 | Self::InstallationDiff(_)
124 | Self::StorageError(_) => false,
125 }
126 }
127}
128
129macro_rules! from_rule_error {
132 ($($source:ty),* $(,)?) => {
133 $(impl From<$source> for CommitValidationError {
134 fn from(error: $source) -> Self {
135 Self::Rule(CommitRuleError::from(error))
136 }
137 })*
138 };
139}
140from_rule_error!(
141 InvalidVersionFormat,
142 GroupMetadataError,
143 GroupMutableMetadataError,
144 GroupMutablePermissionsError,
145 ComponentSourceError,
146);
147
148#[derive(Debug, Clone, Serialize)]
165pub struct ValidatedCommit {
166 pub actor: CommitParticipant,
168 pub proposers: Vec<CommitParticipant>,
170 pub added_inboxes: Vec<Inbox>,
171 pub removed_inboxes: Vec<Inbox>,
172 pub readded_installations: HashSet<Vec<u8>>,
173 pub metadata_changes: MetadataChanges,
174 pub metadata_component_ids: Vec<u16>,
175 pub installations_changed: bool,
176 pub permissions_changed: bool,
177 pub dm_members: Option<DmMembers<String>>,
178}
179
180impl ValidatedCommit {
181 #[cfg(test)]
183 pub async fn from_staged_commit(
184 context: &impl XmtpSharedContext,
185 staged_commit: &StagedCommit,
186 committer_leaf_index: LeafNodeIndex,
187 openmls_group: &OpenMlsGroup,
188 ) -> Result<Self, CommitValidationError> {
189 loop {
190 match Self::from_staged_commit_local(
191 context,
192 &context.db(),
193 staged_commit,
194 committer_leaf_index,
195 openmls_group,
196 u64::MAX,
197 ) {
198 Err(CommitValidationError::IdentityDependency(IdentityDependencyError::Need(
199 requirements,
200 ))) => {
201 crate::identity_updates::resolve_identity_requirements(context, &requirements)
202 .await?;
203 }
204 result => return result,
205 }
206 }
207 }
208
209 pub(crate) fn from_staged_commit_local(
214 context: &impl XmtpSharedContext,
215 conn: &impl DbQuery,
216 staged_commit: &StagedCommit,
217 committer_leaf_index: LeafNodeIndex,
218 openmls_group: &OpenMlsGroup,
219 envelope_sequence: u64,
220 ) -> Result<Self, CommitValidationError> {
221 if let Some(min_version) =
235 xmtp_mls_group_state::app_data::protocol_floor::committed_floor_exceeding(
236 openmls_group,
237 context.version_info().pkg_semver(),
238 )
239 {
240 return Err(CommitValidationError::Rule(
241 CommitRuleError::ProtocolVersionTooLow(min_version),
242 ));
243 }
244 validate_committed_proposals(
246 staged_commit
247 .queued_proposals()
248 .map(QueuedProposal::proposal),
249 |id| read_from_app_data_dict(id, openmls_group).is_some(),
250 )?;
251 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
252 .map_err(CommitValidationError::installed_state)?;
253
254 let group_permissions =
255 super::group_permissions::policy_set_from_dictionary(openmls_group.extensions())
256 .map_err(|error| {
257 CommitValidationError::installed_state(CommitValidationError::Rule(
258 CommitRuleError::GroupMutablePermissions(error),
259 ))
260 })?;
261 let leaf_owners =
262 leaf_owners(openmls_group.members()).map_err(CommitValidationError::installed_state)?;
263 let current_group_members = leaf_owners.keys().cloned().collect();
264
265 let registry = super::app_data::load_component_registry(openmls_group)
268 .map_err(CommitValidationError::installed_state)?;
269 let min_version_bytes =
270 xmtp_mls_group_state::app_data::component_source::read_post_commit_component_bytes(
271 xmtp_mls_group_state::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
272 openmls_group,
273 staged_commit,
274 ®istry,
275 )
276 .map_err(xmtp_mls_group_state::group_mutable_metadata::GroupMutableMetadataError::from)?;
277 let minimum_supported_protocol_version = match min_version_bytes {
278 Some(bytes) => Some(std::str::from_utf8(&bytes).map(str::to_owned).map_err(|e| {
279 CommitValidationError::Rule(CommitRuleError::GroupMutableMetadata(
280 GroupMutableMetadataError::MalformedComponent {
281 component_id: Some(
282 xmtp_mls_group_state::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
283 ),
284 reason: format!("invalid utf-8: {e}"),
285 },
286 ))
287 })?),
288 None => None,
289 };
290 let (actor, proposers) = extract_committer_and_proposers(
294 staged_commit,
295 committer_leaf_index,
296 openmls_group,
297 &immutable_metadata,
298 &mutable_metadata,
299 )?;
300
301 validate_app_data_update_proposals_in_commit(
307 staged_commit,
308 openmls_group,
309 &immutable_metadata,
310 &mutable_metadata,
311 ®istry,
312 )?;
313
314 let ProposalChanges {
316 mut added_installations,
317 self_added_installations,
318 mut removed_installations,
319 mut credentials_to_verify,
320 added_inbox_proposers,
321 removed_inbox_proposers,
322 } = get_proposal_changes(
323 staged_commit,
324 openmls_group,
325 &immutable_metadata,
326 &mutable_metadata,
327 )?;
328
329 if let Some(leaf_node) = staged_commit.update_path_leaf_node() {
334 let path_leaf = CommitParticipant::from_leaf_node(
335 leaf_node,
336 &immutable_metadata,
337 &mutable_metadata,
338 )?;
339 if path_leaf.inbox_id != actor.inbox_id {
340 return Err(CommitValidationError::Rule(
341 CommitRuleError::InboxValidationFailed(path_leaf.inbox_id),
342 ));
343 }
344 credentials_to_verify.push(path_leaf);
345 }
346
347 let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
351 conn,
352 staged_commit,
353 openmls_group,
354 envelope_sequence,
355 &added_inbox_proposers,
356 &removed_inbox_proposers,
357 )?;
358
359 let ExpectedDiff {
360 old_group_membership,
361 new_group_membership,
362 old_failed,
363 new_failed,
364 expected_installation_diff,
365 added_inboxes,
366 removed_inboxes,
367 } = expected_diff;
368
369 let installations_changed =
370 !added_installations.is_empty() || !removed_installations.is_empty();
371
372 let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
373 .failed_installations
374 .iter()
375 .cloned()
376 .collect();
377
378 let required_removals = required_leaf_removals(
379 conn,
380 &old_group_membership,
381 &new_group_membership,
382 &expected_installation_diff.removed_installations,
383 &leaf_owners,
384 )?;
385
386 let readded_installations = extract_readded_installations(
388 ForkActors::new(&immutable_metadata, &mutable_metadata)
389 .readd_authority(&actor.inbox_id),
390 &mut added_installations,
391 &mut removed_installations,
392 &mut failed_installations,
393 );
394 expected_diff_matches_commit(
396 &expected_installation_diff,
397 added_installations,
398 &self_added_installations,
399 removed_installations,
400 &required_removals,
401 ¤t_group_members,
402 )?;
403 validate_new_failed_installations(
404 conn,
405 &new_group_membership,
406 &old_failed,
407 &new_failed,
408 &leaf_owners,
409 )?;
410 credentials_to_verify.push(actor.clone());
411
412 for participant in credentials_to_verify {
419 let inbox_id = &participant.inbox_id;
420 let sequence_id = match new_group_membership.get(inbox_id) {
421 None => {
422 return Err(CommitValidationError::Rule(
423 CommitRuleError::SubjectDoesNotExist,
424 ));
425 }
426 Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
427 let known_leaf = openmls_group.members().any(|member| {
431 member.signature_key == participant.installation_id
432 && inbox_id_from_credential(&member.credential)
433 .is_ok_and(|known_inbox| known_inbox == *inbox_id)
434 });
435 if !known_leaf {
436 return Err(CommitValidationError::Rule(
437 CommitRuleError::InboxValidationFailed(inbox_id.clone()),
438 ));
439 }
440 continue;
441 }
442 Some(sequence_id) => *sequence_id,
443 };
444 let inbox_state = require_association_state(
445 conn,
446 &IdentityRequirement {
447 inbox_id: inbox_id.clone(),
448 sequence_id,
449 },
450 )?;
451
452 if inbox_state
453 .get(&MemberIdentifier::installation(participant.installation_id))
454 .is_none()
455 {
456 return Err(CommitValidationError::Rule(
457 CommitRuleError::InboxValidationFailed(participant.inbox_id),
458 ));
459 }
460 }
461
462 let membership = MembershipValidationInfo {
463 actor: &actor,
464 added_inboxes: &added_inboxes,
465 removed_inboxes: &removed_inboxes,
466 dm_members: immutable_metadata.dm_members.as_ref(),
467 };
468 if !group_permissions.policies.evaluate_membership(&membership) {
469 return Err(CommitValidationError::Rule(
470 CommitRuleError::InsufficientPermissions,
471 ));
472 }
473 if let Some(min_version) = &minimum_supported_protocol_version {
474 let current_version = context.version_info().pkg_semver();
475 let min_supported_version = LibXMTPVersion::parse(min_version)?;
476 tracing::info!(
477 "Validating commit with min_supported_version: {:?}, current_version: {:?}",
478 min_supported_version,
479 current_version
480 );
481
482 if min_supported_version > *current_version {
483 return Err(CommitValidationError::Rule(
484 CommitRuleError::ProtocolVersionTooLow(min_version.clone()),
485 ));
486 }
487 }
488 let post_metadata =
492 read_post_commit_mutable_metadata(openmls_group, staged_commit, ®istry)?;
493 let metadata_changes =
494 metadata_changes_between(&immutable_metadata, &mutable_metadata, &post_metadata);
495 let mut proposed_component_ids: Vec<u16> = staged_commit
496 .app_data_update_proposals()
497 .map(|queued| queued.app_data_update_proposal().component_id())
498 .collect();
499 proposed_component_ids.sort_unstable();
500 proposed_component_ids.dedup();
501 let mut metadata_component_ids = Vec::new();
502 for raw_id in proposed_component_ids {
503 let id = xmtp_mls_group_state::app_data::component_id::ComponentId::from(raw_id);
504 let before = read_from_app_data_dict(id, openmls_group);
505 let after =
508 xmtp_mls_group_state::app_data::component_source::read_post_commit_component_bytes(
509 id,
510 openmls_group,
511 staged_commit,
512 ®istry,
513 )
514 .map_err(GroupMutableMetadataError::from)?;
515 if before != after.as_deref().map(Vec::as_slice) {
516 metadata_component_ids.push(raw_id);
517 }
518 }
519 let permissions_changed = metadata_component_ids.contains(
520 &xmtp_mls_group_state::app_data::component_id::ComponentId::COMPONENT_REGISTRY.as_u16(),
521 );
522 Ok(Self {
523 actor,
524 proposers,
525 added_inboxes,
526 removed_inboxes,
527 readded_installations,
528 metadata_changes,
529 metadata_component_ids,
530 installations_changed,
531 permissions_changed,
532 dm_members: immutable_metadata.dm_members.clone(),
533 })
534 }
535
536 pub fn debug_commit_type(&self) -> CommitType {
539 let metadata_info = &self.metadata_changes;
540 if !self.added_inboxes.is_empty()
541 || !self.removed_inboxes.is_empty()
542 || self.installations_changed
543 {
544 CommitType::UpdateGroupMembership
545 } else if self.permissions_changed {
546 CommitType::UpdatePermission
547 } else if !metadata_info.admins_added.is_empty()
548 || !metadata_info.admins_removed.is_empty()
549 || !metadata_info.super_admins_added.is_empty()
550 || !metadata_info.super_admins_removed.is_empty()
551 {
552 CommitType::UpdateAdminList
553 } else if !metadata_info.metadata_field_changes.is_empty() {
554 CommitType::MetadataUpdate
555 } else {
556 CommitType::KeyUpdate
557 }
558 }
559
560 pub fn is_empty(&self) -> bool {
561 self.added_inboxes.is_empty()
562 && self.removed_inboxes.is_empty()
563 && self.metadata_changes.is_empty()
564 }
565
566 pub fn actor_inbox_id(&self) -> InboxId {
567 self.actor.inbox_id.clone()
568 }
569
570 pub fn actor_installation_id(&self) -> Vec<u8> {
571 self.actor.installation_id.clone()
572 }
573}
574
575impl From<ValidatedCommit> for GroupMembershipChanges {
576 fn from(_commit: ValidatedCommit) -> Self {
577 GroupMembershipChanges {
580 members_added: vec![],
581 members_removed: vec![],
582 installations_added: vec![],
583 installations_removed: vec![],
584 }
585 }
586}
587
588struct ExpectedDiff {
590 old_group_membership: GroupMembership,
592 new_group_membership: GroupMembership,
594 old_failed: FailedInstallations,
595 new_failed: FailedInstallations,
596 expected_installation_diff: InstallationDiff,
598 added_inboxes: Vec<Inbox>,
599 removed_inboxes: Vec<Inbox>,
600}
601
602impl ExpectedDiff {
603 #[allow(clippy::too_many_arguments)]
606 pub(super) fn from_staged_commit_with_proposers(
607 conn: &impl DbQuery,
608 staged_commit: &StagedCommit,
609 openmls_group: &OpenMlsGroup,
610 envelope_sequence: u64,
611 added_inbox_proposers: &HashMap<String, CommitParticipant>,
612 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
613 ) -> Result<Self, CommitValidationError> {
614 let extensions = openmls_group.extensions();
615 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
616 .map_err(CommitValidationError::installed_state)?;
617
618 let expected_diff = Self::extract_expected_diff_with_proposers(
619 conn,
620 staged_commit,
621 envelope_sequence,
622 extensions,
623 &immutable_metadata,
624 &mutable_metadata,
625 added_inbox_proposers,
626 removed_inbox_proposers,
627 )?;
628
629 Ok(expected_diff)
630 }
631
632 #[allow(clippy::too_many_arguments)]
635 fn extract_expected_diff_with_proposers(
636 conn: &impl DbQuery,
637 staged_commit: &StagedCommit,
638 envelope_sequence: u64,
639 existing_group_extensions: &Extensions<GroupContext>,
640 immutable_metadata: &GroupMetadata,
641 mutable_metadata: &GroupMutableMetadata,
642 added_inbox_proposers: &HashMap<String, CommitParticipant>,
643 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
644 ) -> Result<ExpectedDiff, CommitValidationError> {
645 let (old_group_membership, old_failed) =
646 extract_group_membership_with_failures(existing_group_extensions)
647 .map_err(CommitValidationError::installed_state)?;
648 let (new_group_membership, new_failed) =
649 extract_group_membership_with_failures(staged_commit.group_context().extensions())?;
650 validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
651 let membership_diff = old_group_membership.diff(&new_group_membership);
652
653 validate_membership_diff(
654 &old_group_membership,
655 &new_group_membership,
656 &membership_diff,
657 )?;
658 validate_sync_membership(immutable_metadata, &new_group_membership)?;
659
660 let added_inboxes = membership_diff
661 .added_inboxes
662 .iter()
663 .map(|inbox_id| {
664 let proposer = added_inbox_proposers
665 .get(inbox_id.as_str())
666 .cloned()
667 .ok_or(CommitValidationError::Rule(
668 CommitRuleError::ProposerNotFound,
669 ))?;
670 Ok(build_inbox_with_proposer(
671 inbox_id,
672 immutable_metadata,
673 mutable_metadata,
674 proposer,
675 ))
676 })
677 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
678
679 let removed_inboxes = membership_diff
680 .removed_inboxes
681 .iter()
682 .map(|inbox_id| {
683 let proposer = removed_inbox_proposers
684 .get(inbox_id.as_str())
685 .cloned()
686 .ok_or(CommitValidationError::Rule(
687 CommitRuleError::ProposerNotFound,
688 ))?;
689 Ok(build_inbox_with_proposer(
690 inbox_id,
691 immutable_metadata,
692 mutable_metadata,
693 proposer,
694 ))
695 })
696 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
697
698 let expected_installation_diff = get_installation_diff_local(
699 conn,
700 &old_group_membership,
701 &new_group_membership,
702 &membership_diff,
703 )?;
704
705 Ok(ExpectedDiff {
706 old_group_membership,
707 new_group_membership,
708 old_failed,
709 new_failed,
710 expected_installation_diff,
711 added_inboxes,
712 removed_inboxes,
713 })
714 }
715}
716
717fn validate_new_failed_installations(
720 conn: &impl DbQuery,
721 membership: &GroupMembership,
722 before: &FailedInstallations,
723 after: &FailedInstallations,
724 leaf_owners: &HashMap<Vec<u8>, String>,
725) -> Result<(), CommitValidationError> {
726 let mut by_inbox: HashMap<&str, Vec<&[u8]>> = HashMap::new();
727 for (inbox, key) in after.unproven_new_failures(before, leaf_owners) {
728 by_inbox.entry(inbox).or_default().push(key);
729 }
730 let mut missing = MissingProofs::default();
731 for (inbox, keys) in by_inbox {
732 let sequence_id = membership.get(inbox).copied().unwrap_or(0);
733 if sequence_id == 0 {
734 return Err(CommitRuleError::InboxValidationFailed(inbox.to_string()).into());
735 }
736 let Some(state) = missing.read(
737 conn,
738 &IdentityRequirement {
739 inbox_id: inbox.to_string(),
740 sequence_id,
741 },
742 )?
743 else {
744 continue;
745 };
746 let installations: HashSet<_> = state.installation_ids().into_iter().collect();
747 if keys.iter().any(|key| !installations.contains(*key)) {
748 return Err(CommitRuleError::InboxValidationFailed(inbox.to_string()).into());
749 }
750 }
751 missing.check()?;
752 Ok(())
753}
754
755fn expected_diff_matches_commit(
760 expected_diff: &InstallationDiff,
761 added_installations: HashSet<Vec<u8>>,
762 self_added_installations: &HashSet<Vec<u8>>,
763 removed_installations: HashSet<Vec<u8>>,
764 required_removals: &HashSet<Vec<u8>>,
765 existing_installation_ids: &HashSet<Vec<u8>>,
766) -> Result<(), CommitValidationError> {
767 let unknown_adds = added_installations
773 .into_iter()
774 .filter(|installation_id| {
775 !expected_diff
776 .added_installations
777 .contains_key(installation_id)
778 && !existing_installation_ids.contains(installation_id)
779 && !self_added_installations.contains(installation_id)
781 })
782 .collect::<Vec<Vec<u8>>>();
783 if !unknown_adds.is_empty() {
784 return Err(CommitValidationError::Rule(
785 CommitRuleError::UnexpectedInstallationAdded(unknown_adds),
786 ));
787 }
788
789 if &removed_installations != required_removals {
790 let unexpected: Vec<_> = removed_installations
791 .symmetric_difference(required_removals)
792 .cloned()
793 .collect();
794
795 return Err(CommitValidationError::Rule(
796 CommitRuleError::UnexpectedInstallationsRemoved(unexpected),
797 ));
798 }
799
800 Ok(())
801}
802
803pub fn validate_proposal(
809 proposal: &QueuedProposal,
810 openmls_group: &OpenMlsGroup,
811 policy_set: &PolicySet,
812 immutable_metadata: &GroupMetadata,
813 mutable_metadata: &GroupMutableMetadata,
814) -> Result<(), CommitValidationError> {
815 let proposer = match proposal.sender() {
817 Sender::Member(leaf_index) => extract_commit_participant(
818 leaf_index,
819 openmls_group,
820 immutable_metadata,
821 mutable_metadata,
822 )?,
823 Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
824 return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
826 }
827 };
828
829 let unsupported_error = || {
830 CommitValidationError::Rule(CommitRuleError::UnsupportedProposalType(
831 proposal.proposal().proposal_type(),
832 ))
833 };
834
835 match proposal.proposal() {
837 Proposal::Add(add_proposal) => {
838 let added_inbox_id =
840 inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
841 let inbox = Inbox {
842 inbox_id: added_inbox_id.clone(),
843 is_creator: false,
844 is_admin: false,
845 is_super_admin: false,
846 proposer: Some(proposer.clone()),
847 };
848 if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
849 let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
851 (added_inbox_id == dm.member_one_inbox_id.as_ref()
852 || added_inbox_id == dm.member_two_inbox_id.as_ref())
853 && added_inbox_id != proposer.inbox_id
854 });
855 if !is_dm_add {
856 tracing::warn!(
857 proposer_inbox_id = %proposer.inbox_id,
858 "Proposal rejected: proposer does not have permission to add members"
859 );
860 return Err(CommitValidationError::Rule(
861 CommitRuleError::InsufficientPermissions,
862 ));
863 }
864 }
865 }
866 Proposal::Remove(remove_proposal) => {
867 let removed_member = openmls_group.member_at(remove_proposal.removed()).ok_or(
870 CommitValidationError::Rule(CommitRuleError::SubjectDoesNotExist),
871 )?;
872 let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
873 let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
874 let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
875
876 if removed_is_super_admin {
878 tracing::warn!(
879 proposer_inbox_id = %proposer.inbox_id,
880 removed_inbox_id = %removed_inbox_id,
881 "Proposal rejected: cannot remove super admin"
882 );
883 return Err(CommitValidationError::Rule(
884 CommitRuleError::InsufficientPermissions,
885 ));
886 }
887
888 let removed_inbox = Inbox {
889 inbox_id: removed_inbox_id.clone(),
890 is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
891 is_admin: removed_is_admin,
892 is_super_admin: removed_is_super_admin,
893 proposer: Some(proposer.clone()),
894 };
895
896 if !policy_set
897 .remove_member_policy
898 .evaluate(&proposer, &removed_inbox)
899 {
900 tracing::warn!(
901 proposer_inbox_id = %proposer.inbox_id,
902 removed_inbox_id = %removed_inbox_id,
903 "Proposal rejected: proposer does not have permission to remove members"
904 );
905 return Err(CommitValidationError::Rule(
906 CommitRuleError::InsufficientPermissions,
907 ));
908 }
909 }
910 Proposal::GroupContextExtensions(_) => return Err(unsupported_error()),
911 Proposal::Update(update_proposal) => {
912 let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
915 if new_inbox_id != proposer.inbox_id {
916 tracing::warn!(
917 proposer_inbox_id = %proposer.inbox_id,
918 proposer_installation_id = %proposer.installation_id.as_slice().snippet(),
919 leaf_index = ?proposal.sender(),
920 new_inbox_id = %new_inbox_id,
921 new_installation_id = %update_proposal.leaf_node().signature_key().as_slice().snippet(),
922 "Update proposal rejected: new leaf node credential does not match proposer"
923 );
924 return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
925 }
926 }
927 Proposal::PreSharedKey(_) => {
928 return Err(unsupported_error());
929 }
930 Proposal::ReInit(_) => {
931 return Err(unsupported_error());
932 }
933 Proposal::ExternalInit(_) => {
934 return Err(unsupported_error());
935 }
936 Proposal::Custom(_) => {
937 return Err(unsupported_error());
938 }
939 Proposal::AppDataUpdate(app_data) => {
940 use super::app_data::load_component_registry;
941 use xmtp_mls_group_state::app_data::{
942 component_id::ComponentId, validation::ActorAuthority,
943 };
944
945 let registry = load_component_registry(openmls_group)?;
946
947 validate_one_app_data_update(
951 ComponentId::from(app_data.component_id()),
952 app_data.operation(),
953 ActorAuthority::from(&proposer),
954 &proposer.inbox_id,
955 ®istry,
956 openmls_group,
957 immutable_metadata.dm_members.as_ref(),
958 )?;
959 }
960 Proposal::AppEphemeral(_) => {
961 return Err(unsupported_error());
962 }
963 Proposal::SelfRemove => {
964 return Err(unsupported_error());
965 }
966 }
967
968 Ok(())
969}
970
971impl FromWith<ValidatedCommit> for GroupUpdatedProto {
974 type Params = Vec<String>;
976
977 fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
978 use std::collections::HashSet;
979
980 let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
982
983 let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
987 .removed_inboxes
988 .into_iter()
989 .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
990
991 GroupUpdatedProto {
992 initiated_by_inbox_id: commit.actor.inbox_id.clone(),
993 added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
994 removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
995 metadata_field_changes: commit
996 .metadata_changes
997 .metadata_field_changes
998 .iter()
999 .map(MetadataFieldChangeProto::from)
1000 .collect(),
1001 left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
1002 added_admin_inboxes: commit
1003 .metadata_changes
1004 .admins_added
1005 .iter()
1006 .map(InboxProto::from)
1007 .collect(),
1008 removed_admin_inboxes: commit
1009 .metadata_changes
1010 .admins_removed
1011 .iter()
1012 .map(InboxProto::from)
1013 .collect(),
1014 added_super_admin_inboxes: commit
1015 .metadata_changes
1016 .super_admins_added
1017 .iter()
1018 .map(InboxProto::from)
1019 .collect(),
1020 removed_super_admin_inboxes: commit
1021 .metadata_changes
1022 .super_admins_removed
1023 .iter()
1024 .map(InboxProto::from)
1025 .collect(),
1026 }
1027 }
1028}
1029
1030#[cfg(test)]
1031mod committed_proposal_outcomes {
1032 use super::*;
1033 use crate::groups::app_data::accumulate_app_data_updates;
1034 use crate::prelude::*;
1035 use openmls::messages::proposals::{
1036 AppDataUpdateOperation, AppDataUpdateProposal, ProposalType,
1037 };
1038 use openmls_traits::OpenMlsProvider;
1039 use xmtp_mls_group_state::app_data::component_id::ComponentId;
1040
1041 #[xmtp_common::test(unwrap_try = true)]
1048 async fn committed_proposal_outcomes_reject_conflicting_component_operations() {
1049 use crate::tester;
1050 tester!(alix, disable_workers);
1051 let conversation = alix.create_group(None, None)?;
1052 conversation
1053 .update_group_description("described".to_string())
1054 .await?;
1055 let provider = alix.context.mls_provider();
1056 let signer = &alix.context.identity().installation_keys;
1057 let mut group = OpenMlsGroup::load(
1058 alix.context.mls_storage(),
1059 &conversation.group_id.to_openmls(),
1060 )??;
1061 let description = ComponentId::GROUP_DESCRIPTION;
1062 assert!(read_from_app_data_dict(description, &group).is_some());
1063
1064 let update = || AppDataUpdateOperation::Update(b"changed".to_vec().into());
1067 for (by_reference, inline) in [
1068 (
1069 update(),
1070 AppDataUpdateProposal::remove(description.as_u16()),
1071 ),
1072 (
1073 AppDataUpdateOperation::Remove,
1074 AppDataUpdateProposal::update(description.as_u16(), b"changed".to_vec()),
1075 ),
1076 (
1077 AppDataUpdateOperation::Remove,
1078 AppDataUpdateProposal::remove(description.as_u16()),
1079 ),
1080 ] {
1081 group.propose_app_data_update(&provider, signer, description.as_u16(), by_reference)?;
1082 let inline = Proposal::AppDataUpdate(Box::new(inline));
1083 let pending: Vec<_> = group
1084 .pending_proposals()
1085 .map(QueuedProposal::proposal)
1086 .chain([&inline])
1087 .filter_map(|proposal| match proposal {
1088 Proposal::AppDataUpdate(p) => Some((p.component_id(), p.operation().clone())),
1089 _ => None,
1090 })
1091 .collect();
1092 let updates =
1093 accumulate_app_data_updates(&group, pending.iter().map(|(id, op)| (*id, op)))?;
1094 let mut stage = crate::groups::mls_ext::commit_without_group_info(
1095 &mut group,
1096 provider.storage(),
1097 |builder| builder.consume_proposal_store(true).add_proposal(inline),
1098 )?;
1099 stage.with_app_data_dictionary_updates(updates);
1100 stage
1101 .build(provider.rand(), provider.crypto(), signer, |_| true)?
1102 .stage_commit(&provider)?;
1103 let staged = group.pending_commit()?;
1104 let kinds: Vec<_> = staged
1105 .queued_proposals()
1106 .map(|queued| queued.proposal().proposal_type())
1107 .collect();
1108 assert_eq!(kinds, [ProposalType::AppDataUpdate; 2]);
1109
1110 let result = ValidatedCommit::from_staged_commit_local(
1111 &alix.context,
1112 &alix.context.db(),
1113 staged,
1114 group.own_leaf_index(),
1115 &group,
1116 u64::MAX,
1117 );
1118 let Err(error) = result else {
1119 panic!("conflicting component operations were accepted");
1120 };
1121 assert!(
1122 matches!(
1123 &error,
1124 CommitValidationError::Rule(CommitRuleError::InvalidAppDataUpdateList(id))
1125 if *id == description
1126 ),
1127 "{error:?}"
1128 );
1129 assert!(error.is_safe_rejection());
1130
1131 group.clear_pending_commit(provider.storage())?;
1132 group.clear_pending_proposals(provider.storage())?;
1133 }
1134 }
1135
1136 #[xmtp_common::test(unwrap_try = true)]
1141 #[allow(clippy::disallowed_methods)]
1143 async fn committed_proposal_outcomes_hold_gce_proposal_and_commit() {
1144 use crate::tester;
1145 tester!(alix, disable_workers);
1146 let conversation = alix.create_group(None, None)?;
1147 let provider = alix.context.mls_provider();
1148 let mut group = OpenMlsGroup::load(
1149 alix.context.mls_storage(),
1150 &conversation.group_id.to_openmls(),
1151 )??;
1152 let signer = &alix.context.identity().installation_keys;
1153 group.propose_group_context_extensions(&provider, group.extensions().clone(), signer)?;
1154 let (immutable, mutable) = read_committed_metadata(&group)?;
1155 let proposal = group.pending_proposals().next()?;
1156 assert!(matches!(
1157 validate_proposal(
1158 proposal,
1159 &group,
1160 &PolicySet::default(),
1161 &immutable,
1162 &mutable
1163 ),
1164 Err(CommitValidationError::Rule(
1165 CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1166 ))
1167 ));
1168 group.commit_to_pending_proposals(&provider, signer)?;
1169 let staged = group.pending_commit()?;
1170 assert!(matches!(
1171 ValidatedCommit::from_staged_commit_local(
1172 &alix.context,
1173 &alix.context.db(),
1174 staged,
1175 group.own_leaf_index(),
1176 &group,
1177 u64::MAX,
1178 ),
1179 Err(CommitValidationError::Rule(
1180 CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1181 ))
1182 ));
1183 }
1184}
1185
1186#[cfg(all(test, not(target_arch = "wasm32")))]
1187mod membership_log_tests {
1188 use std::sync::Arc;
1189
1190 use xmtp_logging::{Level, LogRecord, LogSinkTarget, SinkError, test_logging::LogCapture};
1191 use xmtp_mls_validation::{
1192 commit::{CommitParticipant, Inbox, MembershipValidationInfo},
1193 group_permissions::{MembershipPolicies, PolicySet},
1194 };
1195
1196 #[derive(Default)]
1197 struct Capture(parking_lot::Mutex<Vec<LogRecord>>);
1198
1199 impl LogSinkTarget for Capture {
1200 fn on_record(&self, record: LogRecord) -> Result<(), SinkError> {
1201 self.0.lock().push(record);
1202 Ok(())
1203 }
1204 }
1205
1206 #[rstest::rstest]
1207 #[case::add_committer(false, false)]
1208 #[case::add_proposer(false, true)]
1209 #[case::remove_committer(true, false)]
1210 #[case::remove_proposer(true, true)]
1211 #[xmtp_common::test(unwrap_try = true)]
1212 fn membership_rejection_log_omits_full_ids(#[case] remove: bool, #[case] has_proposer: bool) {
1213 let actor_id = (32_u8..64).collect::<Vec<_>>();
1214 let proposer_id = (64_u8..96).collect::<Vec<_>>();
1215 let actor = CommitParticipant {
1216 inbox_id: "sentinel-actor-inbox".to_string(),
1217 installation_id: actor_id.clone(),
1218 is_creator: false,
1219 is_admin: true,
1220 is_super_admin: false,
1221 };
1222 let proposer = CommitParticipant {
1223 inbox_id: "sentinel-proposer-inbox".to_string(),
1224 installation_id: proposer_id.clone(),
1225 is_creator: true,
1226 is_admin: false,
1227 is_super_admin: true,
1228 };
1229 let changes = [Inbox {
1230 inbox_id: "sentinel-subject-inbox".to_string(),
1231 is_creator: false,
1232 is_admin: true,
1233 is_super_admin: false,
1234 proposer: has_proposer.then_some(proposer),
1235 }];
1236 let policies = PolicySet {
1237 add_member_policy: MembershipPolicies::deny(),
1238 remove_member_policy: MembershipPolicies::deny(),
1239 ..PolicySet::default()
1240 };
1241 let membership = MembershipValidationInfo {
1242 actor: &actor,
1243 added_inboxes: if remove { &[] } else { &changes },
1244 removed_inboxes: if remove { &changes } else { &[] },
1245 dm_members: None,
1246 };
1247 let sink = Arc::new(Capture::default());
1248 let capture = LogCapture::with_sink(Level::Info, Some(sink.clone()));
1249 let accepted = tracing::dispatcher::with_default(&capture.dispatch(), || {
1250 policies.evaluate_membership(&membership)
1251 });
1252 assert!(!accepted);
1253 let json = capture.output();
1254 let records = sink.0.lock();
1255 assert_eq!(records.len(), 1);
1256 for sensitive in [
1257 hex::encode(&actor_id),
1258 hex::encode(&proposer_id),
1259 format!("{actor_id:?}"),
1260 format!("{proposer_id:?}"),
1261 actor.inbox_id.clone(),
1262 "sentinel-proposer-inbox".to_string(),
1263 changes[0].inbox_id.clone(),
1264 ] {
1265 assert!(
1266 !json.contains(&sensitive),
1267 "sensitive value reached JSON log"
1268 );
1269 assert!(
1270 !records[0].message.contains(&sensitive),
1271 "sensitive value reached sink message"
1272 );
1273 assert!(
1274 records[0]
1275 .fields
1276 .values()
1277 .all(|value| !value.contains(&sensitive)),
1278 "sensitive value reached sink fields"
1279 );
1280 }
1281 let json_record: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
1282 assert_eq!(json_record["message"], "Membership policy rejected change");
1283 assert_eq!(records[0].message, "Membership policy rejected change");
1284 for (field, value) in [
1285 ("has_proposer", has_proposer),
1286 ("actor_is_creator", has_proposer),
1287 ("actor_is_admin", !has_proposer),
1288 ("actor_is_super_admin", has_proposer),
1289 ("subject_is_creator", false),
1290 ("subject_is_admin", true),
1291 ("subject_is_super_admin", false),
1292 ] {
1293 assert_eq!(json_record[field], value);
1294 assert_eq!(records[0].fields.get(field), Some(&value.to_string()));
1295 }
1296 assert_eq!(actor.installation_id, actor_id);
1297 if let Some(proposer) = &changes[0].proposer {
1298 assert_eq!(proposer.installation_id, proposer_id);
1299 }
1300 }
1301}