import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { EventType } from "@bitwarden/common/enums"; import { StateFactory } from "@bitwarden/common/platform/factories/state-factory"; import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { authServiceFactory, AuthServiceInitOptions, } from "../../auth/background/service-factories/auth-service.factory"; import { totpServiceFactory } from "../../auth/background/service-factories/totp-service.factory"; import LockedVaultPendingNotificationsItem from "../../background/models/lockedVaultPendingNotificationsItem"; import { eventCollectionServiceFactory } from "../../background/service-factories/event-collection-service.factory"; import { passwordGenerationServiceFactory } from "../../background/service-factories/password-generation-service.factory"; import { Account } from "../../models/account"; import { CachedServices } from "../../platform/background/service-factories/factory-options"; import { stateServiceFactory } from "../../platform/background/service-factories/state-service.factory"; import { BrowserApi } from "../../platform/browser/browser-api"; import { cipherServiceFactory, CipherServiceInitOptions, } from "../../vault/background/service_factories/cipher-service.factory"; import { autofillServiceFactory } from "../background/service_factories/autofill-service.factory"; import { copyToClipboard, GeneratePasswordToClipboardCommand } from "../clipboard"; import { AutofillTabCommand } from "../commands/autofill-tab-command"; import { AUTOFILL_ID, COPY_IDENTIFIER_ID, COPY_PASSWORD_ID, COPY_USERNAME_ID, COPY_VERIFICATIONCODE_ID, GENERATE_PASSWORD_ID, NOOP_COMMAND_SUFFIX, } from "./main-context-menu-handler"; export type CopyToClipboardOptions = { text: string; tab: chrome.tabs.Tab }; export type CopyToClipboardAction = (options: CopyToClipboardOptions) => void; export type AutofillAction = (tab: chrome.tabs.Tab, cipher: CipherView) => Promise; export type GeneratePasswordToClipboardAction = (tab: chrome.tabs.Tab) => Promise; const NOT_IMPLEMENTED = (..._args: unknown[]) => Promise.reject("This action is not implemented inside of a service worker context."); export class ContextMenuClickedHandler { constructor( private copyToClipboard: CopyToClipboardAction, private generatePasswordToClipboard: GeneratePasswordToClipboardAction, private autofillAction: AutofillAction, private authService: AuthService, private cipherService: CipherService, private totpService: TotpService, private eventCollectionService: EventCollectionService ) {} static async mv3Create(cachedServices: CachedServices) { const stateFactory = new StateFactory(GlobalState, Account); const serviceOptions: AuthServiceInitOptions & CipherServiceInitOptions = { apiServiceOptions: { logoutCallback: NOT_IMPLEMENTED, }, cryptoFunctionServiceOptions: { win: self, }, encryptServiceOptions: { logMacFailures: false, }, i18nServiceOptions: { systemLanguage: chrome.i18n.getUILanguage(), }, keyConnectorServiceOptions: { logoutCallback: NOT_IMPLEMENTED, }, logServiceOptions: { isDev: false, }, platformUtilsServiceOptions: { biometricCallback: NOT_IMPLEMENTED, clipboardWriteCallback: NOT_IMPLEMENTED, win: self, }, stateMigrationServiceOptions: { stateFactory: stateFactory, }, stateServiceOptions: { stateFactory: stateFactory, }, }; const generatePasswordToClipboardCommand = new GeneratePasswordToClipboardCommand( await passwordGenerationServiceFactory(cachedServices, serviceOptions), await stateServiceFactory(cachedServices, serviceOptions) ); const autofillCommand = new AutofillTabCommand( await autofillServiceFactory(cachedServices, serviceOptions) ); return new ContextMenuClickedHandler( (options) => copyToClipboard(options.tab, options.text), (tab) => generatePasswordToClipboardCommand.generatePasswordToClipboard(tab), (tab, cipher) => autofillCommand.doAutofillTabWithCipherCommand(tab, cipher), await authServiceFactory(cachedServices, serviceOptions), await cipherServiceFactory(cachedServices, serviceOptions), await totpServiceFactory(cachedServices, serviceOptions), await eventCollectionServiceFactory(cachedServices, serviceOptions) ); } static async onClickedListener( info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab, cachedServices: CachedServices = {} ) { const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices); await contextMenuClickedHandler.run(info, tab); } static async messageListener( message: { command: string; data: LockedVaultPendingNotificationsItem }, sender: chrome.runtime.MessageSender, cachedServices: CachedServices ) { if ( message.command !== "unlockCompleted" || message.data.target !== "contextmenus.background" ) { return; } const contextMenuClickedHandler = await ContextMenuClickedHandler.mv3Create(cachedServices); await contextMenuClickedHandler.run( message.data.commandToRetry.msg.data, message.data.commandToRetry.sender.tab ); } async run(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { switch (info.menuItemId) { case GENERATE_PASSWORD_ID: if (!tab) { return; } await this.generatePasswordToClipboard(tab); break; case COPY_IDENTIFIER_ID: if (!tab) { return; } this.copyToClipboard({ text: await this.getIdentifier(tab, info), tab: tab }); break; default: await this.cipherAction(info, tab); } } async cipherAction(info: chrome.contextMenus.OnClickData, tab?: chrome.tabs.Tab) { if ((await this.authService.getAuthStatus()) < AuthenticationStatus.Unlocked) { const retryMessage: LockedVaultPendingNotificationsItem = { commandToRetry: { msg: { command: NOOP_COMMAND_SUFFIX, data: info }, sender: { tab: tab }, }, target: "contextmenus.background", }; await BrowserApi.tabSendMessageData( tab, "addToLockedVaultPendingNotifications", retryMessage ); await BrowserApi.tabSendMessageData(tab, "promptForLogin"); return; } // NOTE: We don't actually use the first part of this ID, we further switch based on the parentMenuItemId // I would really love to not add it but that is a departure from how it currently works. const id = (info.menuItemId as string).split("_")[1]; // We create all the ids, we can guarantee they are strings let cipher: CipherView | undefined; if (id === NOOP_COMMAND_SUFFIX) { // This NOOP item has come through which is generally only for no access state but since we got here // we are actually unlocked we will do our best to find a good match of an item to autofill this is useful // in scenarios like unlock on autofill const ciphers = await this.cipherService.getAllDecryptedForUrl(tab.url); cipher = ciphers.find((c) => c.reprompt === CipherRepromptType.None); } else { const ciphers = await this.cipherService.getAllDecrypted(); cipher = ciphers.find((c) => c.id === id); } if (cipher == null) { return; } switch (info.parentMenuItemId) { case AUTOFILL_ID: if (tab == null) { return; } await this.autofillAction(tab, cipher); break; case COPY_USERNAME_ID: this.copyToClipboard({ text: cipher.login.username, tab: tab }); break; case COPY_PASSWORD_ID: this.copyToClipboard({ text: cipher.login.password, tab: tab }); this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id); break; case COPY_VERIFICATIONCODE_ID: this.copyToClipboard({ text: await this.totpService.getCode(cipher.login.totp), tab: tab }); break; } } private async getIdentifier(tab: chrome.tabs.Tab, info: chrome.contextMenus.OnClickData) { return new Promise((resolve, reject) => { BrowserApi.sendTabsMessage( tab.id, { command: "getClickedElement" }, { frameId: info.frameId }, (identifier: string) => { if (chrome.runtime.lastError) { reject(chrome.runtime.lastError); return; } resolve(identifier); } ); }); } }