Skip to main content

xmtp_mls_validation/
lib.rs

1//! Shared payload admission. Storage and transport remain with their callers.
2
3use 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    /// The payload selection is absent. Not retryable.
47    #[error("envelope payload is absent")]
48    MissingPayload,
49    /// The welcome version is absent. Not retryable.
50    #[error("welcome version is absent")]
51    MissingWelcomeVersion,
52    /// Protobuf framing is malformed. Not retryable.
53    #[error(transparent)]
54    Protobuf(#[from] prost::DecodeError),
55    /// MLS framing is malformed. Not retryable.
56    #[error(transparent)]
57    Tls(#[from] tls_codec::Error),
58    /// The MLS body is not a protocol message. Not retryable.
59    #[error(transparent)]
60    Protocol(#[from] openmls::framing::errors::ProtocolMessageError),
61    /// The topic identifier has an invalid shape. Not retryable.
62    #[error(transparent)]
63    #[error_code(inherit)]
64    InvalidTopic(ConversionError),
65    /// The inbox identifier is not hexadecimal. Not retryable.
66    #[error(transparent)]
67    Inbox(#[from] hex::FromHexError),
68    /// The key package fails existing validation. Not retryable.
69    #[error(transparent)]
70    #[error_code(inherit)]
71    KeyPackage(#[from] KeyPackageVerificationError),
72    /// Identity fields cannot be decoded, for example a field is unset or
73    /// unlisted, or an Ethereum identifier is not in its one form. Not
74    /// retryable.
75    #[error(transparent)]
76    #[error_code(inherit)]
77    IdentityEncoding(ConversionError),
78    /// Identity state transition failed. Nested verifier failures can be retryable.
79    #[error(transparent)]
80    #[error_code(inherit)]
81    Association(#[from] AssociationError),
82    /// Signature verification failed. Provider and I/O failures can be retryable.
83    #[error(transparent)]
84    #[error_code(inherit)]
85    Signature(#[from] SignatureError),
86    /// An ERC-6492 signature names a block after the chain head or more than
87    /// [`MAX_BLOCK_AGE_SECS`] before it. Not retryable.
88    #[error("signature block {0} is not fresh")]
89    StaleBlock(u64),
90    /// An ERC-6492 signature's account id is not a CAIP-10 account id, so the
91    /// signature cannot be verified. Not retryable.
92    #[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    /// Classify this error for the backend publish response.
110    ///
111    /// The transport supplies the input index. Retryability is kept separate so
112    /// a provider failure can become `UNAVAILABLE` instead of a bad-payload
113    /// reason. An identity update is malformed only when its inbox id names no
114    /// topic; every other identity failure is a signature or identity reason.
115    // implements: API-230
116    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
135/// Parsed routing metadata and canonical storage bytes.
136///
137/// Parsing derives the topic and retention flag, but does not prove signatures,
138/// group membership, or key-package validity. The outer bytes and hash are
139/// stable for retries; payload byte fields remain unchanged.
140pub struct ParsedEnvelope {
141    /// The decoded client envelope, including its original payload bytes.
142    pub envelope: ClientEnvelope,
143    /// The topic derived from the payload, never supplied by the client.
144    pub topic: Topic,
145    /// Whether an MLS group message carries a commit or proposal content type.
146    pub is_commit_or_proposal: bool,
147    /// Canonical outer protobuf bytes and their SHA-256 hash.
148    pub canonical: CanonicalEnvelope,
149}
150
151impl ParsedEnvelope {
152    /// Classify a parsed envelope for push storage without changing canonical bytes.
153    /// Commits and proposals remain eligible even when the sender disables pushes.
154    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
166/// Parse an MLS group message and preserve accepted trailing bytes.
167///
168/// The parser consumes the first TLS-encoded message. It returns framing or
169/// protocol errors, but does not authenticate the sender or inspect membership.
170pub fn parse_group_message(data: &[u8]) -> Result<ProtocolMessage, ValidationError> {
171    Ok(MlsMessageIn::tls_deserialize(&mut &data[..])?.try_into_protocol_message()?)
172}
173
174/// Classify an MLS message for retention purposes.
175///
176/// Commit and proposal messages are retained without an expiry. This predicate
177/// does not authenticate the sender or validate group state.
178pub fn is_commit_or_proposal(message: &ProtocolMessage) -> bool {
179    matches!(
180        message.content_type(),
181        ContentType::Commit | ContentType::Proposal
182    )
183}
184
185/// Build and re-parse a topic so malformed identifiers fail before storage.
186fn 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
191/// Decode one envelope, derive its topic, and compute canonical retry bytes.
192///
193/// This is the routing phase. It performs the payload-specific decoding needed
194/// to find a topic, but leaves key-package and identity admission to
195/// [`validate_envelope`]. A malformed envelope returns before any verifier call.
196// implements: TOPIC-001, TOPIC-002, API-230
197pub 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
244/// Apply the existing key-package admission checks to serialized bytes.
245///
246/// This function returns the verified package for callers that need it, but the
247/// backend uses it only to reject invalid packages. It does not alter the input.
248pub fn verify_key_package(
249    data: &[u8],
250) -> Result<VerifiedKeyPackageV2, KeyPackageVerificationError> {
251    VerifiedKeyPackageV2::from_bytes(&RustCrypto::default(), data)
252}
253
254pub struct AssociationValidation {
255    /// State after applying all supplied updates.
256    pub state: AssociationState,
257    /// Active-member changes between the old and resulting states.
258    pub diff: AssociationStateDiff,
259    /// The handle each new update claims, in update order. A claim of a handle
260    /// the inbox already holds is included.
261    pub claimed_handles: Vec<Handle>,
262}
263
264/// Validate a new identity suffix against one complete history snapshot.
265///
266/// `old_updates` must be the ordered state read from storage, and
267/// `new_updates` must be the proposed suffix. This function performs signature
268/// verification and pure state transitions only; it does not read storage. An
269/// update in either list that does not decode fails as
270/// [`ValidationError::IdentityEncoding`]. A verifier error is returned
271/// unchanged so callers can preserve retryability.
272pub 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        // A claim moves its handle to the end, so it is the display handle.
293        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
310/// The oldest a new update's ERC-6492 block may be, relative to the chain head.
311pub const MAX_BLOCK_AGE_SECS: u64 = 1800;
312
313/// Every ERC-6492 signature an identity update carries, in action order.
314pub 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
344/// Reject a new update whose ERC-6492 signature names a block after its
345/// chain's head, or one more than [`MAX_BLOCK_AGE_SECS`] before it.
346///
347/// Every account id's form, chain id and address, is checked before
348/// the first chain call, so a malformed one is rejected without chain access. Each chain's head is then
349/// read once, so a block after it is rejected without further calls, and each
350/// distinct block's timestamp once; reads within a round run concurrently.
351/// Chain failures stay retryable; they are never a verdict on the signature.
352/// A block stamped after its head, as reads straddling a reorg can report, is
353/// rejected rather than read as fresh.
354/// Callers run this before signature verification: a verifier asked about a
355/// block the chain has not produced fails retryably instead of rejecting.
356// implements: IDENT-062
357pub 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
403/// Validate one parsed envelope after duplicate lookup.
404///
405/// Key packages receive their existing package checks. Identity updates are
406/// folded against the caller's snapshot and return a projection diff; only the
407/// new update, never the stored history, must first pass [`check_freshness`]. Other
408/// kinds need no cryptographic admission beyond the parsing phase.
409pub 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}