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