logins/sync/
merge.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4
5// Merging for Sync.
6use super::{IncomingLogin, LoginPayload};
7use crate::encryption::EncryptorDecryptor;
8use crate::error::*;
9use crate::login::EncryptedLogin;
10use crate::util;
11use rusqlite::Row;
12use std::time::SystemTime;
13use sync15::bso::{IncomingBso, IncomingKind};
14use sync15::ServerTimestamp;
15use sync_guid::Guid;
16
17#[derive(Clone, Debug)]
18pub(crate) struct MirrorLogin {
19    pub login: EncryptedLogin,
20    pub server_modified: ServerTimestamp,
21}
22
23impl MirrorLogin {
24    #[inline]
25    pub fn guid_str(&self) -> &str {
26        &self.login.meta.id
27    }
28
29    pub(crate) fn from_row(row: &Row<'_>) -> Result<MirrorLogin> {
30        Ok(MirrorLogin {
31            login: EncryptedLogin::from_row(row)?,
32            server_modified: ServerTimestamp(row.get::<_, i64>("server_modified")?),
33        })
34    }
35}
36#[derive(Clone, Debug)]
37pub(crate) enum LocalLogin {
38    Tombstone {
39        id: String,
40        local_modified: SystemTime,
41    },
42    Alive {
43        login: EncryptedLogin,
44        local_modified: SystemTime,
45    },
46}
47
48impl LocalLogin {
49    #[inline]
50    pub fn guid_str(&self) -> &str {
51        match &self {
52            LocalLogin::Tombstone { id, .. } => id.as_str(),
53            LocalLogin::Alive { login, .. } => login.guid_str(),
54        }
55    }
56
57    pub fn local_modified(&self) -> SystemTime {
58        match &self {
59            LocalLogin::Tombstone { local_modified, .. }
60            | LocalLogin::Alive { local_modified, .. } => *local_modified,
61        }
62    }
63
64    pub(crate) fn from_row(row: &Row<'_>) -> Result<LocalLogin> {
65        let local_modified = util::system_time_millis_from_row(row, "local_modified")?;
66        Ok(if row.get("is_deleted")? {
67            let id = row.get("guid")?;
68            LocalLogin::Tombstone { id, local_modified }
69        } else {
70            let login = EncryptedLogin::from_row(row)?;
71            if login.sec_fields.is_empty() {
72                error_support::report_error!("logins-crypto", "empty ciphertext in the db",);
73            }
74            LocalLogin::Alive {
75                login,
76                local_modified,
77            }
78        })
79    }
80
81    // Only used by tests where we want to get the "raw" record - ie, a tombstone will still
82    // be returned here, just with many otherwise invalid empty fields
83    #[cfg(not(feature = "keydb"))]
84    #[cfg(test)]
85    pub(crate) fn test_raw_from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
86        EncryptedLogin::from_row(row)
87    }
88}
89
90macro_rules! impl_login {
91    ($ty:ty { $($fields:tt)* }) => {
92        impl AsRef<EncryptedLogin> for $ty {
93            #[inline]
94            fn as_ref(&self) -> &EncryptedLogin {
95                &self.login
96            }
97        }
98
99        impl AsMut<EncryptedLogin> for $ty {
100            #[inline]
101            fn as_mut(&mut self) -> &mut EncryptedLogin {
102                &mut self.login
103            }
104        }
105
106        impl From<$ty> for EncryptedLogin {
107            #[inline]
108            fn from(l: $ty) -> Self {
109                l.login
110            }
111        }
112
113        impl From<EncryptedLogin> for $ty {
114            #[inline]
115            fn from(login: EncryptedLogin) -> Self {
116                Self { login, $($fields)* }
117            }
118        }
119    };
120}
121
122impl_login!(MirrorLogin {
123    server_modified: ServerTimestamp(0)
124});
125
126// Stores data needed to do a 3-way merge
127#[derive(Debug)]
128pub(super) struct SyncLoginData {
129    pub guid: Guid,
130    pub local: Option<LocalLogin>,
131    pub mirror: Option<MirrorLogin>,
132    // None means it's a deletion
133    pub inbound: Option<IncomingLogin>,
134    pub inbound_ts: ServerTimestamp,
135}
136
137impl SyncLoginData {
138    #[inline]
139    pub fn guid_str(&self) -> &str {
140        self.guid.as_str()
141    }
142
143    #[inline]
144    pub fn guid(&self) -> &Guid {
145        &self.guid
146    }
147
148    pub fn from_bso(bso: IncomingBso, encdec: &dyn EncryptorDecryptor) -> Result<Self> {
149        let guid = bso.envelope.id.clone();
150        let inbound_ts = bso.envelope.modified;
151        let inbound = match bso.into_content::<LoginPayload>().kind {
152            IncomingKind::Content(p) => Some(IncomingLogin::from_incoming_payload(p, encdec)?),
153            IncomingKind::Tombstone => None,
154            // Before the IncomingKind refactor we returned an error. We could probably just
155            // treat it as a tombstone but should check that's sane, so for now, we also err.
156            IncomingKind::Malformed => return Err(Error::MalformedIncomingRecord),
157        };
158        Ok(Self {
159            guid,
160            local: None,
161            mirror: None,
162            inbound,
163            inbound_ts,
164        })
165    }
166}
167
168macro_rules! impl_login_setter {
169    ($setter_name:ident, $field:ident, $Login:ty) => {
170        impl SyncLoginData {
171            pub(crate) fn $setter_name(&mut self, record: $Login) -> Result<()> {
172                // TODO: We probably shouldn't panic in this function!
173                if self.$field.is_some() {
174                    // Shouldn't be possible (only could happen if UNIQUE fails in sqlite, or if we
175                    // get duplicate guids somewhere,but we check).
176                    panic!(
177                        "SyncLoginData::{} called on object that already has {} data",
178                        stringify!($setter_name),
179                        stringify!($field)
180                    );
181                }
182
183                if self.guid_str() != record.guid_str() {
184                    // This is almost certainly a bug in our code.
185                    panic!(
186                        "Wrong guid on login in {}: {:?} != {:?}",
187                        stringify!($setter_name),
188                        self.guid_str(),
189                        record.guid_str()
190                    );
191                }
192
193                self.$field = Some(record);
194                Ok(())
195            }
196        }
197    };
198}
199
200impl_login_setter!(set_local, local, LocalLogin);
201impl_login_setter!(set_mirror, mirror, MirrorLogin);
202
203#[derive(Debug, Default, Clone)]
204pub(crate) struct LoginDelta {
205    // "non-commutative" fields
206    pub origin: Option<String>,
207    pub password: Option<String>,
208    pub username: Option<String>,
209    pub http_realm: Option<String>,
210    pub form_action_origin: Option<String>,
211
212    pub time_created: Option<i64>,
213    pub time_last_used: Option<i64>,
214    pub time_password_changed: Option<i64>,
215
216    // "non-conflicting" fields (which are the same)
217    pub password_field: Option<String>,
218    pub username_field: Option<String>,
219
220    // Commutative field
221    pub times_used: i64,
222}
223
224macro_rules! merge_field {
225    ($merged:ident, $b:ident, $prefer_b:expr, $field:ident) => {
226        if let Some($field) = $b.$field.take() {
227            if $merged.$field.is_some() {
228                warn!("Collision merging login field {}", stringify!($field));
229                if $prefer_b {
230                    $merged.$field = Some($field);
231                }
232            } else {
233                $merged.$field = Some($field);
234            }
235        }
236    };
237}
238
239impl LoginDelta {
240    #[allow(clippy::cognitive_complexity)] // Looks like clippy considers this after macro-expansion...
241    pub fn merge(self, mut b: LoginDelta, b_is_newer: bool) -> LoginDelta {
242        let mut merged = self;
243        merge_field!(merged, b, b_is_newer, origin);
244        merge_field!(merged, b, b_is_newer, password);
245        merge_field!(merged, b, b_is_newer, username);
246        merge_field!(merged, b, b_is_newer, http_realm);
247        merge_field!(merged, b, b_is_newer, form_action_origin);
248
249        merge_field!(merged, b, b_is_newer, time_created);
250        merge_field!(merged, b, b_is_newer, time_last_used);
251        merge_field!(merged, b, b_is_newer, time_password_changed);
252
253        merge_field!(merged, b, b_is_newer, password_field);
254        merge_field!(merged, b, b_is_newer, username_field);
255
256        // commutative fields
257        merged.times_used += b.times_used;
258
259        merged
260    }
261}
262
263macro_rules! apply_field {
264    ($login:ident, $delta:ident, $field:ident) => {
265        if let Some($field) = $delta.$field.take() {
266            $login.fields.$field = $field.into();
267        }
268    };
269}
270
271macro_rules! apply_metadata_field {
272    ($login:ident, $delta:ident, $field:ident) => {
273        if let Some($field) = $delta.$field.take() {
274            $login.meta.$field = $field.into();
275        }
276    };
277}
278
279impl EncryptedLogin {
280    pub(crate) fn apply_delta(
281        &mut self,
282        mut delta: LoginDelta,
283        encdec: &dyn EncryptorDecryptor,
284    ) -> Result<()> {
285        apply_field!(self, delta, origin);
286
287        apply_metadata_field!(self, delta, time_created);
288        apply_metadata_field!(self, delta, time_last_used);
289        apply_metadata_field!(self, delta, time_password_changed);
290
291        apply_field!(self, delta, password_field);
292        apply_field!(self, delta, username_field);
293
294        let mut sec_fields = self.decrypt_fields(encdec)?;
295        if let Some(password) = delta.password.take() {
296            sec_fields.password = password;
297        }
298        if let Some(username) = delta.username.take() {
299            sec_fields.username = username;
300        }
301        self.sec_fields = sec_fields.encrypt(encdec, &self.meta.id)?;
302
303        // Use Some("") to indicate that it should be changed to be None (hacky...)
304        if let Some(realm) = delta.http_realm.take() {
305            self.fields.http_realm = if realm.is_empty() { None } else { Some(realm) };
306        }
307
308        if let Some(url) = delta.form_action_origin.take() {
309            self.fields.form_action_origin = if url.is_empty() { None } else { Some(url) };
310        }
311
312        self.meta.times_used += delta.times_used;
313        Ok(())
314    }
315
316    pub(crate) fn delta(
317        &self,
318        older: &EncryptedLogin,
319        encdec: &dyn EncryptorDecryptor,
320    ) -> Result<LoginDelta> {
321        let mut delta = LoginDelta::default();
322
323        if self.fields.form_action_origin != older.fields.form_action_origin {
324            delta.form_action_origin =
325                Some(self.fields.form_action_origin.clone().unwrap_or_default());
326        }
327
328        if self.fields.http_realm != older.fields.http_realm {
329            delta.http_realm = Some(self.fields.http_realm.clone().unwrap_or_default());
330        }
331
332        if self.fields.origin != older.fields.origin {
333            delta.origin = Some(self.fields.origin.clone());
334        }
335        let older_sec_fields = older.decrypt_fields(encdec)?;
336        let self_sec_fields = self.decrypt_fields(encdec)?;
337        if self_sec_fields.username != older_sec_fields.username {
338            delta.username = Some(self_sec_fields.username.clone());
339        }
340        if self_sec_fields.password != older_sec_fields.password {
341            delta.password = Some(self_sec_fields.password);
342        }
343        if self.fields.password_field != older.fields.password_field {
344            delta.password_field = Some(self.fields.password_field.clone());
345        }
346        if self.fields.username_field != older.fields.username_field {
347            delta.username_field = Some(self.fields.username_field.clone());
348        }
349
350        // We discard zero (and negative numbers) for timestamps so that a
351        // record that doesn't contain this information (these are
352        // `#[serde(default)]`) doesn't skew our records.
353        //
354        // Arguably, we should also also ignore values later than our
355        // `time_created`, or earlier than our `time_last_used` or
356        // `time_password_changed`. Doing this properly would probably require
357        // a scheme analogous to Desktop's weak-reupload system, so I'm punting
358        // on it for now.
359        if self.meta.time_created > 0 && self.meta.time_created != older.meta.time_created {
360            delta.time_created = Some(self.meta.time_created);
361        }
362        if self.meta.time_last_used > 0 && self.meta.time_last_used != older.meta.time_last_used {
363            delta.time_last_used = Some(self.meta.time_last_used);
364        }
365        if self.meta.time_password_changed > 0
366            && self.meta.time_password_changed != older.meta.time_password_changed
367        {
368            delta.time_password_changed = Some(self.meta.time_password_changed);
369        }
370
371        if self.meta.times_used > 0 && self.meta.times_used != older.meta.times_used {
372            delta.times_used = self.meta.times_used - older.meta.times_used;
373        }
374
375        Ok(delta)
376    }
377}
378
379#[cfg(not(feature = "keydb"))]
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use crate::encryption::test_utils::TEST_ENCDEC;
384    use nss::ensure_initialized;
385
386    #[test]
387    fn test_invalid_payload_timestamps() {
388        ensure_initialized();
389        #[allow(clippy::unreadable_literal)]
390        let bad_timestamp = 18446732429235952000u64;
391        let bad_payload = IncomingBso::from_test_content(serde_json::json!({
392            "id": "123412341234",
393            "formSubmitURL": "https://www.example.com/submit",
394            "hostname": "https://www.example.com",
395            "username": "test",
396            "password": "test",
397            "timeCreated": bad_timestamp,
398            "timeLastUsed": "some other garbage",
399            "timePasswordChanged": -30, // valid i64 but negative
400        }));
401        let login = SyncLoginData::from_bso(bad_payload, &*TEST_ENCDEC)
402            .unwrap()
403            .inbound
404            .unwrap()
405            .login;
406        assert_eq!(login.meta.time_created, 0);
407        assert_eq!(login.meta.time_last_used, 0);
408        assert_eq!(login.meta.time_password_changed, 0);
409
410        let now64 = util::system_time_ms_i64(std::time::SystemTime::now());
411        let good_payload = IncomingBso::from_test_content(serde_json::json!({
412            "id": "123412341234",
413            "formSubmitURL": "https://www.example.com/submit",
414            "hostname": "https://www.example.com",
415            "username": "test",
416            "password": "test",
417            "timeCreated": now64 - 100,
418            "timeLastUsed": now64 - 50,
419            "timePasswordChanged": now64 - 25,
420        }));
421
422        let login = SyncLoginData::from_bso(good_payload, &*TEST_ENCDEC)
423            .unwrap()
424            .inbound
425            .unwrap()
426            .login;
427
428        assert_eq!(login.meta.time_created, now64 - 100);
429        assert_eq!(login.meta.time_last_used, now64 - 50);
430        assert_eq!(login.meta.time_password_changed, now64 - 25);
431    }
432}