diff --git a/browser/components/BrowserGlue.jsm b/browser/components/BrowserGlue.jsm index 5e9914c67729..c2bfa9b74d41 100644 --- a/browser/components/BrowserGlue.jsm +++ b/browser/components/BrowserGlue.jsm @@ -519,6 +519,7 @@ XPCOMUtils.defineLazyModuleGetters(this, { HomePage: "resource:///modules/HomePage.jsm", HybridContentTelemetry: "resource://gre/modules/HybridContentTelemetry.jsm", Integration: "resource://gre/modules/Integration.jsm", + LoginBreaches: "resource:///modules/LoginBreaches.jsm", LiveBookmarkMigrator: "resource:///modules/LiveBookmarkMigrator.jsm", NewTabUtils: "resource://gre/modules/NewTabUtils.jsm", Normandy: "resource://normandy/Normandy.jsm", @@ -990,6 +991,8 @@ BrowserGlue.prototype = { "migrateMatchBucketsPrefForUI66-done" ); }); + } else if (data == "add-breaches-sync-handler") { + this._addBreachesSyncHandler(); } break; case "initial-migration-will-import-default-bookmarks": @@ -2203,6 +2206,7 @@ BrowserGlue.prototype = { Services.tm.idleDispatchToMainThread(() => { RemoteSettings.init(); + this._addBreachesSyncHandler(); }); Services.tm.idleDispatchToMainThread(() => { @@ -2210,6 +2214,22 @@ BrowserGlue.prototype = { }); }, + _addBreachesSyncHandler() { + if ( + Services.prefs.getBoolPref( + "signon.management.page.breach-alerts.enabled", + false + ) + ) { + RemoteSettings(LoginBreaches.REMOTE_SETTINGS_COLLECTION).on( + "sync", + async event => { + await LoginBreaches.update(event.data.current); + } + ); + } + }, + _onQuitRequest: function BG__onQuitRequest(aCancelQuit, aQuitType) { // If user has already dismissed quit request, then do nothing if (aCancelQuit instanceof Ci.nsISupportsPRBool && aCancelQuit.data) { diff --git a/browser/components/about/AboutProtectionsHandler.jsm b/browser/components/about/AboutProtectionsHandler.jsm index 7030620742a6..8c7a3a5c0b1e 100644 --- a/browser/components/about/AboutProtectionsHandler.jsm +++ b/browser/components/about/AboutProtectionsHandler.jsm @@ -15,20 +15,12 @@ const { RemotePages } = ChromeUtils.import( ); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); -ChromeUtils.defineModuleGetter( - this, - "fxAccounts", - "resource://gre/modules/FxAccounts.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "LoginHelper", - "resource://gre/modules/LoginHelper.jsm" -); - XPCOMUtils.defineLazyModuleGetters(this, { + fxAccounts: "resource://gre/modules/FxAccounts.jsm", FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.js", FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.js", + LoginBreaches: "resource:///modules/LoginBreaches.jsm", + LoginHelper: "resource://gre/modules/LoginHelper.jsm", }); XPCOMUtils.defineLazyServiceGetter( @@ -213,7 +205,7 @@ var AboutProtectionsHandler = { // password is set. if (!LoginHelper.isMasterPasswordSet()) { const logins = await LoginHelper.getAllUserFacingLogins(); - potentiallyBreachedLogins = await LoginHelper.getBreachesForLogins( + potentiallyBreachedLogins = await LoginBreaches.getPotentialBreachesByLoginGUID( logins ); } diff --git a/browser/components/aboutlogins/AboutLoginsParent.jsm b/browser/components/aboutlogins/AboutLoginsParent.jsm index 33f91e05e00c..b4b1cee14565 100644 --- a/browser/components/aboutlogins/AboutLoginsParent.jsm +++ b/browser/components/aboutlogins/AboutLoginsParent.jsm @@ -9,37 +9,16 @@ var EXPORTED_SYMBOLS = ["AboutLoginsParent"]; const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); -ChromeUtils.defineModuleGetter( - this, - "E10SUtils", - "resource://gre/modules/E10SUtils.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "LoginHelper", - "resource://gre/modules/LoginHelper.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "MigrationUtils", - "resource:///modules/MigrationUtils.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "Services", - "resource://gre/modules/Services.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "UIState", - "resource://services-sync/UIState.jsm" -); -ChromeUtils.defineModuleGetter( - this, - "PlacesUtils", - "resource://gre/modules/PlacesUtils.jsm" -); +XPCOMUtils.defineLazyModuleGetters(this, { + E10SUtils: "resource://gre/modules/E10SUtils.jsm", + LoginBreaches: "resource:////modules/LoginBreaches.jsm", + LoginHelper: "resource://gre/modules/LoginHelper.jsm", + MigrationUtils: "resource:///modules/MigrationUtils.jsm", + Services: "resource://gre/modules/Services.jsm", + UIState: "resource://services-sync/UIState.jsm", + PlacesUtils: "resource://gre/modules/PlacesUtils.jsm", +}); XPCOMUtils.defineLazyGetter(this, "log", () => { return LoginHelper.createLogger("AboutLoginsParent"); @@ -121,9 +100,9 @@ var AboutLoginsParent = { case "AboutLogins:DismissBreachAlert": { const login = message.data.login; - await LoginHelper.recordBreachAlertDismissal(login.guid); + await LoginBreaches.recordDismissal(login.guid); const logins = await this.getAllLogins(); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( logins ); const messageManager = message.target.messageManager; @@ -428,7 +407,7 @@ var AboutLoginsParent = { ); if (BREACH_ALERTS_ENABLED) { - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( logins ); messageManager.sendAsyncMessage( diff --git a/browser/components/aboutlogins/LoginBreaches.jsm b/browser/components/aboutlogins/LoginBreaches.jsm new file mode 100644 index 000000000000..f24e6330cb98 --- /dev/null +++ b/browser/components/aboutlogins/LoginBreaches.jsm @@ -0,0 +1,171 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * Manages breach alerts for saved logins using data from Firefox Monitor via + * RemoteSettings. + */ + +"use strict"; + +const EXPORTED_SYMBOLS = ["LoginBreaches"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + LoginHelper: "resource://gre/modules/LoginHelper.jsm", + RemoteSettings: "resource://services-settings/remote-settings.js", + RemoteSettingsClient: "resource://services-settings/RemoteSettingsClient.jsm", +}); + +this.LoginBreaches = { + REMOTE_SETTINGS_COLLECTION: "fxmonitor-breaches", + + async recordDismissal(loginGuid) { + await Services.logins.initializationPromise; + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + + return storageJSON.recordBreachAlertDismissal(loginGuid); + }, + + async update(breaches = null) { + const logins = await LoginHelper.getAllUserFacingLogins(); + await this.getBreachesForLogins(logins, breaches); + }, + + /** + * Return a Map of login GUIDs to a potential breach affecting that login + * by considering only breaches affecting passwords. + * + * This only uses the breach `Domain` and `timePasswordChanged` to determine + * if a login may be breached which means it may contain false-positives if + * login timestamps are incorrect, the user didn't save their password change + * in Firefox, or the breach didn't contain all accounts, etc. As a result, + * consumers should avoid making stronger claims than the data supports. + * + * @param {nsILoginInfo[]} logins Saved logins to check for potential breaches. + * @param {object[]} [breaches = null] Only ones involving passwords will be used. + * @returns {Map} with a key for each login GUID potentially in a breach. + */ + async getPotentialBreachesByLoginGUID(logins, breaches = null) { + const breachesByLoginGUID = new Map(); + if (!breaches) { + try { + breaches = await RemoteSettings(this.REMOTE_SETTINGS_COLLECTION).get(); + } catch (ex) { + if (ex instanceof RemoteSettingsClient.UnknownCollectionError) { + log.warn( + "Could not get Remote Settings collection.", + this.REMOTE_SETTINGS_COLLECTION, + ex + ); + return breachesByLoginGUID; + } + throw ex; + } + } + const BREACH_ALERT_URL = Services.prefs.getStringPref( + "signon.management.page.breachAlertUrl" + ); + const baseBreachAlertURL = new URL(BREACH_ALERT_URL); + + await Services.logins.initializationPromise; + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + const dismissedBreachAlertsByLoginGUID = storageJSON.getBreachAlertDismissalsByLoginGUID(); + + // Determine potentially breached logins by checking their origin and the last time + // they were changed. It's important to note here that we are NOT considering the + // username and password of that login. + for (const login of logins) { + const loginURI = Services.io.newURI(login.origin); + let loginHost; + try { + // nsIURI.host can throw if the URI scheme doesn't have a host. + loginHost = loginURI.host; + } catch (ex) { + continue; + } + for (const breach of breaches) { + if ( + !breach.Domain || + !Services.eTLD.hasRootDomain(loginHost, breach.Domain) || + !this._breachInvolvedPasswords(breach) || + !this._breachWasAfterPasswordLastChanged(breach, login) + ) { + continue; + } + + if (!storageJSON.isPotentiallyVulnerablePassword(login)) { + storageJSON.addPotentiallyVulnerablePassword(login); + } + + if ( + this._breachAlertIsDismissed( + login, + breach, + dismissedBreachAlertsByLoginGUID + ) + ) { + continue; + } + + let breachAlertURL = new URL(breach.Name, baseBreachAlertURL); + breach.breachAlertURL = breachAlertURL.href; + breachesByLoginGUID.set(login.guid, breach); + } + } + return breachesByLoginGUID; + }, + + /** + * Return information about logins using passwords that were potentially in a + * breach. + * @see the caveats in the documentation for `getPotentialBreachesByLoginGUID`. + * + * @param {nsILoginInfo[]} logins to check the passwords of. + * @returns {Map} from login GUID to `true` for logins that have a password + * that may be vulnerable. + */ + getPotentiallyVulnerablePasswordsByLoginGUID(logins) { + const vulnerablePasswordsByLoginGUID = new Map(); + const storageJSON = + Services.logins.wrappedJSObject._storage.wrappedJSObject; + for (const login of logins) { + if (storageJSON.isPotentiallyVulnerablePassword(login)) { + vulnerablePasswordsByLoginGUID.set(login.guid, true); + } + } + return vulnerablePasswordsByLoginGUID; + }, + + _breachAlertIsDismissed(login, breach, dismissedBreachAlerts) { + const breachAddedDate = new Date(breach.AddedDate).getTime(); + const breachAlertIsDismissed = + dismissedBreachAlerts[login.guid] && + dismissedBreachAlerts[login.guid].timeBreachAlertDismissed > + breachAddedDate; + return breachAlertIsDismissed; + }, + + _breachInvolvedPasswords(breach) { + return ( + breach.hasOwnProperty("DataClasses") && + breach.DataClasses.includes("Passwords") + ); + }, + + _breachWasAfterPasswordLastChanged(breach, login) { + const breachDate = new Date(breach.BreachDate).getTime(); + return login.timePasswordChanged < breachDate; + }, +}; + +XPCOMUtils.defineLazyGetter(this, "log", () => { + return LoginHelper.createLogger("LoginBreaches"); +}); diff --git a/browser/components/aboutlogins/content/components/login-item.css b/browser/components/aboutlogins/content/components/login-item.css index a32c7879af05..823990cad38d 100644 --- a/browser/components/aboutlogins/content/components/login-item.css +++ b/browser/components/aboutlogins/content/components/login-item.css @@ -54,6 +54,7 @@ input[type="url"][readOnly] { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } .delete-button, diff --git a/browser/components/aboutlogins/content/components/login-list.css b/browser/components/aboutlogins/content/components/login-list.css index a96bbf5999e0..3350dbc77cea 100644 --- a/browser/components/aboutlogins/content/components/login-list.css +++ b/browser/components/aboutlogins/content/components/login-list.css @@ -117,6 +117,7 @@ ol { display: block; text-overflow: ellipsis; overflow: hidden; + white-space: nowrap; } .favicon-wrapper { diff --git a/browser/components/aboutlogins/moz.build b/browser/components/aboutlogins/moz.build index a2ecc5be000e..d61498856118 100644 --- a/browser/components/aboutlogins/moz.build +++ b/browser/components/aboutlogins/moz.build @@ -11,6 +11,7 @@ with Files('**'): EXTRA_JS_MODULES += [ 'AboutLoginsParent.jsm', + 'LoginBreaches.jsm', ] FINAL_TARGET_FILES.actors += [ diff --git a/browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js b/browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js index ba99e19ac5aa..011422bf90c6 100644 --- a/browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js +++ b/browser/components/aboutlogins/tests/browser/browser_breachAlertDismissals.js @@ -1,6 +1,10 @@ /* Any copyright is dedicated to the Public Domain. * http://creativecommons.org/publicdomain/zero/1.0/ */ +let { LoginBreaches } = ChromeUtils.import( + "resource:///modules/LoginBreaches.jsm" +); + const TEST_BREACHES = [ { AddedDate: "2019-12-20T23:56:26Z", @@ -31,7 +35,7 @@ add_task(async function setup() { add_task(async function test_show_login() { let browser = gBrowser.selectedBrowser; TEST_LOGIN3.timePasswordChanged = 12345; - let testBreaches = await LoginHelper.getBreachesForLogins( + let testBreaches = await LoginBreaches.getPotentialBreachesByLoginGUID( [TEST_LOGIN3], TEST_BREACHES ); diff --git a/browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js similarity index 59% rename from browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js rename to browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js index a99b382f1f18..453b5fd7a3c3 100644 --- a/browser/components/aboutlogins/tests/unit/test_getBreachesForLogins.js +++ b/browser/components/aboutlogins/tests/unit/test_getPotentialBreachesByLoginGUID.js @@ -1,18 +1,21 @@ /** - * Test LoginHelper.getBreachesForLogins + * Test LoginBreaches.getPotentialBreachesByLoginGUID */ "use strict"; +const { RemoteSettings } = ChromeUtils.import( + "resource://services-settings/remote-settings.js" +); const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); -const { AboutLoginsParent } = ChromeUtils.import( - "resource:///modules/AboutLoginsParent.jsm" +const gBrowserGlue = Cc["@mozilla.org/browser/browserglue;1"].getService( + Ci.nsIObserver ); ChromeUtils.defineModuleGetter( this, - "LoginHelper", - "resource://gre/modules/LoginHelper.jsm" + "LoginBreaches", + "resource:///modules/LoginBreaches.jsm" ); const TEST_BREACHES = [ @@ -59,7 +62,7 @@ const NOT_BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ formActionOrigin: "https://www.example.com", username: "username", password: "password", - timePasswordChanged: Date.now(), + timePasswordChanged: new Date("2018-12-15").getTime(), }); const BREACHED_LOGIN = LoginTestUtils.testData.formLogin({ origin: "https://www.breached.com", @@ -98,10 +101,10 @@ const LOGIN_WITH_NON_STANDARD_URI = LoginTestUtils.testData.formLogin({ timePasswordChanged: new Date("2018-12-15").getTime(), }); -add_task(async function test_getBreachesForLogins_notBreachedLogin() { +add_task(async function test_notBreachedLogin() { Services.logins.addLogin(NOT_BREACHED_LOGIN); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( [NOT_BREACHED_LOGIN], TEST_BREACHES ); @@ -112,10 +115,10 @@ add_task(async function test_getBreachesForLogins_notBreachedLogin() { ); }); -add_task(async function test_getBreachesForLogins_breachedLogin() { +add_task(async function test_breachedLogin() { Services.logins.addLogin(BREACHED_LOGIN); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( [NOT_BREACHED_LOGIN, BREACHED_LOGIN], TEST_BREACHES ); @@ -126,10 +129,10 @@ add_task(async function test_getBreachesForLogins_breachedLogin() { ); }); -add_task(async function test_getBreachesForLogins_notBreachedSubdomain() { +add_task(async function test_notBreachedSubdomain() { Services.logins.addLogin(NOT_BREACHED_SUBDOMAIN_LOGIN); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( [NOT_BREACHED_LOGIN, NOT_BREACHED_SUBDOMAIN_LOGIN], TEST_BREACHES ); @@ -140,10 +143,10 @@ add_task(async function test_getBreachesForLogins_notBreachedSubdomain() { ); }); -add_task(async function test_getBreachesForLogins_breachedSubdomain() { +add_task(async function test_breachedSubdomain() { Services.logins.addLogin(BREACHED_SUBDOMAIN_LOGIN); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( [NOT_BREACHED_SUBDOMAIN_LOGIN, BREACHED_SUBDOMAIN_LOGIN], TEST_BREACHES ); @@ -154,49 +157,58 @@ add_task(async function test_getBreachesForLogins_breachedSubdomain() { ); }); -add_task( - async function test_getBreachesForLogins_breachedSiteWithoutPasswords() { - Services.logins.addLogin(LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS); +add_task(async function test_breachedSiteWithoutPasswords() { + Services.logins.addLogin(LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( - [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS], - TEST_BREACHES - ); - Assert.strictEqual( - breachesByLoginGUID.size, - 0, - "Should be 0 breached login: " + - LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin - ); - } -); + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached login: " + + LOGIN_FOR_BREACHED_SITE_WITHOUT_PASSWORDS.origin + ); +}); -add_task( - async function test_getBreachesForLogins_breachAlertHiddenAfterDismissal() { - BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}"; +add_task(async function test_breachAlertHiddenAfterDismissal() { + BREACHED_LOGIN.guid = "{d2de5ac1-4de6-e544-a7af-1f75abcba92b}"; - await Services.logins.initializationPromise; - const storageJSON = - Services.logins.wrappedJSObject._storage.wrappedJSObject; + await Services.logins.initializationPromise; + const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject; - storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid); + storageJSON.recordBreachAlertDismissal(BREACHED_LOGIN.guid); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( - [BREACHED_LOGIN, NOT_BREACHED_LOGIN], - TEST_BREACHES - ); - Assert.strictEqual( - breachesByLoginGUID.size, - 0, - "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin - ); - } -); + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID.size, + 0, + "Should be 0 breached logins after dismissal: " + BREACHED_LOGIN.origin + ); -add_task(async function test_getBreachesForLogins_newBreachAfterDismissal() { + info("Clear login storage"); + Services.logins.removeAllLogins(); + + const breachesByLoginGUID2 = await LoginBreaches.getPotentialBreachesByLoginGUID( + [BREACHED_LOGIN, NOT_BREACHED_LOGIN], + TEST_BREACHES + ); + Assert.strictEqual( + breachesByLoginGUID2.size, + 1, + "Breached login should re-appear after clearing storage: " + + BREACHED_LOGIN.origin + ); +}); + +add_task(async function test_newBreachAfterDismissal() { TEST_BREACHES[0].AddedDate = new Date().toISOString(); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( [BREACHED_LOGIN, NOT_BREACHED_LOGIN], TEST_BREACHES ); @@ -209,19 +221,17 @@ add_task(async function test_getBreachesForLogins_newBreachAfterDismissal() { ); }); -add_task( - async function test_getBreachesForLogins_ExceptionsThrownByNonStandardURIsAreCaught() { - Services.logins.addLogin(LOGIN_WITH_NON_STANDARD_URI); +add_task(async function test_ExceptionsThrownByNonStandardURIsAreCaught() { + Services.logins.addLogin(LOGIN_WITH_NON_STANDARD_URI); - const breachesByLoginGUID = await LoginHelper.getBreachesForLogins( - [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN], - TEST_BREACHES - ); + const breachesByLoginGUID = await LoginBreaches.getPotentialBreachesByLoginGUID( + [LOGIN_WITH_NON_STANDARD_URI, BREACHED_LOGIN], + TEST_BREACHES + ); - Assert.strictEqual( - breachesByLoginGUID.size, - 1, - "Exceptions thrown by logins with non-standard URIs should be caught." - ); - } -); + Assert.strictEqual( + breachesByLoginGUID.size, + 1, + "Exceptions thrown by logins with non-standard URIs should be caught." + ); +}); diff --git a/browser/components/aboutlogins/tests/unit/xpcshell.ini b/browser/components/aboutlogins/tests/unit/xpcshell.ini index dab14f5cd39d..6ce04c4e711d 100644 --- a/browser/components/aboutlogins/tests/unit/xpcshell.ini +++ b/browser/components/aboutlogins/tests/unit/xpcshell.ini @@ -2,4 +2,4 @@ head = head.js firefox-appdir = browser -[test_getBreachesForLogins.js] +[test_getPotentialBreachesByLoginGUID.js] diff --git a/toolkit/components/passwordmgr/LoginHelper.jsm b/toolkit/components/passwordmgr/LoginHelper.jsm index 36ecac4ca070..ca9824bf6d32 100644 --- a/toolkit/components/passwordmgr/LoginHelper.jsm +++ b/toolkit/components/passwordmgr/LoginHelper.jsm @@ -13,7 +13,6 @@ "use strict"; const EXPORTED_SYMBOLS = ["LoginHelper"]; -const REMOTE_SETTINGS_BREACHES_COLLECTION = "fxmonitor-breaches"; // Globals @@ -22,18 +21,6 @@ const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); -ChromeUtils.defineModuleGetter( - this, - "RemoteSettings", - "resource://services-settings/remote-settings.js" -); - -ChromeUtils.defineModuleGetter( - this, - "RemoteSettingsClient", - "resource://services-settings/RemoteSettingsClient.jsm" -); - /** * Contains functions shared by different Login Manager components. */ @@ -1112,79 +1099,6 @@ this.LoginHelper = { throw e; } }, - - async recordBreachAlertDismissal(loginGuid) { - await Services.logins.initializationPromise; - const storageJSON = - Services.logins.wrappedJSObject._storage.wrappedJSObject; - - return storageJSON.recordBreachAlertDismissal(loginGuid); - }, - - async getBreachesForLogins(logins, breaches = null) { - const breachesByLoginGUID = new Map(); - if (!breaches) { - try { - breaches = await RemoteSettings( - REMOTE_SETTINGS_BREACHES_COLLECTION - ).get(); - } catch (ex) { - if (ex instanceof RemoteSettingsClient.UnknownCollectionError) { - log.warn( - "Could not get Remote Settings collection.", - REMOTE_SETTINGS_BREACHES_COLLECTION, - ex - ); - return breachesByLoginGUID; - } - throw ex; - } - } - const BREACH_ALERT_URL = Services.prefs.getStringPref( - "signon.management.page.breachAlertUrl" - ); - const baseBreachAlertURL = new URL(BREACH_ALERT_URL); - - await Services.logins.initializationPromise; - const storageJSON = - Services.logins.wrappedJSObject._storage.wrappedJSObject; - const dismissedBreachAlertsByLoginGUID = storageJSON.getBreachAlertDismissalsByLoginGUID(); - - // Determine potentially breached logins by checking their origin and the last time - // they were changed. It's important to note here that we are NOT considering the - // username and password of that login. - for (const login of logins) { - const loginURI = Services.io.newURI(login.origin); - let loginHost; - try { - // nsIURI.host can throw if the URI scheme doesn't have a host. - loginHost = loginURI.host; - } catch (ex) { - continue; - } - for (const breach of breaches) { - if (!breach.Domain) { - continue; - } - const breachDate = new Date(breach.BreachDate).getTime(); - const breachAddedDate = new Date(breach.AddedDate).getTime(); - if ( - Services.eTLD.hasRootDomain(loginHost, breach.Domain) && - breach.hasOwnProperty("DataClasses") && - breach.DataClasses.includes("Passwords") && - login.timePasswordChanged < breachDate && - (!dismissedBreachAlertsByLoginGUID[login.guid] || - dismissedBreachAlertsByLoginGUID[login.guid] - .timeBreachAlertDismissed < breachAddedDate) - ) { - let breachAlertURL = new URL(breach.Name, baseBreachAlertURL); - breach.breachAlertURL = breachAlertURL.href; - breachesByLoginGUID.set(login.guid, breach); - } - } - } - return breachesByLoginGUID; - }, }; LoginHelper.init(); @@ -1196,6 +1110,5 @@ XPCOMUtils.defineLazyPreferenceGetter( ); XPCOMUtils.defineLazyGetter(this, "log", () => { - let logger = LoginHelper.createLogger("LoginHelper"); - return logger; + return LoginHelper.createLogger("LoginHelper"); }); diff --git a/toolkit/components/passwordmgr/LoginStore.jsm b/toolkit/components/passwordmgr/LoginStore.jsm index 72c8d592a114..329f889bbfe6 100644 --- a/toolkit/components/passwordmgr/LoginStore.jsm +++ b/toolkit/components/passwordmgr/LoginStore.jsm @@ -98,6 +98,10 @@ LoginStore.prototype._dataPostProcessor = function(data) { data.logins = []; } + if (!data.potentiallyVulnerablePasswords) { + data.potentiallyVulnerablePasswords = []; + } + if (!data.dismissedBreachAlertsByLoginGUID) { data.dismissedBreachAlertsByLoginGUID = {}; } diff --git a/toolkit/components/passwordmgr/storage-json.js b/toolkit/components/passwordmgr/storage-json.js index 438605afd497..449e1281a4c3 100644 --- a/toolkit/components/passwordmgr/storage-json.js +++ b/toolkit/components/passwordmgr/storage-json.js @@ -2,7 +2,7 @@ * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -/* +/** * nsILoginManagerStorage implementation for the JSON back-end. */ @@ -57,6 +57,24 @@ this.LoginManagerStorage_json.prototype = { return this.__crypto; }, + __decryptedPotentiallyVulnerablePasswords: null, + get _decryptedPotentiallyVulnerablePasswords() { + if (!this.__decryptedPotentiallyVulnerablePasswords) { + this._store.ensureDataReady(); + this.__decryptedPotentiallyVulnerablePasswords = []; + for (const potentiallyVulnerablePassword of this._store.data + .potentiallyVulnerablePasswords) { + const decryptedPotentiallyVulnerablePassword = this._crypto.decrypt( + potentiallyVulnerablePassword.encryptedPassword + ); + this.__decryptedPotentiallyVulnerablePasswords.push( + decryptedPotentiallyVulnerablePassword + ); + } + } + return this.__decryptedPotentiallyVulnerablePasswords; + }, + initialize() { try { // Force initialization of the crypto module. @@ -553,6 +571,9 @@ this.LoginManagerStorage_json.prototype = { this.log("Removing all logins"); this._store.data.logins = []; + this._store.data.potentiallyVulnerablePasswords = []; + this.__decryptedPotentiallyVulnerablePasswords = null; + this._store.data.dismissedBreachAlertsByLoginGUID = {}; this._store.saveSoon(); LoginHelper.notifyStorageChanged("removeAllLogins", null); @@ -597,6 +618,26 @@ this.LoginManagerStorage_json.prototype = { return logins.length; }, + addPotentiallyVulnerablePassword(login) { + this._store.ensureDataReady(); + // this breached password is already stored + if (this.isPotentiallyVulnerablePassword(login)) { + return; + } + this.__decryptedPotentiallyVulnerablePasswords.push(login.password); + + this._store.data.potentiallyVulnerablePasswords.push({ + encryptedPassword: this._crypto.encrypt(login.password), + }); + this._store.saveSoon(); + }, + + isPotentiallyVulnerablePassword(login) { + return this._decryptedPotentiallyVulnerablePasswords.includes( + login.password + ); + }, + get uiBusy() { return this._crypto.uiBusy; }, diff --git a/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js new file mode 100644 index 000000000000..268691e63fec --- /dev/null +++ b/toolkit/components/passwordmgr/test/unit/test_vulnerable_passwords.js @@ -0,0 +1,38 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +add_task(async function setup() { + await Services.logins.initializationPromise; +}); + +add_task(async function test_vulnerable_password_methods() { + const storageJSON = Services.logins.wrappedJSObject._storage.wrappedJSObject; + + let logins = TestData.loginList(); + Assert.greater(logins.length, 0, "Initial logins length should be > 0."); + + for (let loginInfo of logins) { + Services.logins.addLogin(loginInfo); + Assert.ok( + !storageJSON.isPotentiallyVulnerablePassword(loginInfo), + "No logins should be vulnerable until addVulnerablePasswords is called." + ); + } + + const vulnerableLogin = logins.shift(); + storageJSON.addPotentiallyVulnerablePassword(vulnerableLogin); + + Assert.ok( + storageJSON.isPotentiallyVulnerablePassword(vulnerableLogin), + "Login should be vulnerable after calling addVulnerablePassword." + ); + for (let loginInfo of logins) { + Assert.ok( + !storageJSON.isPotentiallyVulnerablePassword(loginInfo), + "No other logins should be vulnerable when addVulnerablePassword is called" + + " with a single argument" + ); + } +}); diff --git a/toolkit/components/passwordmgr/test/unit/xpcshell.ini b/toolkit/components/passwordmgr/test/unit/xpcshell.ini index 2e0c792a6711..f354cd06f60c 100644 --- a/toolkit/components/passwordmgr/test/unit/xpcshell.ini +++ b/toolkit/components/passwordmgr/test/unit/xpcshell.ini @@ -57,3 +57,5 @@ skip-if = os == "android" # Not packaged/used on Android [test_shadowHTTPLogins.js] [test_storage.js] [test_telemetry.js] +[test_vulnerable_passwords.js] +skip-if = os == "android" # Not implemented for storage-mozStorage