Skip to main content

xmtp_mls/groups/
validated_commit.rs

1#[cfg(test)]
2mod identity_tests;
3use crate::{
4    context::XmtpSharedContext,
5    groups::app_data::bootstrap_validator::BootstrapValidationError,
6    identity_updates::{
7        IdentityDependencyError, IdentityRequirement, InstallationDiff, InstallationDiffError,
8        get_installation_diff_local, require_association_state,
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 crate::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_common::{
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    commit::{
36        CommitParticipant, CommitRuleError, Inbox, MembershipValidationInfo, MetadataChanges,
37        ProposalChanges, build_inbox_with_proposer, extract_commit_participant,
38        extract_committer_and_proposers, extract_group_membership, extract_readded_installations,
39        get_current_group_members, get_latest_group_membership, get_proposal_changes,
40        inbox_id_from_credential, metadata_changes_between, read_committed_metadata,
41        read_post_commit_mutable_metadata, validate_app_data_update_proposals_in_commit,
42        validate_committed_proposals, validate_identity_sequence_order, validate_membership_diff,
43        validate_one_app_data_update,
44    },
45    group_membership::GroupMembership,
46    group_permissions::{GroupMutablePermissionsError, MembershipPolicy, PolicySet},
47};
48use xmtp_proto::xmtp::mls::message_contents::{
49    GroupMembershipChanges, GroupUpdated as GroupUpdatedProto,
50    group_updated::{Inbox as InboxProto, MetadataFieldChange as MetadataFieldChangeProto},
51};
52
53/// Commit validation failed. [`CommitRuleError`] holds rule violations;
54/// the other variants need client state (identity proofs, the database).
55#[derive(Debug, Error)]
56pub enum CommitValidationError {
57    /// Committed local state could not be read. Repair or restore before retry.
58    #[error("Committed group state is invalid: {0}")]
59    InstalledState(#[source] Box<CommitValidationError>),
60    /// Resolve this proof outside the state transaction, then reload MLS state.
61    #[error(transparent)]
62    IdentityDependency(#[from] IdentityDependencyError),
63    #[error(transparent)]
64    InstallationDiff(#[from] InstallationDiffError),
65    #[error(transparent)]
66    StorageError(#[from] StorageError),
67    /// All bootstrap-commit-validator failures. The bootstrap path runs
68    /// only during the one-time AppData migration; isolating its many
69    /// failure modes in a sub-enum keeps the steady-state validator's
70    /// surface from being dominated by migration-specific noise.
71    #[error(transparent)]
72    Bootstrap(#[from] BootstrapValidationError),
73    /// The commit breaks a validation rule.
74    #[error(transparent)]
75    Rule(#[from] CommitRuleError),
76}
77
78impl crate::worker::NeedsDbReconnect for CommitValidationError {
79    fn needs_db_reconnect(&self) -> bool {
80        match self {
81            Self::InstalledState(error) => error.needs_db_reconnect(),
82            Self::IdentityDependency(error) => error.needs_db_reconnect(),
83            Self::StorageError(error) => error.db_needs_connection(),
84            _ => false,
85        }
86    }
87}
88
89impl RetryableError for CommitValidationError {
90    fn is_retryable(&self) -> bool {
91        match self {
92            CommitValidationError::IdentityDependency(error) => retryable!(error),
93            CommitValidationError::InstallationDiff(diff_error) => retryable!(diff_error),
94            CommitValidationError::Rule(error) => retryable!(error),
95            _ => false,
96        }
97    }
98}
99
100impl CommitValidationError {
101    /// Mark a local state failure so malformed wire input cannot hide corruption.
102    pub(crate) fn installed_state(error: impl Into<Self>) -> Self {
103        Self::InstalledState(Box::new(error.into()))
104    }
105
106    /// Only authenticated, deterministic invalid input can advance the prefix.
107    /// Missing state, failed proofs, unsupported versions, and storage failures
108    /// leave the head pending even when their error is not retryable.
109    pub(crate) fn is_safe_rejection(&self) -> bool {
110        match self {
111            Self::IdentityDependency(
112                IdentityDependencyError::MissingReference(_)
113                | IdentityDependencyError::InvalidSequence(_),
114            ) => true,
115            Self::Rule(error) => error.is_safe_rejection(),
116            Self::Bootstrap(error) => !matches!(
117                error,
118                BootstrapValidationError::ProtocolVersionTooLow(_)
119                    | BootstrapValidationError::Synthesis(_)
120            ),
121            Self::InstalledState(_)
122            | Self::IdentityDependency(_)
123            | Self::InstallationDiff(_)
124            | Self::StorageError(_) => false,
125        }
126    }
127}
128
129/// Route a rule-layer error type through [`CommitValidationError::Rule`], so
130/// `?` keeps working on the calls the validator makes.
131macro_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/**
149 * A [`ValidatedCommit`] is a summary of changes coming from a MLS commit, after all of our validation rules have been applied
150 *
151 * Commit Validation Rules:
152 * 1. If the `sequence_id` for an inbox has changed, it can only increase
153 * 2. The client must create an expected diff of installations added and removed based on the difference between the current
154 *    [`GroupMembership`] and the [`GroupMembership`] found in the [`StagedCommit`]
155 * 3. Installations may only be added or removed in the commit if they were added/removed in the expected diff
156 * 4. For updates (either updating a path or via an Update Proposal) clients must verify that the `installation_id` is
157 *    present in the [`AssociationState`] for the `inbox_id` presented in the credential at the `to_sequence_id` found in the
158 *    new [`GroupMembership`].
159 * 5. All proposals must come from group members (proposer permissions are validated, not committer)
160 * 6. Only Add, Update, Remove, and AppDataUpdate proposals are allowed, in a valid list
161 * 7. New installations may be missing from the commit but still be present in the expected diff.
162 * 8. Confirms metadata character limit is not exceeded
163 */
164#[derive(Debug, Clone, Serialize)]
165pub struct ValidatedCommit {
166    /// The actor who created the commit (the committer)
167    pub actor: CommitParticipant,
168    /// All unique proposers who created proposals in this commit
169    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    /// Test helper for commits that have not received an envelope sequence.
182    #[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                    requirement,
200                ))) => {
201                    crate::identity_updates::resolve_identity_requirement(context, &requirement)
202                        .await?;
203                }
204                result => return result,
205            }
206        }
207    }
208
209    /// Validate with the caller's write connection and exact cached proofs.
210    /// This method cannot fetch identities or call an async signature verifier.
211    /// Every referenced identity sequence `N` must precede envelope sequence `S`.
212    /// On `Need`, roll back, resolve outside the writer, then reload MLS state.
213    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        // PAUSE BEFORE PARSE: when the group's committed floor already
222        // exceeds this client's version, every migrated-state read
223        // below (dict-seeded metadata, registry loads, per-proposal
224        // dispatch) may encounter wire formats introduced after this
225        // version — and any error they raise is a non-retryable
226        // rejection, i.e. a fork against above-floor peers. Surface
227        // the version gap first so the group pauses and the commit is
228        // reprocessed after upgrade. Deliberately reads only the
229        // pre-commit dict (committed, already-validated state) — the
230        // commit that *raises* the floor is instead paused by the
231        // post-policy check at the end of this function, after its
232        // super-admin permission has been verified. See
233        // `committed_floor_exceeding` for the full rationale.
234        if let Some(min_version) =
235            xmtp_mls_common::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        // implements: GMOD-001, GMOD-030
245        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 current_group_members = get_current_group_members(openmls_group);
262
263        // Reuse the committed registry for all component checks. Apply pending
264        // floor updates to this view, then check their policy before pausing.
265        let registry = super::app_data::load_component_registry(openmls_group)
266            .map_err(CommitValidationError::installed_state)?;
267        let min_version_bytes =
268                xmtp_mls_common::app_data::component_source::read_post_commit_component_bytes(
269                    xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
270                    openmls_group,
271                    staged_commit,
272                    &registry,
273                )
274                .map_err(xmtp_mls_common::group_mutable_metadata::GroupMutableMetadataError::from)?;
275        let minimum_supported_protocol_version = match min_version_bytes {
276                Some(bytes) => Some(String::from_utf8(bytes).map_err(|e| {
277                    CommitValidationError::Rule(CommitRuleError::GroupMutableMetadata(
278                        GroupMutableMetadataError::MalformedComponent {
279                            component_id: Some(
280                                xmtp_mls_common::app_data::component_id::ComponentId::MIN_SUPPORTED_PROTOCOL_VERSION,
281                            ),
282                            reason: format!("invalid utf-8: {e}"),
283                        },
284                    ))
285                })?),
286                None => None,
287            };
288        // Get the committer who created the commit and all unique proposers.
289        // The committer may differ from the proposers (e.g., when one member commits
290        // proposals created by other members).
291        let (actor, proposers) = extract_committer_and_proposers(
292            staged_commit,
293            committer_leaf_index,
294            openmls_group,
295            &immutable_metadata,
296            &mutable_metadata,
297        )?;
298
299        // AppDataUpdate proposals carried by a commit (inline OR by
300        // reference, since `staged_commit.app_data_update_proposals()`
301        // iterates both) never flow through `validate_proposal()` —
302        // that path only handles standalone proposal-by-reference
303        // messages — so this is where their permission check lives.
304        validate_app_data_update_proposals_in_commit(
305            staged_commit,
306            openmls_group,
307            &immutable_metadata,
308            &mutable_metadata,
309            &registry,
310        )?;
311
312        // Get the installations actually added and removed in the commit
313        let ProposalChanges {
314            mut added_installations,
315            mut removed_installations,
316            mut credentials_to_verify,
317            added_inbox_proposers,
318            removed_inbox_proposers,
319        } = get_proposal_changes(
320            staged_commit,
321            openmls_group,
322            &immutable_metadata,
323            &mutable_metadata,
324        )?;
325
326        // Get the expected diff of installations added and removed based on the difference between the current
327        // group membership and the new group membership.
328        // Also gets back the added and removed inbox ids from the expected diff
329        let expected_diff = ExpectedDiff::from_staged_commit_with_proposers(
330            conn,
331            staged_commit,
332            openmls_group,
333            envelope_sequence,
334            &added_inbox_proposers,
335            &removed_inbox_proposers,
336        )?;
337
338        let ExpectedDiff {
339            old_group_membership,
340            new_group_membership,
341            expected_installation_diff,
342            added_inboxes,
343            removed_inboxes,
344        } = expected_diff;
345
346        let installations_changed =
347            !added_installations.is_empty() || !removed_installations.is_empty();
348
349        let mut failed_installations: HashSet<Vec<u8>> = new_group_membership
350            .failed_installations
351            .iter()
352            .cloned()
353            .collect();
354
355        // A deleted inbox has no post-commit entry to retain its failures.
356        // Keep authenticated old failures only when they have no current leaf.
357        failed_installations.extend(
358            old_group_membership
359                .failed_installations
360                .iter()
361                .filter(|id| !current_group_members.contains(*id))
362                .cloned(),
363        );
364
365        // Remove readded installations from the added/removed/failed lists before going through validation
366        let readded_installations = extract_readded_installations(
367            &actor,
368            &mut added_installations,
369            &mut removed_installations,
370            &mut failed_installations,
371        );
372        // Ensure that the expected diff matches the added/removed installations in the proposals
373        expected_diff_matches_commit(
374            &expected_installation_diff,
375            added_installations,
376            removed_installations,
377            current_group_members,
378            failed_installations,
379        )?;
380        credentials_to_verify.push(actor.clone());
381
382        // Verify the credentials of the following entities
383        // 1. The actor who created the commit
384        // 2. Anyone referenced in an update proposal
385        // Satisfies Rule 4
386        for participant in credentials_to_verify {
387            let inbox_id = &participant.inbox_id;
388            let sequence_id = match new_group_membership.get(inbox_id) {
389                None => {
390                    return Err(CommitValidationError::Rule(
391                        CommitRuleError::SubjectDoesNotExist,
392                    ));
393                }
394                Some(0) if old_group_membership.get(inbox_id) == Some(&0) => {
395                    // An unchanged creation placeholder uses the authenticated
396                    // committed leaf. A later identity tip cannot change this
397                    // decision. New keys still require an exact nonzero proof.
398                    let known_leaf = openmls_group.members().any(|member| {
399                        member.signature_key == participant.installation_id
400                            && inbox_id_from_credential(&member.credential)
401                                .is_ok_and(|known_inbox| known_inbox == *inbox_id)
402                    });
403                    if !known_leaf {
404                        return Err(CommitValidationError::Rule(
405                            CommitRuleError::InboxValidationFailed(inbox_id.clone()),
406                        ));
407                    }
408                    continue;
409                }
410                Some(sequence_id) => *sequence_id,
411            };
412            let inbox_state = require_association_state(
413                conn,
414                &IdentityRequirement {
415                    inbox_id: inbox_id.clone(),
416                    sequence_id,
417                },
418            )?;
419
420            if inbox_state
421                .get(&MemberIdentifier::installation(participant.installation_id))
422                .is_none()
423            {
424                return Err(CommitValidationError::Rule(
425                    CommitRuleError::InboxValidationFailed(participant.inbox_id),
426                ));
427            }
428        }
429
430        let membership = MembershipValidationInfo {
431            actor: &actor,
432            added_inboxes: &added_inboxes,
433            removed_inboxes: &removed_inboxes,
434            dm_members: immutable_metadata.dm_members.as_ref(),
435        };
436        if !group_permissions.policies.evaluate_membership(&membership) {
437            return Err(CommitValidationError::Rule(
438                CommitRuleError::InsufficientPermissions,
439            ));
440        }
441        if let Some(min_version) = &minimum_supported_protocol_version {
442            let current_version = context.version_info().pkg_semver();
443            let min_supported_version = LibXMTPVersion::parse(min_version)?;
444            tracing::info!(
445                "Validating commit with min_supported_version: {:?}, current_version: {:?}",
446                min_supported_version,
447                current_version
448            );
449
450            if min_supported_version > *current_version {
451                return Err(CommitValidationError::Rule(
452                    CommitRuleError::ProtocolVersionTooLow(min_version.clone()),
453                ));
454            }
455        }
456        // Component policies have already checked each authenticated proposer.
457        // Build the change summary after authorization, so legacy actor-based
458        // checks do not reject a valid proposal committed by another member.
459        let post_metadata =
460            read_post_commit_mutable_metadata(openmls_group, staged_commit, &registry)?;
461        let metadata_changes =
462            metadata_changes_between(&immutable_metadata, &mutable_metadata, &post_metadata);
463        let mut proposed_component_ids: Vec<u16> = staged_commit
464            .app_data_update_proposals()
465            .map(|queued| queued.app_data_update_proposal().component_id())
466            .collect();
467        proposed_component_ids.sort_unstable();
468        proposed_component_ids.dedup();
469        let mut metadata_component_ids = Vec::new();
470        for raw_id in proposed_component_ids {
471            let id = xmtp_mls_common::app_data::component_id::ComponentId::from(raw_id);
472            let before = read_from_app_data_dict(id, openmls_group);
473            let after =
474                xmtp_mls_common::app_data::component_source::read_post_commit_component_bytes(
475                    id,
476                    openmls_group,
477                    staged_commit,
478                    &registry,
479                )
480                .map_err(GroupMutableMetadataError::from)?;
481            if before != after {
482                metadata_component_ids.push(raw_id);
483            }
484        }
485        let permissions_changed = metadata_component_ids.contains(
486            &xmtp_mls_common::app_data::component_id::ComponentId::COMPONENT_REGISTRY.as_u16(),
487        );
488        Ok(Self {
489            actor,
490            proposers,
491            added_inboxes,
492            removed_inboxes,
493            readded_installations,
494            metadata_changes,
495            metadata_component_ids,
496            installations_changed,
497            permissions_changed,
498            dm_members: immutable_metadata.dm_members.clone(),
499        })
500    }
501
502    // Reuse intent kind here to represent the commit type, even if it's an external commit
503    // This is for debugging purposes only, so an approximation is fine
504    pub fn debug_commit_type(&self) -> CommitType {
505        let metadata_info = &self.metadata_changes;
506        if !self.added_inboxes.is_empty()
507            || !self.removed_inboxes.is_empty()
508            || self.installations_changed
509        {
510            CommitType::UpdateGroupMembership
511        } else if self.permissions_changed {
512            CommitType::UpdatePermission
513        } else if !metadata_info.admins_added.is_empty()
514            || !metadata_info.admins_removed.is_empty()
515            || !metadata_info.super_admins_added.is_empty()
516            || !metadata_info.super_admins_removed.is_empty()
517        {
518            CommitType::UpdateAdminList
519        } else if !metadata_info.metadata_field_changes.is_empty() {
520            CommitType::MetadataUpdate
521        } else {
522            CommitType::KeyUpdate
523        }
524    }
525
526    pub fn is_empty(&self) -> bool {
527        self.added_inboxes.is_empty()
528            && self.removed_inboxes.is_empty()
529            && self.metadata_changes.is_empty()
530    }
531
532    pub fn actor_inbox_id(&self) -> InboxId {
533        self.actor.inbox_id.clone()
534    }
535
536    pub fn actor_installation_id(&self) -> Vec<u8> {
537        self.actor.installation_id.clone()
538    }
539}
540
541impl From<ValidatedCommit> for GroupMembershipChanges {
542    fn from(_commit: ValidatedCommit) -> Self {
543        // TODO: Use new GroupMembershipChanges
544
545        GroupMembershipChanges {
546            members_added: vec![],
547            members_removed: vec![],
548            installations_added: vec![],
549            installations_removed: vec![],
550        }
551    }
552}
553
554/// Membership changes derived from the exact old and proposed identity proofs.
555struct ExpectedDiff {
556    /// The membership before this commit. The commit cannot change it.
557    old_group_membership: GroupMembership,
558    /// Proposed inbox sequences. They cannot rewrite the old proof requirements.
559    new_group_membership: GroupMembership,
560    /// Installation changes authorized by those exact identity snapshots.
561    expected_installation_diff: InstallationDiff,
562    added_inboxes: Vec<Inbox>,
563    removed_inboxes: Vec<Inbox>,
564}
565
566impl ExpectedDiff {
567    /// Derive installation changes from cached proofs on the caller's connection.
568    /// Missing exact proofs return `Need`; this method does not fetch identities.
569    #[allow(clippy::too_many_arguments)]
570    pub(super) fn from_staged_commit_with_proposers(
571        conn: &impl DbQuery,
572        staged_commit: &StagedCommit,
573        openmls_group: &OpenMlsGroup,
574        envelope_sequence: u64,
575        added_inbox_proposers: &HashMap<String, CommitParticipant>,
576        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
577    ) -> Result<Self, CommitValidationError> {
578        let extensions = openmls_group.extensions();
579        let (immutable_metadata, mutable_metadata) = read_committed_metadata(openmls_group)
580            .map_err(CommitValidationError::installed_state)?;
581
582        let expected_diff = Self::extract_expected_diff_with_proposers(
583            conn,
584            staged_commit,
585            envelope_sequence,
586            extensions,
587            &immutable_metadata,
588            &mutable_metadata,
589            added_inbox_proposers,
590            removed_inbox_proposers,
591        )?;
592
593        Ok(expected_diff)
594    }
595
596    /// Generates an expected diff with proposer attribution for each inbox change.
597    /// This is used when validating commits with proposals from multiple members.
598    #[allow(clippy::too_many_arguments)]
599    fn extract_expected_diff_with_proposers(
600        conn: &impl DbQuery,
601        staged_commit: &StagedCommit,
602        envelope_sequence: u64,
603        existing_group_extensions: &Extensions<GroupContext>,
604        immutable_metadata: &GroupMetadata,
605        mutable_metadata: &GroupMutableMetadata,
606        added_inbox_proposers: &HashMap<String, CommitParticipant>,
607        removed_inbox_proposers: &HashMap<String, CommitParticipant>,
608    ) -> Result<ExpectedDiff, CommitValidationError> {
609        let old_group_membership = extract_group_membership(existing_group_extensions)
610            .map_err(CommitValidationError::installed_state)?;
611        let new_group_membership = get_latest_group_membership(staged_commit)?;
612        validate_identity_sequence_order(&new_group_membership, envelope_sequence)?;
613        let membership_diff = old_group_membership.diff(&new_group_membership);
614
615        validate_membership_diff(
616            &old_group_membership,
617            &new_group_membership,
618            &membership_diff,
619        )?;
620
621        let added_inboxes = membership_diff
622            .added_inboxes
623            .iter()
624            .map(|inbox_id| {
625                let proposer = added_inbox_proposers
626                    .get(inbox_id.as_str())
627                    .cloned()
628                    .ok_or(CommitValidationError::Rule(
629                        CommitRuleError::ProposerNotFound,
630                    ))?;
631                Ok(build_inbox_with_proposer(
632                    inbox_id,
633                    immutable_metadata,
634                    mutable_metadata,
635                    proposer,
636                ))
637            })
638            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
639
640        let removed_inboxes = membership_diff
641            .removed_inboxes
642            .iter()
643            .map(|inbox_id| {
644                let proposer = removed_inbox_proposers
645                    .get(inbox_id.as_str())
646                    .cloned()
647                    .ok_or(CommitValidationError::Rule(
648                        CommitRuleError::ProposerNotFound,
649                    ))?;
650                Ok(build_inbox_with_proposer(
651                    inbox_id,
652                    immutable_metadata,
653                    mutable_metadata,
654                    proposer,
655                ))
656            })
657            .collect::<Result<Vec<Inbox>, CommitValidationError>>()?;
658
659        let expected_installation_diff = get_installation_diff_local(
660            conn,
661            &old_group_membership,
662            &new_group_membership,
663            &membership_diff,
664        )?;
665
666        Ok(ExpectedDiff {
667            old_group_membership,
668            new_group_membership,
669            expected_installation_diff,
670            added_inboxes,
671            removed_inboxes,
672        })
673    }
674}
675
676/// Compare the list of installations added and removed in the commit to the expected diff based on the changes
677/// to the inbox state.
678/// Satisfies Rule 3 and Rule 7
679// implements: GMOD-010, GMOD-011
680fn expected_diff_matches_commit(
681    expected_diff: &InstallationDiff,
682    added_installations: HashSet<Vec<u8>>,
683    removed_installations: HashSet<Vec<u8>>,
684    existing_installation_ids: HashSet<Vec<u8>>,
685    failed_installation_ids: HashSet<Vec<u8>>,
686) -> Result<(), CommitValidationError> {
687    // Check and make sure that any added installations are either:
688    // 1. In the expected diff
689    // 2. Already a member of the group (for example, the group creator is already a member on the first commit)
690
691    let unknown_adds = added_installations
692        .into_iter()
693        .filter(|installation_id| {
694            !expected_diff.added_installations.contains(installation_id)
695                && !existing_installation_ids.contains(installation_id)
696        })
697        .collect::<Vec<Vec<u8>>>();
698    if !unknown_adds.is_empty() {
699        return Err(CommitValidationError::Rule(
700            CommitRuleError::UnexpectedInstallationAdded(unknown_adds),
701        ));
702    }
703
704    let filtered_expected: HashSet<_> = expected_diff
705        .removed_installations
706        .iter()
707        .filter(|id| !failed_installation_ids.contains(*id))
708        .cloned()
709        .collect();
710
711    if removed_installations != filtered_expected {
712        let unexpected: Vec<_> = removed_installations
713            .difference(&expected_diff.removed_installations)
714            .cloned()
715            .collect();
716
717        return Err(CommitValidationError::Rule(
718            CommitRuleError::UnexpectedInstallationsRemoved(unexpected),
719        ));
720    }
721
722    Ok(())
723}
724
725/// Validates a single proposal by checking if the proposer has the required permissions.
726/// Returns Ok(()) if the proposal is valid, or an error if validation fails.
727///
728/// This function should be called when receiving proposals to ensure they are valid
729/// before they are stored and later committed.
730pub fn validate_proposal(
731    proposal: &QueuedProposal,
732    openmls_group: &OpenMlsGroup,
733    policy_set: &PolicySet,
734    immutable_metadata: &GroupMetadata,
735    mutable_metadata: &GroupMutableMetadata,
736) -> Result<(), CommitValidationError> {
737    // Extract the proposer from the proposal
738    let proposer = match proposal.sender() {
739        Sender::Member(leaf_index) => extract_commit_participant(
740            leaf_index,
741            openmls_group,
742            immutable_metadata,
743            mutable_metadata,
744        )?,
745        Sender::External(_) | Sender::NewMemberCommit | Sender::NewMemberProposal => {
746            // External and new member proposals are not supported
747            return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
748        }
749    };
750
751    let unsupported_error = || {
752        CommitValidationError::Rule(CommitRuleError::UnsupportedProposalType(
753            proposal.proposal().proposal_type(),
754        ))
755    };
756
757    // Validate based on proposal type
758    match proposal.proposal() {
759        Proposal::Add(add_proposal) => {
760            // Check if the proposer has permission to add members
761            let added_inbox_id =
762                inbox_id_from_credential(add_proposal.key_package().leaf_node().credential())?;
763            let inbox = Inbox {
764                inbox_id: added_inbox_id.clone(),
765                is_creator: false,
766                is_admin: false,
767                is_super_admin: false,
768                proposer: Some(proposer.clone()),
769            };
770            if !policy_set.add_member_policy.evaluate(&proposer, &inbox) {
771                // DM bypass: allow adding the other DM participant even if policy denies
772                let is_dm_add = immutable_metadata.dm_members.as_ref().is_some_and(|dm| {
773                    (added_inbox_id == dm.member_one_inbox_id.as_ref()
774                        || added_inbox_id == dm.member_two_inbox_id.as_ref())
775                        && added_inbox_id != proposer.inbox_id
776                });
777                if !is_dm_add {
778                    tracing::warn!(
779                        proposer_inbox_id = %proposer.inbox_id,
780                        "Proposal rejected: proposer does not have permission to add members"
781                    );
782                    return Err(CommitValidationError::Rule(
783                        CommitRuleError::InsufficientPermissions,
784                    ));
785                }
786            }
787        }
788        Proposal::Remove(remove_proposal) => {
789            // Check if the proposer has permission to remove members
790            // Get the inbox_id of the member being removed
791            let removed_member = openmls_group.member_at(remove_proposal.removed()).ok_or(
792                CommitValidationError::Rule(CommitRuleError::SubjectDoesNotExist),
793            )?;
794            let removed_inbox_id = inbox_id_from_credential(&removed_member.credential)?;
795            let removed_is_admin = mutable_metadata.admin_list.contains(&removed_inbox_id);
796            let removed_is_super_admin = mutable_metadata.is_super_admin(&removed_inbox_id);
797
798            // Super admins cannot be removed
799            if removed_is_super_admin {
800                tracing::warn!(
801                    proposer_inbox_id = %proposer.inbox_id,
802                    removed_inbox_id = %removed_inbox_id,
803                    "Proposal rejected: cannot remove super admin"
804                );
805                return Err(CommitValidationError::Rule(
806                    CommitRuleError::InsufficientPermissions,
807                ));
808            }
809
810            let removed_inbox = Inbox {
811                inbox_id: removed_inbox_id.clone(),
812                is_creator: immutable_metadata.creator_inbox_id == removed_inbox_id,
813                is_admin: removed_is_admin,
814                is_super_admin: removed_is_super_admin,
815                proposer: Some(proposer.clone()),
816            };
817
818            if !policy_set
819                .remove_member_policy
820                .evaluate(&proposer, &removed_inbox)
821            {
822                tracing::warn!(
823                    proposer_inbox_id = %proposer.inbox_id,
824                    removed_inbox_id = %removed_inbox_id,
825                    "Proposal rejected: proposer does not have permission to remove members"
826                );
827                return Err(CommitValidationError::Rule(
828                    CommitRuleError::InsufficientPermissions,
829                ));
830            }
831        }
832        Proposal::GroupContextExtensions(_) => return Err(unsupported_error()),
833        Proposal::Update(update_proposal) => {
834            // Update proposals are allowed for the member themselves, but the new leaf node's
835            // credential must match the proposer's identity to prevent identity swaps.
836            let new_inbox_id = inbox_id_from_credential(update_proposal.leaf_node().credential())?;
837            if new_inbox_id != proposer.inbox_id {
838                tracing::warn!(
839                    proposer_inbox_id = %proposer.inbox_id,
840                    proposer_installation_id = %proposer.installation_id.as_slice().snippet(),
841                    leaf_index = ?proposal.sender(),
842                    new_inbox_id = %new_inbox_id,
843                    new_installation_id = %update_proposal.leaf_node().signature_key().as_slice().snippet(),
844                    "Update proposal rejected: new leaf node credential does not match proposer"
845                );
846                return Err(CommitValidationError::Rule(CommitRuleError::ActorNotMember));
847            }
848        }
849        Proposal::PreSharedKey(_) => {
850            return Err(unsupported_error());
851        }
852        Proposal::ReInit(_) => {
853            return Err(unsupported_error());
854        }
855        Proposal::ExternalInit(_) => {
856            return Err(unsupported_error());
857        }
858        Proposal::Custom(_) => {
859            return Err(unsupported_error());
860        }
861        Proposal::AppDataUpdate(app_data) => {
862            use super::app_data::load_component_registry;
863            use xmtp_mls_common::app_data::{
864                component_id::ComponentId, validation::ActorAuthority,
865            };
866
867            let registry = load_component_registry(openmls_group)?;
868
869            // Delegate to the shared helper so the commit-time path
870            // (`validate_app_data_update_proposals_in_commit`) and this
871            // standalone-proposal-by-reference path can't drift apart.
872            validate_one_app_data_update(
873                ComponentId::from(app_data.component_id()),
874                app_data.operation(),
875                ActorAuthority::from(&proposer),
876                &proposer.inbox_id,
877                &registry,
878                openmls_group,
879                immutable_metadata.dm_members.as_ref(),
880            )?;
881        }
882        Proposal::AppEphemeral(_) => {
883            return Err(unsupported_error());
884        }
885        Proposal::SelfRemove => {
886            return Err(unsupported_error());
887        }
888    }
889
890    Ok(())
891}
892
893// Implement the generic conversion: the TARGET (GroupUpdatedProto) declares what params it needs.
894// Here it's `BuildOpts`, but it could be `&dyn Policy`, `&[u8]`, etc.
895impl FromWith<ValidatedCommit> for GroupUpdatedProto {
896    /// Extra parameter is a list of inbox IDs who requested self-removal (pending removals).
897    type Params = Vec<String>;
898
899    fn from_with(commit: ValidatedCommit, pending_removals: &Self::Params) -> Self {
900        use std::collections::HashSet;
901
902        // Convert the pending removals list into a set for fast lookup
903        let pending_set: HashSet<&str> = pending_removals.iter().map(String::as_str).collect();
904
905        // Partition removed inboxes:
906        //  - left_inboxes: those present in pending_removals
907        //  - removed_inboxes: all others
908        let (left_inboxes, removed_inboxes): (Vec<Inbox>, Vec<Inbox>) = commit
909            .removed_inboxes
910            .into_iter()
911            .partition(|inb| pending_set.contains(inb.inbox_id.as_str()));
912
913        GroupUpdatedProto {
914            initiated_by_inbox_id: commit.actor.inbox_id.clone(),
915            added_inboxes: commit.added_inboxes.iter().map(InboxProto::from).collect(),
916            removed_inboxes: removed_inboxes.iter().map(InboxProto::from).collect(),
917            metadata_field_changes: commit
918                .metadata_changes
919                .metadata_field_changes
920                .iter()
921                .map(MetadataFieldChangeProto::from)
922                .collect(),
923            left_inboxes: left_inboxes.iter().map(InboxProto::from).collect(),
924            added_admin_inboxes: commit
925                .metadata_changes
926                .admins_added
927                .iter()
928                .map(InboxProto::from)
929                .collect(),
930            removed_admin_inboxes: commit
931                .metadata_changes
932                .admins_removed
933                .iter()
934                .map(InboxProto::from)
935                .collect(),
936            added_super_admin_inboxes: commit
937                .metadata_changes
938                .super_admins_added
939                .iter()
940                .map(InboxProto::from)
941                .collect(),
942            removed_super_admin_inboxes: commit
943                .metadata_changes
944                .super_admins_removed
945                .iter()
946                .map(InboxProto::from)
947                .collect(),
948        }
949    }
950}
951
952#[cfg(test)]
953mod committed_proposal_outcomes {
954    use super::*;
955    use crate::groups::app_data::accumulate_app_data_updates;
956    use openmls::messages::proposals::{
957        AppDataUpdateOperation, AppDataUpdateProposal, ProposalType,
958    };
959    use openmls_traits::OpenMlsProvider;
960    use xmtp_mls_common::app_data::component_id::ComponentId;
961
962    /// verifies: GMOD-030
963    ///
964    /// A staged commit whose `AppDataUpdate` proposals mix an Update and a
965    /// Remove, or repeat a Remove, for one component is a terminal rejection.
966    /// The commits carry no `GroupContextExtensions` proposal, so OpenMLS's
967    /// own list check returns early at the first Remove and stages them.
968    #[xmtp_common::test(unwrap_try = true)]
969    async fn committed_proposal_outcomes_reject_conflicting_component_operations() {
970        use crate::tester;
971        tester!(alix, disable_workers);
972        let conversation = alix.create_group(None, None)?;
973        conversation
974            .update_group_description("described".to_string())
975            .await?;
976        let provider = alix.context.mls_provider();
977        let signer = &alix.context.identity().installation_keys;
978        let mut group = OpenMlsGroup::load(
979            alix.context.mls_storage(),
980            &conversation.group_id.to_openmls(),
981        )??;
982        let description = ComponentId::GROUP_DESCRIPTION;
983        assert!(read_from_app_data_dict(description, &group).is_some());
984
985        // One operation is proposed by reference and one is inline: two
986        // identical Removes proposed by reference would share a reference.
987        let update = || AppDataUpdateOperation::Update(b"changed".to_vec().into());
988        for (by_reference, inline) in [
989            (
990                update(),
991                AppDataUpdateProposal::remove(description.as_u16()),
992            ),
993            (
994                AppDataUpdateOperation::Remove,
995                AppDataUpdateProposal::update(description.as_u16(), b"changed".to_vec()),
996            ),
997            (
998                AppDataUpdateOperation::Remove,
999                AppDataUpdateProposal::remove(description.as_u16()),
1000            ),
1001        ] {
1002            group.propose_app_data_update(&provider, signer, description.as_u16(), by_reference)?;
1003            let inline = Proposal::AppDataUpdate(Box::new(inline));
1004            let pending: Vec<_> = group
1005                .pending_proposals()
1006                .map(QueuedProposal::proposal)
1007                .chain([&inline])
1008                .filter_map(|proposal| match proposal {
1009                    Proposal::AppDataUpdate(p) => Some((p.component_id(), p.operation().clone())),
1010                    _ => None,
1011                })
1012                .collect();
1013            let updates =
1014                accumulate_app_data_updates(&group, pending.iter().map(|(id, op)| (*id, op)))?;
1015            let mut stage = group
1016                .commit_builder()
1017                .consume_proposal_store(true)
1018                .add_proposal(inline)
1019                .load_psks(provider.storage())?;
1020            stage.with_app_data_dictionary_updates(updates);
1021            stage
1022                .build(provider.rand(), provider.crypto(), signer, |_| true)?
1023                .stage_commit(&provider)?;
1024            let staged = group.pending_commit()?;
1025            let kinds: Vec<_> = staged
1026                .queued_proposals()
1027                .map(|queued| queued.proposal().proposal_type())
1028                .collect();
1029            assert_eq!(kinds, [ProposalType::AppDataUpdate; 2]);
1030
1031            let result = ValidatedCommit::from_staged_commit_local(
1032                &alix.context,
1033                &alix.context.db(),
1034                staged,
1035                group.own_leaf_index(),
1036                &group,
1037                u64::MAX,
1038            );
1039            let Err(error) = result else {
1040                panic!("conflicting component operations were accepted");
1041            };
1042            assert!(
1043                matches!(
1044                    &error,
1045                    CommitValidationError::Rule(CommitRuleError::InvalidAppDataUpdateList(id))
1046                        if *id == description
1047                ),
1048                "{error:?}"
1049            );
1050            assert!(error.is_safe_rejection());
1051
1052            group.clear_pending_commit(provider.storage())?;
1053            group.clear_pending_proposals(provider.storage())?;
1054        }
1055    }
1056
1057    /// verifies: GMOD-001
1058    ///
1059    /// A `GroupContextExtensions` proposal is held both standalone and inside
1060    /// a commit.
1061    #[xmtp_common::test(unwrap_try = true)]
1062    async fn committed_proposal_outcomes_hold_gce_proposal_and_commit() {
1063        use crate::tester;
1064        tester!(alix, disable_workers);
1065        let conversation = alix.create_group(None, None)?;
1066        let provider = alix.context.mls_provider();
1067        let mut group = OpenMlsGroup::load(
1068            alix.context.mls_storage(),
1069            &conversation.group_id.to_openmls(),
1070        )??;
1071        let signer = &alix.context.identity().installation_keys;
1072        group.propose_group_context_extensions(&provider, group.extensions().clone(), signer)?;
1073        let (immutable, mutable) = read_committed_metadata(&group)?;
1074        let proposal = group.pending_proposals().next()?;
1075        assert!(matches!(
1076            validate_proposal(
1077                proposal,
1078                &group,
1079                &PolicySet::default(),
1080                &immutable,
1081                &mutable
1082            ),
1083            Err(CommitValidationError::Rule(
1084                CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1085            ))
1086        ));
1087        group.commit_to_pending_proposals(&provider, signer)?;
1088        let staged = group.pending_commit()?;
1089        assert!(matches!(
1090            ValidatedCommit::from_staged_commit_local(
1091                &alix.context,
1092                &alix.context.db(),
1093                staged,
1094                group.own_leaf_index(),
1095                &group,
1096                u64::MAX,
1097            ),
1098            Err(CommitValidationError::Rule(
1099                CommitRuleError::UnsupportedProposalType(ProposalType::GroupContextExtensions)
1100            ))
1101        ));
1102    }
1103}
1104
1105#[cfg(all(test, not(target_arch = "wasm32")))]
1106mod membership_log_tests {
1107    use std::sync::Arc;
1108
1109    use xmtp_logging::{Level, LogRecord, LogSinkTarget, SinkError, test_logging::LogCapture};
1110    use xmtp_mls_validation::{
1111        commit::{CommitParticipant, Inbox, MembershipValidationInfo},
1112        group_permissions::{MembershipPolicies, PolicySet},
1113    };
1114
1115    #[derive(Default)]
1116    struct Capture(parking_lot::Mutex<Vec<LogRecord>>);
1117
1118    impl LogSinkTarget for Capture {
1119        fn on_record(&self, record: LogRecord) -> Result<(), SinkError> {
1120            self.0.lock().push(record);
1121            Ok(())
1122        }
1123    }
1124
1125    #[rstest::rstest]
1126    #[case::add_committer(false, false)]
1127    #[case::add_proposer(false, true)]
1128    #[case::remove_committer(true, false)]
1129    #[case::remove_proposer(true, true)]
1130    #[xmtp_common::test(unwrap_try = true)]
1131    fn membership_rejection_log_omits_full_ids(#[case] remove: bool, #[case] has_proposer: bool) {
1132        let actor_id = (32_u8..64).collect::<Vec<_>>();
1133        let proposer_id = (64_u8..96).collect::<Vec<_>>();
1134        let actor = CommitParticipant {
1135            inbox_id: "sentinel-actor-inbox".to_string(),
1136            installation_id: actor_id.clone(),
1137            is_creator: false,
1138            is_admin: true,
1139            is_super_admin: false,
1140        };
1141        let proposer = CommitParticipant {
1142            inbox_id: "sentinel-proposer-inbox".to_string(),
1143            installation_id: proposer_id.clone(),
1144            is_creator: true,
1145            is_admin: false,
1146            is_super_admin: true,
1147        };
1148        let changes = [Inbox {
1149            inbox_id: "sentinel-subject-inbox".to_string(),
1150            is_creator: false,
1151            is_admin: true,
1152            is_super_admin: false,
1153            proposer: has_proposer.then_some(proposer),
1154        }];
1155        let policies = PolicySet {
1156            add_member_policy: MembershipPolicies::deny(),
1157            remove_member_policy: MembershipPolicies::deny(),
1158            ..PolicySet::default()
1159        };
1160        let membership = MembershipValidationInfo {
1161            actor: &actor,
1162            added_inboxes: if remove { &[] } else { &changes },
1163            removed_inboxes: if remove { &changes } else { &[] },
1164            dm_members: None,
1165        };
1166        let sink = Arc::new(Capture::default());
1167        let capture = LogCapture::with_sink(Level::Info, Some(sink.clone()));
1168        let accepted = tracing::dispatcher::with_default(&capture.dispatch(), || {
1169            policies.evaluate_membership(&membership)
1170        });
1171        assert!(!accepted);
1172        let json = capture.output();
1173        let records = sink.0.lock();
1174        assert_eq!(records.len(), 1);
1175        for sensitive in [
1176            hex::encode(&actor_id),
1177            hex::encode(&proposer_id),
1178            format!("{actor_id:?}"),
1179            format!("{proposer_id:?}"),
1180            actor.inbox_id.clone(),
1181            "sentinel-proposer-inbox".to_string(),
1182            changes[0].inbox_id.clone(),
1183        ] {
1184            assert!(
1185                !json.contains(&sensitive),
1186                "sensitive value reached JSON log"
1187            );
1188            assert!(
1189                !records[0].message.contains(&sensitive),
1190                "sensitive value reached sink message"
1191            );
1192            assert!(
1193                records[0]
1194                    .fields
1195                    .values()
1196                    .all(|value| !value.contains(&sensitive)),
1197                "sensitive value reached sink fields"
1198            );
1199        }
1200        let json_record: serde_json::Value = serde_json::from_str(json.trim()).unwrap();
1201        assert_eq!(json_record["message"], "Membership policy rejected change");
1202        assert_eq!(records[0].message, "Membership policy rejected change");
1203        for (field, value) in [
1204            ("has_proposer", has_proposer),
1205            ("actor_is_creator", has_proposer),
1206            ("actor_is_admin", !has_proposer),
1207            ("actor_is_super_admin", has_proposer),
1208            ("subject_is_creator", false),
1209            ("subject_is_admin", true),
1210            ("subject_is_super_admin", false),
1211        ] {
1212            assert_eq!(json_record[field], value);
1213            assert_eq!(records[0].fields.get(field), Some(&value.to_string()));
1214        }
1215        assert_eq!(actor.installation_id, actor_id);
1216        if let Some(proposer) = &changes[0].proposer {
1217            assert_eq!(proposer.installation_id, proposer_id);
1218        }
1219    }
1220}