xmtp_mls/worker/device_sync/
preference_sync.rs1use 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 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 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
61pub(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 #[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 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 #[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 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 #[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 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 #[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 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}