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 }. + }