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:
Dan Stillman 2026-04-30 15:25:24 -04:00
parent ef7896ab5f
commit 056f61d8dd
7 changed files with 296 additions and 26 deletions

View file

@ -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"' \

View file

@ -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

View 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));
}
};

View file

@ -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);
}
},

View file

@ -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) {

View file

@ -113,6 +113,7 @@ const xpcomFilesLocal = [
'mime',
'notifier',
'fileHandlers',
'osKeyStore',
'plugins',
'pluginAPI/menuManager',
'pluginAPI/itemPaneManager',

View file

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