Skip to main content

xmtp_mls/groups/
builders.rs

1//! Extension and group-config builders, plus DM validation.
2
3use super::*;
4use xmtp_mls_validation::commit::{extract_group_membership, inbox_id_from_credential};
5
6// implements: DMS-001, ARCH-020
7pub(crate) fn parse_canonical_dm_id(dm_id: Option<&str>) -> Result<DmMembers<String>, GroupError> {
8    let invalid = || {
9        GroupError::from(MetadataPermissionsError::from(
10            DmValidationError::StoredDmIdMismatch,
11        ))
12    };
13    let mut parts = dm_id.ok_or_else(invalid)?.split(':');
14    if parts.next() != Some("dm") {
15        return Err(invalid());
16    }
17    let first = parts.next().ok_or_else(invalid)?;
18    let second = parts.next().ok_or_else(invalid)?;
19    if parts.next().is_some() || first == second {
20        return Err(invalid());
21    }
22    for inbox_id in [first, second] {
23        let bytes = hex::decode(inbox_id).map_err(|_| invalid())?;
24        if bytes.len() != 32 || hex::encode(bytes) != inbox_id {
25            return Err(invalid());
26        }
27    }
28    let members = DmMembers {
29        member_one_inbox_id: first.to_string(),
30        member_two_inbox_id: second.to_string(),
31    };
32    if members.to_string() != dm_id.unwrap_or_default() {
33        return Err(invalid());
34    }
35    Ok(members)
36}
37
38#[cfg(test)]
39pub(crate) fn build_protected_metadata_extension(
40    creator_inbox_id: &str,
41    conversation_type: ConversationType,
42    oneshot_message: Option<OneshotMessage>,
43) -> Result<Extension, MetadataPermissionsError> {
44    assert!(conversation_type != ConversationType::Dm);
45    let metadata = GroupMetadata::new(
46        conversation_type,
47        creator_inbox_id.to_string(),
48        None,
49        oneshot_message,
50    );
51    let protected_metadata = Metadata::new(metadata.try_into()?);
52
53    Ok(Extension::ImmutableMetadata(protected_metadata))
54}
55
56#[cfg(test)]
57pub(in crate::groups) fn build_dm_protected_metadata_extension(
58    creator_inbox_id: &str,
59    dm_inbox_id: InboxId,
60) -> Result<Extension, GroupError> {
61    let dm_members = Some(DmMembers {
62        member_one_inbox_id: creator_inbox_id.to_string(),
63        member_two_inbox_id: dm_inbox_id,
64    });
65
66    let metadata = GroupMetadata::new(
67        ConversationType::Dm,
68        creator_inbox_id.to_string(),
69        dm_members,
70        None,
71    );
72    let protected_metadata = Metadata::new(
73        metadata
74            .try_into()
75            .map_err(MetadataPermissionsError::from)?,
76    );
77
78    Ok(Extension::ImmutableMetadata(protected_metadata))
79}
80
81#[cfg(test)]
82pub(crate) fn build_mutable_permissions_extension(
83    policies: PolicySet,
84) -> Result<Extension, MetadataPermissionsError> {
85    let permissions: Vec<u8> = GroupMutablePermissions::new(policies).try_into()?;
86    let unknown_gc_extension = UnknownExtension(permissions);
87
88    Ok(Extension::Unknown(
89        GROUP_PERMISSIONS_EXTENSION_ID,
90        unknown_gc_extension,
91    ))
92}
93
94pub fn build_mutable_metadata_extension_default(
95    creator_inbox_id: &str,
96    opts: GroupMetadataOptions,
97    commit_log_enabled: bool,
98) -> Result<Extension, GroupError> {
99    let mut commit_log_signer = None;
100    // No signer is minted for a deployment that keeps no commit log.
101    if commit_log_enabled {
102        // Optional TODO(rich): Plumb in provider and use traits in commit_log_key.rs to generate and store secret
103        commit_log_signer = Some(xmtp_cryptography::rand::rand_secret::<ED25519_KEY_LENGTH>());
104    }
105    let mutable_metadata: Vec<u8> =
106        GroupMutableMetadata::new_default(creator_inbox_id.to_string(), commit_log_signer, opts)
107            .try_into()
108            .map_err(MetadataPermissionsError::from)?;
109    let unknown_gc_extension = UnknownExtension(mutable_metadata);
110
111    Ok(Extension::Unknown(
112        MUTABLE_METADATA_EXTENSION_ID,
113        unknown_gc_extension,
114    ))
115}
116
117pub fn build_dm_mutable_metadata_extension_default(
118    creator_inbox_id: &str,
119    dm_target_inbox_id: &str,
120    opts: DMMetadataOptions,
121    commit_log_enabled: bool,
122) -> Result<Extension, MetadataPermissionsError> {
123    let mut commit_log_signer = None;
124    // No signer is minted for a deployment that keeps no commit log.
125    if commit_log_enabled {
126        commit_log_signer = Some(xmtp_cryptography::rand::rand_secret::<ED25519_KEY_LENGTH>());
127    }
128    let mutable_metadata: Vec<u8> = GroupMutableMetadata::new_dm_default(
129        creator_inbox_id.to_string(),
130        dm_target_inbox_id,
131        commit_log_signer,
132        opts,
133    )
134    .try_into()?;
135    let unknown_gc_extension = UnknownExtension(mutable_metadata);
136
137    Ok(Extension::Unknown(
138        MUTABLE_METADATA_EXTENSION_ID,
139        unknown_gc_extension,
140    ))
141}
142
143pub fn build_starting_group_membership_extension(inbox_id: &str, sequence_id: u64) -> Extension {
144    let mut group_membership = GroupMembership::new();
145    group_membership.add(inbox_id.to_string(), sequence_id);
146    build_group_membership_extension(&group_membership)
147}
148
149pub fn build_group_membership_extension(group_membership: &GroupMembership) -> Extension {
150    let unknown_gc_extension = UnknownExtension(group_membership.into());
151
152    Extension::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID, unknown_gc_extension)
153}
154
155// implements: META-002, JOIN-040
156pub(crate) fn build_group_config(
157    dictionary: openmls::extensions::AppDataDictionary,
158) -> Result<MlsGroupCreateConfig, GroupError> {
159    let required_extension_types = &[
160        ExtensionType::AppDataDictionary,
161        ExtensionType::LastResort,
162        ExtensionType::ApplicationId,
163    ];
164    let required_proposal_types = &[ProposalType::AppDataUpdate];
165    let mut supported_extensions = required_extension_types.to_vec();
166    supported_extensions.extend([
167        ExtensionType::Unknown(WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID),
168        ExtensionType::Unknown(WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID),
169    ]);
170    let capabilities = Capabilities::new(
171        None,
172        None,
173        Some(&supported_extensions),
174        Some(required_proposal_types),
175        None,
176    );
177    let extensions = Extensions::from_vec(vec![
178        Extension::AppDataDictionary(openmls::extensions::AppDataDictionaryExtension::new(
179            dictionary,
180        )),
181        Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
182            required_extension_types,
183            required_proposal_types,
184            &[CredentialType::Basic],
185        )),
186    ])?;
187    Ok(MlsGroupCreateConfig::builder()
188        .with_group_context_extensions(extensions)
189        .capabilities(capabilities)
190        .ciphersuite(CIPHERSUITE)
191        .wire_format_policy(WireFormatPolicy::default())
192        .max_past_epochs(MAX_PAST_EPOCHS)
193        .use_ratchet_tree_extension(true)
194        .build())
195}
196
197// Legacy extension fixtures remain until the legacy reader tests are removed.
198#[cfg(test)]
199pub(crate) fn build_legacy_test_group_config(
200    protected_metadata_extension: Extension,
201    mutable_metadata_extension: Extension,
202    group_membership_extension: Extension,
203    mutable_permission_extension: Extension,
204) -> Result<MlsGroupCreateConfig, GroupError> {
205    // Extensions that all group members MUST support (enforced by RequiredCapabilities)
206    let required_extension_types = &[
207        ExtensionType::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID),
208        ExtensionType::Unknown(MUTABLE_METADATA_EXTENSION_ID),
209        ExtensionType::Unknown(GROUP_PERMISSIONS_EXTENSION_ID),
210        ExtensionType::ImmutableMetadata,
211        ExtensionType::LastResort,
212        ExtensionType::ApplicationId,
213    ];
214
215    // Extensions the creator's leaf node advertises support for (superset of required).
216    // `AppDataDictionary` is listed here so the group can be migrated
217    // later, but is NOT in required_extension_types so members without
218    // support can join the pre-migration legacy group.
219    let mut creator_capability_extensions = required_extension_types.to_vec();
220    creator_capability_extensions.push(ExtensionType::Unknown(
221        WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID,
222    ));
223    creator_capability_extensions.push(ExtensionType::Unknown(
224        WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID,
225    ));
226    // AppDataDictionary capability — needed for the bootstrap commit
227    // and steady-state AppDataUpdate proposals. Required-vs-supported
228    // is enforced by RequiredCapabilities (which adds it only after
229    // bootstrap), so advertising here unconditionally is safe and lets
230    // a fresh group's creator be the migration trigger.
231    creator_capability_extensions.push(ExtensionType::AppDataDictionary);
232
233    let required_proposal_types = &[ProposalType::GroupContextExtensions];
234
235    // Leaf-node-advertised proposal types: a superset of `required_proposal_types`.
236    // We advertise `AppDataUpdate` here so that the new path (commit-with-inline-
237    // AppDataUpdate-proposal) is supported, but we DO NOT add it to
238    // `required_proposal_types` because that would break backwards compatibility
239    // with members whose leaf nodes don't yet advertise it. The capability check
240    // OpenMLS performs at commit-build time inspects every member leaf node's
241    // proposal capabilities — adding it here ensures the creator advertises
242    // support; joining clients pick it up via their own key package (see
243    // `identity.rs`).
244    let creator_capability_proposals = &[
245        ProposalType::GroupContextExtensions,
246        ProposalType::AppDataUpdate,
247    ];
248
249    let capabilities = Capabilities::new(
250        None,
251        None,
252        Some(&creator_capability_extensions),
253        Some(creator_capability_proposals),
254        None,
255    );
256    let credentials = &[CredentialType::Basic];
257
258    let required_capabilities =
259        Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
260            required_extension_types,
261            required_proposal_types,
262            credentials,
263        ));
264
265    let extensions = Extensions::from_vec(vec![
266        protected_metadata_extension,
267        mutable_metadata_extension,
268        group_membership_extension,
269        mutable_permission_extension,
270        required_capabilities,
271    ])?;
272
273    Ok(MlsGroupCreateConfig::builder()
274        .with_group_context_extensions(extensions)
275        .capabilities(capabilities)
276        .ciphersuite(CIPHERSUITE)
277        .wire_format_policy(WireFormatPolicy::default())
278        .max_past_epochs(MAX_PAST_EPOCHS)
279        .use_ratchet_tree_extension(true)
280        .build())
281}
282
283pub fn filter_inbox_ids_needing_updates<'a>(
284    conn: &impl DbQuery,
285    filters: &[(&'a str, i64)],
286) -> Result<Vec<&'a str>, xmtp_db::ConnectionError> {
287    let existing_sequence_ids =
288        conn.get_latest_sequence_id(&filters.iter().map(|f| f.0).collect::<Vec<&str>>())?;
289
290    let needs_update = filters
291        .iter()
292        .filter_map(|&(inbox_id, seq)| {
293            let existing_sequence_id = existing_sequence_ids.get(inbox_id);
294            if existing_sequence_id.is_some_and(|&s| s >= seq) {
295                return None;
296            }
297
298            Some(inbox_id)
299        })
300        .collect();
301    Ok(needs_update)
302}
303
304// implements: DMS-003
305pub(in crate::groups) fn validate_dm_group(
306    context: impl XmtpSharedContext,
307    mls_group: &OpenMlsGroup,
308    added_by_inbox: &str,
309) -> Result<(), MetadataPermissionsError> {
310    // Validate dm specific immutable metadata
311    let metadata = extract_group_metadata(mls_group.extensions())?;
312
313    // 1) Check if the conversation type is DM
314    if metadata.conversation_type != ConversationType::Dm {
315        return Err(DmValidationError::InvalidConversationType.into());
316    }
317
318    // 2) If `dm_members` is not set, return an error immediately
319    let dm_members = match &metadata.dm_members {
320        Some(dm) => dm,
321        None => {
322            return Err(DmValidationError::MustHaveMembersSet.into());
323        }
324    };
325
326    let identity = context.identity();
327    let pair = [
328        dm_members.member_one_inbox_id.as_str(),
329        dm_members.member_two_inbox_id.as_str(),
330    ];
331    if pair[0] == pair[1] || !pair.contains(&identity.inbox_id()) {
332        return Err(DmValidationError::OurInboxMustBeMember.into());
333    }
334    if !pair.contains(&added_by_inbox) {
335        return Err(DmValidationError::ExpectedInboxesDoNotMatch.into());
336    }
337
338    // A valid identity proof does not make an inbox outside this pair a DM member.
339    let membership = extract_group_membership(mls_group.extensions())
340        .map_err(|_| DmValidationError::InvalidMembership)?;
341    if membership
342        .members
343        .keys()
344        .any(|inbox| !pair.contains(&inbox.as_str()))
345    {
346        return Err(DmValidationError::MemberOutsidePair.into());
347    }
348    for member in mls_group.members() {
349        let inbox = inbox_id_from_credential(&member.credential)
350            .map_err(|_| DmValidationError::InvalidMemberCredential)?;
351        if !pair.contains(&inbox.as_str()) {
352            return Err(DmValidationError::MemberOutsidePair.into());
353        }
354    }
355
356    // Validate mutable metadata
357    let mutable_metadata =
358        xmtp_mls_common::app_data::component_source::extract_group_mutable_metadata_capability_aware(mls_group)
359            .map_err(|error| match error {
360                xmtp_mls_common::app_data::component_source::ComponentSourceError::GroupMutableMetadata(inner) => {
361                    MetadataPermissionsError::Mutable(inner)
362                }
363                other => MetadataPermissionsError::ComponentSource(other),
364            })?;
365
366    // Check if the admin list and super admin list are empty
367    if !mutable_metadata.admin_list.is_empty() || !mutable_metadata.super_admin_list.is_empty() {
368        return Err(DmValidationError::MustHaveEmptyAdminAndSuperAdmin.into());
369    }
370
371    // Validate permissions so no one adds us to a dm that they can unexpectedly add another member to
372    // Note: we don't validate mutable metadata permissions, because they don't affect group membership
373    let permissions = group_permissions::policy_set_from_dictionary(mls_group.extensions())?;
374    let mut expected_permissions = GroupMutablePermissions::new(PolicySet::new_dm());
375    // Dictionary permission updates require a super admin. A DM has none.
376    expected_permissions.policies.update_permissions_policy =
377        group_permissions::PermissionsPolicies::allow_if_actor_super_admin();
378
379    if permissions.policies.add_member_policy != expected_permissions.policies.add_member_policy
380        || permissions.policies.remove_member_policy
381            != expected_permissions.policies.remove_member_policy
382        || permissions.policies.add_admin_policy != expected_permissions.policies.add_admin_policy
383        || permissions.policies.remove_admin_policy
384            != expected_permissions.policies.remove_admin_policy
385        || permissions.policies.update_permissions_policy
386            != expected_permissions.policies.update_permissions_policy
387    {
388        return Err(DmValidationError::InvalidPermissions.into());
389    }
390
391    Ok(())
392}