From b4e6b364692d6fb4f3bc405469780ba3def1cbbc Mon Sep 17 00:00:00 2001 From: Tim Hardeck Date: Fri, 5 Dec 2025 18:03:19 +0100 Subject: [PATCH 1/3] Add Thunderbird support for email credential management Implement password storage and autofill for Mozilla Thunderbird: - IMAP/SMTP/POP3/NNTP password retrieval from pass - OAuth2 token storage for Gmail, Microsoft, Fastmail - CalDAV/CardDAV OAuth authentication - Automatic credential migration from Thunderbird's password manager - OAuth browser window autofill via clipboard Uses WebExtension Experiments API to hook into Thunderbird's auth system. Requires separate build (`make thunderbird`) due to experimental APIs. --- .github/ISSUE_TEMPLATE.md | 2 +- .gitignore | 1 + Makefile | 31 +- PRIVACY.md | 17 +- README.md | 47 +- src/Makefile | 4 +- src/background.js | 106 + src/helpers/base.js | 11 + src/manifest-thunderbird.json | 60 + src/thunderbird.js | 668 ++++++ src/thunderbird/experiment/implementation.js | 2189 ++++++++++++++++++ src/thunderbird/experiment/schema.json | 68 + 12 files changed, 3194 insertions(+), 10 deletions(-) create mode 100644 src/manifest-thunderbird.json create mode 100644 src/thunderbird.js create mode 100644 src/thunderbird/experiment/implementation.js create mode 100644 src/thunderbird/experiment/schema.json diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md index b74bc39f..b87ef1c9 100644 --- a/.github/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE.md @@ -3,7 +3,7 @@ - Operating system + version: -- Browser + version: +- Browser/Thunderbird + version: - Information about the host app: - How did you install it? diff --git a/.gitignore b/.gitignore index 49fceaae..0ce9c8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /chromium /firefox +/thunderbird /dist /dist-webstore diff --git a/Makefile b/Makefile index e1ff0223..b75f58a0 100644 --- a/Makefile +++ b/Makefile @@ -1,18 +1,19 @@ VERSION ?= $(shell cat .version) -CLEAN_FILES := chromium firefox dist dist-webstore +CLEAN_FILES := chromium firefox thunderbird dist dist-webstore CHROME := $(shell which chromium 2>/dev/null || which chromium-browser 2>/dev/null || which chrome 2>/dev/null || which google-chrome 2>/dev/null || which google-chrome-stable 2>/dev/null) ####################### # For local development .PHONY: all -all: extension chromium firefox +all: extension chromium firefox thunderbird .PHONY: extension extension: $(MAKE) -C src +# Base extension files (shared by all builds) EXTENSION_FILES := \ src/*.png \ src/*.svg \ @@ -31,8 +32,16 @@ EXTENSION_FILES := \ src/js/offscreen.dist.js \ src/js/options.dist.js \ src/js/inject.dist.js + +# Thunderbird-specific files +THUNDERBIRD_EXTRA_FILES := \ + src/thunderbird/experiment/*.js \ + src/thunderbird/experiment/*.json +THUNDERBIRD_EXTRA_FILES := $(wildcard $(THUNDERBIRD_EXTRA_FILES)) + CHROMIUM_FILES := $(patsubst src/%,chromium/%, $(EXTENSION_FILES)) FIREFOX_FILES := $(patsubst src/%,firefox/%, $(EXTENSION_FILES)) +THUNDERBIRD_FILES := $(patsubst src/%,thunderbird/%, $(EXTENSION_FILES)) $(patsubst src/%,thunderbird/%, $(THUNDERBIRD_EXTRA_FILES)) .PHONY: chromium chromium: extension $(CHROMIUM_FILES) chromium/manifest.json @@ -56,6 +65,17 @@ firefox/manifest.json : src/manifest-firefox.json [ -d $(dir $@) ] || mkdir -p $(dir $@) cp $< $@ +.PHONY: thunderbird +thunderbird: extension $(THUNDERBIRD_FILES) thunderbird/manifest.json + +$(THUNDERBIRD_FILES) : thunderbird/% : src/% + [ -d $(dir $@) ] || mkdir -p $(dir $@) + cp $< $@ + +thunderbird/manifest.json : src/manifest-thunderbird.json + [ -d $(dir $@) ] || mkdir -p $(dir $@) + cp $< $@ + ####################### # For official releases @@ -75,13 +95,14 @@ crx-github: mv chromium.crx browserpass-github.crx .PHONY: dist -dist: clean extension chromium firefox crx-webstore crx-github +dist: clean extension chromium firefox thunderbird crx-webstore crx-github mkdir -p dist git -c tar.tar.gz.command="gzip -cn" archive -o dist/browserpass-extension-$(VERSION).tar.gz --format tar.gz --prefix=browserpass-extension-$(VERSION)/ $(VERSION) - (cd chromium && zip -r ../dist/browserpass-chromium-$(VERSION).zip *) - (cd firefox && zip -r ../dist/browserpass-firefox-$(VERSION).zip *) + (cd chromium && zip -r ../dist/browserpass-chromium-$(VERSION).zip *) + (cd firefox && zip -r ../dist/browserpass-firefox-$(VERSION).zip *) + (cd thunderbird && zip -r ../dist/browserpass-thunderbird-$(VERSION).zip *) mv browserpass-webstore.crx dist/browserpass-webstore-$(VERSION).crx mv browserpass-github.crx dist/browserpass-github-$(VERSION).crx diff --git a/PRIVACY.md b/PRIVACY.md index b07ad998..8860ca37 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -17,7 +17,8 @@ This Privacy Policy applies to Browserpass and Browserpass OTP. ## Usage of Credential Files During the course of normal operation, Browserpass handles decrypted Credential Files. -Only files selected by the User via the Browserpass interface are decrypted. +Only files selected by the User via the Browserpass interface are decrypted. In Thunderbird, +Credential Files are decrypted when Thunderbird requests authentication for email accounts. The contents of decrypted Credential Files are used *only* for the following purposes: @@ -26,6 +27,20 @@ The contents of decrypted Credential Files are used *only* for the following pur - To provide the User with an interface to edit the contents of a selected Credential File, - To provide the OTP seed to Browserpass OTP - To fill other fields as requested by the User (e.g. credit card data) + - To authenticate email and news accounts in Thunderbird (IMAP, SMTP, POP3, NNTP); + - To provide OAuth2 tokens for mail providers (Gmail, Microsoft, etc.) and + calendar/contacts services (CalDAV/CardDAV) in Thunderbird. + +**In Thunderbird, credentials are never stored in Thunderbird's built-in password manager.** +All credentials are retrieved directly from the Password Store using GPG decryption. + +When the User enters new credentials in Thunderbird (e.g., during account setup), Browserpass +may save these credentials to the Password Store. OAuth tokens obtained during authentication +are automatically stored in the Password Store for future use. + +Browserpass may migrate existing credentials from Thunderbird's password manager to the +Password Store when the extension is first installed. This migration does not overwrite +existing entries in the Password Store. ## Use & Transmission of Data diff --git a/README.md b/README.md index 825b48e1..fa5e7939 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ Browserpass is a browser extension for [zx2c4's pass](https://www.passwordstore.org/), a UNIX based password store manager. It allows you to auto-fill or copy to clipboard credentials for the current domain, protecting you from phishing attacks. +Browserpass also supports Mozilla Thunderbird, providing password storage and autofill for email accounts (IMAP, SMTP, POP3) and OAuth2 tokens (Gmail, Microsoft, Fastmail). + In order to use Browserpass you must also install a [companion native messaging host](https://github.com/browserpass/browserpass-native), which provides an interface to your password store. ![demo](https://user-images.githubusercontent.com/1177900/56079873-87057600-5dfa-11e9-8ff1-c51744c75585.gif) @@ -12,6 +14,8 @@ In order to use Browserpass you must also install a [companion native messaging - [Requirements](#requirements) - [Installation](#installation) + - [Browser extension](#browser-extension) + - [Thunderbird extension](#thunderbird-extension) - [Verifying authenticity of the Github releases](#verifying-authenticity-of-the-github-releases) - [Updates](#updates) - [Usage](#usage) @@ -43,7 +47,7 @@ In order to use Browserpass you must also install a [companion native messaging ## Requirements -- The latest stable version of Chromium or Firefox, or any of their derivatives. +- The latest stable version of Chromium, Firefox, or Thunderbird (128.0+), or any of their derivatives. - The latest stable version of gpg (having `pass` or `gopass` is actually not required). - A password store that follows certain [naming conventions](#organizing-password-store) @@ -52,6 +56,10 @@ In order to use Browserpass you must also install a [companion native messaging In order to install Browserpass correctly, you have to install two of its components: - [Native messaging host](https://github.com/browserpass/browserpass-native#installation) +- Browser or Thunderbird extension (see below) + +### Browser extension + - Browser extension for Chromium-based browsers (choose one of the options): - Install using a package manager for your OS (which will provide auto-update and keep extension in sync with native host app): - Arch Linux: [browserpass-chromium](https://www.archlinux.org/packages/community/any/browserpass-chromium/), [browserpass-chrome](https://aur.archlinux.org/packages/browserpass-chrome/) @@ -69,6 +77,19 @@ In order to install Browserpass correctly, you have to install two of its compon - Install the extension from [Firefox Add-ons](https://addons.mozilla.org/en-US/firefox/addon/browserpass-ce/) (which will provide auto-updates) - Download `browserpass-firefox.zip` from the latest release, unarchive and use `Load Temporary Add-on` on `about:debugging#addons` (remember the extension will be removed after browser is closed!). +### Thunderbird extension + +Thunderbird requires a separate build due to the experimental APIs needed for credential interception. When Thunderbird requests credentials, Browserpass intercepts the request and retrieves them from your pass store using GPG decryption. **Credentials are never stored in Thunderbird's password manager.** + +1. **Install Native Host**: Follow the [native messaging host](https://github.com/browserpass/browserpass-native) installation, then run `make hosts-thunderbird-user` +2. **Build Extension**: Run `make thunderbird` in the browserpass-extension directory +3. **Create XPI Package**: + ```bash + cd thunderbird + zip -r browserpass-thunderbird.xpi * + ``` +4. **Install in Thunderbird**: Open Add-ons and Themes (`Ctrl+Shift+A`), click the gear icon, select "Install Add-on From File", and select the XPI file + ### Verifying authenticity of the Github releases All release files are signed with a PGP key that is available on [maximbaz.com](https://maximbaz.com/), [keybase.io](https://keybase.io/maximbaz) and various OpenPGP key servers. First, import the public key using any of these commands: @@ -121,6 +142,29 @@ Browserpass was designed with an assumption that certain conventions are being f work.gpg ``` + For Thunderbird, credentials are organized by protocol in subdirectories: + + ``` + ~/.password-store/ + imap/ + mail.example.com.gpg # IMAP server credentials + smtp/ + mail.example.com.gpg # SMTP server credentials + https/ + accounts.google.com.gpg # OAuth identity provider credentials + oauth/ + mail/ + user@gmail.com.gpg # OAuth mail tokens + caldav/ + user@gmail.com.gpg # Calendar OAuth tokens + ``` + + If IMAP and SMTP use the same password, you can use a symlink: + ```bash + cd ~/.password-store/smtp/ + ln -s ../imap/mail.example.com.gpg mail.example.com.gpg + ``` + 1. Password must be defined on a line starting with `password:`, `pass:` or `secret:` (case-insensitive), and if all of these are absent, the first line in the password entry file is considered to be a password. 1. Username must be defined on a line starting with `login:`, `username:`, or `user:` (case-insensitive), and if all of these are absent, default username as configured in browser extension or in `.browserpass.json` of specific password store, and finally if everything is absent the file name is considered to be a username. @@ -405,6 +449,7 @@ See below the list of available `make` goals (check Makefile for more details). | `make extension` | Compile the extension source code | | `make chromium` | Compile the extension source code, prepare unpacked extension for Chromium | | `make firefox` | Compile the extension source code, prepare unpacked extension for Firefox | +| `make thunderbird` | Compile the extension source code, prepare unpacked extension for Thunderbird | | `make crx` | Compile the extension source code, prepare packed extension for Chromium | ### Load an unpacked extension diff --git a/src/Makefile b/src/Makefile index 76516b21..83c34f05 100644 --- a/src/Makefile +++ b/src/Makefile @@ -3,7 +3,7 @@ PRETTIER := node_modules/.bin/prettier LESSC := node_modules/.bin/lessc CLEAN_FILES := css js -PRETTIER_FILES := $(wildcard *.json *.js popup/*.js offscreen/*.js options/*.js *.less popup/*.less options/*.less *.html popup/*.html offscreen/*.html options/*.html) +PRETTIER_FILES := $(wildcard *.json *.js popup/*.js offscreen/*.js options/*.js thunderbird/experiment/*.js thunderbird/experiment/*.json *.less popup/*.less options/*.less *.html popup/*.html offscreen/*.html options/*.html) .PHONY: all all: deps prettier css/popup.dist.css css/options.dist.css js/background.dist.js js/popup.dist.js js/offscreen.dist.js js/options.dist.js js/inject.dist.js @@ -24,7 +24,7 @@ css/options.dist.css: $(LESSC) options/*.less [ -d css ] || mkdir -p css $(LESSC) options/options.less css/options.dist.css -js/background.dist.js: $(BROWSERIFY) background.js helpers/*.js +js/background.dist.js: $(BROWSERIFY) background.js helpers/*.js thunderbird.js [ -d js ] || mkdir -p js $(BROWSERIFY) -o js/background.dist.js background.js diff --git a/src/background.js b/src/background.js index 9f2ac8ec..62d6d615 100644 --- a/src/background.js +++ b/src/background.js @@ -1309,3 +1309,109 @@ function onExtensionInstalled(details) { }); } } + +// ============================================================================= +// Thunderbird Support +// ============================================================================= +// When running in Thunderbird, this extension intercepts credential requests +// from different protocols (IMAP, SMTP, POP3, NNTP) and OAuth authentication flows. +// The experimental API in implementation.js hooks into Thunderbird's auth +// system and emits events that we handle here. + +let thunderbirdModule = null; + +/** + * Initializes Thunderbird support if running in Thunderbird. + * Sets up listeners for credential requests and storage events. + */ +(function initThunderbird() { + if (!helpers.isThunderbird()) { + return; + } + + try { + thunderbirdModule = require("./thunderbird"); + console.log("Browserpass: Thunderbird mode enabled"); + + /** + * Listener for credential requests from Thunderbird. + * Called when Thunderbird needs credentials for IMAP, SMTP, POP3, or other protocols. + * Returns matching credentials from the pass store or empty list if none found. + * + * @param {object} credentialInfo - Information about the credential request + * @param {string} credentialInfo.host - The host requesting credentials + * @param {string} credentialInfo.username - Optional username hint + * @returns {object} Result with autoSubmit flag and array of matching credentials + */ + browser.credentials.onCredentialRequested.addListener(async function (credentialInfo) { + try { + const settings = await getFullSettings(); + settings.appID = appID; + return await thunderbirdModule.handleCredentialRequest(settings, credentialInfo); + } catch (error) { + console.error("Error handling credential request:", error); + return { autoSubmit: false, credentials: [] }; + } + }); + + /** + * Listener for new credential storage requests. + * Called when Thunderbird has new credentials to store (e.g., after successful login). + * Stores the credentials to the pass store for future use. + * + * @param {object} credentialInfo - Information about the credentials to store + * @param {string} credentialInfo.host - The host these credentials are for + * @param {string} credentialInfo.login - The username + * @param {string} credentialInfo.password - The password or OAuth token + * @returns {boolean} True if credentials were successfully stored + */ + browser.credentials.onNewCredential.addListener(async function (credentialInfo) { + try { + const settings = await getFullSettings(); + settings.appID = appID; + return await thunderbirdModule.handleNewCredential(settings, credentialInfo); + } catch (error) { + console.error("Error handling new credential:", error); + return false; + } + }); + + /** + * Listener for messages from content scripts and other extension components. + * Handles special "saveOAuthToken" action to store OAuth tokens obtained from browser windows. + * + * @param {object} request - The message request object + * @param {string} request.action - The action to perform (e.g., "saveOAuthToken") + * @param {string} request.host - The host the token is for + * @param {string} request.login - The username associated with the token + * @param {string} request.token - The OAuth token to store + * @param {object} sender - Information about the message sender + * @param {function} sendResponse - Callback to send response back to sender + * @returns {boolean} True to indicate the response will be sent asynchronously + */ + browser.runtime.onMessage.addListener(function (request, sender, sendResponse) { + if (request.action === "saveOAuthToken") { + (async function () { + try { + console.log("Browserpass: Saving OAuth token for:", request.host); + const settings = await getFullSettings(); + settings.appID = appID; + const result = await thunderbirdModule.handleNewCredential(settings, { + host: request.host, + login: request.login, + password: request.token, + }); + sendResponse({ success: result }); + } catch (error) { + console.error("Error saving OAuth token:", error); + sendResponse({ success: false }); + } + })(); + return true; + } + return false; + }); + } catch (error) { + console.error("Browserpass: Failed to initialize Thunderbird support:", error); + } +})(); diff --git a/src/helpers/base.js b/src/helpers/base.js index 6284c162..a4cb522a 100644 --- a/src/helpers/base.js +++ b/src/helpers/base.js @@ -33,6 +33,7 @@ module.exports = { getSetting, ignoreFiles, isChrome, + isThunderbird, makeTOTP, parseAuthUrl, prepareLogin, @@ -96,6 +97,16 @@ function isChrome() { return chrome.runtime.getURL("/").startsWith("chrom"); } +/** + * Returns true if running in Thunderbird. + * Checks for the browser.credentials API which is only available in Thunderbird. + * + * @return boolean + */ +function isThunderbird() { + return typeof browser !== "undefined" && browser.credentials !== undefined; +} + /** * Get the deepest available domain component of a path * diff --git a/src/manifest-thunderbird.json b/src/manifest-thunderbird.json new file mode 100644 index 00000000..19b14356 --- /dev/null +++ b/src/manifest-thunderbird.json @@ -0,0 +1,60 @@ +{ + "manifest_version": 3, + "name": "Browserpass", + "description": "Thunderbird extension for zx2c4's pass (password manager)", + "version": "3.11.0", + "author": "Maxim Baz , Steve Gilberd ", + "homepage_url": "https://github.com/browserpass/browserpass-extension", + "background": { + "scripts": ["js/background.dist.js"] + }, + "icons": { + "16": "icon16.png", + "128": "icon.png" + }, + "action": { + "default_icon": { + "16": "icon16.png", + "128": "icon.svg" + }, + "default_popup": "popup/popup.html" + }, + "options_ui": { + "page": "options/options.html", + "open_in_tab": false + }, + "permissions": [ + "activeTab", + "alarms", + "tabs", + "clipboardRead", + "clipboardWrite", + "nativeMessaging", + "notifications", + "scripting", + "storage", + "webRequest", + "webRequestAuthProvider" + ], + "host_permissions": ["http://*/*", "https://*/*"], + "content_security_policy": { + "extension_pages": "default-src 'none'; font-src 'self'; img-src 'self' data:; script-src 'self'" + }, + "browser_specific_settings": { + "gecko": { + "id": "browserpass@maximbaz.com", + "strict_min_version": "128.0" + } + }, + "experiment_apis": { + "credentials": { + "schema": "thunderbird/experiment/schema.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["credentials"]], + "script": "thunderbird/experiment/implementation.js" + } + } + }, + "commands": {} +} diff --git a/src/thunderbird.js b/src/thunderbird.js new file mode 100644 index 00000000..3cf1c495 --- /dev/null +++ b/src/thunderbird.js @@ -0,0 +1,668 @@ +"use strict"; + +const helpers = require("./helpers/base"); +const sha1 = require("sha1"); + +// ============================================================================= +// Store Configuration Validation +// ============================================================================= + +// Track if we've already warned about missing store configuration +let storeWarningShown = false; + +/** + * Checks if a valid password store is configured. + * @param {Object} settings - Extension settings + * @returns {boolean} True if at least one store is configured + */ +function hasConfiguredStore(settings) { + const storeIds = Object.keys(settings.stores || {}); + return storeIds.length > 0; +} + +/** + * Gets the first configured store ID. + * @param {Object} settings - Extension settings + * @returns {string|null} Store ID or null if none configured + */ +function getStoreId(settings) { + const storeIds = Object.keys(settings.stores || {}); + return storeIds.length > 0 ? storeIds[0] : null; +} + +/** + * Logs a warning about missing password store configuration and shows a notification. + * Only logs once per session to avoid spamming the console and user. + */ +function warnMissingStore() { + if (!storeWarningShown) { + storeWarningShown = true; + const message = + "Please configure a password store in the extension preferences or set PASSWORD_STORE_DIR environment variable."; + + console.warn("Browserpass: No password store configured. " + message); + + try { + browser.notifications.create("browserpass-no-store", { + type: "basic", + iconUrl: browser.runtime.getURL("icon.svg"), + title: "Browserpass - Password Store Not Configured", + message: message, + }); + } catch (e) { + // Notifications might not be available in all contexts, silently fail + } + } +} + +// ============================================================================= +// Native Messaging +// ============================================================================= + +/** + * Sends a message to the browserpass native host application. + * @param {string} appID - The native application ID + * @param {Object} request - The request payload + * @returns {Promise} The native host response + */ +function sendNativeMessage(appID, request) { + return chrome.runtime.sendNativeMessage(appID, request); +} + +// ============================================================================= +// Password Parsing +// ============================================================================= + +/** + * Parses password file contents from standard pass format. + * @param {string} contents - Raw file contents + * @param {string} filepath - Path for identification + * @returns {{password: string, login: string|null, name: string}} Parsed data + */ +function parsePasswordContents(contents, filepath) { + const lines = contents.split(/[\r\n]+/).filter((line) => line.trim().length > 0); + const data = { + password: lines[0] || "", + login: null, + name: filepath, + }; + + for (let i = 1; i < lines.length; i++) { + const parts = lines[i].match(/^(.+?):(.*)$/); + if (!parts) continue; + + const key = parts[1].trim().toLowerCase(); + const value = parts[2].trim(); + + if (helpers.fieldsPrefix.login.includes(key)) { + data.login = value; + } + } + + return data; +} + +// ============================================================================= +// OAuth Scope Handling +// ============================================================================= + +/** + * Determines which service is required from an OAuth scope string. + * Priority: mail > caldav > carddav (first match wins). + * @param {string} scope - Space-separated OAuth scope URLs + * @returns {string|null} Service name (mail, caldav, carddav) or null if unknown + */ +function scopeToRequiredService(scope) { + if (!scope) { + return null; + } + + // Check for mail-related scopes first (highest priority) + if ( + scope.includes("mail.google.com") || + scope.includes("IMAP.") || + scope.includes("POP.") || + scope.includes("SMTP.") || + scope.includes("EWS.") || + scope.includes("protocol-imap") || + scope.includes("protocol-smtp") || + scope.includes("protocol-pop") + ) { + return "mail"; + } + + // Check for calendar/caldav scopes + if ( + scope.includes("auth/calendar") || + scope.includes("caldav") || + scope.includes("protocol-caldav") || + scope.includes("/calendar") + ) { + return "caldav"; + } + + // Check for contacts/carddav scopes + if ( + scope.includes("auth/carddav") || + scope.includes("carddav") || + scope.includes("protocol-carddav") || + scope.includes("/contacts") || + scope.includes("/addressbook") + ) { + return "carddav"; + } + + return null; +} + +/** + * Checks if a file path supports the required OAuth service scope. + * @param {string} filepath - The pass file path (e.g., oauth/mail/user@example.com.gpg) + * @param {string} requiredScope - Space-separated scope URLs + * @returns {boolean} True if file supports the required service + */ +function fileSupportsRequiredScope(filepath, requiredScope) { + const filenameLower = filepath.toLowerCase(); + + // For non-OAuth files, always return true + if (!filenameLower.startsWith("oauth/")) { + return true; + } + + // Extract service from path: oauth/{service}/{user}.gpg + const pathParts = filenameLower.split("/"); + if (pathParts.length < 3) { + return true; // Malformed path, accept it + } + const fileService = pathParts[1]; // mail, caldav, carddav, or token + + // Use shared scope analysis + const requiredService = scopeToRequiredService(requiredScope); + + // If scope is empty/unknown, prefer mail tokens (broadest scope) + if (!requiredService) { + return fileService === "mail" || fileService === "token"; + } + + return fileService === requiredService; +} + +// ============================================================================= +// OAuth Provider Detection +// ============================================================================= + +/** Known OAuth provider hostnames */ +const OAUTH_PROVIDER_HOSTS = [ + "accounts.google.com", + "login.microsoftonline.com", + "login.live.com", + "app.fastmail.com", + "www.fastmail.com", +]; + +/** + * Checks if a host is a known OAuth provider. + * @param {string} host - The hostname to check + * @returns {boolean} True if this is a known OAuth provider + */ +function isOAuthProviderHost(host) { + if (!host) return false; + try { + const url = new URL(host); + return OAUTH_PROVIDER_HOSTS.includes(url.hostname); + } catch { + return OAUTH_PROVIDER_HOSTS.includes(host); + } +} + +// ============================================================================= +// Credential Request Handling +// ============================================================================= + +/** + * Find credentials in password store for Thunderbird + * + * Supported protocols: + * - imap://{server} - IMAP mail server credentials + * - smtp://{server} - SMTP mail server credentials + * - pop3://{server} - POP3 mail server credentials + * - nntp://{server} - NNTP news server credentials + * - oauth://{provider} - OAuth2 tokens (e.g., oauth://accounts.google.com) + * - https://{server} - OAuth browser window credentials (identity provider login) + * + * Search patterns use directory structure (pass doesn't support colons in filenames): + * - {protocol}/{server} (e.g., imap/mail.example.com, https/accounts.google.com) + * - oauth/{service}/{login} (for OAuth tokens by account) + * + * Files MUST be in protocol-specific directories to be matched. + * This prevents decrypting unrelated files from generic password stores. + * + * @param {Array} files - List of password files from store + * @param {Object} credentialInfo - Credential request information + * @param {string} credentialInfo.host - Host URL with protocol + * @param {string} [credentialInfo.login] - Optional login/username + * @returns {Array} Matching password entries + */ +function findThunderbirdCredentials(files, credentialInfo) { + const host = credentialInfo.host; + const login = credentialInfo.login; + + let protocol = ""; + let hostname = ""; + let hostPort = ""; + + // Handle oauth: and oauth:// formats from Thunderbird + if (host.startsWith("oauth://") || host.startsWith("oauth:")) { + protocol = "oauth"; + hostname = host.replace(/^oauth:\/?\/?/, ""); + } else { + try { + const url = new URL(host); + protocol = url.protocol.replace(":", ""); + hostname = url.hostname; + hostPort = url.port ? `${hostname}:${url.port}` : hostname; + } catch (e) { + hostname = host.replace(/:\d+$/, ""); + hostPort = host.includes(":") ? host : hostname; + } + } + + // Build search patterns - files MUST start with protocol directory + // This ensures we only match Thunderbird-specific credential files + const searchPatterns = []; + + if (protocol === "oauth") { + // OAuth files are stored as oauth/{service}/{user}.gpg + // where service is: mail, caldav, carddav, or token + if (login && login !== true) { + // Match oauth/{service}/{login} patterns + searchPatterns.push(`oauth/mail/${login}`); + searchPatterns.push(`oauth/caldav/${login}`); + searchPatterns.push(`oauth/carddav/${login}`); + searchPatterns.push(`oauth/token/${login}`); + } + } else if (protocol === "https") { + // HTTPS files for OAuth browser window credentials: https/{hostname}.gpg + // Used for identity provider login pages (e.g., accounts.google.com) + searchPatterns.push(`https/${hostname}`); + if (hostPort !== hostname) { + searchPatterns.push(`https/${hostPort}`); + } + } else if (["imap", "smtp", "pop3", "nntp"].includes(protocol)) { + // Mail protocol files: {protocol}/{hostname}.gpg + searchPatterns.push(`${protocol}/${hostname}`); + if (hostPort !== hostname) { + searchPatterns.push(`${protocol}/${hostPort}`); + } + } + // Note: We intentionally don't have a fallback for unknown protocols + // This prevents matching generic password files + + if (searchPatterns.length === 0) { + return []; + } + + return files.filter((file) => { + const filePath = file.toLowerCase(); + return searchPatterns.some((pattern) => { + const patternLower = pattern.toLowerCase(); + // Use startsWith for precise matching - file must be in protocol directory + return filePath.startsWith(patternLower); + }); + }); +} + +/** + * Handles credential requests from Thunderbird's auth prompts. + * @param {Object} settings - Extension settings with store configuration + * @param {Object} credentialInfo - Request details from Thunderbird + * @param {string} credentialInfo.host - Host URL (e.g., imap://mail.example.com) + * @param {string} [credentialInfo.login] - Optional username to match + * @param {string} [credentialInfo.requiredScope] - OAuth scope for filtering + * @returns {Promise<{autoSubmit: boolean, credentials: Array}>} Matching credentials + */ +async function handleCredentialRequest(settings, credentialInfo) { + try { + // Check if a password store is configured + if (!hasConfiguredStore(settings)) { + warnMissingStore(); + return { autoSubmit: false, credentials: [] }; + } + + const isOAuth = credentialInfo.host?.startsWith("oauth"); + + // Detect OAuth browser window requests: host is OAuth provider + has scope + const isOAuthBrowserRequest = + !isOAuth && credentialInfo.requiredScope && isOAuthProviderHost(credentialInfo.host); + + console.debug("Browserpass: Thunderbird credential request:", { + host: credentialInfo.host, + login: credentialInfo.login, + requiredScope: credentialInfo.requiredScope, + isOAuth, + isOAuthBrowserRequest, + }); + + const listResponse = await sendNativeMessage(settings.appID, { + settings: settings, + action: "list", + }); + + if (listResponse.status !== "ok") { + console.error("Browserpass: Failed to list password files:", listResponse); + return { autoSubmit: false, credentials: [] }; + } + + // Flatten all files from all stores + const allFiles = []; + for (const storeId in listResponse.data.files) { + const storeFiles = listResponse.data.files[storeId]; + storeFiles.forEach((file) => { + allFiles.push({ storeId: storeId, path: file }); + }); + } + + // Find matching files using protocol-based search + // For OAuth browser windows, host already has https:// prefix from implementation.js + const matchingFiles = findThunderbirdCredentials( + allFiles.map((f) => f.path), + credentialInfo + ); + + console.debug("Browserpass: Matching files found:", matchingFiles); + + if (matchingFiles.length === 0) { + // No matching files, but store is working - return autoSubmit: true + return { autoSubmit: true, credentials: [] }; + } + + // Fetch and parse password contents + const credentials = []; + for (const matchingFile of matchingFiles) { + const fileObj = allFiles.find((f) => f.path === matchingFile); + if (!fileObj) continue; + + const fetchResponse = await sendNativeMessage(settings.appID, { + settings: settings, + action: "fetch", + storeId: fileObj.storeId, + file: matchingFile, + }); + + if (fetchResponse.status === "ok" && fetchResponse.data.contents) { + const parsed = parsePasswordContents(fetchResponse.data.contents, matchingFile); + + // Filter by login if specified + if ( + credentialInfo.login && + credentialInfo.login !== true && + parsed.login && + parsed.login.toLowerCase() !== credentialInfo.login.toLowerCase() + ) { + continue; + } + + // Filter by scope for OAuth tokens - check if filename matches required service + // Always filter OAuth files to ensure correct service-specific token is returned + if (matchingFile.toLowerCase().includes("oauth/")) { + const scopeMatches = fileSupportsRequiredScope( + matchingFile, + credentialInfo.requiredScope || "" + ); + console.debug( + "Browserpass: OAuth scope check:", + matchingFile, + "requiredScope:", + credentialInfo.requiredScope, + "matches:", + scopeMatches + ); + if (!scopeMatches) { + continue; + } + } + + parsed.uuid = sha1(fileObj.storeId + matchingFile); + parsed.storeId = fileObj.storeId; + parsed.file = matchingFile; + + credentials.push(parsed); + } + } + + if (credentials.length === 0 && (isOAuth || isOAuthBrowserRequest)) { + console.debug("Browserpass: No OAuth token found for:", credentialInfo.host); + } + + // Always log which OAuth credential files are being returned + if ((isOAuth || isOAuthBrowserRequest) && credentials.length > 0) { + console.debug( + "Browserpass: Returning OAuth credentials for", + credentialInfo.host, + "requiredScope:", + credentialInfo.requiredScope, + "files:", + credentials.map((c) => c.file).join(", ") + ); + } + + return { + autoSubmit: settings.autoSubmit || false, + credentials: credentials, + }; + } catch (error) { + console.error("Browserpass: Error handling credential request:", error); + return { autoSubmit: false, credentials: [] }; + } +} + +// ============================================================================= +// Credential Storage +// ============================================================================= + +/** + * Converts OAuth scope URLs to list of service names. + * @param {string} scope - Space-separated OAuth scope URLs + * @returns {string[]} Array of service names like ["mail", "caldav", "carddav"] + */ +function scopeToServices(scope) { + if (!scope) { + return ["token"]; + } + + const services = []; + + // Google scopes + if (scope.includes("mail.google.com")) { + services.push("mail"); + } + if (scope.includes("auth/calendar") || scope.includes("auth/caldav")) { + services.push("caldav"); + } + if (scope.includes("auth/carddav")) { + services.push("carddav"); + } + + // Microsoft scopes + if (scope.includes("IMAP.") || scope.includes("POP.") || scope.includes("SMTP.")) { + if (!services.includes("mail")) services.push("mail"); + } + if (scope.includes("EWS.") || scope.includes("graph.microsoft")) { + if (!services.includes("mail")) services.push("mail"); + } + + // Fastmail scopes + if ( + scope.includes("protocol-imap") || + scope.includes("protocol-pop") || + scope.includes("protocol-smtp") + ) { + if (!services.includes("mail")) services.push("mail"); + } + if (scope.includes("protocol-caldav")) { + if (!services.includes("caldav")) services.push("caldav"); + } + if (scope.includes("protocol-carddav")) { + if (!services.includes("carddav")) services.push("carddav"); + } + + // Generic fallbacks + if (scope.includes("mail") && !services.includes("mail")) { + services.push("mail"); + } + if (scope.includes("calendar") && !services.includes("caldav")) { + services.push("caldav"); + } + if ( + (scope.includes("contacts") || scope.includes("addressbook")) && + !services.includes("carddav") + ) { + services.push("carddav"); + } + + return services.length > 0 ? services : ["token"]; +} + +/** + * Saves an OAuth token file to the password store. + * @param {Object} settings - Extension settings + * @param {string} storeId - Password store ID + * @param {string} filepath - File path within the store + * @param {string} password - OAuth refresh token + * @param {string} login - User's email/login + * @param {string} host - OAuth host (e.g., oauth://accounts.google.com) + * @param {string} [scope] - OAuth scope + * @returns {Promise} True if save succeeded + */ +async function saveOAuthFile(settings, storeId, filepath, password, login, host, scope) { + let contents = password; + if (login && login !== true) { + contents += `\nlogin: ${login}`; + } + contents += `\nurl: ${host}`; + if (scope) { + contents += `\nscope: ${scope}`; + } + + console.debug("Browserpass: Saving OAuth token to:", filepath); + + const saveResponse = await sendNativeMessage(settings.appID, { + settings: settings, + action: "save", + storeId: storeId, + file: filepath, + contents: contents, + }); + + return saveResponse.status === "ok"; +} + +/** + * Handles saving new credentials from Thunderbird. + * @param {Object} settings - Extension settings + * @param {Object} credentialInfo - Credential data + * @param {string} credentialInfo.host - Host URL + * @param {string} [credentialInfo.login] - Username + * @param {string} credentialInfo.password - Password or token + * @param {string} [credentialInfo.scope] - OAuth scope (for OAuth tokens) + * @returns {Promise} True if saved successfully + */ +async function handleNewCredential(settings, credentialInfo) { + try { + if (!credentialInfo.password) { + return false; + } + + // Check if a password store is configured + const storeId = getStoreId(settings); + if (!storeId) { + warnMissingStore(); + return false; + } + + // Handle OAuth tokens - save to service-specific directories + if ( + credentialInfo.host.startsWith("oauth://") || + credentialInfo.host.startsWith("oauth:") + ) { + const username = + credentialInfo.login || credentialInfo.host.replace(/^oauth:\/?\/?\/?/, ""); + const services = scopeToServices(credentialInfo.scope); + + let allSaved = true; + for (const service of services) { + const filepath = `oauth/${service}/${username}.gpg`; + const saved = await saveOAuthFile( + settings, + storeId, + filepath, + credentialInfo.password, + credentialInfo.login, + credentialInfo.host, + credentialInfo.scope + ); + if (!saved) { + console.error("Browserpass: Failed to save:", filepath); + allSaved = false; + } + } + + return allSaved; + } + + // Non-OAuth credentials + let protocol = ""; + let hostname = credentialInfo.host; + + try { + const url = new URL(credentialInfo.host); + protocol = url.protocol.replace(":", ""); + hostname = url.hostname; + if (url.port) { + hostname += ":" + url.port; + } + } catch (e) { + const match = credentialInfo.host.match(/^([a-z]+):\/\/(.+)/i); + if (match) { + protocol = match[1]; + hostname = match[2].split("/")[0]; + } else { + console.error("Browserpass: Invalid URL:", credentialInfo.host); + return false; + } + } + + const filepath = `${protocol}/${hostname}.gpg`; + + let contents = credentialInfo.password; + if (credentialInfo.login && credentialInfo.login !== true) { + contents += `\nlogin: ${credentialInfo.login}`; + } + contents += `\nurl: ${credentialInfo.host}`; + + console.debug("Browserpass: Saving credential to:", filepath, "in store:", storeId); + + const saveResponse = await sendNativeMessage(settings.appID, { + settings: settings, + action: "save", + storeId: storeId, + file: filepath, + contents: contents, + }); + + if (saveResponse.status === "ok") { + return true; + } else { + console.error("Browserpass: Failed to save credential:", saveResponse); + return false; + } + } catch (error) { + console.error("Browserpass: Error handling new credential:", error); + return false; + } +} + +module.exports = { + handleCredentialRequest, + handleNewCredential, +}; diff --git a/src/thunderbird/experiment/implementation.js b/src/thunderbird/experiment/implementation.js new file mode 100644 index 00000000..d1e4d8d3 --- /dev/null +++ b/src/thunderbird/experiment/implementation.js @@ -0,0 +1,2189 @@ +/* globals ChromeUtils, Cc, Ci, Components, XPCOMUtils, globalThis*/ +/* eslint eslint-comments/no-use: off */ +/* eslint {"indent": ["error", "tab", {"SwitchCase": 1, "outerIIFEBody": 0}]}*/ +"use strict"; +((exports) => { + // ============================================================================ + // Module Imports + // ============================================================================ + + /** + * Import a Thunderbird module using the appropriate method for the version. + * Handles both legacy ChromeUtils.import (.jsm) and new ChromeUtils.importESModule (.sys.mjs). + * + * @param {string} path - Resource path to the module (without extension) + * @param {boolean} [addExtension=true] - Whether to append file extension + * @returns {object} The imported module + * @throws {Error} If no import method is available + */ + function importModule(path, addExtension = true) { + if (ChromeUtils.import) { + return ChromeUtils.import(path + (addExtension ? ".jsm" : "")); + } else if (ChromeUtils.importESModule) { + return ChromeUtils.importESModule(path + (addExtension ? ".sys.mjs" : "")); + } else { + throw new Error("Unable to import module " + path); + } + } + + const { ExtensionCommon } = importModule("resource://gre/modules/ExtensionCommon"); + const { ExtensionParent } = importModule("resource://gre/modules/ExtensionParent"); + const { setTimeout, clearTimeout, setInterval, clearInterval } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + + /** + * Get Thunderbird's Services object. + * + * In some Thunderbird contexts, Services is available directly on globalThis. + * In others (especially during early startup), it needs to be imported. + * This pattern tries the global first for performance, then falls back to import. + * The try/catch handles cases where globalThis.Services throws a security error. + */ + const Services = (function () { + let services; + try { + services = globalThis.Services; + } catch (error) { + // Security error accessing globalThis.Services - fall back to import + } + return services || importModule("resource://gre/modules/Services").Services; + })(); + + // ============================================================================ + // Extension Setup + // ============================================================================ + + const extension = ExtensionParent.GlobalManager.getExtension("browserpass@maximbaz.com"); + + const resProto = Cc["@mozilla.org/network/protocol;1?name=resource"].getService( + Ci.nsISubstitutingProtocolHandler + ); + + resProto.setSubstitutionWithFlags( + "browserpass", + Services.io.newURI("thunderbird/experiment", null, extension.rootURI), + resProto.ALLOW_CONTENT_ACCESS + ); + + console.debug("Browserpass: Experimental API initializing..."); + + // ============================================================================ + // Offline Startup Control + // ============================================================================ + // Ensures Thunderbird starts offline so our hooks are ready before any + // credential requests occur. + // + // On shutdown: Save user's offline.startup_state, then set to ALWAYS_OFFLINE (3) + // On startup: Restore user's preference and go online when hooks are ready + + const OFFLINE_STARTUP_PREF = "offline.startup_state"; + const SAVED_STARTUP_STATE_PREF = "browserpass.saved_offline_startup_state"; + const ALWAYS_OFFLINE = 3; + + // Import Thunderbird's OfflineStartup module to re-run startup logic + const { OfflineStartup } = ChromeUtils.importESModule( + "resource:///modules/OfflineStartup.sys.mjs" + ); + + // Force offline immediately (may be too late but helps in some cases) + Services.io.offline = true; + + const offlineControl = { + initialized: false, + observing: false, + + /** + * Called when first listener registers - restore online state. + */ + applyStartupState: function () { + if (this.initialized) { + return; + } + this.initialized = true; + + try { + console.debug("Browserpass: Extension ready - restoring online state"); + + // Restore saved preference if exists + let userWantsOffline = false; + if (Services.prefs.prefHasUserValue(SAVED_STARTUP_STATE_PREF)) { + const savedState = Services.prefs.getIntPref(SAVED_STARTUP_STATE_PREF); + Services.prefs.clearUserPref(SAVED_STARTUP_STATE_PREF); + Services.prefs.setIntPref(OFFLINE_STARTUP_PREF, savedState); + console.debug("Browserpass: Restored offline.startup_state to:", savedState); + userWantsOffline = savedState === ALWAYS_OFFLINE; + } else { + // Check current preference + const currentState = Services.prefs.getIntPref(OFFLINE_STARTUP_PREF, 0); + userWantsOffline = currentState === ALWAYS_OFFLINE; + } + + if (userWantsOffline) { + console.debug( + "Browserpass: User preference is Always offline - staying offline" + ); + return; + } + + // Re-run Thunderbird's startup logic to go online + console.debug( + "Browserpass: Re-running Thunderbird's OfflineStartup.onProfileStartup()" + ); + OfflineStartup.prototype.onProfileStartup(); + + // Enable auto-detect if configured + if (Services.prefs.getBoolPref("offline.autoDetect", false)) { + console.debug("Browserpass: autoDetect enabled - enabling manageOfflineStatus"); + Services.io.offline = false; + Services.io.manageOfflineStatus = true; + } + } catch (e) { + console.error("Browserpass: Error in offlineControl.applyStartupState:", e); + Services.io.offline = false; + } + }, + + /** + * Start observing shutdown to save preferences. + */ + startObserving: function () { + if (this.observing) return; + this.observing = true; + Services.obs.addObserver(this, "quit-application-granted"); + console.debug("Browserpass: Started observing quit-application-granted"); + }, + + /** + * Stop observing shutdown. + */ + stopObserving: function () { + if (!this.observing) return; + this.observing = false; + try { + Services.obs.removeObserver(this, "quit-application-granted"); + } catch (e) { + // Observer may not be registered + } + }, + + /** + * Observer interface implementation. + */ + observe: function (subject, topic, data) { + if (topic === "quit-application-granted") { + this.onShutdown(); + } + }, + + /** + * Called on shutdown - save user's preference and force offline for next startup. + */ + onShutdown: function () { + try { + const currentState = Services.prefs.getIntPref(OFFLINE_STARTUP_PREF, 0); + + // Only save if not already ALWAYS_OFFLINE (user's explicit choice) + if (currentState !== ALWAYS_OFFLINE) { + console.debug( + "Browserpass: Shutdown - saving offline.startup_state:", + currentState + ); + Services.prefs.setIntPref(SAVED_STARTUP_STATE_PREF, currentState); + Services.prefs.setIntPref(OFFLINE_STARTUP_PREF, ALWAYS_OFFLINE); + console.debug( + "Browserpass: Set offline.startup_state to ALWAYS_OFFLINE for next startup" + ); + } + } catch (e) { + console.error("Browserpass: Error in offlineControl.onShutdown:", e); + } + }, + }; + + // Start observing shutdown immediately + offlineControl.startObserving(); + + // ============================================================================ + // Event Emitters + // ============================================================================ + + const passwordRequestEmitter = new ExtensionCommon.EventEmitter(); + const passwordEmitter = new ExtensionCommon.EventEmitter(); + + let requestListenerCount = 0; + let storeListenerCount = 0; + + // Track if we're in account setup mode (disable token injection during setup) + let accountSetupInProgress = false; + + // Queue for storing credentials when no listener is available yet + const pendingStores = []; + + // Store extension context for waking up background script + let extensionContext = null; + + // Promise that resolves when first request listener is available + // This is recreated each time listeners go to 0 + let listenerReadyPromise = null; + let listenerReadyResolve = null; + + // Promise that resolves when first store listener is available + let storeListenerReadyPromise = null; + let storeListenerReadyResolve = null; + + /** + * Returns a promise that resolves when a credential request listener is available. + * Attempts to wake up the extension if no listener is registered. + * + * @returns {Promise} Resolves when listener is ready or times out + */ + async function getListenerReadyPromise() { + if (requestListenerCount > 0) { + return Promise.resolve(); + } + + // Try to wake up the extension and wait for listener + for (let attempt = 0; attempt < 3; attempt++) { + console.debug( + "Browserpass: Waiting for request listener... (attempt", + attempt + 1, + "of 3)" + ); + + const wokenUp = await wakeUpExtension(); + if (wokenUp && requestListenerCount > 0) { + console.debug("Browserpass: Listener registered after wake-up"); + return Promise.resolve(); + } + + if (requestListenerCount > 0) { + return Promise.resolve(); + } + + // Wait a bit for listener to register + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (requestListenerCount > 0) { + return Promise.resolve(); + } + } + + // Still no listener after attempts - wait with timeout + if (!listenerReadyPromise) { + console.debug( + "Browserpass: No listener after wake-up attempts, waiting with timeout..." + ); + listenerReadyPromise = new Promise((resolve) => { + listenerReadyResolve = resolve; + // Timeout after 5 seconds + setTimeout(() => { + if (listenerReadyResolve === resolve) { + console.debug( + "Browserpass: Request listener wait timed out after 5 seconds" + ); + resolve(); + listenerReadyResolve = null; + listenerReadyPromise = null; + } + }, 5000); + }); + } + return listenerReadyPromise; + } + + /** + * Returns a promise that resolves when a credential store listener is available. + * Attempts to wake up the extension if no listener is registered. + * + * @returns {Promise} Resolves when listener is ready or times out + */ + async function getStoreListenerReadyPromise() { + if (storeListenerCount > 0) { + return Promise.resolve(); + } + + // Try to wake up the extension and wait for listener + for (let attempt = 0; attempt < 3; attempt++) { + console.debug( + "Browserpass: Waiting for store listener... (attempt", + attempt + 1, + "of 3)" + ); + + const wokenUp = await wakeUpExtension(); + if (wokenUp && storeListenerCount > 0) { + console.debug("Browserpass: Store listener registered after wake-up"); + return Promise.resolve(); + } + + if (storeListenerCount > 0) { + return Promise.resolve(); + } + + // Wait a bit for listener to register + await new Promise((resolve) => setTimeout(resolve, 500)); + + if (storeListenerCount > 0) { + return Promise.resolve(); + } + } + + // Still no listener after attempts - wait with timeout + if (!storeListenerReadyPromise) { + console.debug( + "Browserpass: No store listener after wake-up attempts, waiting with timeout..." + ); + storeListenerReadyPromise = new Promise((resolve) => { + storeListenerReadyResolve = resolve; + // Timeout after 30 seconds to avoid blocking forever + setTimeout(() => { + if (storeListenerReadyResolve === resolve) { + console.debug( + "Browserpass: Store listener wait timed out after 30 seconds" + ); + resolve(); + storeListenerReadyResolve = null; + storeListenerReadyPromise = null; + } + }, 30000); + }); + } + return storeListenerReadyPromise; + } + + /** + * Signals that a request listener has become available. + */ + function signalListenerReady() { + if (listenerReadyResolve) { + listenerReadyResolve(); + listenerReadyResolve = null; + listenerReadyPromise = null; + } + } + + /** + * Signals that a store listener has become available. + */ + function signalStoreListenerReady() { + if (storeListenerReadyResolve) { + storeListenerReadyResolve(); + storeListenerReadyResolve = null; + storeListenerReadyPromise = null; + } + } + + // ============================================================================ + // Synchronous Wait Helper + // ============================================================================ + // Thunderbird's auth callbacks require synchronous returns, but our + // credential lookup is async. This bridges the gap by spinning the + // event loop until the promise resolves. + // Timeout is set to 30 seconds to allow for hardware GPG key entry. + + /** + * Synchronously waits for an async operation by spinning the event loop. + * Used to bridge async credential lookups with Thunderbird's sync auth callbacks. + * Timeout is set to 30 seconds to allow for hardware GPG key entry. + * + * @param {Promise} asyncOp - The async operation to wait for + * @param {*} fallback - Value to return on timeout or error + * @param {number} [timeoutMs=30000] - Timeout in milliseconds + * @returns {*} The result of asyncOp or fallback on timeout + */ + function awaitSync(asyncOp, fallback, timeoutMs = 30000) { + let done = false; + let result = fallback; + let timedOut = false; + + asyncOp + .then((val) => { + result = val; + }) + .catch((err) => console.error("Browserpass: Async operation failed:", err)) + .finally(() => { + done = true; + }); + + // Set a timeout to prevent infinite spinning + const timeoutHandle = setTimeout(() => { + timedOut = true; + console.error("Browserpass: Async operation timeout after", timeoutMs, "ms"); + }, timeoutMs); + + const spinLoop = + Services.tm.spinEventLoopUntilOrShutdown || + (Services.tm.spinEventLoopUntilOrQuit + ? (fn) => Services.tm.spinEventLoopUntilOrQuit("browserpass:await", fn) + : null); + + if (spinLoop) { + spinLoop(() => done || timedOut); + } else { + console.error("Browserpass: Warning: No synchronous wait mechanism available"); + } + + clearTimeout(timeoutHandle); + return result; + } + + /** + * Emits credential request to extension and collects responses. + * + * @param {object} credentialInfo - The credential request details + * @returns {Promise} Object with autoSubmit and credentials array + */ + async function requestCredentials(credentialInfo) { + if (requestListenerCount === 0) { + await getListenerReadyPromise(); + } + + if (requestListenerCount === 0) { + return { autoSubmit: true, credentials: [] }; + } + + const eventData = await passwordRequestEmitter.emit("password-requested", credentialInfo); + return (eventData || []).reduce( + (details, currentDetails) => { + if (!currentDetails) { + return details; + } + if (currentDetails.autoSubmit !== undefined) { + details.autoSubmit &= currentDetails.autoSubmit; + } + if (currentDetails.credentials && currentDetails.credentials.length) { + details.credentials = details.credentials.concat(currentDetails.credentials); + } + return details; + }, + { autoSubmit: true, credentials: [] } + ); + } + + /** + * Synchronously waits for credentials from pass. + * Blocks the current thread by spinning the event loop until resolved. + * + * @param {object} data - Request data with host, login, etc. + * @returns {object|false} Credentials result or false on timeout + */ + function waitForCredentials(data) { + data.openChoiceDialog = true; + return awaitSync(requestCredentials(data), false); + } + + /** + * Stores credentials asynchronously, waiting for listener if needed. + * + * @param {object} data - Credential data with host, login, password + * @returns {Promise} Array of store results + */ + async function storeCredentials(data) { + console.debug("Browserpass: Storing credentials for:", data.host); + if (storeListenerCount === 0) { + await getStoreListenerReadyPromise(); + } + + if (storeListenerCount === 0) { + console.error("Browserpass: No store listeners available - credential not saved"); + return [false]; + } + + return passwordEmitter.emit("password", data); + } + + /** + * Synchronously waits for credential store operation to complete. + * + * @param {object} data - Credential data with host, login, password + * @returns {boolean} True if stored successfully + */ + function waitForPasswordStore(data) { + const results = awaitSync(storeCredentials(data), [], 35000); + return (results || []).reduce((alreadyStored, stored) => alreadyStored || stored, false); + } + + const queuedCredentialKeys = new Set(); + + /** + * Queues credential store for async processing with deduplication. + * + * @param {object} data - Credential data with host, login, password + */ + function queueCredentialStore(data) { + const key = `${data.login}|${data.host}`; + + if (queuedCredentialKeys.has(key)) { + return; + } + + queuedCredentialKeys.add(key); + data.callback = () => queuedCredentialKeys.delete(key); + + if (storeListenerCount > 0) { + passwordEmitter.emit("password", data); + } else { + pendingStores.push(data); + startPendingStoreRetry(); + } + } + + let pendingStoreRetryTimer = null; + const PENDING_STORE_RETRY_INTERVAL = 2000; + const PENDING_STORE_MAX_RETRIES = 30; + let pendingStoreRetryCount = 0; + + /** + * Starts retry mechanism for pending credential stores. + * Checks periodically until a store listener becomes available. + */ + function startPendingStoreRetry() { + if (pendingStoreRetryTimer) { + return; + } + pendingStoreRetryCount = 0; + + pendingStoreRetryTimer = setInterval(() => { + pendingStoreRetryCount++; + + if (pendingStores.length === 0) { + stopPendingStoreRetry(); + return; + } + + if (pendingStoreRetryCount > PENDING_STORE_MAX_RETRIES) { + console.warn( + "Browserpass: Giving up on", + pendingStores.length, + "pending credential stores" + ); + stopPendingStoreRetry(); + return; + } + + if (storeListenerCount > 0) { + processPendingStores(); + stopPendingStoreRetry(); + } + }, PENDING_STORE_RETRY_INTERVAL); + } + + /** + * Stops the pending store retry mechanism. + */ + function stopPendingStoreRetry() { + if (pendingStoreRetryTimer) { + clearInterval(pendingStoreRetryTimer); + pendingStoreRetryTimer = null; + } + } + + /** + * Wakes up the extension's background script. + * Uses MV3 wakeupBackground() to ensure the background is running. + * + * @returns {Promise} True if wake-up succeeded + */ + async function wakeUpExtension() { + if (!extensionContext) { + return false; + } + try { + const extension = extensionContext.extension; + if (extension) { + await extension.wakeupBackground(); + await new Promise((resolve) => setTimeout(resolve, 100)); + return true; + } + } catch (e) { + console.error("Browserpass: Failed to wake up extension:", e.message); + } + return false; + } + + /** + * Processes any pending credential store requests. + * Called when a store listener becomes available. + */ + async function processPendingStores() { + if (storeListenerCount === 0 || pendingStores.length === 0) { + return; + } + const pending = pendingStores.splice(0); + for (const data of pending) { + await passwordEmitter.emit("password", data); + } + } + + // ============================================================================ + // Save Credential to Pass + // ============================================================================ + + /** + * Saves credentials to pass storage synchronously. + * + * @param {string} host - The host/origin for the credential + * @param {string} login - The username + * @param {string} password - The password to store + */ + function saveCredentialToPass(host, login, password) { + console.log("Browserpass: Saving credential to pass:", host, "login:", login); + waitForPasswordStore({ + host: host, + login: login, + password: password, + }); + } + + // ============================================================================ + // Proactive Migration from Thunderbird Password Manager + // ============================================================================ + + // Track if migration has already run this session + let migrationCompleted = false; + + // All OAuth services to migrate tokens to (Thunderbird stores one token for all) + const ALL_OAUTH_SERVICES = ["mail", "caldav", "carddav"]; + + /** + * Migrates credentials from Thunderbird's password manager to pass. + * For OAuth tokens (oauth://...), parses the scope from the URL and migrates + * only to the service types that match the scope. + * Only runs once per Thunderbird session. + * Skips migration if password store is not properly configured. + */ + async function migrateThunderbirdPasswords() { + if (migrationCompleted) { + return; + } + migrationCompleted = true; + + try { + // Test if password store is working by requesting credentials + // When no store is configured, handleCredentialRequest returns autoSubmit: false + const testResult = await requestCredentials({ + host: "browserpass-test://", + login: "test", + loginChangeable: false, + openChoiceDialog: false, + }); + + // autoSubmit is falsy (false or 0 from bitwise AND) when no password store is configured + if (!testResult || !testResult.autoSubmit) { + console.warn("Browserpass: Password store is not available - skipping migration"); + return; + } + + const allLogins = await Services.logins.getAllLogins(); + if (!allLogins || allLogins.length === 0) { + return; + } + + console.debug( + "Browserpass: Checking", + allLogins.length, + "passwords in Thunderbird for migration" + ); + + for (const loginInfo of allLogins) { + if (!loginInfo.password) { + continue; + } + + const host = loginInfo.origin || loginInfo.hostname; + const login = loginInfo.username; + + if (host.startsWith("chrome://") || host.startsWith("resource://")) { + continue; + } + + // Handle OAuth tokens specially - parse scope and migrate to matching services + if (host.startsWith("oauth://") || host.startsWith("oauth:")) { + // Thunderbird stores OAuth scope in httpRealm field + const scope = loginInfo.httpRealm || ""; + + console.debug( + "Browserpass: OAuth migration - host:", + host, + "scope:", + scope, + "login:", + login + ); + + // Determine which services need this token based on scope + let services = ALL_OAUTH_SERVICES; + if (scope) { + const recognizedServices = scopeToServices(scope); + console.debug( + "Browserpass: OAuth migration - recognizedServices:", + recognizedServices + ); + // Only use recognized services if they're not the fallback ["token"] + if (recognizedServices.length > 0 && recognizedServices[0] !== "token") { + services = recognizedServices; + } + } + + console.debug("Browserpass: OAuth migration - final services:", services); + + for (const service of services) { + // Check if this service-specific token already exists + const result = await requestCredentials({ + host: host, + login: login, + requiredScope: + service === "mail" + ? "mail" + : service === "caldav" + ? "calendar" + : "carddav", + loginChangeable: false, + openChoiceDialog: false, + }); + + if (result && result.credentials && result.credentials.length > 0) { + console.debug( + "Browserpass: OAuth migration - skipping", + login, + "service:", + service, + "- already exists in pass" + ); + continue; + } + + console.log( + "Browserpass: Migrating OAuth token to pass:", + login, + "service:", + service + ); + queueCredentialStore({ + host: host, + login: login, + password: loginInfo.password, + scope: scope, + }); + } + continue; + } + + // Non-OAuth: check if credential already exists in pass + const result = await requestCredentials({ + host: host, + login: login, + loginChangeable: false, + openChoiceDialog: false, + }); + + if (result && result.credentials && result.credentials.length > 0) { + console.debug( + "Browserpass: Migration - skipping", + host, + "login:", + login, + "- already exists in pass" + ); + continue; + } + + console.log("Browserpass: Migrating credential to pass:", host, "login:", login); + queueCredentialStore({ + host: host, + login: login, + password: loginInfo.password, + }); + } + + console.debug("Browserpass: Thunderbird password migration check complete"); + } catch (e) { + console.error("Browserpass: Error during password migration:", e.message); + } + } + + // ============================================================================ + // Original Function Storage + // ============================================================================ + + const originalFunctions = []; + + // ============================================================================ + // Credential Helpers + // ============================================================================ + + /** + * Extracts the first credential from a result set. + * + * @param {object} result - Result object with credentials array + * @returns {object|null} First credential or null if none + */ + function getFirstCredential(result) { + if (result && result.credentials && result.credentials.length > 0) { + return result.credentials[0]; + } + return null; + } + + /** + * Populates Thunderbird's authInfo object with credential data. + * + * @param {nsIAuthInformation} authInfo - Thunderbird auth info object + * @param {object} credential - Credential with login and password + */ + function fillAuthInfo(authInfo, credential) { + if (authInfo && credential) { + if (credential.login) { + authInfo.username = credential.login; + } + authInfo.password = credential.password; + } + } + + /** + * Sets .value property on XPCOM out-parameter objects. + * + * @param {object} obj - XPCOM out-parameter object + * @param {*} value - Value to set + */ + function setObjectValue(obj, value) { + if (obj && typeof obj === "object" && "value" in obj) { + obj.value = value; + } + } + + /** + * Extracts host and login from authentication realm string. + * + * @param {MsgAuthPrompt} prompter - The prompter instance + * @param {string} realm - The authentication realm + * @returns {{host: string, login: string}} Parsed host and login + */ + function parseRealm(prompter, realm) { + let host = realm; + let login = ""; + + if (prompter._getRealmInfo) { + try { + const [realmHost, , realmLogin] = prompter._getRealmInfo(realm); + if (realmHost) { + host = realmHost.replace(/^mailbox:\/\//, "pop3://"); + } + if (realmLogin) { + login = decodeURIComponent(realmLogin); + } + } catch (e) { + console.error("Browserpass: _getRealmInfo failed:", e.message); + } + } + + return { host, login }; + } + + // ============================================================================ + // Failed Authentication Tracking + // ============================================================================ + // Track recently failed authentication attempts to allow manual password entry + const failedAuthAttempts = new Map(); + const lastPromptTime = new Map(); + const FAILED_AUTH_TIMEOUT = 60000; + const PROMPT_RETRY_THRESHOLD = 30000; + + /** + * Records a failed authentication attempt with auto-expiry. + * + * @param {string} host - The host that failed authentication + * @param {string} login - The username that failed + */ + function markAuthenticationFailed(host, login) { + const key = `${host}|${login}`; + failedAuthAttempts.set(key, Date.now()); + + setTimeout(() => { + if (failedAuthAttempts.has(key)) { + failedAuthAttempts.delete(key); + } + }, FAILED_AUTH_TIMEOUT); + } + + /** + * Detects if this is a repeated prompt (indicates auth failure). + * + * @param {string} host - The host being prompted + * @param {string} login - The username being prompted + * @returns {boolean} True if this is a repeated prompt + */ + function checkForRepeatedPrompt(host, login) { + const key = `${host}|${login}`; + const now = Date.now(); + const lastTime = lastPromptTime.get(key); + + lastPromptTime.set(key, now); + + // If we were prompted for the same credential recently, it means auth failed + if (lastTime && now - lastTime < PROMPT_RETRY_THRESHOLD) { + console.error( + "Browserpass: Repeated prompt detected within", + now - lastTime, + "ms - marking as failed" + ); + markAuthenticationFailed(host, login); + return true; + } + + return false; + } + + /** + * Checks if there was a recent auth failure for this host/login. + * + * @param {string} host - The host to check + * @param {string} login - The username to check + * @returns {boolean} True if there was a recent failure + */ + function hasRecentAuthFailure(host, login) { + const key = `${host}|${login}`; + const failedTime = failedAuthAttempts.get(key); + if (failedTime && Date.now() - failedTime < FAILED_AUTH_TIMEOUT) { + return true; + } + return false; + } + + /** + * Clears auth failure tracking after successful authentication. + * + * @param {string} host - The host to clear + * @param {string} login - The username to clear + */ + function clearAuthFailure(host, login) { + const key = `${host}|${login}`; + failedAuthAttempts.delete(key); + lastPromptTime.delete(key); + } + + // ============================================================================ + // OAuth Token Cache + // ============================================================================ + + const tokenCache = new Map(); + const TOKEN_CACHE_TIMEOUT = 28800000; // 8 hours in milliseconds + + /** + * Converts a scope (string or Set) to a space-separated string. + * + * @param {string|Set} scope - The scope to convert + * @returns {string} Space-separated scope string + */ + function scopeToString(scope) { + if (!scope) return ""; + if (typeof scope === "string") return scope; + if (scope instanceof Set) return [...scope].join(" "); + return ""; + } + + /** + * Converts OAuth scope URLs to list of service names. + * Recognizes Google, Microsoft, Fastmail and other OAuth providers. + * + * @param {string} scope - Space-separated OAuth scope URLs + * @returns {string[]} Array of service names like ["mail", "caldav", "carddav"] or ["token"] if not recognized + */ + function scopeToServices(scope) { + if (!scope) { + return ["token"]; + } + + const services = []; + + // Google scopes + if (scope.includes("mail.google.com")) { + services.push("mail"); + } + if (scope.includes("auth/calendar") || scope.includes("auth/caldav")) { + services.push("caldav"); + } + if (scope.includes("auth/carddav")) { + services.push("carddav"); + } + + // Microsoft scopes + if (scope.includes("IMAP.") || scope.includes("POP.") || scope.includes("SMTP.")) { + if (!services.includes("mail")) services.push("mail"); + } + if (scope.includes("EWS.") || scope.includes("graph.microsoft")) { + if (!services.includes("mail")) services.push("mail"); + } + + // Fastmail scopes + if ( + scope.includes("protocol-imap") || + scope.includes("protocol-pop") || + scope.includes("protocol-smtp") + ) { + if (!services.includes("mail")) services.push("mail"); + } + if (scope.includes("protocol-caldav")) { + if (!services.includes("caldav")) services.push("caldav"); + } + if (scope.includes("protocol-carddav")) { + if (!services.includes("carddav")) services.push("carddav"); + } + + // Generic fallbacks + if (scope.includes("mail") && !services.includes("mail")) { + services.push("mail"); + } + if (scope.includes("calendar") && !services.includes("caldav")) { + services.push("caldav"); + } + if ( + (scope.includes("contacts") || scope.includes("addressbook")) && + !services.includes("carddav") + ) { + services.push("carddav"); + } + + return services.length > 0 ? services : ["token"]; + } + + /** + * Generates cache key for OAuth token storage. + * + * @param {string} username - The account username + * @param {string} origin - The login origin + * @returns {string} The cache key + */ + function getCacheKey(username, origin) { + return `${username}|${origin}`; + } + + /** + * Caches OAuth token with auto-expiry. + * + * @param {string} key - The cache key + * @param {string} token - The token to cache + * @param {number} [timeout=TOKEN_CACHE_TIMEOUT] - Cache timeout in ms + */ + function setCachedToken(key, token, timeout = TOKEN_CACHE_TIMEOUT) { + if (tokenCache.has(key)) { + const existing = tokenCache.get(key); + if (existing.timeoutId) { + clearTimeout(existing.timeoutId); + } + } + const timeoutId = setTimeout(() => tokenCache.delete(key), timeout); + tokenCache.set(key, { token, timeoutId }); + } + + /** + * Retrieves cached OAuth token if available. + * + * @param {string} key - The cache key + * @returns {string|null} The cached token or null + */ + function getCachedToken(key) { + const cached = tokenCache.get(key); + return cached ? cached.token : null; + } + + /** + * Checks if a token is already cached (used to prevent redundant storage). + * + * @param {string} key - The cache key + * @param {string} token - The token to check + * @returns {boolean} True if token matches cached value + */ + function isTokenCached(key, token) { + return getCachedToken(key) === token; + } + + /** + * Fetches OAuth refresh token from cache or pass storage. + * + * @param {string} username - The account username + * @param {string} loginOrigin - The login origin (e.g., oauth://accounts.google.com) + * @param {string|Set} [requiredScope] - The scope(s) required for this request + * @returns {string|null} The refresh token or null + */ + function getRefreshTokenForAccount(username, loginOrigin, requiredScope) { + const key = getCacheKey(username, loginOrigin); + const cached = getCachedToken(key); + if (cached) { + console.debug( + "Browserpass: OAuth token found in cache for:", + username, + "origin:", + loginOrigin + ); + return cached; + } + + const scopeString = scopeToString(requiredScope); + console.debug( + "Browserpass: Looking up OAuth token - user:", + username, + "origin:", + loginOrigin, + "scope:", + scopeString + ); + + const credentials = waitForCredentials({ + login: username, + host: loginOrigin, + requiredScope: scopeString, + }); + + const cred = getFirstCredential(credentials); + if (cred && typeof cred.password === "string") { + setCachedToken(key, cred.password); + const scopeStr = scopeString ? ` (scope: ${scopeString})` : ""; + console.debug("Browserpass: Found OAuth token in pass for:", username + scopeStr); + return cred.password; + } + + console.log( + "Browserpass: OAuth token NOT found - user:", + username, + "origin:", + loginOrigin, + "scope:", + scopeString + ); + return null; + } + + // ============================================================================ + // MsgAuthPrompt Hooks (IMAP/SMTP/POP3/NNTP) + // ============================================================================ + + const PASSWORD_SAVE_DISABLED = 0; // Prevents Thunderbird from saving passwords + + // Hooks Thunderbird's auth prompts to intercept IMAP/SMTP/POP3/NNTP credentials + function setupMsgAuthPromptHooks() { + try { + const { MsgAuthPrompt } = ChromeUtils.importESModule( + "resource:///modules/MsgAsyncPrompter.sys.mjs" + ); + + if (!MsgAuthPrompt || !MsgAuthPrompt.prototype) { + console.error("Browserpass: MsgAuthPrompt not available"); + return; + } + + // Hook promptAuth + if (MsgAuthPrompt.prototype.promptAuth) { + const originalPromptAuth = MsgAuthPrompt.prototype.promptAuth; + originalFunctions.push({ + object: MsgAuthPrompt.prototype, + name: "promptAuth", + original: originalPromptAuth, + }); + + MsgAuthPrompt.prototype.promptAuth = function ( + channel, + level, + authInfo, + checkboxLabel, + checkValue + ) { + const uri = channel?.URI; + const scheme = uri?.scheme; + const host = uri?.host; + const port = uri?.port; + + console.debug("Browserpass: MsgAuthPrompt.promptAuth:", { + scheme, + host, + port, + username: authInfo?.username, + }); + + if (scheme && ["imap", "smtp", "pop3", "nntp"].includes(scheme)) { + const hostname = port && port > 0 ? `${host}:${port}` : host; + const fullHost = `${scheme}://${hostname}`; + + const result = waitForCredentials({ + host: fullHost, + login: authInfo?.username || "", + loginChangeable: true, + }); + + const cred = getFirstCredential(result); + if (cred) { + console.debug("Browserpass: Got credentials from pass for:", fullHost); + fillAuthInfo(authInfo, cred); + return true; + } + + console.debug( + "Browserpass: No credentials found, falling through to original" + ); + + const accepted = originalPromptAuth.call( + this, + channel, + level, + authInfo, + checkboxLabel, + PASSWORD_SAVE_DISABLED + ); + + if (accepted && authInfo?.password) { + console.log("Browserpass: Saving credentials to pass"); + saveCredentialToPass( + fullHost, + authInfo.username || "", + authInfo.password + ); + } + + return accepted; + } + + return originalPromptAuth.call( + this, + channel, + level, + authInfo, + checkboxLabel, + checkValue + ); + }; + } + + // Hook promptPassword + if (MsgAuthPrompt.prototype.promptPassword) { + const originalPromptPassword = MsgAuthPrompt.prototype.promptPassword; + originalFunctions.push({ + object: MsgAuthPrompt.prototype, + name: "promptPassword", + original: originalPromptPassword, + }); + + MsgAuthPrompt.prototype.promptPassword = function ( + dialogTitle, + text, + realm, + savePassword, + passwordObj + ) { + const { host, login } = parseRealm(this, realm); + + // Check if this is a repeated prompt (auth failure) BEFORE fetching from pass + const isRepeatedPrompt = checkForRepeatedPrompt(host, login); + + if (isRepeatedPrompt || hasRecentAuthFailure(host, login)) { + console.log( + "Browserpass: Auth failure detected - prompting for new password" + ); + const accepted = originalPromptPassword.call( + this, + dialogTitle, + text, + realm, + PASSWORD_SAVE_DISABLED, + passwordObj + ); + + if (accepted && passwordObj?.value) { + console.debug( + "Browserpass: New password entered - saving to pass and clearing failure marker" + ); + clearAuthFailure(host, login); + saveCredentialToPass(host, login, passwordObj.value); + } + + return accepted; + } + + const result = waitForCredentials({ + host: host, + login: login, + loginChangeable: false, + }); + + const cred = getFirstCredential(result); + if (cred) { + console.debug("Browserpass: Got credentials from pass for:", host); + setObjectValue(passwordObj, cred.password); + return true; + } + + const accepted = originalPromptPassword.call( + this, + dialogTitle, + text, + realm, + PASSWORD_SAVE_DISABLED, + passwordObj + ); + + // If user clicked OK and entered a password, save it to pass + if (accepted && passwordObj?.value) { + console.log("Browserpass: Saving password to pass"); + saveCredentialToPass(host, login, passwordObj.value); + } + + return accepted; + }; + } + + // Hook promptUsernameAndPassword + if (MsgAuthPrompt.prototype.promptUsernameAndPassword) { + const originalPromptUsernameAndPassword = + MsgAuthPrompt.prototype.promptUsernameAndPassword; + originalFunctions.push({ + object: MsgAuthPrompt.prototype, + name: "promptUsernameAndPassword", + original: originalPromptUsernameAndPassword, + }); + + MsgAuthPrompt.prototype.promptUsernameAndPassword = function ( + dialogTitle, + text, + realm, + savePassword, + usernameObj, + passwordObj + ) { + console.debug("Browserpass: MsgAuthPrompt.promptUsernameAndPassword:", { + dialogTitle, + realm, + savePassword, + }); + + const { host, login } = parseRealm(this, realm); + const result = waitForCredentials({ + host: host, + login: login, + loginChangeable: true, + }); + + const cred = getFirstCredential(result); + if (cred) { + console.debug("Browserpass: Got credentials from pass for:", host); + setObjectValue(usernameObj, cred.login); + setObjectValue(passwordObj, cred.password); + return true; + } + + const accepted = originalPromptUsernameAndPassword.call( + this, + dialogTitle, + text, + realm, + PASSWORD_SAVE_DISABLED, + usernameObj, + passwordObj + ); + + console.debug("Browserpass: promptUsernameAndPassword result:", { + accepted, + hasUsername: !!usernameObj?.value, + hasPassword: !!passwordObj?.value, + originalSavePassword: savePassword, + }); + + if (accepted && passwordObj?.value) { + const username = usernameObj?.value || login; + console.log("Browserpass: Saving credentials to pass"); + saveCredentialToPass(host, username, passwordObj.value); + } + + return accepted; + }; + } + + console.debug("Browserpass: MsgAuthPrompt hooks setup complete"); + } catch (error) { + console.error("Browserpass: Failed to setup MsgAuthPrompt hooks:", error.message); + } + } + + // ============================================================================ + // OAuth2Module Hooks (CalDAV/CardDAV) + // ============================================================================ + + // Track recently returned tokens to detect rejections + const recentlyReturnedTokens = new Map(); // key -> { username, scope, timestamp } + + /** + * Records that a token was returned for an account, to detect if it gets rejected. + */ + function trackReturnedToken(username, scope) { + const key = `${username}|${scope}`; + recentlyReturnedTokens.set(key, { + username, + scope, + timestamp: Date.now(), + }); + // Clean up after 30 seconds + setTimeout(() => recentlyReturnedTokens.delete(key), 30000); + } + + /** + * Checks if a token was recently returned for this user/scope but got rejected. + * If so, logs a warning to help with debugging. + */ + function checkForTokenRejection(username, scope) { + const key = `${username}|${scope}`; + const entry = recentlyReturnedTokens.get(key); + if (entry) { + const elapsed = Date.now() - entry.timestamp; + console.log( + "Browserpass: TOKEN REJECTED: OAuth token for", + username, + "was returned but rejected by provider.", + "Scope:", + scope, + "Time since return:", + elapsed + "ms" + ); + console.log( + "Browserpass: This usually means the stored token is expired or revoked. Re-authenticate to get a new token." + ); + recentlyReturnedTokens.delete(key); + return true; + } + return false; + } + + // Hooks OAuth2Module for CalDAV/CardDAV token storage + function setupOAuth2ModuleHooks() { + try { + const { OAuth2Module } = ChromeUtils.importESModule( + "resource:///modules/OAuth2Module.sys.mjs" + ); + + if (!OAuth2Module || !OAuth2Module.prototype) { + console.error("Browserpass: OAuth2Module not available"); + return; + } + + // Hook getRefreshToken + if (typeof OAuth2Module.prototype.getRefreshToken === "function") { + const originalGetRefreshToken = OAuth2Module.prototype.getRefreshToken; + originalFunctions.push({ + object: OAuth2Module.prototype, + name: "getRefreshToken", + original: originalGetRefreshToken, + }); + + OAuth2Module.prototype.getRefreshToken = function () { + console.debug( + "Browserpass: getRefreshToken called - user:", + this._username, + "origin:", + this._loginOrigin, + "_scope:", + this._scope, + "_requiredScopes:", + this._requiredScopes, + "accountSetupInProgress:", + accountSetupInProgress + ); + + if (accountSetupInProgress) { + console.debug( + "Browserpass: Skipping token lookup during account setup for:", + this._loginOrigin + ); + return undefined; + } + + // Use _requiredScopes if available, otherwise fall back to _scope + const requiredScopeStr = scopeToString(this._requiredScopes); + const effectiveScope = requiredScopeStr || this._scope || ""; + + const token = getRefreshTokenForAccount( + this._username, + this._loginOrigin, + effectiveScope + ); + if (token !== null) { + console.debug( + "Browserpass: getRefreshToken returning token for:", + this._username + ); + // Track that we returned a token, so we can detect if it gets rejected + trackReturnedToken(this._username, effectiveScope); + return token; + } + + console.debug( + "Browserpass: getRefreshToken returning undefined - no token found for:", + this._username + ); + return undefined; + }; + } + + // Hook setRefreshToken + if (typeof OAuth2Module.prototype.setRefreshToken === "function") { + const originalSetRefreshToken = OAuth2Module.prototype.setRefreshToken; + originalFunctions.push({ + object: OAuth2Module.prototype, + name: "setRefreshToken", + original: originalSetRefreshToken, + }); + + OAuth2Module.prototype.setRefreshToken = async function (refreshToken) { + if (refreshToken) { + const key = getCacheKey(this._username, this._loginOrigin); + + // Check cache first (fast path) + if (isTokenCached(key, refreshToken)) { + console.debug( + "Browserpass: setRefreshToken - token matches cache:", + this._username + ); + return refreshToken; + } + + // Cache miss - check if token already exists in pass + const scope = this._oauth?.scope || this._scope || ""; + const existingToken = getRefreshTokenForAccount( + this._username, + this._loginOrigin, + scope + ); + if (existingToken === refreshToken) { + console.debug( + "Browserpass: setRefreshToken - token matches pass storage, not re-storing:", + this._username + ); + return refreshToken; + } + + // Token is new or different - cache and store it + setCachedToken(key, refreshToken); + + console.debug( + "Browserpass: Storing OAuth token for:", + this._username, + "scope:", + scope + ); + queueCredentialStore({ + login: this._username, + password: refreshToken, + host: this._loginOrigin, + scope: scope, + }); + } + + return refreshToken; + }; + } + + console.debug("Browserpass: OAuth2Module hooks setup complete"); + } catch (error) { + console.error("Browserpass: Failed to setup OAuth2Module hooks:", error.message); + } + } + + // ============================================================================ + // OAuth Browser Window Hooks (clipboard-based autofill) + // ============================================================================ + + // Handles OAuth browser windows with clipboard-based credential autofill + function setupBrowserRequestHooks() { + try { + const { ExtensionSupport } = ChromeUtils.importESModule( + "resource:///modules/ExtensionSupport.sys.mjs" + ); + + // Track last copied text and timer for auto-clearing + let lastCopiedText = null; + let clearClipboardTimer = null; + + function readFromClipboard() { + try { + const clipboard = Cc["@mozilla.org/widget/clipboard;1"].getService( + Ci.nsIClipboard + ); + const transferable = Cc["@mozilla.org/widget/transferable;1"].createInstance( + Ci.nsITransferable + ); + + transferable.init(null); + transferable.addDataFlavor("text/plain"); + + clipboard.getData(transferable, Ci.nsIClipboard.kGlobalClipboard); + + const data = {}; + transferable.getTransferData("text/plain", data); + + if (data.value) { + return data.value.QueryInterface(Ci.nsISupportsString).data; + } + return ""; + } catch (e) { + console.error("Browserpass: Clipboard read failed:", e.message); + return ""; + } + } + + function copyToClipboard(text, autoClear = true) { + try { + const clipboardHelper = Cc["@mozilla.org/widget/clipboardhelper;1"].getService( + Ci.nsIClipboardHelper + ); + clipboardHelper.copyString(text); + + // Schedule clipboard clearing after 60 seconds (like Firefox implementation) + if (autoClear && text) { + lastCopiedText = text; + if (clearClipboardTimer) { + clearClipboardTimer.cancel(); + } + clearClipboardTimer = Cc["@mozilla.org/timer;1"].createInstance( + Ci.nsITimer + ); + clearClipboardTimer.initWithCallback( + { + notify: function () { + try { + // Only clear if clipboard still contains what we copied + const current = readFromClipboard(); + if (current === lastCopiedText) { + clipboardHelper.copyString(""); + console.log( + "Browserpass: Clipboard auto-cleared after 60 seconds" + ); + } else { + console.log( + "Browserpass: Clipboard changed, not clearing" + ); + } + lastCopiedText = null; + clearClipboardTimer = null; + } catch (e) { + console.error( + "Browserpass: Clipboard clear failed:", + e.message + ); + } + }, + }, + 60000, + Ci.nsITimer.TYPE_ONE_SHOT + ); + } + + return true; + } catch (e) { + console.error("Browserpass: Clipboard copy failed:", e.message); + return false; + } + } + + function showNotification(window, title, message) { + try { + const alertsService = Cc["@mozilla.org/alerts-service;1"]?.getService( + Ci.nsIAlertsService + ); + if (alertsService) { + alertsService.showAlertNotification( + null, + title, + message, + false, + "", + null, + "browserpass-oauth" + ); + } + } catch (e) { + console.warn("Browserpass: Could not show notification:", e.message); + } + } + + function getCredentialInfoFromWindow(window) { + try { + const request = window.arguments[0]?.wrappedJSObject; + if (!request || !request.url) { + return null; + } + + const url = Services.io.newURI(request.url); + let login = ""; + let scope = ""; + + if (request.account?.extraAuthParams) { + const params = request.account.extraAuthParams; + for (let i = 0; i < params.length; i++) { + if (Array.isArray(params[i])) { + if (params[i][0] === "login_hint") { + login = params[i][1]; + } else if (params[i][0] === "scope") { + scope = params[i][1]; + } + } else if (params[i] === "login_hint" && params[i + 1]) { + login = params[i + 1]; + } + } + } + + // Try to extract scope from URL if not found in extraAuthParams + if (!scope && request.url.includes("scope=")) { + const match = request.url.match(/scope=([^&]+)/); + if (match) { + scope = decodeURIComponent(match[1]); + } + } + + if (!login && request.url.includes("login_hint=")) { + const match = request.url.match(/login_hint=([^&]+)/); + if (match) { + login = decodeURIComponent(match[1]); + } + } + + console.debug( + "Browserpass: OAuth window credential info - host:", + url.host, + "login:", + login, + "scope:", + scope + ); + + return { host: url.host, login: login, scope: scope }; + } catch (e) { + console.error( + "Browserpass: Error getting credential info from window:", + e.message + ); + return null; + } + } + + function handleOAuthWindow(window, credentialInfo) { + const scopeStr = credentialInfo.scope ? ` (scope: ${credentialInfo.scope})` : ""; + + // Check if we recently returned a token for this user/scope - if so, it was rejected + let reason = "unknown reason"; + if (credentialInfo.login && credentialInfo.scope) { + if (checkForTokenRejection(credentialInfo.login, credentialInfo.scope)) { + reason = "token was rejected by provider (expired/revoked)"; + } else { + reason = "no token found in pass"; + } + } else if (!credentialInfo.login) { + reason = "no login hint available"; + } else { + reason = "no scope information available"; + } + + console.debug( + "Browserpass: OAuth browser window opened for:", + credentialInfo.host + scopeStr + ); + console.debug("Browserpass: Reason:", reason); + + // We're in account setup when the OAuth browser opens + accountSetupInProgress = true; + + // Clear setup flag after 2 minutes (setup should be done by then) + setTimeout(() => { + accountSetupInProgress = false; + console.debug( + "Browserpass: Account setup phase timeout - re-enabling token injection" + ); + }, 120000); + + const offeredHosts = new Set(); + const requestFrame = window.document.getElementById("requestFrame"); + + if (!requestFrame) { + console.debug("Browserpass: No requestFrame found"); + return; + } + + function offerCredentialsForHost(host) { + if (offeredHosts.has(host)) { + return; + } + offeredHosts.add(host); + + const credRequest = { + host: `https://${host}`, + login: credentialInfo.login || "", + loginChangeable: true, + openChoiceDialog: true, + }; + + // Pass the OAuth scope if we have it, so credential lookup can identify the correct service + if (credentialInfo.scope) { + credRequest.requiredScope = credentialInfo.scope; + } + + const result = waitForCredentials(credRequest); + + const cred = getFirstCredential(result); + if (cred) { + const scopeStr = credentialInfo.scope + ? ` (scope: ${credentialInfo.scope})` + : ""; + console.debug( + "Browserpass: Found OAuth credentials for:", + host + scopeStr, + "- login:", + cred.login + ); + + if (copyToClipboard(cred.login)) { + console.debug("Browserpass: Username copied to clipboard:", cred.login); + showNotification( + window, + "Browserpass", + `Step 1: Username copied to clipboard\n` + + `Press Ctrl+V to paste\n` + + `\n` + + `Step 2: Copy password\n` + + `Press Ctrl+Shift+P then Ctrl+V to paste` + ); + } + + window._browserpassPassword = cred.password; + window._browserpassLogin = cred.login; + + // Cache the token with OAuth origin format so setRefreshToken won't re-store it + // credentialInfo.host is the OAuth provider (e.g., accounts.google.com) + if (cred.password) { + const oauthOrigin = `oauth://${credentialInfo.host}`; + const key = getCacheKey(cred.login, oauthOrigin); + setCachedToken(key, cred.password); + } + } else { + const scopeStr = credentialInfo.scope + ? ` (scope: ${credentialInfo.scope})` + : ""; + console.log("Browserpass: No credentials found for:", host + scopeStr); + } + } + + const progressListener = { + QueryInterface: ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsISupportsWeakReference, + ]), + onLocationChange: function (_webProgress, _request, location) { + if (!location) { + return; + } + + // location.host can throw for certain URI types (about:, data:, etc.) + let host; + try { + host = location.host; + } catch (e) { + return; // URI doesn't have a host + } + + if (!host || host.includes("gstatic") || host.includes("doubleclick")) { + return; + } + + offerCredentialsForHost(host); + }, + onStateChange: function () {}, + onProgressChange: function () {}, + onStatusChange: function () {}, + onSecurityChange: function () {}, + }; + + try { + requestFrame.addProgressListener( + progressListener, + Ci.nsIWebProgress.NOTIFY_LOCATION + ); + } catch (e) { + console.error("Browserpass: Error adding location listener:", e.message); + } + + window.addEventListener("keydown", function (event) { + if (event.ctrlKey && event.shiftKey && event.key === "P") { + event.preventDefault(); + if ( + window._browserpassPassword && + copyToClipboard(window._browserpassPassword) + ) { + console.debug("Browserpass: Password copied to clipboard"); + showNotification( + window, + "Browserpass", + "Password copied to clipboard\n(clears automatically in 60s)\n\nPress Ctrl+V to paste" + ); + } + } else if (event.ctrlKey && event.shiftKey && event.key === "U") { + event.preventDefault(); + if (window._browserpassLogin && copyToClipboard(window._browserpassLogin)) { + console.debug( + "Browserpass: Username copied to clipboard:", + window._browserpassLogin + ); + showNotification( + window, + "Browserpass", + "Username copied to clipboard\n(clears automatically in 60s)\n\nPress Ctrl+V to paste" + ); + } + } + }); + + window._browserpassProgressListener = progressListener; + } + + ExtensionSupport.registerWindowListener("browserpass-oauth-window", { + chromeURLs: [ + "chrome://messenger/content/browserRequest.xhtml", + "chrome://gdata-provider/content/browserRequest.xul", + ], + onLoadWindow: function (window) { + // During startup, Thunderbird is in offline mode but CalDAV still + // tries to open OAuth windows (Looks like a Thunderbird bug). + // Close the window immediately if we're in offline startup mode. + if (Services.io.offline && !offlineControl.initialized) { + console.debug( + "Browserpass: OAuth window opened during offline startup - closing immediately" + ); + try { + // Cancel the OAuth request properly + const request = window.arguments[0]?.wrappedJSObject; + if (request && typeof request.cancelled === "function") { + request.cancelled(); + } + window.close(); + } catch (e) { + console.error("Browserpass: Error closing OAuth window:", e.message); + // Fallback: just close the window + try { + window.close(); + } catch (e2) {} + } + return; + } + + const credentialInfo = getCredentialInfoFromWindow(window); + if (credentialInfo) { + handleOAuthWindow(window, credentialInfo); + } + }, + onUnloadWindow: function (window) { + if (window._browserpassProgressListener) { + try { + const requestFrame = window.document.getElementById("requestFrame"); + if (requestFrame) { + requestFrame.removeProgressListener( + window._browserpassProgressListener + ); + } + } catch (e) { + // Window may already be closed + } + } + + accountSetupInProgress = false; + console.debug( + "Browserpass: OAuth window closed - account setup phase complete" + ); + }, + }); + + originalFunctions.push({ + object: null, + name: "browserpass-oauth-window", + cleanup: function () { + ExtensionSupport.unregisterWindowListener("browserpass-oauth-window"); + }, + }); + + console.debug("Browserpass: BrowserRequest hooks setup complete"); + } catch (error) { + console.error("Browserpass: Failed to setup BrowserRequest hooks:", error.message); + } + } + + // ============================================================================ + // Hook Initialization + // ============================================================================ + + let hooksInitialized = false; + + function initializeHooks() { + if (hooksInitialized) { + return; + } + hooksInitialized = true; + + setupMsgAuthPromptHooks(); + setupOAuth2ModuleHooks(); + setupBrowserRequestHooks(); + + console.debug("Browserpass: All hooks initialized"); + } + + initializeHooks(); + + console.debug("Browserpass: Experimental API initialized"); + + // ============================================================================ + // Extension API Export + // ============================================================================ + + exports.credentials = class extends ExtensionCommon.ExtensionAPI { + getAPI(context) { + // Store context for wake-up calls + extensionContext = context; + console.debug("Browserpass: Extension context stored for wake-up capability"); + + // Process any pending stores that accumulated before context was available + if (pendingStores.length > 0) { + console.debug( + "Browserpass: Found", + pendingStores.length, + "pending stores on context init" + ); + } + + return { + credentials: { + /** + * Event fired when Thunderbird requests credentials. + * + * Triggered by: auth prompts (IMAP/SMTP/POP3), token lookups, account setup + * Listener returns: array of matching credentials from pass + */ + onCredentialRequested: new ExtensionCommon.EventManager({ + context, + name: "credentials.onCredentialRequested", + register(fire) { + // Callback receives (event, credentialInfo) from emit() + async function callback(event, credentialInfo) { + try { + return await fire.async(credentialInfo); + } catch (e) { + console.error(e); + return false; + } + } + + passwordRequestEmitter.on("password-requested", callback); + requestListenerCount++; + console.debug( + "Browserpass: Request listener added, count:", + requestListenerCount + ); + + // Signal that listener is ready + if (requestListenerCount === 1) { + signalListenerReady(); + // Extension is ready - apply the user's startup state + offlineControl.applyStartupState(); + // Start background migration of Thunderbird passwords to pass + migrateThunderbirdPasswords(); + } + + return function () { + passwordRequestEmitter.off("password-requested", callback); + requestListenerCount--; + console.debug( + "Browserpass: Request listener removed, count:", + requestListenerCount + ); + }; + }, + }).api(), + + /** + * Event fired when new credentials need to be stored to pass. + * + * Triggered by: password prompts, OAuth authentication, manual password entry + * Listener receives: credential data (host, login, password, scope) + * Listener returns: boolean indicating success/failure of storage + */ + onNewCredential: new ExtensionCommon.EventManager({ + context, + name: "credentials.onNewCredential", + register(fire) { + async function callback(event, credentialInfo) { + try { + const cb = credentialInfo.callback; + delete credentialInfo.callback; + const returnValue = await fire.async(credentialInfo); + if (cb) { + await cb(returnValue); + } + return returnValue; + } catch (e) { + console.error(e); + return false; + } + } + + passwordEmitter.on("password", callback); + storeListenerCount++; + console.debug( + "Browserpass: Store listener added, count:", + storeListenerCount + ); + + // Signal that store listener is ready and process any pending stores + if (storeListenerCount === 1) { + signalStoreListenerReady(); + processPendingStores(); + } + + return function () { + passwordEmitter.off("password", callback); + storeListenerCount--; + console.debug( + "Browserpass: Store listener removed, count:", + storeListenerCount + ); + }; + }, + }).api(), + }, + }; + } + + /** + * Called when extension is being unloaded. + * Restores original hooked functions and cleans up resources. + * + * @param {boolean} isAppShutdown - True if Thunderbird is shutting down + */ + onShutdown(isAppShutdown) { + if (isAppShutdown) { + return; + } + + offlineControl.stopObserving(); + + originalFunctions.forEach((item) => { + if (item.cleanup) { + item.cleanup(); + } else if (item.object && item.name && item.original) { + item.object[item.name] = item.original; + } + }); + + tokenCache.forEach((entry) => { + if (entry.timeoutId) { + clearTimeout(entry.timeoutId); + } + }); + tokenCache.clear(); + + resProto.setSubstitution("browserpass", null); + + Services.obs.notifyObservers(null, "startupcache-invalidate"); + } + }; +})(this); diff --git a/src/thunderbird/experiment/schema.json b/src/thunderbird/experiment/schema.json new file mode 100644 index 00000000..51027be2 --- /dev/null +++ b/src/thunderbird/experiment/schema.json @@ -0,0 +1,68 @@ +[ + { + "namespace": "credentials", + "events": [ + { + "name": "onCredentialRequested", + "description": "Fires when credentials are requested", + "type": "function", + "parameters": [ + { + "name": "credentialInformation", + "description": "Information about the requested credentials", + "type": "object", + "properties": { + "host": { + "description": "The host including protocol for which credentials are requested", + "type": "string" + }, + "login": { + "description": "The login for which credentials are requested", + "optional": true, + "type": "string" + }, + "loginChangeable": { + "description": "If the login is changeable in the password dialog", + "optional": true, + "type": "boolean" + }, + "openChoiceDialog": { + "description": "If the choice dialog should be displayed if more than one entry is found or auto submit is disabled", + "optional": true, + "type": "boolean" + } + } + } + ] + }, + { + "name": "onNewCredential", + "description": "Fires when new credentials are entered", + "type": "function", + "parameters": [ + { + "name": "credentialInformation", + "description": "Information about the entered credentials", + "type": "object", + "properties": { + "host": { + "description": "The host including protocol for which credentials were entered.", + "type": "string" + }, + "login": { + "description": "The login for which credentials were entered.", + "optional": true, + "type": "string" + }, + "password": { + "description": "The password that was entered.", + "optional": true, + "type": "string" + } + } + } + ] + } + ] + } +] From c4de233b9dc42c014bee5c24b42ed593b4f95ffc Mon Sep 17 00:00:00 2001 From: Tim Hardeck Date: Fri, 5 Dec 2025 18:44:17 +0100 Subject: [PATCH 2/3] Show message for Browserpass button in Thunderbird --- src/popup/popup.js | 26 ++++++++++++++++++++++++++ src/popup/popup.less | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) diff --git a/src/popup/popup.js b/src/popup/popup.js index 3e2ffb2c..79fc1bfe 100644 --- a/src/popup/popup.js +++ b/src/popup/popup.js @@ -8,6 +8,7 @@ const Login = require("./models/Login"); const Settings = require("./models/Settings"); // utils, libs const helpers = require("../helpers/ui"); +const { isThunderbird } = require("../helpers/base"); const m = require("mithril"); // components const AddEditInterface = require("./addEditInterface"); @@ -40,6 +41,31 @@ async function run() { var logins = [], settings = await settingsModel.get(), root = document.getElementsByTagName("html")[0]; + + if (isThunderbird()) { + root.classList.add("colors-light"); + document.body.innerHTML = ` +
+

Browserpass for Thunderbird

+

This popup is designed for web browsers.

+

In Thunderbird, Browserpass works automatically:

+
    +
  • IMAP/SMTP/POP3/NNTP passwords and OAuth tokens are retrieved from your password store
  • +
  • OAuth browser window credentials are copied from https/{hostname}
  • +
+

OAuth window shortcuts:

+
    +
  • Ctrl+Shift+U — Copy username
  • +
  • Ctrl+Shift+P — Copy password
  • +
+

To configure Browserpass, go to Add-ons → Browserpass → Preferences.

+
+

To hide this button: Right-click the Menu Bar → Customize → drag the Browserpass button off the toolbar → click Save.

+
+ `; + return; + } + root.classList.remove("colors-dark"); root.classList.add(`colors-${settings.theme}`); diff --git a/src/popup/popup.less b/src/popup/popup.less index 92df5850..d5fd7b9c 100644 --- a/src/popup/popup.less +++ b/src/popup/popup.less @@ -667,3 +667,38 @@ dialog#browserpass-modal { .generate-heights(@start, @end, (@i + 1)); } .generate-heights(300, 1000); + +// Thunderbird popup message +.thunderbird-popup { + padding: 20px; + max-width: 400px; + font-family: "Open Sans", sans-serif; + white-space: normal; + + h3 { + margin-top: 0; + } + + ul { + padding-left: 20px; + } + + code { + background-color: #f0f0f0; + padding: 1px 4px; + border-radius: 3px; + font-family: "Source Code Pro", monospace; + font-size: 0.9em; + } + + hr { + margin: 15px 0; + border: none; + border-top: 1px solid #ccc; + } + + .thunderbird-popup-hint { + font-size: 0.9em; + color: #666; + } +} From f48a376a378ab1f77ad5077a023456427d36823d Mon Sep 17 00:00:00 2001 From: Tim Hardeck Date: Sat, 6 Dec 2025 08:44:17 +0100 Subject: [PATCH 3/3] Improve OAuth credential matching to decrease the amount of decryption operations needed to find the matching token. --- src/thunderbird.js | 40 +++++++++++++++------------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/src/thunderbird.js b/src/thunderbird.js index 3cf1c495..36cf222a 100644 --- a/src/thunderbird.js +++ b/src/thunderbird.js @@ -241,11 +241,13 @@ function isOAuthProviderHost(host) { * @param {Object} credentialInfo - Credential request information * @param {string} credentialInfo.host - Host URL with protocol * @param {string} [credentialInfo.login] - Optional login/username + * @param {string} [credentialInfo.requiredScope] - OAuth scope for service filtering * @returns {Array} Matching password entries */ function findThunderbirdCredentials(files, credentialInfo) { const host = credentialInfo.host; const login = credentialInfo.login; + const requiredScope = credentialInfo.requiredScope; let protocol = ""; let hostname = ""; @@ -275,11 +277,19 @@ function findThunderbirdCredentials(files, credentialInfo) { // OAuth files are stored as oauth/{service}/{user}.gpg // where service is: mail, caldav, carddav, or token if (login && login !== true) { - // Match oauth/{service}/{login} patterns - searchPatterns.push(`oauth/mail/${login}`); - searchPatterns.push(`oauth/caldav/${login}`); - searchPatterns.push(`oauth/carddav/${login}`); - searchPatterns.push(`oauth/token/${login}`); + // Determine required service from scope to minimize file matching + const requiredService = scopeToRequiredService(requiredScope); + + if (requiredService) { + // Only match the specific service directory needed + searchPatterns.push(`oauth/${requiredService}/${login}`); + } else { + // No specific scope - match all OAuth service directories + searchPatterns.push(`oauth/mail/${login}`); + searchPatterns.push(`oauth/caldav/${login}`); + searchPatterns.push(`oauth/carddav/${login}`); + searchPatterns.push(`oauth/token/${login}`); + } } } else if (protocol === "https") { // HTTPS files for OAuth browser window credentials: https/{hostname}.gpg @@ -402,26 +412,6 @@ async function handleCredentialRequest(settings, credentialInfo) { continue; } - // Filter by scope for OAuth tokens - check if filename matches required service - // Always filter OAuth files to ensure correct service-specific token is returned - if (matchingFile.toLowerCase().includes("oauth/")) { - const scopeMatches = fileSupportsRequiredScope( - matchingFile, - credentialInfo.requiredScope || "" - ); - console.debug( - "Browserpass: OAuth scope check:", - matchingFile, - "requiredScope:", - credentialInfo.requiredScope, - "matches:", - scopeMatches - ); - if (!scopeMatches) { - continue; - } - } - parsed.uuid = sha1(fileObj.storeId + matchingFile); parsed.storeId = fileObj.storeId; parsed.file = matchingFile;