1use crate::core::sync_tasks::schedule_add_missing_installations;
2use crate::state_tx::StateTx;
3use crate::{
4 context::XmtpSharedContext,
5 groups::{
6 GroupError, MlsGroup, PreconfiguredPolicies, send_message_opts, summary::SyncSummary,
7 },
8 identity::parse_credential,
9 identity_updates::{IdentityUpdateError, IdentityUpdates},
10 mls_store::MlsStoreError,
11 worker::{NeedsDbReconnect, metrics::WorkerMetrics},
12};
13use openmls::prelude::{BasicCredential, Member};
14use prost::Message;
15use std::sync::Arc;
16use thiserror::Error;
17use tokio::sync::broadcast::error::RecvError;
18use tracing::instrument;
19use worker::SyncMetric;
20use xmtp_archive::ArchiveError;
21use xmtp_common::ErrorCode;
22use xmtp_common::RetryableError;
23use xmtp_configuration::{
24 INSTALLATION_RETRY_MIN_PROTOCOL_VERSION, SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS,
25};
26use xmtp_cryptography::secret::{Zeroize, Zeroizing};
27use xmtp_db::TransactionalKeyStore;
28use xmtp_db::{
29 NotFound, StorageError,
30 group_message::{DeliveryStatus, StoredGroupMessage},
31 sync_outbox::{OwedPage, clear_owed, owed_page},
32};
33use xmtp_db::{XmtpDb, group::ConversationType, prelude::*};
34use xmtp_id::{InboxIdRef, associations::DeserializationError};
35use xmtp_mls_group_state::app_data::protocol_floor::committed_floor_at_least;
36use xmtp_mls_group_state::group::GroupMetadataOptions;
37use xmtp_mls_group_state::libxmtp_version::LibXMTPVersion;
38use xmtp_proto::ShortHex;
39use xmtp_proto::types::{GroupId, InstallationId};
40use xmtp_proto::xmtp::{
41 device_sync::content::{
42 DeviceSyncContent as DeviceSyncContentProto, HmacKeyUpdate,
43 PreferenceUpdate as PreferenceUpdateMessage, PreferenceUpdates,
44 device_sync_content::Content as ContentProto,
45 preference_update::Update as PreferenceUpdateProto,
46 },
47 mls::message_contents::{ContentTypeId, EncodedContent},
48};
49
50pub mod archive;
51pub mod preference_sync;
52pub mod worker;
53
54pub use xmtp_archive::archive_options::{ArchiveOptions, BackupElementSelection};
55
56#[cfg(test)]
57mod tests;
58
59fn sync_message_bytes(content: ContentProto) -> Zeroizing<Vec<u8>> {
65 let mut content = DeviceSyncContentProto {
66 content: Some(content),
67 };
68 let inner = content.encode_to_vec();
70 if let Some(content) = &mut content.content {
71 wipe_hmac_keys(content);
72 }
73 let mut encoded = EncodedContent {
74 r#type: Some(ContentTypeId {
75 authority_id: "xmtp.org".to_string(),
76 type_id: "application/x-protobuf".to_string(),
77 version_major: 1,
78 version_minor: 0,
79 }),
80 parameters: Default::default(),
81 fallback: None,
82 compression: None,
83 content: inner,
84 };
85 let bytes = Zeroizing::new(encoded.encode_to_vec());
86 encoded.content.zeroize();
87 bytes
88}
89
90fn wipe_hmac_keys(content: &mut ContentProto) {
92 if let ContentProto::PreferenceUpdates(updates) = content {
93 for update in &mut updates.updates {
94 if let Some(PreferenceUpdateProto::Hmac(hmac)) = &mut update.update {
95 hmac.key.zeroize();
96 }
97 }
98 }
99}
100
101fn take_plaintext(msg: &mut StoredGroupMessage) -> Zeroizing<Vec<u8>> {
105 Zeroizing::new(std::mem::take(&mut msg.decrypted_message_bytes))
106}
107
108fn with_plaintext(rows: Vec<StoredGroupMessage>) -> Vec<(StoredGroupMessage, Zeroizing<Vec<u8>>)> {
110 rows.into_iter()
111 .map(|mut msg| {
112 let plaintext = take_plaintext(&mut msg);
113 (msg, plaintext)
114 })
115 .collect()
116}
117
118pub(super) struct ReceivedContent(pub(super) ContentProto);
121
122impl Drop for ReceivedContent {
123 fn drop(&mut self) {
124 wipe_hmac_keys(&mut self.0);
125 }
126}
127
128pub(super) fn selected_sync_group_in<T: TransactionalKeyStore>(
132 tx: &mut StateTx<'_, T>,
133 own_inbox_id: &str,
134) -> Result<Option<GroupId>, GroupError> {
135 let groups = tx.storage().db().all_sync_groups()?;
136 for stored in groups {
137 let own = tx.with_group(stored.id, |group, _| {
138 Ok::<_, GroupError>(is_own_sync_group(
139 own_inbox_id,
140 &stored.added_by_inbox_id,
141 group.members(),
142 ))
143 })?;
144 if own {
145 return Ok(Some(stored.id));
146 }
147 }
148 Ok(None)
149}
150
151pub(crate) fn is_own_sync_group(
155 own_inbox_id: &str,
156 added_by_inbox_id: &str,
157 members: impl IntoIterator<Item = Member>,
158) -> bool {
159 added_by_inbox_id == own_inbox_id
160 && members.into_iter().all(|member| {
161 BasicCredential::try_from(member.credential)
162 .ok()
163 .and_then(|credential| parse_credential(credential.identity()).ok())
164 .is_some_and(|inbox_id| inbox_id == own_inbox_id)
165 })
166}
167
168#[derive(Debug, Error, ErrorCode)]
169pub enum DeviceSyncError {
170 #[error("IO error: {0}")]
174 IO(#[from] std::io::Error),
175 #[error("Serialization/Deserialization Error {0}")]
179 Serde(#[from] serde_json::Error),
180 #[error(transparent)]
181 #[error_code(inherit)]
182 ProtoConversion(#[from] xmtp_proto::ConversionError),
183 #[error("AES-GCM encryption error")]
187 AesGcm(#[from] aes_gcm::Error),
188 #[error("storage error: {0}")]
189 #[error_code(inherit)]
190 Storage(#[from] StorageError),
191 #[error("type conversion error")]
195 Conversion,
196 #[error("utf-8 error: {0}")]
200 UTF8(#[from] std::str::Utf8Error),
201 #[error("identity update error: {0}")]
202 #[error_code(inherit)]
203 IdentityUpdate(#[from] IdentityUpdateError),
204 #[error("group error: {0}")]
205 #[error_code(inherit)]
206 Group(#[from] GroupError),
207 #[error("invalid history message payload")]
211 InvalidPayload,
212 #[error("unspecified device sync kind")]
216 UnspecifiedDeviceSyncKind,
217 #[error(transparent)]
221 Archive(#[from] ArchiveError),
222 #[error(transparent)]
226 Decode(#[from] prost::DecodeError),
227 #[error(transparent)]
228 #[error_code(inherit)]
229 Deserialization(#[from] DeserializationError),
230 #[error("Missing sync group")]
234 MissingSyncGroup,
235 #[error(transparent)]
236 #[error_code(inherit)]
237 Db(#[from] xmtp_db::ConnectionError),
238 #[error("{}", _0.to_string())]
242 Sync(#[source] Box<SyncSummary>),
243 #[error(transparent)]
247 MlsStore(#[from] MlsStoreError),
248 #[error(transparent)]
252 Recv(#[from] RecvError),
253 #[error("Missing Field: {0:?} {1}")]
257 MissingField(MissingField, String),
258 #[error("own identity state unavailable: {0}")]
260 #[error_code(inherit)]
261 OwnStateUnavailable(#[source] IdentityUpdateError),
262 #[error("a queued sync message is not published yet")]
267 OutboxPageUnpublished,
268}
269
270#[derive(Debug)]
271pub enum MissingField {
272 Conversation(ConversationField),
273}
274#[derive(Debug)]
275pub enum ConversationField {
276 DmId,
277}
278
279impl From<SyncSummary> for DeviceSyncError {
280 fn from(value: SyncSummary) -> Self {
281 DeviceSyncError::Sync(Box::new(value))
282 }
283}
284
285impl NeedsDbReconnect for DeviceSyncError {
286 fn restart_delay(&self) -> Option<xmtp_common::time::Duration> {
287 RetryableError::rate_limit_delay(self)
288 }
289
290 #[deny(clippy::wildcard_enum_match_arm)]
291 fn needs_db_reconnect(&self) -> bool {
292 match self {
293 Self::IdentityUpdate(e) => e.needs_db_reconnect(),
294 Self::Storage(s) => s.db_needs_connection(),
295 Self::Db(c) => c.db_needs_connection(),
298 Self::Group(e) => e.needs_db_reconnect(),
299 Self::MlsStore(e) => e.needs_db_reconnect(),
300 Self::OwnStateUnavailable(e) => e.needs_db_reconnect(),
301 Self::Sync(summary) => summary.needs_db_reconnect(),
302 Self::Archive(ArchiveError::Storage(s)) => s.db_needs_connection(),
303 Self::Archive(_)
304 | Self::IO(..)
305 | Self::Serde(..)
306 | Self::ProtoConversion(..)
307 | Self::AesGcm(..)
308 | Self::Conversion
309 | Self::UTF8(..)
310 | Self::InvalidPayload
311 | Self::UnspecifiedDeviceSyncKind
312 | Self::Decode(..)
313 | Self::Deserialization(..)
314 | Self::MissingSyncGroup
315 | Self::Recv(..)
316 | Self::MissingField(..)
317 | Self::OutboxPageUnpublished => false,
318 }
319 }
320}
321
322impl RetryableError for DeviceSyncError {
323 fn is_retryable(&self) -> bool {
324 !matches!(
325 self,
326 Self::MissingSyncGroup | Self::UnspecifiedDeviceSyncKind
327 )
328 }
329
330 fn rate_limit_delay(&self) -> Option<xmtp_common::time::Duration> {
331 match self {
332 Self::IdentityUpdate(e) => e.rate_limit_delay(),
333 Self::Group(e) => e.rate_limit_delay(),
334 Self::Sync(e) => e.rate_limit_delay(),
335 Self::MlsStore(e) => e.rate_limit_delay(),
336 Self::OwnStateUnavailable(e) => e.rate_limit_delay(),
337 _ => None,
338 }
339 }
340}
341
342impl From<NotFound> for DeviceSyncError {
343 fn from(value: NotFound) -> Self {
344 DeviceSyncError::Storage(StorageError::NotFound(value))
345 }
346}
347
348#[derive(Clone)]
349pub struct DeviceSyncClient<Context> {
350 pub(crate) context: Context,
351 pub(crate) metrics: Arc<WorkerMetrics<SyncMetric>>,
352}
353
354impl<Context: XmtpSharedContext> DeviceSyncClient<Context> {
355 pub fn new(context: Context, metrics: Arc<WorkerMetrics<SyncMetric>>) -> Self {
356 Self { context, metrics }
357 }
358}
359
360impl<Context> DeviceSyncClient<Context>
361where
362 Context: XmtpSharedContext,
363{
364 pub fn inbox_id(&self) -> InboxIdRef<'_> {
365 self.context.identity().inbox_id()
366 }
367
368 pub fn installation_id(&self) -> InstallationId {
369 self.context.installation_id()
370 }
371
372 pub fn db(&self) -> <Context::Db as XmtpDb>::DbQuery {
373 self.context.db()
374 }
375
376 pub async fn wait_for_sync_worker_init(&self) -> Result<(), xmtp_common::time::Expired> {
378 self.metrics.wait_for_init().await
379 }
380
381 #[tracing::instrument(level = "debug", skip_all, fields(operation = "publish_outbox"))]
386 pub(super) async fn publish_outbox(&self) -> Result<(), DeviceSyncError> {
387 let max_bytes = (self.context.api().limits().max_envelope_bytes / 4).min(PAGE_BYTES);
389 self.get_sync_group().await?;
390 let mut add_tried = false;
392 loop {
393 let page = owed_page(&self.db(), PAGE_UPDATES, max_bytes)?;
397 if page.is_empty() {
398 return Ok(());
399 }
400 let group = self
401 .primary_sync_group()?
402 .ok_or(DeviceSyncError::MissingSyncGroup)?;
403 let gap = self.own_membership_gap(&group).await?;
405 if gap.missing && !add_tried {
406 add_tried = true;
407 if let Err(e) = group.add_missing_installations().await {
411 if gap.revoked || e.needs_db_reconnect() || e.rate_limit_delay().is_some() {
412 return Err(e.into());
413 }
414 tracing::warn!(error = %e, "adding own installations failed");
415 }
416 } else if gap.revoked {
417 group.remove_revoked_installations().await?;
418 }
419 if gap.missing || gap.revoked {
420 let gap = self.own_membership_gap(&group).await?;
421 if gap.revoked {
422 return Err(GroupError::InvalidGroupMembership.into());
423 }
424 if gap.retriable {
427 let interval = SYNC_UPDATE_INSTALLATIONS_INTERVAL_NS as u64;
428 let delay = xmtp_common::time::Duration::from_nanos(interval);
429 self.schedule_add_missing_installations_task(group.group_id, Some(delay))?;
430 }
431 }
432 group.sync_until_last_intent_resolved().await?;
435 tracing::info!(
436 group_id = group.group_id.short_hex(),
437 "publishing owed preferences"
438 );
439 let id = group.prepare_message(
440 &sync_message_bytes(page_content(&page)),
441 send_message_opts::SendMessageOpts {
442 should_push: false,
443 idempotency_key: None,
444 },
445 )?;
446 group.sync_until_last_intent_resolved().await?;
447 let status = self.db().get_group_message(&id)?.map(|mut sent| {
450 drop(take_plaintext(&mut sent));
451 sent.delivery_status
452 });
453 #[cfg(test)]
454 let status = UNCONFIRMED_NEXT_PAGE
455 .lock()
456 .take_if(|installation| *installation == self.installation_id().as_slice())
457 .map_or(status, |_| None);
458 if status != Some(DeliveryStatus::Published) {
459 return Err(DeviceSyncError::OutboxPageUnpublished);
460 }
461 clear_owed(&self.db(), &page)?;
462 for _ in &page.consent {
463 self.metrics.increment_metric(SyncMetric::ConsentSent);
464 }
465 if page.root_key.is_some() {
466 self.metrics.increment_metric(SyncMetric::HmacSent);
467 }
468 }
469 }
470
471 #[cfg(any(test, feature = "test-utils"))]
473 pub async fn sync_passes_for_test(&self) -> Result<(), DeviceSyncError> {
474 self.publish_outbox().await?;
475 self.process_sync_group_messages().await
476 }
477
478 async fn own_membership_gap(
482 &self,
483 group: &MlsGroup<Context>,
484 ) -> Result<MembershipGap, DeviceSyncError> {
485 let own = match IdentityUpdates::new(&self.context)
488 .get_association_state(&self.db(), self.inbox_id(), None)
489 .await
490 {
491 Ok(state) => state.installation_ids(),
492 Err(e) if e.needs_db_reconnect() => return Err(e.into()),
493 Err(e) => return Err(DeviceSyncError::OwnStateUnavailable(e)),
494 };
495 let me = self.installation_id();
496 Ok(group.with_group_snapshot(|mls_group| {
497 let missing = own.iter().any(|installation| {
498 !mls_group
499 .members()
500 .any(|member| member.signature_key == *installation)
501 });
502 let gate = LibXMTPVersion::parse(INSTALLATION_RETRY_MIN_PROTOCOL_VERSION).ok();
503 Ok(MembershipGap {
504 missing,
505 revoked: mls_group.members().any(|member| {
506 member.signature_key != me.as_slice() && !own.contains(&member.signature_key)
507 }),
508 retriable: missing
509 && gate.is_some_and(|gate| {
510 committed_floor_at_least(mls_group.extensions(), &gate)
511 }),
512 })
513 })?)
514 }
515
516 pub(crate) async fn retry_own_installations(
519 &self,
520 group: &MlsGroup<Context>,
521 ) -> Result<bool, DeviceSyncError> {
522 group.add_missing_installations().await?;
523 if self
524 .primary_sync_group()?
525 .is_none_or(|selected| selected.group_id != group.group_id)
526 {
527 return Ok(false);
528 }
529 Ok(self.own_membership_gap(group).await?.retriable)
530 }
531
532 pub(crate) fn primary_sync_group(&self) -> Result<Option<MlsGroup<Context>>, GroupError> {
536 let own_inbox_id = self.context.inbox_id();
537 for stored in self.context.db().all_sync_groups()? {
538 let group = MlsGroup::load_by_id(&self.context, &stored.id)?;
539 let own = group.with_group_snapshot(|mls_group| {
540 Ok(is_own_sync_group(
541 own_inbox_id,
542 &stored.added_by_inbox_id,
543 mls_group.members(),
544 ))
545 })?;
546 if own {
547 return Ok(Some(group));
548 }
549 tracing::debug!(
550 group_id = %stored.id,
551 "skipping a sync group that names another inbox"
552 );
553 }
554 Ok(None)
555 }
556
557 #[instrument(level = "trace", skip_all)]
559 pub async fn get_sync_group(&self) -> Result<MlsGroup<Context>, GroupError> {
560 let sync_group = match self.primary_sync_group()? {
561 Some(sync_group) => sync_group,
562 None => {
563 let sync_group = MlsGroup::create_and_insert(
564 self.context.clone(),
565 ConversationType::Sync,
566 PreconfiguredPolicies::default().to_policy_set(),
567 GroupMetadataOptions::default(),
568 None,
569 )?;
570 tracing::info!(
571 "Creating sync group: {}",
572 xmtp_common::fmt::debug_hex(sync_group.group_id)
573 );
574 if let Err(inline_err) = sync_group.add_missing_installations().await {
575 self.schedule_add_missing_installations_task(
578 sync_group.group_id,
579 inline_err.rate_limit_delay(),
580 )?;
581 return Err(inline_err);
582 }
583 sync_group.sync_with_conn().await?;
585
586 self.metrics.increment_metric(SyncMetric::SyncGroupCreated);
587
588 sync_group
589 }
590 };
591
592 Ok(sync_group)
593 }
594
595 pub(crate) fn schedule_add_missing_installations_task(
598 &self,
599 group_id: GroupId,
600 delay: Option<xmtp_common::time::Duration>,
601 ) -> Result<(), StorageError> {
602 schedule_add_missing_installations(
603 &self.context.db(),
604 self.context.events(),
605 group_id,
606 delay,
607 true,
608 )
609 }
610
611 #[cfg(any(test, feature = "test-utils"))]
613 pub fn schedule_add_installations_to_groups(&self) -> Result<usize, DeviceSyncError> {
614 Ok(crate::core::sync_tasks::schedule_newcomer_adds(
615 &self.context.db(),
616 self.context.events(),
617 )?)
618 }
619}
620
621struct MembershipGap {
623 missing: bool,
624 revoked: bool,
625 retriable: bool,
627}
628
629#[cfg(test)]
632pub(super) static UNCONFIRMED_NEXT_PAGE: parking_lot::Mutex<Option<Vec<u8>>> =
633 parking_lot::Mutex::new(None);
634
635const PAGE_UPDATES: usize = 1_000;
637const PAGE_BYTES: usize = 256 * 1024;
639
640fn page_content(page: &OwedPage) -> ContentProto {
643 let root_key = page.root_key.iter().map(|root| {
644 PreferenceUpdateProto::Hmac(HmacKeyUpdate {
645 key: root.key.as_slice().to_vec(),
646 cycled_at_ns: root.cycled_at_ns,
647 })
648 });
649 let consent = page
650 .consent
651 .iter()
652 .map(|record| PreferenceUpdateProto::Consent(record.clone().into()));
653 ContentProto::PreferenceUpdates(PreferenceUpdates {
654 updates: root_key
655 .chain(consent)
656 .map(|update| PreferenceUpdateMessage {
657 update: Some(update),
658 })
659 .collect(),
660 })
661}
662
663#[derive(Clone, PartialEq, prost::Message)]
667struct SyncPayload {
668 #[prost(bytes = "vec", tag = "4")]
669 content: Vec<u8>,
670}
671
672impl Drop for SyncPayload {
673 fn drop(&mut self) {
674 self.content.zeroize();
675 }
676}
677
678pub(super) fn decode_sync_message(plaintext: &[u8]) -> Option<ReceivedContent> {
681 let payload = SyncPayload::decode(decode_buffer(plaintext)).ok()?;
682 decode_supported_content(&payload.content)
683}
684
685fn decode_buffer(bytes: &[u8]) -> bytes::Bytes {
689 bytes::Bytes::from_owner(Zeroizing::new(bytes.to_vec()))
690}
691
692fn decode_supported_content(bytes: &[u8]) -> Option<ReceivedContent> {
701 DeviceSyncContentProto::decode(decode_buffer(bytes))
702 .ok()?
703 .content
704 .map(ReceivedContent)
705}