Skip to main content

xmtp_mls/groups/
builders.rs

1//! Extension and group-config builders, plus DM validation.
2
3use super::*;
4use openmls::prelude::SenderRatchetConfiguration;
5use xmtp_configuration::{
6    SENDER_RATCHET_MAX_FORWARD_DISTANCE, SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
7};
8use xmtp_mls_group_state::app_data::dm_registry_profile::{
9    dm_registry_profiles, matches_dm_registry_profile,
10};
11use xmtp_mls_validation::commit::{extract_group_membership, inbox_id_from_credential};
12
13// implements: DMS-001, ARCH-020
14pub(crate) fn parse_canonical_dm_id(dm_id: Option<&str>) -> Result<DmMembers<String>, GroupError> {
15    let invalid = || {
16        GroupError::from(MetadataPermissionsError::from(
17            DmValidationError::StoredDmIdMismatch,
18        ))
19    };
20    let mut parts = dm_id.ok_or_else(invalid)?.split(':');
21    if parts.next() != Some("dm") {
22        return Err(invalid());
23    }
24    let first = parts.next().ok_or_else(invalid)?;
25    let second = parts.next().ok_or_else(invalid)?;
26    if parts.next().is_some() || first == second {
27        return Err(invalid());
28    }
29    for inbox_id in [first, second] {
30        let bytes = hex::decode(inbox_id).map_err(|_| invalid())?;
31        if bytes.len() != 32 || hex::encode(bytes) != inbox_id {
32            return Err(invalid());
33        }
34    }
35    let members = DmMembers {
36        member_one_inbox_id: first.to_string(),
37        member_two_inbox_id: second.to_string(),
38    };
39    if members.to_string() != dm_id.unwrap_or_default() {
40        return Err(invalid());
41    }
42    Ok(members)
43}
44
45pub fn build_starting_group_membership_extension(inbox_id: &str, sequence_id: u64) -> Extension {
46    let mut group_membership = GroupMembership::new();
47    group_membership.add(inbox_id.to_string(), sequence_id);
48    build_group_membership_extension(&group_membership)
49}
50
51pub fn build_group_membership_extension(group_membership: &GroupMembership) -> Extension {
52    let unknown_gc_extension = UnknownExtension(group_membership.into());
53
54    Extension::Unknown(GROUP_MEMBERSHIP_EXTENSION_ID, unknown_gc_extension)
55}
56
57// implements: META-002, JOIN-040
58pub(crate) fn build_group_config(
59    dictionary: openmls::extensions::AppDataDictionary,
60) -> Result<MlsGroupCreateConfig, GroupError> {
61    // No `LastResort`: key packages carry their last-resort mark in the
62    // AppDataDictionary extension.
63    let required_extension_types = &[
64        ExtensionType::AppDataDictionary,
65        ExtensionType::ApplicationId,
66    ];
67    let required_proposal_types = &[ProposalType::AppDataUpdate];
68    let mut supported_extensions = required_extension_types.to_vec();
69    supported_extensions.extend([
70        ExtensionType::Unknown(WELCOME_WRAPPER_ENCRYPTION_EXTENSION_ID),
71        ExtensionType::Unknown(WELCOME_POINTEE_ENCRYPTION_AEAD_TYPES_EXTENSION_ID),
72    ]);
73    let capabilities = Capabilities::new(
74        None,
75        None,
76        Some(&supported_extensions),
77        Some(required_proposal_types),
78        None,
79    );
80    let extensions = Extensions::from_vec(vec![
81        Extension::AppDataDictionary(openmls::extensions::AppDataDictionaryExtension::new(
82            dictionary,
83        )),
84        Extension::RequiredCapabilities(RequiredCapabilitiesExtension::new(
85            required_extension_types,
86            required_proposal_types,
87            &[CredentialType::Basic],
88        )),
89    ])?;
90    Ok(MlsGroupCreateConfig::builder()
91        .with_group_context_extensions(extensions)
92        .capabilities(capabilities)
93        .ciphersuite(CIPHERSUITE)
94        .wire_format_policy(WireFormatPolicy::default())
95        .max_past_epochs(MAX_PAST_EPOCHS)
96        .sender_ratchet_configuration(SenderRatchetConfiguration::new(
97            SENDER_RATCHET_OUT_OF_ORDER_TOLERANCE,
98            SENDER_RATCHET_MAX_FORWARD_DISTANCE,
99        ))
100        .use_ratchet_tree_extension(true)
101        .build())
102}
103
104pub fn filter_inbox_ids_needing_updates<'a>(
105    conn: &impl DbQuery,
106    filters: &[(&'a str, i64)],
107) -> Result<Vec<&'a str>, xmtp_db::ConnectionError> {
108    let existing_sequence_ids =
109        conn.get_latest_sequence_id(&filters.iter().map(|f| f.0).collect::<Vec<&str>>())?;
110
111    let needs_update = filters
112        .iter()
113        .filter_map(|&(inbox_id, seq)| {
114            let existing_sequence_id = existing_sequence_ids.get(inbox_id);
115            if existing_sequence_id.is_some_and(|&s| s >= seq) {
116                return None;
117            }
118
119            Some(inbox_id)
120        })
121        .collect();
122    Ok(needs_update)
123}
124
125// implements: DMS-021
126pub(in crate::groups) fn validate_dm_group(
127    context: impl XmtpSharedContext,
128    mls_group: &OpenMlsGroup,
129    added_by_inbox: &str,
130) -> Result<(), MetadataPermissionsError> {
131    // Validate dm specific immutable metadata
132    let metadata = extract_group_metadata(mls_group.extensions())?;
133
134    // 1) Check if the conversation type is DM
135    if metadata.conversation_type != ConversationType::Dm {
136        return Err(DmValidationError::InvalidConversationType.into());
137    }
138
139    // 2) If `dm_members` is not set, return an error immediately
140    let dm_members = match &metadata.dm_members {
141        Some(dm) => dm,
142        None => {
143            return Err(DmValidationError::MustHaveMembersSet.into());
144        }
145    };
146
147    let identity = context.identity();
148    let pair = [
149        dm_members.member_one_inbox_id.as_str(),
150        dm_members.member_two_inbox_id.as_str(),
151    ];
152    if pair[0] == pair[1] || !pair.contains(&identity.inbox_id()) {
153        return Err(DmValidationError::OurInboxMustBeMember.into());
154    }
155    if !pair.contains(&added_by_inbox) {
156        return Err(DmValidationError::ExpectedInboxesDoNotMatch.into());
157    }
158
159    // A valid identity proof does not make an inbox outside this pair a DM member.
160    let membership = extract_group_membership(mls_group.extensions())
161        .map_err(|_| DmValidationError::InvalidMembership)?;
162    if membership
163        .members
164        .keys()
165        .any(|inbox| !pair.contains(&inbox.as_str()))
166    {
167        return Err(DmValidationError::MemberOutsidePair.into());
168    }
169    for member in mls_group.members() {
170        let inbox = inbox_id_from_credential(&member.credential)
171            .map_err(|_| DmValidationError::InvalidMemberCredential)?;
172        if !pair.contains(&inbox.as_str()) {
173            return Err(DmValidationError::MemberOutsidePair.into());
174        }
175    }
176
177    // Validate mutable metadata
178    let mutable_metadata =
179        xmtp_mls_group_state::app_data::component_source::extract_group_mutable_metadata_capability_aware(mls_group)
180            .map_err(|error| match error {
181                xmtp_mls_group_state::app_data::component_source::ComponentSourceError::GroupMutableMetadata(inner) => {
182                    MetadataPermissionsError::Mutable(inner)
183                }
184                other => MetadataPermissionsError::ComponentSource(other),
185            })?;
186
187    // Check if the admin list and super admin list are empty
188    if !mutable_metadata.admin_list.is_empty() || !mutable_metadata.super_admin_list.is_empty() {
189        return Err(DmValidationError::MustHaveEmptyAdminAndSuperAdmin.into());
190    }
191
192    // The entries outside the application range must be one DM registry
193    // profile, byte for byte.
194    let received = app_data::load_component_registry(mls_group)?;
195    if !matches_dm_registry_profile(&received, &dm_registry_profiles()) {
196        return Err(DmValidationError::InvalidPermissions.into());
197    }
198
199    Ok(())
200}