Skip to main content

xmtp_mls/worker/device_sync/
mod.rs

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
59/// Encode sync content as the `EncodedContent` bytes of a sync message.
60///
61/// Sync content can hold an HMAC root key. Every buffer that holds it here is
62/// wiped: the key fields of `content` after encoding, the inner encoding, and
63/// the returned bytes when the caller drops them.
64fn sync_message_bytes(content: ContentProto) -> Zeroizing<Vec<u8>> {
65    let mut content = DeviceSyncContentProto {
66        content: Some(content),
67    };
68    // `encode_to_vec` sizes its buffer up front, so it never frees a partial copy.
69    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
90/// Wipe the HMAC root key of every preference update in `content`.
91fn 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
101/// Move a stored sync message's plaintext into a buffer wiped on drop. It
102/// can hold an HMAC root key, so it moves as the row loads, before any
103/// path out can drop it.
104fn take_plaintext(msg: &mut StoredGroupMessage) -> Zeroizing<Vec<u8>> {
105    Zeroizing::new(std::mem::take(&mut msg.decrypted_message_bytes))
106}
107
108/// [`take_plaintext`] for every loaded row.
109fn 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
118/// Sync content decoded from a received message. Its HMAC root keys are wiped
119/// on drop, on every path out of processing.
120pub(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
128/// The id of the group [`DeviceSyncClient::primary_sync_group`] selects,
129/// read in the caller's write.
130// implements: SYNC-002, SYNC-010
131pub(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
151/// A sync group belongs to the own inbox only when that inbox added this
152/// client and every leaf node's credential names it.
153// implements: SYNC-010
154pub(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    /// I/O error.
171    ///
172    /// File system or network I/O failed. May be retryable.
173    #[error("IO error: {0}")]
174    IO(#[from] std::io::Error),
175    /// Serialization error.
176    ///
177    /// JSON serialization/deserialization failed. Retryable.
178    #[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    /// AES-GCM encryption error.
184    ///
185    /// Encryption/decryption of sync payload failed. Retryable.
186    #[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    /// Type conversion error.
192    ///
193    /// Internal type conversion failed. Retryable.
194    #[error("type conversion error")]
195    Conversion,
196    /// UTF-8 error.
197    ///
198    /// String is not valid UTF-8. Retryable.
199    #[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    /// Invalid payload.
208    ///
209    /// Sync message payload is malformed. Retryable.
210    #[error("invalid history message payload")]
211    InvalidPayload,
212    /// Unspecified sync kind.
213    ///
214    /// Device sync kind not specified. Not retryable.
215    #[error("unspecified device sync kind")]
216    UnspecifiedDeviceSyncKind,
217    /// Archive error.
218    ///
219    /// Sync archive operation failed. Retryable.
220    #[error(transparent)]
221    Archive(#[from] ArchiveError),
222    /// Decode error.
223    ///
224    /// Protobuf decoding failed. Retryable.
225    #[error(transparent)]
226    Decode(#[from] prost::DecodeError),
227    #[error(transparent)]
228    #[error_code(inherit)]
229    Deserialization(#[from] DeserializationError),
230    /// Missing sync group.
231    ///
232    /// Sync group not found. Not retryable.
233    #[error("Missing sync group")]
234    MissingSyncGroup,
235    #[error(transparent)]
236    #[error_code(inherit)]
237    Db(#[from] xmtp_db::ConnectionError),
238    /// Sync summary.
239    ///
240    /// Sync completed with errors. May be retryable.
241    #[error("{}", _0.to_string())]
242    Sync(#[source] Box<SyncSummary>),
243    /// MLS store error.
244    ///
245    /// OpenMLS key store operation failed. Retryable.
246    #[error(transparent)]
247    MlsStore(#[from] MlsStoreError),
248    /// Receive error.
249    ///
250    /// Channel receive failed. Retryable.
251    #[error(transparent)]
252    Recv(#[from] RecvError),
253    /// Missing field.
254    ///
255    /// Required field not present. Retryable.
256    #[error("Missing Field: {0:?} {1}")]
257    MissingField(MissingField, String),
258    // The own identity state could not be read; a sync publish waits for it.
259    #[error("own identity state unavailable: {0}")]
260    #[error_code(inherit)]
261    OwnStateUnavailable(#[source] IdentityUpdateError),
262    /// Outbox page unpublished.
263    ///
264    /// A queued sync message is not published yet; its values stay owed and
265    /// the outbox retries. Retryable.
266    #[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            // A dropped pool can hide in these wrapped errors; forward so the
296            // worker stops instead of hot-looping.
297            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    /// Blocks until the sync worker notifies that it is initialized and running.
377    pub async fn wait_for_sync_worker_init(&self) -> Result<(), xmtp_common::time::Expired> {
378        self.metrics.wait_for_init().await
379    }
380
381    /// Publish every owed value to the selected sync group, a page at
382    /// a time. A page's marks clear only after the message carrying it is
383    /// published.
384    // implements: SYNC-004, SYNC-034, SYNC-029, SYNC-033
385    #[tracing::instrument(level = "debug", skip_all, fields(operation = "publish_outbox"))]
386    pub(super) async fn publish_outbox(&self) -> Result<(), DeviceSyncError> {
387        // A quarter of the deployment's envelope, so MLS and envelope framing fit.
388        let max_bytes = (self.context.api().limits().max_envelope_bytes / 4).min(PAGE_BYTES);
389        self.get_sync_group().await?;
390        // One add per pass; the stored retry repeats it after the interval.
391        let mut add_tried = false;
392        loop {
393            // Values first, target second. A sync Welcome that commits after
394            // this read marks every key again, so the clear below skips them.
395            // One that committed before it is already the selected group.
396            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            // implements: SYNC-034
404            let gap = self.own_membership_gap(&group).await?;
405            if gap.missing && !add_tried {
406                add_tried = true;
407                // A failed add holds no page back, unless a revoked leaf may
408                // stay or the add must wait out a rate limit.
409                // implements: RATE-041
410                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                // The leaves present still get this page; the task retries
425                // the add once the retry interval has passed.
426                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            // One intent at a time: a page an earlier pass queued is
433            // published, or fails for good, before this one is queued.
434            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            // Only a published page clears its marks. Otherwise they stay
448            // owed, the pass backs off, and a later pass sends them again.
449            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    /// One outbox pass, then one receive pass, as the worker runs them.
472    #[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    /// How `group`'s leaves differ from the newest own association state held
479    /// locally: an associated installation without a leaf, or a leaf other
480    /// than this installation's that the state does not associate.
481    async fn own_membership_gap(
482        &self,
483        group: &MlsGroup<Context>,
484    ) -> Result<MembershipGap, DeviceSyncError> {
485        // implements: SYNC-034. An unreadable own state, such as a smart-wallet
486        // signature that needs the verifier, means not yet (as the receive path does).
487        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    /// Add the own installations that sync group `group` lacks. `true` while
517    /// it is the selected group and one it can retry still has no leaf.
518    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    /// The newest sync group that only the own inbox is in.
533    /// Older builds stored sync groups from other inboxes; this skips them.
534    // implements: SYNC-002, SYNC-010
535    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    // implements: SYNC-001
558    #[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                    // A rate-limited add is not resent before its delay.
576                    // implements: RATE-041
577                    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                // The task checks for missing leaves even after an Ok inline add.
584                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    /// Arm the sync group's reconcile task for `group_id`, due once `delay`
596    /// has passed. It is kept until it finishes, whatever its failures.
597    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    /// Schedule the adds to the user's conversations for a new installation.
612    #[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
621/// How a sync group's leaves differ from the own inbox's installations.
622struct MembershipGap {
623    missing: bool,
624    revoked: bool,
625    /// Missing, and the group's floor admits own-installation retries.
626    retriable: bool,
627}
628
629/// Test-only fault: the next outbox page of this installation reads back as
630/// not published, as when its confirmation fails after the backend took it.
631#[cfg(test)]
632pub(super) static UNCONFIRMED_NEXT_PAGE: parking_lot::Mutex<Option<Vec<u8>>> =
633    parking_lot::Mutex::new(None);
634
635/// Records per sync message the outbox sends.
636const PAGE_UPDATES: usize = 1_000;
637/// Bytes of encoded updates per sync message.
638const PAGE_BYTES: usize = 256 * 1024;
639
640/// The sync content of an outbox page. It holds a plain copy of the root key;
641/// `sync_message_bytes` wipes it.
642fn 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/// The payload of a sync message's outer `EncodedContent`, the only field the
664/// worker reads. It can hold an HMAC root key, so it is wiped on drop, also
665/// when prost drops a value whose decode fails part way.
666#[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
678/// Decode a received sync message: the outer `EncodedContent`, then the
679/// supported device-sync content inside it.
680pub(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
685/// A copy of `bytes` for prost to decode. prost decodes a bytes field of a
686/// `&[u8]` through a temporary copy that it frees unwiped; from `Bytes` it
687/// takes a slice instead. This copy is wiped on drop.
688fn decode_buffer(bytes: &[u8]) -> bytes::Bytes {
689    bytes::Bytes::from_owner(Zeroizing::new(bytes.to_vec()))
690}
691
692/// Decode only device-sync content that this client supports.
693///
694/// Old archive transfer fields decode as an unset oneof after their field
695/// numbers became reserved. Ignore them so older installations do not make
696/// the sync worker fail or retry the message.
697///
698/// `bytes` can hold an HMAC root key: the caller wipes them, and the content
699/// wipes its keys on drop.
700fn decode_supported_content(bytes: &[u8]) -> Option<ReceivedContent> {
701    DeviceSyncContentProto::decode(decode_buffer(bytes))
702        .ok()?
703        .content
704        .map(ReceivedContent)
705}