mirror of
https://github.com/zotero/zotero.git
synced 2026-10-03 02:21:49 +00:00
Encrypt API key and WebDAV password using OS keychain
Wraps the values stored in nsILoginManager with OSKeyStore, which derives its master key from Keychain on macOS, DPAPI on Windows, and libsecret on Linux. A copy of the profile alone is no longer enough to extract these credentials. Existing plaintext entries are mirrored once per session to a new "(encrypted)" realm but preserved in the original realm so a user can still downgrade to a release that doesn't know about encryption. Active credential changes (sign in, sign out, password change) write to the encrypted realm only and remove the legacy entry. A future version can clear any remaining legacy entries on startup. Patches MOZ_APP_BASENAME in the bundled runtime so the keychain master key is labeled "Zotero Encrypted Storage" rather than "Firefox Encrypted Storage", with a check_line guard so a future Mozilla change to the OSKeyStore label format fails the build instead of silently rebranding the entry. Also fixes check_line to take an explicit file argument.
This commit is contained in:
parent
ef7896ab5f
commit
056f61d8dd
7 changed files with 296 additions and 26 deletions
|
|
@ -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 '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>'
|
||||
check_line '<html:link rel="stylesheet" href="chrome:\/\/global\/skin\/global.css" \/>' chrome/toolkit/content/global/commonDialog.xhtml
|
||||
replace_line 'chrome:\/\/global\/skin\/commonDialog.css"' \
|
||||
'chrome:\/\/global\/skin\/commonDialog.css"\/>
|
||||
<html:link rel="stylesheet" href="chrome:\/\/zotero-platform\/content\/zotero.css"' \
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ get_canonical_arch() {
|
|||
|
||||
function check_line {
|
||||
pattern=$1
|
||||
file=$2
|
||||
if ! grep -E -q "$pattern" "$file"; then
|
||||
echo "$pattern" not found in "$file" -- aborting 2>&1
|
||||
exit 1
|
||||
|
|
|
|||
117
chrome/content/zotero/xpcom/osKeyStore.js
Normal file
117
chrome/content/zotero/xpcom/osKeyStore.js
Normal file
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
***** 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));
|
||||
}
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@ const xpcomFilesLocal = [
|
|||
'mime',
|
||||
'notifier',
|
||||
'fileHandlers',
|
||||
'osKeyStore',
|
||||
'plugins',
|
||||
'pluginAPI/menuManager',
|
||||
'pluginAPI/itemPaneManager',
|
||||
|
|
|
|||
|
|
@ -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 }.
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue