Skip to main content

xmtp_mls_validation/
lib.rs

1//! Shared payload admission. Storage and transport remain with their callers.
2
3use openmls::prelude::{ContentType, KeyPackageIn, MlsMessageIn, ProtocolMessage};
4use openmls_rust_crypto::RustCrypto;
5use tls_codec::Deserialize;
6use xmtp_common::RetryableError;
7use xmtp_id::{
8    associations::{
9        self, AssociationError, AssociationState, AssociationStateDiff, DeserializationError,
10        SignatureError, try_map_vec, unverified::UnverifiedIdentityUpdate, verify_updates,
11    },
12    key_package::{KeyPackageVerificationError, VerifiedKeyPackageV2},
13    scw_verifier::SmartContractSignatureVerifier,
14};
15use xmtp_mls_common::commit_log::decode_commit_log;
16use xmtp_proto::{
17    ConversionError,
18    types::{CanonicalEnvelope, Topic, TopicKind, canonical_envelope},
19    xmtp::{
20        backend::v1::{
21            ClientEnvelope, client_envelope::Payload, publish_error::Reason,
22            welcome_message::Version,
23        },
24        identity::associations::IdentityUpdate,
25    },
26};
27
28#[cfg(any(test, feature = "test-utils"))]
29pub mod test_utils;
30
31#[cfg(test)]
32mod tests;
33
34#[derive(Debug, thiserror::Error, xmtp_common::ErrorCode)]
35#[error_code(internal)]
36pub enum ValidationError {
37    /// The payload selection is absent. Not retryable.
38    #[error("envelope payload is absent")]
39    MissingPayload,
40    /// The welcome version is absent. Not retryable.
41    #[error("welcome version is absent")]
42    MissingWelcomeVersion,
43    /// Protobuf framing is malformed. Not retryable.
44    #[error(transparent)]
45    Protobuf(#[from] prost::DecodeError),
46    /// MLS framing is malformed. Not retryable.
47    #[error(transparent)]
48    Tls(#[from] tls_codec::Error),
49    /// The MLS body is not a protocol message. Not retryable.
50    #[error(transparent)]
51    Protocol(#[from] openmls::framing::errors::ProtocolMessageError),
52    /// The identifier or topic has an invalid shape. Not retryable.
53    #[error(transparent)]
54    #[error_code(inherit)]
55    Conversion(#[from] ConversionError),
56    /// The inbox identifier is not hexadecimal. Not retryable.
57    #[error(transparent)]
58    Inbox(#[from] hex::FromHexError),
59    /// The key package fails existing validation. Not retryable.
60    #[error(transparent)]
61    #[error_code(inherit)]
62    KeyPackage(#[from] KeyPackageVerificationError),
63    /// Identity fields cannot be decoded. Not retryable.
64    #[error(transparent)]
65    #[error_code(inherit)]
66    IdentityEncoding(#[from] DeserializationError),
67    /// Identity state transition failed. Nested verifier failures can be retryable.
68    #[error(transparent)]
69    #[error_code(inherit)]
70    Association(#[from] AssociationError),
71    /// Signature verification failed. Provider and I/O failures can be retryable.
72    #[error(transparent)]
73    #[error_code(inherit)]
74    Signature(#[from] SignatureError),
75}
76
77impl RetryableError for ValidationError {
78    fn is_retryable(&self) -> bool {
79        match self {
80            Self::Signature(error) | Self::Association(AssociationError::Signature(error)) => {
81                error.is_retryable()
82            }
83            _ => false,
84        }
85    }
86}
87
88impl ValidationError {
89    /// Classify this error for the backend publish response.
90    ///
91    /// The transport supplies the input index. Retryability is kept separate so
92    /// a provider failure can become `UNAVAILABLE` instead of a bad-payload
93    /// reason.
94    pub fn reason(&self) -> Reason {
95        match self {
96            Self::KeyPackage(_) => Reason::InvalidKeyPackage,
97            Self::Signature(_) | Self::Association(AssociationError::Signature(_)) => {
98                Reason::InvalidSignature
99            }
100            Self::IdentityEncoding(_) | Self::Association(_) => Reason::InvalidIdentityUpdate,
101            _ => Reason::MalformedPayload,
102        }
103    }
104}
105
106/// Parsed routing metadata and canonical storage bytes.
107///
108/// Parsing derives the topic and retention flag, but does not prove signatures,
109/// group membership, or key-package validity. The outer bytes and hash are
110/// stable for retries; payload byte fields remain unchanged.
111pub struct ParsedEnvelope {
112    /// The decoded client envelope, including its original payload bytes.
113    pub envelope: ClientEnvelope,
114    /// The topic derived from the payload, never supplied by the client.
115    pub topic: Topic,
116    /// Whether an MLS group message carries a commit or proposal content type.
117    pub is_commit_or_proposal: bool,
118    /// Canonical outer protobuf bytes and their SHA-256 hash.
119    pub canonical: CanonicalEnvelope,
120}
121
122impl ParsedEnvelope {
123    /// Classify a parsed envelope for push storage without changing canonical bytes.
124    /// Commits and proposals remain eligible even when the sender disables pushes.
125    pub fn push_fields(&self) -> (bool, Option<Vec<u8>>) {
126        match self.envelope.payload.as_ref() {
127            Some(Payload::GroupMessage(group)) => (
128                self.is_commit_or_proposal || group.should_push,
129                (group.sender_hmac.len() == 32).then(|| group.sender_hmac.clone()),
130            ),
131            Some(Payload::WelcomeMessage(_)) => (true, None),
132            _ => (false, None),
133        }
134    }
135}
136
137/// Parse an MLS group message and preserve accepted trailing bytes.
138///
139/// The parser consumes the first TLS-encoded message. It returns framing or
140/// protocol errors, but does not authenticate the sender or inspect membership.
141pub fn parse_group_message(data: &[u8]) -> Result<ProtocolMessage, ValidationError> {
142    Ok(MlsMessageIn::tls_deserialize(&mut &data[..])?.try_into_protocol_message()?)
143}
144
145/// Classify an MLS message for retention purposes.
146///
147/// Commit and proposal messages are retained without an expiry. This predicate
148/// does not authenticate the sender or validate group state.
149pub fn is_commit_or_proposal(message: &ProtocolMessage) -> bool {
150    matches!(
151        message.content_type(),
152        ContentType::Commit | ContentType::Proposal
153    )
154}
155
156/// Build and re-parse a topic so malformed identifiers fail before storage.
157fn checked_topic(kind: TopicKind, identifier: impl AsRef<[u8]>) -> Result<Topic, ValidationError> {
158    let topic = kind.create(identifier);
159    Ok(Topic::parse(&topic)?)
160}
161
162/// Decode one envelope, derive its topic, and compute canonical retry bytes.
163///
164/// This is the routing phase. It performs the payload-specific decoding needed
165/// to find a topic, but leaves key-package and identity admission to
166/// [`validate_envelope`]. A malformed envelope returns before any verifier call.
167pub fn parse_envelope(envelope: ClientEnvelope) -> Result<ParsedEnvelope, ValidationError> {
168    let payload = envelope
169        .payload
170        .as_ref()
171        .ok_or(ValidationError::MissingPayload)?;
172    let mut is_commit_or_proposal = false;
173    let topic = match payload {
174        Payload::GroupMessage(group) => {
175            let message = parse_group_message(&group.data)?;
176            is_commit_or_proposal = self::is_commit_or_proposal(&message);
177            checked_topic(TopicKind::GroupMessagesV1, message.group_id().as_slice())?
178        }
179        Payload::WelcomeMessage(welcome) => {
180            let key = match welcome
181                .version
182                .as_ref()
183                .ok_or(ValidationError::MissingWelcomeVersion)?
184            {
185                Version::V1(welcome) => &welcome.installation_key,
186                Version::WelcomePointer(pointer) => &pointer.installation_key,
187            };
188            checked_topic(TopicKind::WelcomeMessagesV1, key)?
189        }
190        Payload::KeyPackage(package) => {
191            let package = KeyPackageIn::tls_deserialize_exact(&package.key_package_tls_serialized)?;
192            checked_topic(
193                TopicKind::KeyPackagesV1,
194                package.unverified_credential().signature_key.as_slice(),
195            )?
196        }
197        Payload::IdentityUpdate(update) => {
198            checked_topic(TopicKind::IdentityUpdatesV1, hex::decode(&update.inbox_id)?)?
199        }
200        Payload::CommitLogEntry(entry) => {
201            let decoded = decode_commit_log(&entry.serialized_commit_log_entry)?;
202            checked_topic(TopicKind::CommitLogEntriesV1, &decoded.group_id)?
203        }
204    };
205    let canonical = canonical_envelope(&envelope);
206    Ok(ParsedEnvelope {
207        envelope,
208        topic,
209        is_commit_or_proposal,
210        canonical,
211    })
212}
213
214/// Apply the existing key-package admission checks to serialized bytes.
215///
216/// This function returns the verified package for callers that need it, but the
217/// backend uses it only to reject invalid packages. It does not alter the input.
218pub fn verify_key_package(
219    data: &[u8],
220) -> Result<VerifiedKeyPackageV2, KeyPackageVerificationError> {
221    VerifiedKeyPackageV2::from_bytes(&RustCrypto::default(), data)
222}
223
224pub struct AssociationValidation {
225    /// State after applying all supplied updates.
226    pub state: AssociationState,
227    /// Active-member changes between the old and resulting states.
228    pub diff: AssociationStateDiff,
229}
230
231/// Validate a new identity suffix against one complete history snapshot.
232///
233/// `old_updates` must be the ordered state read from storage, and
234/// `new_updates` must be the proposed suffix. This function performs signature
235/// verification and pure state transitions only; it does not read storage. A
236/// verifier error is returned unchanged so callers can preserve retryability.
237pub async fn validate_identity_updates(
238    old_updates: Vec<IdentityUpdate>,
239    new_updates: Vec<IdentityUpdate>,
240    verifier: impl SmartContractSignatureVerifier,
241) -> Result<AssociationValidation, ValidationError> {
242    let old: Vec<UnverifiedIdentityUpdate> = try_map_vec(old_updates)?;
243    let new: Vec<UnverifiedIdentityUpdate> = try_map_vec(new_updates)?;
244    let old = verify_updates(old, &verifier).await?;
245    let new = verify_updates(new, &verifier).await?;
246    if old.is_empty() {
247        let state = associations::get_state(new)?;
248        let diff = state.as_diff();
249        return Ok(AssociationValidation { state, diff });
250    }
251    let old_state = associations::get_state(old)?;
252    let mut state = old_state.clone();
253    for update in new {
254        state = associations::apply_update(state, update)?;
255    }
256    let diff = old_state.diff(&state);
257    Ok(AssociationValidation { state, diff })
258}
259
260/// Validate one parsed envelope after duplicate lookup.
261///
262/// Key packages receive their existing package checks. Identity updates are
263/// folded against the caller's snapshot and return a projection diff. Other
264/// kinds need no cryptographic admission beyond the parsing phase.
265pub async fn validate_envelope(
266    parsed: &ParsedEnvelope,
267    history: &[IdentityUpdate],
268    verifier: impl SmartContractSignatureVerifier,
269) -> Result<Option<AssociationValidation>, ValidationError> {
270    match parsed
271        .envelope
272        .payload
273        .as_ref()
274        .ok_or(ValidationError::MissingPayload)?
275    {
276        Payload::KeyPackage(package) => {
277            verify_key_package(&package.key_package_tls_serialized)?;
278            Ok(None)
279        }
280        Payload::IdentityUpdate(update) => Ok(Some(
281            validate_identity_updates(history.to_vec(), vec![update.clone()], verifier).await?,
282        )),
283        _ => Ok(None),
284    }
285}