From 0d304c1ea3fc03c40dc257b4301b68d58b5d3bc9 Mon Sep 17 00:00:00 2001 From: Tim Hardeck Date: Sun, 26 Apr 2026 16:43:50 +0200 Subject: [PATCH 1/2] Add Thunderbird support for email credential management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Intercept Thunderbird's authentication prompts (IMAP, SMTP, POP3, NNTP) and OAuth2 token requests (CalDAV, CardDAV, mail) to retrieve credentials from the pass password store via GPG decryption. Credentials are stored under thunderbird/{protocol}-{hostname} in pass. A single OAuth refresh token is kept per provider hostname. Pass is the sole credential store — tokens are never written to Thunderbird's built-in password manager. A WebExtension Experiment (implementation.js) hooks MsgAuthPrompt, OAuth2Module, and OAuth2.prototype.connect to bridge Thunderbird's synchronous auth callbacks with the async native messaging host. Concurrent CalDAV calendar syncs are serialised through a pending- lookups set to avoid multiple YubiKey touches for the same token. The OAuth2.prototype.connect hook addresses Thunderbird bug 2008995 where CalDAV creates the shared OAuth2 instance before the extension hook is installed, leaving its refreshToken empty. The hook populates the token from pass before each connect() call. Includes credential migration from Thunderbird's password manager, an informational popup, and build/packaging support. --- .github/ISSUE_TEMPLATE.md | 2 +- .gitignore | 2 + Makefile | 31 +- PRIVACY.md | 17 +- README.md | 41 +- src/Makefile | 4 +- src/background.js | 142 ++ src/helpers/base.js | 11 + src/manifest-thunderbird.json | 60 + src/options/interface.js | 48 + src/popup/popup.js | 37 + src/popup/popup.less | 35 + src/thunderbird.js | 515 +++++ src/thunderbird/experiment/implementation.js | 2030 ++++++++++++++++++ src/thunderbird/experiment/schema.json | 77 + 15 files changed, 3042 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..5ad08cac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ /chromium /firefox +/thunderbird /dist /dist-webstore @@ -10,4 +11,5 @@ *.pem *.crx +*.xpi /.vscode diff --git a/Makefile b/Makefile index ecd6e07b..f2a8404b 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 \ @@ -32,8 +33,16 @@ EXTENSION_FILES := \ src/js/options.dist.js \ src/js/inject.dist.js \ src/js/early-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 @@ -57,6 +66,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 @@ -76,13 +96,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..8ff8f019 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 can migrate existing credentials from Thunderbird's password manager to the +Password Store when the user triggers migration via the Add-on Preferences page. 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..1ab1390b 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,23 @@ Browserpass was designed with an assumption that certain conventions are being f work.gpg ``` + For Thunderbird, all credentials are stored under `thunderbird/` with `{protocol}-{hostname}` naming: + + ``` + ~/.password-store/ + thunderbird/ + imap-mail.example.com.gpg # IMAP server credentials + smtp-mail.example.com.gpg # SMTP server credentials + oauth-accounts.google.com.gpg # OAuth refresh token (one per provider) + https-sso.example.com.gpg # OAuth browser window credentials + ``` + + If IMAP and SMTP use the same password, you can use a symlink: + ```bash + cd ~/.password-store/thunderbird/ + ln -s imap-mail.example.com.gpg smtp-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 +443,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 61a0c591..8e374632 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 js/early-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 6eb8624e..03362cc7 100644 --- a/src/background.js +++ b/src/background.js @@ -992,6 +992,78 @@ async function handleMessage(settings, message, sendResponse) { }); } break; + case "migrateCredentials": + if (!helpers.isThunderbird() || !thunderbirdModule) { + sendResponse({ status: "error", message: "Not running in Thunderbird" }); + break; + } + try { + settings.appID = appID; + const logins = await browser.credentials.getThunderbirdSavedLogins(); + let migrated = 0; + let skipped = 0; + let failed = 0; + + // List existing pass files to detect duplicates + const listResponse = await hostAction(settings, "list"); + const existingFiles = new Set(); + if (listResponse.status === "ok") { + for (const storeId in listResponse.data.files) { + listResponse.data.files[storeId].forEach((f) => + existingFiles.add(f.toLowerCase()) + ); + } + } + + for (const login of logins) { + if (!login.password) { + skipped++; + continue; + } + + // Derive the target filepath (without .gpg, for prefix matching) + const pathInfo = thunderbirdModule.getThunderbirdStorePath(login.host); + if (!pathInfo) { + skipped++; + continue; + } + const filepath = pathInfo.path; + + // Skip if a matching file already exists (startsWith like findThunderbirdCredentials) + const isDuplicate = [...existingFiles].some((f) => + f.startsWith(filepath.toLowerCase()) + ); + if (isDuplicate) { + skipped++; + continue; + } + + const result = await thunderbirdModule.handleNewCredential(settings, { + host: login.host, + login: login.login, + password: login.password, + scope: login.httpRealm || undefined, + }); + + if (result) { + migrated++; + } else { + failed++; + } + } + + sendResponse({ + status: "ok", + migrated, + skipped, + failed, + total: logins.length, + }); + } catch (error) { + console.error("Error migrating credentials:", error); + sendResponse({ status: "error", message: error.message }); + } + break; default: sendResponse({ status: "error", @@ -1317,3 +1389,73 @@ 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; + } + }); + } 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/options/interface.js b/src/options/interface.js index 41c696f8..a22ded32 100644 --- a/src/options/interface.js +++ b/src/options/interface.js @@ -1,6 +1,7 @@ module.exports = Interface; const m = require("mithril"); +const { isThunderbird } = require("../helpers/base"); /** * Options main interface @@ -136,6 +137,53 @@ function view(ctl, params) { "Clear usage data" ) ); + + if (isThunderbird()) { + nodes.push(m("h3", "Thunderbird credential migration")); + nodes.push( + m( + "div", + { class: "notice" }, + "Migrate credentials from Thunderbird's password manager to your pass store. Existing pass entries are not overwritten." + ) + ); + if (this.migrationResult) { + nodes.push( + m( + "div.migration-result", + { class: this.migrationResult.status === "ok" ? "notice" : "error" }, + this.migrationResult.status === "ok" + ? `Migration complete: ${this.migrationResult.migrated} migrated, ${this.migrationResult.skipped} skipped, ${this.migrationResult.failed} failed (${this.migrationResult.total} total)` + : `Migration failed: ${this.migrationResult.message}` + ) + ); + } + nodes.push( + m( + "button.migrateCredentials", + { + disabled: this.migrationInProgress, + onclick: async () => { + this.migrationInProgress = true; + this.migrationResult = undefined; + m.redraw(); + try { + const response = await chrome.runtime.sendMessage({ + action: "migrateCredentials", + }); + this.migrationResult = response; + } catch (e) { + this.migrationResult = { status: "error", message: e.message }; + } + this.migrationInProgress = false; + m.redraw(); + }, + }, + this.migrationInProgress ? "Migrating..." : "Migrate Thunderbird credentials" + ) + ); + } + return nodes; } diff --git a/src/popup/popup.js b/src/popup/popup.js index 3e2ffb2c..95f1dd92 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,42 @@ 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

+

In Thunderbird, Browserpass works automatically in the background:

+

Pass store layout

+
    +
  • thunderbird/imap-{hostname} — IMAP password
  • +
  • thunderbird/smtp-{hostname} — SMTP password
  • +
  • thunderbird/pop3-{hostname} — POP3 password
  • +
  • thunderbird/oauth-{provider} — OAuth refresh token (CalDAV/CardDAV)
  • +
  • thunderbird/https-{hostname} — OAuth browser-window login credentials +
      +
    • Username is auto-copied to clipboard when the window opens
    • +
    • Ctrl+Shift+U — re-copy username to clipboard
    • +
    • Ctrl+Shift+P — copy password to clipboard
    • +
    +
  • +
+

Migrating existing Thunderbird credentials to pass:

+
    +
  1. Open Add-ons ManagerBrowserpassPreferences
  2. +
  3. Scroll to the Thunderbird section and click + Migrate Thunderbird credentials to pass
  4. +
  5. After migration succeeds, you could remove the internal copies: + Settings → Privacy & Security → Saved Passwords → delete them
  6. +
+
+

To hide this button: Right-click the toolbar → Customize → drag the Browserpass button off the toolbar → 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; + } +} diff --git a/src/thunderbird.js b/src/thunderbird.js new file mode 100644 index 00000000..6e6ee790 --- /dev/null +++ b/src/thunderbird.js @@ -0,0 +1,515 @@ +"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 URL Parsing +// ============================================================================= + +/** + * Parses Thunderbird's OAuth origin format to extract provider hostname and scope. + * Thunderbird uses: "oauth://hostname (scope1 scope2 ...)" or plain "oauth://hostname" + * + * @param {string} host - OAuth host string from Thunderbird + * @returns {{ provider: string, scope: string }} + */ +function parseOAuthProvider(host) { + const withoutPrefix = host.replace(/^oauth:\/?\/?\//, ""); + // Hostname is everything before the first space or parenthesis + const provider = withoutPrefix.split(/[\s(]/)[0].trim(); + // Scope is the content inside parentheses, if present + const scopeMatch = withoutPrefix.match(/\(([^)]*)\)/); + const scope = scopeMatch ? scopeMatch[1].trim() : ""; + return { provider, scope }; +} + +/** + * Derives the pass store path prefix (without the .gpg extension) for a + * Thunderbird host string, plus the login encoded in the host when present. + * + * Naming scheme: thunderbird/{protocol}-{hostname[:port]} and, for OAuth, + * thunderbird/oauth-{provider}. Shared by credential saving and migration so + * the layout has a single source of truth. + * + * @param {string} host - Host URL (e.g. imap://mail.example.com or oauth://...) + * @param {string} [login] - Optional login hint; used when the host has none + * @returns {{path: string, protocol: string, login: string|null}|null} Path + * info, or null if the host cannot be parsed + */ +function getThunderbirdStorePath(host, login = null) { + if (host.startsWith("oauth://") || host.startsWith("oauth:")) { + const { provider } = parseOAuthProvider(host); + return { path: `thunderbird/oauth-${provider}`, protocol: "oauth", login }; + } + + let protocol = ""; + let hostname = host; + let resolvedLogin = login; + try { + const url = new URL(host); + protocol = url.protocol.replace(":", ""); + hostname = url.hostname; + if (url.port) { + hostname += ":" + url.port; + } + // Thunderbird's realm embeds the username in the URL (e.g. + // smtp://user%40host@mail.example.com) but passes an empty login + // field. Extract it from the URL when login is not provided. + if (!resolvedLogin && url.username) { + resolvedLogin = decodeURIComponent(url.username); + } + } catch (e) { + const match = host.match(/^([a-z]+):\/\/(.+)/i); + if (!match) { + return null; + } + protocol = match[1]; + hostname = match[2].split("/")[0]; + } + + return { path: `thunderbird/${protocol}-${hostname}`, protocol, login: resolvedLogin }; +} + +// ============================================================================= +// Credential Request Handling +// ============================================================================= + +/** + * Find credentials in password store for Thunderbird + * + * All credentials are stored under thunderbird/ with {protocol}-{hostname} naming: + * - thunderbird/imap-{server}.gpg - IMAP server credentials + * - thunderbird/smtp-{server}.gpg - SMTP server credentials + * - thunderbird/pop3-{server}.gpg - POP3 server credentials + * - thunderbird/nntp-{server}.gpg - NNTP news server credentials + * - thunderbird/oauth-{provider}.gpg - OAuth2 token (single per provider hostname) + * - thunderbird/https-{server}.gpg - OAuth browser window credentials + * + * @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; + + let protocol = ""; + let hostname = ""; + let hostPort = ""; + + // Handle oauth: and oauth:// formats from Thunderbird. + // Thunderbird uses "oauth://hostname (scope1 scope2 ...)" format. + if (host.startsWith("oauth://") || host.startsWith("oauth:")) { + protocol = "oauth"; + hostname = parseOAuthProvider(host).provider; + } 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 under thunderbird/ directory + const searchPatterns = []; + + if (protocol === "oauth") { + // Single OAuth token per provider: thunderbird/oauth-{provider}.gpg + searchPatterns.push(`thunderbird/oauth-${hostname}`); + } else if (protocol === "https") { + // OAuth browser window credentials: thunderbird/https-{hostname}.gpg + searchPatterns.push(`thunderbird/https-${hostname}`); + if (hostPort !== hostname) { + searchPatterns.push(`thunderbird/https-${hostPort}`); + } + } else if (["imap", "smtp", "pop3", "nntp"].includes(protocol)) { + // Mail protocol files: thunderbird/{protocol}-{hostname}.gpg + searchPatterns.push(`thunderbird/${protocol}-${hostname}`); + if (hostPort !== hostname) { + searchPatterns.push(`thunderbird/${protocol}-${hostPort}`); + } + } + + if (searchPatterns.length === 0) { + return []; + } + + return files.filter((file) => { + const filePath = file.toLowerCase(); + return searchPatterns.some((pattern) => filePath.startsWith(pattern.toLowerCase())); + }); +} + +/** + * 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 + * @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"); + + console.debug("Browserpass: Thunderbird credential request:", { + host: credentialInfo.host, + login: credentialInfo.login, + }); + + 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: [] }; + } + + // IMPORTANT: Only try the first matching file to avoid multiple GPG key prompts + // If YubiKey times out or user cancels, trying additional files will cause GPG + // to prompt for other keys in the keyring that may not be related to pass + console.debug( + "Browserpass: Found", + matchingFiles.length, + "matching file(s), will only attempt first one to avoid multiple GPG prompts" + ); + const filesToTry = [matchingFiles[0]]; + + // Fetch and parse password contents + const credentials = []; + for (const matchingFile of filesToTry) { + 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; + } + + parsed.uuid = sha1(fileObj.storeId + matchingFile); + parsed.storeId = fileObj.storeId; + parsed.file = matchingFile; + + credentials.push(parsed); + } else { + // Decryption failed (timeout, cancelled, wrong key, no contents, etc.) + // Stop trying other files immediately to avoid repeated GPG prompts + console.warn( + "Browserpass: Decryption failed or cancelled for", + matchingFile, + "status:", + fetchResponse.status, + "- stopping further attempts to avoid repeated GPG prompts" + ); + break; + } + } + + if (credentials.length === 0 && isOAuth) { + console.debug("Browserpass: No OAuth token found for:", credentialInfo.host); + } + + if (isOAuth && credentials.length > 0) { + console.debug( + "Browserpass: Returning OAuth credentials for", + credentialInfo.host, + "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 +// ============================================================================= + +/** + * Saves an OAuth token file to the password store. + * Single token per provider hostname: thunderbird/oauth-{provider}.gpg + * + * @param {Object} settings - Extension settings + * @param {string} storeId - Password store ID + * @param {string} username - User's email/login + * @param {string} password - OAuth refresh token + * @param {string} host - OAuth host (e.g., oauth://accounts.google.com) + * @param {string} [scope] - OAuth scope (space-separated URLs) + * @returns {Promise} True if save succeeded + */ +async function saveOAuthFile(settings, storeId, username, password, host, scope) { + // Parse Thunderbird's OAuth origin format: "oauth://hostname (scope1 scope2 ...)" + const parsed = parseOAuthProvider(host); + const filepath = `thunderbird/oauth-${parsed.provider}.gpg`; + // Use explicitly provided scope, or extract from the host URL format + const effectiveScope = scope || parsed.scope; + let contents = password; + if (username && username !== true) { + contents += `\nlogin: ${username}`; + } + contents += `\nurl: oauth://${parsed.provider}`; + if (effectiveScope) { + contents += `\nscope: ${effectiveScope}`; + } + + 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 thunderbird/oauth-{provider}.gpg + if ( + credentialInfo.host.startsWith("oauth://") || + credentialInfo.host.startsWith("oauth:") + ) { + const username = + credentialInfo.login || parseOAuthProvider(credentialInfo.host).provider; + + return await saveOAuthFile( + settings, + storeId, + username, + credentialInfo.password, + credentialInfo.host, + credentialInfo.scope + ); + } + + // Non-OAuth credentials + const pathInfo = getThunderbirdStorePath(credentialInfo.host, credentialInfo.login); + if (!pathInfo) { + console.error("Browserpass: Invalid URL:", credentialInfo.host); + return false; + } + + const { protocol, login } = pathInfo; + const filepath = `${pathInfo.path}.gpg`; + // Reconstruct a clean hostname (path is thunderbird/{protocol}-{hostname}) + const hostname = pathInfo.path.slice(`thunderbird/${protocol}-`.length); + + let contents = credentialInfo.password; + if (login && login !== true) { + contents += `\nlogin: ${login}`; + } + // Store a clean url without userinfo — the filename already encodes + // protocol + hostname, but the field is useful for human reference. + contents += `\nurl: ${protocol}://${hostname}`; + + 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, + parseOAuthProvider, + getThunderbirdStorePath, +}; diff --git a/src/thunderbird/experiment/implementation.js b/src/thunderbird/experiment/implementation.js new file mode 100644 index 00000000..9c7fbaac --- /dev/null +++ b/src/thunderbird/experiment/implementation.js @@ -0,0 +1,2030 @@ +/* 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 (Thunderbird 128+) + // ============================================================================ + + const { ExtensionCommon } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionCommon.sys.mjs" + ); + const { ExtensionParent } = ChromeUtils.importESModule( + "resource://gre/modules/ExtensionParent.sys.mjs" + ); + const { setTimeout, clearTimeout, setInterval, clearInterval } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" + ); + + // ============================================================================ + // 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()" + ); + startupReady = true; + 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; + // Set to true once the extension's request listener is registered and the + // startup state has been applied. handleOAuthWindow skips windows that open + // before this flag is set (TB bug 2008995 opens a CalDAV OAuth window before + // our hook is installed; we must not set accountSetupInProgress for that + // window or it blocks all subsequent getRefreshToken lookups until dismissed). + let startupReady = 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 60 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 60 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=60000] - Timeout in milliseconds + * @param {Function|null} [abortFn] - If provided, exit the spin early when this returns true. + * @param {Function|null} [onThen] - If provided, called inside the .then() handler with the + * resolved value, BEFORE done=true is set in .finally(). Use this to perform side-effects + * (e.g. clearing pendingLookups) that nested spin-waiters are blocked on, so they can exit + * in the same processNextEvent() turn that delivers the result — avoiding a deadlock where + * inner spins block the outer spin from ever checking done=true. + * @returns {*} The result of asyncOp or fallback on timeout + */ + function awaitSync(asyncOp, fallback, timeoutMs = 60000, abortFn = null, onThen = null) { + let done = false; + let result = fallback; + let timedOut = false; + + asyncOp + .then((val) => { + if (onThen) onThen(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 || (abortFn !== null && abortFn())); + } 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, onThen = null) { + data.openChoiceDialog = true; + // abortFn: exit the spin as soon as the background conduit closes + // (requestListenerCount drops to 0). This allows the caller to retry + // quickly once the background is revived instead of waiting 60 s for + // the awaitSync timeout. Combined with the onThen mechanism (see + // getRefreshTokenForAccount) this also breaks the nested-spin deadlock + // that forms when multiple CalDAV calendars call getRefreshToken + // simultaneously and the background is killed mid-decrypt. + return awaitSync( + requestCredentials(data), + false, + 60000, + () => requestListenerCount === 0, + onThen + ); + } + + /** + * 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(); + return; + } + + // Actively try to revive the background so the store listener + // re-registers. Without this the retry loop only polls passively + // and the background stays dead, causing the token to be lost + // after PENDING_STORE_MAX_RETRIES * PENDING_STORE_RETRY_INTERVAL. + wakeUpExtension().catch(() => {}); + }, 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); + } + } + + // ============================================================================ + // 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 + // ============================================================================ + // Session-only cache so each pass file is decrypted at most once per session. + // Pass is the sole credential store; tokens are never written to loginManager. + + const tokenCache = new Map(); + // Tracks keys for which a GPG decrypt is currently in progress. + // Prevents concurrent CalDAV calendar syncs from each triggering a separate + // YubiKey touch for the same OAuth token. + const pendingLookups = new Set(); + + /** + * 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 for the current session. + * + * @param {string} key - The cache key + * @param {string} token - The token to cache + */ + function setCachedToken(key, token) { + tokenCache.set(key, token); + } + + /** + * Retrieves cached OAuth token if available. + * + * @param {string} key - The cache key + * @returns {string|null} The cached token or null + */ + function getCachedToken(key) { + return tokenCache.get(key) || 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) + * @returns {string|null} The refresh token or null + */ + function getRefreshTokenForAccount(username, loginOrigin) { + const key = getCacheKey(username, loginOrigin); + + // Direct spin function reused for two purposes below: + // 1. Spin-waiting for the primary lookup to finish (pendingLookups). + // 2. Waiting for the background to come up before retrying. + const _spinFn = + Services.tm.spinEventLoopUntilOrShutdown || + (Services.tm.spinEventLoopUntilOrQuit + ? (fn) => Services.tm.spinEventLoopUntilOrQuit("browserpass:pendingwait", fn) + : null); + + // Up to 6 passes: spin-waiters consume passes while the primary caller + // decrypts via GPG; the primary retries if the background was unloaded + // mid-decrypt. Typically resolves in 1-2 passes. + for (let pass = 0; pass < 6; pass++) { + // Fast path: cache already populated by a previous lookup. + const cached = getCachedToken(key); + if (cached) { + console.debug( + "Browserpass: OAuth token found in cache for:", + username, + "origin:", + loginOrigin + ); + return cached; + } + + // Another caller already holds the primary role — spin until it + // finishes (success) or gives up. On the next pass we either read + // the token from cache or become the new primary. + // + // DEADLOCK PREVENTION: also exit when requestListenerCount === 0. + // When the background is killed while the primary's awaitSync is + // spinning (level 1), the waiter spins are at level 2+. Without + // this extra condition the waiters can only exit via their 90-second + // deadline, which keeps level 1 stuck and the whole chain deadlocked + // for 90 s. Exiting on background-death lets the levels unwind + // quickly; the primary's abortFn (requestListenerCount===0, also + // checked in waitForCredentials) exits level 1 as well. + if (pendingLookups.has(key)) { + console.debug( + "Browserpass: OAuth lookup in progress for:", + username, + "- waiting for result" + ); + const deadline = Date.now() + 90000; + if (_spinFn) { + _spinFn( + () => + !pendingLookups.has(key) || + requestListenerCount === 0 || + Date.now() > deadline + ); + } + continue; + } + + // Wait for the background to be available before becoming primary. + if (requestListenerCount === 0) { + console.debug( + "Browserpass: No listener available, waiting for background...", + pass > 0 ? `(retry ${pass})` : "" + ); + const deadline = Date.now() + 30000; + if (_spinFn) { + _spinFn(() => requestListenerCount > 0 || Date.now() > deadline); + } + } + if (requestListenerCount === 0) { + console.debug("Browserpass: Background unavailable, cannot look up OAuth token"); + break; + } + + // We are the primary for this pass. + console.debug( + "Browserpass: Looking up OAuth token - user:", + username, + "origin:", + loginOrigin, + pass > 0 ? `(retry ${pass})` : "" + ); + + pendingLookups.add(key); + let credResult = null; + + // onThen runs INSIDE awaitSync's .then() — as a microtask, BEFORE + // done=true is set in .finally(). Deleting from pendingLookups here + // (rather than in a try/finally around waitForCredentials) means that + // nested spin-waiters' _spinFn(!pendingLookups.has(key)) condition + // becomes true during the SAME processNextEvent() call that delivers + // the GPG result, so they can exit before the outer awaitSync spin + // (level 1) checks done=true — breaking the deadlock. + const onThen = (credentials) => { + const c = getFirstCredential(credentials); + if (c && typeof c.password === "string") { + setCachedToken(key, c.password); + credResult = c.password; + console.debug( + "Browserpass: Found OAuth token in pass for:", + username, + "origin:", + loginOrigin + ); + } else { + console.debug( + "Browserpass: OAuth token NOT found in pass for:", + username, + "origin:", + loginOrigin + ); + } + // Must be last: unblocks nested spin-waiters. + pendingLookups.delete(key); + }; + + waitForCredentials({ login: username, host: loginOrigin }, onThen); + + // If abortFn fired (background died before .then could run), + // onThen may not have deleted pendingLookups yet — clean up now. + pendingLookups.delete(key); + + if (credResult !== null) { + return credResult; + } + // If the background is alive, the token is genuinely not in pass. + // No point retrying - break immediately. + if (requestListenerCount > 0) { + console.debug( + "Browserpass: OAuth token not in pass for:", + username, + "- not retrying" + ); + break; + } + // Background died during the lookup - retry once the background revives. + } + + 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"); + waitForPasswordStore({ + host: fullHost, + login: authInfo.username || "", + password: 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); + waitForPasswordStore({ + host: host, + login: login, + password: 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"); + waitForPasswordStore({ + host: host, + login: login, + password: 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"); + waitForPasswordStore({ + host: host, + login: username, + password: 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) + // ============================================================================ + + // 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 + ); + + // Always check in-memory cache first, even during account setup. + // setRefreshToken populates the cache when the OAuth exchange completes. + // getRefreshToken must be able to return that cached token so Thunderbird + // can verify the newly established account (account setup would otherwise + // fail because accountSetupInProgress blocks the pass lookup below). + const cacheKey = getCacheKey(this._username, this._loginOrigin); + const cachedToken = getCachedToken(cacheKey); + if (cachedToken) { + console.debug( + "Browserpass: getRefreshToken - returning cached token for:", + this._username + ); + return cachedToken; + } + + if (accountSetupInProgress) { + console.debug( + "Browserpass: Skipping token lookup during account setup for:", + this._loginOrigin + ); + return undefined; + } + + const token = getRefreshTokenForAccount(this._username, this._loginOrigin); + if (token !== null) { + console.debug( + "Browserpass: getRefreshToken returning token from pass for:", + this._username + ); + return token; + } + + // Not found in pass - let Thunderbird handle it naturally + // (e.g. show an OAuth window so the user can authenticate). + return originalGetRefreshToken.call(this); + }; + } + + // 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) { + // Token cleared by Thunderbird (e.g. auth failure) - propagate the + // clear to loginManager so stale entries don't linger there. + return await originalSetRefreshToken.call(this, refreshToken); + } + + const key = getCacheKey(this._username, this._loginOrigin); + + // Token already in cache and presumably already in pass - no-op. + if (isTokenCached(key, refreshToken)) { + console.debug( + "Browserpass: setRefreshToken - token matches cache, skipping:", + this._username + ); + return; + } + + const scope = this._oauth?.scope || this._scope || ""; + + // Check if token already exists in pass. + const existingToken = getRefreshTokenForAccount( + this._username, + this._loginOrigin + ); + if (existingToken === refreshToken) { + console.debug( + "Browserpass: setRefreshToken - token matches pass storage:", + this._username + ); + setCachedToken(key, refreshToken); + return; + } + + // Token is new or different - cache it and queue save to pass. + setCachedToken(key, refreshToken); + + console.debug( + "Browserpass: New/updated OAuth token for:", + this._username, + "scope:", + scope + ); + + queueCredentialStore({ + host: this._loginOrigin, + login: this._username, + password: refreshToken, + scope: scope, + }); + // Do NOT call originalSetRefreshToken - we do not write tokens to + // Thunderbird's loginManager. Pass is the sole credential store. + }; + } + + // Hook OAuth2.prototype.connect to populate refreshToken before connect(). + // + // Root cause of TB bug 2008995 startup OAuth windows: + // - getRefreshToken() is called ONLY inside initFromHostname() when a NEW + // OAuth2 object is created. All Google calendars SHARE one cached OAuth2 + // instance (oAuth2Objects in OAuth2Module.sys.mjs). + // - That shared instance is created BEFORE our hook is installed (startup + // race), so its refreshToken is set to "" from the empty loginManager. + // - OAuth2Module.connect() calls this._oauth.connect() WITHOUT calling + // getRefreshToken() again — so the empty refreshToken persists and + // connect() opens an OAuth window on every sync attempt. + // + // Hooking connect() lets us populate this.refreshToken from pass/cache + // just before the check, transparently, for every connect() invocation. + try { + const { OAuth2 } = ChromeUtils.importESModule("resource:///modules/OAuth2.sys.mjs"); + + if (OAuth2?.prototype && typeof OAuth2.prototype.connect === "function") { + const originalOAuth2Connect = OAuth2.prototype.connect; + originalFunctions.push({ + object: OAuth2.prototype, + name: "connect", + original: originalOAuth2Connect, + }); + + OAuth2.prototype.connect = function (aWithUI, aRefresh) { + if (!this.refreshToken && this.username) { + try { + const host = new URL(this.authorizationEndpoint).hostname; + const oauthOrigin = `oauth://${host}`; + const token = getRefreshTokenForAccount(this.username, oauthOrigin); + if (token) { + console.debug( + "Browserpass: Populated refresh token from pass for:", + this.username + ); + this.refreshToken = token; + } + } catch (e) { + // Don't block the OAuth flow if our lookup fails + } + } + return originalOAuth2Connect.call(this, aWithUI, aRefresh); + }; + } + } catch (e) { + console.error("Browserpass: Failed to hook OAuth2.prototype.connect:", e.message); + } + + 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 showHintBanner(window, text) { + // Reuse existing banner rather than stacking multiple banners. + let banner = window.document.getElementById("browserpass-hint"); + if (banner) { + banner.textContent = text; + return; + } + try { + banner = window.document.createElementNS("http://www.w3.org/1999/xhtml", "div"); + banner.id = "browserpass-hint"; + banner.setAttribute( + "style", + "background:#1a1a2e;color:#cce;padding:6px 12px;font-size:12px;" + + "font-family:monospace;line-height:1.5;border-bottom:2px solid #4444cc" + ); + banner.textContent = text; + const frame = window.document.getElementById("requestFrame"); + if (frame && frame.parentNode) { + frame.parentNode.insertBefore(banner, frame); + } + } catch (e) { + console.warn("Browserpass: Could not show hint banner:", 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.oauth?.extraAuthParams) { + const params = request.oauth.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})` : ""; + + // TB bug 2008995: CalDAV fires getRefreshToken before our hook is + // installed, causing an OAuth window to open at startup. By the time + // setupBrowserRequestHooks() runs, that window is already open and + // onLoadWindow fires immediately. We must NOT handle it here because: + // 1. The background is not yet connected, so waitForCredentials would + // spin the event loop and block startup (hang). + // 2. Setting accountSetupInProgress=true would block all subsequent + // getRefreshToken lookups until the window is dismissed, causing + // a second OAuth window when the user clicks Sync. + // Leave the flag unset; the user can dismiss the startup window. + // https://bugzilla.mozilla.org/show_bug.cgi?id=2008995 + if (!startupReady) { + console.debug( + "Browserpass: Ignoring OAuth window opened before hooks were ready (TB bug 2008995):", + credentialInfo.host + ); + return; + } + + console.debug( + "Browserpass: OAuth browser window opened for:", + credentialInfo.host + scopeStr + ); + + // Genuine new-account OAuth setup: block other token lookups while + // the user completes the OAuth dance in the browser window. + // (When the token is already in pass, OAuth2.prototype.connect populates + // this.refreshToken before connect() runs, so no window opens and this + // code is never reached for "known account" restarts.) + 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, + }; + + 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); + showHintBanner( + window, + `Browserpass: Username copied \u2014 paste with Ctrl+V\n` + + `Then press Ctrl+Shift+P to copy password, 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"); + showHintBanner( + window, + "Browserpass: Password copied \u2014 paste with Ctrl+V" + ); + } + } 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 + ); + showHintBanner( + window, + "Browserpass: Username copied \u2014 paste with Ctrl+V" + ); + } + } + }); + + window._browserpassProgressListener = progressListener; + } + + ExtensionSupport.registerWindowListener("browserpass-oauth-window", { + chromeURLs: [ + "chrome://messenger/content/browserRequest.xhtml", + "chrome://gdata-provider/content/browserRequest.xul", + ], + onLoadWindow: function (window) { + 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: { + /** + * Returns all credentials stored in Thunderbird's password manager. + * Used for migration to pass. + */ + getThunderbirdSavedLogins: async function () { + const logins = Services.logins.findLogins("", null, ""); + return logins.map((login) => ({ + host: login.origin || login.hostname, + login: login.username, + password: login.password, + httpRealm: login.httpRealm, + formActionOrigin: login.formActionOrigin, + })); + }, + + /** + * 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(); + } + + 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) { + // Always stop timers first — a live setInterval prevents the module + // from being garbage-collected and blocks Thunderbird's shutdown. + stopPendingStoreRetry(); + // Prevent wakeUpExtension() from being called during shutdown. + extensionContext = null; + + 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.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..f914f973 --- /dev/null +++ b/src/thunderbird/experiment/schema.json @@ -0,0 +1,77 @@ +[ + { + "namespace": "credentials", + "functions": [ + { + "name": "getThunderbirdSavedLogins", + "description": "Returns all credentials stored in Thunderbird's password manager for migration", + "type": "function", + "async": true, + "parameters": [] + } + ], + "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 23e80f8c944fbae8f9298cd2263d901f36f1eac7 Mon Sep 17 00:00:00 2001 From: Tim Hardeck Date: Sun, 31 May 2026 21:22:38 +0200 Subject: [PATCH 2/2] Offer retry when GPG decryption fails When a matching pass entry exists but cannot be decrypted in time, the auth prompt now shows a Retry/Cancel dialog instead of silently falling through to manual entry. This lets the user trigger another attempt when a hardware GPG key (e.g. a YubiKey) was not touched before the agent timed out, similar to a wallet unlock retry. handleCredentialRequest reports a decryptionFailed flag so the experiment can distinguish a decrypt failure from a genuinely absent credential and only prompt to retry in the former case. --- src/thunderbird.js | 26 +++++-- src/thunderbird/experiment/implementation.js | 71 ++++++++++++++++++-- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/src/thunderbird.js b/src/thunderbird.js index 6e6ee790..195500d1 100644 --- a/src/thunderbird.js +++ b/src/thunderbird.js @@ -314,16 +314,28 @@ async function handleCredentialRequest(settings, credentialInfo) { // Fetch and parse password contents const credentials = []; + // True when a matching file exists but could not be decrypted (e.g. a + // hardware GPG key was not touched in time). Signals the caller that a + // retry may succeed, as opposed to the credential being genuinely absent. + let decryptionFailed = false; for (const matchingFile of filesToTry) { 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, - }); + let fetchResponse; + try { + fetchResponse = await sendNativeMessage(settings.appID, { + settings: settings, + action: "fetch", + storeId: fileObj.storeId, + file: matchingFile, + }); + } catch (e) { + // Native host disconnected mid-decrypt (e.g. GPG aborted/timed out) + console.warn("Browserpass: Native fetch failed for", matchingFile, e?.message); + decryptionFailed = true; + break; + } if (fetchResponse.status === "ok" && fetchResponse.data.contents) { const parsed = parsePasswordContents(fetchResponse.data.contents, matchingFile); @@ -353,6 +365,7 @@ async function handleCredentialRequest(settings, credentialInfo) { fetchResponse.status, "- stopping further attempts to avoid repeated GPG prompts" ); + decryptionFailed = true; break; } } @@ -373,6 +386,7 @@ async function handleCredentialRequest(settings, credentialInfo) { return { autoSubmit: settings.autoSubmit || false, credentials: credentials, + decryptionFailed: credentials.length === 0 && decryptionFailed, }; } catch (error) { console.error("Browserpass: Error handling credential request:", error); diff --git a/src/thunderbird/experiment/implementation.js b/src/thunderbird/experiment/implementation.js index 9c7fbaac..7fff39ac 100644 --- a/src/thunderbird/experiment/implementation.js +++ b/src/thunderbird/experiment/implementation.js @@ -436,6 +436,9 @@ if (currentDetails.credentials && currentDetails.credentials.length) { details.credentials = details.credentials.concat(currentDetails.credentials); } + if (currentDetails.decryptionFailed) { + details.decryptionFailed = true; + } return details; }, { autoSubmit: true, credentials: [] } @@ -467,6 +470,66 @@ ); } + /** + * Shows a modal Retry/Cancel dialog when a matching pass entry exists but + * could not be decrypted in time. This mirrors the behaviour of password + * wallets (e.g. KDE) that let the user retry an unlock: when the GPG key + * lives on a hardware token (e.g. a YubiKey) and was not touched before + * the agent timed out, the user can trigger another attempt instead of + * being forced to type the password manually. + * + * @param {string} host - The host whose credential failed to decrypt + * @returns {boolean} True if the user chose to retry + */ + function promptDecryptionRetry(host) { + try { + const flags = + Services.prompt.BUTTON_POS_0 * Services.prompt.BUTTON_TITLE_IS_STRING + + Services.prompt.BUTTON_POS_1 * Services.prompt.BUTTON_TITLE_CANCEL; + const parent = Services.wm.getMostRecentWindow(null); + const button = Services.prompt.confirmEx( + parent, + "Browserpass", + `Could not decrypt the stored password for ${host}.\n\n` + + "If your GPG key is on a hardware token (e.g. a YubiKey), it may not have " + + "been touched before the request timed out. You can retry the decryption.", + flags, + "Retry", + null, + null, + null, + { value: false } + ); + return button === 0; + } catch (e) { + console.error("Browserpass: Failed to show retry dialog:", e.message); + return false; + } + } + + /** + * Looks up credentials and, when a matching pass entry exists but could not + * be decrypted, offers the user a Retry/Cancel dialog and tries again. On + * cancel (or when no matching entry exists) the original result is returned + * so the caller can fall through to manual entry. + * + * @param {object} data - Request data with host, login, etc. + * @returns {object|false} Credentials result or false on timeout + */ + function waitForCredentialsWithRetry(data) { + let result = waitForCredentials(data); + while ( + !getFirstCredential(result) && + result && + result.decryptionFailed && + promptDecryptionRetry(data.host) + ) { + console.log("Browserpass: Retrying GPG decryption for:", data.host); + result = waitForCredentials(data); + } + return result; + } + /** * Stores credentials asynchronously, waiting for listener if needed. * @@ -1029,7 +1092,7 @@ const hostname = port && port > 0 ? `${host}:${port}` : host; const fullHost = `${scheme}://${hostname}`; - const result = waitForCredentials({ + const result = waitForCredentialsWithRetry({ host: fullHost, login: authInfo?.username || "", loginChangeable: true, @@ -1127,7 +1190,7 @@ return accepted; } - const result = waitForCredentials({ + const result = waitForCredentialsWithRetry({ host: host, login: login, loginChangeable: false, @@ -1188,7 +1251,7 @@ }); const { host, login } = parseRealm(this, realm); - const result = waitForCredentials({ + const result = waitForCredentialsWithRetry({ host: host, login: login, loginChangeable: true, @@ -1679,7 +1742,7 @@ openChoiceDialog: true, }; - const result = waitForCredentials(credRequest); + const result = waitForCredentialsWithRetry(credRequest); const cred = getFirstCredential(result); if (cred) {