1use std::collections::{BTreeSet, HashMap};
4
5use futures::future::try_join_all;
6use openmls::prelude::{ContentType, KeyPackageIn, MlsMessageIn, ProtocolMessage};
7use openmls_rust_crypto::RustCrypto;
8use tls_codec::Deserialize;
9use xmtp_common::RetryableError;
10use xmtp_id::{
11 associations::{
12 self, AccountId, AssociationError, AssociationState, AssociationStateDiff, Handle,
13 IdentityAction as _, SignatureError, try_map_vec, unverified::UnverifiedIdentityUpdate,
14 verify_updates,
15 },
16 key_package::{KeyPackageVerificationError, VerifiedKeyPackageV2},
17 scw_verifier::{BlockStamp, ChainBlocks, SmartContractSignatureVerifier, VerifierError},
18};
19use xmtp_mls_common::commit_log::decode_commit_log;
20use xmtp_proto::{
21 ConversionError,
22 types::{CanonicalEnvelope, Topic, TopicKind, canonical_envelope},
23 xmtp::{
24 backend::v1::{
25 ClientEnvelope, client_envelope::Payload, publish_error::Reason,
26 welcome_message::Version,
27 },
28 identity::associations::{
29 IdentityUpdate, SmartContractWalletSignature, identity_action, signature,
30 },
31 },
32};
33
34pub mod commit;
35pub mod group_membership;
36pub mod group_permissions;
37#[cfg(any(test, feature = "test-utils"))]
38pub mod test_utils;
39
40#[cfg(test)]
41mod tests;
42
43#[derive(Debug, thiserror::Error, xmtp_common::ErrorCode)]
44#[error_code(internal)]
45pub enum ValidationError {
46 #[error("envelope payload is absent")]
48 MissingPayload,
49 #[error("welcome version is absent")]
51 MissingWelcomeVersion,
52 #[error(transparent)]
54 Protobuf(#[from] prost::DecodeError),
55 #[error(transparent)]
57 Tls(#[from] tls_codec::Error),
58 #[error(transparent)]
60 Protocol(#[from] openmls::framing::errors::ProtocolMessageError),
61 #[error(transparent)]
63 #[error_code(inherit)]
64 InvalidTopic(ConversionError),
65 #[error(transparent)]
67 Inbox(#[from] hex::FromHexError),
68 #[error(transparent)]
70 #[error_code(inherit)]
71 KeyPackage(#[from] KeyPackageVerificationError),
72 #[error(transparent)]
76 #[error_code(inherit)]
77 IdentityEncoding(ConversionError),
78 #[error(transparent)]
80 #[error_code(inherit)]
81 Association(#[from] AssociationError),
82 #[error(transparent)]
84 #[error_code(inherit)]
85 Signature(#[from] SignatureError),
86 #[error("signature block {0} is not fresh")]
89 StaleBlock(u64),
90 #[error(transparent)]
93 #[error_code(inherit)]
94 InvalidAccountId(ConversionError),
95}
96
97impl RetryableError for ValidationError {
98 fn is_retryable(&self) -> bool {
99 match self {
100 Self::Signature(error) | Self::Association(AssociationError::Signature(error)) => {
101 error.is_retryable()
102 }
103 _ => false,
104 }
105 }
106}
107
108impl ValidationError {
109 pub fn reason(&self) -> Reason {
117 match self {
118 Self::KeyPackage(_) => Reason::InvalidKeyPackage,
119 Self::Signature(_)
120 | Self::Association(AssociationError::Signature(_))
121 | Self::StaleBlock(_)
122 | Self::InvalidAccountId(_) => Reason::InvalidSignature,
123 Self::IdentityEncoding(_) | Self::Association(_) => Reason::InvalidIdentityUpdate,
124 Self::MissingPayload
125 | Self::MissingWelcomeVersion
126 | Self::Protobuf(_)
127 | Self::Tls(_)
128 | Self::Protocol(_)
129 | Self::InvalidTopic(_)
130 | Self::Inbox(_) => Reason::MalformedPayload,
131 }
132 }
133}
134
135pub struct ParsedEnvelope {
141 pub envelope: ClientEnvelope,
143 pub topic: Topic,
145 pub is_commit_or_proposal: bool,
147 pub canonical: CanonicalEnvelope,
149}
150
151impl ParsedEnvelope {
152 pub fn push_fields(&self) -> (bool, Option<Vec<u8>>) {
155 match self.envelope.payload.as_ref() {
156 Some(Payload::GroupMessage(group)) => (
157 self.is_commit_or_proposal || group.should_push,
158 (group.sender_hmac.len() == 32).then(|| group.sender_hmac.clone()),
159 ),
160 Some(Payload::WelcomeMessage(_)) => (true, None),
161 _ => (false, None),
162 }
163 }
164}
165
166pub fn parse_group_message(data: &[u8]) -> Result<ProtocolMessage, ValidationError> {
171 Ok(MlsMessageIn::tls_deserialize(&mut &data[..])?.try_into_protocol_message()?)
172}
173
174pub fn is_commit_or_proposal(message: &ProtocolMessage) -> bool {
179 matches!(
180 message.content_type(),
181 ContentType::Commit | ContentType::Proposal
182 )
183}
184
185fn checked_topic(kind: TopicKind, identifier: impl AsRef<[u8]>) -> Result<Topic, ValidationError> {
187 let topic = kind.create(identifier);
188 Topic::parse(&topic).map_err(ValidationError::InvalidTopic)
189}
190
191pub fn parse_envelope(envelope: ClientEnvelope) -> Result<ParsedEnvelope, ValidationError> {
198 let payload = envelope
199 .payload
200 .as_ref()
201 .ok_or(ValidationError::MissingPayload)?;
202 let mut is_commit_or_proposal = false;
203 let topic = match payload {
204 Payload::GroupMessage(group) => {
205 let message = parse_group_message(&group.data)?;
206 is_commit_or_proposal = self::is_commit_or_proposal(&message);
207 checked_topic(TopicKind::GroupMessagesV1, message.group_id().as_slice())?
208 }
209 Payload::WelcomeMessage(welcome) => {
210 let key = match welcome
211 .version
212 .as_ref()
213 .ok_or(ValidationError::MissingWelcomeVersion)?
214 {
215 Version::V1(welcome) => &welcome.installation_key,
216 Version::WelcomePointer(pointer) => &pointer.installation_key,
217 };
218 checked_topic(TopicKind::WelcomeMessagesV1, key)?
219 }
220 Payload::KeyPackage(package) => {
221 let package = KeyPackageIn::tls_deserialize_exact(&package.key_package_tls_serialized)?;
222 checked_topic(
223 TopicKind::KeyPackagesV1,
224 package.unverified_credential().signature_key.as_slice(),
225 )?
226 }
227 Payload::IdentityUpdate(update) => {
228 checked_topic(TopicKind::IdentityUpdatesV1, hex::decode(&update.inbox_id)?)?
229 }
230 Payload::CommitLogEntry(entry) => {
231 let decoded = decode_commit_log(&entry.serialized_commit_log_entry)?;
232 checked_topic(TopicKind::CommitLogEntriesV1, &decoded.group_id)?
233 }
234 };
235 let canonical = canonical_envelope(&envelope);
236 Ok(ParsedEnvelope {
237 envelope,
238 topic,
239 is_commit_or_proposal,
240 canonical,
241 })
242}
243
244pub fn verify_key_package(
249 data: &[u8],
250) -> Result<VerifiedKeyPackageV2, KeyPackageVerificationError> {
251 VerifiedKeyPackageV2::from_bytes(&RustCrypto::default(), data)
252}
253
254pub struct AssociationValidation {
255 pub state: AssociationState,
257 pub diff: AssociationStateDiff,
259 pub claimed_handles: Vec<Handle>,
262}
263
264pub async fn validate_identity_updates(
273 old_updates: Vec<IdentityUpdate>,
274 new_updates: Vec<IdentityUpdate>,
275 verifier: impl SmartContractSignatureVerifier,
276) -> Result<AssociationValidation, ValidationError> {
277 let old: Vec<UnverifiedIdentityUpdate> =
278 try_map_vec(old_updates).map_err(ValidationError::IdentityEncoding)?;
279 let new: Vec<UnverifiedIdentityUpdate> =
280 try_map_vec(new_updates).map_err(ValidationError::IdentityEncoding)?;
281 let old = verify_updates(old, &verifier).await?;
282 let new = verify_updates(new, &verifier).await?;
283 let old_state = if old.is_empty() {
284 None
285 } else {
286 Some(associations::get_state(old)?)
287 };
288 let mut state = old_state.clone();
289 let mut claimed_handles = Vec::new();
290 for update in new {
291 let applied = update.update_state(state, update.client_timestamp_ns)?;
292 if update.claims_handle() {
294 claimed_handles.extend(applied.display_handle().cloned());
295 }
296 state = Some(applied);
297 }
298 let state = state.ok_or(AssociationError::NotCreated)?;
299 let diff = match &old_state {
300 Some(old_state) => old_state.diff(&state),
301 None => state.as_diff(),
302 };
303 Ok(AssociationValidation {
304 state,
305 diff,
306 claimed_handles,
307 })
308}
309
310pub const MAX_BLOCK_AGE_SECS: u64 = 1800;
312
313pub fn erc6492_signatures(
315 update: &IdentityUpdate,
316) -> impl Iterator<Item = &SmartContractWalletSignature> {
317 update
318 .actions
319 .iter()
320 .flat_map(|action| match &action.kind {
321 Some(identity_action::Kind::CreateInbox(value)) => {
322 [value.initial_identifier_signature.as_ref(), None]
323 }
324 Some(identity_action::Kind::Add(value)) => [
325 value.existing_member_signature.as_ref(),
326 value.new_member_signature.as_ref(),
327 ],
328 Some(identity_action::Kind::Revoke(value)) => {
329 [value.recovery_identifier_signature.as_ref(), None]
330 }
331 Some(identity_action::Kind::ChangeRecoveryAddress(value)) => {
332 [value.existing_recovery_identifier_signature.as_ref(), None]
333 }
334 Some(identity_action::Kind::ClaimHandle(value)) => [value.signature.as_ref(), None],
335 None => [None, None],
336 })
337 .flatten()
338 .filter_map(|value| match &value.signature {
339 Some(signature::Signature::Erc6492(signature)) => Some(signature),
340 _ => None,
341 })
342}
343
344pub async fn check_freshness(
358 update: &IdentityUpdate,
359 chain: &dyn ChainBlocks,
360) -> Result<(), ValidationError> {
361 let blocks = erc6492_signatures(update)
362 .map(|signature| {
363 let account = AccountId::try_from(signature.account_id.as_str())
364 .map_err(ValidationError::InvalidAccountId)?;
365 account.eip155_chain_id().map_err(SignatureError::from)?;
366 Ok((account.get_chain_id().to_owned(), signature.block_number))
367 })
368 .collect::<Result<BTreeSet<_>, ValidationError>>()?;
369 let chain_ids: BTreeSet<&str> = blocks.iter().map(|(id, _)| id.as_str()).collect();
370 let heads: HashMap<&str, BlockStamp> = try_join_all(
371 chain_ids
372 .into_iter()
373 .map(|id| async move { Ok::<_, VerifierError>((id, chain.head(id).await?)) }),
374 )
375 .await
376 .map_err(SignatureError::from)?
377 .into_iter()
378 .collect();
379 if let Some((_, number)) = blocks
380 .iter()
381 .find(|(id, number)| *number > heads[id.as_str()].number)
382 {
383 return Err(ValidationError::StaleBlock(*number));
384 }
385 let timestamps = try_join_all(
386 blocks
387 .iter()
388 .map(|(id, number)| chain.timestamp(id, *number)),
389 )
390 .await
391 .map_err(SignatureError::from)?;
392 blocks
393 .iter()
394 .zip(timestamps)
395 .try_for_each(|((id, number), timestamp)| {
396 match heads[id.as_str()].timestamp.checked_sub(timestamp) {
397 Some(age) if age <= MAX_BLOCK_AGE_SECS => Ok(()),
398 _ => Err(ValidationError::StaleBlock(*number)),
399 }
400 })
401}
402
403pub async fn validate_envelope(
410 parsed: &ParsedEnvelope,
411 history: &[IdentityUpdate],
412 verifier: impl SmartContractSignatureVerifier,
413 chain: &dyn ChainBlocks,
414) -> Result<Option<AssociationValidation>, ValidationError> {
415 match parsed
416 .envelope
417 .payload
418 .as_ref()
419 .ok_or(ValidationError::MissingPayload)?
420 {
421 Payload::KeyPackage(package) => {
422 verify_key_package(&package.key_package_tls_serialized)?;
423 Ok(None)
424 }
425 Payload::IdentityUpdate(update) => {
426 check_freshness(update, chain).await?;
427 Ok(Some(
428 validate_identity_updates(history.to_vec(), vec![update.clone()], verifier).await?,
429 ))
430 }
431 _ => Ok(None),
432 }
433}