logins/sync/
payload.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// Login entry from a server payload
6//
7// This struct is used for fetching/sending login records to the server.  There are a number
8// of differences between this and the top-level Login struct; some fields are renamed, some are
9// locally encrypted, etc.
10use crate::encryption::EncryptorDecryptor;
11use crate::error::*;
12use crate::login::ValidateAndFixup;
13use crate::util::sanitize_timestamp;
14use crate::SecureLoginFields;
15use crate::{EncryptedLogin, LoginEntry, LoginFields, LoginMeta};
16use serde_derive::*;
17use sync15::bso::OutgoingBso;
18use sync_guid::Guid;
19
20type UnknownFields = serde_json::Map<String, serde_json::Value>;
21
22trait UnknownFieldsExt {
23    fn encrypt(&self, encdec: &dyn EncryptorDecryptor) -> Result<String>;
24    fn decrypt(ciphertext: &str, encdec: &dyn EncryptorDecryptor) -> Result<Self>
25    where
26        Self: Sized;
27}
28
29impl UnknownFieldsExt for UnknownFields {
30    fn encrypt(&self, encdec: &dyn EncryptorDecryptor) -> Result<String> {
31        let string = serde_json::to_string(&self)?;
32        let cipherbytes = encdec
33            .encrypt(string.as_bytes().into())
34            .map_err(|e| Error::EncryptionFailed(e.to_string()))?;
35        let ciphertext = std::str::from_utf8(&cipherbytes)
36            .map_err(|e| Error::EncryptionFailed(e.to_string()))?;
37        Ok(ciphertext.to_owned())
38    }
39
40    fn decrypt(ciphertext: &str, encdec: &dyn EncryptorDecryptor) -> Result<Self> {
41        let jsonbytes = encdec
42            .decrypt(ciphertext.as_bytes().into())
43            .map_err(|e| Error::DecryptionFailed(e.to_string()))?;
44        let json =
45            std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
46        Ok(serde_json::from_str(json)?)
47    }
48}
49
50/// What we get from the server after parsing the payload. We need to round-trip "unknown"
51/// fields, but don't want to carry them around in `EncryptedLogin`.
52#[derive(Debug)]
53pub(super) struct IncomingLogin {
54    pub login: EncryptedLogin,
55    // An encrypted UnknownFields, or None if there are none.
56    pub unknown: Option<String>,
57}
58
59impl IncomingLogin {
60    pub fn guid(&self) -> Guid {
61        self.login.guid()
62    }
63
64    pub(super) fn from_incoming_payload(
65        p: LoginPayload,
66        encdec: &dyn EncryptorDecryptor,
67    ) -> Result<Self> {
68        let original_fields = LoginFields {
69            origin: p.hostname,
70            form_action_origin: p.form_submit_url,
71            http_realm: p.http_realm,
72            username_field: p.username_field,
73            password_field: p.password_field,
74        };
75        let original_sec_fields = SecureLoginFields {
76            username: p.username,
77            password: p.password,
78        };
79        // we do a bit of a dance here to maybe_fixup() the fields via LoginEntry
80        let original_login_entry = LoginEntry::new(original_fields, original_sec_fields);
81        let login_entry = original_login_entry
82            .maybe_fixup()?
83            .unwrap_or(original_login_entry);
84        let fields = LoginFields {
85            origin: login_entry.origin,
86            form_action_origin: login_entry.form_action_origin,
87            http_realm: login_entry.http_realm,
88            username_field: login_entry.username_field,
89            password_field: login_entry.password_field,
90        };
91        let id = String::from(p.guid);
92        let sec_fields = SecureLoginFields {
93            username: login_entry.username,
94            password: login_entry.password,
95        }
96        .encrypt(encdec, &id)?;
97
98        // We handle NULL in the DB for migrated databases and it's wasteful
99        // to encrypt the common case of an empty map, so...
100        let unknown = if p.unknown_fields.is_empty() {
101            None
102        } else {
103            Some(p.unknown_fields.encrypt(encdec)?)
104        };
105
106        // If we can't fix the parts we keep the invalid bits.
107        Ok(Self {
108            login: EncryptedLogin {
109                meta: LoginMeta {
110                    id,
111                    time_created: p.time_created,
112                    time_password_changed: p.time_password_changed,
113                    time_last_used: p.time_last_used,
114                    times_used: p.times_used,
115                    time_last_breach_alert_dismissed: p.time_last_breach_alert_dismissed,
116                },
117                fields,
118                sec_fields,
119            },
120            unknown,
121        })
122    }
123}
124
125/// The JSON payload that lives on the storage servers.
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127#[serde(rename_all = "camelCase")]
128pub struct LoginPayload {
129    #[serde(rename = "id")]
130    pub guid: Guid,
131
132    // This is 'origin' in our Login struct.
133    pub hostname: String,
134
135    // This is 'form_action_origin' in our Login struct.
136    // rename_all = "camelCase" by default will do formSubmitUrl, but we can just
137    // override this one field.
138    #[serde(rename = "formSubmitURL")]
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub form_submit_url: Option<String>,
141
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub http_realm: Option<String>,
144
145    #[serde(default)]
146    pub username: String,
147
148    pub password: String,
149
150    #[serde(default)]
151    pub username_field: String,
152
153    #[serde(default)]
154    pub password_field: String,
155
156    #[serde(default)]
157    #[serde(deserialize_with = "deserialize_timestamp")]
158    pub time_created: i64,
159
160    #[serde(default)]
161    #[serde(deserialize_with = "deserialize_timestamp")]
162    pub time_password_changed: i64,
163
164    #[serde(default)]
165    #[serde(deserialize_with = "deserialize_timestamp")]
166    pub time_last_used: i64,
167
168    #[serde(default)]
169    pub times_used: i64,
170
171    #[serde(default)]
172    #[serde(deserialize_with = "deserialize_optional_timestamp")]
173    pub time_last_breach_alert_dismissed: Option<i64>,
174
175    // Additional "unknown" round-tripped fields.
176    #[serde(flatten)]
177    unknown_fields: UnknownFields,
178}
179
180// These probably should be on the payload itself, but one refactor at a time!
181impl EncryptedLogin {
182    pub fn into_bso(
183        self,
184        encdec: &dyn EncryptorDecryptor,
185        enc_unknown_fields: Option<String>,
186    ) -> Result<OutgoingBso> {
187        let unknown_fields = match enc_unknown_fields {
188            Some(s) => UnknownFields::decrypt(&s, encdec)?,
189            None => Default::default(),
190        };
191        let sec_fields = SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)?;
192        Ok(OutgoingBso::from_content_with_id(
193            crate::sync::LoginPayload {
194                guid: self.guid(),
195                hostname: self.fields.origin,
196                form_submit_url: self.fields.form_action_origin,
197                http_realm: self.fields.http_realm,
198                username_field: self.fields.username_field,
199                password_field: self.fields.password_field,
200                username: sec_fields.username,
201                password: sec_fields.password,
202                time_created: self.meta.time_created,
203                time_password_changed: self.meta.time_password_changed,
204                time_last_used: self.meta.time_last_used,
205                times_used: self.meta.times_used,
206                time_last_breach_alert_dismissed: self.meta.time_last_breach_alert_dismissed,
207                unknown_fields,
208            },
209        )?)
210    }
211}
212
213// Quiet clippy, since this function is passed to deserialiaze_with...
214#[allow(clippy::unnecessary_wraps)]
215fn deserialize_timestamp<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
216where
217    D: serde::de::Deserializer<'de>,
218{
219    use serde::de::Deserialize;
220    // Invalid and negative timestamps are replaced with 0.
221    //
222    // See bug 2066257: the JS sync engine declines to set a timestamp that `new
223    // Date()` cannot represent, but the bridged engine hands the payload
224    // straight to us, and the only check here used to be `.max(0)` - so an
225    // absurd positive value went into the database unexamined and then throws
226    // on the way back to JS.
227    Ok(sanitize_timestamp(
228        i64::deserialize(deserializer).unwrap_or_default(),
229    ))
230}
231
232// Quiet clippy, since this function is passed to deserialiaze_with...
233#[allow(clippy::unnecessary_wraps)]
234fn deserialize_optional_timestamp<'de, D>(
235    deserializer: D,
236) -> std::result::Result<Option<i64>, D::Error>
237where
238    D: serde::de::Deserializer<'de>,
239{
240    use serde::de::Deserialize;
241    Ok(i64::deserialize(deserializer).ok().map(sanitize_timestamp))
242}
243
244#[cfg(not(feature = "keydb"))]
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::encryption::test_utils::{encrypt_struct, TEST_ENCDEC};
249    use crate::sync::merge::SyncLoginData;
250    use crate::{EncryptedLogin, LoginFields, LoginMeta, SecureLoginFields};
251    use sync15::bso::IncomingBso;
252
253    #[test]
254    fn test_payload_to_login() {
255        let bso = IncomingBso::from_test_content(serde_json::json!({
256            "id": "123412341234",
257            "httpRealm": "test",
258            "hostname": "https://www.example.com",
259            "username": "user",
260            "password": "password",
261        }));
262        let login = IncomingLogin::from_incoming_payload(
263            bso.into_content::<LoginPayload>().content().unwrap(),
264            &*TEST_ENCDEC,
265        )
266        .unwrap()
267        .login;
268        assert_eq!(login.meta.id, "123412341234");
269        assert_eq!(login.fields.http_realm, Some("test".to_string()));
270        assert_eq!(login.fields.origin, "https://www.example.com");
271        assert_eq!(login.fields.form_action_origin, None);
272        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
273        assert_eq!(sec_fields.username, "user");
274        assert_eq!(sec_fields.password, "password");
275    }
276
277    /// A peer running a version without the clamp - or one with a badly broken clock - must
278    /// not be able to push a timestamp that consumers cannot represent. Bug 2066257.
279    #[test]
280    fn test_payload_repairs_absurd_timestamps() {
281        let bso = IncomingBso::from_test_content(serde_json::json!({
282            "id": "123412341234",
283            "httpRealm": "test",
284            "hostname": "https://www.example.com",
285            "username": "user",
286            "password": "password",
287            // Values observed in telemetry
288            "timeCreated": 18446744071857664i64,
289            "timePasswordChanged": 18446744073217880i64,
290            "timeLastUsed": -1,
291            "timeLastBreachAlertDismissed": i64::MAX,
292        }));
293        let login = IncomingLogin::from_incoming_payload(
294            bso.into_content::<LoginPayload>().content().unwrap(),
295            &*TEST_ENCDEC,
296        )
297        .unwrap()
298        .login;
299
300        assert_eq!(login.meta.time_created, 0);
301        assert_eq!(login.meta.time_password_changed, 0);
302        assert_eq!(login.meta.time_last_used, 0);
303        assert_eq!(login.meta.time_last_breach_alert_dismissed, Some(0));
304    }
305
306    // formSubmitURL (now formActionOrigin) being an empty string is a valid
307    // legacy case that is supported on desktop, we should ensure we are as well
308    // https://searchfox.org/mozilla-central/rev/32c74afbb24dce4b5dd6b33be71197e615631d71/toolkit/components/passwordmgr/test/unit/test_logins_change.js#183-184
309    #[test]
310    fn test_payload_empty_form_action_to_login() {
311        let bso = IncomingBso::from_test_content(serde_json::json!({
312            "id": "123412341234",
313            "formSubmitURL": "",
314            "hostname": "https://www.example.com",
315            "username": "user",
316            "password": "password",
317        }));
318        let login = IncomingLogin::from_incoming_payload(
319            bso.into_content::<LoginPayload>().content().unwrap(),
320            &*TEST_ENCDEC,
321        )
322        .unwrap()
323        .login;
324        assert_eq!(login.meta.id, "123412341234");
325        assert_eq!(login.fields.form_action_origin, Some("".to_string()));
326        assert_eq!(login.fields.http_realm, None);
327        assert_eq!(login.fields.origin, "https://www.example.com");
328        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
329        assert_eq!(sec_fields.username, "user");
330        assert_eq!(sec_fields.password, "password");
331
332        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
333        assert_eq!(bso.envelope.id, "123412341234");
334        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
335        assert_eq!(payload_data["httpRealm"], serde_json::Value::Null);
336        assert_eq!(payload_data["formSubmitURL"], "".to_string());
337    }
338
339    #[test]
340    fn test_payload_unknown_fields() {
341        // No "unknown" fields.
342        let bso = IncomingBso::from_test_content(serde_json::json!({
343            "id": "123412341234",
344            "httpRealm": "test",
345            "hostname": "https://www.example.com",
346            "username": "user",
347            "password": "password",
348        }));
349        let payload = bso.into_content::<LoginPayload>().content().unwrap();
350        assert!(payload.unknown_fields.is_empty());
351
352        // An unknown "foo"
353        let bso = IncomingBso::from_test_content(serde_json::json!({
354            "id": "123412341234",
355            "httpRealm": "test",
356            "hostname": "https://www.example.com",
357            "username": "user",
358            "password": "password",
359            "foo": "bar",
360        }));
361        let payload = bso.into_content::<LoginPayload>().content().unwrap();
362        assert_eq!(payload.unknown_fields.len(), 1);
363        assert_eq!(
364            payload.unknown_fields.get("foo").unwrap().as_str().unwrap(),
365            "bar"
366        );
367        // re-serialize it.
368        let unknown = Some(encrypt_struct::<UnknownFields>(&payload.unknown_fields));
369        let login = IncomingLogin::from_incoming_payload(payload, &*TEST_ENCDEC)
370            .unwrap()
371            .login;
372        // The raw outgoing payload should have it back.
373        let outgoing = login.into_bso(&*TEST_ENCDEC, unknown).unwrap();
374        let json =
375            serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&outgoing.payload)
376                .unwrap();
377        assert_eq!(json.get("foo").unwrap().as_str().unwrap(), "bar");
378    }
379
380    #[test]
381    fn test_form_submit_payload_to_login() {
382        let bso = IncomingBso::from_test_content(serde_json::json!({
383            "id": "123412341234",
384            "hostname": "https://www.example.com",
385            "formSubmitURL": "https://www.example.com",
386            "usernameField": "username-field",
387            "username": "user",
388            "password": "password",
389        }));
390        let login = IncomingLogin::from_incoming_payload(
391            bso.into_content::<LoginPayload>().content().unwrap(),
392            &*TEST_ENCDEC,
393        )
394        .unwrap()
395        .login;
396        assert_eq!(login.meta.id, "123412341234");
397        assert_eq!(login.fields.http_realm, None);
398        assert_eq!(login.fields.origin, "https://www.example.com");
399        assert_eq!(
400            login.fields.form_action_origin,
401            Some("https://www.example.com".to_string())
402        );
403        assert_eq!(login.fields.username_field, "username-field");
404        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
405        assert_eq!(sec_fields.username, "user");
406        assert_eq!(sec_fields.password, "password");
407    }
408
409    #[test]
410    fn test_login_into_payload() {
411        let login = EncryptedLogin {
412            meta: LoginMeta {
413                id: "123412341234".into(),
414                ..Default::default()
415            },
416            fields: LoginFields {
417                http_realm: Some("test".into()),
418                origin: "https://www.example.com".into(),
419                ..Default::default()
420            },
421            sec_fields: encrypt_struct(&SecureLoginFields {
422                username: "user".into(),
423                password: "password".into(),
424            }),
425        };
426        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
427        assert_eq!(bso.envelope.id, "123412341234");
428        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
429        assert_eq!(payload_data["httpRealm"], "test".to_string());
430        assert_eq!(payload_data["hostname"], "https://www.example.com");
431        assert_eq!(payload_data["username"], "user");
432        assert_eq!(payload_data["password"], "password");
433        assert!(matches!(
434            payload_data["formActionOrigin"],
435            serde_json::Value::Null
436        ));
437    }
438
439    #[test]
440    fn test_username_field_requires_a_form_target() {
441        let bad_json = serde_json::json!({
442            "id": "123412341234",
443            "httpRealm": "test",
444            "hostname": "https://www.example.com",
445            "username": "test",
446            "password": "test",
447            "usernameField": "invalid"
448        });
449        let bad_bso = IncomingBso::from_test_content(bad_json.clone());
450
451        // Incoming sync data gets fixed automatically.
452        let login = IncomingLogin::from_incoming_payload(
453            bad_bso.into_content::<LoginPayload>().content().unwrap(),
454            &*TEST_ENCDEC,
455        )
456        .unwrap()
457        .login;
458        assert_eq!(login.fields.username_field, "");
459
460        // SyncLoginData::from_payload also fixes up.
461        let bad_bso = IncomingBso::from_test_content(bad_json);
462        let login = SyncLoginData::from_bso(bad_bso, &*TEST_ENCDEC)
463            .unwrap()
464            .inbound
465            .unwrap()
466            .login;
467        assert_eq!(login.fields.username_field, "");
468    }
469
470    #[test]
471    fn test_password_field_requires_a_form_target() {
472        let bad_bso = IncomingBso::from_test_content(serde_json::json!({
473            "id": "123412341234",
474            "httpRealm": "test",
475            "hostname": "https://www.example.com",
476            "username": "test",
477            "password": "test",
478            "passwordField": "invalid"
479        }));
480
481        let login = IncomingLogin::from_incoming_payload(
482            bad_bso.into_content::<LoginPayload>().content().unwrap(),
483            &*TEST_ENCDEC,
484        )
485        .unwrap()
486        .login;
487        assert_eq!(login.fields.password_field, "");
488    }
489}