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        };
74        let original_sec_fields = SecureLoginFields {
75            username: p.username,
76            password: p.password,
77        };
78        // we do a bit of a dance here to maybe_fixup() the fields via LoginEntry
79        let original_login_entry = LoginEntry::new(original_fields, original_sec_fields);
80        let login_entry = original_login_entry
81            .maybe_fixup()?
82            .unwrap_or(original_login_entry);
83        let fields = LoginFields {
84            origin: login_entry.origin,
85            form_action_origin: login_entry.form_action_origin,
86            http_realm: login_entry.http_realm,
87            username_field: login_entry.username_field,
88            password_field: login_entry.password_field,
89        };
90        let id = String::from(p.guid);
91        let sec_fields = SecureLoginFields {
92            username: login_entry.username,
93            password: login_entry.password,
94        }
95        .encrypt(encdec, &id)?;
96
97        // We handle NULL in the DB for migrated databases and it's wasteful
98        // to encrypt the common case of an empty map, so...
99        let unknown = if p.unknown_fields.is_empty() {
100            None
101        } else {
102            Some(p.unknown_fields.encrypt(encdec)?)
103        };
104
105        // If we can't fix the parts we keep the invalid bits.
106        Ok(Self {
107            login: EncryptedLogin {
108                meta: LoginMeta {
109                    id,
110                    time_created: p.time_created,
111                    time_password_changed: p.time_password_changed,
112                    time_last_used: p.time_last_used,
113                    times_used: p.times_used,
114                    time_of_last_breach: p.time_of_last_breach,
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_of_last_breach: Option<i64>,
174
175    #[serde(default)]
176    #[serde(deserialize_with = "deserialize_optional_timestamp")]
177    pub time_last_breach_alert_dismissed: Option<i64>,
178
179    // Additional "unknown" round-tripped fields.
180    #[serde(flatten)]
181    unknown_fields: UnknownFields,
182}
183
184// These probably should be on the payload itself, but one refactor at a time!
185impl EncryptedLogin {
186    pub fn into_bso(
187        self,
188        encdec: &dyn EncryptorDecryptor,
189        enc_unknown_fields: Option<String>,
190    ) -> Result<OutgoingBso> {
191        let unknown_fields = match enc_unknown_fields {
192            Some(s) => UnknownFields::decrypt(&s, encdec)?,
193            None => Default::default(),
194        };
195        let sec_fields = SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)?;
196        Ok(OutgoingBso::from_content_with_id(
197            crate::sync::LoginPayload {
198                guid: self.guid(),
199                hostname: self.fields.origin,
200                form_submit_url: self.fields.form_action_origin,
201                http_realm: self.fields.http_realm,
202                username_field: self.fields.username_field,
203                password_field: self.fields.password_field,
204                username: sec_fields.username,
205                password: sec_fields.password,
206                time_created: self.meta.time_created,
207                time_password_changed: self.meta.time_password_changed,
208                time_last_used: self.meta.time_last_used,
209                times_used: self.meta.times_used,
210                time_of_last_breach: self.meta.time_of_last_breach,
211                time_last_breach_alert_dismissed: self.meta.time_last_breach_alert_dismissed,
212                unknown_fields,
213            },
214        )?)
215    }
216}
217
218// Quiet clippy, since this function is passed to deserialiaze_with...
219#[allow(clippy::unnecessary_wraps)]
220fn deserialize_timestamp<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
221where
222    D: serde::de::Deserializer<'de>,
223{
224    use serde::de::Deserialize;
225    // Invalid and negative timestamps are all replaced with 0. Eventually we
226    // should investigate replacing values that are unreasonable but still fit
227    // in an i64 (a date 1000 years in the future, for example), but
228    // appropriately handling that is complex.
229    Ok(i64::deserialize(deserializer).unwrap_or_default().max(0))
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())
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    // formSubmitURL (now formActionOrigin) being an empty string is a valid
278    // legacy case that is supported on desktop, we should ensure we are as well
279    // https://searchfox.org/mozilla-central/rev/32c74afbb24dce4b5dd6b33be71197e615631d71/toolkit/components/passwordmgr/test/unit/test_logins_change.js#183-184
280    #[test]
281    fn test_payload_empty_form_action_to_login() {
282        let bso = IncomingBso::from_test_content(serde_json::json!({
283            "id": "123412341234",
284            "formSubmitURL": "",
285            "hostname": "https://www.example.com",
286            "username": "user",
287            "password": "password",
288        }));
289        let login = IncomingLogin::from_incoming_payload(
290            bso.into_content::<LoginPayload>().content().unwrap(),
291            &*TEST_ENCDEC,
292        )
293        .unwrap()
294        .login;
295        assert_eq!(login.meta.id, "123412341234");
296        assert_eq!(login.fields.form_action_origin, Some("".to_string()));
297        assert_eq!(login.fields.http_realm, None);
298        assert_eq!(login.fields.origin, "https://www.example.com");
299        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
300        assert_eq!(sec_fields.username, "user");
301        assert_eq!(sec_fields.password, "password");
302
303        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
304        assert_eq!(bso.envelope.id, "123412341234");
305        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
306        assert_eq!(payload_data["httpRealm"], serde_json::Value::Null);
307        assert_eq!(payload_data["formSubmitURL"], "".to_string());
308    }
309
310    #[test]
311    fn test_payload_unknown_fields() {
312        // No "unknown" fields.
313        let bso = IncomingBso::from_test_content(serde_json::json!({
314            "id": "123412341234",
315            "httpRealm": "test",
316            "hostname": "https://www.example.com",
317            "username": "user",
318            "password": "password",
319        }));
320        let payload = bso.into_content::<LoginPayload>().content().unwrap();
321        assert!(payload.unknown_fields.is_empty());
322
323        // An unknown "foo"
324        let bso = IncomingBso::from_test_content(serde_json::json!({
325            "id": "123412341234",
326            "httpRealm": "test",
327            "hostname": "https://www.example.com",
328            "username": "user",
329            "password": "password",
330            "foo": "bar",
331        }));
332        let payload = bso.into_content::<LoginPayload>().content().unwrap();
333        assert_eq!(payload.unknown_fields.len(), 1);
334        assert_eq!(
335            payload.unknown_fields.get("foo").unwrap().as_str().unwrap(),
336            "bar"
337        );
338        // re-serialize it.
339        let unknown = Some(encrypt_struct::<UnknownFields>(&payload.unknown_fields));
340        let login = IncomingLogin::from_incoming_payload(payload, &*TEST_ENCDEC)
341            .unwrap()
342            .login;
343        // The raw outgoing payload should have it back.
344        let outgoing = login.into_bso(&*TEST_ENCDEC, unknown).unwrap();
345        let json =
346            serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&outgoing.payload)
347                .unwrap();
348        assert_eq!(json.get("foo").unwrap().as_str().unwrap(), "bar");
349    }
350
351    #[test]
352    fn test_form_submit_payload_to_login() {
353        let bso = IncomingBso::from_test_content(serde_json::json!({
354            "id": "123412341234",
355            "hostname": "https://www.example.com",
356            "formSubmitURL": "https://www.example.com",
357            "usernameField": "username-field",
358            "username": "user",
359            "password": "password",
360        }));
361        let login = IncomingLogin::from_incoming_payload(
362            bso.into_content::<LoginPayload>().content().unwrap(),
363            &*TEST_ENCDEC,
364        )
365        .unwrap()
366        .login;
367        assert_eq!(login.meta.id, "123412341234");
368        assert_eq!(login.fields.http_realm, None);
369        assert_eq!(login.fields.origin, "https://www.example.com");
370        assert_eq!(
371            login.fields.form_action_origin,
372            Some("https://www.example.com".to_string())
373        );
374        assert_eq!(login.fields.username_field, "username-field");
375        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
376        assert_eq!(sec_fields.username, "user");
377        assert_eq!(sec_fields.password, "password");
378    }
379
380    #[test]
381    fn test_login_into_payload() {
382        let login = EncryptedLogin {
383            meta: LoginMeta {
384                id: "123412341234".into(),
385                ..Default::default()
386            },
387            fields: LoginFields {
388                http_realm: Some("test".into()),
389                origin: "https://www.example.com".into(),
390                ..Default::default()
391            },
392            sec_fields: encrypt_struct(&SecureLoginFields {
393                username: "user".into(),
394                password: "password".into(),
395            }),
396        };
397        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
398        assert_eq!(bso.envelope.id, "123412341234");
399        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
400        assert_eq!(payload_data["httpRealm"], "test".to_string());
401        assert_eq!(payload_data["hostname"], "https://www.example.com");
402        assert_eq!(payload_data["username"], "user");
403        assert_eq!(payload_data["password"], "password");
404        assert!(matches!(
405            payload_data["formActionOrigin"],
406            serde_json::Value::Null
407        ));
408    }
409
410    #[test]
411    fn test_username_field_requires_a_form_target() {
412        let bad_json = serde_json::json!({
413            "id": "123412341234",
414            "httpRealm": "test",
415            "hostname": "https://www.example.com",
416            "username": "test",
417            "password": "test",
418            "usernameField": "invalid"
419        });
420        let bad_bso = IncomingBso::from_test_content(bad_json.clone());
421
422        // Incoming sync data gets fixed automatically.
423        let login = IncomingLogin::from_incoming_payload(
424            bad_bso.into_content::<LoginPayload>().content().unwrap(),
425            &*TEST_ENCDEC,
426        )
427        .unwrap()
428        .login;
429        assert_eq!(login.fields.username_field, "");
430
431        // SyncLoginData::from_payload also fixes up.
432        let bad_bso = IncomingBso::from_test_content(bad_json);
433        let login = SyncLoginData::from_bso(bad_bso, &*TEST_ENCDEC)
434            .unwrap()
435            .inbound
436            .unwrap()
437            .login;
438        assert_eq!(login.fields.username_field, "");
439    }
440
441    #[test]
442    fn test_password_field_requires_a_form_target() {
443        let bad_bso = IncomingBso::from_test_content(serde_json::json!({
444            "id": "123412341234",
445            "httpRealm": "test",
446            "hostname": "https://www.example.com",
447            "username": "test",
448            "password": "test",
449            "passwordField": "invalid"
450        }));
451
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.password_field, "");
459    }
460}