diff --git a/app/scripts/fetch_xulrunner b/app/scripts/fetch_xulrunner
index 5a81affa57..8d93b38e5f 100755
--- a/app/scripts/fetch_xulrunner
+++ b/app/scripts/fetch_xulrunner
@@ -133,6 +133,12 @@ function modify_omni {
rm actors/AudioPlayback{Parent,Child}.sys.mjs
replace_line 'BROWSER_CHROME_URL:.+' 'BROWSER_CHROME_URL: "chrome:\/\/zotero\/content\/zoteroPane.xhtml",' modules/AppConstants.sys.mjs
+ # Used by OSKeyStore as the master-key label, visible in macOS Keychain Access.
+ # Verify that OSKeyStore still derives the label from MOZ_APP_BASENAME, so a
+ # future Mozilla change to a hardcoded string doesn't silently rebrand the
+ # keychain entry back to "Firefox Encrypted Storage".
+ replace_line 'MOZ_APP_BASENAME: "Firefox"' 'MOZ_APP_BASENAME: "Zotero"' modules/AppConstants.sys.mjs
+ check_line 'STORE_LABEL: AppConstants\.MOZ_APP_BASENAME \+ " Encrypted Storage"' modules/OSKeyStore.sys.mjs
# https://firefox-source-docs.mozilla.org/toolkit/components/telemetry/internals/preferences.html
#
@@ -432,7 +438,7 @@ function modify_omni {
chrome/toolkit/content/global/commonDialog.xhtml
# commonDialog.css link is split across multiple lines, so we have to do a weird substitution,
# so check the one-line global.css to make sure the format hasn't changed
- check_line ''
+ check_line '' chrome/toolkit/content/global/commonDialog.xhtml
replace_line 'chrome:\/\/global\/skin\/commonDialog.css"' \
'chrome:\/\/global\/skin\/commonDialog.css"\/>
&1
exit 1
diff --git a/chrome/content/zotero/xpcom/osKeyStore.js b/chrome/content/zotero/xpcom/osKeyStore.js
new file mode 100644
index 0000000000..24e8a1efcb
--- /dev/null
+++ b/chrome/content/zotero/xpcom/osKeyStore.js
@@ -0,0 +1,117 @@
+/*
+ ***** BEGIN LICENSE BLOCK *****
+
+ Copyright © 2026 Corporation for Digital Scholarship
+ Vienna, Virginia, USA
+ https://www.zotero.org
+
+ This file is part of Zotero.
+
+ Zotero is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ Zotero is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with Zotero. If not, see .
+
+ ***** END LICENSE BLOCK *****
+*/
+
+// Wrapper around Mozilla's OSKeyStore, which derives an encryption key from
+// platform-native key storage (Keychain on macOS, DPAPI on Windows, libsecret
+// on Linux). Encrypted values are returned with a versioned prefix so callers
+// can distinguish them from legacy plaintext values previously written to
+// nsILoginManager.
+Zotero.OSKeyStore = {
+ _prefix: 'oskv1:',
+ _module: null,
+
+ _load: function () {
+ if (this._module === null) {
+ try {
+ let { OSKeyStore } = ChromeUtils.importESModule(
+ "resource://gre/modules/OSKeyStore.sys.mjs"
+ );
+ this._module = OSKeyStore;
+ }
+ catch (e) {
+ Zotero.logError(e);
+ this._module = false;
+ }
+ }
+ return this._module;
+ },
+
+ get available() {
+ return !!this._load();
+ },
+
+ isEncrypted: function (value) {
+ return typeof value == 'string' && value.startsWith(this._prefix);
+ },
+
+ // Show an alert when an active write of new credentials fails (e.g., keychain unavailable)
+ alertSaveFailed: function () {
+ let win = Services.wm.getMostRecentWindow('zotero:main');
+ if (!win) {
+ return;
+ }
+ Zotero.alert(
+ win,
+ Zotero.getString('general-error'),
+ Zotero.getString('os-keystore-save-failed')
+ );
+ },
+
+ // Show a one-shot alert when migration of an existing legacy plaintext entry
+ // fails. The caller falls back to using the legacy value, so the user isn't
+ // blocked, but show an alert so the keychain issue can be reported and
+ // addressed before a future version drops the legacy fallback.
+ alertMigrateFailed: function () {
+ if (this._migrateAlertShown) {
+ return;
+ }
+ this._migrateAlertShown = true;
+ let win = Services.wm.getMostRecentWindow('zotero:main');
+ if (!win) {
+ return;
+ }
+ Zotero.alert(
+ win,
+ Zotero.getString('general-error'),
+ Zotero.getString('os-keystore-migrate-failed')
+ );
+ },
+
+ // Returns prefixed ciphertext. Throws if OSKeyStore is unavailable so we
+ // don't silently store plaintext when a caller expects encryption.
+ encrypt: async function (plaintext) {
+ let mod = this._load();
+ if (!mod) {
+ throw new Error("OSKeyStore unavailable");
+ }
+ let ciphertext = await mod.encrypt(plaintext);
+ return this._prefix + ciphertext;
+ },
+
+ // Returns the plaintext, or the input unchanged if it doesn't carry our
+ // prefix (legacy plaintext). Throws if the value is prefixed but decryption
+ // fails -- e.g. keychain locked, user canceled the unlock prompt, profile
+ // copied to a different OS user, ciphertext corrupted.
+ decrypt: async function (value) {
+ if (!this.isEncrypted(value)) {
+ return value;
+ }
+ let mod = this._load();
+ if (!mod) {
+ throw new Error("OSKeyStore unavailable but stored value is encrypted");
+ }
+ return mod.decrypt(value.slice(this._prefix.length));
+ }
+};
diff --git a/chrome/content/zotero/xpcom/storage/webdav.js b/chrome/content/zotero/xpcom/storage/webdav.js
index 263ac3bd52..4883b95564 100644
--- a/chrome/content/zotero/xpcom/storage/webdav.js
+++ b/chrome/content/zotero/xpcom/storage/webdav.js
@@ -214,7 +214,8 @@ Zotero.Sync.Storage.Mode.WebDAV.prototype = {
},
_loginManagerHost: 'chrome://zotero',
- _loginManagerRealm: 'Zotero Storage Server',
+ _loginManagerRealm: 'Zotero Storage Server (encrypted)',
+ _loginManagerRealmLegacy: 'Zotero Storage Server',
get defaultError() {
@@ -238,14 +239,41 @@ Zotero.Sync.Storage.Mode.WebDAV.prototype = {
}
Zotero.debug('Getting WebDAV password');
+
+ // Prefer the legacy realm during the transition window: an older version
+ // may have written a fresh value there after we migrated. Mirror it to
+ // the encrypted realm but keep the legacy entry so a downgrade can still
+ // read it. The legacy realm will be cleared in a future version once
+ // downgrades are unlikely.
+ var legacyLogins = await Services.logins.searchLoginsAsync({
+ origin: this._loginManagerHost,
+ httpRealm: this._loginManagerRealmLegacy,
+ });
+ for (let i = 0; i < legacyLogins.length; i++) {
+ if (legacyLogins[i].username == username) {
+ let password = legacyLogins[i].password;
+ if (!this._mirroredPassword) {
+ try {
+ Zotero.debug("Mirroring plaintext WebDAV password to encrypted storage");
+ await this._writeEncryptedPassword(username, password);
+ this._mirroredPassword = true;
+ }
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.OSKeyStore.alertMigrateFailed();
+ }
+ }
+ return password;
+ }
+ }
+
var logins = await Services.logins.searchLoginsAsync({
origin: this._loginManagerHost,
httpRealm: this._loginManagerRealm,
});
- // Find user from returned array of nsILoginInfo objects
for (var i = 0; i < logins.length; i++) {
if (logins[i].username == username) {
- return logins[i].password;
+ return Zotero.OSKeyStore.decrypt(logins[i].password);
}
}
@@ -270,22 +298,43 @@ Zotero.Sync.Storage.Mode.WebDAV.prototype = {
return;
}
- if (password == (await this.getPassword())) {
- Zotero.debug("WebDAV password hasn't changed");
- return;
+ // Skip the write if the password hasn't changed. This is an optimization,
+ // not a correctness requirement -- if we can't read the existing value
+ // (e.g. keychain locked), proceed with the write anyway.
+ try {
+ if (password == (await this.getPassword())) {
+ Zotero.debug("WebDAV password hasn't changed");
+ return;
+ }
+ }
+ catch (e) {
+ Zotero.logError(e);
}
this._basicAuthHeader = false;
this._digestParams = null;
+ try {
+ await this._writeEncryptedPassword(username, password);
+ }
+ catch (e) {
+ Zotero.OSKeyStore.alertSaveFailed();
+ throw e;
+ }
+
+ // Drop any leftover plaintext entry from the legacy realm
var logins = await Services.logins.searchLoginsAsync({
origin: this._loginManagerHost,
- httpRealm: this._loginManagerRealm
+ httpRealm: this._loginManagerRealmLegacy
});
- for (var i = 0; i < logins.length; i++) {
- Zotero.debug('Clearing WebDAV passwords');
- if (logins[i].httpRealm == this._loginManagerRealm) {
- Services.logins.removeLogin(logins[i]);
+ for (let i = 0; i < logins.length; i++) {
+ if (logins[i].httpRealm == this._loginManagerRealmLegacy) {
+ try {
+ Services.logins.removeLogin(logins[i]);
+ }
+ catch (e) {
+ Zotero.logError(e);
+ }
}
break;
}
@@ -306,13 +355,26 @@ Zotero.Sync.Storage.Mode.WebDAV.prototype = {
}
break;
}
+ },
+
+ async _writeEncryptedPassword(username, password) {
+ // Remove any existing entries in the encrypted realm for this user
+ var logins = await Services.logins.searchLoginsAsync({
+ origin: this._loginManagerHost,
+ httpRealm: this._loginManagerRealm
+ });
+ for (let i = 0; i < logins.length; i++) {
+ if (logins[i].username == username) {
+ Services.logins.removeLogin(logins[i]);
+ }
+ }
if (password) {
- Zotero.debug('Setting WebDAV password');
- var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
+ let storedValue = await Zotero.OSKeyStore.encrypt(password);
+ let nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
Components.interfaces.nsILoginInfo, "init");
- var loginInfo = new nsLoginInfo(this._loginManagerHost, null,
- this._loginManagerRealm, username, password, "", "");
+ let loginInfo = new nsLoginInfo(this._loginManagerHost, null,
+ this._loginManagerRealm, username, storedValue, "", "");
await Services.logins.addLoginAsync(loginInfo);
}
},
diff --git a/chrome/content/zotero/xpcom/sync/syncLocal.js b/chrome/content/zotero/xpcom/sync/syncLocal.js
index d5a50384fd..900889c002 100644
--- a/chrome/content/zotero/xpcom/sync/syncLocal.js
+++ b/chrome/content/zotero/xpcom/sync/syncLocal.js
@@ -30,7 +30,8 @@ if (!Zotero.Sync.Data) {
Zotero.Sync.Data.Local = {
_syncQueueIntervals: [0.5, 1, 4, 16, 16, 16, 16, 16, 16, 16, 64], // hours
_loginManagerHost: 'chrome://zotero',
- _loginManagerRealm: 'Zotero Web API',
+ _loginManagerRealm: 'Zotero Web API (encrypted)',
+ _loginManagerRealmLegacy: 'Zotero Web API',
_lastSyncTime: null,
_lastClassicSyncTime: null,
@@ -45,12 +46,35 @@ Zotero.Sync.Data.Local = {
/**
* @return {Promise}
*/
- getAPIKey: function () {
+ getAPIKey: async function () {
+ // Prefer the legacy realm during the transition window: an older version
+ // may have written a fresh value there after we migrated, and we want
+ // to use the most recent value. Mirror it to the encrypted realm but
+ // keep the legacy entry so a downgrade can still read it. The legacy
+ // realm will be cleared in a future version once downgrades are
+ // unlikely.
+ var legacyLogin = this._getLegacyAPIKeyLoginInfo();
+ if (legacyLogin) {
+ let apiKey = legacyLogin.password;
+ if (!this._mirroredAPIKey) {
+ try {
+ Zotero.debug("Mirroring plaintext API key to encrypted storage");
+ await this._writeEncryptedAPIKey(apiKey);
+ this._mirroredAPIKey = true;
+ }
+ catch (e) {
+ Zotero.logError(e);
+ Zotero.OSKeyStore.alertMigrateFailed();
+ }
+ }
+ return apiKey;
+ }
var login = this._getAPIKeyLoginInfo();
- return login
- ? login.password
- // Fallback to old username/password
- : this._getAPIKeyFromLogin();
+ if (login) {
+ return Zotero.OSKeyStore.decrypt(login.password);
+ }
+ // Fallback to old username/password
+ return this._getAPIKeyFromLogin();
},
@@ -58,8 +82,7 @@ Zotero.Sync.Data.Local = {
* Check for an API key or a legacy username/password (which may or may not be valid)
*/
hasCredentials: function () {
- var login = this._getAPIKeyLoginInfo();
- if (login) {
+ if (this._getAPIKeyLoginInfo() || this._getLegacyAPIKeyLoginInfo()) {
return true;
}
// If no API key, check for legacy login
@@ -70,6 +93,7 @@ Zotero.Sync.Data.Local = {
setAPIKey: async function (apiKey) {
var oldLoginInfo = this._getAPIKeyLoginInfo();
+ var legacyLoginInfo = this._getLegacyAPIKeyLoginInfo();
// Clear old login
if ((!apiKey || apiKey === "")) {
@@ -77,10 +101,31 @@ Zotero.Sync.Data.Local = {
Zotero.debug("Clearing old API key");
Services.logins.removeLogin(oldLoginInfo);
}
+ if (legacyLoginInfo) {
+ Services.logins.removeLogin(legacyLoginInfo);
+ }
Zotero.Notifier.trigger('delete', 'api-key', []);
return;
}
+ try {
+ await this._writeEncryptedAPIKey(apiKey);
+ }
+ catch (e) {
+ Zotero.OSKeyStore.alertSaveFailed();
+ throw e;
+ }
+ // Drop any leftover plaintext entry from the legacy realm
+ if (legacyLoginInfo) {
+ Services.logins.removeLogin(legacyLoginInfo);
+ }
+ Zotero.Notifier.trigger('modify', 'api-key', []);
+ },
+
+
+ _writeEncryptedAPIKey: async function (apiKey) {
+ var oldLoginInfo = this._getAPIKeyLoginInfo();
+ var storedValue = await Zotero.OSKeyStore.encrypt(apiKey);
var nsLoginInfo = new Components.Constructor("@mozilla.org/login-manager/loginInfo;1",
Components.interfaces.nsILoginInfo, "init");
var loginInfo = new nsLoginInfo(
@@ -88,7 +133,7 @@ Zotero.Sync.Data.Local = {
null,
this._loginManagerRealm,
'API Key',
- apiKey,
+ storedValue,
'',
''
);
@@ -100,7 +145,6 @@ Zotero.Sync.Data.Local = {
Zotero.debug("Replacing API key");
Services.logins.modifyLogin(oldLoginInfo, loginInfo);
}
- Zotero.Notifier.trigger('modify', 'api-key', []);
},
@@ -428,6 +472,25 @@ Zotero.Sync.Data.Local = {
},
+ /**
+ * @return {nsILoginInfo|false}
+ */
+ _getLegacyAPIKeyLoginInfo: function () {
+ try {
+ var logins = Services.logins.findLogins(
+ this._loginManagerHost,
+ null,
+ this._loginManagerRealmLegacy
+ );
+ }
+ catch (e) {
+ Zotero.logError(e);
+ return false;
+ }
+ return logins.length ? logins[0] : false;
+ },
+
+
_getAPIKeyFromLogin: async function () {
let username = Zotero.Prefs.get('sync.server.username');
if (username) {
diff --git a/chrome/content/zotero/zotero.mjs b/chrome/content/zotero/zotero.mjs
index a4e359db8e..c14299a365 100644
--- a/chrome/content/zotero/zotero.mjs
+++ b/chrome/content/zotero/zotero.mjs
@@ -113,6 +113,7 @@ const xpcomFilesLocal = [
'mime',
'notifier',
'fileHandlers',
+ 'osKeyStore',
'plugins',
'pluginAPI/menuManager',
'pluginAPI/itemPaneManager',
diff --git a/chrome/locale/en-US/zotero/zotero.ftl b/chrome/locale/en-US/zotero/zotero.ftl
index df65a30cbf..aca0f3b34c 100644
--- a/chrome/locale/en-US/zotero/zotero.ftl
+++ b/chrome/locale/en-US/zotero/zotero.ftl
@@ -25,6 +25,12 @@ delete-or-backspace =
[macos] Delete
*[other] Backspace
}
+-os-name =
+ { PLATFORM() ->
+ [macos] macOS
+ [windows] Windows
+ *[other] Linux
+ }
general-print = Print
general-remove = Remove
@@ -900,3 +906,17 @@ plugins-blocked-plugin =
.message = This plugin has been disabled by { -app-name }.
data-dir-unsupported-storage = This can happen if the { -app-name } data directory is in a cloud storage folder (OneDrive, Dropbox, etc.) or on a network share.
+
+os-keystore-save-failed =
+ { PLATFORM() ->
+ [macos] { -app-name } couldn’t access the { -os-name } Keychain to securely save your credentials. Make sure your Keychain is accessible and try again.
+ [windows] { -app-name } couldn’t securely save your credentials. Try again or restart { -app-name }.
+ *[other] { -app-name } couldn’t access your { -os-name } keyring to securely save your credentials. Make sure a keyring service is running and try again.
+ }
+
+os-keystore-migrate-failed =
+ { PLATFORM() ->
+ [macos] { -app-name } couldn’t access the { -os-name } Keychain to encrypt your stored credentials. Your credentials remain stored unencrypted on disk. Make sure your Keychain is accessible and restart { -app-name }.
+ [windows] { -app-name } couldn’t encrypt your stored credentials. Your credentials remain stored unencrypted on disk. Restart { -app-name } and try again.
+ *[other] { -app-name } couldn’t access your { -os-name } keyring to encrypt your stored credentials. Your credentials remain stored unencrypted on disk. Make sure a keyring service is running and restart { -app-name }.
+ }