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