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/**
6 * Contains functions shared by different Login Manager components.
7 *
8 * This JavaScript module exists in order to share code between the different
9 * XPCOM components that constitute the Login Manager, including implementations
10 * of nsILoginManager and nsILoginManagerStorage.
11 */
12
13"use strict";
14
15const EXPORTED_SYMBOLS = ["LoginHelper"];
16
17const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm");
18const { XPCOMUtils } = ChromeUtils.import(
19  "resource://gre/modules/XPCOMUtils.jsm"
20);
21ChromeUtils.defineModuleGetter(
22  this,
23  "OSKeyStore",
24  "resource://gre/modules/OSKeyStore.jsm"
25);
26
27/**
28 * A helper class to deal with CSV import rows.
29 */
30class ImportRowProcessor {
31  uniqueLoginIdentifiers = new Set();
32  originToRows = new Map();
33  summary = [];
34  mandatoryFields = ["origin", "password"];
35
36  /**
37   * Validates if the login data contains a GUID that was already found in a previous row in the current import.
38   * If this is the case, the summary will be updated with an error.
39   * @param {object} loginData
40   *        An vanilla object for the login without any methods.
41   * @returns {boolean} True if there is an error, false otherwise.
42   */
43  checkNonUniqueGuidError(loginData) {
44    if (loginData.guid) {
45      if (this.uniqueLoginIdentifiers.has(loginData.guid)) {
46        this.addLoginToSummary({ ...loginData }, "error");
47        return true;
48      }
49      this.uniqueLoginIdentifiers.add(loginData.guid);
50    }
51    return false;
52  }
53
54  /**
55   * Validates if the login data contains invalid fields that are mandatory like origin and password.
56   * If this is the case, the summary will be updated with an error.
57   * @param {object} loginData
58   *        An vanilla object for the login without any methods.
59   * @returns {boolean} True if there is an error, false otherwise.
60   */
61  checkMissingMandatoryFieldsError(loginData) {
62    loginData.origin = LoginHelper.getLoginOrigin(loginData.origin);
63    for (let mandatoryField of this.mandatoryFields) {
64      if (!loginData[mandatoryField]) {
65        const missingFieldRow = this.addLoginToSummary(
66          { ...loginData },
67          "error_missing_field"
68        );
69        missingFieldRow.field_name = mandatoryField;
70        return true;
71      }
72    }
73    return false;
74  }
75
76  /**
77   * Validates if there is already an existing entry with similar values.
78   * If there are similar values but not identical, a new "modified" entry will be added to the summary.
79   * If there are identical values, a new "no_change" entry will be added to the summary
80   * If either of these is the case, it will return true.
81   * @param {object} loginData
82   *        An vanilla object for the login without any methods.
83   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
84   */
85  async checkExistingEntry(loginData) {
86    if (loginData.guid) {
87      // First check for `guid` matches if it's set.
88      // `guid` matches will allow every kind of update, including reverting
89      // to older passwords which can be useful if the user wants to recover
90      // an old password.
91      let existingLogins = await Services.logins.searchLoginsAsync({
92        guid: loginData.guid,
93        origin: loginData.origin, // Ignored outside of GV.
94      });
95
96      if (existingLogins.length) {
97        log.debug("maybeImportLogins: Found existing login with GUID");
98        // There should only be one `guid` match.
99        let existingLogin = existingLogins[0].QueryInterface(
100          Ci.nsILoginMetaInfo
101        );
102
103        if (
104          loginData.username !== existingLogin.username ||
105          loginData.password !== existingLogin.password ||
106          loginData.httpRealm !== existingLogin.httpRealm ||
107          loginData.formActionOrigin !== existingLogin.formActionOrigin ||
108          `${loginData.timeCreated}` !== `${existingLogin.timeCreated}` ||
109          `${loginData.timePasswordChanged}` !==
110            `${existingLogin.timePasswordChanged}`
111        ) {
112          // Use a property bag rather than an nsILoginInfo so we don't clobber
113          // properties that the import source doesn't provide.
114          let propBag = LoginHelper.newPropertyBag(loginData);
115          this.addLoginToSummary({ ...existingLogin }, "modified", propBag);
116          return true;
117        }
118        this.addLoginToSummary({ ...existingLogin }, "no_change");
119        return true;
120      }
121    }
122    return false;
123  }
124
125  /**
126   * Validates if there is a conflict with previous rows based on the origin.
127   * We need to check the logins that we've already decided to add, to see if this is a duplicate.
128   * If this is the case, we mark this one as "no_change" in the summary and return true.
129   * @param {object} login
130   *        A login object.
131   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
132   */
133  checkConflictingOriginWithPreviousRows(login) {
134    let rowsPerOrigin = this.originToRows.get(login.origin);
135    if (rowsPerOrigin) {
136      if (
137        rowsPerOrigin.some(r =>
138          login.matches(r.login, false /* ignorePassword */)
139        )
140      ) {
141        this.addLoginToSummary(login, "no_change");
142        return true;
143      }
144      for (let row of rowsPerOrigin) {
145        let newLogin = row.login;
146        if (login.username == newLogin.username) {
147          this.addLoginToSummary(login, "no_change");
148          return true;
149        }
150      }
151    }
152    return false;
153  }
154
155  /**
156   * Validates if there is a conflict with existing logins based on the origin.
157   * If this is the case and there are some changes, we mark it as "modified" in the summary.
158   * If it matches an existing login without any extra modifications, we mark it as "no_change".
159   * For both cases we return true.
160   * @param {object} login
161   *        A login object.
162   * @returns {boolean} True if the entry is similar or identical to another previously processed entry, false otherwise.
163   */
164  checkConflictingWithExistingLogins(login) {
165    // While here we're passing formActionOrigin and httpRealm, they could be empty/null and get
166    // ignored in that case, leading to multiple logins for the same username.
167    let existingLogins = Services.logins.findLogins(
168      login.origin,
169      login.formActionOrigin,
170      login.httpRealm
171    );
172    // Check for an existing login that matches *including* the password.
173    // If such a login exists, we do not need to add a new login.
174    if (
175      existingLogins.some(l => login.matches(l, false /* ignorePassword */))
176    ) {
177      this.addLoginToSummary(login, "no_change");
178      return true;
179    }
180    // Now check for a login with the same username, where it may be that we have an
181    // updated password.
182    let foundMatchingLogin = false;
183    for (let existingLogin of existingLogins) {
184      if (login.username == existingLogin.username) {
185        foundMatchingLogin = true;
186        existingLogin.QueryInterface(Ci.nsILoginMetaInfo);
187        if (
188          (login.password != existingLogin.password) &
189          (login.timePasswordChanged > existingLogin.timePasswordChanged)
190        ) {
191          // if a login with the same username and different password already exists and it's older
192          // than the current one, update its password and timestamp.
193          let propBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
194            Ci.nsIWritablePropertyBag
195          );
196          propBag.setProperty("password", login.password);
197          propBag.setProperty("timePasswordChanged", login.timePasswordChanged);
198          this.addLoginToSummary({ ...existingLogin }, "modified", propBag);
199          return true;
200        }
201      }
202    }
203    // if the new login is an update or is older than an exiting login, don't add it.
204    if (foundMatchingLogin) {
205      this.addLoginToSummary(login, "no_change");
206      return true;
207    }
208    return false;
209  }
210
211  /**
212   * Validates if there are any invalid values using LoginHelper.checkLoginValues.
213   * If this is the case we mark it as "error" and return true.
214   * @param {object} login
215   *        A login object.
216   * @param {object} loginData
217   *        An vanilla object for the login without any methods.
218   * @returns {boolean} True if there is a validation error we return true, false otherwise.
219   */
220  checkLoginValuesError(login, loginData) {
221    try {
222      // Ensure we only send checked logins through, since the validation is optimized
223      // out from the bulk APIs below us.
224      LoginHelper.checkLoginValues(login);
225    } catch (e) {
226      this.addLoginToSummary({ ...loginData }, "error");
227      Cu.reportError(e);
228      return true;
229    }
230    return false;
231  }
232
233  /**
234   * Creates a new login from loginData.
235   * @param {object} loginData
236   *        An vanilla object for the login without any methods.
237   * @returns {object} A login object.
238   */
239  createNewLogin(loginData) {
240    let login = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
241      Ci.nsILoginInfo
242    );
243    login.init(
244      loginData.origin,
245      loginData.formActionOrigin,
246      loginData.httpRealm,
247      loginData.username,
248      loginData.password,
249      loginData.usernameElement || "",
250      loginData.passwordElement || ""
251    );
252
253    login.QueryInterface(Ci.nsILoginMetaInfo);
254    login.timeCreated = loginData.timeCreated;
255    login.timeLastUsed = loginData.timeLastUsed || loginData.timeCreated;
256    login.timePasswordChanged =
257      loginData.timePasswordChanged || loginData.timeCreated;
258    login.timesUsed = loginData.timesUsed || 1;
259    login.guid = loginData.guid || null;
260    return login;
261  }
262
263  /**
264   * Cleans the action and realm field of the loginData.
265   * @param {object} loginData
266   *        An vanilla object for the login without any methods.
267   */
268  cleanupActionAndRealmFields(loginData) {
269    const cleanOrigin = loginData.formActionOrigin
270      ? LoginHelper.getLoginOrigin(loginData.formActionOrigin, true)
271      : "";
272    loginData.formActionOrigin =
273      cleanOrigin || (typeof loginData.httpRealm == "string" ? null : "");
274
275    loginData.httpRealm =
276      typeof loginData.httpRealm == "string" ? loginData.httpRealm : null;
277  }
278
279  /**
280   * Adds a login to the summary.
281   * @param {object} login
282   *        A login object.
283   * @param {string} result
284   *        The result type. One of "added", "modified", "error", "error_invalid_origin", "error_invalid_password" or "no_change".
285   * @param {object} propBag
286   *        An optional parameter with the properties bag.
287   * @returns {object} The row that was added.
288   */
289  addLoginToSummary(login, result, propBag) {
290    let rows = this.originToRows.get(login.origin) || [];
291    if (rows.length === 0) {
292      this.originToRows.set(login.origin, rows);
293    }
294    const newSummaryRow = { result, login, propBag };
295    rows.push(newSummaryRow);
296    this.summary.push(newSummaryRow);
297    return newSummaryRow;
298  }
299
300  /**
301   * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor.
302   * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added".
303   * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change".
304   */
305  markLastTimePasswordChangedAsModified() {
306    const originUserToRowMap = new Map();
307    for (let currentRow of this.summary) {
308      if (
309        currentRow.result === "added" ||
310        currentRow.result === "modified" ||
311        currentRow.result === "no_change"
312      ) {
313        const originAndUser =
314          currentRow.login.origin + currentRow.login.username;
315        let lastTimeChangedRow = originUserToRowMap.get(originAndUser);
316        if (lastTimeChangedRow) {
317          if (
318            (currentRow.login.password != lastTimeChangedRow.login.password) &
319            (currentRow.login.timePasswordChanged >
320              lastTimeChangedRow.login.timePasswordChanged)
321          ) {
322            lastTimeChangedRow.result = "no_change";
323            currentRow.result = "added";
324            originUserToRowMap.set(originAndUser, currentRow);
325          }
326        } else {
327          originUserToRowMap.set(originAndUser, currentRow);
328        }
329      }
330    }
331  }
332
333  /**
334   * Iterates over all then rows where more than two match the same origin. It mutates the internal state of the processor.
335   * It makes sure that if the `timePasswordChanged` field is present it will be used to decide if it's a "no_change" or "added".
336   * The entry with the oldest `timePasswordChanged` will be "added", the rest will be "no_change".
337   * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data.
338   */
339  async processLoginsAndBuildSummary() {
340    this.markLastTimePasswordChangedAsModified();
341    for (let summaryRow of this.summary) {
342      try {
343        if (summaryRow.result === "added") {
344          summaryRow.login = await Services.logins.addLogin(summaryRow.login);
345        } else if (summaryRow.result === "modified") {
346          Services.logins.modifyLogin(summaryRow.login, summaryRow.propBag);
347        }
348      } catch (e) {
349        Cu.reportError(e);
350        summaryRow.result = "error";
351      }
352    }
353    return this.summary;
354  }
355}
356
357/**
358 * Contains functions shared by different Login Manager components.
359 */
360this.LoginHelper = {
361  debug: null,
362  enabled: null,
363  storageEnabled: null,
364  formlessCaptureEnabled: null,
365  formRemovalCaptureEnabled: null,
366  generationAvailable: null,
367  generationConfidenceThreshold: null,
368  generationEnabled: null,
369  improvedPasswordRulesEnabled: null,
370  improvedPasswordRulesCollection: "password-rules",
371  includeOtherSubdomainsInLookup: null,
372  insecureAutofill: null,
373  privateBrowsingCaptureEnabled: null,
374  remoteRecipesEnabled: null,
375  remoteRecipesCollection: "password-recipes",
376  relatedRealmsEnabled: null,
377  relatedRealmsCollection: "websites-with-shared-credential-backends",
378  schemeUpgrades: null,
379  showAutoCompleteFooter: null,
380  showAutoCompleteImport: null,
381  testOnlyUserHasInteractedWithDocument: null,
382  userInputRequiredToCapture: null,
383  captureInputChanges: null,
384
385  init() {
386    // Watch for pref changes to update cached pref values.
387    Services.prefs.addObserver("signon.", () => this.updateSignonPrefs());
388    this.updateSignonPrefs();
389    Services.telemetry.setEventRecordingEnabled("pwmgr", true);
390    Services.telemetry.setEventRecordingEnabled("form_autocomplete", true);
391  },
392
393  updateSignonPrefs() {
394    this.autofillForms = Services.prefs.getBoolPref("signon.autofillForms");
395    this.autofillAutocompleteOff = Services.prefs.getBoolPref(
396      "signon.autofillForms.autocompleteOff"
397    );
398    this.captureInputChanges = Services.prefs.getBoolPref(
399      "signon.capture.inputChanges.enabled"
400    );
401    this.debug = Services.prefs.getBoolPref("signon.debug");
402    this.enabled = Services.prefs.getBoolPref("signon.rememberSignons");
403    this.storageEnabled = Services.prefs.getBoolPref(
404      "signon.storeSignons",
405      true
406    );
407    this.formlessCaptureEnabled = Services.prefs.getBoolPref(
408      "signon.formlessCapture.enabled"
409    );
410    this.formRemovalCaptureEnabled = Services.prefs.getBoolPref(
411      "signon.formRemovalCapture.enabled"
412    );
413    this.generationAvailable = Services.prefs.getBoolPref(
414      "signon.generation.available"
415    );
416    this.generationConfidenceThreshold = parseFloat(
417      Services.prefs.getStringPref("signon.generation.confidenceThreshold")
418    );
419    this.generationEnabled = Services.prefs.getBoolPref(
420      "signon.generation.enabled"
421    );
422    this.improvedPasswordRulesEnabled = Services.prefs.getBoolPref(
423      "signon.improvedPasswordRules.enabled"
424    );
425    this.insecureAutofill = Services.prefs.getBoolPref(
426      "signon.autofillForms.http"
427    );
428    this.includeOtherSubdomainsInLookup = Services.prefs.getBoolPref(
429      "signon.includeOtherSubdomainsInLookup"
430    );
431    this.passwordEditCaptureEnabled = Services.prefs.getBoolPref(
432      "signon.passwordEditCapture.enabled"
433    );
434    this.privateBrowsingCaptureEnabled = Services.prefs.getBoolPref(
435      "signon.privateBrowsingCapture.enabled"
436    );
437    this.schemeUpgrades = Services.prefs.getBoolPref("signon.schemeUpgrades");
438    this.showAutoCompleteFooter = Services.prefs.getBoolPref(
439      "signon.showAutoCompleteFooter"
440    );
441
442    this.showAutoCompleteImport = Services.prefs.getStringPref(
443      "signon.showAutoCompleteImport",
444      ""
445    );
446
447    this.storeWhenAutocompleteOff = Services.prefs.getBoolPref(
448      "signon.storeWhenAutocompleteOff"
449    );
450
451    this.suggestImportCount = Services.prefs.getIntPref(
452      "signon.suggestImportCount",
453      0
454    );
455
456    if (
457      Services.prefs.getBoolPref(
458        "signon.testOnlyUserHasInteractedByPrefValue",
459        false
460      )
461    ) {
462      this.testOnlyUserHasInteractedWithDocument = Services.prefs.getBoolPref(
463        "signon.testOnlyUserHasInteractedWithDocument",
464        false
465      );
466      log.debug(
467        "updateSignonPrefs, using pref value for testOnlyUserHasInteractedWithDocument",
468        this.testOnlyUserHasInteractedWithDocument
469      );
470    } else {
471      this.testOnlyUserHasInteractedWithDocument = null;
472    }
473
474    this.userInputRequiredToCapture = Services.prefs.getBoolPref(
475      "signon.userInputRequiredToCapture.enabled"
476    );
477    this.usernameOnlyFormEnabled = Services.prefs.getBoolPref(
478      "signon.usernameOnlyForm.enabled"
479    );
480    this.remoteRecipesEnabled = Services.prefs.getBoolPref(
481      "signon.recipes.remoteRecipes.enabled"
482    );
483    this.relatedRealmsEnabled = Services.prefs.getBoolPref(
484      "signon.relatedRealms.enabled"
485    );
486  },
487
488  createLogger(aLogPrefix) {
489    let getMaxLogLevel = () => {
490      return this.debug ? "Debug" : "Warn";
491    };
492
493    // Create a new instance of the ConsoleAPI so we can control the maxLogLevel with a pref.
494    let consoleOptions = {
495      maxLogLevel: getMaxLogLevel(),
496      prefix: aLogPrefix,
497    };
498    let logger = console.createInstance(consoleOptions);
499
500    // Watch for pref changes and update this.debug and the maxLogLevel for created loggers
501    Services.prefs.addObserver("signon.debug", () => {
502      this.debug = Services.prefs.getBoolPref("signon.debug");
503      if (logger) {
504        logger.maxLogLevel = getMaxLogLevel();
505      }
506    });
507
508    return logger;
509  },
510
511  /**
512   * Due to the way the signons2.txt file is formatted, we need to make
513   * sure certain field values or characters do not cause the file to
514   * be parsed incorrectly.  Reject origins that we can't store correctly.
515   *
516   * @throws String with English message in case validation failed.
517   */
518  checkOriginValue(aOrigin) {
519    // Nulls are invalid, as they don't round-trip well.  Newlines are also
520    // invalid for any field stored as plaintext, and an origin made of a
521    // single dot cannot be stored in the legacy format.
522    if (
523      aOrigin == "." ||
524      aOrigin.includes("\r") ||
525      aOrigin.includes("\n") ||
526      aOrigin.includes("\0")
527    ) {
528      throw new Error("Invalid origin");
529    }
530  },
531
532  /**
533   * Due to the way the signons2.txt file was formatted, we needed to make
534   * sure certain field values or characters do not cause the file to
535   * be parsed incorrectly. These characters can cause problems in other
536   * formats/languages too so reject logins that may not be stored correctly.
537   *
538   * @throws String with English message in case validation failed.
539   */
540  checkLoginValues(aLogin) {
541    function badCharacterPresent(l, c) {
542      return (
543        (l.formActionOrigin && l.formActionOrigin.includes(c)) ||
544        (l.httpRealm && l.httpRealm.includes(c)) ||
545        l.origin.includes(c) ||
546        l.usernameField.includes(c) ||
547        l.passwordField.includes(c)
548      );
549    }
550
551    // Nulls are invalid, as they don't round-trip well.
552    // Mostly not a formatting problem, although ".\0" can be quirky.
553    if (badCharacterPresent(aLogin, "\0")) {
554      throw new Error("login values can't contain nulls");
555    }
556
557    if (!aLogin.password || typeof aLogin.password != "string") {
558      throw new Error("passwords must be non-empty strings");
559    }
560
561    // In theory these nulls should just be rolled up into the encrypted
562    // values, but nsISecretDecoderRing doesn't use nsStrings, so the
563    // nulls cause truncation. Check for them here just to avoid
564    // unexpected round-trip surprises.
565    if (aLogin.username.includes("\0") || aLogin.password.includes("\0")) {
566      throw new Error("login values can't contain nulls");
567    }
568
569    // Newlines are invalid for any field stored as plaintext.
570    if (
571      badCharacterPresent(aLogin, "\r") ||
572      badCharacterPresent(aLogin, "\n")
573    ) {
574      throw new Error("login values can't contain newlines");
575    }
576
577    // A line with just a "." can have special meaning.
578    if (aLogin.usernameField == "." || aLogin.formActionOrigin == ".") {
579      throw new Error("login values can't be periods");
580    }
581
582    // An origin with "\ \(" won't roundtrip.
583    // eg host="foo (", realm="bar" --> "foo ( (bar)"
584    // vs host="foo", realm=" (bar" --> "foo ( (bar)"
585    if (aLogin.origin.includes(" (")) {
586      throw new Error("bad parens in origin");
587    }
588  },
589
590  /**
591   * Returns a new XPCOM property bag with the provided properties.
592   *
593   * @param {Object} aProperties
594   *        Each property of this object is copied to the property bag.  This
595   *        parameter can be omitted to return an empty property bag.
596   *
597   * @return A new property bag, that is an instance of nsIWritablePropertyBag,
598   *         nsIWritablePropertyBag2, nsIPropertyBag, and nsIPropertyBag2.
599   */
600  newPropertyBag(aProperties) {
601    let propertyBag = Cc["@mozilla.org/hash-property-bag;1"].createInstance(
602      Ci.nsIWritablePropertyBag
603    );
604    if (aProperties) {
605      for (let [name, value] of Object.entries(aProperties)) {
606        propertyBag.setProperty(name, value);
607      }
608    }
609    return propertyBag
610      .QueryInterface(Ci.nsIPropertyBag)
611      .QueryInterface(Ci.nsIPropertyBag2)
612      .QueryInterface(Ci.nsIWritablePropertyBag2);
613  },
614
615  /**
616   * Helper to avoid the property bags when calling
617   * Services.logins.searchLogins from JS.
618   * @deprecated Use Services.logins.searchLoginsAsync instead.
619   *
620   * @param {Object} aSearchOptions - A regular JS object to copy to a property bag before searching
621   * @return {nsILoginInfo[]} - The result of calling searchLogins.
622   */
623  searchLoginsWithObject(aSearchOptions) {
624    return Services.logins.searchLogins(this.newPropertyBag(aSearchOptions));
625  },
626
627  /**
628   * @param {string} aURL
629   * @returns {string} which is the hostPort of aURL if supported by the scheme
630   *                   otherwise, returns the original aURL.
631   */
632  maybeGetHostPortForURL(aURL) {
633    try {
634      let uri = Services.io.newURI(aURL);
635      return uri.hostPort;
636    } catch (ex) {
637      // No need to warn for javascript:/data:/about:/chrome:/etc.
638    }
639    return aURL;
640  },
641
642  /**
643   * Get the parts of the URL we want for identification.
644   * Strip out things like the userPass portion and handle javascript:.
645   */
646  getLoginOrigin(uriString, allowJS = false) {
647    let realm = "";
648    try {
649      let uri = Services.io.newURI(uriString);
650
651      if (allowJS && uri.scheme == "javascript") {
652        return "javascript:";
653      }
654      // TODO: Bug 1559205 - Add support for moz-proxy
655
656      // Build this manually instead of using prePath to avoid including the userPass portion.
657      realm = uri.scheme + "://" + uri.displayHostPort;
658    } catch (e) {
659      // bug 159484 - disallow url types that don't support a hostPort.
660      // (although we handle "javascript:..." as a special case above.)
661      log.warn("Couldn't parse origin for", uriString, e);
662      realm = null;
663    }
664
665    return realm;
666  },
667
668  getFormActionOrigin(form) {
669    let uriString = form.action;
670
671    // A blank or missing action submits to where it came from.
672    if (uriString == "") {
673      // ala bug 297761
674      uriString = form.baseURI;
675    }
676
677    return this.getLoginOrigin(uriString, true);
678  },
679
680  /**
681   * @param {String} aLoginOrigin - An origin value from a stored login's
682   *                                origin or formActionOrigin properties.
683   * @param {String} aSearchOrigin - The origin that was are looking to match
684   *                                 with aLoginOrigin. This would normally come
685   *                                 from a form or page that we are considering.
686   * @param {nsILoginFindOptions} aOptions - Options to affect whether the origin
687   *                                         from the login (aLoginOrigin) is a
688   *                                         match for the origin we're looking
689   *                                         for (aSearchOrigin).
690   */
691  isOriginMatching(
692    aLoginOrigin,
693    aSearchOrigin,
694    aOptions = {
695      schemeUpgrades: false,
696      acceptWildcardMatch: false,
697      acceptDifferentSubdomains: false,
698      acceptRelatedRealms: false,
699      relatedRealms: [],
700    }
701  ) {
702    if (aLoginOrigin == aSearchOrigin) {
703      return true;
704    }
705
706    if (!aOptions) {
707      return false;
708    }
709
710    if (aOptions.acceptWildcardMatch && aLoginOrigin == "") {
711      return true;
712    }
713
714    // We can only match logins now if either of these flags are true, so
715    // avoid doing the work of constructing URL objects if neither is true.
716    if (!aOptions.acceptDifferentSubdomains && !aOptions.schemeUpgrades) {
717      return false;
718    }
719
720    try {
721      let loginURI = Services.io.newURI(aLoginOrigin);
722      let searchURI = Services.io.newURI(aSearchOrigin);
723      let schemeMatches =
724        loginURI.scheme == "http" && searchURI.scheme == "https";
725
726      if (aOptions.acceptDifferentSubdomains) {
727        let loginBaseDomain = Services.eTLD.getBaseDomain(loginURI);
728        let searchBaseDomain = Services.eTLD.getBaseDomain(searchURI);
729        if (
730          loginBaseDomain == searchBaseDomain &&
731          (loginURI.scheme == searchURI.scheme ||
732            (aOptions.schemeUpgrades && schemeMatches))
733        ) {
734          return true;
735        }
736        if (
737          aOptions.acceptRelatedRealms &&
738          aOptions.relatedRealms.length &&
739          (loginURI.scheme == searchURI.scheme ||
740            (aOptions.schemeUpgrades && schemeMatches))
741        ) {
742          for (let relatedOrigin of aOptions.relatedRealms) {
743            if (Services.eTLD.hasRootDomain(loginURI.host, relatedOrigin)) {
744              return true;
745            }
746          }
747        }
748      }
749
750      if (
751        aOptions.schemeUpgrades &&
752        loginURI.host == searchURI.host &&
753        schemeMatches &&
754        loginURI.port == searchURI.port
755      ) {
756        return true;
757      }
758    } catch (ex) {
759      // newURI will throw for some values e.g. chrome://FirefoxAccounts
760      // uri.host and uri.port will throw for some values e.g. javascript:
761      return false;
762    }
763
764    return false;
765  },
766
767  doLoginsMatch(
768    aLogin1,
769    aLogin2,
770    { ignorePassword = false, ignoreSchemes = false }
771  ) {
772    if (
773      aLogin1.httpRealm != aLogin2.httpRealm ||
774      aLogin1.username != aLogin2.username
775    ) {
776      return false;
777    }
778
779    if (!ignorePassword && aLogin1.password != aLogin2.password) {
780      return false;
781    }
782
783    if (ignoreSchemes) {
784      let login1HostPort = this.maybeGetHostPortForURL(aLogin1.origin);
785      let login2HostPort = this.maybeGetHostPortForURL(aLogin2.origin);
786      if (login1HostPort != login2HostPort) {
787        return false;
788      }
789
790      if (
791        aLogin1.formActionOrigin != "" &&
792        aLogin2.formActionOrigin != "" &&
793        this.maybeGetHostPortForURL(aLogin1.formActionOrigin) !=
794          this.maybeGetHostPortForURL(aLogin2.formActionOrigin)
795      ) {
796        return false;
797      }
798    } else {
799      if (aLogin1.origin != aLogin2.origin) {
800        return false;
801      }
802
803      // If either formActionOrigin is blank (but not null), then match.
804      if (
805        aLogin1.formActionOrigin != "" &&
806        aLogin2.formActionOrigin != "" &&
807        aLogin1.formActionOrigin != aLogin2.formActionOrigin
808      ) {
809        return false;
810      }
811    }
812
813    // The .usernameField and .passwordField values are ignored.
814
815    return true;
816  },
817
818  /**
819   * Creates a new login object that results by modifying the given object with
820   * the provided data.
821   *
822   * @param {nsILoginInfo} aOldStoredLogin
823   *        Existing login object to modify.
824   * @param {nsILoginInfo|nsIProperyBag} aNewLoginData
825   *        The new login values, either as an nsILoginInfo or nsIProperyBag.
826   *
827   * @return {nsILoginInfo} The newly created nsILoginInfo object.
828   *
829   * @throws {Error} With English message in case validation failed.
830   */
831  buildModifiedLogin(aOldStoredLogin, aNewLoginData) {
832    function bagHasProperty(aPropName) {
833      try {
834        aNewLoginData.getProperty(aPropName);
835        return true;
836      } catch (ex) {}
837      return false;
838    }
839
840    aOldStoredLogin.QueryInterface(Ci.nsILoginMetaInfo);
841
842    let newLogin;
843    if (aNewLoginData instanceof Ci.nsILoginInfo) {
844      // Clone the existing login to get its nsILoginMetaInfo, then init it
845      // with the replacement nsILoginInfo data from the new login.
846      newLogin = aOldStoredLogin.clone();
847      newLogin.init(
848        aNewLoginData.origin,
849        aNewLoginData.formActionOrigin,
850        aNewLoginData.httpRealm,
851        aNewLoginData.username,
852        aNewLoginData.password,
853        aNewLoginData.usernameField,
854        aNewLoginData.passwordField
855      );
856      newLogin.QueryInterface(Ci.nsILoginMetaInfo);
857
858      // Automatically update metainfo when password is changed.
859      if (newLogin.password != aOldStoredLogin.password) {
860        newLogin.timePasswordChanged = Date.now();
861      }
862    } else if (aNewLoginData instanceof Ci.nsIPropertyBag) {
863      // Clone the existing login, along with all its properties.
864      newLogin = aOldStoredLogin.clone();
865      newLogin.QueryInterface(Ci.nsILoginMetaInfo);
866
867      // Automatically update metainfo when password is changed.
868      // (Done before the main property updates, lest the caller be
869      // explicitly updating both .password and .timePasswordChanged)
870      if (bagHasProperty("password")) {
871        let newPassword = aNewLoginData.getProperty("password");
872        if (newPassword != aOldStoredLogin.password) {
873          newLogin.timePasswordChanged = Date.now();
874        }
875      }
876
877      for (let prop of aNewLoginData.enumerator) {
878        switch (prop.name) {
879          // nsILoginInfo (fall through)
880          case "origin":
881          case "httpRealm":
882          case "formActionOrigin":
883          case "username":
884          case "password":
885          case "usernameField":
886          case "passwordField":
887          // nsILoginMetaInfo (fall through)
888          case "guid":
889          case "timeCreated":
890          case "timeLastUsed":
891          case "timePasswordChanged":
892          case "timesUsed":
893            newLogin[prop.name] = prop.value;
894            break;
895
896          // Fake property, allows easy incrementing.
897          case "timesUsedIncrement":
898            newLogin.timesUsed += prop.value;
899            break;
900
901          // Fail if caller requests setting an unknown property.
902          default:
903            throw new Error("Unexpected propertybag item: " + prop.name);
904        }
905      }
906    } else {
907      throw new Error("newLoginData needs an expected interface!");
908    }
909
910    // Sanity check the login
911    if (newLogin.origin == null || !newLogin.origin.length) {
912      throw new Error("Can't add a login with a null or empty origin.");
913    }
914
915    // For logins w/o a username, set to "", not null.
916    if (newLogin.username == null) {
917      throw new Error("Can't add a login with a null username.");
918    }
919
920    if (newLogin.password == null || !newLogin.password.length) {
921      throw new Error("Can't add a login with a null or empty password.");
922    }
923
924    if (newLogin.formActionOrigin || newLogin.formActionOrigin == "") {
925      // We have a form submit URL. Can't have a HTTP realm.
926      if (newLogin.httpRealm != null) {
927        throw new Error(
928          "Can't add a login with both a httpRealm and formActionOrigin."
929        );
930      }
931    } else if (newLogin.httpRealm) {
932      // We have a HTTP realm. Can't have a form submit URL.
933      if (newLogin.formActionOrigin != null) {
934        throw new Error(
935          "Can't add a login with both a httpRealm and formActionOrigin."
936        );
937      }
938    } else {
939      // Need one or the other!
940      throw new Error(
941        "Can't add a login without a httpRealm or formActionOrigin."
942      );
943    }
944
945    // Throws if there are bogus values.
946    this.checkLoginValues(newLogin);
947
948    return newLogin;
949  },
950
951  /**
952   * Remove http: logins when there is an https: login with the same username and hostPort.
953   * Sort order is preserved.
954   *
955   * @param {nsILoginInfo[]} logins
956   *        A list of logins we want to process for shadowing.
957   * @returns {nsILoginInfo[]} A subset of of the passed logins.
958   */
959  shadowHTTPLogins(logins) {
960    /**
961     * Map a (hostPort, username) to a boolean indicating whether `logins`
962     * contains an https: login for that combo.
963     */
964    let hasHTTPSByHostPortUsername = new Map();
965    for (let login of logins) {
966      let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]);
967      let hasHTTPSlogin = hasHTTPSByHostPortUsername.get(key) || false;
968      let loginURI = Services.io.newURI(login.origin);
969      hasHTTPSByHostPortUsername.set(
970        key,
971        loginURI.scheme == "https" || hasHTTPSlogin
972      );
973    }
974
975    return logins.filter(login => {
976      let key = this.getUniqueKeyForLogin(login, ["hostPort", "username"]);
977      let loginURI = Services.io.newURI(login.origin);
978      if (loginURI.scheme == "http" && hasHTTPSByHostPortUsername.get(key)) {
979        // If this is an http: login and we have an https: login for the
980        // (hostPort, username) combo then remove it.
981        return false;
982      }
983      return true;
984    });
985  },
986
987  /**
988   * Generate a unique key string from a login.
989   * @param {nsILoginInfo} login
990   * @param {string[]} uniqueKeys containing nsILoginInfo attribute names or "hostPort"
991   * @returns {string} to use as a key in a Map
992   */
993  getUniqueKeyForLogin(login, uniqueKeys) {
994    const KEY_DELIMITER = ":";
995    return uniqueKeys.reduce((prev, key) => {
996      let val = null;
997      if (key == "hostPort") {
998        val = Services.io.newURI(login.origin).hostPort;
999      } else {
1000        val = login[key];
1001      }
1002
1003      return prev + KEY_DELIMITER + val;
1004    }, "");
1005  },
1006
1007  /**
1008   * Removes duplicates from a list of logins while preserving the sort order.
1009   *
1010   * @param {nsILoginInfo[]} logins
1011   *        A list of logins we want to deduplicate.
1012   * @param {string[]} [uniqueKeys = ["username", "password"]]
1013   *        A list of login attributes to use as unique keys for the deduplication.
1014   * @param {string[]} [resolveBy = ["timeLastUsed"]]
1015   *        Ordered array of keyword strings used to decide which of the
1016   *        duplicates should be used. "scheme" would prefer the login that has
1017   *        a scheme matching `preferredOrigin`'s if there are two logins with
1018   *        the same `uniqueKeys`. The default preference to distinguish two
1019   *        logins is `timeLastUsed`. If there is no preference between two
1020   *        logins, the first one found wins.
1021   * @param {string} [preferredOrigin = undefined]
1022   *        String representing the origin to use for preferring one login over
1023   *        another when they are dupes. This is used with "scheme" for
1024   *        `resolveBy` so the scheme from this origin will be preferred.
1025   * @param {string} [preferredFormActionOrigin = undefined]
1026   *        String representing the action origin to use for preferring one login over
1027   *        another when they are dupes. This is used with "actionOrigin" for
1028   *        `resolveBy` so the scheme from this action origin will be preferred.
1029   *
1030   * @returns {nsILoginInfo[]} list of unique logins.
1031   */
1032  dedupeLogins(
1033    logins,
1034    uniqueKeys = ["username", "password"],
1035    resolveBy = ["timeLastUsed"],
1036    preferredOrigin = undefined,
1037    preferredFormActionOrigin = undefined
1038  ) {
1039    if (!preferredOrigin) {
1040      if (resolveBy.includes("scheme")) {
1041        throw new Error(
1042          "dedupeLogins: `preferredOrigin` is required in order to " +
1043            "prefer schemes which match it."
1044        );
1045      }
1046      if (resolveBy.includes("subdomain")) {
1047        throw new Error(
1048          "dedupeLogins: `preferredOrigin` is required in order to " +
1049            "prefer subdomains which match it."
1050        );
1051      }
1052    }
1053
1054    let preferredOriginScheme;
1055    if (preferredOrigin) {
1056      try {
1057        preferredOriginScheme = Services.io.newURI(preferredOrigin).scheme;
1058      } catch (ex) {
1059        // Handle strings that aren't valid URIs e.g. chrome://FirefoxAccounts
1060      }
1061    }
1062
1063    if (!preferredOriginScheme && resolveBy.includes("scheme")) {
1064      log.warn(
1065        "dedupeLogins: Deduping with a scheme preference but couldn't " +
1066          "get the preferred origin scheme."
1067      );
1068    }
1069
1070    // We use a Map to easily lookup logins by their unique keys.
1071    let loginsByKeys = new Map();
1072
1073    /**
1074     * @return {bool} whether `login` is preferred over its duplicate (considering `uniqueKeys`)
1075     *                `existingLogin`.
1076     *
1077     * `resolveBy` is a sorted array so we can return true the first time `login` is preferred
1078     * over the existingLogin.
1079     */
1080    function isLoginPreferred(existingLogin, login) {
1081      if (!resolveBy || !resolveBy.length) {
1082        // If there is no preference, prefer the existing login.
1083        return false;
1084      }
1085
1086      for (let preference of resolveBy) {
1087        switch (preference) {
1088          case "actionOrigin": {
1089            if (!preferredFormActionOrigin) {
1090              break;
1091            }
1092            if (
1093              LoginHelper.isOriginMatching(
1094                existingLogin.formActionOrigin,
1095                preferredFormActionOrigin,
1096                { schemeUpgrades: LoginHelper.schemeUpgrades }
1097              ) &&
1098              !LoginHelper.isOriginMatching(
1099                login.formActionOrigin,
1100                preferredFormActionOrigin,
1101                { schemeUpgrades: LoginHelper.schemeUpgrades }
1102              )
1103            ) {
1104              return false;
1105            }
1106            break;
1107          }
1108          case "scheme": {
1109            if (!preferredOriginScheme) {
1110              break;
1111            }
1112
1113            try {
1114              // Only `origin` is currently considered
1115              let existingLoginURI = Services.io.newURI(existingLogin.origin);
1116              let loginURI = Services.io.newURI(login.origin);
1117              // If the schemes of the two logins are the same or neither match the
1118              // preferredOriginScheme then we have no preference and look at the next resolveBy.
1119              if (
1120                loginURI.scheme == existingLoginURI.scheme ||
1121                (loginURI.scheme != preferredOriginScheme &&
1122                  existingLoginURI.scheme != preferredOriginScheme)
1123              ) {
1124                break;
1125              }
1126
1127              return loginURI.scheme == preferredOriginScheme;
1128            } catch (ex) {
1129              // Some URLs aren't valid nsIURI (e.g. chrome://FirefoxAccounts)
1130              log.debug(
1131                "dedupeLogins/shouldReplaceExisting: Error comparing schemes:",
1132                existingLogin.origin,
1133                login.origin,
1134                "preferredOrigin:",
1135                preferredOrigin,
1136                ex
1137              );
1138            }
1139            break;
1140          }
1141          case "subdomain": {
1142            // Replace the existing login only if the new login is an exact match on the host.
1143            let existingLoginURI = Services.io.newURI(existingLogin.origin);
1144            let newLoginURI = Services.io.newURI(login.origin);
1145            let preferredOriginURI = Services.io.newURI(preferredOrigin);
1146            if (
1147              existingLoginURI.hostPort != preferredOriginURI.hostPort &&
1148              newLoginURI.hostPort == preferredOriginURI.hostPort
1149            ) {
1150              return true;
1151            }
1152            if (
1153              existingLoginURI.host != preferredOriginURI.host &&
1154              newLoginURI.host == preferredOriginURI.host
1155            ) {
1156              return true;
1157            }
1158            // if the existing login host *is* a match and the new one isn't
1159            // we explicitly want to keep the existing one
1160            if (
1161              existingLoginURI.host == preferredOriginURI.host &&
1162              newLoginURI.host != preferredOriginURI.host
1163            ) {
1164              return false;
1165            }
1166            break;
1167          }
1168          case "timeLastUsed":
1169          case "timePasswordChanged": {
1170            // If we find a more recent login for the same key, replace the existing one.
1171            let loginDate = login.QueryInterface(Ci.nsILoginMetaInfo)[
1172              preference
1173            ];
1174            let storedLoginDate = existingLogin.QueryInterface(
1175              Ci.nsILoginMetaInfo
1176            )[preference];
1177            if (loginDate == storedLoginDate) {
1178              break;
1179            }
1180
1181            return loginDate > storedLoginDate;
1182          }
1183          default: {
1184            throw new Error(
1185              "dedupeLogins: Invalid resolveBy preference: " + preference
1186            );
1187          }
1188        }
1189      }
1190
1191      return false;
1192    }
1193
1194    for (let login of logins) {
1195      let key = this.getUniqueKeyForLogin(login, uniqueKeys);
1196
1197      if (loginsByKeys.has(key)) {
1198        if (!isLoginPreferred(loginsByKeys.get(key), login)) {
1199          // If there is no preference for the new login, use the existing one.
1200          continue;
1201        }
1202      }
1203      loginsByKeys.set(key, login);
1204    }
1205
1206    // Return the map values in the form of an array.
1207    return [...loginsByKeys.values()];
1208  },
1209
1210  /**
1211   * Open the password manager window.
1212   *
1213   * @param {Window} window
1214   *                 the window from where we want to open the dialog
1215   *
1216   * @param {object?} args
1217   *                  params for opening the password manager
1218   * @param {string} [args.filterString=""]
1219   *                 the domain (not origin) to pass to the login manager dialog
1220   *                 to pre-filter the results
1221   * @param {string} args.entryPoint
1222   *                 The name of the entry point, used for telemetry
1223   */
1224  openPasswordManager(window, { filterString = "", entryPoint = "" } = {}) {
1225    const params = new URLSearchParams({
1226      ...(filterString && { filter: filterString }),
1227      ...(entryPoint && { entryPoint }),
1228    });
1229    const separator = params.toString() ? "?" : "";
1230    const destination = `about:logins${separator}${params}`;
1231
1232    // We assume that managementURL has a '?' already
1233    window.openTrustedLinkIn(destination, "tab");
1234  },
1235
1236  /**
1237   * Checks if a field type is password compatible.
1238   *
1239   * @param {Element} element
1240   *                  the field we want to check.
1241   * @param {Object} options
1242   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
1243   *                                         of the element.
1244   *
1245   * @returns {Boolean} true if the field can
1246   *                    be treated as a password input
1247   */
1248  isPasswordFieldType(element, { ignoreConnect = false } = {}) {
1249    if (ChromeUtils.getClassName(element) !== "HTMLInputElement") {
1250      return false;
1251    }
1252
1253    if (!element.isConnected && !ignoreConnect) {
1254      // If the element isn't connected then it isn't visible to the user so
1255      // shouldn't be considered. It must have been connected in the past.
1256      return false;
1257    }
1258
1259    if (!element.hasBeenTypePassword) {
1260      return false;
1261    }
1262
1263    // Ensure the element is of a type that could have autocomplete.
1264    // These include the types with user-editable values. If not, even if it used to be
1265    // a type=password, we can't treat it as a password input now
1266    let acInfo = element.getAutocompleteInfo();
1267    if (!acInfo) {
1268      return false;
1269    }
1270
1271    return true;
1272  },
1273
1274  /**
1275   * Checks if a field type is username compatible.
1276   *
1277   * @param {Element} element
1278   *                  the field we want to check.
1279   * @param {Object} options
1280   * @param {bool} [options.ignoreConnect] - Whether to ignore checking isConnected
1281   *                                         of the element.
1282   *
1283   * @returns {Boolean} true if the field type is one
1284   *                    of the username types.
1285   */
1286  isUsernameFieldType(element, { ignoreConnect = false } = {}) {
1287    if (ChromeUtils.getClassName(element) !== "HTMLInputElement") {
1288      return false;
1289    }
1290
1291    if (!element.isConnected && !ignoreConnect) {
1292      // If the element isn't connected then it isn't visible to the user so
1293      // shouldn't be considered. It must have been connected in the past.
1294      return false;
1295    }
1296
1297    if (element.hasBeenTypePassword) {
1298      return false;
1299    }
1300
1301    let fieldType = element.hasAttribute("type")
1302      ? element.getAttribute("type").toLowerCase()
1303      : element.type;
1304    if (
1305      !(
1306        fieldType == "text" ||
1307        fieldType == "email" ||
1308        fieldType == "url" ||
1309        fieldType == "tel" ||
1310        fieldType == "number"
1311      )
1312    ) {
1313      return false;
1314    }
1315
1316    let acFieldName = element.getAutocompleteInfo().fieldName;
1317    if (
1318      !(
1319        acFieldName == "username" ||
1320        // Bug 1540154: Some sites use tel/email on their username fields.
1321        acFieldName == "email" ||
1322        acFieldName == "tel" ||
1323        acFieldName == "tel-national" ||
1324        acFieldName == "off" ||
1325        acFieldName == "on" ||
1326        acFieldName == ""
1327      )
1328    ) {
1329      return false;
1330    }
1331    return true;
1332  },
1333
1334  /**
1335   * Infer whether a form is a sign-in form by searching keywords
1336   * in its attributes
1337   *
1338   * @param {Element} element
1339   *                  the form we want to check.
1340   *
1341   * @returns {boolean} True if any of the rules matches
1342   */
1343  isInferredLoginForm(formElement) {
1344    // This is copied from 'loginFormAttrRegex' in NewPasswordModel.jsm
1345    const loginExpr = /login|log in|log on|log-on|sign in|sigin|sign\/in|sign-in|sign on|sign-on/i;
1346
1347    if (this._elementAttrsMatchRegex(formElement, loginExpr)) {
1348      return true;
1349    }
1350
1351    return false;
1352  },
1353
1354  /**
1355   * Infer whether an input field is a username field by searching
1356   * 'username' keyword in its attributes
1357   *
1358   * @param {Element} element
1359   *                  the field we want to check.
1360   *
1361   * @returns {boolean} True if any of the rules matches
1362   */
1363  isInferredUsernameField(element) {
1364    const expr = /username/i;
1365
1366    let ac = element.getAutocompleteInfo()?.fieldName;
1367    if (ac && ac == "username") {
1368      return true;
1369    }
1370
1371    if (
1372      this._elementAttrsMatchRegex(element, expr) ||
1373      this._hasLabelMatchingRegex(element, expr)
1374    ) {
1375      return true;
1376    }
1377
1378    return false;
1379  },
1380
1381  /**
1382   * Infer whether an input field is an email field by searching
1383   * 'email' keyword in its attributes.
1384   *
1385   * @param {Element} element
1386   *                  the field we want to check.
1387   *
1388   * @returns {boolean} True if any of the rules matches
1389   */
1390  isInferredEmailField(element) {
1391    const expr = /email/i;
1392
1393    if (element.type == "email") {
1394      return true;
1395    }
1396
1397    let ac = element.getAutocompleteInfo()?.fieldName;
1398    if (ac && ac == "email") {
1399      return true;
1400    }
1401
1402    if (
1403      this._elementAttrsMatchRegex(element, expr) ||
1404      this._hasLabelMatchingRegex(element, expr)
1405    ) {
1406      return true;
1407    }
1408
1409    return false;
1410  },
1411
1412  /**
1413   * Test whether the element has the keyword in its attributes.
1414   * The tested attributes include id, name, className, and placeholder.
1415   */
1416  _elementAttrsMatchRegex(element, regex) {
1417    if (
1418      regex.test(element.id) ||
1419      regex.test(element.name) ||
1420      regex.test(element.className)
1421    ) {
1422      return true;
1423    }
1424
1425    let placeholder = element.getAttribute("placeholder");
1426    if (placeholder && regex.test(placeholder)) {
1427      return true;
1428    }
1429    return false;
1430  },
1431
1432  /**
1433   * Test whether associated labels of the element have the keyword.
1434   * This is a simplified rule of hasLabelMatchingRegex in NewPasswordModel.jsm
1435   * Consider changing it if this is not good enough.
1436   */
1437  _hasLabelMatchingRegex(element, regex) {
1438    if (element.labels !== null && element.labels.length) {
1439      if (regex.test(element.labels[0].textContent)) {
1440        return true;
1441      }
1442    }
1443
1444    return false;
1445  },
1446
1447  /**
1448   * For each login, add the login to the password manager if a similar one
1449   * doesn't already exist. Merge it otherwise with the similar existing ones.
1450   *
1451   * @param {Object[]} loginDatas - For each login, the data that needs to be added.
1452   * @returns {Object[]} An entry for each processed row containing how the row was processed and the login data.
1453   */
1454  async maybeImportLogins(loginDatas) {
1455    const processor = new ImportRowProcessor();
1456    for (let rawLoginData of loginDatas) {
1457      // Do some sanitization on a clone of the loginData.
1458      let loginData = ChromeUtils.shallowClone(rawLoginData);
1459      if (processor.checkNonUniqueGuidError(loginData)) {
1460        continue;
1461      }
1462      if (processor.checkMissingMandatoryFieldsError(loginData)) {
1463        continue;
1464      }
1465      processor.cleanupActionAndRealmFields(loginData);
1466      if (await processor.checkExistingEntry(loginData)) {
1467        continue;
1468      }
1469      let login = processor.createNewLogin(loginData);
1470      if (processor.checkLoginValuesError(login, loginData)) {
1471        continue;
1472      }
1473      if (processor.checkConflictingOriginWithPreviousRows(login)) {
1474        continue;
1475      }
1476      if (processor.checkConflictingWithExistingLogins(login)) {
1477        continue;
1478      }
1479      processor.addLoginToSummary(login, "added");
1480    }
1481    return processor.processLoginsAndBuildSummary();
1482  },
1483
1484  /**
1485   * Convert an array of nsILoginInfo to vanilla JS objects suitable for
1486   * sending over IPC. Avoid using this in other cases.
1487   *
1488   * NB: All members of nsILoginInfo (not nsILoginMetaInfo) are strings.
1489   */
1490  loginsToVanillaObjects(logins) {
1491    return logins.map(this.loginToVanillaObject);
1492  },
1493
1494  /**
1495   * Same as above, but for a single login.
1496   */
1497  loginToVanillaObject(login) {
1498    let obj = {};
1499    for (let i in login.QueryInterface(Ci.nsILoginMetaInfo)) {
1500      if (typeof login[i] !== "function") {
1501        obj[i] = login[i];
1502      }
1503    }
1504    return obj;
1505  },
1506
1507  /**
1508   * Convert an object received from IPC into an nsILoginInfo (with guid).
1509   */
1510  vanillaObjectToLogin(login) {
1511    let formLogin = Cc["@mozilla.org/login-manager/loginInfo;1"].createInstance(
1512      Ci.nsILoginInfo
1513    );
1514    formLogin.init(
1515      login.origin,
1516      login.formActionOrigin,
1517      login.httpRealm,
1518      login.username,
1519      login.password,
1520      login.usernameField,
1521      login.passwordField
1522    );
1523
1524    formLogin.QueryInterface(Ci.nsILoginMetaInfo);
1525    for (let prop of [
1526      "guid",
1527      "timeCreated",
1528      "timeLastUsed",
1529      "timePasswordChanged",
1530      "timesUsed",
1531    ]) {
1532      formLogin[prop] = login[prop];
1533    }
1534    return formLogin;
1535  },
1536
1537  /**
1538   * As above, but for an array of objects.
1539   */
1540  vanillaObjectsToLogins(vanillaObjects) {
1541    const logins = [];
1542    for (const vanillaObject of vanillaObjects) {
1543      logins.push(this.vanillaObjectToLogin(vanillaObject));
1544    }
1545    return logins;
1546  },
1547
1548  /**
1549   * Returns true if the user has a master password set and false otherwise.
1550   */
1551  isMasterPasswordSet() {
1552    let tokenDB = Cc["@mozilla.org/security/pk11tokendb;1"].getService(
1553      Ci.nsIPK11TokenDB
1554    );
1555    let token = tokenDB.getInternalKeyToken();
1556    return token.hasPassword;
1557  },
1558
1559  /**
1560   * Shows the Master Password prompt if enabled, or the
1561   * OS auth dialog otherwise.
1562   * @param {Element} browser
1563   *        The <browser> that the prompt should be shown on
1564   * @param OSReauthEnabled Boolean indicating if OS reauth should be tried
1565   * @param expirationTime Optional timestamp indicating next required re-authentication
1566   * @param messageText Formatted and localized string to be displayed when the OS auth dialog is used.
1567   * @param captionText Formatted and localized string to be displayed when the OS auth dialog is used.
1568   */
1569  async requestReauth(
1570    browser,
1571    OSReauthEnabled,
1572    expirationTime,
1573    messageText,
1574    captionText
1575  ) {
1576    let isAuthorized = false;
1577    let telemetryEvent;
1578
1579    // This does no harm if master password isn't set.
1580    let tokendb = Cc["@mozilla.org/security/pk11tokendb;1"].createInstance(
1581      Ci.nsIPK11TokenDB
1582    );
1583    let token = tokendb.getInternalKeyToken();
1584
1585    // Do we have a recent authorization?
1586    if (expirationTime && Date.now() < expirationTime) {
1587      isAuthorized = true;
1588      telemetryEvent = {
1589        object: token.hasPassword ? "master_password" : "os_auth",
1590        method: "reauthenticate",
1591        value: "success_no_prompt",
1592      };
1593      return {
1594        isAuthorized,
1595        telemetryEvent,
1596      };
1597    }
1598
1599    // Default to true if there is no master password and OS reauth is not available
1600    if (!token.hasPassword && !OSReauthEnabled) {
1601      isAuthorized = true;
1602      telemetryEvent = {
1603        object: "os_auth",
1604        method: "reauthenticate",
1605        value: "success_disabled",
1606      };
1607      return {
1608        isAuthorized,
1609        telemetryEvent,
1610      };
1611    }
1612    // Use the OS auth dialog if there is no master password
1613    if (!token.hasPassword && OSReauthEnabled) {
1614      let result = await OSKeyStore.ensureLoggedIn(
1615        messageText,
1616        captionText,
1617        browser.ownerGlobal,
1618        false
1619      );
1620      isAuthorized = result.authenticated;
1621      telemetryEvent = {
1622        object: "os_auth",
1623        method: "reauthenticate",
1624        value: result.auth_details,
1625        extra: result.auth_details_extra,
1626      };
1627      return {
1628        isAuthorized,
1629        telemetryEvent,
1630      };
1631    }
1632    // We'll attempt to re-auth via Master Password, force a log-out
1633    token.checkPassword("");
1634
1635    // If a master password prompt is already open, just exit early and return false.
1636    // The user can re-trigger it after responding to the already open dialog.
1637    if (Services.logins.uiBusy) {
1638      isAuthorized = false;
1639      return {
1640        isAuthorized,
1641        telemetryEvent,
1642      };
1643    }
1644
1645    // So there's a master password. But since checkPassword didn't succeed, we're logged out (per nsIPK11Token.idl).
1646    try {
1647      // Relogin and ask for the master password.
1648      token.login(true); // 'true' means always prompt for token password. User will be prompted until
1649      // clicking 'Cancel' or entering the correct password.
1650    } catch (e) {
1651      // An exception will be thrown if the user cancels the login prompt dialog.
1652      // User is also logged out of Software Security Device.
1653    }
1654    isAuthorized = token.isLoggedIn();
1655    telemetryEvent = {
1656      object: "master_password",
1657      method: "reauthenticate",
1658      value: isAuthorized ? "success" : "fail",
1659    };
1660    return {
1661      isAuthorized,
1662      telemetryEvent,
1663    };
1664  },
1665
1666  /**
1667   * Send a notification when stored data is changed.
1668   */
1669  notifyStorageChanged(changeType, data) {
1670    let dataObject = data;
1671    // Can't pass a raw JS string or array though notifyObservers(). :-(
1672    if (Array.isArray(data)) {
1673      dataObject = Cc["@mozilla.org/array;1"].createInstance(
1674        Ci.nsIMutableArray
1675      );
1676      for (let i = 0; i < data.length; i++) {
1677        dataObject.appendElement(data[i]);
1678      }
1679    } else if (typeof data == "string") {
1680      dataObject = Cc["@mozilla.org/supports-string;1"].createInstance(
1681        Ci.nsISupportsString
1682      );
1683      dataObject.data = data;
1684    }
1685    Services.obs.notifyObservers(
1686      dataObject,
1687      "passwordmgr-storage-changed",
1688      changeType
1689    );
1690  },
1691
1692  isUserFacingLogin(login) {
1693    return login.origin != "chrome://FirefoxAccounts"; // FXA_PWDMGR_HOST
1694  },
1695
1696  async getAllUserFacingLogins() {
1697    try {
1698      let logins = await Services.logins.getAllLoginsAsync();
1699      return logins.filter(this.isUserFacingLogin);
1700    } catch (e) {
1701      if (e.result == Cr.NS_ERROR_ABORT) {
1702        // If the user cancels the MP prompt then return no logins.
1703        return [];
1704      }
1705      throw e;
1706    }
1707  },
1708
1709  createLoginAlreadyExistsError(guid) {
1710    // The GUID is stored in an nsISupportsString here because we cannot pass
1711    // raw JS objects within Components.Exception due to bug 743121.
1712    let guidSupportsString = Cc[
1713      "@mozilla.org/supports-string;1"
1714    ].createInstance(Ci.nsISupportsString);
1715    guidSupportsString.data = guid;
1716    return Components.Exception("This login already exists.", {
1717      data: guidSupportsString,
1718    });
1719  },
1720
1721  /**
1722   * Determine the <browser> that a prompt should be shown on.
1723   *
1724   * Some sites pop up a temporary login window, which disappears
1725   * upon submission of credentials. We want to put the notification
1726   * prompt in the opener window if this seems to be happening.
1727   *
1728   * @param {Element} browser
1729   *        The <browser> that a prompt was triggered for
1730   * @returns {Element} The <browser> that the prompt should be shown on,
1731   *                    which could be in a different window.
1732   */
1733  getBrowserForPrompt(browser) {
1734    let chromeWindow = browser.ownerGlobal;
1735    let openerBrowsingContext = browser.browsingContext.opener;
1736    let openerBrowser = openerBrowsingContext
1737      ? openerBrowsingContext.top.embedderElement
1738      : null;
1739    if (openerBrowser) {
1740      let chromeDoc = chromeWindow.document.documentElement;
1741
1742      // Check to see if the current window was opened with chrome
1743      // disabled, and if so use the opener window. But if the window
1744      // has been used to visit other pages (ie, has a history),
1745      // assume it'll stick around and *don't* use the opener.
1746      if (chromeDoc.getAttribute("chromehidden") && !browser.canGoBack) {
1747        log.debug("Using opener window for prompt.");
1748        return openerBrowser;
1749      }
1750    }
1751
1752    return browser;
1753  },
1754};
1755
1756XPCOMUtils.defineLazyPreferenceGetter(
1757  LoginHelper,
1758  "showInsecureFieldWarning",
1759  "security.insecure_field_warning.contextual.enabled"
1760);
1761
1762XPCOMUtils.defineLazyGetter(this, "log", () => {
1763  let processName =
1764    Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT
1765      ? "Main"
1766      : "Content";
1767  return LoginHelper.createLogger(`LoginHelper(${processName})`);
1768});
1769
1770LoginHelper.init();
1771