Skip to main content

xmtp_mls/groups/
mls_sync.rs

1use super::{
2    FailedInstallationIds, GroupError, HmacKey, MlsGroup,
3    change_callbacks::AppDataChange,
4    intents::{
5        CommitPendingProposalsIntentData, Installation, IntentError, PostCommitAction,
6        ProposeMemberUpdateIntentData, SendMessageIntentData, SendWelcomesAction,
7        UpdateAdminListIntentData, UpdateGroupMembershipIntentData, UpdatePermissionIntentData,
8    },
9    summary::{MessageIdentifier, MessageIdentifierBuilder, ProcessSummary, SyncSummary},
10    validated_commit::{CommitValidationError, validate_proposal},
11};
12use crate::{
13    context::XmtpSharedContext,
14    core::hmac_epoch::hmac_epoch,
15    groups::traits::IntoWith,
16    groups::{
17        group_membership::GroupMembership,
18        intents::MembershipDiffWithKeyPackages,
19        intents::{QueueIntent, ReaddInstallationsIntentData, UpdateMetadataIntentData},
20        mls_ext::{CommitLogStorer, MlsGroupReload},
21        mls_sync::update_group_membership::apply_readd_installations_intent,
22        validated_commit::ValidatedCommit,
23    },
24    groups::{
25        hash::sha256,
26        message_id::{calculate_message_id, calculate_message_id_for_intent},
27    },
28    identity::{IdentityError, parse_credential},
29    identity_updates::{IdentityUpdateError, IdentityUpdates, load_identity_updates_for_client},
30    intents::ProcessIntentError,
31    messages::{decoded_message::MessageBody, enrichment::EnrichMessageError},
32    mls_store::MlsStore,
33};
34use hkdf::Hkdf;
35use hmac::{Hmac, Mac};
36use openmls::prelude::BasicCredentialError;
37use openmls::{
38    credentials::BasicCredential,
39    framing::ProtocolMessage,
40    group::{
41        CommitToPendingProposalsError, GroupContext, GroupEpoch, ProcessMessageError, StagedCommit,
42        ValidationError,
43    },
44    key_packages::KeyPackage,
45    messages::proposals::Proposal,
46    prelude::{
47        Extensions, LeafNodeIndex, MlsGroup as OpenMlsGroup, ProcessedMessage,
48        ProcessedMessageContent, ProposalType, Sender,
49        tls_codec::{Error as TlsCodecError, Serialize},
50    },
51    treesync::LeafNodeParameters,
52};
53use openmls_traits::OpenMlsProvider;
54use prost::Message;
55use prost::bytes::Bytes;
56use sha2::Sha256;
57use std::{
58    collections::{BTreeSet, HashMap, HashSet},
59    mem::{Discriminant, discriminant},
60    ops::RangeInclusive,
61    time::Duration,
62};
63use thiserror::Error;
64use tracing::debug;
65use update_group_membership::apply_update_group_membership_intent;
66use xmtp_api::PublishUnit;
67use xmtp_common::{Event, ExponentialBackoff, RetryableError, Strategy, log_event, time::now_ns};
68use xmtp_configuration::{
69    HMAC_SALT, MAX_GROUP_SYNC_RETRIES, MAX_PAST_EPOCHS, SYNC_BACKOFF_TOTAL_WAIT_MAX_SECS,
70    SYNC_BACKOFF_WAIT_MS, SYNC_JITTER_MS, SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS,
71    WELCOME_HPKE_LABEL,
72};
73use xmtp_content_types::{CodecError, ContentCodec, group_updated::GroupUpdatedCodec};
74use xmtp_db::TransactionOutcome::{Continue, Rollback};
75use xmtp_db::XmtpMlsStorageProvider;
76use xmtp_db::group::GroupMembershipState;
77use xmtp_db::{
78    Fetch, StorageError, StoreOrIgnore, TransactionOutcome,
79    group::ConversationType,
80    group_intent::{ID, IntentKind, IntentState, StoredGroupIntent},
81    group_message::{ContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage},
82    remote_commit_log::CommitResult,
83    sql_key_store,
84    user_preferences::ensure_root_key,
85};
86use xmtp_db::{NotFound, group_intent::IntentKind::MetadataUpdate};
87use xmtp_db::{XmtpOpenMlsProviderRef, prelude::*};
88use xmtp_db::{
89    group_message::MsgQueryArgs,
90    pending_remove::{PendingRemove, QueryPendingRemove},
91};
92use xmtp_events::{ClientEvent, EventContext, EventWriter};
93use xmtp_id::{InboxId, InboxIdRef};
94use xmtp_mls_group_keys::mls_ext::payload_encryption::{
95    WrapPayloadError, wrap_payload_hpke, wrap_payload_symmetric,
96};
97use xmtp_mls_group_state::app_data::component_id::ComponentId;
98use xmtp_mls_group_state::group_mutable_metadata::MetadataField;
99use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
100use xmtp_mls_validation::commit::{
101    CommitRuleError, Inbox, MetadataChanges, extract_group_membership,
102};
103use xmtp_proto::ShortHex;
104use xmtp_proto::backend_v1::{
105    ClientEnvelope, GroupMessage as BackendGroupMessage, WelcomeMessage as WelcomeMessageInput,
106    client_envelope::Payload,
107    welcome_message::{
108        V1 as WelcomeMessageInputV1, Version as WelcomeMessageInputVersion,
109        WelcomePointer as WelcomePointerInput,
110    },
111};
112use xmtp_proto::types::GroupId;
113use xmtp_proto::xmtp::mls::{
114    database::{ProcessPendingSelfRemove, Task as TaskProto, task::Task as TaskKind},
115    message_contents::{
116        GroupUpdated, PlaintextEnvelope, WelcomeMetadata, WelcomePointer as WelcomePointerProto,
117        group_updated,
118        plaintext_envelope::{Content, V2},
119    },
120};
121use xmtp_proto::{
122    GroupUpdateDeduper,
123    types::{Cursor, GroupMessage},
124};
125use zeroize::Zeroizing;
126
127mod envelope_and_app_data;
128mod external_message;
129mod leave_and_delete;
130mod membership_helpers;
131mod own_message;
132mod post_commit;
133mod processing;
134mod receive;
135mod sync_driver;
136#[cfg(any(test, feature = "test-hooks"))]
137pub mod test_mocks;
138pub(in crate::groups) use membership_helpers::*;
139mod processing_policy;
140pub mod update_group_membership;
141pub(crate) use processing::GroupHeadOutcome;
142pub(crate) mod publish;
143
144impl<Context: XmtpSharedContext> MlsGroup<Context> {
145    fn metadata_changed_event(&self, commit: &ValidatedCommit) -> Option<ClientEvent> {
146        let changed: BTreeSet<_> = commit
147            .metadata_component_ids
148            .iter()
149            .copied()
150            .map(ComponentId::from)
151            .map(ComponentId::event_name)
152            .collect();
153        (!changed.is_empty()).then(|| {
154            ClientEvent::ConversationMetadataChanged(xmtp_events::MetadataChanged {
155                group_id: self.group_id.to_vec(),
156                changed: changed.into_iter().collect(),
157            })
158        })
159    }
160
161    pub(in crate::groups) fn emit_message_status_changed(
162        &self,
163        message_id: Vec<u8>,
164        previous: xmtp_events::MessageStatus,
165        current: xmtp_events::MessageStatus,
166        writer: &impl EventWriter<crate::subscriptions::internal::InternalEvent>,
167    ) {
168        if self.conversation_type.is_virtual() || previous == current {
169            return;
170        }
171        writer.emit_with_context(
172            Some(ClientEvent::MessageStatusChanged(
173                xmtp_events::MessageStatusChanged {
174                    group_id: self.group_id.to_vec(),
175                    message_id,
176                    previous,
177                    current,
178                },
179            )),
180            None,
181            EventContext {
182                dm_identifier: self.dm_id.as_ref().map(|id| id.as_bytes().to_vec()),
183                references_own_messages: false,
184                ..Default::default()
185            },
186        );
187    }
188
189    /// Queue the public changes of one applied commit in kind-table order.
190    fn emit_commit_events(
191        &self,
192        commit: &ValidatedCommit,
193        group_active: bool,
194        storage: &impl XmtpMlsStorageProvider,
195        writer: &impl EventWriter<crate::subscriptions::internal::InternalEvent>,
196    ) -> Result<(), GroupMessageProcessingError> {
197        if self.conversation_type.is_virtual() {
198            return Ok(());
199        }
200        let context = EventContext {
201            dm_identifier: self.dm_id.as_ref().map(|id| id.as_bytes().to_vec()),
202            references_own_messages: false,
203            ..Default::default()
204        };
205        if !group_active {
206            let left = storage
207                .db()
208                .get_pending_remove_users(&self.group_id)?
209                .contains(&self.context.inbox_id().to_string());
210            writer.emit_with_context(
211                Some(ClientEvent::ConversationRemoved(
212                    xmtp_events::ConversationRemoved {
213                        group_id: self.group_id.to_vec(),
214                        cause: if left {
215                            xmtp_events::RemovalCause::Left
216                        } else {
217                            xmtp_events::RemovalCause::Removed
218                        },
219                    },
220                )),
221                None,
222                context.clone(),
223            );
224        }
225        if !commit.added_inboxes.is_empty() || !commit.removed_inboxes.is_empty() {
226            writer.emit_with_context(
227                Some(ClientEvent::ConversationMembershipChanged(
228                    xmtp_events::MembershipChanged {
229                        group_id: self.group_id.to_vec(),
230                        added_inbox_ids: commit
231                            .added_inboxes
232                            .iter()
233                            .map(|inbox| inbox.inbox_id.clone())
234                            .collect(),
235                        removed_inbox_ids: commit
236                            .removed_inboxes
237                            .iter()
238                            .map(|inbox| inbox.inbox_id.clone())
239                            .collect(),
240                    },
241                )),
242                None,
243                context.clone(),
244            );
245        }
246        if let Some(event) = self.metadata_changed_event(commit) {
247            writer.emit_with_context(Some(event), None, context);
248        }
249        Ok(())
250    }
251}
252
253#[derive(Debug, Error)]
254pub enum GroupMessageProcessingError {
255    #[error("intent already processed")]
256    IntentAlreadyProcessed,
257    #[error("message with cursor [{}] for group [{}] already processed", _0.cursor, xmtp_common::fmt::debug_hex(_0.group_id)
258    )]
259    MessageAlreadyProcessed(Box<MessageIdentifier>),
260    #[error("message identifier not found")]
261    MessageIdentifierNotFound,
262    #[error("welcome with cursor [{0}] already processed")]
263    WelcomeAlreadyProcessed(u64),
264    #[error("[{message_time_ns:?}] invalid sender with credential: {credential:?}")]
265    InvalidSender {
266        message_time_ns: u64,
267        credential: Vec<u8>,
268    },
269    #[error("invalid payload")]
270    InvalidPayload,
271    /// The received prefix changed before this attempt acquired the writer.
272    #[error("incoming group head changed")]
273    IncomingHeadChanged,
274    /// The local pending record cannot be decoded. Processing must stop.
275    #[error("stored incoming envelope is corrupt: {0}")]
276    CorruptIncomingEnvelope(prost::DecodeError),
277    /// The MLS wire version is not supported. Keep this envelope pending.
278    #[error("unsupported MLS wire version")]
279    UnsupportedMlsVersion,
280    /// A commit names a proposal the ordered prefix delivered, but the local
281    /// proposal store no longer holds it. This is lost local state, not
282    /// evidence that the commit is invalid, so the head is held.
283    #[error("a committed proposal was received but is missing from local state")]
284    LostProposalState,
285    /// An application sender is beyond the fixed forward window. Not retryable.
286    #[error("application sender generation exceeds the forward window")]
287    FarForwardApplicationMessage,
288    /// An application sender names a leaf outside the ratchet tree. Not retryable.
289    #[error("application sender is outside the ratchet tree")]
290    ApplicationSenderOutsideTree,
291    /// Supported envelope framing fails validation after its complete prefix.
292    #[error(transparent)]
293    Envelope(xmtp_api_backend::envelope::EnvelopeError),
294    /// Own ciphertext has no durable prepared attempt.
295    #[error("own envelope has no prepared attempt")]
296    OwnMessageWithoutAttempt,
297    /// Our own echo names an intent whose kind a newer build wrote and this
298    /// one cannot decode.
299    ///
300    /// This holds the head rather than rejecting it. The envelope may be a
301    /// commit every other member applied; skipping it would leave this
302    /// installation behind the group with no way back. It is also not an
303    /// external message — treating it as one would validate a commit we
304    /// authored against external-actor rules. Not retryable, so the head is
305    /// marked blocked and reconsidered once a build that can read the kind
306    /// runs again.
307    #[error("own intent kind for payload {0} is not supported by this version")]
308    UnsupportedOwnIntentKind(String),
309    /// A local prepared attempt cannot safely explain this own envelope.
310    #[error("prepared attempt state: {0}")]
311    PreparedAttempt(#[source] Box<GroupError>),
312    /// A stored terminal rejection whose original parameters were not retained.
313    #[error("intent rejected: {0}")]
314    RejectedIntent(&'static str),
315    #[error("storage error: {0}")]
316    Storage(#[from] xmtp_db::StorageError),
317    #[error(transparent)]
318    Identity(#[from] IdentityError),
319    #[error("openmls process message error: {0}")]
320    OpenMlsProcessMessage(
321        #[from] openmls::prelude::ProcessMessageError<sql_key_store::SqlKeyStoreError>,
322    ),
323    /// AppDataUpdate-aware processing wrapper error.
324    ///
325    /// Wraps the same `ProcessMessageError` as the variant above, plus the
326    /// `ComponentSourceError` that fires when an incoming `AppDataUpdate`
327    /// payload can't be decoded under our wire format. Kept distinct from
328    /// `OpenMlsProcessMessage` so the AppData-decode failure mode is
329    /// greppable in logs.
330    #[error("app-data process message error: {0}")]
331    OpenMlsProcessMessageWithAppData(
332        #[from] super::app_data::ProcessMessageWithAppDataError<sql_key_store::SqlKeyStoreError>,
333    ),
334    #[error("merge staged commit: {0}")]
335    MergeStagedCommit(#[from] openmls::group::MergeCommitError<sql_key_store::SqlKeyStoreError>),
336    #[error("TLS Codec error: {0}")]
337    TlsError(#[from] TlsCodecError),
338    #[error("unsupported message type: {0:?}")]
339    UnsupportedMessageType(Discriminant<ProtocolMessage>),
340    /// Processed-message content that cannot legitimately reach the apply
341    /// phase: `UnresolvedAppDataCommit` is always resolved inside
342    /// `process_message_with_app_data`, and `OwnPendingCommit` is only
343    /// produced for public-framed commits, which libxmtp's
344    /// pure-ciphertext wire format policy never emits.
345    #[error("unexpected processed message content: {0}")]
346    UnexpectedProcessedContent(&'static str),
347    #[error("commit validation: {0}")]
348    CommitValidation(#[from] CommitValidationError),
349    #[error("epoch increment not allowed")]
350    EpochIncrementNotAllowed,
351    #[error("clear pending commit error: {0}")]
352    ClearPendingCommit(#[from] sql_key_store::SqlKeyStoreError),
353    #[error("Serialization/Deserialization Error {0}")]
354    Serde(#[from] serde_json::Error),
355    #[error("intent is missing staged_commit field")]
356    IntentMissingStagedCommit,
357    #[error("encode proto: {0}")]
358    EncodeProto(#[from] prost::EncodeError),
359    #[error("proto decode error: {0}")]
360    DecodeProto(#[from] prost::DecodeError),
361    #[error(transparent)]
362    Intent(#[from] IntentError),
363    #[error(transparent)]
364    Codec(#[from] CodecError),
365    #[error("wrong credential type")]
366    WrongCredentialType(#[from] BasicCredentialError),
367    #[error(transparent)]
368    ProcessIntent(#[from] ProcessIntentError),
369    #[error(transparent)]
370    AssociationDeserialization(#[from] xmtp_id::associations::DeserializationError),
371    #[error(transparent)]
372    IdentityUpdate(#[from] IdentityUpdateError),
373    #[error("Group paused due to minimum protocol version requirement")]
374    GroupPaused,
375    /// Processing stops at the removal commit until a valid rejoin Welcome.
376    #[error("group is inactive")]
377    GroupInactive,
378    #[error("Message epoch [{0}] is too old [{1}]")]
379    OldEpoch(u64, u64),
380    #[error("Message epoch [{0}] is greater than group epoch [{1}]")]
381    FutureEpoch(u64, u64),
382    #[error(transparent)]
383    Db(#[from] xmtp_db::ConnectionError),
384    #[error(transparent)]
385    Builder(#[from] derive_builder::UninitializedFieldError),
386    #[error(transparent)]
387    Diesel(#[from] xmtp_db::diesel::result::Error),
388    #[error(transparent)]
389    EnrichMessage(#[from] EnrichMessageError),
390    #[error("pre-commit proposal phase complete, re-queuing intent")]
391    PreCommitProposalPhaseComplete,
392    #[error(transparent)]
393    Conversion(#[from] xmtp_proto::ConversionError),
394    /// A successful staged-commit merge by a member that remains in the group
395    /// must advance the epoch and therefore change the epoch authenticator.
396    /// If it did not, the group state the commit was merged onto was
397    /// corrupt/torn (e.g. produced by a cross-process race on a shared MLS
398    /// DB). Retryable: the enclosing transaction (cursor + merge + commit
399    /// log) rolls back and the message is reprocessed against settled state.
400    #[error(
401        "staged commit merge for group [{group_id}] at sequence [{commit_sequence_id}] reached \
402         epoch [{epoch}] without advancing the epoch authenticator; refusing to record corrupt \
403         commit log entry"
404    )]
405    EpochAuthenticatorNotAdvanced {
406        group_id: GroupId,
407        commit_sequence_id: i64,
408        epoch: u64,
409    },
410}
411
412impl From<xmtp_mls_validation::commit::CommitRuleError> for GroupMessageProcessingError {
413    fn from(error: xmtp_mls_validation::commit::CommitRuleError) -> Self {
414        Self::CommitValidation(error.into())
415    }
416}
417
418impl RetryableError for GroupMessageProcessingError {
419    fn is_retryable(&self) -> bool {
420        match self {
421            Self::IncomingHeadChanged => true,
422            Self::PreparedAttempt(error) => error.is_retryable(),
423            Self::CorruptIncomingEnvelope(_)
424            | Self::UnsupportedMlsVersion
425            | Self::LostProposalState
426            | Self::FarForwardApplicationMessage
427            | Self::ApplicationSenderOutsideTree
428            | Self::Envelope(_)
429            | Self::OwnMessageWithoutAttempt
430            | Self::UnsupportedOwnIntentKind(_) => false,
431            Self::RejectedIntent(_) => false,
432            Self::Storage(err) => err.is_retryable(),
433            Self::Diesel(err) => err.is_retryable(),
434            Self::Identity(err) => err.is_retryable(),
435            Self::OpenMlsProcessMessage(err) => err.is_retryable(),
436            Self::OpenMlsProcessMessageWithAppData(err) => match err {
437                super::app_data::ProcessMessageWithAppDataError::OpenMls(e) => e.is_retryable(),
438                // Decode failures are wire-format violations from the
439                // peer — retrying won't help.
440                super::app_data::ProcessMessageWithAppDataError::AppDataDecode(_) => false,
441                // Resolved by upgrading, not by retrying. In practice
442                // this variant never reaches here: the call sites remap
443                // it to `CommitValidation(ProtocolVersionTooLow)` so
444                // the pause machinery in `post_process_message` fires.
445                super::app_data::ProcessMessageWithAppDataError::ProtocolVersionTooLow {
446                    ..
447                } => false,
448                // Staging failures are validation-shaped — the same
449                // `StageCommitError` on a commit without app-data
450                // proposals surfaces through the `OpenMls` arm as a
451                // non-retryable `InvalidCommit`.
452                super::app_data::ProcessMessageWithAppDataError::ResolveAppDataCommit(_) => false,
453            },
454            Self::MergeStagedCommit(err) => err.is_retryable(),
455            Self::ProcessIntent(err) => err.is_retryable(),
456            Self::CommitValidation(err) => err.is_retryable(),
457            Self::ClearPendingCommit(err) => err.is_retryable(),
458            Self::IdentityUpdate(err) => err.is_retryable(),
459            Self::Db(e) => e.is_retryable(),
460            Self::EnrichMessage(e) => e.is_retryable(),
461            Self::IntentAlreadyProcessed
462            | Self::MessageIdentifierNotFound
463            | Self::WrongCredentialType(_)
464            | Self::Codec(_)
465            | Self::MessageAlreadyProcessed(_)
466            | Self::WelcomeAlreadyProcessed(_)
467            | Self::InvalidSender { .. }
468            | Self::DecodeProto(_)
469            | Self::InvalidPayload
470            | Self::Intent(_)
471            | Self::UnexpectedProcessedContent(_)
472            | Self::EpochIncrementNotAllowed
473            | Self::EncodeProto(_)
474            | Self::IntentMissingStagedCommit
475            | Self::Serde(_)
476            | Self::AssociationDeserialization(_)
477            | Self::TlsError(_)
478            | Self::UnsupportedMessageType(_)
479            | Self::GroupPaused
480            | Self::GroupInactive
481            | Self::FutureEpoch(_, _)
482            | Self::OldEpoch(_, _)
483            | Self::PreCommitProposalPhaseComplete => false,
484            Self::Builder(_) => false,
485            Self::Conversion(_) => false,
486            // Retry so the enclosing transaction rolls back (including the
487            // cursor advance) and the message converges via cursor dedup
488            // instead of persisting a forked commit log entry.
489            Self::EpochAuthenticatorNotAdvanced { .. } => true,
490        }
491    }
492
493    fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
494        match self {
495            Self::PreparedAttempt(error) => error.rate_limit_delay(),
496            Self::Identity(err) => err.rate_limit_delay(),
497            Self::IdentityUpdate(err) => err.rate_limit_delay(),
498            Self::CommitValidation(err) => err.rate_limit_delay(),
499            _ => None,
500        }
501    }
502}
503
504impl crate::worker::NeedsDbReconnect for GroupMessageProcessingError {
505    #[deny(clippy::wildcard_enum_match_arm)]
506    fn needs_db_reconnect(&self) -> bool {
507        match self {
508            Self::Storage(error) => error.db_needs_connection(),
509            Self::Db(error) => error.db_needs_connection(),
510            Self::IdentityUpdate(error) => error.needs_db_reconnect(),
511            Self::Identity(error) => error.needs_db_reconnect(),
512            Self::PreparedAttempt(error) => error.needs_db_reconnect(),
513            Self::CommitValidation(error) => error.needs_db_reconnect(),
514            Self::IntentAlreadyProcessed
515            | Self::MessageAlreadyProcessed(..)
516            | Self::MessageIdentifierNotFound
517            | Self::WelcomeAlreadyProcessed(..)
518            | Self::InvalidSender { .. }
519            | Self::InvalidPayload
520            | Self::IncomingHeadChanged
521            | Self::CorruptIncomingEnvelope(..)
522            | Self::UnsupportedMlsVersion
523            | Self::LostProposalState
524            | Self::FarForwardApplicationMessage
525            | Self::ApplicationSenderOutsideTree
526            | Self::Envelope(..)
527            | Self::OwnMessageWithoutAttempt
528            | Self::UnsupportedOwnIntentKind(..)
529            | Self::RejectedIntent(..)
530            | Self::OpenMlsProcessMessage(..)
531            | Self::OpenMlsProcessMessageWithAppData(..)
532            | Self::MergeStagedCommit(..)
533            | Self::TlsError(..)
534            | Self::UnsupportedMessageType(..)
535            | Self::UnexpectedProcessedContent(..)
536            | Self::EpochIncrementNotAllowed
537            | Self::ClearPendingCommit(..)
538            | Self::Serde(..)
539            | Self::IntentMissingStagedCommit
540            | Self::EncodeProto(..)
541            | Self::DecodeProto(..)
542            | Self::Intent(..)
543            | Self::Codec(..)
544            | Self::WrongCredentialType(..)
545            | Self::ProcessIntent(..)
546            | Self::AssociationDeserialization(..)
547            | Self::GroupPaused
548            | Self::GroupInactive
549            | Self::OldEpoch(..)
550            | Self::FutureEpoch(..)
551            | Self::Builder(..)
552            | Self::Diesel(..)
553            | Self::EnrichMessage(..)
554            | Self::PreCommitProposalPhaseComplete
555            | Self::Conversion(..)
556            | Self::EpochAuthenticatorNotAdvanced { .. } => false,
557        }
558    }
559}
560
561impl GroupMessageProcessingError {
562    /// Route an app-data processing failure into this error type.
563    ///
564    /// The pre-dispatch floor guard in `process_message_with_app_data`
565    /// surfaces as the same `ProtocolVersionTooLow` commit-validation
566    /// error the validator's post-policy floor check emits, so
567    /// `post_process_message` pauses the group (held cursor,
568    /// `set_group_paused`) instead of treating it like a wire-format
569    /// rejection that advances past the commit. Every other variant
570    /// wraps as `OpenMlsProcessMessageWithAppData`, same as `From`.
571    /// Use this instead of `?`'s implicit conversion at call sites
572    /// that can see commits from newer protocol versions.
573    fn from_app_data_processing(
574        err: super::app_data::ProcessMessageWithAppDataError<sql_key_store::SqlKeyStoreError>,
575    ) -> Self {
576        match err {
577            super::app_data::ProcessMessageWithAppDataError::ProtocolVersionTooLow {
578                min_version,
579                ..
580            } => Self::CommitValidation(CommitValidationError::Rule(
581                CommitRuleError::ProtocolVersionTooLow(min_version),
582            )),
583            other => other.into(),
584        }
585    }
586
587    pub(crate) fn commit_result(&self) -> CommitResult {
588        use super::app_data::ProcessMessageWithAppDataError;
589        match self {
590            GroupMessageProcessingError::OpenMlsProcessMessage(
591                ProcessMessageError::ValidationError(ValidationError::WrongEpoch),
592            ) => CommitResult::WrongEpoch,
593            // Treat the AppData-aware wrapper the same as the bare
594            // OpenMLS error: if it carries a ValidationError(WrongEpoch),
595            // surface as WrongEpoch; if it carries any other OpenMLS
596            // error, surface as Undecryptable. Decode failures (the
597            // AppData-side variant) are treated as Invalid because they
598            // mean the peer's wire format was wrong.
599            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
600                ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::ValidationError(
601                    ValidationError::WrongEpoch,
602                )),
603            ) => CommitResult::WrongEpoch,
604            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
605                ProcessMessageWithAppDataError::OpenMls(_),
606            ) => CommitResult::Undecryptable,
607            GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
608                ProcessMessageWithAppDataError::AppDataDecode(_),
609            ) => CommitResult::Invalid,
610            GroupMessageProcessingError::OldEpoch(_, _) => CommitResult::WrongEpoch,
611            GroupMessageProcessingError::FutureEpoch(_, _) => CommitResult::WrongEpoch,
612            GroupMessageProcessingError::CommitValidation(_) => CommitResult::Invalid,
613            GroupMessageProcessingError::OpenMlsProcessMessage(_) => CommitResult::Undecryptable,
614            _ => CommitResult::Unknown,
615        }
616    }
617}
618
619#[derive(Debug, Error)]
620pub struct IntentResolutionError {
621    processing_error: GroupMessageProcessingError,
622}
623
624impl std::fmt::Display for IntentResolutionError {
625    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
626        write!(f, "IntentValidationError: {}", self.processing_error)
627    }
628}
629
630impl RetryableError for IntentResolutionError {
631    fn is_retryable(&self) -> bool {
632        self.processing_error.is_retryable()
633    }
634
635    fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
636        self.processing_error.rate_limit_delay()
637    }
638}
639
640#[derive(Debug)]
641pub(crate) struct PublishIntentData {
642    pub(crate) staged_commit: Option<Vec<u8>>,
643    pub(crate) post_commit_action: Option<Vec<u8>>,
644    /// One or more payloads to publish. Most intents have a single payload (commit or message),
645    /// but proposal intents may have multiple payloads (one per proposal).
646    pub(crate) payloads_to_publish: Vec<Vec<u8>>,
647    pub(crate) should_send_push_notification: bool,
648    pub(crate) group_epoch: u64,
649}
650
651#[cfg(any(test, feature = "test-utils"))]
652impl PublishIntentData {
653    #[allow(dead_code)]
654    pub fn post_commit_data(&self) -> Option<Vec<u8>> {
655        self.post_commit_action.clone()
656    }
657
658    #[allow(dead_code)]
659    pub fn staged_commit(&self) -> Option<Vec<u8>> {
660        self.staged_commit.clone()
661    }
662}
663
664/// Post-commit work from one processed durable group head.
665#[derive(Debug)]
666pub(crate) struct ProcessedMessageOutcome {
667    /// Active state from the same transaction that processed the message.
668    /// The controller retires the group after that transaction commits.
669    pub(crate) group_active: bool,
670    /// True when this message stored a disappearing (expiring) message. The
671    /// disappearing worker is re-armed *after* the storage transaction commits
672    /// (see `process_message`), so the worker's `min_expire_at_ns`
673    /// query is guaranteed to observe the newly written `expire_at_ns`.
674    /// Set when processing this message changed the group's `app_data`.
675    /// Carried out of the state writer so the host callback can be
676    /// awaited after commit; `None` whenever no
677    /// callback is registered, since the snapshot is skipped entirely then.
678    pub(crate) app_data_change: Option<AppDataChange>,
679}
680
681impl ProcessedMessageOutcome {
682    /// An outcome with no worker wake or app-data callback.
683    fn new(group_active: bool) -> Self {
684        Self {
685            group_active,
686            app_data_change: None,
687        }
688    }
689}
690
691#[cfg(test)]
692pub(crate) mod tests {
693    use crate::prelude::*;
694
695    use super::*;
696    use crate::{builder::ClientBuilder, utils::TestMlsGroup};
697    use std::sync::Arc;
698    use xmtp_cryptography::utils::generate_local_wallet;
699
700    #[xmtp_common::test(unwrap_try = true)]
701    async fn publish_stores_envelope_metadata_without_sync() {
702        use crate::tester;
703        use xmtp_proto::types::Topic;
704
705        tester!(alix, disable_workers);
706        // Rotate the group key before the message exists.
707        let group = alix.create_group(None, None)?;
708        group.key_update().await?;
709        let id = group.send_message_optimistic(b"publish metadata", Default::default())?;
710        group.publish_intents().await?;
711        let published = alix.context.db().find_group_intents(
712            group.group_id,
713            Some(vec![IntentState::Published]),
714            Some(vec![IntentKind::SendMessage]),
715        )?;
716        assert_eq!(published.len(), 1);
717
718        // Read the local row before any query. Publishing must fill both fields.
719        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
720        assert_eq!(stored.sequence_id, 0);
721        assert!(
722            stored.envelope_hash.is_some(),
723            "publish left envelope_hash NULL"
724        );
725        assert!(stored.expiry_ns.is_some(), "publish left expiry_ns NULL");
726
727        // Read the wire envelope without processing it into the local database.
728        let envelopes = alix
729            .context
730            .api()
731            .query_all(
732                [(Topic::new_group_message(group.group_id), Cursor(0))].into(),
733                alix.context.api().limits().max_query_limit as u32,
734            )
735            .await?;
736        let envelope = envelopes.last().unwrap();
737        let canonical = xmtp_mls_validation::parse_envelope(envelope.envelope.clone().unwrap())?;
738        assert_eq!(
739            stored.envelope_hash,
740            Some(canonical.canonical.hash.to_vec())
741        );
742        assert_eq!(
743            stored.expiry_ns,
744            Some(i64::try_from(envelope.meta.as_ref().unwrap().expiry_ns)?)
745        );
746    }
747
748    #[xmtp_common::test(unwrap_try = true)]
749    async fn partial_envelope_metadata_preserves_stored_fields() {
750        use crate::tester;
751        use xmtp_proto::types::Topic;
752
753        tester!(alix, disable_workers);
754        let group = alix.create_group(None, None)?;
755        let id = group
756            .send_message(b"partial metadata", Default::default())
757            .await?;
758        let original: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
759        assert!(original.envelope_hash.is_some());
760        assert!(original.expiry_ns.is_some());
761        let mut envelopes = alix
762            .context
763            .api()
764            .query_all(
765                [(Topic::new_group_message(group.group_id), Cursor(0))].into(),
766                alix.context.api().limits().max_query_limit as u32,
767            )
768            .await?;
769        let mut envelope =
770            xmtp_api_backend::envelope::decode_group_message(envelopes.pop().unwrap())?;
771        envelope.envelope_hash = None;
772        TestMlsGroup::save_envelope_metadata_with_db(&alix.context.db(), &envelope)?;
773        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
774        assert_eq!(stored.envelope_hash, original.envelope_hash);
775        assert_eq!(stored.expiry_ns, original.expiry_ns);
776
777        envelope.envelope_hash = original.envelope_hash.clone();
778        envelope.expiry_ns = None;
779        TestMlsGroup::save_envelope_metadata_with_db(&alix.context.db(), &envelope)?;
780        let stored: StoredGroupMessage = alix.context.db().fetch(&id)?.unwrap();
781        assert_eq!(stored.envelope_hash, original.envelope_hash);
782        assert_eq!(stored.expiry_ns, original.expiry_ns);
783    }
784
785    /// This test is not reproducible in webassembly, b/c webassembly has only one thread.
786    #[cfg_attr(
787        not(target_arch = "wasm32"),
788        tokio::test(flavor = "multi_thread", worker_threads = 10)
789    )]
790    #[cfg(not(target_family = "wasm"))]
791    async fn publish_intents_worst_case_scenario() {
792        use crate::tester;
793
794        tester!(amal_a, triggers);
795        let amal_group_a: Arc<MlsGroup<_>> =
796            Arc::new(amal_a.create_group(None, Default::default()).unwrap());
797
798        let db = amal_a.context.db();
799
800        // create group intent
801        amal_group_a.sync().await.unwrap();
802        assert_eq!(db.intents_processed(), 1);
803
804        for _ in 0..100 {
805            use crate::groups::send_message_opts::SendMessageOpts;
806
807            let s = xmtp_common::rand_string::<100>();
808            amal_group_a
809                .send_message_optimistic(s.as_bytes(), SendMessageOpts::default())
810                .unwrap();
811        }
812
813        let mut set = tokio::task::JoinSet::new();
814        for _ in 0..50 {
815            let g = amal_group_a.clone();
816            set.spawn(async move { g.publish_intents().await });
817        }
818
819        let res = set.join_all().await;
820        let errs: Vec<&Result<_, _>> = res.iter().filter(|r| r.is_err()).collect();
821        errs.iter().for_each(|e| {
822            tracing::error!("{}", e.as_ref().unwrap_err());
823        });
824
825        let published = db.intents_published();
826        assert_eq!(published, 101);
827        let created = db.intents_created();
828        assert_eq!(created, 101);
829        if !errs.is_empty() {
830            panic!("Errors during publish");
831        }
832    }
833
834    #[xmtp_common::test]
835    async fn hmac_keys_work_as_expected() {
836        let wallet = generate_local_wallet();
837        let amal = Arc::new(ClientBuilder::new_test_client(&wallet).await);
838        let amal_group: Arc<TestMlsGroup> =
839            Arc::new(amal.create_group(None, Default::default()).unwrap());
840
841        let hmac_keys = amal_group.hmac_keys(-1..=1).unwrap();
842        let current_hmac_key = amal_group.hmac_keys(0..=0).unwrap().pop().unwrap();
843        assert_eq!(hmac_keys.len(), 3);
844        assert_eq!(hmac_keys[1].key, current_hmac_key.key);
845        assert_eq!(hmac_keys[1].epoch, current_hmac_key.epoch);
846
847        // Make sure the keys are different
848        assert_ne!(hmac_keys[0].key, hmac_keys[1].key);
849        assert_ne!(hmac_keys[0].key, hmac_keys[2].key);
850        assert_ne!(hmac_keys[1].key, hmac_keys[2].key);
851
852        // Make sure the epochs align
853        let current_epoch = hmac_epoch();
854        assert_eq!(hmac_keys[0].epoch, current_epoch - 1);
855        assert_eq!(hmac_keys[1].epoch, current_epoch);
856        assert_eq!(hmac_keys[2].epoch, current_epoch + 1);
857    }
858
859    /// A fixed root key, group id and epoch derive the same key and sender
860    /// HMAC as before the derivation buffers were made zeroizing. The bytes
861    /// were recorded from the code at `50b83f6316` and match an independent
862    /// RFC 5869 HKDF-SHA256 and RFC 2104 HMAC-SHA256 computation.
863    // verifies: PUSH-258
864    #[xmtp_common::test(unwrap_try = true)]
865    async fn hmac_derivation_matches_known_answer() {
866        use crate::tester;
867        use xmtp_db::group::ConversationType;
868
869        const ROOT_KEY: &str =
870            "0b30557a9fc4e90e33587da2c7ec11365b80a5caef14395e83a8cdf2173c6186abd0f51a3f6489aed3f8";
871        const GROUP_ID: &str = "a0a1a2a3a4a5a6a7a8a9aaabacadaeaf";
872        const EPOCH: i64 = 678;
873        const PAYLOAD: &[u8] = b"PUSH-258 known answer";
874        const DERIVED_KEY: &str =
875            "6468ea9126760f346449c020e4bd094ddea2103e6aafe3e5d66cc862e92b32799965df39f5fefaab55b7";
876        const SENDER_HMAC: &str =
877            "673228bd6e2b8ee58e74e708f4cfebdec70658703cf5a9a782f2acac9354ebf2";
878
879        tester!(alix, disable_workers);
880        let db = alix.context.db();
881        xmtp_db::user_preferences::store_test_root_key(
882            &db,
883            &hex::decode(ROOT_KEY)?,
884            xmtp_common::time::now_ns() + xmtp_common::NS_IN_DAY,
885        )?;
886        let group = MlsGroup::new(
887            alix.context.clone(),
888            GroupId::try_from(hex::decode(GROUP_ID)?.as_slice())?,
889            None,
890            ConversationType::Group,
891            0,
892        );
893
894        // The epoch comes from the clock, so ask for the fixed one by its delta.
895        let delta = EPOCH - hmac_epoch();
896        let keys = group.hmac_keys_in(&db, alix.context.events(), delta..=delta)?;
897        assert_eq!(keys.len(), 1);
898        assert_eq!(keys[0].epoch, EPOCH);
899        assert_eq!(hex::encode(keys[0].key), DERIVED_KEY);
900
901        // Any group keys: the sender HMAC does not depend on the signature.
902        let group_keys = xmtp_mls_group_keys::group_keys::GroupKeys::derive(
903            &xmtp_mls_group_keys::group_keys::GroupRoot::from_bytes(&[7; 32])?,
904        );
905        let envelopes =
906            post_commit::group_message_envelopes(&keys[0], &group_keys, vec![(PAYLOAD, true)])?;
907        let [
908            ClientEnvelope {
909                payload: Some(Payload::GroupMessage(message)),
910            },
911        ] = envelopes.as_slice()
912        else {
913            panic!("expected one group message envelope, got {envelopes:?}");
914        };
915        assert_eq!(message.data, PAYLOAD);
916        assert_eq!(hex::encode(&message.sender_hmac), SENDER_HMAC);
917    }
918
919    /// A DeleteMessage that cannot be decoded, or names no message id, is
920    /// skipped without failing sync and stores no deletion.
921    #[xmtp_common::test(unwrap_try = true)]
922    async fn malformed_delete_messages_store_no_deletion() {
923        use crate::tester;
924        use xmtp_content_types::{
925            ContentCodec, compression::compress, delete_message::DeleteMessageCodec,
926            encoded_content_to_bytes,
927        };
928        use xmtp_db::group_message::{
929            ContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage,
930        };
931        use xmtp_db::message_deletion::QueryMessageDeletion;
932        use xmtp_proto::xmtp::mls::message_contents::Compression;
933        use xmtp_proto::xmtp::mls::message_contents::content_types::DeleteMessage;
934
935        tester!(alix);
936        let alix_group = alix.create_group(None, None)?;
937        let delete_of = |message_id: &str| {
938            DeleteMessageCodec::encode(DeleteMessage {
939                message_id: message_id.into(),
940            })
941        };
942        let mut malformed_inner = delete_of("")?;
943        malformed_inner.content = vec![0xFF, 0xFE, 0xFD];
944        let oversized = compress(delete_of(&"ab".repeat(512 * 1024))?, Compression::Deflate)?;
945
946        let rows = [
947            ("malformed EncodedContent", vec![0xFF, 0xFE, 0xFD]),
948            (
949                "malformed DeleteMessage",
950                encoded_content_to_bytes(malformed_inner),
951            ),
952            (
953                "non-hex id",
954                encoded_content_to_bytes(delete_of("not_valid_hex!!!")?),
955            ),
956            ("2-byte id", encoded_content_to_bytes(delete_of("0102")?)),
957            (
958                "33-byte id",
959                encoded_content_to_bytes(delete_of(&"ab".repeat(33))?),
960            ),
961            (
962                "compressed oversized id",
963                encoded_content_to_bytes(oversized),
964            ),
965        ];
966        let storage = alix.context.mls_storage();
967        for (n, (case, decrypted_message_bytes)) in (1..).zip(rows) {
968            // The receive path stores the reference it decodes from the bytes.
969            let fields = TestMlsGroup::extract_queryable_content_fields(&decrypted_message_bytes);
970            let message = StoredGroupMessage {
971                id: vec![n; 32],
972                group_id: alix_group.group_id,
973                decrypted_message_bytes,
974                sent_at_ns: xmtp_common::time::now_ns(),
975                kind: GroupMessageKind::Application,
976                sender_installation_id: vec![1, 2, 3],
977                sender_inbox_id: alix.inbox_id().to_string(),
978                delivery_status: DeliveryStatus::Published,
979                content_type: ContentType::DeleteMessage,
980                version_major: 1,
981                version_minor: 0,
982                authority_id: "xmtp.org".to_string(),
983                reference_id: fields.reference_id,
984                expiry_unknown: false,
985                deleted_by_inbox_id: None,
986                expire_at_ns: None,
987                sequence_id: n.into(),
988                envelope_hash: None,
989                expiry_ns: None,
990                inserted_at_ns: 0,
991                should_push: false,
992                idempotency_key: String::new(),
993            };
994            let result = crate::state_tx::state_write_with_events(
995                storage,
996                alix.context.events(),
997                |tx, buffer| {
998                    let storage = tx.storage();
999                    alix_group
1000                        .store_external_application_message(&storage, &message, true, buffer)?;
1001                    Ok::<_, GroupMessageProcessingError>(xmtp_db::TransactionOutcome::Continue(()))
1002                },
1003            );
1004            assert!(result.is_ok(), "{case}: {result:?}");
1005            let deletion = alix.context.db().get_message_deletion(&message.id)?;
1006            assert!(deletion.is_none(), "{case}: {deletion:?}");
1007        }
1008    }
1009
1010    /// Pin the `CommitResult` mapping for each arm of the AppData-aware
1011    /// wrapper error so future refactors of `commit_result()` can't
1012    /// silently reshuffle what a receiver will write to the remote
1013    /// commit log. In particular: `AppDataDecode` failures must be
1014    /// `Invalid` (non-retriable wire-format violation), not
1015    /// `Undecryptable` (retriable transport failure).
1016    #[test]
1017    fn process_message_with_app_data_error_commit_result_mapping() {
1018        use super::super::app_data::ProcessMessageWithAppDataError;
1019        use openmls::group::ValidationError;
1020        use xmtp_mls_group_state::app_data::component_source::ComponentSourceError;
1021
1022        let wrong_epoch = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1023            ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::ValidationError(
1024                ValidationError::WrongEpoch,
1025            )),
1026        );
1027        assert_eq!(wrong_epoch.commit_result(), CommitResult::WrongEpoch);
1028
1029        let other_openmls = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1030            ProcessMessageWithAppDataError::OpenMls(ProcessMessageError::IncompatibleWireFormat),
1031        );
1032        assert_eq!(other_openmls.commit_result(), CommitResult::Undecryptable);
1033
1034        // AppData decode failures are deterministic wire-format violations:
1035        // retrying the same bytes can't fix them, and the receiver should
1036        // log them as `Invalid` rather than `Undecryptable`.
1037        let decode_failure = GroupMessageProcessingError::OpenMlsProcessMessageWithAppData(
1038            ProcessMessageWithAppDataError::AppDataDecode(ComponentSourceError::UnknownComponent(
1039                xmtp_mls_group_state::app_data::component_id::ComponentId::from(0u16),
1040            )),
1041        );
1042        assert_eq!(decode_failure.commit_result(), CommitResult::Invalid);
1043        assert!(
1044            !decode_failure.is_retryable(),
1045            "wire-format violations must not be retriable"
1046        );
1047    }
1048}