logins/
login.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//  N.B. if you're making a documentation change here, you might also want to make it in:
6//
7//    * The API docs in ../ios/Logins/LoginRecord.swift
8//    * The API docs in ../android/src/main/java/mozilla/appservices/logins/ServerPassword.kt
9//    * The android-components docs at
10//      https://github.com/mozilla-mobile/android-components/tree/master/components/service/sync-logins
11//
12//  We'll figure out a more scalable approach to maintaining all those docs at some point...
13
14//! # Login Structs
15//!
16//! This module defines a number of core structs for Logins. They are:
17//! * [`LoginEntry`] A login entry by the user.  This includes the username/password, the site it
18//!   was submitted to, etc.  [`LoginEntry`] does not store data specific to a DB record.
19//! * [`Login`] - A [`LoginEntry`] plus DB record information.  This includes the GUID and metadata
20//!   like time_last_used.
21//! * [`EncryptedLogin`] -- A Login above with the username/password data encrypted.
22//! * [`LoginFields`], [`SecureLoginFields`], [`LoginMeta`] -- These group the common fields in the
23//!   structs above.
24//!
25//! Why so many structs for similar data?  Consider some common use cases in a hypothetical browser
26//! (currently no browsers act exactly like this, although Fenix/android-components comes close):
27//!
28//! - User visits a page with a login form.
29//!   - We inform the user if there are saved logins that can be autofilled.  We use the
30//!     `LoginDb.get_by_base_domain()` which returns a `Vec<EncryptedLogin>`.  We don't decrypt the
31//!     logins because we want to avoid requiring the encryption key at this point, which would
32//!     force the user to authenticate.  Note: this is aspirational at this point, no actual
33//!     implementations follow this flow.  Still, we want application-services to support it.
34//!   - If the user chooses to autofill, we decrypt the logins into a `Vec<Login>`.  We need to
35//!     decrypt at this point to display the username and autofill the password if they select one.
36//!   - When the user selects a login, we can use the already decrypted data from `Login` to fill
37//!     in the form.
38//! - User chooses to save a login for autofilling later.
39//!    - We present the user with a dialog that:
40//!       - Displays a header that differentiates between different types of save: adding a new
41//!         login, updating an existing login, filling in a blank username, etc.
42//!       - Allows the user to tweak the username, in case we failed to detect the form field
43//!         correctly.  This may affect which header should be shown.
44//!    - Here we use `find_login_to_update()` which returns an `Option<Login>`.  Returning a login
45//!      that has decrypted data avoids forcing the consumer code to decrypt the username again.
46//!
47//! # Login
48//! This has the complete set of data about a login. Very closely related is the
49//! "sync payload", defined in sync/payload.rs, which handles all aspects of the JSON serialization.
50//! It contains the following fields:
51//! - `meta`: A [`LoginMeta`] struct.
52//! - fields: A [`LoginFields`] struct.
53//! - sec_fields: A [`SecureLoginFields`] struct.
54//!
55//! # LoginEntry
56//! The struct used to add or update logins. This has the plain-text version of the fields that are
57//! stored encrypted, so almost all uses of an LoginEntry struct will also require the
58//! encryption key to be known and passed in.    [LoginDB] methods that save data typically input
59//! [LoginEntry] instances.  This allows the DB code to handle dupe-checking issues like
60//! determining which login record should be updated for a newly submitted [LoginEntry].
61//! It contains the following fields:
62//! - fields: A [`LoginFields`] struct.
63//! - sec_fields: A [`SecureLoginFields`] struct.
64//!
65//! # EncryptedLogin
66//! Encrypted version of [`Login`].  [LoginDB] methods that return data typically return [EncryptedLogin]
67//! this allows deferring decryption, and therefore user authentication, until the secure data is needed.
68//! It contains the following fields
69//! - `meta`: A [`LoginMeta`] struct.
70//! - `fields`: A [`LoginFields`] struct.
71//! - `sec_fields`: The secure fields as an encrypted string
72//!
73//! # SecureLoginFields
74//! The struct used to hold the fields which are stored encrypted. It contains:
75//! - username: A string.
76//! - password: A string.
77//!
78//! # LoginFields
79//!
80//! The core set of fields, use by both [`Login`] and [`LoginEntry`]
81//! It contains the following fields:
82//!
83//! - `origin`:  The origin at which this login can be used, as a string.
84//!
85//!   The login should only be used on sites that match this origin (for whatever definition
86//!   of "matches" makes sense at the application level, e.g. eTLD+1 matching).
87//!   This field is required, must be a valid origin in punycode format, and must not be
88//!   set to the empty string.
89//!
90//!   Examples of valid `origin` values include:
91//!   - "https://site.com"
92//!   - "http://site.com:1234"
93//!   - "ftp://ftp.site.com"
94//!   - "moz-proxy://127.0.0.1:8888"
95//!   - "chrome://MyLegacyExtension"
96//!   - "file://"
97//!   - "https://\[::1\]"
98//!
99//!   If invalid data is received in this field (either from the application, or via sync)
100//!   then the logins store will attempt to coerce it into valid data by:
101//!   - truncating full URLs to just their origin component, if it is not an opaque origin
102//!   - converting values with non-ascii characters into punycode
103//!
104//!   **XXX TODO:**
105//!   - Add a field with the original unicode versions of the URLs instead of punycode?
106//!
107//! - `sec_fields`: The `username` and `password` for the site, stored as a encrypted JSON
108//!   representation of an `SecureLoginFields`.
109//!
110//!   This field is required and usually encrypted.  There are two different value types:
111//!   - Plaintext empty string: Used for deleted records
112//!   - Encrypted value: The credentials associated with the login.
113//!
114//! - `http_realm`:  The challenge string for HTTP Basic authentication, if any.
115//!
116//!   If present, the login should only be used in response to a HTTP Basic Auth
117//!   challenge that specifies a matching realm. For legacy reasons this string may not
118//!   contain null bytes, carriage returns or newlines.
119//!
120//!   If this field is set to the empty string, this indicates a wildcard match on realm.
121//!
122//!   This field must not be present if `form_action_origin` is set, since they indicate different types
123//!   of login (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin`
124//!   must be present.
125//!
126//! - `form_action_origin`:  The target origin of forms in which this login can be used, if any, as a string.
127//!
128//!   If present, the login should only be used in forms whose target submission URL matches this origin.
129//!   This field must be a valid origin or one of the following special cases:
130//!   - An empty string, which is a wildcard match for any origin.
131//!   - The single character ".", which is equivalent to the empty string
132//!   - The string "javascript:", which matches any form with javascript target URL.
133//!
134//!   This field must not be present if `http_realm` is set, since they indicate different types of login
135//!   (HTTP-Auth based versus form-based). Exactly one of `http_realm` and `form_action_origin` must be present.
136//!
137//!   If invalid data is received in this field (either from the application, or via sync) then the
138//!   logins store will attempt to coerce it into valid data by:
139//!   - truncating full URLs to just their origin component
140//!   - converting origins with non-ascii characters into punycode
141//!   - replacing invalid values with null if a valid 'http_realm' field is present
142//!
143//! - `username_field`:  The name of the form field into which the 'username' should be filled, if any.
144//!
145//!   This value is stored if provided by the application, but does not imply any restrictions on
146//!   how the login may be used in practice. For legacy reasons this string may not contain null
147//!   bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
148//!
149//!   If invalid data is received in this field (either from the application, or via sync)
150//!   then the logins store will attempt to coerce it into valid data by:
151//!   - setting to the empty string if 'form_action_origin' is not present
152//!
153//! - `password_field`:  The name of the form field into which the 'password' should be filled, if any.
154//!
155//!   This value is stored if provided by the application, but does not imply any restrictions on
156//!   how the login may be used in practice. For legacy reasons this string may not contain null
157//!   bytes, carriage returns or newlines. This field must be empty unless `form_action_origin` is set.
158//!
159//!   If invalid data is received in this field (either from the application, or via sync)
160//!   then the logins store will attempt to coerce it into valid data by:
161//!   - setting to the empty string if 'form_action_origin' is not present
162//!
163//! # LoginMeta
164//!
165//! This contains data relating to the login database record -- both on the local instance and
166//! synced to other browsers.
167//! It contains the following fields:
168//! - `id`:  A unique string identifier for this record.
169//!
170//!   Consumers may assume that `id` contains only "safe" ASCII characters but should otherwise
171//!   treat this it as an opaque identifier. These are generated as needed.
172//!
173//! - `timesUsed`:  A lower bound on the number of times the password from this record has been used, as an integer.
174//!
175//!   Applications should use the `touch()` method of the logins store to indicate when a password
176//!   has been used, and should ensure that they only count uses of the actual `password` field
177//!   (so for example, copying the `password` field to the clipboard should count as a "use", but
178//!   copying just the `username` field should not).
179//!
180//!   This number may not record uses that occurred on other devices, since some legacy
181//!   sync clients do not record this information. It may be zero for records obtained
182//!   via sync that have never been used locally.
183//!
184//!   When merging duplicate records, the two usage counts are summed.
185//!
186//!   This field is managed internally by the logins store by default and does not need to
187//!   be set explicitly, although any application-provided value will be preserved when creating
188//!   a new record.
189//!
190//!   If invalid data is received in this field (either from the application, or via sync)
191//!   then the logins store will attempt to coerce it into valid data by:
192//!   - replacing missing or negative values with 0
193//!
194//!   **XXX TODO:**
195//!   - test that we prevent this counter from moving backwards.
196//!   - test fixups of missing or negative values
197//!   - test that we correctly merge dupes
198//!
199//! - `time_created`: An upper bound on the time of creation of this login, in integer milliseconds from the unix epoch.
200//!
201//!   This is an upper bound because some legacy sync clients do not record this information.
202//!
203//!   Note that this field is typically a timestamp taken from the local machine clock, so it
204//!   may be wildly inaccurate if the client does not have an accurate clock.
205//!
206//!   This field is managed internally by the logins store by default and does not need to
207//!   be set explicitly, although any application-provided value will be preserved when creating
208//!   a new record.
209//!
210//!   When merging duplicate records, the smallest non-zero value is taken.
211//!
212//!   If invalid data is received in this field (either from the application, or via sync)
213//!   then the logins store will attempt to coerce it into valid data by:
214//!   - replacing missing or negative values with the current time
215//!
216//!   **XXX TODO:**
217//!   - test that we prevent this timestamp from moving backwards.
218//!   - test fixups of missing or negative values
219//!   - test that we correctly merge dupes
220//!
221//! - `time_last_used`: A lower bound on the time of last use of this login, in integer milliseconds from the unix epoch.
222//!
223//!   This is a lower bound because some legacy sync clients do not record this information;
224//!   in that case newer clients set `timeLastUsed` when they use the record for the first time.
225//!
226//!   Note that this field is typically a timestamp taken from the local machine clock, so it
227//!   may be wildly inaccurate if the client does not have an accurate clock.
228//!
229//!   This field is managed internally by the logins store by default and does not need to
230//!   be set explicitly, although any application-provided value will be preserved when creating
231//!   a new record.
232//!
233//!   When merging duplicate records, the largest non-zero value is taken.
234//!
235//!   If invalid data is received in this field (either from the application, or via sync)
236//!   then the logins store will attempt to coerce it into valid data by:
237//!   - removing negative values
238//!
239//!   **XXX TODO:**
240//!   - test that we prevent this timestamp from moving backwards.
241//!   - test fixups of missing or negative values
242//!   - test that we correctly merge dupes
243//!
244//! - `time_password_changed`: A lower bound on the time that the `password` field was last changed, in integer
245//!   milliseconds from the unix epoch.
246//!
247//!   Changes to other fields (such as `username`) are not reflected in this timestamp.
248//!   This is a lower bound because some legacy sync clients do not record this information;
249//!   in that case newer clients set `time_password_changed` when they change the `password` field.
250//!
251//!   Note that this field is typically a timestamp taken from the local machine clock, so it
252//!   may be wildly inaccurate if the client does not have an accurate clock.
253//!
254//!   This field is managed internally by the logins store by default and does not need to
255//!   be set explicitly, although any application-provided value will be preserved when creating
256//!   a new record.
257//!
258//!   When merging duplicate records, the largest non-zero value is taken.
259//!
260//!   If invalid data is received in this field (either from the application, or via sync)
261//!   then the logins store will attempt to coerce it into valid data by:
262//!   - removing negative values
263//!
264//!   **XXX TODO:**
265//!   - test that we prevent this timestamp from moving backwards.
266//!   - test that we don't set this for changes to other fields.
267//!   - test that we correctly merge dupes
268//!
269//!
270//! In order to deal with data from legacy clients in a robust way, it is necessary to be able to build
271//! and manipulate all these `Login` structs that contain invalid data.  The non-encrypted structs
272//! implement the `ValidateAndFixup` trait, providing the following methods which can be used by
273//! callers to ensure that they're only working with valid records:
274//!
275//! - `Login::check_valid()`:    Checks validity of a login record, returning `()` if it is valid
276//!   or an error if it is not.
277//!
278//! - `Login::fixup()`:   Returns either the existing login if it is valid, a clone with invalid fields
279//!   fixed up if it was safe to do so, or an error if the login is irreparably invalid.
280
281use crate::{encryption::EncryptorDecryptor, error::*};
282use rusqlite::Row;
283use serde_derive::*;
284use sync_guid::Guid;
285use url::Url;
286
287// LoginEntry fields that are stored in cleartext
288#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
289pub struct LoginFields {
290    pub origin: String,
291    pub form_action_origin: Option<String>,
292    pub http_realm: Option<String>,
293    pub username_field: String,
294    pub password_field: String,
295    pub time_of_last_breach: Option<i64>,
296    pub time_last_breach_alert_dismissed: Option<i64>,
297}
298
299/// LoginEntry fields that are stored encrypted
300#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize, Default)]
301pub struct SecureLoginFields {
302    // - Username cannot be null, use the empty string instead
303    // - Password can't be empty or null (enforced in the ValidateAndFixup code)
304    //
305    // This matches the desktop behavior:
306    // https://searchfox.org/mozilla-central/rev/d3683dbb252506400c71256ef3994cdbdfb71ada/toolkit/components/passwordmgr/LoginManager.jsm#260-267
307
308    // Because we store the json version of this in the DB, and that's the only place the json
309    // is used, we rename the fields to short names, just to reduce the overhead in the DB.
310    #[serde(rename = "u")]
311    pub username: String,
312    #[serde(rename = "p")]
313    pub password: String,
314}
315
316impl SecureLoginFields {
317    pub fn encrypt(&self, encdec: &dyn EncryptorDecryptor, login_id: &str) -> Result<String> {
318        let string = serde_json::to_string(&self)?;
319        let cipherbytes = encdec
320            .encrypt(string.as_bytes().into())
321            .map_err(|e| Error::EncryptionFailed(format!("{e} (encrypting {login_id})")))?;
322        let ciphertext = std::str::from_utf8(&cipherbytes).map_err(|e| {
323            Error::EncryptionFailed(format!("{e} (encrypting {login_id}: data not utf8)"))
324        })?;
325        Ok(ciphertext.to_owned())
326    }
327
328    pub fn decrypt(
329        ciphertext: &str,
330        encdec: &dyn EncryptorDecryptor,
331        login_id: &str,
332    ) -> Result<Self> {
333        let jsonbytes = encdec
334            .decrypt(ciphertext.as_bytes().into())
335            .map_err(|e| Error::DecryptionFailed(format!("{e} (decrypting {login_id})")))?;
336        let json =
337            std::str::from_utf8(&jsonbytes).map_err(|e| Error::DecryptionFailed(e.to_string()))?;
338        Ok(serde_json::from_str(json)?)
339    }
340}
341
342/// Login data specific to database records
343#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
344pub struct LoginMeta {
345    pub id: String,
346    pub time_created: i64,
347    pub time_password_changed: i64,
348    pub time_last_used: i64,
349    pub times_used: i64,
350}
351
352/// A login together with meta fields, handed over to the store API; ie a login persisted
353/// elsewhere, useful for migrations
354pub struct LoginEntryWithMeta {
355    pub entry: LoginEntry,
356    pub meta: LoginMeta,
357}
358
359/// A bulk insert result entry, returned by `add_many` and `add_many_with_records`
360/// Please note that although the success case is much larger than the error case, this is
361/// negligible in real life, as we expect a very small success/error ratio.
362#[allow(clippy::large_enum_variant)]
363pub enum BulkResultEntry {
364    Success { login: Login },
365    Error { message: String },
366}
367
368/// A login handed over to the store API; ie a login not yet persisted
369#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
370pub struct LoginEntry {
371    // login fields
372    pub origin: String,
373    pub form_action_origin: Option<String>,
374    pub http_realm: Option<String>,
375    pub username_field: String,
376    pub password_field: String,
377
378    // secure fields
379    pub username: String,
380    pub password: String,
381}
382
383impl LoginEntry {
384    pub fn new(fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
385        Self {
386            origin: fields.origin,
387            form_action_origin: fields.form_action_origin,
388            http_realm: fields.http_realm,
389            username_field: fields.username_field,
390            password_field: fields.password_field,
391
392            username: sec_fields.username,
393            password: sec_fields.password,
394        }
395    }
396
397    /// Helper for validation and fixups of an "origin" provided as a string.
398    pub fn validate_and_fixup_origin(origin: &str) -> Result<Option<String>> {
399        // Check we can parse the origin, then use the normalized version of it.
400        match Url::parse(origin) {
401            Ok(mut u) => {
402                // Presumably this is a faster path than always setting?
403                if u.path() != "/"
404                    || u.fragment().is_some()
405                    || u.query().is_some()
406                    || u.username() != "/"
407                    || u.password().is_some()
408                {
409                    // Not identical - we only want the origin part, so kill
410                    // any other parts which may exist.
411                    // But first special case `file://` URLs which always
412                    // resolve to `file://`
413                    if u.scheme() == "file" {
414                        return Ok(if origin == "file://" {
415                            None
416                        } else {
417                            Some("file://".into())
418                        });
419                    }
420                    u.set_path("");
421                    u.set_fragment(None);
422                    u.set_query(None);
423                    let _ = u.set_username("");
424                    let _ = u.set_password(None);
425                    let mut href = String::from(u);
426                    // We always store without the trailing "/" which Urls have.
427                    if href.ends_with('/') {
428                        href.pop().expect("url must have a length");
429                    }
430                    if origin != href {
431                        // Needs to be fixed up.
432                        return Ok(Some(href));
433                    }
434                }
435                Ok(None)
436            }
437            Err(e) => {
438                breadcrumb!(
439                    "Error parsing login origin: {e:?} ({})",
440                    error_support::redact_url(origin)
441                );
442                // We can't fixup completely invalid records, so always throw.
443                Err(InvalidLogin::IllegalOrigin {
444                    reason: e.to_string(),
445                }
446                .into())
447            }
448        }
449    }
450}
451
452/// A login handed over from the store API, which has been persisted and contains persistence
453/// information such as id and time stamps
454#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
455pub struct Login {
456    // meta fields
457    pub id: String,
458    pub time_created: i64,
459    pub time_password_changed: i64,
460    pub time_last_used: i64,
461    pub times_used: i64,
462
463    // login fields
464    pub origin: String,
465    pub form_action_origin: Option<String>,
466    pub http_realm: Option<String>,
467    pub username_field: String,
468    pub password_field: String,
469
470    // secure fields
471    pub username: String,
472    pub password: String,
473
474    // breach alerts
475    pub time_of_last_breach: Option<i64>,
476    pub time_last_breach_alert_dismissed: Option<i64>,
477}
478
479impl Login {
480    pub fn new(meta: LoginMeta, fields: LoginFields, sec_fields: SecureLoginFields) -> Self {
481        Self {
482            id: meta.id,
483            time_created: meta.time_created,
484            time_password_changed: meta.time_password_changed,
485            time_last_used: meta.time_last_used,
486            times_used: meta.times_used,
487
488            origin: fields.origin,
489            form_action_origin: fields.form_action_origin,
490            http_realm: fields.http_realm,
491            username_field: fields.username_field,
492            password_field: fields.password_field,
493
494            username: sec_fields.username,
495            password: sec_fields.password,
496
497            time_of_last_breach: fields.time_last_breach_alert_dismissed,
498            time_last_breach_alert_dismissed: fields.time_last_breach_alert_dismissed,
499        }
500    }
501
502    #[inline]
503    pub fn guid(&self) -> Guid {
504        Guid::from_string(self.id.clone())
505    }
506
507    pub fn entry(&self) -> LoginEntry {
508        LoginEntry {
509            origin: self.origin.clone(),
510            form_action_origin: self.form_action_origin.clone(),
511            http_realm: self.http_realm.clone(),
512            username_field: self.username_field.clone(),
513            password_field: self.password_field.clone(),
514
515            username: self.username.clone(),
516            password: self.password.clone(),
517        }
518    }
519
520    pub fn encrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<EncryptedLogin> {
521        let sec_fields = SecureLoginFields {
522            username: self.username,
523            password: self.password,
524        }
525        .encrypt(encdec, &self.id)?;
526        Ok(EncryptedLogin {
527            meta: LoginMeta {
528                id: self.id,
529                time_created: self.time_created,
530                time_password_changed: self.time_password_changed,
531                time_last_used: self.time_last_used,
532                times_used: self.times_used,
533            },
534            fields: LoginFields {
535                origin: self.origin,
536                form_action_origin: self.form_action_origin,
537                http_realm: self.http_realm,
538                username_field: self.username_field,
539                password_field: self.password_field,
540                time_of_last_breach: self.time_last_breach_alert_dismissed,
541                time_last_breach_alert_dismissed: self.time_last_breach_alert_dismissed,
542            },
543            sec_fields,
544        })
545    }
546}
547
548/// A login stored in the database
549#[derive(Debug, Clone, Hash, PartialEq, Eq, Default)]
550pub struct EncryptedLogin {
551    pub meta: LoginMeta,
552    pub fields: LoginFields,
553    pub sec_fields: String,
554}
555
556impl EncryptedLogin {
557    #[inline]
558    pub fn guid(&self) -> Guid {
559        Guid::from_string(self.meta.id.clone())
560    }
561
562    // TODO: Remove this: https://github.com/mozilla/application-services/issues/4185
563    #[inline]
564    pub fn guid_str(&self) -> &str {
565        &self.meta.id
566    }
567
568    pub fn decrypt(self, encdec: &dyn EncryptorDecryptor) -> Result<Login> {
569        let sec_fields = self.decrypt_fields(encdec)?;
570        Ok(Login::new(self.meta, self.fields, sec_fields))
571    }
572
573    pub fn decrypt_fields(&self, encdec: &dyn EncryptorDecryptor) -> Result<SecureLoginFields> {
574        SecureLoginFields::decrypt(&self.sec_fields, encdec, &self.meta.id)
575    }
576
577    pub(crate) fn from_row(row: &Row<'_>) -> Result<EncryptedLogin> {
578        let login = EncryptedLogin {
579            meta: LoginMeta {
580                id: row.get("guid")?,
581                time_created: row.get("timeCreated")?,
582                // Might be null
583                time_last_used: row
584                    .get::<_, Option<i64>>("timeLastUsed")?
585                    .unwrap_or_default(),
586
587                time_password_changed: row.get("timePasswordChanged")?,
588                times_used: row.get("timesUsed")?,
589            },
590            fields: LoginFields {
591                origin: row.get("origin")?,
592                http_realm: row.get("httpRealm")?,
593
594                form_action_origin: row.get("formActionOrigin")?,
595
596                username_field: string_or_default(row, "usernameField")?,
597                password_field: string_or_default(row, "passwordField")?,
598
599                time_of_last_breach: row.get::<_, Option<i64>>("timeOfLastBreach")?,
600                time_last_breach_alert_dismissed: row
601                    .get::<_, Option<i64>>("timeLastBreachAlertDismissed")?,
602            },
603            sec_fields: row.get("secFields")?,
604        };
605        // XXX - we used to perform a fixup here, but that seems heavy-handed
606        // and difficult - we now only do that on add/insert when we have the
607        // encryption key.
608        Ok(login)
609    }
610}
611
612fn string_or_default(row: &Row<'_>, col: &str) -> Result<String> {
613    Ok(row.get::<_, Option<String>>(col)?.unwrap_or_default())
614}
615
616pub trait ValidateAndFixup {
617    // Our validate and fixup functions.
618    fn check_valid(&self) -> Result<()>
619    where
620        Self: Sized,
621    {
622        self.validate_and_fixup(false)?;
623        Ok(())
624    }
625
626    fn fixup(self) -> Result<Self>
627    where
628        Self: Sized,
629    {
630        match self.maybe_fixup()? {
631            None => Ok(self),
632            Some(login) => Ok(login),
633        }
634    }
635
636    fn maybe_fixup(&self) -> Result<Option<Self>>
637    where
638        Self: Sized,
639    {
640        self.validate_and_fixup(true)
641    }
642
643    // validates, and optionally fixes, a struct. If fixup is false and there is a validation
644    // issue, an `Err` is returned. If fixup is true and a problem was fixed, and `Ok(Some<Self>)`
645    // is returned with the fixed version. If there was no validation problem, `Ok(None)` is
646    // returned.
647    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>>
648    where
649        Self: Sized;
650}
651
652impl ValidateAndFixup for LoginEntry {
653    fn validate_and_fixup(&self, fixup: bool) -> Result<Option<Self>> {
654        // XXX TODO: we've definitely got more validation and fixups to add here!
655
656        let mut maybe_fixed = None;
657
658        /// A little helper to magic a Some(self.clone()) into existence when needed.
659        macro_rules! get_fixed_or_throw {
660            ($err:expr) => {
661                // This is a block expression returning a local variable,
662                // entirely so we can give it an explicit type declaration.
663                {
664                    if !fixup {
665                        return Err($err.into());
666                    }
667                    warn!("Fixing login record {:?}", $err);
668                    let fixed: Result<&mut Self> =
669                        Ok(maybe_fixed.get_or_insert_with(|| self.clone()));
670                    fixed
671                }
672            };
673        }
674
675        if self.origin.is_empty() {
676            return Err(InvalidLogin::EmptyOrigin.into());
677        }
678
679        if self.form_action_origin.is_some() && self.http_realm.is_some() {
680            get_fixed_or_throw!(InvalidLogin::BothTargets)?.http_realm = None;
681        }
682
683        if self.form_action_origin.is_none() && self.http_realm.is_none() {
684            return Err(InvalidLogin::NoTarget.into());
685        }
686
687        let form_action_origin = self.form_action_origin.clone().unwrap_or_default();
688        let http_realm = maybe_fixed
689            .as_ref()
690            .unwrap_or(self)
691            .http_realm
692            .clone()
693            .unwrap_or_default();
694
695        let field_data = [
696            ("form_action_origin", &form_action_origin),
697            ("http_realm", &http_realm),
698            ("origin", &self.origin),
699            ("username_field", &self.username_field),
700            ("password_field", &self.password_field),
701        ];
702
703        for (field_name, field_value) in &field_data {
704            // Nuls are invalid.
705            if field_value.contains('\0') {
706                return Err(InvalidLogin::IllegalFieldValue {
707                    field_info: format!("`{}` contains Nul", field_name),
708                }
709                .into());
710            }
711
712            // Newlines are invalid in Desktop for all the fields here.
713            if field_value.contains('\n') || field_value.contains('\r') {
714                return Err(InvalidLogin::IllegalFieldValue {
715                    field_info: format!("`{}` contains newline", field_name),
716                }
717                .into());
718            }
719        }
720
721        // Desktop doesn't like fields with the below patterns
722        if self.username_field == "." {
723            return Err(InvalidLogin::IllegalFieldValue {
724                field_info: "`username_field` is a period".into(),
725            }
726            .into());
727        }
728
729        // Check we can parse the origin, then use the normalized version of it.
730        if let Some(fixed) = Self::validate_and_fixup_origin(&self.origin)? {
731            get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
732                field_info: "Origin is not normalized".into()
733            })?
734            .origin = fixed;
735        }
736
737        match &maybe_fixed.as_ref().unwrap_or(self).form_action_origin {
738            None => {
739                if !self.username_field.is_empty() {
740                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
741                        field_info: "username_field must be empty when form_action_origin is null"
742                            .into()
743                    })?
744                    .username_field
745                    .clear();
746                }
747                if !self.password_field.is_empty() {
748                    get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
749                        field_info: "password_field must be empty when form_action_origin is null"
750                            .into()
751                    })?
752                    .password_field
753                    .clear();
754                }
755            }
756            Some(href) => {
757                // "", ".", and "javascript:" are special cases documented at the top of this file.
758                if href == "." {
759                    // A bit of a special case - if we are being asked to fixup, we replace
760                    // "." with an empty string - but if not fixing up we don't complain.
761                    if fixup {
762                        maybe_fixed
763                            .get_or_insert_with(|| self.clone())
764                            .form_action_origin = Some("".into());
765                    }
766                } else if !href.is_empty() && href != "javascript:" {
767                    if let Some(fixed) = Self::validate_and_fixup_origin(href)? {
768                        get_fixed_or_throw!(InvalidLogin::IllegalFieldValue {
769                            field_info: "form_action_origin is not normalized".into()
770                        })?
771                        .form_action_origin = Some(fixed);
772                    }
773                }
774            }
775        }
776
777        // secure fields
778        //
779        // \r\n chars are valid in desktop for some reason, so we allow them here too.
780        if self.username.contains('\0') {
781            return Err(InvalidLogin::IllegalFieldValue {
782                field_info: "`username` contains Nul".into(),
783            }
784            .into());
785        }
786        if self.password.is_empty() {
787            return Err(InvalidLogin::EmptyPassword.into());
788        }
789        if self.password.contains('\0') {
790            return Err(InvalidLogin::IllegalFieldValue {
791                field_info: "`password` contains Nul".into(),
792            }
793            .into());
794        }
795
796        Ok(maybe_fixed)
797    }
798}
799
800#[cfg(test)]
801pub mod test_utils {
802    use super::*;
803    use crate::encryption::test_utils::encrypt_struct;
804
805    // Factory function to make a new login
806    //
807    // It uses the guid to create a unique origin/form_action_origin
808    pub fn enc_login(id: &str, password: &str) -> EncryptedLogin {
809        let sec_fields = SecureLoginFields {
810            username: "user".to_string(),
811            password: password.to_string(),
812        };
813        EncryptedLogin {
814            meta: LoginMeta {
815                id: id.to_string(),
816                ..Default::default()
817            },
818            fields: LoginFields {
819                form_action_origin: Some(format!("https://{}.example.com", id)),
820                origin: format!("https://{}.example.com", id),
821                ..Default::default()
822            },
823            // TODO: fixme
824            sec_fields: encrypt_struct(&sec_fields),
825        }
826    }
827}
828
829#[cfg(test)]
830mod tests {
831    use super::*;
832
833    #[test]
834    fn test_url_fixups() -> Result<()> {
835        // Start with URLs which are all valid and already normalized.
836        for input in &[
837            // The list of valid origins documented at the top of this file.
838            "https://site.com",
839            "http://site.com:1234",
840            "ftp://ftp.site.com",
841            "moz-proxy://127.0.0.1:8888",
842            "chrome://MyLegacyExtension",
843            "file://",
844            "https://[::1]",
845        ] {
846            assert_eq!(LoginEntry::validate_and_fixup_origin(input)?, None);
847        }
848
849        // And URLs which get normalized.
850        for (input, output) in &[
851            ("https://site.com/", "https://site.com"),
852            ("http://site.com:1234/", "http://site.com:1234"),
853            ("http://example.com/foo?query=wtf#bar", "http://example.com"),
854            ("http://example.com/foo#bar", "http://example.com"),
855            (
856                "http://username:password@example.com/",
857                "http://example.com",
858            ),
859            ("http://😍.com/", "http://xn--r28h.com"),
860            ("https://[0:0:0:0:0:0:0:1]", "https://[::1]"),
861            // All `file://` URLs normalize to exactly `file://`. See #2384 for
862            // why we might consider changing that later.
863            ("file:///", "file://"),
864            ("file://foo/bar", "file://"),
865            ("file://foo/bar/", "file://"),
866            ("moz-proxy://127.0.0.1:8888/", "moz-proxy://127.0.0.1:8888"),
867            (
868                "moz-proxy://127.0.0.1:8888/foo",
869                "moz-proxy://127.0.0.1:8888",
870            ),
871            ("chrome://MyLegacyExtension/", "chrome://MyLegacyExtension"),
872            (
873                "chrome://MyLegacyExtension/foo",
874                "chrome://MyLegacyExtension",
875            ),
876        ] {
877            assert_eq!(
878                LoginEntry::validate_and_fixup_origin(input)?,
879                Some((*output).into())
880            );
881        }
882
883        // Finally, look at some invalid logins
884        for input in &[".", "example", "example.com"] {
885            assert!(LoginEntry::validate_and_fixup_origin(input).is_err());
886        }
887
888        Ok(())
889    }
890
891    #[test]
892    fn test_check_valid() {
893        #[derive(Debug, Clone)]
894        struct TestCase {
895            login: LoginEntry,
896            should_err: bool,
897            expected_err: &'static str,
898        }
899
900        let valid_login = LoginEntry {
901            origin: "https://www.example.com".into(),
902            http_realm: Some("https://www.example.com".into()),
903            username: "test".into(),
904            password: "test".into(),
905            ..Default::default()
906        };
907
908        let login_with_empty_origin = LoginEntry {
909            origin: "".into(),
910            http_realm: Some("https://www.example.com".into()),
911            username: "test".into(),
912            password: "test".into(),
913            ..Default::default()
914        };
915
916        let login_with_empty_password = LoginEntry {
917            origin: "https://www.example.com".into(),
918            http_realm: Some("https://www.example.com".into()),
919            username: "test".into(),
920            password: "".into(),
921            ..Default::default()
922        };
923
924        let login_with_form_submit_and_http_realm = LoginEntry {
925            origin: "https://www.example.com".into(),
926            http_realm: Some("https://www.example.com".into()),
927            form_action_origin: Some("https://www.example.com".into()),
928            username: "".into(),
929            password: "test".into(),
930            ..Default::default()
931        };
932
933        let login_without_form_submit_or_http_realm = LoginEntry {
934            origin: "https://www.example.com".into(),
935            username: "".into(),
936            password: "test".into(),
937            ..Default::default()
938        };
939
940        let login_with_legacy_form_submit_and_http_realm = LoginEntry {
941            origin: "https://www.example.com".into(),
942            form_action_origin: Some("".into()),
943            username: "".into(),
944            password: "test".into(),
945            ..Default::default()
946        };
947
948        let login_with_null_http_realm = LoginEntry {
949            origin: "https://www.example.com".into(),
950            http_realm: Some("https://www.example.\0com".into()),
951            username: "test".into(),
952            password: "test".into(),
953            ..Default::default()
954        };
955
956        let login_with_null_username = LoginEntry {
957            origin: "https://www.example.com".into(),
958            http_realm: Some("https://www.example.com".into()),
959            username: "\0".into(),
960            password: "test".into(),
961            ..Default::default()
962        };
963
964        let login_with_null_password = LoginEntry {
965            origin: "https://www.example.com".into(),
966            http_realm: Some("https://www.example.com".into()),
967            username: "username".into(),
968            password: "test\0".into(),
969            ..Default::default()
970        };
971
972        let login_with_newline_origin = LoginEntry {
973            origin: "\rhttps://www.example.com".into(),
974            http_realm: Some("https://www.example.com".into()),
975            username: "test".into(),
976            password: "test".into(),
977            ..Default::default()
978        };
979
980        let login_with_newline_username_field = LoginEntry {
981            origin: "https://www.example.com".into(),
982            http_realm: Some("https://www.example.com".into()),
983            username_field: "\n".into(),
984            username: "test".into(),
985            password: "test".into(),
986            ..Default::default()
987        };
988
989        let login_with_newline_realm = LoginEntry {
990            origin: "https://www.example.com".into(),
991            http_realm: Some("foo\nbar".into()),
992            username: "test".into(),
993            password: "test".into(),
994            ..Default::default()
995        };
996
997        let login_with_newline_password = LoginEntry {
998            origin: "https://www.example.com".into(),
999            http_realm: Some("https://www.example.com".into()),
1000            username: "test".into(),
1001            password: "test\n".into(),
1002            ..Default::default()
1003        };
1004
1005        let login_with_period_username_field = LoginEntry {
1006            origin: "https://www.example.com".into(),
1007            http_realm: Some("https://www.example.com".into()),
1008            username_field: ".".into(),
1009            username: "test".into(),
1010            password: "test".into(),
1011            ..Default::default()
1012        };
1013
1014        let login_with_period_form_action_origin = LoginEntry {
1015            form_action_origin: Some(".".into()),
1016            origin: "https://www.example.com".into(),
1017            username: "test".into(),
1018            password: "test".into(),
1019            ..Default::default()
1020        };
1021
1022        let login_with_javascript_form_action_origin = LoginEntry {
1023            form_action_origin: Some("javascript:".into()),
1024            origin: "https://www.example.com".into(),
1025            username: "test".into(),
1026            password: "test".into(),
1027            ..Default::default()
1028        };
1029
1030        let login_with_malformed_origin_parens = LoginEntry {
1031            origin: " (".into(),
1032            http_realm: Some("https://www.example.com".into()),
1033            username: "test".into(),
1034            password: "test".into(),
1035            ..Default::default()
1036        };
1037
1038        let login_with_host_unicode = LoginEntry {
1039            origin: "http://💖.com".into(),
1040            http_realm: Some("https://www.example.com".into()),
1041            username: "test".into(),
1042            password: "test".into(),
1043            ..Default::default()
1044        };
1045
1046        let login_with_origin_trailing_slash = LoginEntry {
1047            origin: "https://www.example.com/".into(),
1048            http_realm: Some("https://www.example.com".into()),
1049            username: "test".into(),
1050            password: "test".into(),
1051            ..Default::default()
1052        };
1053
1054        let login_with_origin_expanded_ipv6 = LoginEntry {
1055            origin: "https://[0:0:0:0:0:0:1:1]".into(),
1056            http_realm: Some("https://www.example.com".into()),
1057            username: "test".into(),
1058            password: "test".into(),
1059            ..Default::default()
1060        };
1061
1062        let login_with_unknown_protocol = LoginEntry {
1063            origin: "moz-proxy://127.0.0.1:8888".into(),
1064            http_realm: Some("https://www.example.com".into()),
1065            username: "test".into(),
1066            password: "test".into(),
1067            ..Default::default()
1068        };
1069
1070        let test_cases = [
1071            TestCase {
1072                login: valid_login,
1073                should_err: false,
1074                expected_err: "",
1075            },
1076            TestCase {
1077                login: login_with_empty_origin,
1078                should_err: true,
1079                expected_err: "Invalid login: Origin is empty",
1080            },
1081            TestCase {
1082                login: login_with_empty_password,
1083                should_err: true,
1084                expected_err: "Invalid login: Password is empty",
1085            },
1086            TestCase {
1087                login: login_with_form_submit_and_http_realm,
1088                should_err: true,
1089                expected_err: "Invalid login: Both `formActionOrigin` and `httpRealm` are present",
1090            },
1091            TestCase {
1092                login: login_without_form_submit_or_http_realm,
1093                should_err: true,
1094                expected_err:
1095                    "Invalid login: Neither `formActionOrigin` or `httpRealm` are present",
1096            },
1097            TestCase {
1098                login: login_with_null_http_realm,
1099                should_err: true,
1100                expected_err: "Invalid login: Login has illegal field: `http_realm` contains Nul",
1101            },
1102            TestCase {
1103                login: login_with_null_username,
1104                should_err: true,
1105                expected_err: "Invalid login: Login has illegal field: `username` contains Nul",
1106            },
1107            TestCase {
1108                login: login_with_null_password,
1109                should_err: true,
1110                expected_err: "Invalid login: Login has illegal field: `password` contains Nul",
1111            },
1112            TestCase {
1113                login: login_with_newline_origin,
1114                should_err: true,
1115                expected_err: "Invalid login: Login has illegal field: `origin` contains newline",
1116            },
1117            TestCase {
1118                login: login_with_newline_realm,
1119                should_err: true,
1120                expected_err:
1121                    "Invalid login: Login has illegal field: `http_realm` contains newline",
1122            },
1123            TestCase {
1124                login: login_with_newline_username_field,
1125                should_err: true,
1126                expected_err:
1127                    "Invalid login: Login has illegal field: `username_field` contains newline",
1128            },
1129            TestCase {
1130                login: login_with_newline_password,
1131                should_err: false,
1132                expected_err: "",
1133            },
1134            TestCase {
1135                login: login_with_period_username_field,
1136                should_err: true,
1137                expected_err:
1138                    "Invalid login: Login has illegal field: `username_field` is a period",
1139            },
1140            TestCase {
1141                login: login_with_period_form_action_origin,
1142                should_err: false,
1143                expected_err: "",
1144            },
1145            TestCase {
1146                login: login_with_javascript_form_action_origin,
1147                should_err: false,
1148                expected_err: "",
1149            },
1150            TestCase {
1151                login: login_with_malformed_origin_parens,
1152                should_err: true,
1153                expected_err:
1154                    "Invalid login: Login has illegal origin: relative URL without a base",
1155            },
1156            TestCase {
1157                login: login_with_host_unicode,
1158                should_err: true,
1159                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1160            },
1161            TestCase {
1162                login: login_with_origin_trailing_slash,
1163                should_err: true,
1164                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1165            },
1166            TestCase {
1167                login: login_with_origin_expanded_ipv6,
1168                should_err: true,
1169                expected_err: "Invalid login: Login has illegal field: Origin is not normalized",
1170            },
1171            TestCase {
1172                login: login_with_unknown_protocol,
1173                should_err: false,
1174                expected_err: "",
1175            },
1176            TestCase {
1177                login: login_with_legacy_form_submit_and_http_realm,
1178                should_err: false,
1179                expected_err: "",
1180            },
1181        ];
1182
1183        for tc in &test_cases {
1184            let actual = tc.login.check_valid();
1185
1186            if tc.should_err {
1187                assert!(actual.is_err(), "{:#?}", tc);
1188                assert_eq!(
1189                    tc.expected_err,
1190                    actual.unwrap_err().to_string(),
1191                    "{:#?}",
1192                    tc,
1193                );
1194            } else {
1195                assert!(actual.is_ok(), "{:#?}", tc);
1196                assert!(
1197                    tc.login.clone().fixup().is_ok(),
1198                    "Fixup failed after check_valid passed: {:#?}",
1199                    &tc,
1200                );
1201            }
1202        }
1203    }
1204
1205    #[test]
1206    fn test_fixup() {
1207        #[derive(Debug, Default)]
1208        struct TestCase {
1209            login: LoginEntry,
1210            fixedup_host: Option<&'static str>,
1211            fixedup_form_action_origin: Option<String>,
1212        }
1213
1214        // Note that most URL fixups are tested above, but we have one or 2 here.
1215        let login_with_full_url = LoginEntry {
1216            origin: "http://example.com/foo?query=wtf#bar".into(),
1217            form_action_origin: Some("http://example.com/foo?query=wtf#bar".into()),
1218            username: "test".into(),
1219            password: "test".into(),
1220            ..Default::default()
1221        };
1222
1223        let login_with_host_unicode = LoginEntry {
1224            origin: "http://😍.com".into(),
1225            form_action_origin: Some("http://😍.com".into()),
1226            username: "test".into(),
1227            password: "test".into(),
1228            ..Default::default()
1229        };
1230
1231        let login_with_period_fsu = LoginEntry {
1232            origin: "https://example.com".into(),
1233            form_action_origin: Some(".".into()),
1234            username: "test".into(),
1235            password: "test".into(),
1236            ..Default::default()
1237        };
1238        let login_with_empty_fsu = LoginEntry {
1239            origin: "https://example.com".into(),
1240            form_action_origin: Some("".into()),
1241            username: "test".into(),
1242            password: "test".into(),
1243            ..Default::default()
1244        };
1245
1246        let login_with_form_submit_and_http_realm = LoginEntry {
1247            origin: "https://www.example.com".into(),
1248            form_action_origin: Some("https://www.example.com".into()),
1249            // If both http_realm and form_action_origin are specified, we drop
1250            // the former when fixing up. So for this test we must have an
1251            // invalid value in http_realm to ensure we don't validate a value
1252            // we end up dropping.
1253            http_realm: Some("\n".into()),
1254            username: "".into(),
1255            password: "test".into(),
1256            ..Default::default()
1257        };
1258
1259        let test_cases = [
1260            TestCase {
1261                login: login_with_full_url,
1262                fixedup_host: "http://example.com".into(),
1263                fixedup_form_action_origin: Some("http://example.com".into()),
1264            },
1265            TestCase {
1266                login: login_with_host_unicode,
1267                fixedup_host: "http://xn--r28h.com".into(),
1268                fixedup_form_action_origin: Some("http://xn--r28h.com".into()),
1269            },
1270            TestCase {
1271                login: login_with_period_fsu,
1272                fixedup_form_action_origin: Some("".into()),
1273                ..TestCase::default()
1274            },
1275            TestCase {
1276                login: login_with_form_submit_and_http_realm,
1277                fixedup_form_action_origin: Some("https://www.example.com".into()),
1278                ..TestCase::default()
1279            },
1280            TestCase {
1281                login: login_with_empty_fsu,
1282                // Should still be empty.
1283                fixedup_form_action_origin: Some("".into()),
1284                ..TestCase::default()
1285            },
1286        ];
1287
1288        for tc in &test_cases {
1289            let login = tc.login.clone().fixup().expect("should work");
1290            if let Some(expected) = tc.fixedup_host {
1291                assert_eq!(login.origin, expected, "origin not fixed in {:#?}", tc);
1292            }
1293            assert_eq!(
1294                login.form_action_origin, tc.fixedup_form_action_origin,
1295                "form_action_origin not fixed in {:#?}",
1296                tc,
1297            );
1298            login.check_valid().unwrap_or_else(|e| {
1299                panic!("Fixup produces invalid record: {:#?}", (e, &tc, &login));
1300            });
1301            assert_eq!(
1302                login.clone().fixup().unwrap(),
1303                login,
1304                "fixup did not reach fixed point for testcase: {:#?}",
1305                tc,
1306            );
1307        }
1308    }
1309
1310    #[test]
1311    fn test_secure_fields_serde() {
1312        let sf = SecureLoginFields {
1313            username: "foo".into(),
1314            password: "pwd".into(),
1315        };
1316        assert_eq!(
1317            serde_json::to_string(&sf).unwrap(),
1318            r#"{"u":"foo","p":"pwd"}"#
1319        );
1320        let got: SecureLoginFields = serde_json::from_str(r#"{"u": "user", "p": "p"}"#).unwrap();
1321        let expected = SecureLoginFields {
1322            username: "user".into(),
1323            password: "p".into(),
1324        };
1325        assert_eq!(got, expected);
1326    }
1327}