Skip to main content

xmtp_mls/groups/
builders.rs

1//! Extension and group-config builders, plus DM validation.
2
3use super::*;
4
5pub(crate) fn build_protected_metadata_extension(
6    creator_inbox_id: &str,
7    conversation_type: ConversationType,
8    oneshot_message: Option<OneshotMessage>,
9) -> Result<Extension, MetadataPermissionsError> {
10    assert!(conversation_type != ConversationType::Dm);
11    let metadata = GroupMetadata::new(
12        conversation_type,
13        creator_inbox_id.to_string(),
14        None,
15        oneshot_message,
16    );
17    let protected_metadata = Metadata::new(metadata.try_into()?);
18
19    Ok(Extension::ImmutableMetadata(protected_metadata))
20}
21
22pub(in crate::groups) fn build_dm_protected_metadata_extension(
23    creator_inbox_id: &str,
24    dm_inbox_id: InboxId,
25) -> Result<Extension, GroupError> {
26    let dm_members = Some(DmMembers {
27        member_one_inbox_id: creator_inbox_id.to_string(),
28        member_two_inbox_id: dm_inbox_id,
29    });
30
31    let metadata = GroupMetadata::new(
32        ConversationType::Dm,
33        creator_inbox_id.to_string(),
34        dm_members,
35        None,
36    );
37    let protected_metadata = Metadata::new(
38        metadata
39            .try_into()
40            .map_err(MetadataPermissionsError::from)?,
41    );
42
43    Ok(Extension::ImmutableMetadata(protected_metadata))
44}
45
46pub(crate) fn build_mutable_permissions_extension(
47    policies: PolicySet,
48) -> Result<Extension, MetadataPermissionsError> {
49    let permissions: Vec<u8> = GroupMutablePermissions::new(policies).try_into()?;
50    let unknown_gc_extension = UnknownExtension(permissions);
51
52    Ok(Extension::Unknown(
53        GROUP_PERMISSIONS_EXTENSION_ID,
54        unknown_gc_extension,
55    ))
56}
57
58pub fn build_mutable_metadata_extension_default(
59    creator_inbox_id: &str,
60    opts: GroupMetadataOptions,
61    commit_log_enabled: bool,
62) -> Result<Extension, GroupError> {
63    let mut commit_log_signer = None;
64    // CFG-068: no signer is minted for a deployment that keeps no commit log.
65    if commit_log_enabled {
66        // Optional TODO(rich): Plumb in provider and use traits in commit_log_key.rs to generate and store secret
67        commit_log_signer = Some(xmtp_cryptography::rand::rand_secret::<ED25519_KEY_LENGTH>());
68    }
69    let mutable_metadata: Vec<u8> =
70        GroupMutableMetadata::new_default(creator_inbox_id.to_string(), commit_log_signer, opts)
71            .try_into()
72            .map_err(MetadataPermissionsError::from)?;
73    let unknown_gc_extension = UnknownExtension(mutable_metadata);
74
75    Ok(Extension::Unknown(
76        MUTABLE_METADATA_EXTENSION_ID,
77        unknown_gc_extension,
78    ))
79}
80
81pub fn build_dm_mutable_metadata_extension_default(
82    creator_inbox_id: &str,
83    dm_target_inbox_id: &str,
84    opts: DMMetadataOptions,
85    commit_log_enabled: bool,
86) -> Result<Extension, MetadataPermissionsError> {
87    let mut commit_log_signer = None;
88    // CFG-068: no signer is minted for a deployment that keeps no commit log.
89    if commit_log_enabled {
90        commit_log_signer = Some(xmtp_cryptography::rand::rand_secret::<ED25519_KEY_LENGTH>());
91    }
92    let mutable_metadata: Vec<u8> = GroupMutableMetadata::new_dm_default(
93        creator_inbox_id.to_string(),
94        dm_target_inbox_id,
95        commit_log_signer,
96        opts,
97    )
98    .try_into()?;
99    let unknown_gc_extension = UnknownExtension(mutable_metadata);
100
101    Ok(Extension::Unknown(
102        MUTABLE_METADATA_EXTENSION_ID,
103        unknown_gc_extension,
104    ))
105}
106
107#[tracing::instrument(level = "trace", skip_all)]
108pub fn build_extensions_for_metadata_update(
109    group: &OpenMlsGroup,
110    field_name: String,
111    field_value: String,
112) -> Result<Extensions<GroupContext>, MetadataPermissionsError> {
113    let existing_metadata: GroupMutableMetadata = group.try_into()?;
114    let mut attributes = existing_metadata.attributes.clone();
115    attributes.insert(field_name, field_value);
116    let new_mutable_metadata: Vec<u8> = GroupMutableMetadata::new(
117        attributes,
118        existing_metadata.admin_list,
119        existing_metadata.super_admin_list,
120    )
121    .try_into()?;
122    let unknown_gc_extension = UnknownExtension(new_mutable_metadata);
123    let extension = Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, unknown_gc_extension);
124    let mut extensions = group.extensions().clone();
125    extensions.add_or_replace(extension)?;
126    Ok(extensions)
127}
128
129#[tracing::instrument(level = "trace", skip_all)]
130pub fn build_extensions_for_permissions_update(
131    group: &OpenMlsGroup,
132    update_permissions_intent: UpdatePermissionIntentData,
133) -> Result<Extensions<GroupContext>, MetadataPermissionsError> {
134    let existing_permissions: GroupMutablePermissions = group.try_into()?;
135    let existing_policy_set = existing_permissions.policies.clone();
136    let new_policy_set = match update_permissions_intent.update_type {
137        PermissionUpdateType::AddMember => PolicySet::new(
138            update_permissions_intent.policy_option.into(),
139            existing_policy_set.remove_member_policy,
140            existing_policy_set.update_metadata_policy,
141            existing_policy_set.add_admin_policy,
142            existing_policy_set.remove_admin_policy,
143            existing_policy_set.update_permissions_policy,
144        ),
145        PermissionUpdateType::RemoveMember => PolicySet::new(
146            existing_policy_set.add_member_policy,
147            update_permissions_intent.policy_option.into(),
148            existing_policy_set.update_metadata_policy,
149            existing_policy_set.add_admin_policy,
150            existing_policy_set.remove_admin_policy,
151            existing_policy_set.update_permissions_policy,
152        ),
153        PermissionUpdateType::AddAdmin => PolicySet::new(
154            existing_policy_set.add_member_policy,
155            existing_policy_set.remove_member_policy,
156            existing_policy_set.update_metadata_policy,
157            update_permissions_intent.policy_option.into(),
158            existing_policy_set.remove_admin_policy,
159            existing_policy_set.update_permissions_policy,
160        ),
161        PermissionUpdateType::RemoveAdmin => PolicySet::new(
162            existing_policy_set.add_member_policy,
163            existing_policy_set.remove_member_policy,
164            existing_policy_set.update_metadata_policy,
165            existing_policy_set.add_admin_policy,
166            update_permissions_intent.policy_option.into(),
167            existing_policy_set.update_permissions_policy,
168        ),
169        PermissionUpdateType::UpdateMetadata => {
170            let mut metadata_policy = existing_policy_set.update_metadata_policy.clone();
171            metadata_policy.insert(
172                update_permissions_intent
173                    .metadata_field_name
174                    .ok_or(GroupMutableMetadataError::MissingMetadataField)?,
175                update_permissions_intent.policy_option.into(),
176            );
177            PolicySet::new(
178                existing_policy_set.add_member_policy,
179                existing_policy_set.remove_member_policy,
180                metadata_policy,
181                existing_policy_set.add_admin_policy,
182                existing_policy_set.remove_admin_policy,
183                existing_policy_set.update_permissions_policy,
184            )
185        }
186    };
187    let new_group_permissions: Vec<u8> = GroupMutablePermissions::new(new_policy_set).try_into()?;
188    let unknown_gc_extension = UnknownExtension(new_group_permissions);
189    let extension = Extension::Unknown(GROUP_PERMISSIONS_EXTENSION_ID, unknown_gc_extension);
190    let mut extensions = group.extensions().clone();
191    extensions.add_or_replace(extension)?;
192    Ok(extensions)
193}
194
195#[tracing::instrument(level = "trace", skip_all)]
196pub fn build_extensions_for_admin_lists_update(
197    group: &OpenMlsGroup,
198    admin_lists_update: UpdateAdminListIntentData,
199) -> Result<Extensions<GroupContext>, MetadataPermissionsError> {
200    let existing_metadata: GroupMutableMetadata = group.try_into()?;
201    let attributes = existing_metadata.attributes.clone();
202    let mut admin_list = existing_metadata.admin_list;
203    let mut super_admin_list = existing_metadata.super_admin_list;
204    match admin_lists_update.action_type {
205        AdminListActionType::Add => {
206            if !admin_list.contains(&admin_lists_update.inbox_id) {
207                admin_list.push(admin_lists_update.inbox_id);
208            }
209        }
210        AdminListActionType::Remove => admin_list.retain(|x| x != &admin_lists_update.inbox_id),
211        AdminListActionType::AddSuper => {
212            if !super_admin_list.contains(&admin_lists_update.inbox_id) {
213                super_admin_list.push(admin_lists_update.inbox_id);
214            }
215        }
216        AdminListActionType::RemoveSuper => {
217            super_admin_list.retain(|x| x != &admin_lists_update.inbox_id)
218        }
219    }
220    let new_mutable_metadata: Vec<u8> =
221        GroupMutableMetadata::new(attributes, admin_list, super_admin_list).try_into()?;
222    let unknown_gc_extension = UnknownExtension(new_mutable_metadata);
223    let extension = Extension::Unknown(MUTABLE_METADATA_EXTENSION_ID, unknown_gc_extension);
224    let mut extensions = group.extensions().clone();
225    extensions.add_or_replace(extension)?;
226    Ok(extensions)
227}
228
229pub fn build_starting_group_membership_extension(inbox_id: &str, sequence_id: u64) -> Extension {
230    let mut group_membership = GroupMembership::new();
231    group_membership.add(inbox_id.to_string(), sequence_id);
232    build_group_membership_extension(&group_membership)
233}
234
235pub fn build_group_membership_extension(group_membership: &GroupMembership) -> Extension {
236    let unknown_gc_extension = UnknownExtension(group_membership.into());
237
238    Extension::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID, unknown_gc_extension)
239}
240
241/// Check if the given extensions belong to a migrated group.
242///
243/// "Migrated" means the bootstrap commit completed: the
244/// [`ExtensionType::AppDataDictionary`] group-context extension is
245/// present and its dict contains a `COMPONENT_REGISTRY` entry. This is
246/// also the canonical predicate used by
247/// [`crate::groups::app_data::is_migrated_extensions`]; the two
248/// converge so the rest of the codebase can use one name where the
249/// proposal-by-reference flow is the question and another where the
250/// dict-source-of-truth migration is the question.
251pub fn check_proposals_enabled(extensions: &Extensions<GroupContext>) -> bool {
252    crate::groups::app_data::is_migrated_extensions(extensions)
253}
254
255/// Update the `RequiredCapabilities` extension to add or remove
256/// `ExtensionType::AppDataDictionary`. Per MLS RFC 9420 §7.2, unknown
257/// extensions in the group context MUST be listed in the required
258/// capabilities, so this must be called whenever the AppData
259/// dictionary extension is added to or removed from the group context.
260pub fn update_required_capabilities_for_proposals(
261    extensions: &mut Extensions<GroupContext>,
262    add: bool,
263) -> Result<(), GroupError> {
264    let app_data_ext_type = ExtensionType::AppDataDictionary;
265
266    if let Some(required_caps) = extensions.required_capabilities() {
267        let mut ext_types: Vec<ExtensionType> = required_caps.extension_types().to_vec();
268        let has_it = ext_types.contains(&app_data_ext_type);
269
270        if add && !has_it {
271            ext_types.push(app_data_ext_type);
272        } else if !add && has_it {
273            ext_types.retain(|t| *t != app_data_ext_type);
274        } else {
275            return Ok(()); // already in desired state
276        }
277
278        let new_required = Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
279            &ext_types,
280            required_caps.proposal_types(),
281            required_caps.credential_types(),
282        ));
283        extensions.add_or_replace(new_required)?;
284    }
285
286    Ok(())
287}
288
289/// Update RequiredCapabilities for the bootstrap commit:
290///   - Add `ExtensionType::AppDataDictionary` to the required
291///     extension types so receivers MUST advertise support for the
292///     standard AppData dictionary group-context extension.
293///   - Remove the four legacy XMTP extension types
294///     (`MUTABLE_METADATA`, `GROUP_PERMISSIONS`, `GROUP_MEMBERSHIP`,
295///     `ImmutableMetadata`) since the bootstrap commit also removes
296///     those extensions from the group context.
297///
298/// Per MLS RFC 9420 §7.2, every extension present in the group
299/// context must also be in `required_capabilities.extension_types`,
300/// so the two changes have to land together.
301pub fn update_required_capabilities_for_bootstrap(
302    extensions: &mut Extensions<GroupContext>,
303) -> Result<(), GroupError> {
304    let app_data_ext_type = ExtensionType::AppDataDictionary;
305    let to_remove: &[ExtensionType] = &[
306        ExtensionType::Unknown(MUTABLE_METADATA_EXTENSION_ID),
307        ExtensionType::Unknown(GROUP_PERMISSIONS_EXTENSION_ID),
308        ExtensionType::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID),
309        ExtensionType::ImmutableMetadata,
310    ];
311
312    if let Some(required_caps) = extensions.required_capabilities() {
313        let mut ext_types: Vec<ExtensionType> = required_caps
314            .extension_types()
315            .iter()
316            .copied()
317            .filter(|t| !to_remove.contains(t))
318            .collect();
319        if !ext_types.contains(&app_data_ext_type) {
320            ext_types.push(app_data_ext_type);
321        }
322        let new_required = Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
323            &ext_types,
324            required_caps.proposal_types(),
325            required_caps.credential_types(),
326        ));
327        extensions.add_or_replace(new_required)?;
328    }
329    Ok(())
330}
331
332/// Build extensions with updated group membership for a GroupContextExtensions proposal.
333/// This is used when proposing to add or remove members to include the membership update
334/// alongside the Add/Remove proposals.
335#[tracing::instrument(level = "trace", skip_all)]
336pub fn build_extensions_for_membership_update(
337    group: &OpenMlsGroup,
338    new_membership: &GroupMembership,
339) -> Result<Extensions<GroupContext>, GroupError> {
340    let mut extensions: Extensions<GroupContext> = group.extensions().clone();
341    extensions.add_or_replace(build_group_membership_extension(new_membership))?;
342    Ok(extensions)
343}
344
345pub(crate) fn build_group_config(
346    protected_metadata_extension: Extension,
347    mutable_metadata_extension: Extension,
348    group_membership_extension: Extension,
349    mutable_permission_extension: Extension,
350) -> Result<MlsGroupCreateConfig, GroupError> {
351    // Extensions that all group members MUST support (enforced by RequiredCapabilities)
352    let required_extension_types = &[
353        ExtensionType::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID),
354        ExtensionType::Unknown(MUTABLE_METADATA_EXTENSION_ID),
355        ExtensionType::Unknown(GROUP_PERMISSIONS_EXTENSION_ID),
356        ExtensionType::ImmutableMetadata,
357        ExtensionType::LastResort,
358        ExtensionType::ApplicationId,
359    ];
360
361    // Extensions the creator's leaf node advertises support for (superset of required).
362    // `AppDataDictionary` is listed here so the group can be migrated
363    // later, but is NOT in required_extension_types so members without
364    // support can join the pre-migration legacy group.
365    let mut creator_capability_extensions = required_extension_types.to_vec();
366    creator_capability_extensions.push(ExtensionType::Unknown(
367        WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID,
368    ));
369    creator_capability_extensions.push(ExtensionType::Unknown(
370        WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID,
371    ));
372    // AppDataDictionary capability — needed for the bootstrap commit
373    // and steady-state AppDataUpdate proposals. Required-vs-supported
374    // is enforced by RequiredCapabilities (which adds it only after
375    // bootstrap), so advertising here unconditionally is safe and lets
376    // a fresh group's creator be the migration trigger.
377    creator_capability_extensions.push(ExtensionType::AppDataDictionary);
378
379    let required_proposal_types = &[ProposalType::GroupContextExtensions];
380
381    // Leaf-node-advertised proposal types: a superset of `required_proposal_types`.
382    // We advertise `AppDataUpdate` here so that the new path (commit-with-inline-
383    // AppDataUpdate-proposal) is supported, but we DO NOT add it to
384    // `required_proposal_types` because that would break backwards compatibility
385    // with members whose leaf nodes don't yet advertise it. The capability check
386    // OpenMLS performs at commit-build time inspects every member leaf node's
387    // proposal capabilities — adding it here ensures the creator advertises
388    // support; joining clients pick it up via their own key package (see
389    // `identity.rs`).
390    let creator_capability_proposals = &[
391        ProposalType::GroupContextExtensions,
392        ProposalType::AppDataUpdate,
393    ];
394
395    let capabilities = Capabilities::new(
396        None,
397        None,
398        Some(&creator_capability_extensions),
399        Some(creator_capability_proposals),
400        None,
401    );
402    let credentials = &[CredentialType::Basic];
403
404    let required_capabilities =
405        Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
406            required_extension_types,
407            required_proposal_types,
408            credentials,
409        ));
410
411    let extensions = Extensions::from_vec(vec![
412        protected_metadata_extension,
413        mutable_metadata_extension,
414        group_membership_extension,
415        mutable_permission_extension,
416        required_capabilities,
417    ])?;
418
419    Ok(MlsGroupCreateConfig::builder()
420        .with_group_context_extensions(extensions)
421        .capabilities(capabilities)
422        .ciphersuite(CIPHERSUITE)
423        .wire_format_policy(WireFormatPolicy::default())
424        .max_past_epochs(MAX_PAST_EPOCHS)
425        .use_ratchet_tree_extension(true)
426        .build())
427}
428
429pub fn filter_inbox_ids_needing_updates<'a>(
430    conn: &impl DbQuery,
431    filters: &[(&'a str, i64)],
432) -> Result<Vec<&'a str>, xmtp_db::ConnectionError> {
433    let existing_sequence_ids =
434        conn.get_latest_sequence_id(&filters.iter().map(|f| f.0).collect::<Vec<&str>>())?;
435
436    let needs_update = filters
437        .iter()
438        .filter_map(|&(inbox_id, seq)| {
439            let existing_sequence_id = existing_sequence_ids.get(inbox_id);
440            if existing_sequence_id.is_some_and(|&s| s >= seq) {
441                return None;
442            }
443
444            Some(inbox_id)
445        })
446        .collect();
447    Ok(needs_update)
448}
449
450pub(in crate::groups) fn validate_dm_group(
451    context: impl XmtpSharedContext,
452    mls_group: &OpenMlsGroup,
453    added_by_inbox: &str,
454) -> Result<(), MetadataPermissionsError> {
455    // Validate dm specific immutable metadata
456    let metadata = extract_group_metadata(mls_group.extensions())?;
457
458    // 1) Check if the conversation type is DM
459    if metadata.conversation_type != ConversationType::Dm {
460        return Err(DmValidationError::InvalidConversationType.into());
461    }
462
463    // 2) If `dm_members` is not set, return an error immediately
464    let dm_members = match &metadata.dm_members {
465        Some(dm) => dm,
466        None => {
467            return Err(DmValidationError::MustHaveMembersSet.into());
468        }
469    };
470
471    // 3) If the inbox that added this group is our inbox, make sure that
472    //    one of the `dm_members` is our inbox id
473    let identity = context.identity();
474    if added_by_inbox == identity.inbox_id() {
475        if !(dm_members.member_one_inbox_id == identity.inbox_id()
476            || dm_members.member_two_inbox_id == identity.inbox_id())
477        {
478            return Err(DmValidationError::OurInboxMustBeMember.into());
479        }
480        return Ok(());
481    }
482
483    // 4) Otherwise, make sure one of the `dm_members` is ours, and the other is `added_by_inbox`
484    let is_expected_pair = (dm_members.member_one_inbox_id == added_by_inbox
485        && dm_members.member_two_inbox_id == identity.inbox_id())
486        || (dm_members.member_one_inbox_id == identity.inbox_id()
487            && dm_members.member_two_inbox_id == added_by_inbox);
488
489    if !is_expected_pair {
490        return Err(DmValidationError::ExpectedInboxesDoNotMatch.into());
491    }
492
493    // Validate mutable metadata
494    let mutable_metadata: GroupMutableMetadata = mls_group.try_into()?;
495
496    // Check if the admin list and super admin list are empty
497    if !mutable_metadata.admin_list.is_empty() || !mutable_metadata.super_admin_list.is_empty() {
498        return Err(DmValidationError::MustHaveEmptyAdminAndSuperAdmin.into());
499    }
500
501    // Validate permissions so no one adds us to a dm that they can unexpectedly add another member to
502    // Note: we don't validate mutable metadata permissions, because they don't affect group membership
503    let permissions = extract_group_permissions(mls_group)?;
504    let expected_permissions = GroupMutablePermissions::new(PolicySet::new_dm());
505
506    if permissions.policies.add_member_policy != expected_permissions.policies.add_member_policy
507        && permissions.policies.remove_member_policy
508            != expected_permissions.policies.remove_member_policy
509        && permissions.policies.add_admin_policy != expected_permissions.policies.add_admin_policy
510        && permissions.policies.remove_admin_policy
511            != expected_permissions.policies.remove_admin_policy
512        && permissions.policies.update_permissions_policy
513            != expected_permissions.policies.update_permissions_policy
514    {
515        return Err(DmValidationError::InvalidPermissions.into());
516    }
517
518    Ok(())
519}