Skip to main content

xmtp_mls_common/
group_metadata.rs

1use std::fmt::Display;
2
3use openmls::{extensions::Extensions, group::GroupContext};
4use prost::Message;
5use serde::Serialize;
6use thiserror::Error;
7use xmtp_common::ErrorCode;
8
9use xmtp_id::InboxId;
10use xmtp_proto::xmtp::mls::message_contents::{
11    ConversationType as ConversationTypeProto, DmMembers as DmMembersProto,
12    GroupMetadataV1 as GroupMetadataProto, Inbox as InboxProto, OneshotMessage,
13};
14
15use xmtp_proto::types::ConversationType;
16
17#[derive(Debug, Error, ErrorCode)]
18pub enum GroupMetadataError {
19    /// Serialization error.
20    ///
21    /// Failed to encode metadata protobuf. Not retryable.
22    #[error("serialization: {0}")]
23    Serialization(#[from] prost::EncodeError),
24    /// Deserialization error.
25    ///
26    /// Failed to decode metadata protobuf. Not retryable.
27    #[error("deserialization: {0}")]
28    Deserialization(#[from] prost::DecodeError),
29    /// Invalid conversation type.
30    ///
31    /// Protobuf conversation type not recognized. Not retryable.
32    #[error("invalid conversation type")]
33    InvalidConversationType,
34    /// Missing extension.
35    ///
36    /// Immutable metadata MLS extension not found. Not retryable.
37    #[error("missing extension")]
38    MissingExtension,
39    /// Invalid DM members.
40    ///
41    /// DM member data is invalid. Not retryable.
42    #[error("invalid dm members")]
43    InvalidDmMembers,
44    /// Missing DM member.
45    ///
46    /// A DM member field is not set. Not retryable.
47    #[error("missing a dm member")]
48    MissingDmMember,
49    #[error(transparent)]
50    #[error_code(inherit)]
51    Conversion(#[from] xmtp_proto::ConversionError),
52}
53
54/// `GroupMetadata` is immutable and created at the time of group creation.
55#[derive(Debug, Clone, PartialEq)]
56pub struct GroupMetadata {
57    pub conversation_type: ConversationType,
58    // TODO: Remove this once transition is completed
59    pub creator_inbox_id: String,
60    pub dm_members: Option<DmMembers<InboxId>>,
61    pub oneshot_message: Option<OneshotMessage>,
62}
63
64impl GroupMetadata {
65    pub fn new(
66        conversation_type: ConversationType,
67        creator_inbox_id: String,
68        dm_members: Option<DmMembers<InboxId>>,
69        oneshot_message: Option<OneshotMessage>,
70    ) -> Self {
71        Self {
72            conversation_type,
73            creator_inbox_id,
74            dm_members,
75            oneshot_message,
76        }
77    }
78}
79
80impl TryFrom<GroupMetadata> for Vec<u8> {
81    type Error = GroupMetadataError;
82
83    fn try_from(value: GroupMetadata) -> Result<Self, Self::Error> {
84        let conversation_type: ConversationTypeProto = value.conversation_type.into();
85        let proto_val = GroupMetadataProto {
86            conversation_type: conversation_type as i32,
87            creator_inbox_id: value.creator_inbox_id.clone(),
88            creator_account_address: "".to_string(), // TODO: remove from proto
89            dm_members: value.dm_members.clone().map(|dm| dm.into()),
90            oneshot_message: value.oneshot_message,
91        };
92        let mut buf: Vec<u8> = Vec::new();
93        proto_val.encode(&mut buf)?;
94
95        Ok(buf)
96    }
97}
98
99impl TryFrom<&Vec<u8>> for GroupMetadata {
100    type Error = GroupMetadataError;
101
102    fn try_from(value: &Vec<u8>) -> Result<Self, Self::Error> {
103        let proto_val = GroupMetadataProto::decode(value.as_slice())?;
104        proto_val.try_into()
105    }
106}
107
108impl TryFrom<GroupMetadataProto> for GroupMetadata {
109    type Error = GroupMetadataError;
110
111    fn try_from(value: GroupMetadataProto) -> Result<Self, Self::Error> {
112        let dm_members = value.dm_members.map(DmMembers::try_from).transpose()?;
113        Ok(Self::new(
114            value.conversation_type.try_into()?,
115            value.creator_inbox_id,
116            dm_members,
117            value.oneshot_message,
118        ))
119    }
120}
121
122#[derive(Debug, Clone, PartialEq, Serialize)]
123pub struct DmMembers<Id: AsRef<str>> {
124    pub member_one_inbox_id: Id,
125    pub member_two_inbox_id: Id,
126}
127
128impl<'a> DmMembers<String> {
129    pub fn as_ref(&'a self) -> DmMembers<&'a str> {
130        DmMembers {
131            member_one_inbox_id: &*self.member_one_inbox_id,
132            member_two_inbox_id: &*self.member_two_inbox_id,
133        }
134    }
135}
136
137impl<Id> From<DmMembers<Id>> for DmMembersProto
138where
139    Id: AsRef<str>,
140{
141    fn from(value: DmMembers<Id>) -> Self {
142        DmMembersProto {
143            dm_member_one: Some(InboxProto {
144                inbox_id: value.member_one_inbox_id.as_ref().to_string(),
145            }),
146            dm_member_two: Some(InboxProto {
147                inbox_id: value.member_two_inbox_id.as_ref().to_string(),
148            }),
149        }
150    }
151}
152
153impl<Id> From<&DmMembers<Id>> for String
154where
155    Id: AsRef<str>,
156{
157    fn from(members: &DmMembers<Id>) -> Self {
158        members.to_string()
159    }
160}
161
162impl<Id> From<DmMembers<Id>> for String
163where
164    Id: AsRef<str>,
165{
166    fn from(members: DmMembers<Id>) -> Self {
167        members.to_string()
168    }
169}
170
171impl<Id> Display for DmMembers<Id>
172where
173    Id: AsRef<str>,
174{
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        let mut inbox_ids = [
177            self.member_one_inbox_id.as_ref(),
178            self.member_two_inbox_id.as_ref(),
179        ]
180        .into_iter()
181        .map(str::to_lowercase)
182        .collect::<Vec<_>>();
183        inbox_ids.sort();
184
185        write!(f, "dm:{}", inbox_ids.join(":"))
186    }
187}
188
189impl TryFrom<DmMembersProto> for DmMembers<InboxId> {
190    type Error = GroupMetadataError;
191
192    fn try_from(value: DmMembersProto) -> Result<Self, Self::Error> {
193        Ok(Self {
194            member_one_inbox_id: value
195                .dm_member_one
196                .ok_or(GroupMetadataError::MissingDmMember)?
197                .inbox_id,
198            member_two_inbox_id: value
199                .dm_member_two
200                .ok_or(GroupMetadataError::MissingDmMember)?
201                .inbox_id,
202        })
203    }
204}
205
206/// Extract `GroupMetadata` from a group context.
207///
208/// **Capability-aware.** On migrated groups (post-bootstrap, where the
209/// The AppData dictionary contains the canonical `COMPONENT_REGISTRY` entry.
210/// The metadata is reconstructed from the dictionary's `CONVERSATION_TYPE`,
211/// `CREATOR_INBOX_ID`, `DM_MEMBERS`, and `ONESHOT_MESSAGE` components.
212pub fn extract_group_metadata(
213    extensions: &Extensions<GroupContext>,
214) -> Result<GroupMetadata, GroupMetadataError> {
215    read_group_metadata_from_dict(extensions)
216}
217
218/// Read `GroupMetadata` from the AppData dictionary.
219fn read_group_metadata_from_dict(
220    extensions: &Extensions<GroupContext>,
221) -> Result<GroupMetadata, GroupMetadataError> {
222    use crate::app_data::component_id::ComponentId;
223    use crate::inbox_id::InboxId as DictInboxId;
224    use crate::tls_set::TlsSet;
225    use tls_codec::Deserialize;
226
227    let ext = extensions
228        .app_data_dictionary()
229        .ok_or(GroupMetadataError::MissingExtension)?;
230    let dict = ext.dictionary();
231
232    if !dict.contains(&ComponentId::COMPONENT_REGISTRY.as_u16()) {
233        return Err(GroupMetadataError::MissingExtension);
234    }
235
236    let Some(ct_bytes) = dict.get(&ComponentId::CONVERSATION_TYPE.as_u16()) else {
237        return Err(GroupMetadataError::Conversion(
238            xmtp_proto::ConversionError::Missing {
239                item: "CONVERSATION_TYPE",
240                r#type: "AppData dictionary entry",
241            },
242        ));
243    };
244    let Some(creator_bytes) = dict.get(&ComponentId::CREATOR_INBOX_ID.as_u16()) else {
245        return Err(GroupMetadataError::Conversion(
246            xmtp_proto::ConversionError::Missing {
247                item: "CREATOR_INBOX_ID",
248                r#type: "AppData dictionary entry",
249            },
250        ));
251    };
252
253    // CONVERSATION_TYPE: 4-byte big-endian i32 matching `ConversationTypeProto`.
254    let ct_arr: [u8; 4] = ct_bytes
255        .try_into()
256        .map_err(|_| GroupMetadataError::InvalidConversationType)?;
257    let conversation_type_i32 = i32::from_be_bytes(ct_arr);
258    let conversation_type: ConversationType = conversation_type_i32.try_into()?;
259
260    // CREATOR_INBOX_ID: versioned `InboxId` TLS form. Hex-encode for the
261    // legacy `String` slot the rest of the codebase consumes.
262    let creator_inbox_id = DictInboxId::tls_deserialize_exact(creator_bytes)
263        .map_err(|e| {
264            GroupMetadataError::Conversion(xmtp_proto::ConversionError::InvalidValue {
265                item: "CREATOR_INBOX_ID",
266                expected: "versioned InboxId TLS encoding",
267                got: format!("{e}"),
268            })
269        })?
270        .to_hex();
271
272    // DM_MEMBERS: `TlsSet<InboxId>` with exactly two elements in a DM, or
273    // absent. The pair grants DM-only authority, so another conversation
274    // type carrying it is malformed.
275    // implements: META-030
276    let dm_members = match dict.get(&ComponentId::DM_MEMBERS.as_u16()) {
277        Some(_) if conversation_type != ConversationType::Dm => {
278            return Err(GroupMetadataError::InvalidDmMembers);
279        }
280        Some(b) => {
281            let set = TlsSet::<DictInboxId>::tls_deserialize_exact(b)
282                .map_err(|_| GroupMetadataError::InvalidDmMembers)?;
283            let ids: Vec<DictInboxId> = set.iter().copied().collect();
284            if ids.len() != 2 {
285                return Err(GroupMetadataError::InvalidDmMembers);
286            }
287            Some(DmMembers {
288                member_one_inbox_id: ids[0].to_hex(),
289                member_two_inbox_id: ids[1].to_hex(),
290            })
291        }
292        None => None,
293    };
294
295    // ONESHOT_MESSAGE: prost-encoded `OneshotMessage`.
296    let oneshot_message = match dict.get(&ComponentId::ONESHOT_MESSAGE.as_u16()) {
297        Some(b) => Some(OneshotMessage::decode(b)?),
298        None => None,
299    };
300
301    Ok(GroupMetadata {
302        conversation_type,
303        creator_inbox_id,
304        dm_members,
305        oneshot_message,
306    })
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    #[xmtp_common::test(unwrap_try = true)]
314    fn test_dm_members_sort() {
315        let members = DmMembers {
316            member_one_inbox_id: "thats_me".to_string(),
317            member_two_inbox_id: "some_wise_guy".to_string(),
318        };
319
320        let members2 = DmMembers {
321            member_one_inbox_id: "some_wise_guy".to_string(),
322            member_two_inbox_id: "thats_me".to_string(),
323        };
324
325        assert_eq!(members.to_string(), members2.to_string());
326    }
327
328    fn extensions_with(
329        conversation_type: ConversationType,
330        dm_members: bool,
331    ) -> Extensions<GroupContext> {
332        use crate::app_data::component_id::ComponentId;
333        use crate::inbox_id::InboxId as DictInboxId;
334        use crate::tls_set::TlsSet;
335        use openmls::extensions::{AppDataDictionary, AppDataDictionaryExtension, Extension};
336        use tls_codec::Serialize;
337
338        let mut dict = AppDataDictionary::new();
339        let mut insert = |id: ComponentId, bytes: Vec<u8>| {
340            let _ = dict.insert(id.as_u16(), bytes);
341        };
342        insert(ComponentId::COMPONENT_REGISTRY, vec![]);
343        insert(
344            ComponentId::CONVERSATION_TYPE,
345            (conversation_type as i32).to_be_bytes().to_vec(),
346        );
347        insert(
348            ComponentId::CREATOR_INBOX_ID,
349            DictInboxId::from_bytes([1; 32])
350                .tls_serialize_detached()
351                .unwrap(),
352        );
353        if dm_members {
354            let pair = TlsSet::from_keys([
355                DictInboxId::from_bytes([1; 32]),
356                DictInboxId::from_bytes([2; 32]),
357            ]);
358            insert(
359                ComponentId::DM_MEMBERS,
360                pair.tls_serialize_detached().unwrap(),
361            );
362        }
363        Extensions::from_vec(vec![Extension::AppDataDictionary(
364            AppDataDictionaryExtension::new(dict),
365        )])
366        .unwrap()
367    }
368
369    /// A DM pair grants DM-only authority (the pair add exception, the DM
370    /// registry exception, and the DM application super-admin role), so a
371    /// conversation that is not a DM must not be able to claim one.
372    // verifies: META-030
373    #[xmtp_common::test(unwrap_try = true)]
374    fn dm_members_are_rejected_outside_a_dm() {
375        let dm = extract_group_metadata(&extensions_with(ConversationType::Dm, true))?;
376        assert!(dm.dm_members.is_some());
377        assert!(extract_group_metadata(&extensions_with(ConversationType::Group, false)).is_ok());
378        for conversation_type in [
379            ConversationType::Group,
380            ConversationType::Sync,
381            ConversationType::Oneshot,
382        ] {
383            assert!(matches!(
384                extract_group_metadata(&extensions_with(conversation_type, true)),
385                Err(GroupMetadataError::InvalidDmMembers)
386            ));
387        }
388    }
389}