tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 0305db53899b13877d064d4b47f929640fe9d4a4
parent 62629c88caf131954d49d54e74653848d25428b5
Author: Johannes Jörg Schmidt <joschmidt@mozilla.com>
Date:   Tue,  7 Oct 2025 09:28:34 +0000

Bug 1966327 - Add Rust Logins Storage Adapter and Integration Infrastructure r=dimi,firefox-desktop-core-reviewers ,mossop

* Introduce and integrate storage-rust for logins storage.
  - Add a storage adapter for converting login objects between JS and Rust.
  - Implement necessary Rust storage methods.
* Integrate NSS key manager for secure key handling.
* Introduce a preference (signon.rustMirror.enabled) to control the Rust mirror:
  - Enabled by default for Nightly users.
* Rust Mirror is activated only if no Primary Password is set, ensuring frictionless user experience.
* Perform migration on application startup, if profile needs migration.
  - pref `signon.rustMirror.migrationNeeded` keeps track of the
    migration
* Monitor changes in existing storage-json and reflect them in storage-rust (Bug 1971674).

This milestone (III) focuses on preparing infrastructure and telemetry for the
Application Services Logins component on Firefox desktop. The Rust-based
Logins Storage operates as a write-only mirror initially, targeting
Nightly users without a Primary Password to collect data integrety
telemetry data, laying the groundwork for complete adoption in Milestone IV.

Co-authored-by: Ryan Safaeian <rsafaeian@mozilla.com>
Co-authored-by: Tessa Heidkamp <theidkamp@mozilla.com>

Differential Revision: https://phabricator.services.mozilla.com/D260123

Diffstat:
Mbrowser/components/BrowserGlue.sys.mjs | 2+-
Mbrowser/components/ProfileDataUpgrader.sys.mjs | 5+++++
Mmodules/libpref/init/all.js | 6++++++
Atoolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs | 211+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/passwordmgr/moz.build | 2++
Mtoolkit/components/passwordmgr/storage-desktop.sys.mjs | 35++++++++++++++++++++++++++++-------
Mtoolkit/components/passwordmgr/storage-json.sys.mjs | 28+++++++++++++++++++++++++++-
Atoolkit/components/passwordmgr/storage-rust.sys.mjs | 723+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtoolkit/components/passwordmgr/test/browser/browser.toml | 3+++
Atoolkit/components/passwordmgr/test/browser/browser_rust_mirror.js | 339+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 1345 insertions(+), 9 deletions(-)

diff --git a/browser/components/BrowserGlue.sys.mjs b/browser/components/BrowserGlue.sys.mjs @@ -1605,7 +1605,7 @@ BrowserGlue.prototype = { // Use an increasing number to keep track of the current state of the user's // profile, so we can move data around as needed as the browser evolves. // Completely unrelated to the current Firefox release number. - const APP_DATA_VERSION = 160; + const APP_DATA_VERSION = 161; const PREF = "browser.migration.version"; let profileDataVersion = Services.prefs.getIntPref(PREF, -1); diff --git a/browser/components/ProfileDataUpgrader.sys.mjs b/browser/components/ProfileDataUpgrader.sys.mjs @@ -929,6 +929,11 @@ export let ProfileDataUpgrader = { Services.prefs.setBoolPref("signon.reencryptionNeeded", true); } + if (existingDataVersion < 161) { + // Force all logins to be re-migrated to the rust store. + Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", true); + } + // Update the migration version. Services.prefs.setIntPref("browser.migration.version", newVersion); }, diff --git a/modules/libpref/init/all.js b/modules/libpref/init/all.js @@ -3020,6 +3020,12 @@ pref("signon.firefoxRelay.terms_of_service_url", "https://www.mozilla.org/%LOCAL pref("signon.firefoxRelay.privacy_policy_url", "https://www.mozilla.org/%LOCALE%/privacy/subscription-services/"); pref("signon.signupDetection.confidenceThreshold", "0.75"); +#ifdef NIGHTLY_BUILD + pref("signon.rustMirror.enabled", true); +#else + pref("signon.rustMirror.enabled", false); +#endif + // Satchel (Form Manager) prefs pref("browser.formfill.debug", false); pref("browser.formfill.enable", true); diff --git a/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs b/toolkit/components/passwordmgr/LoginManagerRustMirror.sys.mjs @@ -0,0 +1,211 @@ +/* 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/. */ + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); + +export class LoginManagerRustMirror { + #logger = null; + #jsonStorage = null; + #rustStorage = null; + #isEnabled = false; + #migrationInProgress = false; + #observer = null; + + constructor(jsonStorage, rustStorage) { + this.#logger = lazy.LoginHelper.createLogger("LoginManagerRustMirror"); + this.#jsonStorage = jsonStorage; + this.#rustStorage = rustStorage; + + Services.prefs.addObserver("signon.rustMirror.enabled", () => + this.#maybeEnable(this) + ); + + this.#logger.log("Rust Mirror is ready."); + + this.#maybeEnable(); + } + + #removeJsonStoreObserver() { + if (this.#observer) { + Services.obs.removeObserver( + this.#observer, + "passwordmgr-storage-changed" + ); + this.#observer = null; + } + } + + #addJsonStoreObserver() { + if (!this.#observer) { + this.#observer = (subject, _, eventName) => + this.#onJsonStorageChanged(eventName, subject); + Services.obs.addObserver(this.#observer, "passwordmgr-storage-changed"); + } + } + + #maybeEnable() { + const enabled = + Services.prefs.getBoolPref("signon.rustMirror.enabled", true) && + !lazy.LoginHelper.isPrimaryPasswordSet(); + + return enabled ? this.enable() : this.disable(); + } + + async enable() { + if (this.#isEnabled) { + return; + } + + this.#removeJsonStoreObserver(); + this.#isEnabled = true; + + try { + await this.#maybeRunMigration(); + this.#addJsonStoreObserver(); + this.#logger.log("Rust Mirror is enabled."); + } catch (e) { + this.#logger.error("Login migration failed", e); + } + } + + disable() { + if (!this.#isEnabled) { + return; + } + + this.#removeJsonStoreObserver(); + + this.#isEnabled = false; + this.#logger.log("Rust Mirror is disabled."); + + // Since we'll miss updates we'll need to migrate again once disabled + Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", true); + } + + async #onJsonStorageChanged(eventName, subject) { + this.#logger.log(`received change event ${eventName}...`); + + // eg in case a primary password has been set after enabling + if (!this.#isEnabled || lazy.LoginHelper.isPrimaryPasswordSet()) { + this.#logger.log("Mirror is not active. Change will not be mirrored."); + return; + } + + if (this.#migrationInProgress) { + this.#logger.log(`Migration in progress, skipping event ${eventName}`); + return; + } + + let loginToModify; + let newLoginData; + + switch (eventName) { + case "addLogin": + this.#logger.log(`adding login ${subject.guid}...`); + try { + await this.#rustStorage.addLoginsAsync([subject]); + this.#logger.log(`added login ${subject.guid}.`); + } catch (e) { + this.#logger.error("mirror-error:", e); + } + break; + + case "modifyLogin": + loginToModify = subject.queryElementAt(0, Ci.nsILoginInfo); + newLoginData = subject.queryElementAt(1, Ci.nsILoginInfo); + this.#logger.log(`modifying login ${loginToModify.guid}...`); + try { + this.#rustStorage.modifyLogin(loginToModify, newLoginData); + this.#logger.log(`modified login ${loginToModify.guid}.`); + } catch (e) { + this.#logger.error("error: modifyLogin:", e); + } + break; + + case "removeLogin": + this.#logger.log(`removing login ${subject.guid}...`); + try { + this.#rustStorage.removeLogin(subject); + this.#logger.log(`removed login ${subject.guid}.`); + } catch (e) { + this.#logger.error("error: removeLogin:", e); + } + break; + + case "removeAllLogins": + this.#logger.log("removing all logins..."); + try { + this.#rustStorage.removeAllLogins(); + this.#logger.log("removed all logins."); + } catch (e) { + this.#logger.error("error: removeAllLogins:", e); + } + break; + + case "importLogins": + // ignoring importLogins event + break; + + default: + this.#logger.error(`error: received unhandled event "${eventName}"`); + break; + } + } + + async #maybeRunMigration() { + if (this.#migrationInProgress) { + this.#logger.log("Migration already in progress."); + return; + } + + if (!this.#isEnabled || lazy.LoginHelper.isPrimaryPasswordSet()) { + this.#logger.log("Mirror is not active. Migration will not run."); + return; + } + + const migrationNeeded = Services.prefs.getBoolPref( + "signon.rustMirror.migrationNeeded", + false + ); + + // eg in case a primary password has been set after enabling + if (!migrationNeeded) { + this.#logger.log("No migration needed."); + return; + } + + this.#logger.log("Migration is needed, migrating..."); + + // We ignore events during migration run. Once we switch the + // stores over, we will run an initial migration again to ensure + // consistancy. + this.#migrationInProgress = true; + + // wait until loaded + await this.#jsonStorage.initializationPromise; + + try { + this.#rustStorage.removeAllLogins(); + this.#logger.log("Cleared existing Rust logins."); + + const logins = await this.#jsonStorage.getAllLogins(); + + await this.#rustStorage.addLoginsAsync(logins, true); + + this.#logger.log(`Successfully migrated ${logins.length} logins.`); + + // Migration complete, don't run again + Services.prefs.setBoolPref("signon.rustMirror.migrationNeeded", false); + + this.#logger.log("Migration complete."); + } catch (e) { + this.#logger.error("migration error:", e); + } finally { + this.#migrationInProgress = false; + } + } +} diff --git a/toolkit/components/passwordmgr/moz.build b/toolkit/components/passwordmgr/moz.build @@ -39,10 +39,12 @@ EXTRA_JS_MODULES += [ "LoginManagerChild.sys.mjs", "LoginManagerParent.sys.mjs", "LoginManagerPrompter.sys.mjs", + "LoginManagerRustMirror.sys.mjs", "LoginRecipes.sys.mjs", "LoginRelatedRealms.sys.mjs", "PasswordRulesManager.sys.mjs", "storage-json.sys.mjs", + "storage-rust.sys.mjs", ] EXTRA_JS_MODULES.shared += [ diff --git a/toolkit/components/passwordmgr/storage-desktop.sys.mjs b/toolkit/components/passwordmgr/storage-desktop.sys.mjs @@ -3,18 +3,39 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { LoginManagerStorage_json } from "resource://gre/modules/storage-json.sys.mjs"; +import { LoginManagerRustStorage } from "resource://gre/modules/storage-rust.sys.mjs"; +import { LoginManagerRustMirror } from "resource://gre/modules/LoginManagerRustMirror.sys.mjs"; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", +}); export class LoginManagerStorage extends LoginManagerStorage_json { - static #storage = null; + static #jsonStorage = null; + static #rustStorage = null; + static #logger = lazy.LoginHelper.createLogger("LoginManagerStorage"); + static #initializationPromise = null; static create(callback) { - if (!LoginManagerStorage.#storage) { - LoginManagerStorage.#storage = new LoginManagerStorage(); - LoginManagerStorage.#storage.initialize().then(callback); - } else if (callback) { - callback(); + if (this.#initializationPromise) { + this.#logger.log("json storage already initialized"); + } else { + this.#jsonStorage = new LoginManagerStorage_json(); + this.#rustStorage = new LoginManagerRustStorage(); + + new LoginManagerRustMirror(this.#jsonStorage, this.#rustStorage); + + this.#initializationPromise = new Promise(resolve => + this.#jsonStorage + .initialize() + .then(() => this.#rustStorage.initialize()) + .then(resolve) + ); } - return LoginManagerStorage.#storage; + this.#initializationPromise.then(() => callback?.()); + + return this.#jsonStorage; } } diff --git a/toolkit/components/passwordmgr/storage-json.sys.mjs b/toolkit/components/passwordmgr/storage-json.sys.mjs @@ -676,7 +676,33 @@ export class LoginManagerStorage_json { ) { remainingLogins.push(login); } else { - removedLogins.push(login); + // Create the nsLoginInfo object which to emit + const loginInfo = Cc[ + "@mozilla.org/login-manager/loginInfo;1" + ].createInstance(Ci.nsILoginInfo); + loginInfo.init( + login.hostname, + login.formSubmitURL, + login.httpRealm, + login.encryptedUsername, + login.encryptedPassword, + login.usernameField, + login.passwordField + ); + // set nsILoginMetaInfo values + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + loginInfo.guid = login.guid; + loginInfo.timeCreated = login.timeCreated; + loginInfo.timeLastUsed = login.timeLastUsed; + loginInfo.timePasswordChanged = login.timePasswordChanged; + loginInfo.timesUsed = login.timesUsed; + loginInfo.syncCounter = login.syncCounter; + loginInfo.everSynced = login.everSynced; + + // Any unknown fields along for the ride + loginInfo.unknownFields = login.encryptedUnknownFields; + + removedLogins.push(loginInfo); if (!fullyRemove && login?.everSynced) { // The login has been synced, so mark it as deleted. this.#incrementSyncCounter(login); diff --git a/toolkit/components/passwordmgr/storage-rust.sys.mjs b/toolkit/components/passwordmgr/storage-rust.sys.mjs @@ -0,0 +1,723 @@ +/* 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/. */ + +/** + * LoginManagerStorage implementation for the Rust logins storage back-end. + */ + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + FXA_PWDMGR_HOST: "resource://gre/modules/FxAccountsCommon.sys.mjs", + FXA_PWDMGR_REALM: "resource://gre/modules/FxAccountsCommon.sys.mjs", + + LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", + + AsyncShutdown: "resource://gre/modules/AsyncShutdown.sys.mjs", +}); + +import { initialize as initRustComponents } from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustInitRustComponents.sys.mjs"; + +import { + LoginEntry, + LoginMeta, + LoginEntryWithMeta, + BulkResultEntry, + PrimaryPasswordAuthenticator, + createLoginStoreWithNssKeymanager, +} from "moz-src:///toolkit/components/uniffi-bindgen-gecko-js/components/generated/RustLogins.sys.mjs"; + +const LoginInfo = Components.Constructor( + "@mozilla.org/login-manager/loginInfo;1", + "nsILoginInfo", + "init" +); + +// Convert a LoginInfo, as known to the JS world, to a LoginEntry, the Rust +// Login type. +// This could be an instance method implemented in +// toolkit/components/passwordmgr/LoginInfo.sys.mjs +// but I'd like to decouple from as many components as possible by now +const loginInfoToLoginEntry = loginInfo => + new LoginEntry({ + origin: loginInfo.origin, + httpRealm: loginInfo.httpRealm, + formActionOrigin: loginInfo.formActionOrigin, + usernameField: loginInfo.usernameField, + passwordField: loginInfo.passwordField, + username: loginInfo.username, + password: loginInfo.password, + }); + +// Convert a LoginInfo to a LoginEntryWithMeta, to be used for migrating +// records between legacy and Rust storage. +const loginInfoToLoginEntryWithMeta = loginInfo => + new LoginEntryWithMeta({ + entry: loginInfoToLoginEntry(loginInfo), + meta: new LoginMeta({ + id: loginInfo.guid, + timesUsed: loginInfo.timesUsed, + timeCreated: loginInfo.timeCreated, + timeLastUsed: loginInfo.timeLastUsed, + timePasswordChanged: loginInfo.timePasswordChanged, + }), + }); + +// Convert a Login instance, as returned from Rust Logins, to a LoginInfo +const loginToLoginInfo = login => { + const loginInfo = new LoginInfo( + login.origin, + login.formActionOrigin, + login.httpRealm, + login.username, + login.password, + login.usernameField, + login.passwordField + ); + + // add meta information + loginInfo.QueryInterface(Ci.nsILoginMetaInfo); + // Rust Login ids are guids + loginInfo.guid = login.id; + loginInfo.timeCreated = login.timeCreated; + loginInfo.timeLastUsed = login.timeLastUsed; + loginInfo.timePasswordChanged = login.timePasswordChanged; + loginInfo.timesUsed = login.timesUsed; + + /* These fields are not attributes on the Rust Login class + loginInfo.syncCounter = login.syncCounter; + loginInfo.everSynced = login.everSynced; + loginInfo.unknownFields = login.encryptedUnknownFields; + */ + + return loginInfo; +}; + +// An adapter which talks to the Rust Logins Store via LoginInfo objects +class RustLoginsStoreAdapter { + #store = null; + + constructor(store) { + this.#store = store; + } + + get(id) { + const login = this.#store.get(id); + return login && loginToLoginInfo(login); + } + + list() { + const logins = this.#store.list(); + return logins.map(loginToLoginInfo); + } + + update(id, loginInfo) { + const loginEntry = loginInfoToLoginEntry(loginInfo); + const login = this.#store.update(id, loginEntry); + return loginToLoginInfo(login); + } + + add(loginInfo) { + const loginEntry = loginInfoToLoginEntry(loginInfo); + const login = this.#store.add(loginEntry); + return loginToLoginInfo(login); + } + + addWithMeta(loginInfo) { + const loginEntryWithMeta = loginInfoToLoginEntryWithMeta(loginInfo); + const login = this.#store.addWithMeta(loginEntryWithMeta); + return loginToLoginInfo(login); + } + + addManyWithMeta(loginInfos, continueOnDuplicates) { + const loginEntriesWithMeta = loginInfos.map(loginInfoToLoginEntryWithMeta); + const results = this.#store.addManyWithMeta(loginEntriesWithMeta); + + // on continuous mode, return result objects, which could be either a login or an error + if (continueOnDuplicates) { + return results + .filter(l => l instanceof BulkResultEntry.Success) + .map(({ login, message }) => ({ + login: loginToLoginInfo(login), + error: { message }, + })); + } + + // otherwise throw first error + const error = results.find(l => l instanceof BulkResultEntry.Error); + if (error) { + throw error; + } + // and return login info objects + return results + .filter(l => l instanceof BulkResultEntry.Success) + .map(({ login }) => loginToLoginInfo(login)); + } + + delete(id) { + return this.#store.delete(id); + } + + deleteMany(ids) { + return this.#store.deleteMany(ids); + } + + // reset() { + // return this.#store.reset() + // } + + wipeLocal() { + return this.#store.wipeLocal(); + } + + count() { + return this.#store.count(); + } + + countByOrigin(origin) { + return this.#store.countByOrigin(origin); + } + + countByFormActionOrigin(formActionOrigin) { + return this.#store.countByFormActionOrigin(formActionOrigin); + } + + touch(id) { + this.#store.touch(id); + } + + findLoginToUpdate(loginInfo) { + const loginEntry = loginInfoToLoginEntry(loginInfo); + const login = this.#store.findLoginToUpdate(loginEntry); + return login && loginToLoginInfo(login); + } + + shutdown() { + this.#store.shutdown(); + } +} + +// This is a mock atm, as the Rust Logins mirror is not enabled for primary +// password users. A primary password entered outide of Rust will still unlock +// the Rust encdec, because it uses the same NSS. +class LoginStorageAuthenticator extends PrimaryPasswordAuthenticator {} + +export class LoginManagerRustStorage { + #storageAdapter = null; + #initializationPromise = null; + + // have it a singleton + constructor() { + if (LoginManagerRustStorage._instance) { + return LoginManagerRustStorage._instance; + } + LoginManagerRustStorage._instance = this; + } + + initialize() { + if (this.#initializationPromise) { + this.log("rust storage already initialized"); + } else { + try { + const profilePath = Services.dirsvc.get("ProfD", Ci.nsIFile).path; + const path = `${profilePath}/logins.db`; + + this.#initializationPromise = new Promise(resolve => { + this.log(`Initializing Rust login storage at ${path}`); + + initRustComponents(profilePath).then(() => { + const authenticator = new LoginStorageAuthenticator(); + const store = createLoginStoreWithNssKeymanager( + path, + authenticator + ); + + this.#storageAdapter = new RustLoginsStoreAdapter(store); + this.log("Rust login storage ready."); + + // Interrupt sooner prior to the `profile-before-change` phase to allow + // all the in-progress IOs to exit. + lazy.AsyncShutdown.profileChangeTeardown.addBlocker( + "LoginManagerRustStorage: Interrupt IO operations on login store", + () => this.terminate() + ); + + resolve(this); + }); + }); + } catch (e) { + this.log(`Initialization failed ${e.name}.`); + this.log(e); + throw new Error("Initialization failed"); + } + } + + return this.#initializationPromise; + } + + /** + * Internal method used by regression tests only. It is called before + * replacing this storage module with a new instance, and on shutdown + */ + terminate() { + this.#storageAdapter.shutdown(); + } + + /** + * Returns the "sync id" used by Sync to know whether the store is current with + * respect to the sync servers. It is stored encrypted, but only so we + * can detect failure to decrypt (for example, a "reset" of the primary + * password will leave all logins alone, but they will fail to decrypt. We + * also want this metadata to be unavailable in that scenario) + * + * Returns null if the data doesn't exist or if the data can't be + * decrypted (including if the primary-password prompt is cancelled). This is + * OK for Sync as it can't even begin syncing if the primary-password is + * locked as the sync encrytion keys are stored in this login manager. + */ + async getSyncID() { + throw Components.Exception("getSyncID", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async setSyncID(_syncID) { + throw Components.Exception("setSyncID", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async getLastSync() { + throw Components.Exception("getLastSync", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async setLastSync(_timestamp) { + throw Components.Exception("setLastSync", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + async resetSyncCounter(_guid, _value) { + throw Components.Exception("resetSyncCounter", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + // Returns false if the login has marked as deleted or doesn't exist. + loginIsDeleted(_guid) { + throw Components.Exception("loginIsDeleted", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + addWithMeta(login) { + return this.#storageAdapter.addWithMeta(login); + } + + async addLoginsAsync(logins, continueOnDuplicates = false) { + if (logins.length === 0) { + return logins; + } + + const result = this.#storageAdapter.addManyWithMeta( + logins, + continueOnDuplicates + ); + + // Emulate being async + return Promise.resolve(result); + } + + modifyLogin(oldLogin, newLoginData, _fromSync) { + const oldStoredLogin = this.#storageAdapter.findLoginToUpdate(oldLogin); + + if (!oldStoredLogin) { + throw new Error("No matching logins"); + } + + const idToModify = oldStoredLogin.guid; + + const newLogin = lazy.LoginHelper.buildModifiedLogin( + oldStoredLogin, + newLoginData + ); + + // Check if the new GUID is duplicate. + if (newLogin.guid != idToModify && !this.#isGuidUnique(newLogin.guid)) { + throw new Error("specified GUID already exists"); + } + + // Look for an existing entry in case key properties changed. + if (!newLogin.matches(oldLogin, true)) { + const loginData = { + origin: newLogin.origin, + formActionOrigin: newLogin.formActionOrigin, + httpRealm: newLogin.httpRealm, + }; + + const logins = this.searchLogins( + lazy.LoginHelper.newPropertyBag(loginData) + ); + + const matchingLogin = logins.find(login => newLogin.matches(login, true)); + if (matchingLogin) { + throw lazy.LoginHelper.createLoginAlreadyExistsError( + matchingLogin.guid + ); + } + } + + this.#storageAdapter.update(idToModify, newLogin); + } + + /** + * Checks to see if the specified GUID already exists. + */ + #isGuidUnique(guid) { + return !this.#storageAdapter.get(guid); + } + + recordPasswordUse(login) { + const oldStoredLogin = this.#storageAdapter.findLoginToUpdate(login); + + if (!oldStoredLogin) { + throw new Error("No matching logins"); + } + + this.#storageAdapter.touch(oldStoredLogin.guid); + } + + async recordBreachAlertDismissal(_loginGUID) { + throw Components.Exception( + "recordBreachAlertDismissal", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + getBreachAlertDismissalsByLoginGUID() { + throw Components.Exception( + "getBreachAlertDismissalsByLoginGUID", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + /** + * Returns an array of nsILoginInfo. If decryption of a login + * fails due to a corrupt entry, the login is not included in + * the resulting array. + * + * @resolve {nsILoginInfo[]} + */ + async getAllLogins(includeDeleted) { + // `includeDeleted` is currentlty unsupported + if (includeDeleted) { + throw Components.Exception( + "getAllLogins with includeDeleted", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + return Promise.resolve(this.#storageAdapter.list()); + } + + // The Rust API is sync atm + searchLoginsAsync(matchData, includeDeleted) { + this.log(`Searching for matching logins for origin ${matchData.origin}.`); + const result = this.searchLogins( + lazy.LoginHelper.newPropertyBag(matchData), + includeDeleted + ); + + // Emulate being async: + return Promise.resolve(result); + } + + /** + * Public wrapper around #searchLogins to convert the nsIPropertyBag to a + * JavaScript object and decrypt the results. + * + * @return {nsILoginInfo[]} which are decrypted. + */ + searchLogins(matchData, includeDeleted) { + const realMatchData = {}; + const options = {}; + matchData.QueryInterface(Ci.nsIPropertyBag2); + + if (matchData.hasKey("guid")) { + realMatchData.guid = matchData.getProperty("guid"); + } else { + for (const prop of matchData.enumerator) { + switch (prop.name) { + // Some property names aren't field names but are special options to + // affect the search. + case "acceptDifferentSubdomains": + case "schemeUpgrades": + case "acceptRelatedRealms": + case "relatedRealms": { + options[prop.name] = prop.value; + break; + } + default: { + realMatchData[prop.name] = prop.value; + break; + } + } + } + } + const [logins] = this.#searchLogins(realMatchData, includeDeleted, options); + return logins; + } + + #searchLogins( + matchData, + includeDeleted = false, + aOptions = { + schemeUpgrades: false, + acceptDifferentSubdomains: false, + acceptRelatedRealms: false, + relatedRealms: [], + }, + candidateLogins = this.#storageAdapter.list() + ) { + function match(aLoginItem) { + for (const field in matchData) { + const wantedValue = matchData[field]; + + // Override the storage field name for some fields due to backwards + // compatibility with Sync/storage. + let storageFieldName = field; + switch (field) { + case "formActionOrigin": { + storageFieldName = "formSubmitURL"; + break; + } + case "origin": { + storageFieldName = "hostname"; + break; + } + } + + switch (field) { + case "formActionOrigin": + if (wantedValue != null) { + // Historical compatibility requires this special case + if ( + aLoginItem.formSubmitURL == "" || + (wantedValue == "" && Object.keys(matchData).length != 1) + ) { + break; + } + if ( + !lazy.LoginHelper.isOriginMatching( + aLoginItem[storageFieldName], + wantedValue, + aOptions + ) + ) { + return false; + } + break; + } + // fall through + case "origin": + if (wantedValue != null) { + // needed for formActionOrigin fall through + if ( + !lazy.LoginHelper.isOriginMatching( + aLoginItem[storageFieldName], + wantedValue, + aOptions + ) + ) { + return false; + } + break; + } + // Normal cases. + // fall through + case "httpRealm": + case "id": + case "usernameField": + case "passwordField": + case "encryptedUsername": + case "encryptedPassword": + case "guid": + case "encType": + case "timeCreated": + case "timeLastUsed": + case "timePasswordChanged": + case "timesUsed": + case "syncCounter": + case "everSynced": + if (wantedValue == null && aLoginItem[storageFieldName]) { + return false; + } else if (aLoginItem[storageFieldName] != wantedValue) { + return false; + } + break; + // Fail if caller requests an unknown property. + default: + throw new Error("Unexpected field: " + field); + } + } + return true; + } + + const foundLogins = []; + const foundIds = []; + + for (const login of candidateLogins) { + if (login.deleted && !includeDeleted) { + continue; // skip deleted items + } + + if (match(login)) { + foundLogins.push(login); + foundIds.push(login.guid); + } + } + + this.log( + `Returning ${foundLogins.length} logins for specified origin with options ${aOptions}` + ); + return [foundLogins, foundIds]; + } + + removeLogin(login, _fromSync) { + const storedLogin = this.#storageAdapter.findLoginToUpdate(login); + + if (!storedLogin) { + throw new Error("No matching logins"); + } + + const idToDelete = storedLogin.guid; + + this.#storageAdapter.delete(idToDelete); + } + + /** + * Removes all logins from local storage, including FxA Sync key. + * + * NOTE: You probably want removeAllUserFacingLogins instead of this function. + * + */ + removeAllLogins() { + this.#removeLogins(false, true); + } + + /** + * Removes all user facing logins from storage. e.g. all logins except the FxA Sync key + * + * If you need to remove the FxA key, use `removeAllLogins` instead + * + * @param fullyRemove remove the logins rather than mark them deleted. + */ + removeAllUserFacingLogins(fullyRemove) { + this.#removeLogins(fullyRemove, false); + } + + /** + * Removes all logins from storage. If removeFXALogin is true, then the FxA Sync + * key is also removed. + * + * @param fullyRemove remove the logins rather than mark them deleted. + * @param removeFXALogin also remove the FxA Sync key. + */ + #removeLogins(fullyRemove, removeFXALogin = false) { + this.log("Removing all logins."); + + const removedLogins = []; + const remainingLogins = []; + + const logins = this.#storageAdapter.list(); + const idsToDelete = []; + for (const login of logins) { + if ( + !removeFXALogin && + login.hostname == lazy.FXA_PWDMGR_HOST && + login.httpRealm == lazy.FXA_PWDMGR_REALM + ) { + remainingLogins.push(login); + } else { + removedLogins.push(login); + + idsToDelete.push(login.guid); + } + } + + this.#storageAdapter.deleteMany(idsToDelete); + } + + findLogins(origin, formActionOrigin, httpRealm) { + const loginData = { + origin, + formActionOrigin, + httpRealm, + }; + const matchData = {}; + for (const field of ["origin", "formActionOrigin", "httpRealm"]) { + if (loginData[field] != "") { + matchData[field] = loginData[field]; + } + } + const [logins] = this.#searchLogins(matchData); + + this.log(`Returning ${logins.length} logins.`); + return logins; + } + + countLogins(origin, formActionOrigin, httpRealm) { + if (!origin && !formActionOrigin && !httpRealm) { + return this.#storageAdapter.count(); + } + + if (origin && !formActionOrigin && !httpRealm) { + return this.#storageAdapter.countByOrigin(origin); + } + + if (!origin && formActionOrigin && !httpRealm) { + return this.#storageAdapter.countByFormActionOrigin(formActionOrigin); + } + + const loginData = { + origin, + formActionOrigin, + httpRealm, + }; + + const matchData = {}; + for (const field of ["origin", "formActionOrigin", "httpRealm"]) { + if (loginData[field] != "") { + matchData[field] = loginData[field]; + } + } + const [logins] = this.#searchLogins(matchData); + + this.log(`Counted ${logins.length} logins.`); + return logins.length; + } + + addPotentiallyVulnerablePassword(_login) { + throw Components.Exception( + "addPotentiallyVulnerablePassword", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + isPotentiallyVulnerablePassword(_login) { + throw Components.Exception( + "isPotentiallyVulnerablePassword", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + clearAllPotentiallyVulnerablePasswords() { + throw Components.Exception( + "clearAllPotentiallyVulnerablePasswords", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + + get uiBusy() { + throw Components.Exception("uiBusy", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + get isLoggedIn() { + throw Components.Exception("isLoggedIn", Cr.NS_ERROR_NOT_IMPLEMENTED); + } +} + +ChromeUtils.defineLazyGetter(LoginManagerRustStorage.prototype, "log", () => { + const logger = lazy.LoginHelper.createLogger("RustLogins"); + return logger.log.bind(logger); +}); diff --git a/toolkit/components/passwordmgr/test/browser/browser.toml b/toolkit/components/passwordmgr/test/browser/browser.toml @@ -260,6 +260,9 @@ support-files = ["browser_relay_utils.js"] ["browser_relay_use.js"] support-files = ["browser_relay_utils.js"] +["browser_rust_mirror.js"] +skip-if = ["os == 'android'"] + ["browser_telemetry_SignUpFormRuleset.js"] ["browser_test_changeContentInputValue.js"] diff --git a/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js b/toolkit/components/passwordmgr/test/browser/browser_rust_mirror.js @@ -0,0 +1,339 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ + * + * Tests the AS RustLogins write-only mirror + */ +("use strict"); + +const { LoginManagerRustStorage } = ChromeUtils.importESModule( + "resource://gre/modules/storage-rust.sys.mjs" +); +const { sinon } = ChromeUtils.importESModule( + "resource://testing-common/Sinon.sys.mjs" +); + +/** + * Tests addLogin gets synced to Rust Storage + */ +add_task(async function test_mirror_addLogin() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + const loginInfo = LoginTestUtils.testData.formLogin({ + username: "username", + password: "password", + }); + await Services.logins.addLoginAsync(loginInfo); + + // note LoginManagerRustStorage is a singleton and already initialized when + // Services.logins gets initialized. + const rustStorage = new LoginManagerRustStorage(); + + const storedLoginInfos = await Services.logins.getAllLogins(); + const rustStoredLoginInfos = await rustStorage.getAllLogins(); + LoginTestUtils.assertLoginListsEqual(storedLoginInfos, rustStoredLoginInfos); + + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests modifyLogin gets synced to Rust Storage + */ +add_task(async function test_mirror_modifyLogin() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + const loginInfo = LoginTestUtils.testData.formLogin({ + username: "username", + password: "password", + }); + await Services.logins.addLoginAsync(loginInfo); + + const rustStorage = new LoginManagerRustStorage(); + + const [storedLoginInfo] = await Services.logins.getAllLogins(); + + const modifiedLoginInfo = LoginTestUtils.testData.formLogin({ + username: "username", + password: "password", + usernameField: "new_form_field_username", + passwordField: "new_form_field_password", + }); + Services.logins.modifyLogin(storedLoginInfo, modifiedLoginInfo); + + const [storedModifiedLoginInfo] = await Services.logins.getAllLogins(); + const [rustStoredModifiedLoginInfo] = await rustStorage.searchLoginsAsync({ + guid: storedLoginInfo.guid, + }); + + LoginTestUtils.assertLoginListsEqual( + [storedModifiedLoginInfo], + [rustStoredModifiedLoginInfo] + ); + + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Tests removeLogin gets synced to Rust Storage + */ +add_task(async function test_mirror_removeLogin() { + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + const loginInfo = LoginTestUtils.testData.formLogin({ + username: "username", + password: "password", + }); + await Services.logins.addLoginAsync(loginInfo); + + const rustStorage = new LoginManagerRustStorage(); + + const [storedLoginInfo] = await Services.logins.getAllLogins(); + + Services.logins.removeLogin(storedLoginInfo); + + const allLogins = await rustStorage.getAllLogins(); + Assert.equal(allLogins.length, 0); + + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Verifies that the migration is triggered by according pref change + */ +add_task(async function test_migration_is_triggered_by_pref_change() { + // enable rust mirror, triggering migration + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + + Assert.equal( + Services.prefs.getBoolPref("signon.rustMirror.migrationNeeded", false), + true, + "migrationNeeded is set to true" + ); + + const prefChangePromise = TestUtils.waitForPrefChange( + "signon.rustMirror.migrationNeeded" + ); + + // enable rust mirror, triggering migration + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + await prefChangePromise; + Assert.equal( + Services.prefs.getBoolPref("signon.rustMirror.migrationNeeded", false), + false, + "migrationNeeded is set to false" + ); + + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Verifies that the migration is idempotent by ensuring that running + * it multiple times does not create duplicate logins in the Rust store. + */ +add_task(async function test_migration_is_idempotent() { + // ensure mirror is on + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + const login = LoginTestUtils.testData.formLogin({ + username: "test-user", + password: "secure-password", + }); + await Services.logins.addLoginAsync(login); + + const rustStorage = new LoginManagerRustStorage(); + + let rustLogins = await rustStorage.getAllLogins(); + Assert.equal( + rustLogins.length, + 1, + "Rust store contains login after first migration" + ); + + // trigger again + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + // using the migrationNeeded pref change as an indicator that the migration did run + const prefChangePromise = TestUtils.waitForPrefChange( + "signon.rustMirror.migrationNeeded" + ); + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + await prefChangePromise; + + rustLogins = await rustStorage.getAllLogins(); + Assert.equal(rustLogins.length, 1, "No duplicate after second migration"); + + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Verify that the migration: + * - continues when some rows fail (partial failure), + * - still migrates valid logins, + */ + +add_task(async function test_migration_partial_failure() { + // ensure mirror is off + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + + const rustStorage = new LoginManagerRustStorage(); + // Save the first (valid) login into Rust for real, then simulate results + sinon.stub(rustStorage, "addLoginsAsync").callsFake(async (logins, _cont) => { + await rustStorage.addWithMeta(logins[0]); + return [ + { login: {}, error: null }, // row 0 success + { login: null, error: { message: "row failed" } }, // row 1 failure + ]; + }); + + const login_ok = LoginTestUtils.testData.formLogin({ + username: "test-user-ok", + password: "secure-password", + }); + await Services.logins.addLoginAsync(login_ok); + const login_bad = LoginTestUtils.testData.formLogin({ + username: "test-user-bad", + password: "secure-password", + }); + await Services.logins.addLoginAsync(login_bad); + + // trigger again + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + // and wait a little, due to the lack of a migration-complete event. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 200)); + + const rustLogins = await rustStorage.getAllLogins(); + Assert.equal(rustLogins.length, 1, "only valid login migrated"); + + sinon.restore(); + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Verify that when the bulk add operation rejects (hard failure), + * the migration itself rejects. + */ +add_task(async function test_migration_rejects_when_bulk_add_rejects() { + // turn mirror off + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + + const rustStorage = new LoginManagerRustStorage(); + // force the bulk add to fail + sinon.stub(rustStorage, "addLoginsAsync").rejects(new Error("bulk failed")); + + const login = LoginTestUtils.testData.formLogin({ + username: "test-user", + password: "secure-password", + }); + await Services.logins.addLoginAsync(login); + + // trigger again + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + + // and wait a little, due to the lack of a migration-complete event. + // eslint-disable-next-line mozilla/no-arbitrary-setTimeout + await new Promise(resolve => setTimeout(resolve, 200)); + + const rustLogins = await rustStorage.getAllLogins(); + Assert.equal(rustLogins.length, 0, "zero logins migrated"); + + const newPrefValue = Services.prefs.getBoolPref( + "signon.rustMirror.migrationNeeded", + false + ); + + Assert.equal(newPrefValue, true, "pref has not been reset"); + + sinon.restore(); + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); + await SpecialPowers.popPrefEnv(); +}); + +/** + * Ensures that migrating a large number of logins (100) from the JSON store to + * the Rust store completes within a reasonable time frame (under 1 second). + **/ +add_task(async function test_migration_time_under_threshold() { + // ensure mirror is off + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", false]], + }); + + const numberOfLogins = 100; + + const logins = Array.from({ length: numberOfLogins }, (_, i) => + LoginTestUtils.testData.formLogin({ + origin: `https://www${i}.example.com`, + username: `user${i}`, + }) + ); + await Services.logins.addLogins(logins); + await LoginTestUtils.reloadData(); + + const rustStorage = new LoginManagerRustStorage(); + + const start = Date.now(); + // using the migrationNeeded pref change as an indicator that the migration did run + const prefChangePromise = TestUtils.waitForPrefChange( + "signon.rustMirror.migrationNeeded" + ); + await SpecialPowers.pushPrefEnv({ + set: [["signon.rustMirror.enabled", true]], + }); + await prefChangePromise; + + const duration = Date.now() - start; + Assert.less(duration, 2000, "Migration should complete under 2s"); + Assert.equal(rustStorage.countLogins("", "", ""), numberOfLogins); + + LoginTestUtils.clearData(); + rustStorage.removeAllLogins(); +});