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_last_breach_alert_dismissed: p.time_last_breach_alert_dismissed,
115                },
116                fields,
117                sec_fields,
118            },
119            unknown,
120        })
121    }
122}
123
124/// The JSON payload that lives on the storage servers.
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
126#[serde(rename_all = "camelCase")]
127pub struct LoginPayload {
128    #[serde(rename = "id")]
129    pub guid: Guid,
130
131    // This is 'origin' in our Login struct.
132    pub hostname: String,
133
134    // This is 'form_action_origin' in our Login struct.
135    // rename_all = "camelCase" by default will do formSubmitUrl, but we can just
136    // override this one field.
137    #[serde(rename = "formSubmitURL")]
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub form_submit_url: Option<String>,
140
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub http_realm: Option<String>,
143
144    #[serde(default)]
145    pub username: String,
146
147    pub password: String,
148
149    #[serde(default)]
150    pub username_field: String,
151
152    #[serde(default)]
153    pub password_field: String,
154
155    #[serde(default)]
156    #[serde(deserialize_with = "deserialize_timestamp")]
157    pub time_created: i64,
158
159    #[serde(default)]
160    #[serde(deserialize_with = "deserialize_timestamp")]
161    pub time_password_changed: i64,
162
163    #[serde(default)]
164    #[serde(deserialize_with = "deserialize_timestamp")]
165    pub time_last_used: i64,
166
167    #[serde(default)]
168    pub times_used: i64,
169
170    #[serde(default)]
171    #[serde(deserialize_with = "deserialize_optional_timestamp")]
172    pub time_last_breach_alert_dismissed: Option<i64>,
173
174    // Additional "unknown" round-tripped fields.
175    #[serde(flatten)]
176    unknown_fields: UnknownFields,
177}
178
179// These probably should be on the payload itself, but one refactor at a time!
180impl EncryptedLogin {
181    pub fn into_bso(
182        self,
183        encdec: &dyn EncryptorDecryptor,
184        enc_unknown_fields: Option<String>,
185    ) -> Result<OutgoingBso> {
186        let unknown_fields = match enc_unknown_fields {
187            Some(s) => UnknownFields::decrypt(&s, encdec)?,
188            None => Default::default(),
189        };
190        let sec_fields = SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)?;
191        Ok(OutgoingBso::from_content_with_id(
192            crate::sync::LoginPayload {
193                guid: self.guid(),
194                hostname: self.fields.origin,
195                form_submit_url: self.fields.form_action_origin,
196                http_realm: self.fields.http_realm,
197                username_field: self.fields.username_field,
198                password_field: self.fields.password_field,
199                username: sec_fields.username,
200                password: sec_fields.password,
201                time_created: self.meta.time_created,
202                time_password_changed: self.meta.time_password_changed,
203                time_last_used: self.meta.time_last_used,
204                times_used: self.meta.times_used,
205                time_last_breach_alert_dismissed: self.meta.time_last_breach_alert_dismissed,
206                unknown_fields,
207            },
208        )?)
209    }
210}
211
212// Quiet clippy, since this function is passed to deserialiaze_with...
213#[allow(clippy::unnecessary_wraps)]
214fn deserialize_timestamp<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
215where
216    D: serde::de::Deserializer<'de>,
217{
218    use serde::de::Deserialize;
219    // Invalid and negative timestamps are all replaced with 0. Eventually we
220    // should investigate replacing values that are unreasonable but still fit
221    // in an i64 (a date 1000 years in the future, for example), but
222    // appropriately handling that is complex.
223    Ok(i64::deserialize(deserializer).unwrap_or_default().max(0))
224}
225
226// Quiet clippy, since this function is passed to deserialiaze_with...
227#[allow(clippy::unnecessary_wraps)]
228fn deserialize_optional_timestamp<'de, D>(
229    deserializer: D,
230) -> std::result::Result<Option<i64>, D::Error>
231where
232    D: serde::de::Deserializer<'de>,
233{
234    use serde::de::Deserialize;
235    Ok(i64::deserialize(deserializer).ok())
236}
237
238#[cfg(not(feature = "keydb"))]
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use crate::encryption::test_utils::{encrypt_struct, TEST_ENCDEC};
243    use crate::sync::merge::SyncLoginData;
244    use crate::{EncryptedLogin, LoginFields, LoginMeta, SecureLoginFields};
245    use sync15::bso::IncomingBso;
246
247    #[test]
248    fn test_payload_to_login() {
249        let bso = IncomingBso::from_test_content(serde_json::json!({
250            "id": "123412341234",
251            "httpRealm": "test",
252            "hostname": "https://www.example.com",
253            "username": "user",
254            "password": "password",
255        }));
256        let login = IncomingLogin::from_incoming_payload(
257            bso.into_content::<LoginPayload>().content().unwrap(),
258            &*TEST_ENCDEC,
259        )
260        .unwrap()
261        .login;
262        assert_eq!(login.meta.id, "123412341234");
263        assert_eq!(login.fields.http_realm, Some("test".to_string()));
264        assert_eq!(login.fields.origin, "https://www.example.com");
265        assert_eq!(login.fields.form_action_origin, None);
266        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
267        assert_eq!(sec_fields.username, "user");
268        assert_eq!(sec_fields.password, "password");
269    }
270
271    // formSubmitURL (now formActionOrigin) being an empty string is a valid
272    // legacy case that is supported on desktop, we should ensure we are as well
273    // https://searchfox.org/mozilla-central/rev/32c74afbb24dce4b5dd6b33be71197e615631d71/toolkit/components/passwordmgr/test/unit/test_logins_change.js#183-184
274    #[test]
275    fn test_payload_empty_form_action_to_login() {
276        let bso = IncomingBso::from_test_content(serde_json::json!({
277            "id": "123412341234",
278            "formSubmitURL": "",
279            "hostname": "https://www.example.com",
280            "username": "user",
281            "password": "password",
282        }));
283        let login = IncomingLogin::from_incoming_payload(
284            bso.into_content::<LoginPayload>().content().unwrap(),
285            &*TEST_ENCDEC,
286        )
287        .unwrap()
288        .login;
289        assert_eq!(login.meta.id, "123412341234");
290        assert_eq!(login.fields.form_action_origin, Some("".to_string()));
291        assert_eq!(login.fields.http_realm, None);
292        assert_eq!(login.fields.origin, "https://www.example.com");
293        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
294        assert_eq!(sec_fields.username, "user");
295        assert_eq!(sec_fields.password, "password");
296
297        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
298        assert_eq!(bso.envelope.id, "123412341234");
299        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
300        assert_eq!(payload_data["httpRealm"], serde_json::Value::Null);
301        assert_eq!(payload_data["formSubmitURL"], "".to_string());
302    }
303
304    #[test]
305    fn test_payload_unknown_fields() {
306        // No "unknown" fields.
307        let bso = IncomingBso::from_test_content(serde_json::json!({
308            "id": "123412341234",
309            "httpRealm": "test",
310            "hostname": "https://www.example.com",
311            "username": "user",
312            "password": "password",
313        }));
314        let payload = bso.into_content::<LoginPayload>().content().unwrap();
315        assert!(payload.unknown_fields.is_empty());
316
317        // An unknown "foo"
318        let bso = IncomingBso::from_test_content(serde_json::json!({
319            "id": "123412341234",
320            "httpRealm": "test",
321            "hostname": "https://www.example.com",
322            "username": "user",
323            "password": "password",
324            "foo": "bar",
325        }));
326        let payload = bso.into_content::<LoginPayload>().content().unwrap();
327        assert_eq!(payload.unknown_fields.len(), 1);
328        assert_eq!(
329            payload.unknown_fields.get("foo").unwrap().as_str().unwrap(),
330            "bar"
331        );
332        // re-serialize it.
333        let unknown = Some(encrypt_struct::<UnknownFields>(&payload.unknown_fields));
334        let login = IncomingLogin::from_incoming_payload(payload, &*TEST_ENCDEC)
335            .unwrap()
336            .login;
337        // The raw outgoing payload should have it back.
338        let outgoing = login.into_bso(&*TEST_ENCDEC, unknown).unwrap();
339        let json =
340            serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&outgoing.payload)
341                .unwrap();
342        assert_eq!(json.get("foo").unwrap().as_str().unwrap(), "bar");
343    }
344
345    #[test]
346    fn test_form_submit_payload_to_login() {
347        let bso = IncomingBso::from_test_content(serde_json::json!({
348            "id": "123412341234",
349            "hostname": "https://www.example.com",
350            "formSubmitURL": "https://www.example.com",
351            "usernameField": "username-field",
352            "username": "user",
353            "password": "password",
354        }));
355        let login = IncomingLogin::from_incoming_payload(
356            bso.into_content::<LoginPayload>().content().unwrap(),
357            &*TEST_ENCDEC,
358        )
359        .unwrap()
360        .login;
361        assert_eq!(login.meta.id, "123412341234");
362        assert_eq!(login.fields.http_realm, None);
363        assert_eq!(login.fields.origin, "https://www.example.com");
364        assert_eq!(
365            login.fields.form_action_origin,
366            Some("https://www.example.com".to_string())
367        );
368        assert_eq!(login.fields.username_field, "username-field");
369        let sec_fields = login.decrypt_fields(&*TEST_ENCDEC).unwrap();
370        assert_eq!(sec_fields.username, "user");
371        assert_eq!(sec_fields.password, "password");
372    }
373
374    #[test]
375    fn test_login_into_payload() {
376        let login = EncryptedLogin {
377            meta: LoginMeta {
378                id: "123412341234".into(),
379                ..Default::default()
380            },
381            fields: LoginFields {
382                http_realm: Some("test".into()),
383                origin: "https://www.example.com".into(),
384                ..Default::default()
385            },
386            sec_fields: encrypt_struct(&SecureLoginFields {
387                username: "user".into(),
388                password: "password".into(),
389            }),
390        };
391        let bso = login.into_bso(&*TEST_ENCDEC, None).unwrap();
392        assert_eq!(bso.envelope.id, "123412341234");
393        let payload_data: serde_json::Value = serde_json::from_str(&bso.payload).unwrap();
394        assert_eq!(payload_data["httpRealm"], "test".to_string());
395        assert_eq!(payload_data["hostname"], "https://www.example.com");
396        assert_eq!(payload_data["username"], "user");
397        assert_eq!(payload_data["password"], "password");
398        assert!(matches!(
399            payload_data["formActionOrigin"],
400            serde_json::Value::Null
401        ));
402    }
403
404    #[test]
405    fn test_username_field_requires_a_form_target() {
406        let bad_json = serde_json::json!({
407            "id": "123412341234",
408            "httpRealm": "test",
409            "hostname": "https://www.example.com",
410            "username": "test",
411            "password": "test",
412            "usernameField": "invalid"
413        });
414        let bad_bso = IncomingBso::from_test_content(bad_json.clone());
415
416        // Incoming sync data gets fixed automatically.
417        let login = IncomingLogin::from_incoming_payload(
418            bad_bso.into_content::<LoginPayload>().content().unwrap(),
419            &*TEST_ENCDEC,
420        )
421        .unwrap()
422        .login;
423        assert_eq!(login.fields.username_field, "");
424
425        // SyncLoginData::from_payload also fixes up.
426        let bad_bso = IncomingBso::from_test_content(bad_json);
427        let login = SyncLoginData::from_bso(bad_bso, &*TEST_ENCDEC)
428            .unwrap()
429            .inbound
430            .unwrap()
431            .login;
432        assert_eq!(login.fields.username_field, "");
433    }
434
435    #[test]
436    fn test_password_field_requires_a_form_target() {
437        let bad_bso = IncomingBso::from_test_content(serde_json::json!({
438            "id": "123412341234",
439            "httpRealm": "test",
440            "hostname": "https://www.example.com",
441            "username": "test",
442            "password": "test",
443            "passwordField": "invalid"
444        }));
445
446        let login = IncomingLogin::from_incoming_payload(
447            bad_bso.into_content::<LoginPayload>().content().unwrap(),
448            &*TEST_ENCDEC,
449        )
450        .unwrap()
451        .login;
452        assert_eq!(login.fields.password_field, "");
453    }
454}