Skip to main content

xmtp_mls/worker/device_sync/
preference_sync.rs

1use super::*;
2use std::collections::{HashMap, HashSet};
3use xmtp_common::time::now_ns;
4use xmtp_db::consent_record::StoredConsentRecord;
5use xmtp_db::user_preferences::{HmacKey, StoredUserPreferences};
6use xmtp_proto::ConversionError;
7use xmtp_proto::xmtp::device_sync::content::HmacKeyUpdate as HmacKeyUpdateProto;
8use xmtp_proto::xmtp::device_sync::content::{
9    PreferenceUpdate as PreferenceUpdateProto, PreferenceUpdates,
10    device_sync_content::Content as ContentProto, preference_update::Update as UpdateProto,
11};
12
13#[derive(Clone, Debug, PartialEq)]
14pub enum PreferenceUpdate {
15    Consent(StoredConsentRecord),
16    Hmac { key: Vec<u8>, cycled_at_ns: i64 },
17}
18
19impl<Context> DeviceSyncClient<Context>
20where
21    Context: XmtpSharedContext,
22{
23    // implements: SYNC-020
24    pub(crate) async fn sync_preferences(
25        &self,
26        updates: Vec<PreferenceUpdate>,
27    ) -> Result<Vec<PreferenceUpdate>, ClientError> {
28        self.send_device_sync_message(ContentProto::PreferenceUpdates(PreferenceUpdates {
29            updates: updates.clone().into_iter().map(From::from).collect(),
30        }))
31        .await?;
32
33        updates.iter().for_each(|update| match update {
34            PreferenceUpdate::Consent(_) => self.metrics.increment_metric(SyncMetric::ConsentSent),
35            PreferenceUpdate::Hmac { .. } => self.metrics.increment_metric(SyncMetric::HmacSent),
36        });
37
38        #[cfg(test)]
39        worker::test_hooks::observe_preferences(
40            self.context.installation_id(),
41            worker::test_hooks::PreferenceStage::Published,
42            &updates,
43        );
44        Ok(updates)
45    }
46
47    // implements: SYNC-015
48    pub(crate) async fn cycle_hmac(&self) -> Result<(), ClientError> {
49        tracing::info!("Sending new HMAC key to sync group.");
50
51        self.sync_preferences(vec![PreferenceUpdate::Hmac {
52            key: HmacKey::random_key(),
53            cycled_at_ns: now_ns(),
54        }])
55        .await?;
56
57        Ok(())
58    }
59}
60
61// implements: SYNC-023
62pub(super) struct StoredPreferenceUpdates {
63    pub legacy: Vec<PreferenceUpdate>,
64    pub public: Vec<PreferenceUpdate>,
65}
66
67pub(super) fn store_preference_updates(
68    updates: Vec<PreferenceUpdateProto>,
69    conn: &impl DbQuery,
70    handle: &WorkerMetrics<SyncMetric>,
71) -> Result<StoredPreferenceUpdates, StorageError> {
72    let mut changed = vec![];
73    let mut initial_consents = HashMap::new();
74    let initial_hmac = StoredUserPreferences::load(conn)?;
75    for update in updates.into_iter().filter_map(|u| u.update) {
76        match update {
77            UpdateProto::Consent(consent_save) => {
78                tracing::info!(
79                    "Storing consent update from sync group. State: {:?}",
80                    consent_save.state
81                );
82
83                let consent_record: StoredConsentRecord = consent_save.try_into()?;
84                let key = (
85                    consent_record.entity_type as i32,
86                    consent_record.entity.clone(),
87                );
88                if let std::collections::hash_map::Entry::Vacant(entry) =
89                    initial_consents.entry(key)
90                {
91                    let initial = conn
92                        .get_consent_record(
93                            consent_record.entity.clone(),
94                            consent_record.entity_type,
95                        )?
96                        .map(|record| record.state);
97                    entry.insert(initial);
98                }
99                let updated = conn
100                    .insert_newer_consent_record(consent_record.clone())?
101                    .state_changed;
102
103                if updated {
104                    changed.push(PreferenceUpdate::Consent(consent_record));
105                }
106
107                handle.increment_metric(SyncMetric::ConsentReceived);
108            }
109            UpdateProto::Hmac(HmacKeyUpdateProto { key, cycled_at_ns }) => {
110                tracing::info!("Storing new HMAC key from sync group");
111                let before = StoredUserPreferences::load(conn)?;
112                StoredUserPreferences::store_hmac_key(conn, &key, Some(cycled_at_ns))?;
113                let after = StoredUserPreferences::load(conn)?;
114                if before.hmac_key != after.hmac_key
115                    || before.hmac_key_cycled_at_ns != after.hmac_key_cycled_at_ns
116                {
117                    changed.push(PreferenceUpdate::Hmac { key, cycled_at_ns });
118                }
119                handle.increment_metric(SyncMetric::HmacReceived);
120            }
121        }
122    }
123
124    let final_hmac = StoredUserPreferences::load(conn)?;
125    let hmac_changed = initial_hmac.hmac_key != final_hmac.hmac_key
126        || initial_hmac.hmac_key_cycled_at_ns != final_hmac.hmac_key_cycled_at_ns;
127    let mut seen_consents = HashSet::new();
128    let mut seen_hmac = false;
129    let mut public: Vec<_> = changed
130        .iter()
131        .rev()
132        .filter(|update| match update {
133            PreferenceUpdate::Consent(record) => {
134                seen_consents.insert((record.entity_type as i32, record.entity.clone()))
135                    && initial_consents
136                        .get(&(record.entity_type as i32, record.entity.clone()))
137                        .copied()
138                        .flatten()
139                        != Some(record.state)
140            }
141            PreferenceUpdate::Hmac { .. } => {
142                !std::mem::replace(&mut seen_hmac, true) && hmac_changed
143            }
144        })
145        .cloned()
146        .collect();
147    public.reverse();
148    Ok(StoredPreferenceUpdates {
149        legacy: changed,
150        public,
151    })
152}
153
154impl TryFrom<PreferenceUpdateProto> for PreferenceUpdate {
155    type Error = ConversionError;
156    fn try_from(update: PreferenceUpdateProto) -> Result<Self, Self::Error> {
157        let Some(update) = update.update else {
158            return Err(ConversionError::Unspecified("update"));
159        };
160        update.try_into()
161    }
162}
163impl TryFrom<UpdateProto> for PreferenceUpdate {
164    type Error = ConversionError;
165    fn try_from(update: UpdateProto) -> Result<Self, Self::Error> {
166        let update = match update {
167            UpdateProto::Consent(consent) => Self::Consent(consent.try_into()?),
168            UpdateProto::Hmac(HmacKeyUpdateProto { key, cycled_at_ns }) => {
169                Self::Hmac { key, cycled_at_ns }
170            }
171        };
172        Ok(update)
173    }
174}
175
176impl From<PreferenceUpdate> for PreferenceUpdateProto {
177    fn from(update: PreferenceUpdate) -> Self {
178        PreferenceUpdateProto {
179            update: Some(match update {
180                PreferenceUpdate::Consent(consent) => UpdateProto::Consent(consent.into()),
181                PreferenceUpdate::Hmac { key, cycled_at_ns } => {
182                    UpdateProto::Hmac(HmacKeyUpdateProto { key, cycled_at_ns })
183                }
184            }),
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::{
193        tester,
194        worker::{device_sync::worker::SyncMetric, metrics::WorkerMetrics},
195    };
196    use xmtp_db::consent_record::{ConsentState, ConsentType};
197    use xmtp_db::user_preferences::StoredUserPreferences;
198
199    // verifies: PROC-036
200    #[cfg(not(target_arch = "wasm32"))]
201    #[xmtp_common::test(unwrap_try = true)]
202    async fn cycle_hmac_log_omits_installation_id() {
203        use futures::FutureExt;
204        use xmtp_logging::{Level, test_logging::LogCapture};
205
206        tester!(alix, disable_workers);
207        let capture = LogCapture::new(Level::Info);
208        let sync = alix.context.device_sync_client();
209        tracing::dispatcher::with_default(&capture.dispatch(), || {
210            // Poll the real call through its log emission, before network completion.
211            let _ = sync.cycle_hmac().now_or_never();
212        });
213        let output = capture.output();
214        let event = output
215            .lines()
216            .find(|line| line.contains("Sending new HMAC key to sync group."))
217            .expect("cycle_hmac emitted its INFO event");
218        assert!(
219            !event.contains(&alix.context.installation_id().to_string()),
220            "cycle_hmac log contains the full installation ID"
221        );
222    }
223
224    // verifies: SYNC-015
225    #[rstest::rstest]
226    #[xmtp_common::test(unwrap_try = true)]
227    async fn test_hmac_sync() {
228        use xmtp_id::associations::test_utils::add_wallet_signature;
229
230        tester!(amal_a, sync_worker);
231        tester!(amal_b, from: amal_a);
232
233        amal_a.test_has_same_sync_group_as(&amal_b).await?;
234
235        xmtp_common::wait_for_eq(
236            || async {
237                amal_a.worker().get(SyncMetric::HmacSent)
238                    + amal_b.worker().get(SyncMetric::HmacSent)
239                    >= 1
240            },
241            true,
242        )
243        .await?;
244
245        amal_a.sync_all_welcomes_and_device_sync_groups().await?;
246        amal_a
247            .worker()
248            .register_interest(SyncMetric::HmacReceived, 1)
249            .wait()
250            .await?;
251
252        // Wait for a to process the new hmac key
253        amal_b
254            .context
255            .device_sync_client()
256            .get_sync_group()
257            .await?
258            .sync()
259            .await?;
260        amal_b
261            .worker()
262            .register_interest(SyncMetric::HmacReceived, 1)
263            .wait()
264            .await?;
265
266        let pref_a = StoredUserPreferences::load(amal_a.context.db())?;
267        let pref_b = StoredUserPreferences::load(amal_b.context.db())?;
268
269        assert_eq!(pref_a.hmac_key, pref_b.hmac_key);
270
271        let sent_before_revoke = amal_a.worker().get(SyncMetric::HmacSent);
272        let received_before_revoke = amal_a.worker().get(SyncMetric::HmacReceived);
273        let mut revoke = amal_a
274            .identity_updates()
275            .revoke_installations(vec![amal_b.context.installation_id().to_vec()])
276            .await?;
277        add_wallet_signature(&mut revoke, &amal_a.builder.owner).await;
278        amal_a
279            .identity_updates()
280            .apply_signature_request(revoke)
281            .await?;
282        amal_a
283            .worker()
284            .register_interest(SyncMetric::HmacSent, sent_before_revoke + 1)
285            .wait()
286            .await?;
287
288        amal_a.sync_all_welcomes_and_device_sync_groups().await?;
289        amal_a
290            .worker()
291            .register_interest(SyncMetric::HmacReceived, received_before_revoke + 1)
292            .wait()
293            .await?;
294        let new_pref_a = StoredUserPreferences::load(amal_a.context.db())?;
295        assert_ne!(pref_a.hmac_key, new_pref_a.hmac_key);
296    }
297
298    // verifies: EVENT-001, EVENT-010
299    #[xmtp_common::test(unwrap_try = true)]
300    async fn sync_batch_preserves_legacy_updates() {
301        tester!(alix, disable_workers);
302        let metrics = WorkerMetrics::new(alix.context.installation_id());
303        let db = alix.context.db();
304        let hmac = |key: u8, cycled_at_ns| PreferenceUpdate::Hmac {
305            key: vec![key; 42],
306            cycled_at_ns,
307        };
308        let first: Vec<_> = [hmac(1, i64::MAX - 2), hmac(2, i64::MAX - 1)]
309            .into_iter()
310            .map(Into::into)
311            .collect();
312        let first = store_preference_updates(first, &db, &metrics)?;
313        assert_eq!(
314            first.legacy,
315            vec![hmac(1, i64::MAX - 2), hmac(2, i64::MAX - 1)]
316        );
317        assert_eq!(first.public, vec![hmac(2, i64::MAX - 1)]);
318        let no_change: Vec<_> = [hmac(2, i64::MAX - 1), hmac(3, i64::MAX - 3)]
319            .into_iter()
320            .map(Into::into)
321            .collect();
322        let no_change = store_preference_updates(no_change, &db, &metrics)?;
323        assert!(no_change.legacy.is_empty());
324        assert!(no_change.public.is_empty());
325        assert_eq!(
326            StoredUserPreferences::load(&db)?.hmac_key,
327            Some(vec![2; 42])
328        );
329
330        let mut allowed = StoredConsentRecord::new(
331            ConsentType::InboxId,
332            ConsentState::Allowed,
333            "one-entity".into(),
334        );
335        allowed.consented_at_ns = 100;
336        let mut denied = StoredConsentRecord::new(
337            ConsentType::InboxId,
338            ConsentState::Denied,
339            "one-entity".into(),
340        );
341        denied.consented_at_ns = 101;
342        let updates = vec![
343            PreferenceUpdate::Consent(allowed.clone()).into(),
344            PreferenceUpdate::Consent(denied.clone()).into(),
345        ];
346        let changed = store_preference_updates(updates, &db, &metrics)?;
347        assert_eq!(
348            changed.legacy,
349            vec![
350                PreferenceUpdate::Consent(allowed),
351                PreferenceUpdate::Consent(denied.clone())
352            ]
353        );
354        assert_eq!(changed.public, vec![PreferenceUpdate::Consent(denied)]);
355
356        let mut denied_again = StoredConsentRecord::new(
357            ConsentType::InboxId,
358            ConsentState::Denied,
359            "one-entity".into(),
360        );
361        denied_again.consented_at_ns = 103;
362        let mut allowed_again = StoredConsentRecord::new(
363            ConsentType::InboxId,
364            ConsentState::Allowed,
365            "one-entity".into(),
366        );
367        allowed_again.consented_at_ns = 102;
368        let round_trip = vec![
369            PreferenceUpdate::Consent(allowed_again.clone()).into(),
370            PreferenceUpdate::Consent(denied_again.clone()).into(),
371        ];
372        // The final state is Denied, which matches the state before this batch.
373        let round_trip = store_preference_updates(round_trip, &db, &metrics)?;
374        assert_eq!(round_trip.legacy.len(), 2);
375        assert!(round_trip.public.is_empty());
376
377        let mut unknown = StoredConsentRecord::new(
378            ConsentType::InboxId,
379            ConsentState::Unknown,
380            "new-unknown".into(),
381        );
382        unknown.consented_at_ns = 104;
383        let inserted = store_preference_updates(
384            vec![PreferenceUpdate::Consent(unknown.clone()).into()],
385            &db,
386            &metrics,
387        )?;
388        assert_eq!(inserted.public, vec![PreferenceUpdate::Consent(unknown)]);
389    }
390
391    // verifies: CONS-010
392    #[xmtp_common::test(unwrap_try = true)]
393    async fn peer_sync_consent_keeps_the_latest_record() {
394        tester!(alix, disable_workers);
395        let metrics = WorkerMetrics::new(alix.context.installation_id());
396        let db = alix.context.db();
397        let updates = [
398            (ConsentState::Allowed, 10),
399            (ConsentState::Allowed, 30),
400            (ConsentState::Denied, 20),
401        ]
402        .map(|(state, consented_at_ns)| {
403            PreferenceUpdate::Consent(StoredConsentRecord {
404                entity_type: ConsentType::InboxId,
405                state,
406                entity: "peer-inbox".into(),
407                consented_at_ns,
408            })
409            .into()
410        });
411        let changed = store_preference_updates(updates.into(), &db, &metrics)?;
412        // Allowed@10 is a change. Allowed@30 repeats the stored state: it
413        // moves the time but is not a change. Denied@20 is older and rejected.
414        assert_eq!(changed.legacy.len(), 1);
415        let stored = db
416            .get_consent_record("peer-inbox".into(), ConsentType::InboxId)?
417            .unwrap();
418        assert_eq!(stored.state, ConsentState::Allowed);
419        assert_eq!(stored.consented_at_ns, 30);
420    }
421}