xmtp_mls/groups/welcomes/
validated_membership.rs1use crate::context::XmtpSharedContext;
2use crate::groups::{GroupError, GroupMembership};
3use crate::identity::parse_credential;
4use crate::identity_updates::{
5 IdentityDependencyError, IdentityRequirement, InstallationDiffError, MissingProofs,
6 resolve_identity_requirements,
7};
8use openmls::prelude::{BasicCredential, StagedWelcome};
9use std::collections::{HashMap, HashSet};
10use xmtp_common::snippet::Snippet;
11use xmtp_db::DbQuery;
12use xmtp_mls_validation::{
13 commit::extract_group_membership_with_failures, group_membership::FailedInstallations,
14};
15
16#[allow(async_fn_in_trait)]
18pub trait ValidateGroupMembership {
19 async fn check_initial_membership(&self, welcome: &WelcomeMembership)
21 -> Result<(), GroupError>;
22
23 fn check_verified_membership(
25 &self,
26 _welcome: &WelcomeMembership,
27 _db: &impl DbQuery,
28 ) -> Result<(), GroupError> {
29 Ok(())
30 }
31}
32
33#[derive(Debug, PartialEq)]
35pub struct WelcomeMembership {
36 membership: GroupMembership,
38 failed: FailedInstallations,
39 members: Vec<(String, Vec<u8>)>,
41}
42
43impl WelcomeMembership {
44 pub(crate) fn from_staged(welcome: &StagedWelcome) -> Result<Self, GroupError> {
46 let extensions = welcome.group_context().extensions();
47 let (membership, failed) = extract_group_membership_with_failures(extensions)
48 .map_err(|_| GroupError::InvalidWelcomeMetadata)?;
49 let members = welcome
50 .members()
51 .map(|member| {
52 let credential = BasicCredential::try_from(member.credential.clone())?;
53 Ok((
54 parse_credential(credential.identity())?,
55 member.signature_key,
56 ))
57 })
58 .collect::<Result<_, GroupError>>()?;
59 Ok(Self {
60 membership,
61 failed,
62 members,
63 })
64 }
65
66 fn requirements(&self) -> impl Iterator<Item = IdentityRequirement> + '_ {
67 self.membership
68 .members
69 .iter()
70 .map(|(inbox_id, sequence_id)| IdentityRequirement {
71 inbox_id: inbox_id.clone(),
72 sequence_id: *sequence_id,
73 })
74 }
75
76 pub(crate) fn validate_sequences(&self, welcome_sequence: u64) -> Result<(), GroupError> {
79 for requirement in self.requirements() {
80 if requirement.sequence_id == 0 || requirement.sequence_id >= welcome_sequence {
81 return Err(
82 InstallationDiffError::from(IdentityDependencyError::InvalidSequence(
83 requirement.sequence_id,
84 ))
85 .into(),
86 );
87 }
88 }
89 Ok(())
90 }
91}
92
93pub struct InitialMembershipValidator<C> {
95 context: C,
96}
97
98impl<C> InitialMembershipValidator<C> {
99 pub fn new(context: C) -> InitialMembershipValidator<C> {
100 Self { context }
101 }
102}
103
104impl<C> ValidateGroupMembership for InitialMembershipValidator<C>
105where
106 C: XmtpSharedContext,
107{
108 async fn check_initial_membership(
110 &self,
111 welcome: &WelcomeMembership,
112 ) -> Result<(), GroupError> {
113 resolve_identity_requirements(&self.context, &welcome.requirements().collect())
114 .await
115 .map_err(InstallationDiffError::from)?;
116 self.check_verified_membership(welcome, &self.context.db())
117 }
118
119 fn check_verified_membership(
122 &self,
123 welcome: &WelcomeMembership,
124 db: &impl DbQuery,
125 ) -> Result<(), GroupError> {
126 let mut expected_members = HashMap::<String, HashSet<Vec<u8>>>::new();
127 let mut missing = MissingProofs::default();
128 for requirement in welcome.requirements() {
129 let Some(association_state) = missing
130 .read(db, &requirement)
131 .map_err(InstallationDiffError::from)?
132 else {
133 continue;
134 };
135 expected_members.insert(
136 association_state.inbox_id().to_string(),
137 HashSet::from_iter(association_state.installation_ids()),
138 );
139 }
140 missing.check().map_err(InstallationDiffError::from)?;
141
142 for (claimed_inbox_id, signature_key) in &welcome.members {
143 let Some(installation_ids) = expected_members.get_mut(claimed_inbox_id) else {
144 tracing::error!(
145 claimed_inbox_id = claimed_inbox_id,
146 "Inbox ID not found in expected members",
147 );
148 return Err(GroupError::InvalidGroupMembership);
149 };
150 if !installation_ids.contains(signature_key) {
151 tracing::error!(
152 claimed_inbox_id = claimed_inbox_id,
153 "Installation ID not found in expected members for inbox ID",
154 );
155 return Err(GroupError::InvalidGroupMembership);
156 }
157 installation_ids.remove(signature_key);
158 }
159 for (inbox, installation_set) in &expected_members {
160 for remaining_installation_id in installation_set {
161 if !welcome
162 .failed
163 .of(inbox)
164 .is_some_and(|keys| keys.contains(remaining_installation_id))
165 {
166 tracing::error!(
167 installation_id = %remaining_installation_id.as_slice().snippet(),
168 "Installation ID in expected members not found in ratchet tree",
169 );
170 return Err(GroupError::InvalidGroupMembership);
171 }
172 }
173 }
174
175 tracing::info!("Group membership validated");
176
177 Ok(())
178 }
179}
180
181#[cfg(any(test, feature = "test-utils"))]
182pub mod test {
183 use super::*;
184
185 #[derive(Default)]
186 pub struct NoopValidator;
187
188 impl ValidateGroupMembership for NoopValidator {
189 async fn check_initial_membership(
190 &self,
191 _welcome: &WelcomeMembership,
192 ) -> Result<(), GroupError> {
193 Ok(())
194 }
195 }
196}
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201 use crate::tester;
202 use std::collections::BTreeMap;
203 use xmtp_db::identity_update::QueryIdentityUpdates;
204 use xmtp_mls_group_state::{
205 app_data::migration::encode_group_membership_dict, inbox_id::InboxId,
206 };
207 use xmtp_mls_validation::commit::membership_entries;
208 use xmtp_proto::xmtp::mls::message_contents::{
209 GroupMembershipEntry,
210 group_membership_entry::{V1, Version},
211 };
212
213 #[xmtp_common::test(unwrap_try = true)]
215 async fn misattributed_failed_installation_is_rejected() {
216 tester!(alix, disable_workers);
217 tester!(bo, disable_workers);
218 tester!(caro, disable_workers);
219 tester!(bo2, from: bo, disable_workers);
220 for client in [&*alix, &*bo, &*caro] {
221 client.inbox_state(true).await?;
222 }
223 let sequence = |client: &crate::utils::FullXmtpClient| -> u64 {
224 client
225 .context
226 .db()
227 .get_latest_sequence_id_for_inbox(client.inbox_id())
228 .unwrap() as u64
229 };
230 let absent_id = bo2.context.installation_id().to_vec();
231 let members = [&*alix, &*bo, &*caro]
232 .into_iter()
233 .map(|client| {
234 (
235 client.inbox_id().to_string(),
236 client.context.installation_id().to_vec(),
237 )
238 })
239 .collect::<Vec<_>>();
240 let validator = InitialMembershipValidator::new(caro.context.clone());
241 for owner in [alix.inbox_id(), bo.inbox_id()] {
242 let entries = [&*alix, &*bo, &*caro]
243 .into_iter()
244 .map(|client| {
245 (
246 InboxId::from_hex(client.inbox_id()).unwrap(),
247 GroupMembershipEntry {
248 version: Some(Version::V1(V1 {
249 sequence_id: sequence(client),
250 failed_installations: if client.inbox_id() == owner {
251 vec![absent_id.clone()]
252 } else {
253 vec![]
254 },
255 })),
256 },
257 )
258 })
259 .collect::<BTreeMap<_, _>>();
260 let (membership, failed) =
261 membership_entries(&encode_group_membership_dict(&entries)?)?;
262 let welcome = WelcomeMembership {
263 membership,
264 failed,
265 members: members.clone(),
266 };
267 let result = validator.check_initial_membership(&welcome).await;
268 if owner == bo.inbox_id() {
269 result?;
270 } else {
271 assert!(matches!(result, Err(GroupError::InvalidGroupMembership)));
272 }
273 }
274 }
275}