1use super::{
2 MAX_APP_DATA_LENGTH, MAX_GROUP_DESCRIPTION_LENGTH, MAX_GROUP_IMAGE_URL_LENGTH,
3 MAX_GROUP_NAME_LENGTH,
4 group_membership::{GroupMembership, MembershipDiff},
5 group_permissions::{
6 GroupMutablePermissions, GroupMutablePermissionsError, MembershipPolicy, MetadataPolicy,
7 PermissionsPolicy, PolicySet, extract_group_permissions,
8 },
9};
10
11#[cfg(test)]
12mod identity_tests;
13use crate::{
14 context::XmtpSharedContext,
15 identity_updates::{
16 IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
17 get_installation_diff_local, require_association_state,
18 },
19};
20use openmls::{
21 credentials::{BasicCredential, Credential as OpenMlsCredential, errors::BasicCredentialError},
22 extensions::{Extension, Extensions, UnknownExtension},
23 group::{GroupContext, MlsGroup as OpenMlsGroup, QueuedProposal, StagedCommit},
24 messages::proposals::{Proposal, ProposalType},
25 prelude::{LeafNodeIndex, Sender},
26 treesync::LeafNode,
27};
28
29use crate::traits::FromWith;
30use prost::Message;
31use serde::Serialize;
32use std::collections::HashSet;
33use thiserror::Error;
34use xmtp_common::{retry::RetryableError, retryable};
35use xmtp_db::local_commit_log::CommitType;
36use xmtp_db::{DbQuery, StorageError};
37#[cfg(doc)]
38use xmtp_id::associations::AssociationState;
39use xmtp_id::{InboxId, associations::MemberIdentifier};
40use xmtp_mls_common::{
41 group_metadata::{DmMembers, GroupMetadata, GroupMetadataError},
42 group_mutable_metadata::{
43 GroupMutableMetadata, GroupMutableMetadataError, MetadataField,
44 find_mutable_metadata_extension,
45 },
46};
47use xmtp_proto::xmtp::{
48 identity::MlsCredential,
49 mls::message_contents::{
50 GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
51 group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
52 },
53};
54
55#[derive(Debug, Error)]
56pub enum CommitValidationError {
57 #[error("Committed group state is invalid: {0}")]
59 InstalledState(Box<CommitValidationError>),
60 #[error(transparent)]
62 IdentityDependency(#[from] IdentityDependencyError),
63 #[error(
65 "Identity sequence {identity_sequence} does not precede group sequence {envelope_sequence}"
66 )]
67 IdentitySequenceNotBeforeEnvelope {
68 identity_sequence: u64,
70 envelope_sequence: u64,
72 },
73 #[error("Actor could not be found")]
74 ActorCouldNotBeFound,
75 #[error("Inbox validation failed for {0}")]
77 InboxValidationFailed(String),
78 #[error("Insufficient permissions")]
79 InsufficientPermissions,
80 #[error("Invalid version format: {0}")]
81 InvalidVersionFormat(String),
82 #[error("Minimum supported protocol version {0} exceeds current version")]
83 ProtocolVersionTooLow(String),
84 #[error("Actor not a member of the group")]
86 ActorNotMember,
87 #[error("Subject not a member of the group")]
88 SubjectDoesNotExist,
89 #[error("Multiple actors in commit")]
92 MultipleActors,
93 #[error("Missing group membership")]
94 MissingGroupMembership,
95 #[error("Missing mutable metadata")]
96 MissingMutableMetadata,
97 #[error("Unexpected installations added:")]
98 UnexpectedInstallationAdded(Vec<Vec<u8>>),
99 #[error("Sequence ID can only increase")]
100 SequenceIdDecreased,
101 #[error("Unexpected installations removed: {0:?}")]
102 UnexpectedInstallationsRemoved(Vec<Vec<u8>>),
103 #[error(transparent)]
104 GroupMetadata(#[from] GroupMetadataError),
105 #[error(transparent)]
106 MlsCredential(#[from] BasicCredentialError),
107 #[error(transparent)]
108 GroupMutableMetadata(#[from] GroupMutableMetadataError),
109 #[error(transparent)]
110 ProtoDecode(#[from] prost::DecodeError),
111 #[error(transparent)]
112 InstallationDiff(#[from] InstallationDiffError),
113 #[error("Failed to parse group mutable permissions: {0}")]
114 GroupMutablePermissions(#[from] GroupMutablePermissionsError),
115 #[error("PSKs are not supported")]
116 NoPSKSupport,
117 #[error("Unsupported proposal type: {0:?}")]
118 UnsupportedProposalType(ProposalType),
119 #[error(transparent)]
120 StorageError(#[from] StorageError),
121 #[error("Exceeded max characters for this field. Must be under: {length}")]
122 TooManyCharacters { length: usize },
123 #[error("Proposer could not be determined for inbox change in proposal-enabled group")]
124 ProposerNotFound,
125 #[error("Proposals are not enabled on this group")]
126 ProposalsNotEnabled,
127 #[error("min_version {requested} would downgrade existing floor {current}")]
132 MinVersionDowngrade { requested: String, current: String },
133 #[error("min_version remove is rejected; existing floor is {current}")]
138 MinVersionRemoveOnExistingFloor { current: String },
139 #[error(transparent)]
146 ComponentSource(#[from] super::app_data::component_source::ComponentSourceError),
147
148 #[error(transparent)]
153 Bootstrap(#[from] super::app_data::bootstrap_validator::BootstrapValidationError),
154 #[error(transparent)]
155 Conversion(#[from] xmtp_proto::ConversionError),
156}
157
158impl crate::worker::NeedsDbReconnect for CommitValidationError {
159 fn needs_db_reconnect(&self) -> bool {
160 match self {
161 Self::InstalledState(error) => error.needs_db_reconnect(),
162 Self::IdentityDependency(error) => error.needs_db_reconnect(),
163 Self::StorageError(error) => error.db_needs_connection(),
164 _ => false,
165 }
166 }
167}
168
169impl RetryableError for CommitValidationError {
170 fn is_retryable(&self) -> bool {
171 match self {
172 CommitValidationError::IdentityDependency(error) => retryable!(error),
173 CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
174 _ => false,
175 }
176 }
177}
178
179impl CommitValidationError {
180 pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
182 Self::InstalledState(Box::new(error.into()))
183 }
184
185 pub(crate) fn is_safe_rejection(&self) -> bool {
189 match self {
190 Self::IdentityDependency(IdentityDependencyError::MissingReference(_)
191 | IdentityDependencyError::InvalidSequence(_))
192 | Self::IdentitySequenceNotBeforeEnvelope { .. }
193 | Self::ActorCouldNotBeFound
194 | Self::InboxValidationFailed(_)
195 | Self::InsufficientPermissions
196 | Self::InvalidVersionFormat(_)
197 | Self::ActorNotMember
198 | Self::SubjectDoesNotExist
199 | Self::MultipleActors
200 | Self::UnexpectedInstallationAdded(_)
201 | Self::SequenceIdDecreased
202 | Self::UnexpectedInstallationsRemoved(_)
203 | Self::MlsCredential(_)
204 | Self::ProtoDecode(_)
205 | Self::NoPSKSupport
206 | Self::TooManyCharacters { .. }
207 | Self::ProposerNotFound
208 | Self::ProposalsNotEnabled
209 | Self::MinVersionDowngrade { .. }
210 | Self::MinVersionRemoveOnExistingFloor { .. }
211 | Self::MissingGroupMembership
212 | Self::MissingMutableMetadata
213 | Self::GroupMetadata(_)
214 | Self::GroupMutableMetadata(_)
215 | Self::GroupMutablePermissions(_)
216 | Self::ComponentSource(_)
217 | Self::Conversion(_) => true,
218 Self::Bootstrap(error) => !matches!(error,
219 super::app_data::bootstrap_validator::BootstrapValidationError::ProtocolVersionTooLow(_)
220 | super::app_data::bootstrap_validator::BootstrapValidationError::Synthesis(_)),
221 Self::InstalledState(_)
222 | Self::IdentityDependency(_)
223 | Self::InstallationDiff(_)
224 | Self::StorageError(_)
225 | Self::ProtocolVersionTooLow(_)
226 | Self::UnsupportedProposalType(_) => false,
227 }
228 }
229}
230
231#[derive(Clone, PartialEq, Hash, Serialize)]
232pub struct CommitParticipant {
233 pub inbox_id: String,
234 pub installation_id: Vec<u8>,
235 pub is_creator: bool,
236 pub is_admin: bool,
237 pub is_super_admin: bool,
238}
239
240impl std::fmt::Debug for CommitParticipant {
241 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
242 let Self {
243 inbox_id,
244 installation_id,
245 is_creator,
246 is_admin,
247 is_super_admin,
248 } = &self;
249 write!(
250 f,
251 "CommitParticipant {{ inbox_id={}, installation_id={}, is_creator={}, is_admin={}, is_super_admin={} }}",
252 inbox_id,
253 hex::encode(installation_id),
254 is_creator,
255 is_admin,
256 is_super_admin,
257 )
258 }
259}
260
261impl CommitParticipant {
262 pub fn build(
263 inbox_id: String,
264 installation_id: Vec<u8>,
265 immutable_metadata: &GroupMetadata,
266 mutable_metadata: &GroupMutableMetadata,
267 ) -> Self {
268 let is_creator = inbox_id == immutable_metadata.creator_inbox_id;
269 let is_admin = mutable_metadata.is_admin(&inbox_id);
270 let is_super_admin = mutable_metadata.is_super_admin(&inbox_id);
271
272 Self {
273 inbox_id,
274 installation_id,
275 is_creator,
276 is_admin,
277 is_super_admin,
278 }
279 }
280
281 pub fn from_leaf_node(
282 leaf_node: &LeafNode,
283 immutable_metadata: &GroupMetadata,
284 mutable_metadata: &GroupMutableMetadata,
285 ) -> Result<Self, CommitValidationError> {
286 let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
287 let installation_id = leaf_node.signature_key().as_slice().to_vec();
288
289 Ok(Self::build(
290 inbox_id,
291 installation_id,
292 immutable_metadata,
293 mutable_metadata,
294 ))
295 }
296
297 fn actor_authority(&self) -> xmtp_mls_common::app_data::validation::ActorAuthority {
300 xmtp_mls_common::app_data::validation::ActorAuthority {
301 is_admin: self.is_admin,
302 is_super_admin: self.is_super_admin,
303 }
304 }
305}
306
307impl From<&CommitParticipant> for xmtp_mls_common::app_data::validation::ActorAuthority {
308 fn from(participant: &CommitParticipant) -> Self {
309 participant.actor_authority()
310 }
311}
312
313#[derive(Debug, Clone, Default, Serialize)]
314pub struct MutableMetadataValidationInfo {
315 pub metadata_field_changes: Vec<MetadataFieldChange>,
316 pub admins_added: Vec<Inbox>,
317 pub admins_removed: Vec<Inbox>,
318 pub super_admins_added: Vec<Inbox>,
319 pub super_admins_removed: Vec<Inbox>,
320 pub num_super_admins: u32,
321 pub minimum_supported_protocol_version: Option<String>,
322}
323
324impl MutableMetadataValidationInfo {
325 pub fn is_empty(&self) -> bool {
326 self.metadata_field_changes.is_empty()
327 && self.admins_added.is_empty()
328 && self.admins_removed.is_empty()
329 && self.super_admins_added.is_empty()
330 && self.super_admins_removed.is_empty()
331 && self.minimum_supported_protocol_version.is_none()
332 }
333}
334
335#[derive(Debug, Clone, Serialize)]
336pub struct Inbox {
337 pub inbox_id: String,
338 #[allow(dead_code)]
339 pub is_creator: bool,
340 pub is_admin: bool,
341 pub is_super_admin: bool,
342 #[serde(skip_serializing_if = "Option::is_none")]
344 pub proposer: Option<CommitParticipant>,
345}
346
347#[derive(Debug, Clone, Serialize)]
348pub struct MetadataFieldChange {
349 pub field_name: String,
350 #[allow(dead_code)]
351 pub old_value: Option<String>,
352 #[allow(dead_code)]
353 pub new_value: Option<String>,
354}
355
356impl MetadataFieldChange {
357 pub fn new(field_name: String, old_value: Option<String>, new_value: Option<String>) -> Self {
358 Self {
359 field_name,
360 old_value,
361 new_value,
362 }
363 }
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
389pub struct LibXMTPVersion(semver::Version);
390
391impl LibXMTPVersion {
392 pub fn parse(version_str: &str) -> Result<Self, CommitValidationError> {
393 semver::Version::parse(version_str)
394 .map(Self)
395 .map_err(|_| CommitValidationError::InvalidVersionFormat(version_str.to_string()))
396 }
397
398 pub fn semver(&self) -> &semver::Version {
400 &self.0
401 }
402}
403
404impl std::fmt::Display for LibXMTPVersion {
405 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
406 std::fmt::Display::fmt(&self.0, f)
407 }
408}
409
410#[derive(Debug, Clone, Serialize)]
427pub struct ValidatedCommit {
428 pub actor: CommitParticipant,
430 pub proposers: Vec<CommitParticipant>,
432 pub added_inboxes: Vec<Inbox>,
433 pub removed_inboxes: Vec<Inbox>,
434 pub readded_installations: HashSet<Vec<u8>>,
435 pub metadata_validation_info: MutableMetadataValidationInfo,
436 pub installations_changed: bool,
437 pub permissions_changed: bool,
438 pub dm_members: Option<DmMembers<String>>,
439}
440
441fn reject_psk_proposals(staged_commit: &StagedCommit) -> Result<(), CommitValidationError> {
449 if staged_commit.psk_proposals().any(|_| true) {
450 return Err(CommitValidationError::NoPSKSupport);
451 }
452 Ok(())
453}
454
455impl ValidatedCommit {
456 #[cfg(test)]
458 pub async fn from_staged_commit(
459 context: &impl XmtpSharedContext,
460 staged_commit: &StagedCommit,
461 committer_leaf_index: LeafNodeIndex,
462 openmls_group: &OpenMlsGroup,
463 ) -> Result<Self, CommitValidationError> {
464 loop {
465 match Self::from_staged_commit_local(
466 context,
467 &context.db(),
468 staged_commit,
469 committer_leaf_index,
470 openmls_group,
471 u64::MAX,
472 ) {
473 Err(CommitValidationError::IdentityDependency(IdentityDependencyError::Need(
474 requirement,
475 ))) => {
476 crate::identity_updates::resolve_identity_requirement(context, &requirement)
477 .await?;
478 }
479 result => return result,
480 }
481 }
482 }
483
484 pub(crate) fn from_staged_commit_local(
489 context: &impl XmtpSharedContext,
490 conn: &impl DbQuery,
491 staged_commit: &StagedCommit,
492 committer_leaf_index: LeafNodeIndex,
493 openmls_group: &OpenMlsGroup,
494 envelope_sequence: u64,
495 ) -> Result<Self, CommitValidationError> {
496 let extensions = openmls_group.extensions();
497 let is_migrated = super::app_data::is_migrated_extensions(extensions);
504 if is_migrated
518 && let Some(min_version) = super::app_data::committed_floor_exceeding(
519 openmls_group,
520 context.version_info().pkg_semver(),
521 )
522 {
523 return Err(CommitValidationError::ProtocolVersionTooLow(min_version));
524 }
525 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
526 .map_err(CommitValidationError::installed_state)?;
527
528 if super::app_data::bootstrap_validator::is_bootstrap_commit(staged_commit, extensions) {
537 validate_identity_sequence_order(
538 &extract_group_membership(extensions)
539 .map_err(CommitValidationError::installed_state)?,
540 envelope_sequence,
541 )?;
542 return Self::validate_bootstrap_and_build(
543 staged_commit,
544 committer_leaf_index,
545 openmls_group,
546 immutable_metadata,
547 mutable_metadata,
548 context.version_info().pkg_version(),
549 );
550 }
551
552 let group_permissions: GroupMutablePermissions = if is_migrated {
565 super::app_data::policy::membership_policy_set_from_registry(openmls_group)
566 .map_err(CommitValidationError::installed_state)?
567 } else {
568 GroupMutablePermissions::try_from(extensions)
569 .map_err(CommitValidationError::installed_state)?
570 };
571 let current_group_members = get_current_group_members(openmls_group);
572
573 let existing_group_extensions = openmls_group.extensions();
574 let proposals_enabled = super::check_proposals_enabled(existing_group_extensions);
575 let new_group_extensions = staged_commit.group_context().extensions();
576
577 let (metadata_validation_info, migrated_registry) = if is_migrated {
608 let registry = super::app_data::load_component_registry(openmls_group)
609 .map_err(CommitValidationError::installed_state)?;
610 let min_version_bytes =
611 super::app_data::component_source::read_post_commit_component_bytes(
612 xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
613 openmls_group,
614 staged_commit,
615 ®istry,
616 )
617 .map_err(xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from)?;
618 let minimum_supported_protocol_version = match min_version_bytes {
619 Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
620 CommitValidationError::GroupMutableMetadata(
621 GroupMutableMetadataError::MalformedComponent {
622 component_id: Some(
623 xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
624 ),
625 reason: format!("invalid utf-8: {e}"),
626 },
627 )
628 })?),
629 None => None,
630 };
631 (
632 MutableMetadataValidationInfo {
633 minimum_supported_protocol_version,
634 ..Default::default()
635 },
636 Some(registry),
637 )
638 } else {
639 (
640 extract_metadata_changes(
641 &immutable_metadata,
642 &mutable_metadata,
643 existing_group_extensions,
644 new_group_extensions,
645 )?,
646 None,
647 )
648 };
649
650 for field_change in &metadata_validation_info.metadata_field_changes {
652 if let Some(new_value) = &field_change.new_value {
653 match field_change.field_name.as_str() {
654 val if val == MetadataField::Description.as_str()
655 && new_value.len() > MAX_GROUP_DESCRIPTION_LENGTH =>
656 {
657 return Err(CommitValidationError::TooManyCharacters {
658 length: MAX_GROUP_DESCRIPTION_LENGTH,
659 });
660 }
661 val if val == MetadataField::GroupName.as_str()
662 && new_value.len() > MAX_GROUP_NAME_LENGTH =>
663 {
664 return Err(CommitValidationError::TooManyCharacters {
665 length: MAX_GROUP_NAME_LENGTH,
666 });
667 }
668 val if val == MetadataField::GroupImageUrlSquare.as_str()
669 && new_value.len() > MAX_GROUP_IMAGE_URL_LENGTH =>
670 {
671 return Err(CommitValidationError::TooManyCharacters {
672 length: MAX_GROUP_IMAGE_URL_LENGTH,
673 });
674 }
675 val if val == MetadataField::AppData.as_str()
676 && new_value.len() > MAX_APP_DATA_LENGTH =>
677 {
678 return Err(CommitValidationError::TooManyCharacters {
679 length: MAX_APP_DATA_LENGTH,
680 });
681 }
682 _ => {}
683 }
684 }
685 }
686
687 let permissions_changed = if is_migrated {
693 false
694 } else {
695 extract_permissions_changed(&group_permissions, new_group_extensions)?
696 };
697 let (actor, proposers) = extract_committer_and_proposers(
701 staged_commit,
702 committer_leaf_index,
703 openmls_group,
704 &immutable_metadata,
705 &mutable_metadata,
706 )?;
707
708 reject_psk_proposals(staged_commit)?;
709
710 validate_app_data_update_proposals_in_commit(
719 staged_commit,
720 openmls_group,
721 &immutable_metadata,
722 &mutable_metadata,
723 migrated_registry.as_ref(),
724 )?;
725
726 let ProposalChanges {
728 mut added_installations,
729 mut removed_installations,
730 mut credentials_to_verify,
731 added_inbox_proposers,
732 removed_inbox_proposers,
733 gce_proposer,
734 } = get_proposal_changes(
735 staged_commit,
736 openmls_group,
737 &immutable_metadata,
738 &mutable_metadata,
739 )?;
740
741 let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
745 conn,
746 staged_commit,
747 openmls_group,
748 envelope_sequence,
749 proposals_enabled,
750 &gce_proposer,
751 &added_inbox_proposers,
752 &removed_inbox_proposers,
753 )?;
754
755 let ExpectedDiff {
756 old_group_membership,
757 new_group_membership,
758 expected_installation_diff,
759 added_inboxes,
760 removed_inboxes,
761 } = expected_diff;
762
763 let installations_changed =
764 !added_installations.is_empty() || !removed_installations.is_empty();
765
766 let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
767 .failed_installations
768 .iter()
769 .cloned()
770 .collect();
771
772 let readded_installations = extract_readded_installations(
774 &actor,
775 &mut added_installations,
776 &mut removed_installations,
777 &mut failed_installations,
778 );
779 expected_diff_matches_commit(
781 &expected_installation_diff,
782 added_installations,
783 removed_installations,
784 current_group_members,
785 failed_installations,
786 )?;
787 credentials_to_verify.push(actor.clone());
788
789 for participant in credentials_to_verify {
794 let inbox_id = &participant.inbox_id;
795 let sequence_id = match new_group_membership.get(inbox_id) {
796 None => return Err(CommitValidationError::SubjectDoesNotExist),
797 Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
798 let known_leaf = openmls_group.members().any(|member| {
802 member.signature_key == participant.installation_id
803 && inbox_id_from_credential(&member.credential)
804 .is_ok_and(|known_inbox| known_inbox == *inbox_id)
805 });
806 if !known_leaf {
807 return Err(CommitValidationError::InboxValidationFailed(
808 inbox_id.clone(),
809 ));
810 }
811 continue;
812 }
813 Some(sequence_id) => *sequence_id,
814 };
815 let inbox_state = require_association_state(
816 conn,
817 &IdentityRequirement {
818 inbox_id: inbox_id.clone(),
819 sequence_id,
820 },
821 )?;
822
823 if inbox_state
824 .get(&MemberIdentifier::installation(participant.installation_id))
825 .is_none()
826 {
827 return Err(CommitValidationError::InboxValidationFailed(
828 participant.inbox_id,
829 ));
830 }
831 }
832
833 let verified_commit = Self {
834 actor,
835 proposers,
836 added_inboxes,
837 removed_inboxes,
838 readded_installations,
839 metadata_validation_info,
840 installations_changed,
841 permissions_changed,
842 dm_members: immutable_metadata.dm_members,
843 };
844
845 let policy_set = if is_migrated {
853 group_permissions.clone()
854 } else {
855 extract_group_permissions(openmls_group)
856 .map_err(CommitValidationError::installed_state)?
857 };
858 if !policy_set.policies.evaluate_commit(&verified_commit) {
859 return Err(CommitValidationError::InsufficientPermissions);
860 }
861 if let Some(min_version) = &verified_commit
862 .metadata_validation_info
863 .minimum_supported_protocol_version
864 {
865 let current_version = context.version_info().pkg_semver();
866 let min_supported_version = LibXMTPVersion::parse(min_version)?;
867 tracing::info!(
868 "Validating commit with min_supported_version: {:?}, current_version: {:?}",
869 min_supported_version,
870 current_version
871 );
872
873 if min_supported_version > *current_version {
874 return Err(CommitValidationError::ProtocolVersionTooLow(
875 min_version.clone(),
876 ));
877 }
878 }
879 Ok(verified_commit)
880 }
881
882 pub fn debug_commit_type(&self) -> CommitType {
885 let metadata_info = &self.metadata_validation_info;
886 if !self.added_inboxes.is_empty()
887 || !self.removed_inboxes.is_empty()
888 || self.installations_changed
889 {
890 CommitType::UpdateGroupMembership
891 } else if self.permissions_changed {
892 CommitType::UpdatePermission
893 } else if !metadata_info.admins_added.is_empty()
894 || !metadata_info.admins_removed.is_empty()
895 || !metadata_info.super_admins_added.is_empty()
896 || !metadata_info.super_admins_removed.is_empty()
897 {
898 CommitType::UpdateAdminList
899 } else if !metadata_info.metadata_field_changes.is_empty() {
900 CommitType::MetadataUpdate
901 } else {
902 CommitType::KeyUpdate
903 }
904 }
905
906 pub fn is_empty(&self) -> bool {
907 self.added_inboxes.is_empty()
908 && self.removed_inboxes.is_empty()
909 && self.metadata_validation_info.is_empty()
910 }
911
912 pub fn actor_inbox_id(&self) -> InboxId {
913 self.actor.inbox_id.clone()
914 }
915
916 pub fn actor_installation_id(&self) -> Vec<u8> {
917 self.actor.installation_id.clone()
918 }
919
920 fn validate_bootstrap_and_build(
932 staged_commit: &StagedCommit,
933 committer_leaf_index: LeafNodeIndex,
934 openmls_group: &OpenMlsGroup,
935 immutable_metadata: GroupMetadata,
936 mutable_metadata: GroupMutableMetadata,
937 own_version: &str,
938 ) -> Result<Self, CommitValidationError> {
939 reject_psk_proposals(staged_commit)?;
940
941 let (actor, proposers) = extract_committer_and_proposers(
942 staged_commit,
943 committer_leaf_index,
944 openmls_group,
945 &immutable_metadata,
946 &mutable_metadata,
947 )?;
948
949 let gce_proposer = super::app_data::bootstrap_validator::extract_gce_proposer(
950 staged_commit,
951 openmls_group,
952 &immutable_metadata,
953 &mutable_metadata,
954 )?
955 .ok_or(CommitValidationError::ProposerNotFound)?;
956
957 super::app_data::bootstrap_validator::validate_bootstrap_commit(
963 staged_commit,
964 openmls_group,
965 &gce_proposer,
966 own_version,
967 )
968 .map_err(|e| {
969 match e {
970 super::app_data::bootstrap_validator::BootstrapValidationError::ProtocolVersionTooLow(
971 min_version,
972 ) => CommitValidationError::ProtocolVersionTooLow(min_version),
973 other => other.into(),
974 }
975 })?;
976
977 Ok(Self {
978 actor,
979 proposers,
980 added_inboxes: Vec::new(),
981 removed_inboxes: Vec::new(),
982 readded_installations: HashSet::new(),
983 metadata_validation_info: MutableMetadataValidationInfo::default(),
984 installations_changed: false,
985 permissions_changed: false,
986 dm_members: immutable_metadata.dm_members,
987 })
988 }
989}
990
991impl From<ValidatedCommit> for GroupMembershipChanges {
992 fn from(_commit: ValidatedCommit) -> Self {
993 GroupMembershipChanges {
996 members_added: vec![],
997 members_removed: vec![],
998 installations_added: vec![],
999 installations_removed: vec![],
1000 }
1001 }
1002}
1003
1004use std::collections::HashMap;
1005
1006struct ProposalChanges {
1007 added_installations: HashSet<Vec<u8>>,
1008 removed_installations: HashSet<Vec<u8>>,
1009 credentials_to_verify: Vec<CommitParticipant>,
1010 added_inbox_proposers: HashMap<String, CommitParticipant>,
1012 removed_inbox_proposers: HashMap<String, CommitParticipant>,
1014 gce_proposer: Option<CommitParticipant>,
1016}
1017
1018fn get_proposal_changes(
1024 staged_commit: &StagedCommit,
1025 openmls_group: &OpenMlsGroup,
1026 immutable_metadata: &GroupMetadata,
1027 mutable_metadata: &GroupMutableMetadata,
1028) -> Result<ProposalChanges, CommitValidationError> {
1029 let mut added_installations: HashSet<Vec<u8>> = HashSet::new();
1031 let mut removed_installations: HashSet<Vec<u8>> = HashSet::new();
1032 let mut credentials_to_verify: Vec<CommitParticipant> = vec![];
1033 let mut added_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
1034 let mut removed_inbox_proposers: HashMap<String, CommitParticipant> = HashMap::new();
1035 let mut gce_proposer: Option<CommitParticipant> = None;
1036
1037 for proposal in staged_commit.queued_proposals() {
1038 let proposer = match proposal.sender() {
1040 Sender::Member(leaf_index) => extract_commit_participant(
1041 leaf_index,
1042 openmls_group,
1043 immutable_metadata,
1044 mutable_metadata,
1045 )?,
1046 _ => return Err(CommitValidationError::ActorNotMember),
1047 };
1048
1049 match proposal.proposal() {
1050 Proposal::Update(update_proposal) => {
1053 credentials_to_verify.push(CommitParticipant::from_leaf_node(
1054 update_proposal.leaf_node(),
1055 immutable_metadata,
1056 mutable_metadata,
1057 )?);
1058 }
1059 Proposal::Add(add_proposal) => {
1061 let leaf_node = add_proposal.key_package().leaf_node();
1064 let installation_id = leaf_node.signature_key().as_slice().to_vec();
1065 let inbox_id = inbox_id_from_credential(leaf_node.credential())?;
1066 added_installations.insert(installation_id);
1067 added_inbox_proposers.insert(inbox_id, proposer);
1068 }
1069 Proposal::Remove(remove_proposal) => {
1071 let leaf_node = openmls_group
1072 .member_at(remove_proposal.removed())
1073 .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1074 let installation_id = leaf_node.signature_key.to_vec();
1075 let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
1076 removed_installations.insert(installation_id);
1077 removed_inbox_proposers.insert(inbox_id, proposer);
1078 }
1079 Proposal::GroupContextExtensions(_) => {
1081 gce_proposer = Some(proposer);
1082 }
1083 _ => continue,
1084 }
1085 }
1086
1087 Ok(ProposalChanges {
1088 added_installations,
1089 removed_installations,
1090 credentials_to_verify,
1091 added_inbox_proposers,
1092 removed_inbox_proposers,
1093 gce_proposer,
1094 })
1095}
1096
1097fn get_latest_group_membership(
1103 staged_commit: &StagedCommit,
1104) -> Result<GroupMembership, CommitValidationError> {
1105 for proposal in staged_commit.queued_proposals() {
1106 match proposal.proposal() {
1107 Proposal::GroupContextExtensions(group_context_extensions) => {
1108 let new_group_membership: GroupMembership =
1109 extract_group_membership(group_context_extensions.extensions())?;
1110 tracing::info!(
1111 "Group context extensions proposal found: {:?}",
1112 new_group_membership
1113 );
1114 return Ok(new_group_membership);
1115 }
1116 _ => continue,
1117 }
1118 }
1119
1120 extract_group_membership(staged_commit.group_context().extensions())
1121}
1122
1123struct ExpectedDiff {
1125 old_group_membership: GroupMembership,
1127 new_group_membership: GroupMembership,
1129 expected_installation_diff: InstallationDiff,
1131 added_inboxes: Vec<Inbox>,
1132 removed_inboxes: Vec<Inbox>,
1133}
1134
1135fn read_committed_metadata(
1137 group: &OpenMlsGroup,
1138) -> Result<(GroupMetadata, GroupMutableMetadata), CommitValidationError> {
1139 let extensions = group.extensions();
1140 if !super::app_data::is_migrated_extensions(extensions) {
1141 return Ok((extensions.try_into()?, extensions.try_into()?));
1142 }
1143 let seed = super::app_data::component_source::read_group_metadata_from_dict(group)?
1144 .ok_or(GroupMetadataError::MissingExtension)?;
1145 let immutable =
1146 GroupMetadata::try_from(xmtp_proto::xmtp::mls::message_contents::GroupMetadataV1 {
1147 conversation_type: seed.conversation_type,
1148 creator_inbox_id: seed.creator_inbox_id,
1149 creator_account_address: String::new(),
1150 dm_members: seed.dm_members,
1151 oneshot_message: seed.oneshot,
1152 })?;
1153 let mut mutable = GroupMutableMetadata::new(HashMap::new(), Vec::new(), Vec::new());
1154 super::app_data::component_source::merge_app_data_into_mutable_metadata(&mut mutable, group)?;
1155 Ok((immutable, mutable))
1156}
1157
1158fn validate_identity_sequence_order(
1161 membership: &GroupMembership,
1162 envelope_sequence: u64,
1163) -> Result<(), CommitValidationError> {
1164 for identity_sequence in membership.members.values() {
1165 if *identity_sequence >= envelope_sequence {
1166 return Err(CommitValidationError::IdentitySequenceNotBeforeEnvelope {
1167 identity_sequence: *identity_sequence,
1168 envelope_sequence,
1169 });
1170 }
1171 }
1172 Ok(())
1173}
1174
1175impl ExpectedDiff {
1176 #[allow(clippy::too_many_arguments)]
1179 pub(super) fn from_staged_commit_with_proposers(
1180 conn: &impl DbQuery,
1181 staged_commit: &StagedCommit,
1182 openmls_group: &OpenMlsGroup,
1183 envelope_sequence: u64,
1184 proposals_enabled: bool,
1185 gce_proposer: &Option<CommitParticipant>,
1186 added_inbox_proposers: &HashMap<String, CommitParticipant>,
1187 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
1188 ) -> Result<Self, CommitValidationError> {
1189 let extensions = openmls_group.extensions();
1190 let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
1191 .map_err(CommitValidationError::installed_state)?;
1192
1193 reject_psk_proposals(staged_commit)?;
1194
1195 let expected_diff = Self::extract_expected_diff_with_proposers(
1196 conn,
1197 staged_commit,
1198 envelope_sequence,
1199 extensions,
1200 &immutable_metadata,
1201 &mutable_metadata,
1202 proposals_enabled,
1203 gce_proposer,
1204 added_inbox_proposers,
1205 removed_inbox_proposers,
1206 )?;
1207
1208 Ok(expected_diff)
1209 }
1210
1211 #[allow(clippy::too_many_arguments)]
1214 fn extract_expected_diff_with_proposers(
1215 conn: &impl DbQuery,
1216 staged_commit: &StagedCommit,
1217 envelope_sequence: u64,
1218 existing_group_extensions: &Extensions<GroupContext>,
1219 immutable_metadata: &GroupMetadata,
1220 mutable_metadata: &GroupMutableMetadata,
1221 proposals_enabled: bool,
1222 gce_proposer: &Option<CommitParticipant>,
1223 added_inbox_proposers: &HashMap<String, CommitParticipant>,
1224 removed_inbox_proposers: &HashMap<String, CommitParticipant>,
1225 ) -> Result<ExpectedDiff, CommitValidationError> {
1226 let old_group_membership = extract_group_membership(existing_group_extensions)
1227 .map_err(CommitValidationError::installed_state)?;
1228 let new_group_membership = get_latest_group_membership(staged_commit)?;
1229 validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
1230 let membership_diff = old_group_membership.diff(&new_group_membership);
1231
1232 validate_membership_diff(
1233 &old_group_membership,
1234 &new_group_membership,
1235 &membership_diff,
1236 )?;
1237
1238 let added_inboxes = membership_diff
1243 .added_inboxes
1244 .iter()
1245 .map(|inbox_id| {
1246 let proposer = added_inbox_proposers
1250 .get(inbox_id.as_str())
1251 .cloned()
1252 .or_else(|| gce_proposer.clone());
1253 match proposer {
1254 Some(p) => Ok(build_inbox_with_proposer(
1255 inbox_id,
1256 immutable_metadata,
1257 mutable_metadata,
1258 p,
1259 )),
1260 None if proposals_enabled => Err(CommitValidationError::ProposerNotFound),
1261 None => Ok(build_inbox(inbox_id, immutable_metadata, mutable_metadata)),
1262 }
1263 })
1264 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
1265
1266 let removed_inboxes = membership_diff
1271 .removed_inboxes
1272 .iter()
1273 .map(|inbox_id| {
1274 let proposer = removed_inbox_proposers
1275 .get(inbox_id.as_str())
1276 .cloned()
1277 .or_else(|| gce_proposer.clone());
1278 match proposer {
1279 Some(p) => Ok(build_inbox_with_proposer(
1280 inbox_id,
1281 immutable_metadata,
1282 mutable_metadata,
1283 p,
1284 )),
1285 None if proposals_enabled => Err(CommitValidationError::ProposerNotFound),
1286 None => Ok(build_inbox(inbox_id, immutable_metadata, mutable_metadata)),
1287 }
1288 })
1289 .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
1290
1291 let expected_installation_diff = get_installation_diff_local(
1292 conn,
1293 &old_group_membership,
1294 &new_group_membership,
1295 &membership_diff,
1296 )?;
1297
1298 Ok(ExpectedDiff {
1299 old_group_membership,
1300 new_group_membership,
1301 expected_installation_diff,
1302 added_inboxes,
1303 removed_inboxes,
1304 })
1305 }
1306}
1307
1308pub(super) fn extract_readded_installations(
1311 actor: &CommitParticipant,
1312 added_installations: &mut HashSet<Vec<u8>>,
1313 removed_installations: &mut HashSet<Vec<u8>>,
1314 failed_installations: &mut HashSet<Vec<u8>>,
1315) -> HashSet<Vec<u8>> {
1316 if !actor.is_super_admin {
1317 return HashSet::new();
1318 }
1319 let successfully_readded = added_installations
1320 .intersection(removed_installations)
1321 .cloned()
1322 .collect::<HashSet<Vec<u8>>>();
1323 added_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
1324 removed_installations.retain(|installation_id| !successfully_readded.contains(installation_id));
1325
1326 let unsuccessfully_readded = failed_installations
1328 .intersection(removed_installations)
1329 .cloned()
1330 .collect::<HashSet<Vec<u8>>>();
1331 failed_installations
1332 .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
1333 removed_installations
1334 .retain(|installation_id| !unsuccessfully_readded.contains(installation_id));
1335
1336 successfully_readded
1337 .union(&unsuccessfully_readded)
1338 .cloned()
1339 .collect()
1340}
1341
1342fn expected_diff_matches_commit(
1346 expected_diff: &InstallationDiff,
1347 added_installations: HashSet<Vec<u8>>,
1348 removed_installations: HashSet<Vec<u8>>,
1349 existing_installation_ids: HashSet<Vec<u8>>,
1350 failed_installation_ids: HashSet<Vec<u8>>,
1351) -> Result<(), CommitValidationError> {
1352 let unknown_adds = added_installations
1357 .into_iter()
1358 .filter(|installation_id| {
1359 !expected_diff.added_installations.contains(installation_id)
1360 && !existing_installation_ids.contains(installation_id)
1361 })
1362 .collect::<Vec<Vec<u8>>>();
1363 if !unknown_adds.is_empty() {
1364 return Err(CommitValidationError::UnexpectedInstallationAdded(
1365 unknown_adds,
1366 ));
1367 }
1368
1369 let filtered_expected: HashSet<_> = expected_diff
1370 .removed_installations
1371 .iter()
1372 .filter(|id| !failed_installation_ids.contains(*id))
1373 .cloned()
1374 .collect();
1375
1376 if removed_installations != filtered_expected {
1377 let unexpected: Vec<_> = removed_installations
1378 .difference(&expected_diff.removed_installations)
1379 .cloned()
1380 .collect();
1381
1382 return Err(CommitValidationError::UnexpectedInstallationsRemoved(
1383 unexpected,
1384 ));
1385 }
1386
1387 Ok(())
1388}
1389
1390fn get_current_group_members(openmls_group: &OpenMlsGroup) -> HashSet<Vec<u8>> {
1391 openmls_group
1392 .members()
1393 .map(|member| member.signature_key)
1394 .collect()
1395}
1396
1397fn validate_membership_diff(
1400 old_membership: &GroupMembership,
1401 new_membership: &GroupMembership,
1402 diff: &MembershipDiff<'_>,
1403) -> Result<(), CommitValidationError> {
1404 for inbox_id in diff.updated_inboxes.iter() {
1405 let old_sequence_id = old_membership
1406 .get(inbox_id)
1407 .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1408 let new_sequence_id = new_membership
1409 .get(inbox_id)
1410 .ok_or(CommitValidationError::SubjectDoesNotExist)?;
1411
1412 if new_sequence_id.lt(old_sequence_id) {
1413 return Err(CommitValidationError::SequenceIdDecreased);
1414 }
1415 }
1416
1417 Ok(())
1418}
1419
1420fn validate_one_app_data_update(
1444 component_id: xmtp_mls_common::app_data::component_id::ComponentId,
1445 operation: &openmls::messages::proposals::AppDataUpdateOperation,
1446 actor: xmtp_mls_common::app_data::validation::ActorAuthority,
1447 proposer_inbox_id: &str,
1448 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1449 openmls_group: &OpenMlsGroup,
1450) -> Result<(), CommitValidationError> {
1451 use super::app_data::component_source::read_from_app_data_dict;
1452
1453 let old_value = read_from_app_data_dict(component_id, openmls_group);
1458
1459 validate_one_app_data_update_with_old_value(
1460 component_id,
1461 operation,
1462 actor,
1463 proposer_inbox_id,
1464 registry,
1465 old_value.as_deref(),
1466 )
1467}
1468
1469fn enforce_min_version_monotonicity(
1481 operation: &openmls::messages::proposals::AppDataUpdateOperation,
1482 old_value: Option<&[u8]>,
1483) -> Result<(), CommitValidationError> {
1484 use openmls::messages::proposals::AppDataUpdateOperation;
1485 let Some(old_bytes) = old_value else {
1488 return Ok(());
1489 };
1490 let Ok(old_str) = std::str::from_utf8(old_bytes) else {
1494 return Ok(());
1495 };
1496 let Ok(old_v) = LibXMTPVersion::parse(old_str) else {
1497 return Ok(());
1498 };
1499 match operation {
1500 AppDataUpdateOperation::Update(payload) => {
1501 let new_bytes = payload.as_slice();
1502 let new_str = std::str::from_utf8(new_bytes).map_err(|_| {
1503 CommitValidationError::InvalidVersionFormat(format!("{:?}", new_bytes))
1504 })?;
1505 let new_v = LibXMTPVersion::parse(new_str)?;
1506 if new_v < old_v {
1507 return Err(CommitValidationError::MinVersionDowngrade {
1508 requested: new_str.to_string(),
1509 current: old_str.to_string(),
1510 });
1511 }
1512 Ok(())
1513 }
1514 AppDataUpdateOperation::Remove => {
1515 Err(CommitValidationError::MinVersionRemoveOnExistingFloor {
1516 current: old_str.to_string(),
1517 })
1518 }
1519 }
1520}
1521
1522pub(super) fn validate_one_app_data_update_with_old_value(
1526 component_id: xmtp_mls_common::app_data::component_id::ComponentId,
1527 operation: &openmls::messages::proposals::AppDataUpdateOperation,
1528 actor: xmtp_mls_common::app_data::validation::ActorAuthority,
1529 proposer_inbox_id: &str,
1530 registry: &xmtp_mls_common::app_data::component_registry::ComponentRegistry,
1531 old_value: Option<&[u8]>,
1532) -> Result<(), CommitValidationError> {
1533 use xmtp_mls_common::app_data::{
1534 registry_table::lookup_component,
1535 validation::{ComponentChange, validate_component_write},
1536 };
1537
1538 if component_id
1544 == xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION
1545 {
1546 enforce_min_version_monotonicity(operation, old_value).inspect_err(|err| {
1547 tracing::warn!(
1548 proposer_inbox_id,
1549 component_id = %component_id,
1550 error = %err,
1551 "AppDataUpdate proposal rejected: min_version monotonicity"
1552 );
1553 })?;
1554 }
1555
1556 let component = lookup_component(component_id);
1571 let changes = if let Some(component) = component {
1572 component
1573 .expand_to_changes(operation, old_value)
1574 .map_err(|e| {
1575 let wrapped = super::app_data::component_source::ComponentSourceError::from(e);
1576 tracing::warn!(
1577 proposer_inbox_id,
1578 component_id = %component_id,
1579 error = %wrapped,
1580 "AppDataUpdate proposal rejected: failed to expand payload"
1581 );
1582 CommitValidationError::InsufficientPermissions
1583 })?
1584 } else {
1585 match super::app_data::component_source::expand_app_data_update_to_changes(
1586 component_id,
1587 operation,
1588 old_value,
1589 registry,
1590 ) {
1591 Ok(changes) => changes,
1592 Err(err) => {
1593 tracing::warn!(
1594 proposer_inbox_id,
1595 component_id = %component_id,
1596 error = %err,
1597 "AppDataUpdate proposal rejected"
1598 );
1599 return Err(CommitValidationError::InsufficientPermissions);
1600 }
1601 }
1602 };
1603
1604 for change in &changes {
1605 let cc = ComponentChange::builder()
1606 .component_id(component_id)
1607 .op(change.op)
1608 .actor(actor)
1609 .maybe_new_value(change.value.as_deref())
1610 .build();
1611
1612 if let Err(e) = validate_component_write(&cc, registry) {
1616 tracing::warn!(
1617 proposer_inbox_id,
1618 component_id = %component_id,
1619 op = %change.op,
1620 error = %e,
1621 "AppDataUpdate proposal rejected"
1622 );
1623 return Err(CommitValidationError::InsufficientPermissions);
1624 }
1625
1626 if let Some(component) = component
1631 && let Err(e) = component.validate_invariant(&cc, registry)
1632 {
1633 tracing::warn!(
1634 proposer_inbox_id,
1635 component_id = %component_id,
1636 op = %change.op,
1637 error = %e,
1638 "AppDataUpdate proposal rejected: component invariant violated"
1639 );
1640 return Err(CommitValidationError::InsufficientPermissions);
1641 }
1642 }
1643
1644 Ok(())
1645}
1646
1647pub(super) fn app_data_update_proposer_leaf(
1655 sender: &Sender,
1656) -> Result<&LeafNodeIndex, CommitValidationError> {
1657 match sender {
1658 Sender::Member(leaf_index) => Ok(leaf_index),
1659 Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
1660 Err(CommitValidationError::ActorNotMember)
1661 }
1662 }
1663}
1664
1665fn validate_app_data_update_proposals_in_commit(
1703 staged_commit: &StagedCommit,
1704 openmls_group: &OpenMlsGroup,
1705 immutable_metadata: &GroupMetadata,
1706 mutable_metadata: &GroupMutableMetadata,
1707 preloaded_registry: Option<&xmtp_mls_common::app_data::component_registry::ComponentRegistry>,
1708) -> Result<(), CommitValidationError> {
1709 use super::app_data::load_component_registry;
1710 use std::collections::HashMap;
1711 use xmtp_mls_common::app_data::{component_id::ComponentId, validation::ActorAuthority};
1712
1713 let mut proposals = staged_commit.app_data_update_proposals().peekable();
1726 if proposals.peek().is_none() {
1727 return Ok(());
1728 }
1729
1730 let owned_registry;
1734 let registry = match preloaded_registry {
1735 Some(r) => r,
1736 None => {
1737 owned_registry = load_component_registry(openmls_group)
1738 .map_err(CommitValidationError::installed_state)?;
1739 &owned_registry
1740 }
1741 };
1742
1743 let mut participants: HashMap<LeafNodeIndex, CommitParticipant> = HashMap::new();
1747
1748 for queued in proposals {
1749 let app_data = queued.app_data_update_proposal();
1750 let proposer_leaf = app_data_update_proposer_leaf(queued.sender())?;
1751 let proposer = match participants.get(proposer_leaf) {
1752 Some(cached) => cached,
1753 None => {
1754 let fresh = extract_commit_participant(
1755 proposer_leaf,
1756 openmls_group,
1757 immutable_metadata,
1758 mutable_metadata,
1759 )?;
1760 participants.entry(*proposer_leaf).or_insert(fresh)
1761 }
1762 };
1763
1764 validate_one_app_data_update(
1765 ComponentId::from(app_data.component_id()),
1766 app_data.operation(),
1767 ActorAuthority::from(proposer),
1768 &proposer.inbox_id,
1769 registry,
1770 openmls_group,
1771 )?;
1772 }
1773
1774 Ok(())
1775}
1776
1777pub(super) fn extract_commit_participant(
1779 leaf_index: &LeafNodeIndex,
1780 group: &OpenMlsGroup,
1781 immutable_metadata: &GroupMetadata,
1782 mutable_metadata: &GroupMutableMetadata,
1783) -> Result<CommitParticipant, CommitValidationError> {
1784 if let Some(leaf_node) = group.member_at(*leaf_index) {
1785 let installation_id = leaf_node.signature_key.to_vec();
1786 let inbox_id = inbox_id_from_credential(&leaf_node.credential)?;
1787 Ok(CommitParticipant::build(
1788 inbox_id,
1789 installation_id,
1790 immutable_metadata,
1791 mutable_metadata,
1792 ))
1793 } else {
1794 Err(CommitValidationError::ActorNotMember)
1796 }
1797}
1798
1799#[tracing::instrument(level = "trace", skip_all)]
1805pub fn extract_group_membership(
1806 extensions: &Extensions<GroupContext>,
1807) -> Result<GroupMembership, CommitValidationError> {
1808 if let Some(proto) = super::app_data::component_source::read_group_membership_from_dict(
1809 extensions,
1810 )
1811 .map_err(|e| {
1812 CommitValidationError::GroupMutableMetadata(
1813 xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from(e),
1814 )
1815 })? {
1816 return Ok(GroupMembership {
1820 members: proto.members,
1821 failed_installations: proto.failed_installations,
1822 });
1823 }
1824
1825 for extension in extensions.iter() {
1826 if let Extension::Unknown(
1827 xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
1828 UnknownExtension(group_membership),
1829 ) = extension
1830 {
1831 return Ok(GroupMembership::try_from(group_membership.clone())?);
1832 }
1833 }
1834
1835 Err(CommitValidationError::MissingGroupMembership)
1836}
1837
1838fn extract_metadata_changes(
1844 immutable_metadata: &GroupMetadata,
1845 old_mutable_metadata: &GroupMutableMetadata,
1847 old_group_extensions: &Extensions<GroupContext>,
1848 new_group_extensions: &Extensions<GroupContext>,
1849) -> Result<MutableMetadataValidationInfo, CommitValidationError> {
1850 let old_mutable_metadata_ext = find_mutable_metadata_extension(old_group_extensions)
1851 .ok_or(CommitValidationError::MissingMutableMetadata)?;
1852 let new_mutable_metadata_ext = find_mutable_metadata_extension(new_group_extensions)
1853 .ok_or(CommitValidationError::MissingMutableMetadata)?;
1854
1855 if old_mutable_metadata_ext.eq(new_mutable_metadata_ext) {
1858 let minimum_supported_protocol_version: Option<String> = old_mutable_metadata
1859 .attributes
1860 .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
1861 .map(|s| s.to_string());
1862 return Ok(MutableMetadataValidationInfo {
1863 minimum_supported_protocol_version,
1864 ..Default::default()
1865 });
1866 }
1867
1868 let new_mutable_metadata: GroupMutableMetadata = new_mutable_metadata_ext.try_into()?;
1869
1870 let metadata_field_changes =
1871 mutable_metadata_field_changes(old_mutable_metadata, &new_mutable_metadata);
1872
1873 Ok(MutableMetadataValidationInfo {
1874 metadata_field_changes,
1875 admins_added: get_added_members(
1876 &old_mutable_metadata.admin_list,
1877 &new_mutable_metadata.admin_list,
1878 immutable_metadata,
1879 old_mutable_metadata,
1880 ),
1881 admins_removed: get_removed_members(
1882 &old_mutable_metadata.admin_list,
1883 &new_mutable_metadata.admin_list,
1884 immutable_metadata,
1885 old_mutable_metadata,
1886 ),
1887 super_admins_added: get_added_members(
1888 &old_mutable_metadata.super_admin_list,
1889 &new_mutable_metadata.super_admin_list,
1890 immutable_metadata,
1891 old_mutable_metadata,
1892 ),
1893 super_admins_removed: get_removed_members(
1894 &old_mutable_metadata.super_admin_list,
1895 &new_mutable_metadata.super_admin_list,
1896 immutable_metadata,
1897 old_mutable_metadata,
1898 ),
1899 num_super_admins: new_mutable_metadata.super_admin_list.len() as u32,
1900 minimum_supported_protocol_version: new_mutable_metadata
1901 .attributes
1902 .get(MetadataField::MinimumSupportedProtocolVersion.as_str())
1903 .map(|s| s.to_string()),
1904 })
1905}
1906
1907fn extract_permissions_changed(
1909 old_group_permissions: &GroupMutablePermissions,
1910 new_group_extensions: &Extensions<GroupContext>,
1911) -> Result<bool, CommitValidationError> {
1912 let new_group_permissions: GroupMutablePermissions = new_group_extensions.try_into()?;
1913 Ok(!old_group_permissions.eq(&new_group_permissions))
1914}
1915
1916fn find_unknown_extension(
1917 extensions: &Extensions<GroupContext>,
1918 extension_type: u16,
1919) -> Option<&Vec<u8>> {
1920 extensions.iter().find_map(|extension| {
1921 if let Extension::Unknown(id, UnknownExtension(bytes)) = extension
1922 && *id == extension_type
1923 {
1924 return Some(bytes);
1925 }
1926 None
1927 })
1928}
1929
1930fn get_added_members(
1934 old: &[String],
1935 new: &[String],
1936 immutable_metadata: &GroupMetadata,
1937 mutable_metadata: &GroupMutableMetadata,
1938) -> Vec<Inbox> {
1939 new.iter()
1940 .filter(|new_inbox| !old.contains(new_inbox))
1941 .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1942 .collect()
1943}
1944
1945fn get_removed_members(
1949 old: &[String],
1950 new: &[String],
1951 immutable_metadata: &GroupMetadata,
1952 mutable_metadata: &GroupMutableMetadata,
1953) -> Vec<Inbox> {
1954 old.iter()
1955 .filter(|old_inbox| !new.contains(old_inbox))
1956 .map(|inbox_id| build_inbox(inbox_id, immutable_metadata, mutable_metadata))
1957 .collect()
1958}
1959
1960fn build_inbox(
1961 inbox_id: &String,
1962 immutable_metadata: &GroupMetadata,
1963 mutable_metadata: &GroupMutableMetadata,
1964) -> Inbox {
1965 Inbox {
1966 inbox_id: inbox_id.to_string(),
1967 is_admin: mutable_metadata.is_admin(inbox_id),
1968 is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1969 is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1970 proposer: None,
1971 }
1972}
1973
1974fn build_inbox_with_proposer(
1975 inbox_id: &String,
1976 immutable_metadata: &GroupMetadata,
1977 mutable_metadata: &GroupMutableMetadata,
1978 proposer: CommitParticipant,
1979) -> Inbox {
1980 Inbox {
1981 inbox_id: inbox_id.to_string(),
1982 is_admin: mutable_metadata.is_admin(inbox_id),
1983 is_super_admin: mutable_metadata.is_super_admin(inbox_id),
1984 is_creator: immutable_metadata.creator_inbox_id.eq(inbox_id),
1985 proposer: Some(proposer),
1986 }
1987}
1988
1989fn mutable_metadata_field_changes(
1993 old_metadata: &GroupMutableMetadata,
1994 new_metadata: &GroupMutableMetadata,
1995) -> Vec<MetadataFieldChange> {
1996 let all_keys = old_metadata
1997 .attributes
1998 .keys()
1999 .chain(new_metadata.attributes.keys())
2000 .fold(HashSet::new(), |mut key_set, key| {
2001 key_set.insert(key);
2002 key_set
2003 });
2004
2005 all_keys
2006 .into_iter()
2007 .filter_map(|key| {
2008 let old_val = old_metadata.attributes.get(key);
2009 let new_val = new_metadata.attributes.get(key);
2010 if old_val.ne(&new_val) {
2011 Some(MetadataFieldChange::new(
2012 key.clone(),
2013 old_val.cloned(),
2014 new_val.cloned(),
2015 ))
2016 } else {
2017 None
2018 }
2019 })
2020 .collect()
2021}
2022
2023fn inbox_id_from_credential(
2025 credential: &OpenMlsCredential,
2026) -> Result<String, CommitValidationError> {
2027 let basic_credential = BasicCredential::try_from(credential.clone())?;
2028 let identity_bytes = basic_credential.identity();
2029 let decoded = MlsCredential::decode(identity_bytes)?;
2030
2031 Ok(decoded.inbox_id)
2032}
2033
2034fn extract_committer_and_proposers(
2050 staged_commit: &StagedCommit,
2051 committer_leaf_index: LeafNodeIndex,
2052 openmls_group: &OpenMlsGroup,
2053 immutable_metadata: &GroupMetadata,
2054 mutable_metadata: &GroupMutableMetadata,
2055) -> Result<(CommitParticipant, Vec<CommitParticipant>), CommitValidationError> {
2056 let mut proposer_leaf_indices: Vec<&LeafNodeIndex> = Vec::new();
2058 for proposal in staged_commit.queued_proposals() {
2059 match proposal.sender() {
2060 Sender::Member(member_leaf_node_index) => {
2061 if !proposer_leaf_indices.contains(&member_leaf_node_index) {
2063 proposer_leaf_indices.push(member_leaf_node_index);
2064 }
2065 }
2066 _ => return Err(CommitValidationError::ActorNotMember),
2067 }
2068 }
2069
2070 let mut proposers: Vec<CommitParticipant> = Vec::new();
2072 for leaf_index in &proposer_leaf_indices {
2073 let participant = extract_commit_participant(
2074 leaf_index,
2075 openmls_group,
2076 immutable_metadata,
2077 mutable_metadata,
2078 )?;
2079 proposers.push(participant);
2080 }
2081
2082 let committer = extract_commit_participant(
2083 &committer_leaf_index,
2084 openmls_group,
2085 immutable_metadata,
2086 mutable_metadata,
2087 )?;
2088
2089 Ok((committer, proposers))
2090}
2091
2092pub fn validate_proposal(
2098 proposal: &QueuedProposal,
2099 openmls_group: &OpenMlsGroup,
2100 policy_set: &PolicySet,
2101 immutable_metadata: &GroupMetadata,
2102 mutable_metadata: &GroupMutableMetadata,
2103) -> Result<(), CommitValidationError> {
2104 let proposer = match proposal.sender() {
2106 Sender::Member(leaf_index) => extract_commit_participant(
2107 leaf_index,
2108 openmls_group,
2109 immutable_metadata,
2110 mutable_metadata,
2111 )?,
2112 Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
2113 return Err(CommitValidationError::ActorNotMember);
2115 }
2116 };
2117
2118 let unsupported_error =
2119 || CommitValidationError::UnsupportedProposalType(proposal.proposal().proposal_type());
2120
2121 match proposal.proposal() {
2123 Proposal::Add(add_proposal) => {
2124 let added_inbox_id =
2126 inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
2127 let inbox = Inbox {
2128 inbox_id: added_inbox_id.clone(),
2129 is_creator: false,
2130 is_admin: false,
2131 is_super_admin: false,
2132 proposer: Some(proposer.clone()),
2133 };
2134 if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
2135 let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
2137 (added_inbox_id == dm.member_one_inbox_id.as_ref()
2138 || added_inbox_id == dm.member_two_inbox_id.as_ref())
2139 && added_inbox_id != proposer.inbox_id
2140 });
2141 if !is_dm_add {
2142 tracing::warn!(
2143 proposer_inbox_id = %proposer.inbox_id,
2144 "Proposal rejected: proposer does not have permission to add members"
2145 );
2146 return Err(CommitValidationError::InsufficientPermissions);
2147 }
2148 }
2149 }
2150 Proposal::Remove(remove_proposal) => {
2151 let removed_member = openmls_group
2154 .member_at(remove_proposal.removed())
2155 .ok_or(CommitValidationError::SubjectDoesNotExist)?;
2156 let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
2157 let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
2158 let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
2159
2160 if removed_is_super_admin {
2162 tracing::warn!(
2163 proposer_inbox_id = %proposer.inbox_id,
2164 removed_inbox_id = %removed_inbox_id,
2165 "Proposal rejected: cannot remove super admin"
2166 );
2167 return Err(CommitValidationError::InsufficientPermissions);
2168 }
2169
2170 let removed_inbox = Inbox {
2171 inbox_id: removed_inbox_id.clone(),
2172 is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
2173 is_admin: removed_is_admin,
2174 is_super_admin: removed_is_super_admin,
2175 proposer: Some(proposer.clone()),
2176 };
2177
2178 if !policy_set
2179 .remove_member_policy
2180 .evaluate(&proposer, &removed_inbox)
2181 {
2182 tracing::warn!(
2183 proposer_inbox_id = %proposer.inbox_id,
2184 removed_inbox_id = %removed_inbox_id,
2185 "Proposal rejected: proposer does not have permission to remove members"
2186 );
2187 return Err(CommitValidationError::InsufficientPermissions);
2188 }
2189 }
2190 Proposal::GroupContextExtensions(gce_proposal) => {
2191 let existing_extensions = openmls_group.extensions();
2192 let new_extensions = gce_proposal.extensions();
2193
2194 let old_meta = find_mutable_metadata_extension(existing_extensions);
2196 let new_meta = find_mutable_metadata_extension(new_extensions);
2197 if old_meta.is_some() && new_meta.is_none() {
2198 tracing::warn!(
2199 proposer_inbox_id = %proposer.inbox_id,
2200 "GCE proposal rejected: cannot remove mutable metadata extension"
2201 );
2202 return Err(CommitValidationError::InsufficientPermissions);
2203 }
2204 if let (Some(old_meta), Some(new_meta)) = (old_meta, new_meta)
2205 && old_meta != new_meta
2206 {
2207 let metadata_changes = extract_metadata_changes(
2208 immutable_metadata,
2209 mutable_metadata,
2210 existing_extensions,
2211 new_extensions,
2212 )?;
2213
2214 for change in &metadata_changes.metadata_field_changes {
2215 if let Some(policy) = policy_set.update_metadata_policy.get(&change.field_name)
2216 && !policy.evaluate(&proposer, change)
2217 {
2218 tracing::warn!(
2219 proposer_inbox_id = %proposer.inbox_id,
2220 field = %change.field_name,
2221 "GCE proposal rejected: no permission to update metadata field"
2222 );
2223 return Err(CommitValidationError::InsufficientPermissions);
2224 }
2225 }
2226
2227 if !metadata_changes.admins_added.is_empty()
2228 && !policy_set.add_admin_policy.evaluate(&proposer)
2229 {
2230 tracing::warn!(
2231 proposer_inbox_id = %proposer.inbox_id,
2232 "GCE proposal rejected: no permission to add admins"
2233 );
2234 return Err(CommitValidationError::InsufficientPermissions);
2235 }
2236 if !metadata_changes.admins_removed.is_empty()
2237 && !policy_set.remove_admin_policy.evaluate(&proposer)
2238 {
2239 tracing::warn!(
2240 proposer_inbox_id = %proposer.inbox_id,
2241 "GCE proposal rejected: no permission to remove admins"
2242 );
2243 return Err(CommitValidationError::InsufficientPermissions);
2244 }
2245
2246 if (!metadata_changes.super_admins_added.is_empty()
2247 || !metadata_changes.super_admins_removed.is_empty())
2248 && !proposer.is_super_admin
2249 {
2250 tracing::warn!(
2251 proposer_inbox_id = %proposer.inbox_id,
2252 "GCE proposal rejected: only super admins can modify super admin list"
2253 );
2254 return Err(CommitValidationError::InsufficientPermissions);
2255 }
2256 }
2257
2258 let migrated_for_perms = super::app_data::is_migrated_extensions(existing_extensions);
2272 if migrated_for_perms {
2273 for (ext_id, ext_name) in [
2274 (
2275 xmtp_configuration::GROUP_PERMISSIONS_EXTENSION_ID,
2276 "GROUP_PERMISSIONS",
2277 ),
2278 (
2279 xmtp_configuration::MUTABLE_METADATA_EXTENSION_ID,
2280 "MUTABLE_METADATA",
2281 ),
2282 (
2283 xmtp_configuration::GROUP_MEMBERSHIP_EXTENSION_ID,
2284 "GROUP_MEMBERSHIP",
2285 ),
2286 ] {
2287 if find_unknown_extension(new_extensions, ext_id).is_some() {
2288 tracing::warn!(
2289 proposer_inbox_id = %proposer.inbox_id,
2290 extension = ext_name,
2291 "GCE proposal rejected: legacy extension cannot be (re-)added to a migrated group"
2292 );
2293 return Err(CommitValidationError::InsufficientPermissions);
2294 }
2295 }
2296 } else {
2297 let old_permissions: GroupMutablePermissions = existing_extensions.try_into()?;
2298 if !proposer.is_super_admin
2299 && let Ok(true) = extract_permissions_changed(&old_permissions, new_extensions)
2300 {
2301 tracing::warn!(
2302 proposer_inbox_id = %proposer.inbox_id,
2303 "GCE proposal rejected: only super admins can change permissions"
2304 );
2305 return Err(CommitValidationError::InsufficientPermissions);
2306 }
2307 }
2308 }
2309 Proposal::Update(update_proposal) => {
2310 let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
2313 if new_inbox_id != proposer.inbox_id {
2314 tracing::warn!(
2315 proposer_inbox_id = %proposer.inbox_id,
2316 proposer_installation_id = hex::encode(&proposer.installation_id),
2317 leaf_index = ?proposal.sender(),
2318 new_inbox_id = %new_inbox_id,
2319 new_installation_id = hex::encode(update_proposal.leaf_node().signature_key().as_slice()),
2320 "Update proposal rejected: new leaf node credential does not match proposer"
2321 );
2322 return Err(CommitValidationError::ActorNotMember);
2323 }
2324 }
2325 Proposal::PreSharedKey(_) => {
2326 return Err(unsupported_error());
2327 }
2328 Proposal::ReInit(_) => {
2329 return Err(unsupported_error());
2330 }
2331 Proposal::ExternalInit(_) => {
2332 return Err(unsupported_error());
2333 }
2334 Proposal::Custom(_) => {
2335 return Err(unsupported_error());
2336 }
2337 Proposal::AppDataUpdate(app_data) => {
2338 use super::app_data::load_component_registry;
2339 use xmtp_mls_common::app_data::{
2340 component_id::ComponentId, validation::ActorAuthority,
2341 };
2342
2343 let registry = load_component_registry(openmls_group)?;
2344
2345 validate_one_app_data_update(
2349 ComponentId::from(app_data.component_id()),
2350 app_data.operation(),
2351 ActorAuthority::from(&proposer),
2352 &proposer.inbox_id,
2353 ®istry,
2354 openmls_group,
2355 )?;
2356 }
2357 Proposal::AppEphemeral(_) => {
2358 return Err(unsupported_error());
2359 }
2360 Proposal::SelfRemove => {
2361 return Err(unsupported_error());
2362 }
2363 }
2364
2365 Ok(())
2366}
2367
2368impl From<&MetadataFieldChange> for MetadataFieldChangeProto {
2369 fn from(change: &MetadataFieldChange) -> Self {
2370 MetadataFieldChangeProto {
2371 field_name: change.field_name.clone(),
2372 old_value: change.old_value.clone(),
2373 new_value: change.new_value.clone(),
2374 }
2375 }
2376}
2377
2378impl From<&Inbox> for InboxProto {
2379 fn from(inbox: &Inbox) -> Self {
2380 InboxProto {
2381 inbox_id: inbox.inbox_id.clone(),
2382 }
2383 }
2384}
2385
2386impl FromWith<ValidatedCommit> for GroupUpdatedProto {
2389 type Params = Vec<String>;
2391
2392 fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
2393 use std::collections::HashSet;
2394
2395 let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
2397
2398 let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
2402 .removed_inboxes
2403 .into_iter()
2404 .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
2405
2406 GroupUpdatedProto {
2407 initiated_by_inbox_id: commit.actor.inbox_id.clone(),
2408 added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
2409 removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
2410 metadata_field_changes: commit
2411 .metadata_validation_info
2412 .metadata_field_changes
2413 .iter()
2414 .map(MetadataFieldChangeProto::from)
2415 .collect(),
2416 left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
2417 added_admin_inboxes: commit
2418 .metadata_validation_info
2419 .admins_added
2420 .iter()
2421 .map(InboxProto::from)
2422 .collect(),
2423 removed_admin_inboxes: commit
2424 .metadata_validation_info
2425 .admins_removed
2426 .iter()
2427 .map(InboxProto::from)
2428 .collect(),
2429 added_super_admin_inboxes: commit
2430 .metadata_validation_info
2431 .super_admins_added
2432 .iter()
2433 .map(InboxProto::from)
2434 .collect(),
2435 removed_super_admin_inboxes: commit
2436 .metadata_validation_info
2437 .super_admins_removed
2438 .iter()
2439 .map(InboxProto::from)
2440 .collect(),
2441 }
2442 }
2443}
2444
2445#[cfg(test)]
2446mod permission_on_receive_tests {
2447 use super::*;
2455 use openmls::messages::proposals::AppDataUpdateOperation;
2456 use xmtp_mls_common::app_data::{
2457 component_id::ComponentId, component_registry::ComponentRegistry,
2458 validation::ActorAuthority,
2459 };
2460
2461 fn non_admin_actor() -> ActorAuthority {
2462 ActorAuthority {
2463 is_admin: false,
2464 is_super_admin: false,
2465 }
2466 }
2467
2468 fn admin_actor() -> ActorAuthority {
2469 ActorAuthority {
2470 is_admin: true,
2471 is_super_admin: false,
2472 }
2473 }
2474
2475 #[xmtp_common::test(unwrap_try = true)]
2476 fn non_admin_writing_super_admin_only_component_is_rejected() {
2477 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2478 let registry = ComponentRegistry::new();
2479 let err = validate_one_app_data_update_with_old_value(
2480 ComponentId::COMPONENT_REGISTRY,
2481 &operation,
2482 non_admin_actor(),
2483 "test-inbox",
2484 ®istry,
2485 None,
2486 )
2487 .expect_err("non-admin write to super-admin-only component must be rejected");
2488 assert!(
2489 matches!(err, CommitValidationError::InsufficientPermissions),
2490 "expected InsufficientPermissions, got {err:?}"
2491 );
2492 }
2493
2494 #[xmtp_common::test(unwrap_try = true)]
2495 fn plain_admin_writing_super_admin_only_component_is_rejected() {
2496 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2497 let registry = ComponentRegistry::new();
2498 let err = validate_one_app_data_update_with_old_value(
2499 ComponentId::COMPONENT_REGISTRY,
2500 &operation,
2501 admin_actor(),
2502 "test-inbox",
2503 ®istry,
2504 None,
2505 )
2506 .expect_err("plain-admin write to super-admin-only component must be rejected");
2507 assert!(
2508 matches!(err, CommitValidationError::InsufficientPermissions),
2509 "expected InsufficientPermissions, got {err:?}"
2510 );
2511 }
2512
2513 #[xmtp_common::test(unwrap_try = true)]
2514 fn non_admin_writing_component_with_no_registry_entry_is_rejected() {
2515 let unknown_id = ComponentId::new(0x80FF);
2519 let operation = AppDataUpdateOperation::Update(vec![0u8; 16].into());
2520 let registry = ComponentRegistry::new();
2521 let err = validate_one_app_data_update_with_old_value(
2522 unknown_id,
2523 &operation,
2524 non_admin_actor(),
2525 "test-inbox",
2526 ®istry,
2527 None,
2528 )
2529 .expect_err("write to unregistered component must be rejected");
2530 assert!(
2531 matches!(err, CommitValidationError::InsufficientPermissions),
2532 "expected InsufficientPermissions, got {err:?}"
2533 );
2534 }
2535}
2536
2537#[cfg(test)]
2538mod min_version_monotonicity_tests {
2539 use super::*;
2540 use openmls::messages::proposals::AppDataUpdateOperation;
2541
2542 fn update_op(s: &str) -> AppDataUpdateOperation {
2543 AppDataUpdateOperation::Update(s.as_bytes().to_vec().into())
2544 }
2545
2546 #[xmtp_common::test(unwrap_try = true)]
2547 fn first_set_with_no_prior_floor_is_allowed() {
2548 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), None)?;
2549 }
2550
2551 #[xmtp_common::test(unwrap_try = true)]
2552 fn equal_version_is_allowed() {
2553 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0-dev"))?;
2554 }
2555
2556 #[xmtp_common::test(unwrap_try = true)]
2557 fn higher_version_is_allowed() {
2558 enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
2559 enforce_min_version_monotonicity(&update_op("1.12.0"), Some(b"1.11.0-dev"))?;
2560 enforce_min_version_monotonicity(&update_op("2.0.0"), Some(b"1.11.0-dev"))?;
2561 }
2562
2563 #[xmtp_common::test(unwrap_try = true)]
2564 fn lower_version_is_rejected() {
2565 let err = enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.11.0-dev"))
2566 .expect_err("downgrade must be rejected");
2567 assert!(
2568 matches!(
2569 err,
2570 CommitValidationError::MinVersionDowngrade { ref requested, ref current }
2571 if requested == "1.10.0" && current == "1.11.0-dev"
2572 ),
2573 "expected MinVersionDowngrade, got {err:?}",
2574 );
2575 }
2576
2577 #[xmtp_common::test(unwrap_try = true)]
2578 fn remove_with_prior_floor_is_rejected() {
2579 let err =
2580 enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, Some(b"1.11.0-dev"))
2581 .expect_err("remove on a set floor must be rejected");
2582 assert!(
2583 matches!(
2584 err,
2585 CommitValidationError::MinVersionRemoveOnExistingFloor { ref current }
2586 if current == "1.11.0-dev"
2587 ),
2588 "expected MinVersionRemoveOnExistingFloor, got {err:?}",
2589 );
2590 }
2591
2592 #[xmtp_common::test(unwrap_try = true)]
2593 fn remove_with_no_prior_floor_is_allowed() {
2594 enforce_min_version_monotonicity(&AppDataUpdateOperation::Remove, None)?;
2595 }
2596
2597 #[xmtp_common::test(unwrap_try = true)]
2598 fn malformed_prior_skips_check() {
2599 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"not-a-version"))?;
2602 enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(&[0xff, 0xfe, 0xfd]))?;
2603 }
2604
2605 #[xmtp_common::test(unwrap_try = true)]
2606 fn malformed_new_value_surfaces_parse_error() {
2607 let err =
2608 enforce_min_version_monotonicity(&update_op("not-a-version"), Some(b"1.11.0-dev"))
2609 .expect_err("malformed new value must error");
2610 assert!(
2611 matches!(err, CommitValidationError::InvalidVersionFormat(_)),
2612 "expected InvalidVersionFormat, got {err:?}",
2613 );
2614 }
2615
2616 #[xmtp_common::test(unwrap_try = true)]
2617 fn prerelease_ordering_matches_semver() {
2618 enforce_min_version_monotonicity(&update_op("1.10.0"), Some(b"1.10.0-rc.1"))?;
2622 let err = enforce_min_version_monotonicity(&update_op("1.10.0-rc.1"), Some(b"1.10.0"))
2623 .expect_err("rc → release reverse must be rejected");
2624 assert!(
2625 matches!(err, CommitValidationError::MinVersionDowngrade { .. }),
2626 "expected MinVersionDowngrade, got {err:?}",
2627 );
2628 }
2629
2630 #[xmtp_common::test(unwrap_try = true)]
2631 fn dev_prerelease_is_lower_than_release() {
2632 let dev = LibXMTPVersion::parse("1.11.0-dev")?;
2640 let release = LibXMTPVersion::parse("1.11.0")?;
2641 assert!(
2642 release > dev,
2643 "expected 1.11.0 > 1.11.0-dev per semver §11, got release={release:?} dev={dev:?}",
2644 );
2645 assert!(dev < release, "expected 1.11.0-dev < 1.11.0 per semver §11");
2646
2647 enforce_min_version_monotonicity(&update_op("1.11.0"), Some(b"1.11.0-dev"))?;
2648
2649 let err = enforce_min_version_monotonicity(&update_op("1.11.0-dev"), Some(b"1.11.0"))
2650 .expect_err("release → -dev reverse must be rejected");
2651 assert!(
2652 matches!(
2653 err,
2654 CommitValidationError::MinVersionDowngrade { ref requested, ref current }
2655 if requested == "1.11.0-dev" && current == "1.11.0"
2656 ),
2657 "expected MinVersionDowngrade, got {err:?}",
2658 );
2659 }
2660}