commit 7f763790d281b0c748588749fdfd15ac98355b8a
parent e285c7f3d366a63e6fd365ccc346ef9e3aeb4184
Author: Johannes Jörg Schmidt <joschmidt@mozilla.com>
Date: Thu, 2 Oct 2025 08:12:33 +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:
10 files changed, 1341 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
@@ -3023,6 +3023,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,207 @@
+/* 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
+ 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;
+ }
+
+ 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":
+ const loginToModify = subject.queryElementAt(0, Ci.nsILoginInfo);
+ const 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":
+ this.#logger.log("ignoring importLogins message");
+ break;
+
+ default:
+ this.#logger.error(`error: received unhandled event "${eventName}"`);
+ }
+ }
+
+ 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 erolling 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();
+});