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..195500d1 --- /dev/null +++ b/src/thunderbird.js @@ -0,0 +1,529 @@ +"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 = []; + // 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; + + 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); + + // 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" + ); + decryptionFailed = true; + 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, + decryptionFailed: credentials.length === 0 && decryptionFailed, + }; + } 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..7fff39ac --- /dev/null +++ b/src/thunderbird/experiment/implementation.js @@ -0,0 +1,2093 @@ +/* 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); + } + if (currentDetails.decryptionFailed) { + details.decryptionFailed = true; + } + 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 + ); + } + + /** + * 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. + * + * @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 = waitForCredentialsWithRetry({ + 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 = waitForCredentialsWithRetry({ + 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 = waitForCredentialsWithRetry({ + 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 = waitForCredentialsWithRetry(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" + } + } + } + ] + } + ] + } +]