1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval

This commit is contained in:
rr-bw
2025-11-07 11:22:14 -08:00
182 changed files with 3121 additions and 2342 deletions

View File

@@ -304,7 +304,6 @@ jobs:
path: apps/desktop/dist/com.bitwarden.desktop.flatpak
if-no-files-found: error
linux-arm64:
name: Linux ARM64 Build
# Note, before updating the ubuntu version of the workflow, ensure the snap base image
@@ -338,14 +337,24 @@ jobs:
- name: Set up environment
run: |
sudo apt-get update
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder
sudo apt-get -y install pkg-config libxss-dev rpm musl-dev musl-tools flatpak flatpak-builder squashfs-tools ruby ruby-dev rubygems build-essential
sudo gem install --no-document fpm
- name: Set up Snap
run: sudo snap install snapcraft --classic
- name: Install snaps required by snapcraft in destructive mode
run: |
sudo snap install core22
sudo snap install gtk-common-themes
sudo snap install gnome-3-28-1804
- name: Print environment
run: |
node --version
npm --version
snap --version
snapcraft --version || echo 'snapcraft unavailable'
snapcraft --version
- name: Install Node dependencies
run: npm ci
@@ -403,8 +412,19 @@ jobs:
fi
- name: Build application
env:
# Snapcraft environment variables to bypass LXD requirement on ARM64
SNAPCRAFT_BUILD_ENVIRONMENT: host
USE_SYSTEM_FPM: true
run: npm run dist:lin:arm64
- name: Upload .snap artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap
path: apps/desktop/dist/bitwarden_${{ env._PACKAGE_VERSION }}_arm64.snap
if-no-files-found: error
- name: Upload tar.gz artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
@@ -412,14 +432,27 @@ jobs:
path: apps/desktop/dist/bitwarden_desktop_arm64.tar.gz
if-no-files-found: error
- name: Build flatpak
working-directory: apps/desktop
run: |
sudo flatpak remote-add --if-not-exists flathub https://flathub.org/repo/flathub.flatpakrepo
sudo npm run pack:lin:flatpak
- name: Upload flatpak artifact
uses: actions/upload-artifact@65c4c4a1ddee5b72f698fdd19549f0f0fb45cf08 # v4.6.0
with:
name: com.bitwarden.desktop-arm64.flatpak
path: apps/desktop/dist/com.bitwarden.desktop.flatpak
if-no-files-found: error
windows:
name: Windows Build
runs-on: windows-2022
needs:
- setup
permissions:
contents: read
id-token: write
contents: read
id-token: write
defaults:
run:
shell: pwsh
@@ -677,8 +710,8 @@ jobs:
runs-on: windows-2022
needs: setup
permissions:
contents: read
id-token: write
contents: read
id-token: write
defaults:
run:
shell: pwsh
@@ -905,15 +938,14 @@ jobs:
path: apps/desktop/dist/nsis-web/${{ needs.setup.outputs.release_channel }}.yml
if-no-files-found: error
macos-build:
name: MacOS Build
runs-on: macos-13
needs:
- setup
permissions:
contents: read
id-token: write
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -1117,7 +1149,6 @@ jobs:
- name: Build application (dev)
run: npm run build
browser-build:
name: Browser Build
needs: setup
@@ -1129,7 +1160,6 @@ jobs:
pull-requests: write
id-token: write
macos-package-github:
name: MacOS Package GitHub Release Assets
runs-on: macos-13
@@ -1139,8 +1169,8 @@ jobs:
- macos-build
- setup
permissions:
contents: read
id-token: write
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -1390,7 +1420,6 @@ jobs:
path: apps/desktop/dist/${{ needs.setup.outputs.release_channel }}-mac.yml
if-no-files-found: error
macos-package-mas:
name: MacOS Package Prod Release Asset
runs-on: macos-13
@@ -1400,8 +1429,8 @@ jobs:
- macos-build
- setup
permissions:
contents: read
id-token: write
contents: read
id-token: write
env:
_PACKAGE_VERSION: ${{ needs.setup.outputs.package_version }}
_NODE_VERSION: ${{ needs.setup.outputs.node_version }}
@@ -1731,9 +1760,9 @@ jobs:
- macos-package-github
- macos-package-mas
permissions:
contents: write
pull-requests: write
id-token: write
contents: write
pull-requests: write
id-token: write
runs-on: ubuntu-22.04
steps:
- name: Check out repo
@@ -1771,7 +1800,6 @@ jobs:
upload_sources: true
upload_translations: false
check-failures:
name: Check for failures
if: always()
@@ -1787,8 +1815,8 @@ jobs:
- macos-package-mas
- crowdin-push
permissions:
contents: read
id-token: write
contents: read
id-token: write
steps:
- name: Check if any job failed
if: |
@@ -1823,4 +1851,3 @@ jobs:
SLACK_WEBHOOK_URL: ${{ steps.retrieve-secrets.outputs.devops-alerts-slack-webhook-url }}
with:
status: ${{ job.status }}

View File

@@ -109,6 +109,8 @@ jobs:
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.rpm,
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x64.freebsd,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_amd64.snap,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.snap,
apps/desktop/artifacts/bitwarden_${{ env.PKG_VERSION }}_arm64.tar.gz,
apps/desktop/artifacts/Bitwarden-${{ env.PKG_VERSION }}-x86_64.AppImage,
apps/desktop/artifacts/Bitwarden-Portable-${{ env.PKG_VERSION }}.exe,
apps/desktop/artifacts/Bitwarden-Installer-${{ env.PKG_VERSION }}.exe,

View File

@@ -12,6 +12,13 @@ export function mockPorts() {
(chrome.runtime.connect as jest.Mock).mockImplementation((portInfo) => {
const port = mockDeep<chrome.runtime.Port>();
port.name = portInfo.name;
port.sender = { url: chrome.runtime.getURL("") };
// convert to internal port
delete (port as any).tab;
delete (port as any).documentId;
delete (port as any).documentLifecycle;
delete (port as any).frameId;
// set message broadcast
(port.postMessage as jest.Mock).mockImplementation((message) => {

View File

@@ -147,7 +147,7 @@ type NotificationBackgroundExtensionMessageHandlers = {
bgGetEnableChangedPasswordPrompt: () => Promise<boolean>;
bgGetEnableAddedLoginPrompt: () => Promise<boolean>;
bgGetExcludedDomains: () => Promise<NeverDomains>;
bgGetActiveUserServerConfig: () => Promise<ServerConfig>;
bgGetActiveUserServerConfig: () => Promise<ServerConfig | null>;
getWebVaultUrlForNotification: () => Promise<string>;
};

View File

@@ -1,19 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { InlineMenuFillType } from "../../enums/autofill-overlay.enum";
import AutofillField from "../../models/autofill-field";
import AutofillPageDetails from "../../models/autofill-page-details";
import { PageDetail } from "../../services/abstractions/autofill.service";
import { LockedVaultPendingNotificationsData } from "./notification.background";
export type PageDetailsForTab = Record<
chrome.runtime.MessageSender["tab"]["id"],
Map<chrome.runtime.MessageSender["frameId"], PageDetail>
>;
export type TabId = NonNullable<chrome.tabs.Tab["id"]>;
export type FrameId = NonNullable<chrome.runtime.MessageSender["frameId"]>;
type PageDetailsByFrame = Map<FrameId, PageDetail>;
export type PageDetailsForTab = Record<TabId, PageDetailsByFrame>;
export type SubFrameOffsetData = {
top: number;
@@ -21,19 +24,14 @@ export type SubFrameOffsetData = {
url?: string;
frameId?: number;
parentFrameIds?: number[];
isCrossOriginSubframe?: boolean;
isMainFrame?: boolean;
hasParentFrame?: boolean;
} | null;
export type SubFrameOffsetsForTab = Record<
chrome.runtime.MessageSender["tab"]["id"],
Map<chrome.runtime.MessageSender["frameId"], SubFrameOffsetData>
>;
type SubFrameOffsetsByFrame = Map<FrameId, SubFrameOffsetData>;
export type WebsiteIconData = {
imageEnabled: boolean;
image: string;
fallbackImage: string;
icon: string;
};
export type SubFrameOffsetsForTab = Record<TabId, SubFrameOffsetsByFrame>;
export type UpdateOverlayCiphersParams = {
updateAllCipherTypes: boolean;
@@ -146,7 +144,7 @@ export type OverlayBackgroundExtensionMessage = {
isFieldCurrentlyFilling?: boolean;
subFrameData?: SubFrameOffsetData;
focusedFieldData?: FocusedFieldData;
allFieldsRect?: any;
allFieldsRect?: AutofillField[];
isOpeningFullInlineMenu?: boolean;
styles?: Partial<CSSStyleDeclaration>;
data?: LockedVaultPendingNotificationsData;
@@ -155,13 +153,30 @@ export type OverlayBackgroundExtensionMessage = {
ToggleInlineMenuHiddenMessage &
UpdateInlineMenuVisibilityMessage;
export type OverlayPortCommand =
| "fillCipher"
| "addNewVaultItem"
| "viewCipher"
| "redirectFocus"
| "updateHeight"
| "buttonClicked"
| "blurred"
| "updateColorScheme"
| "unlockVault"
| "refreshGeneratedPassword"
| "fillGeneratedPassword";
export type OverlayPortMessage = {
[key: string]: any;
command: string;
direction?: string;
command: OverlayPortCommand;
direction?: "up" | "down" | "left" | "right";
inlineMenuCipherId?: string;
addNewCipherType?: CipherType;
usePasskey?: boolean;
height?: number;
backgroundColorScheme?: "light" | "dark";
viewsCipherData?: InlineMenuCipherData;
loginUrl?: string;
fillGeneratedPassword?: boolean;
};
export type InlineMenuCipherData = {
@@ -170,7 +185,7 @@ export type InlineMenuCipherData = {
type: CipherType;
reprompt: CipherRepromptType;
favorite: boolean;
icon: WebsiteIconData;
icon: CipherIconDetails;
accountCreationFieldType?: string;
login?: {
totp?: string;
@@ -201,9 +216,14 @@ export type BuildCipherDataParams = {
export type BackgroundMessageParam = {
message: OverlayBackgroundExtensionMessage;
};
export type BackgroundSenderParam = {
sender: chrome.runtime.MessageSender;
sender: chrome.runtime.MessageSender & {
tab: NonNullable<chrome.runtime.MessageSender["tab"]>;
frameId: FrameId;
};
};
export type BackgroundOnMessageHandlerParams = BackgroundMessageParam & BackgroundSenderParam;
export type OverlayBackgroundExtensionMessageHandlers = {
@@ -253,9 +273,13 @@ export type OverlayBackgroundExtensionMessageHandlers = {
export type PortMessageParam = {
message: OverlayPortMessage;
};
export type PortConnectionParam = {
port: chrome.runtime.Port;
port: chrome.runtime.Port & {
sender: NonNullable<chrome.runtime.Port["sender"]>;
};
};
export type PortOnMessageHandlerParams = PortMessageParam & PortConnectionParam;
export type InlineMenuButtonPortMessageHandlers = {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { BrowserApi } from "../../platform/browser/browser-api";
import { ContextMenuClickedHandler } from "../browser/context-menu-clicked-handler";
@@ -17,9 +15,11 @@ export default class ContextMenusBackground {
return;
}
this.contextMenus.onClicked.addListener((info, tab) =>
this.contextMenuClickedHandler.run(info, tab),
);
this.contextMenus.onClicked.addListener((info, tab) => {
if (tab) {
return this.contextMenuClickedHandler.run(info, tab);
}
});
BrowserApi.messageListener(
"contextmenus.background",
@@ -28,18 +28,16 @@ export default class ContextMenusBackground {
sender: chrome.runtime.MessageSender,
) => {
if (msg.command === "unlockCompleted" && msg.data.target === "contextmenus.background") {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.contextMenuClickedHandler
.cipherAction(
msg.data.commandToRetry.message.contextMenuOnClickData,
msg.data.commandToRetry.sender.tab,
)
.then(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
const onClickData = msg.data.commandToRetry.message.contextMenuOnClickData;
const senderTab = msg.data.commandToRetry.sender.tab;
if (onClickData && senderTab) {
void this.contextMenuClickedHandler.cipherAction(onClickData, senderTab).then(() => {
if (sender.tab) {
void BrowserApi.tabSendMessageData(sender.tab, "closeNotificationBar");
}
});
}
}
},
);

View File

@@ -39,9 +39,7 @@ describe("TabsBackground", () => {
"handleWindowOnFocusChanged",
);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
tabsBackground.init();
void tabsBackground.init();
expect(chrome.windows.onFocusChanged.addListener).toHaveBeenCalledWith(
handleWindowOnFocusChangedSpy,

View File

@@ -191,9 +191,11 @@ export class ContextMenuClickedHandler {
});
} else {
this.copyToClipboard({ text: cipher.login.password, tab: tab });
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.eventCollectionService.collect(EventType.Cipher_ClientCopiedPassword, cipher.id);
void this.eventCollectionService.collect(
EventType.Cipher_ClientCopiedPassword,
cipher.id,
);
}
break;

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -179,9 +177,11 @@ export class MainContextMenuHandler {
try {
const account = await firstValueFrom(this.accountService.activeAccount$);
const hasPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
const hasPremium =
!!account?.id &&
(await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
));
const isCardRestricted = (
await firstValueFrom(this.restrictedItemTypesService.restricted$)
@@ -198,14 +198,16 @@ export class MainContextMenuHandler {
if (requiresPremiumAccess && !hasPremium) {
continue;
}
if (menuItem.id.startsWith(AUTOFILL_CARD_ID) && isCardRestricted) {
if (menuItem.id?.startsWith(AUTOFILL_CARD_ID) && isCardRestricted) {
continue;
}
await MainContextMenuHandler.create({ ...otherOptions, contexts: ["all"] });
}
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
} finally {
this.initRunning = false;
}
@@ -318,9 +320,11 @@ export class MainContextMenuHandler {
}
const account = await firstValueFrom(this.accountService.activeAccount$);
const canAccessPremium = await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
);
const canAccessPremium =
!!account?.id &&
(await firstValueFrom(
this.billingAccountProfileStateService.hasPremiumFromAnySource$(account.id),
));
if (canAccessPremium && (!cipher || !Utils.isNullOrEmpty(cipher.login?.totp))) {
await createChildItem(COPY_VERIFICATION_CODE_ID);
}
@@ -333,7 +337,9 @@ export class MainContextMenuHandler {
await createChildItem(AUTOFILL_IDENTITY_ID);
}
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
}
}
@@ -351,7 +357,11 @@ export class MainContextMenuHandler {
this.loadOptions(
this.i18nService.t(authed ? "unlockVaultMenu" : "loginToVaultMenu"),
NOOP_COMMAND_SUFFIX,
).catch((error) => this.logService.warning(error.message));
).catch((error) => {
if (error instanceof Error) {
return this.logService.warning(error.message);
}
});
}
}
@@ -363,7 +373,9 @@ export class MainContextMenuHandler {
}
}
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
}
}
@@ -373,7 +385,9 @@ export class MainContextMenuHandler {
await MainContextMenuHandler.create(menuItem);
}
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
}
}
@@ -383,7 +397,9 @@ export class MainContextMenuHandler {
await MainContextMenuHandler.create(menuItem);
}
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
}
}
@@ -395,7 +411,9 @@ export class MainContextMenuHandler {
await this.loadOptions(this.i18nService.t("addLoginMenu"), CREATE_LOGIN_ID);
} catch (error) {
this.logService.warning(error.message);
if (error instanceof Error) {
this.logService.warning(error.message);
}
}
}
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import AutofillPageDetails from "../models/autofill-page-details";
@@ -123,9 +121,9 @@ import {
* @param fillScript - The autofill script to use
*/
function triggerAutoSubmitOnForm(fillScript: AutofillScript) {
const formOpid = fillScript.autosubmit[0];
const formOpid = fillScript.autosubmit?.[0];
if (formOpid === null) {
if (!formOpid) {
triggerAutoSubmitOnFormlessFields(fillScript);
return;
}
@@ -159,8 +157,11 @@ import {
fillScript.script[fillScript.script.length - 1][1],
);
const lastFieldIsPasswordInput =
elementIsInputElement(currentElement) && currentElement.type === "password";
const lastFieldIsPasswordInput = !!(
currentElement &&
elementIsInputElement(currentElement) &&
currentElement.type === "password"
);
while (currentElement && currentElement.tagName !== "HTML") {
if (submitElementFoundAndClicked(currentElement, lastFieldIsPasswordInput)) {

View File

@@ -1,3 +1,5 @@
import { CipherIconDetails } from "@bitwarden/common/vault/icon/build-cipher-icon";
export const CipherTypes = {
Login: 1,
SecureNote: 2,
@@ -22,20 +24,13 @@ export const OrganizationCategories = {
family: "family",
} as const;
export type WebsiteIconData = {
imageEnabled: boolean;
image: string;
fallbackImage: string;
icon: string;
};
type BaseCipherData<CipherTypeValue> = {
id: string;
name: string;
type: CipherTypeValue;
reprompt: CipherRepromptType;
favorite: boolean;
icon: WebsiteIconData;
icon: CipherIconDetails;
};
export type CipherData = BaseCipherData<CipherType> & {

View File

@@ -1,43 +1,43 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
const inputTags = ["input", "textarea", "select"];
const labelTags = ["label", "span"];
const attributes = ["id", "name", "label-aria", "placeholder"];
const attributeKeys = ["id", "name", "label-aria", "placeholder"];
const invalidElement = chrome.i18n.getMessage("copyCustomFieldNameInvalidElement");
const noUniqueIdentifier = chrome.i18n.getMessage("copyCustomFieldNameNotUnique");
let clickedEl: HTMLElement = null;
let clickedElement: HTMLElement | null = null;
// Find the best attribute to be used as the Name for an element in a custom field.
function getClickedElementIdentifier() {
if (clickedEl == null) {
if (clickedElement == null) {
return invalidElement;
}
const clickedTag = clickedEl.nodeName.toLowerCase();
let inputEl = null;
const clickedTag = clickedElement.nodeName.toLowerCase();
let inputElement = null;
// Try to identify the input element (which may not be the clicked element)
if (labelTags.includes(clickedTag)) {
let inputId = null;
let inputId;
if (clickedTag === "label") {
inputId = clickedEl.getAttribute("for");
inputId = clickedElement.getAttribute("for");
} else {
inputId = clickedEl.closest("label")?.getAttribute("for");
inputId = clickedElement.closest("label")?.getAttribute("for");
}
inputEl = document.getElementById(inputId);
if (inputId) {
inputElement = document.getElementById(inputId);
}
} else {
inputEl = clickedEl;
inputElement = clickedElement;
}
if (inputEl == null || !inputTags.includes(inputEl.nodeName.toLowerCase())) {
if (inputElement == null || !inputTags.includes(inputElement.nodeName.toLowerCase())) {
return invalidElement;
}
for (const attr of attributes) {
const attributeValue = inputEl.getAttribute(attr);
const selector = "[" + attr + '="' + attributeValue + '"]';
for (const attributeKey of attributeKeys) {
const attributeValue = inputElement.getAttribute(attributeKey);
const selector = "[" + attributeKey + '="' + attributeValue + '"]';
if (!isNullOrEmpty(attributeValue) && document.querySelectorAll(selector)?.length === 1) {
return attributeValue;
}
@@ -45,14 +45,14 @@ function getClickedElementIdentifier() {
return noUniqueIdentifier;
}
function isNullOrEmpty(s: string) {
function isNullOrEmpty(s: string | null) {
return s == null || s === "";
}
// We only have access to the element that's been clicked when the context menu is first opened.
// Remember it for use later.
document.addEventListener("contextmenu", (event) => {
clickedEl = event.target as HTMLElement;
clickedElement = event.target as HTMLElement;
});
// Runs when the 'Copy Custom Field Name' context menu item is actually clicked.
@@ -62,9 +62,8 @@ chrome.runtime.onMessage.addListener((event, _sender, sendResponse) => {
if (sendResponse) {
sendResponse(identifier);
}
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
chrome.runtime.sendMessage({
void chrome.runtime.sendMessage({
command: "getClickedElementResponse",
sender: "contextMenuHandler",
identifier: identifier,

View File

@@ -267,9 +267,7 @@ import { Messenger } from "./messaging/messenger";
clearWaitForFocus();
void messenger.destroy();
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (e) {
} catch {
/** empty */
}
}

View File

@@ -31,9 +31,8 @@ describe("Messenger", () => {
it("should deliver message to B when sending request from A", () => {
const request = createRequest();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.request(request);
void messengerA.request(request);
const received = handlerB.receive();
@@ -66,14 +65,13 @@ describe("Messenger", () => {
it("should deliver abort signal to B when requesting abort", () => {
const abortController = new AbortController();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.request(createRequest(), abortController.signal);
void messengerA.request(createRequest(), abortController.signal);
abortController.abort();
const received = handlerB.receive();
expect(received[0].abortController.signal.aborted).toBe(true);
expect(received[0].abortController?.signal.aborted).toBe(true);
});
describe("destroy", () => {
@@ -103,29 +101,25 @@ describe("Messenger", () => {
it("should dispatch the destroy event on messenger destruction", async () => {
const request = createRequest();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.request(request);
void messengerA.request(request);
const dispatchEventSpy = jest.spyOn((messengerA as any).onDestroy, "dispatchEvent");
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.destroy();
void messengerA.destroy();
expect(dispatchEventSpy).toHaveBeenCalledWith(expect.any(Event));
});
it("should trigger onDestroyListener when the destroy event is dispatched", async () => {
const request = createRequest();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.request(request);
void messengerA.request(request);
const onDestroyListener = jest.fn();
(messengerA as any).onDestroy.addEventListener("destroy", onDestroyListener);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
messengerA.destroy();
void messengerA.destroy();
expect(onDestroyListener).toHaveBeenCalled();
const eventArg = onDestroyListener.mock.calls[0][0];
@@ -213,7 +207,7 @@ class MockMessagePort<T> {
remotePort: MockMessagePort<T>;
postMessage(message: T, port?: MessagePort) {
this.remotePort.onmessage(
this.remotePort.onmessage?.(
new MessageEvent("message", {
data: message,
ports: port ? [port] : [],

View File

@@ -155,9 +155,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
}
static sendMessage(msg: BrowserFido2Message) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
BrowserApi.sendMessage(BrowserFido2MessageName, msg);
void BrowserApi.sendMessage(BrowserFido2MessageName, msg);
}
static abortPopout(sessionId: string, fallbackRequested = false) {
@@ -206,9 +204,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
fromEvent(abortController.signal, "abort")
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.close();
void this.close();
BrowserFido2UserInterfaceSession.sendMessage({
type: BrowserFido2MessageTypes.AbortRequest,
sessionId: this.sessionId,
@@ -224,12 +220,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
)
.subscribe((msg) => {
if (msg.type === BrowserFido2MessageTypes.AbortResponse) {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.close();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.abort(msg.fallbackRequested);
void this.close();
void this.abort(msg.fallbackRequested);
}
});
@@ -388,12 +380,8 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
takeUntil(this.destroy$),
)
.subscribe(() => {
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.close();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.abort(true);
void this.close();
void this.abort(true);
});
await connectPromise;

View File

@@ -1,6 +1,4 @@
import { FieldRect } from "../background/abstractions/overlay.background";
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AutofillFieldQualifierType } from "../enums/autofill-field.enums";
import {
InlineMenuAccountCreationFieldTypes,
@@ -13,34 +11,36 @@ import {
export default class AutofillField {
[key: string]: any;
/**
* The unique identifier assigned to this field during collection of the page details
* Non-null asserted. The unique identifier assigned to this field during collection of the page details
*/
opid: string;
opid!: string;
/**
* Sequential number assigned to each element collected, based on its position in the DOM.
* Non-null asserted. Sequential number assigned to each element collected, based on its position in the DOM.
* Used to do perform proximal checks for username and password fields on the DOM.
*/
elementNumber: number;
elementNumber!: number;
/**
* Designates whether the field is viewable on the current part of the DOM that the user can see
* Non-null asserted. Designates whether the field is viewable on the current part of the DOM that the user can see
*/
viewable: boolean;
viewable!: boolean;
/**
* The HTML `id` attribute of the field
* Non-null asserted. The HTML `id` attribute of the field
*/
htmlID: string | null;
htmlID!: string | null;
/**
* The HTML `name` attribute of the field
* Non-null asserted. The HTML `name` attribute of the field
*/
htmlName: string | null;
htmlName!: string | null;
/**
* The HTML `class` attribute of the field
* Non-null asserted. The HTML `class` attribute of the field
*/
htmlClass: string | null;
htmlClass!: string | null;
tabindex: string | null;
/** Non-null asserted. */
tabindex!: string | null;
title: string | null;
/** Non-null asserted. */
title!: string | null;
/**
* The `tagName` for the field
*/

View File

@@ -1,28 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
/**
* Represents an HTML form whose elements can be autofilled
*/
export default class AutofillForm {
[key: string]: any;
/**
* The unique identifier assigned to this field during collection of the page details
* Non-null asserted. The unique identifier assigned to this field during collection of the page details
*/
opid: string;
opid!: string;
/**
* The HTML `name` attribute of the form field
* Non-null asserted. The HTML `name` attribute of the form field
*/
htmlName: string;
htmlName!: string;
/**
* The HTML `id` attribute of the form field
* Non-null asserted. The HTML `id` attribute of the form field
*/
htmlID: string;
htmlID!: string;
/**
* The HTML `action` attribute of the form field
* Non-null asserted. The HTML `action` attribute of the form field
*/
htmlAction: string;
htmlAction!: string;
/**
* The HTML `method` attribute of the form field
* Non-null asserted. The HTML `method` attribute of the form field.
*/
htmlMethod: string;
htmlMethod!: "get" | "post" | string;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import AutofillField from "./autofill-field";
import AutofillForm from "./autofill-form";
@@ -7,16 +5,20 @@ import AutofillForm from "./autofill-form";
* The details of a page that have been collected and can be used for autofill
*/
export default class AutofillPageDetails {
title: string;
url: string;
documentUrl: string;
/** Non-null asserted. */
title!: string;
/** Non-null asserted. */
url!: string;
/** Non-null asserted. */
documentUrl!: string;
/**
* A collection of all of the forms in the page DOM, keyed by their `opid`
* Non-null asserted. A collection of all of the forms in the page DOM, keyed by their `opid`
*/
forms: { [id: string]: AutofillForm };
forms!: { [id: string]: AutofillForm };
/**
* A collection of all the fields in the page DOM, keyed by their `opid`
* Non-null asserted. A collection of all the fields in the page DOM, keyed by their `opid`
*/
fields: AutofillField[];
collectedTimestamp: number;
fields!: AutofillField[];
/** Non-null asserted. */
collectedTimestamp!: number;
}

View File

@@ -1,26 +1,33 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// String values affect code flow in autofill.ts and must not be changed
export type FillScriptActions = "click_on_opid" | "focus_by_opid" | "fill_by_opid";
export type FillScript = [action: FillScriptActions, opid: string, value?: string];
export type AutofillScriptProperties = {
delay_between_operations?: number;
};
export const FillScriptActionTypes = {
fill_by_opid: "fill_by_opid",
click_on_opid: "click_on_opid",
focus_by_opid: "focus_by_opid",
} as const;
// String values affect code flow in autofill.ts and must not be changed
export type FillScriptActions = keyof typeof FillScriptActionTypes;
export type AutofillInsertActions = {
fill_by_opid: ({ opid, value }: { opid: string; value: string }) => void;
click_on_opid: ({ opid }: { opid: string }) => void;
focus_by_opid: ({ opid }: { opid: string }) => void;
[FillScriptActionTypes.fill_by_opid]: ({ opid, value }: { opid: string; value: string }) => void;
[FillScriptActionTypes.click_on_opid]: ({ opid }: { opid: string }) => void;
[FillScriptActionTypes.focus_by_opid]: ({ opid }: { opid: string }) => void;
};
export default class AutofillScript {
script: FillScript[] = [];
properties: AutofillScriptProperties = {};
metadata: any = {}; // Unused, not written or read
autosubmit: string[]; // Appears to be unused, read but not written
savedUrls: string[];
untrustedIframe: boolean;
itemType: string; // Appears to be unused, read but not written
/** Non-null asserted. */
autosubmit!: string[] | null; // Appears to be unused, read but not written
/** Non-null asserted. */
savedUrls!: string[];
/** Non-null asserted. */
untrustedIframe!: boolean;
/** Non-null asserted. */
itemType!: string; // Appears to be unused, read but not written
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import "@webcomponents/custom-elements";
import "lit/polyfill-support.js";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -103,7 +101,10 @@ export class AutofillInlineMenuButton extends AutofillInlineMenuPageElement {
*/
private updatePageColorScheme({ colorScheme }: AutofillInlineMenuButtonMessage) {
const colorSchemeMetaTag = globalThis.document.querySelector("meta[name='color-scheme']");
colorSchemeMetaTag?.setAttribute("content", colorScheme);
if (colorSchemeMetaTag && colorScheme) {
colorSchemeMetaTag.setAttribute("content", colorScheme);
}
}
/**

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { setElementStyles } from "../../../../utils";
@@ -14,8 +12,10 @@ export class AutofillInlineMenuContainer {
private readonly setElementStyles = setElementStyles;
private readonly extensionOriginsSet: Set<string>;
private port: chrome.runtime.Port | null = null;
private portName: string;
private inlineMenuPageIframe: HTMLIFrameElement;
/** Non-null asserted. */
private portName!: string;
/** Non-null asserted. */
private inlineMenuPageIframe!: HTMLIFrameElement;
private readonly iframeStyles: Partial<CSSStyleDeclaration> = {
all: "initial",
position: "fixed",
@@ -42,8 +42,10 @@ export class AutofillInlineMenuContainer {
tabIndex: "-1",
};
private readonly windowMessageHandlers: AutofillInlineMenuContainerWindowMessageHandlers = {
initAutofillInlineMenuButton: (message) => this.handleInitInlineMenuIframe(message),
initAutofillInlineMenuList: (message) => this.handleInitInlineMenuIframe(message),
initAutofillInlineMenuButton: (message: InitAutofillInlineMenuElementMessage) =>
this.handleInitInlineMenuIframe(message),
initAutofillInlineMenuList: (message: InitAutofillInlineMenuElementMessage) =>
this.handleInitInlineMenuIframe(message),
};
constructor() {
@@ -116,14 +118,20 @@ export class AutofillInlineMenuContainer {
*
* @param event - The message event.
*/
private handleWindowMessage = (event: MessageEvent) => {
private handleWindowMessage = (event: MessageEvent<AutofillInlineMenuContainerWindowMessage>) => {
const message = event.data;
if (this.isForeignWindowMessage(event)) {
return;
}
if (this.windowMessageHandlers[message.command]) {
this.windowMessageHandlers[message.command](message);
if (
this.windowMessageHandlers[
message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers
]
) {
this.windowMessageHandlers[
message.command as keyof AutofillInlineMenuContainerWindowMessageHandlers
](message);
return;
}
@@ -142,8 +150,8 @@ export class AutofillInlineMenuContainer {
*
* @param event - The message event.
*/
private isForeignWindowMessage(event: MessageEvent) {
if (!event.data.portKey) {
private isForeignWindowMessage(event: MessageEvent<AutofillInlineMenuContainerWindowMessage>) {
if (!event.data?.portKey) {
return true;
}
@@ -159,7 +167,9 @@ export class AutofillInlineMenuContainer {
*
* @param event - The message event.
*/
private isMessageFromParentWindow(event: MessageEvent): boolean {
private isMessageFromParentWindow(
event: MessageEvent<AutofillInlineMenuContainerWindowMessage>,
): boolean {
return globalThis.parent === event.source;
}
@@ -168,7 +178,9 @@ export class AutofillInlineMenuContainer {
*
* @param event - The message event.
*/
private isMessageFromInlineMenuPageIframe(event: MessageEvent): boolean {
private isMessageFromInlineMenuPageIframe(
event: MessageEvent<AutofillInlineMenuContainerWindowMessage>,
): boolean {
if (!this.inlineMenuPageIframe) {
return false;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS } from "@bitwarden/common/autofill/constants";
import { RedirectFocusDirection } from "../../../../enums/autofill-overlay.enum";
@@ -10,10 +8,14 @@ import {
export class AutofillInlineMenuPageElement extends HTMLElement {
protected shadowDom: ShadowRoot;
protected messageOrigin: string;
protected translations: Record<string, string>;
private portKey: string;
protected windowMessageHandlers: AutofillInlineMenuPageElementWindowMessageHandlers;
/** Non-null asserted. */
protected messageOrigin!: string;
/** Non-null asserted. */
protected translations!: Record<string, string>;
/** Non-null asserted. */
private portKey!: string;
/** Non-null asserted. */
protected windowMessageHandlers!: AutofillInlineMenuPageElementWindowMessageHandlers;
constructor() {
super();

View File

@@ -20,7 +20,7 @@ describe("OverlayNotificationsContentService", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.spyOn(utils, "sendExtensionMessage").mockImplementation(jest.fn());
jest.spyOn(utils, "sendExtensionMessage").mockImplementation(async () => null);
jest.spyOn(HTMLIFrameElement.prototype, "contentWindow", "get").mockReturnValue(window);
postMessageSpy = jest.spyOn(window, "postMessage").mockImplementation(jest.fn());
domQueryService = mock<DomQueryService>();

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { CommonModule } from "@angular/common";
import { Component } from "@angular/core";
import { firstValueFrom } from "rxjs";
@@ -69,7 +67,7 @@ export class Fido2UseBrowserLinkComponent {
this.platformUtilsService.showToast(
"success",
null,
"",
this.i18nService.t("domainAddedToExcludedDomains", validDomain),
);
}

View File

@@ -155,13 +155,15 @@ export class AutofillComponent implements OnInit {
autofillOnPageLoadOptions: { name: string; value: boolean }[];
enableContextMenuItem: boolean = false;
enableAutoTotpCopy: boolean = false;
clearClipboard: ClearClipboardDelaySetting;
/** Non-null asserted. */
clearClipboard!: ClearClipboardDelaySetting;
clearClipboardOptions: { name: string; value: ClearClipboardDelaySetting }[];
defaultUriMatch: UriMatchStrategySetting = UriMatchStrategy.Domain;
uriMatchOptions: { name: string; value: UriMatchStrategySetting; disabled?: boolean }[];
showCardsCurrentTab: boolean = true;
showIdentitiesCurrentTab: boolean = true;
autofillKeyboardHelperText: string;
/** Non-null asserted. */
autofillKeyboardHelperText!: string;
accountSwitcherEnabled: boolean = false;
constructor(

View File

@@ -26,7 +26,7 @@ export type AutofillOverlayContentExtensionMessageHandlers = {
destroyAutofillInlineMenuListeners: () => void;
getInlineMenuFormFieldData: ({
message,
}: AutofillExtensionMessageParam) => Promise<ModifyLoginCipherFormData>;
}: AutofillExtensionMessageParam) => Promise<ModifyLoginCipherFormData | void>;
};
export interface AutofillOverlayContentService {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Observable } from "rxjs";
import { UriMatchStrategySetting } from "@bitwarden/common/models/domain/domain-service";
@@ -64,29 +62,39 @@ export const COLLECT_PAGE_DETAILS_RESPONSE_COMMAND =
);
export abstract class AutofillService {
collectPageDetailsFromTab$: (tab: chrome.tabs.Tab) => Observable<PageDetail[]>;
loadAutofillScriptsOnInstall: () => Promise<void>;
reloadAutofillScripts: () => Promise<void>;
injectAutofillScripts: (
/** Non-null asserted. */
collectPageDetailsFromTab$!: (tab: chrome.tabs.Tab) => Observable<PageDetail[]>;
/** Non-null asserted. */
loadAutofillScriptsOnInstall!: () => Promise<void>;
/** Non-null asserted. */
reloadAutofillScripts!: () => Promise<void>;
/** Non-null asserted. */
injectAutofillScripts!: (
tab: chrome.tabs.Tab,
frameId?: number,
triggeringOnPageLoad?: boolean,
) => Promise<void>;
getFormsWithPasswordFields: (pageDetails: AutofillPageDetails) => FormData[];
doAutoFill: (options: AutoFillOptions) => Promise<string | null>;
doAutoFillOnTab: (
/** Non-null asserted. */
getFormsWithPasswordFields!: (pageDetails: AutofillPageDetails) => FormData[];
/** Non-null asserted. */
doAutoFill!: (options: AutoFillOptions) => Promise<string | null>;
/** Non-null asserted. */
doAutoFillOnTab!: (
pageDetails: PageDetail[],
tab: chrome.tabs.Tab,
fromCommand: boolean,
autoSubmitLogin?: boolean,
) => Promise<string | null>;
doAutoFillActiveTab: (
/** Non-null asserted. */
doAutoFillActiveTab!: (
pageDetails: PageDetail[],
fromCommand: boolean,
cipherType?: CipherType,
) => Promise<string | null>;
setAutoFillOnPageLoadOrgPolicy: () => Promise<void>;
isPasswordRepromptRequired: (
/** Non-null asserted. */
setAutoFillOnPageLoadOrgPolicy!: () => Promise<void>;
/** Non-null asserted. */
isPasswordRepromptRequired!: (
cipher: CipherView,
tab: chrome.tabs.Tab,
action?: string,

View File

@@ -369,9 +369,7 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "injectAutofillScriptsInAllTabs");
jest.spyOn(autofillService, "getAutofillOnPageLoad").mockResolvedValue(true);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
autofillService.reloadAutofillScripts();
void autofillService.reloadAutofillScripts();
expect(port1.disconnect).toHaveBeenCalled();
expect(port2.disconnect).toHaveBeenCalled();
@@ -680,7 +678,9 @@ describe("AutofillService", () => {
await autofillService.doAutoFill(autofillOptions);
triggerTestFailure();
} catch (error) {
expect(error.message).toBe(nothingToAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(nothingToAutofillError);
}
}
});
@@ -691,7 +691,9 @@ describe("AutofillService", () => {
await autofillService.doAutoFill(autofillOptions);
triggerTestFailure();
} catch (error) {
expect(error.message).toBe(nothingToAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(nothingToAutofillError);
}
}
});
@@ -702,7 +704,9 @@ describe("AutofillService", () => {
await autofillService.doAutoFill(autofillOptions);
triggerTestFailure();
} catch (error) {
expect(error.message).toBe(nothingToAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(nothingToAutofillError);
}
}
});
@@ -713,7 +717,9 @@ describe("AutofillService", () => {
await autofillService.doAutoFill(autofillOptions);
triggerTestFailure();
} catch (error) {
expect(error.message).toBe(nothingToAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(nothingToAutofillError);
}
}
});
@@ -727,7 +733,9 @@ describe("AutofillService", () => {
await autofillService.doAutoFill(autofillOptions);
triggerTestFailure();
} catch (error) {
expect(error.message).toBe(didNotAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(didNotAutofillError);
}
}
});
});
@@ -766,7 +774,6 @@ describe("AutofillService", () => {
{
command: "fillForm",
fillScript: {
metadata: {},
properties: {
delay_between_operations: 20,
},
@@ -863,7 +870,9 @@ describe("AutofillService", () => {
expect(logService.info).toHaveBeenCalledWith(
"Autofill on page load was blocked due to an untrusted iframe.",
);
expect(error.message).toBe(didNotAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(didNotAutofillError);
}
}
});
@@ -898,7 +907,10 @@ describe("AutofillService", () => {
} catch (error) {
expect(autofillService["generateFillScript"]).toHaveBeenCalled();
expect(BrowserApi.tabSendMessage).not.toHaveBeenCalled();
expect(error.message).toBe(didNotAutofillError);
if (error instanceof Error) {
expect(error.message).toBe(didNotAutofillError);
}
}
});
@@ -1370,7 +1382,10 @@ describe("AutofillService", () => {
triggerTestFailure();
} catch (error) {
expect(BrowserApi.getTabFromCurrentWindow).toHaveBeenCalled();
expect(error.message).toBe("No tab found.");
if (error instanceof Error) {
expect(error.message).toBe("No tab found.");
}
}
});
@@ -1610,7 +1625,6 @@ describe("AutofillService", () => {
expect(autofillService["generateLoginFillScript"]).toHaveBeenCalledWith(
{
metadata: {},
properties: {},
script: [
["click_on_opid", "username-field"],
@@ -1648,7 +1662,6 @@ describe("AutofillService", () => {
expect(autofillService["generateCardFillScript"]).toHaveBeenCalledWith(
{
metadata: {},
properties: {},
script: [
["click_on_opid", "username-field"],
@@ -1686,7 +1699,6 @@ describe("AutofillService", () => {
expect(autofillService["generateIdentityFillScript"]).toHaveBeenCalledWith(
{
metadata: {},
properties: {},
script: [
["click_on_opid", "username-field"],
@@ -2279,7 +2291,7 @@ describe("AutofillService", () => {
);
expect(value).toStrictEqual({
autosubmit: null,
metadata: {},
itemType: "",
properties: { delay_between_operations: 20 },
savedUrls: ["https://www.example.com"],
script: [
@@ -2294,7 +2306,6 @@ describe("AutofillService", () => {
["fill_by_opid", "password", "password"],
["focus_by_opid", "password"],
],
itemType: "",
untrustedIframe: false,
});
});
@@ -2364,11 +2375,10 @@ describe("AutofillService", () => {
describe("given an invalid autofill field", () => {
const unmodifiedFillScriptValues: AutofillScript = {
autosubmit: null,
metadata: {},
itemType: "",
properties: { delay_between_operations: 20 },
savedUrls: [],
script: [],
itemType: "",
untrustedIframe: false,
};
@@ -2555,7 +2565,6 @@ describe("AutofillService", () => {
expect(value).toStrictEqual({
autosubmit: null,
itemType: "",
metadata: {},
properties: {
delay_between_operations: 20,
},

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { AutofillInlineMenuContentService } from "../overlay/inline-menu/abstractions/autofill-inline-menu-content.service";
import { FillableFormFieldElement, FormFieldElement } from "../types";
@@ -202,7 +200,7 @@ class DomElementVisibilityService implements DomElementVisibilityServiceInterfac
const closestParentLabel = elementAtCenterPoint?.parentElement?.closest("label");
return targetElementLabelsSet.has(closestParentLabel);
return closestParentLabel ? targetElementLabelsSet.has(closestParentLabel) : false;
}
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS, MAX_DEEP_QUERY_RECURSION_DEPTH } from "@bitwarden/common/autofill/constants";
import { nodeIsElement } from "../utils";
@@ -7,7 +5,8 @@ import { nodeIsElement } from "../utils";
import { DomQueryService as DomQueryServiceInterface } from "./abstractions/dom-query.service";
export class DomQueryService implements DomQueryServiceInterface {
private pageContainsShadowDom: boolean;
/** Non-null asserted. */
private pageContainsShadowDom!: boolean;
private ignoredTreeWalkerNodes = new Set([
"svg",
"script",
@@ -217,13 +216,12 @@ export class DomQueryService implements DomQueryServiceInterface {
if ((chrome as any).dom?.openOrClosedShadowRoot) {
try {
return (chrome as any).dom.openOrClosedShadowRoot(node);
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-unused-vars
} catch (error) {
} catch {
return null;
}
}
// Firefox-specific equivalent of `openOrClosedShadowRoot`
return (node as any).openOrClosedShadowRoot;
}
@@ -276,7 +274,7 @@ export class DomQueryService implements DomQueryServiceInterface {
? NodeFilter.FILTER_REJECT
: NodeFilter.FILTER_ACCEPT,
);
let currentNode = treeWalker?.currentNode;
let currentNode: Node | null = treeWalker?.currentNode;
while (currentNode) {
if (filterCallback(currentNode)) {

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import AutofillField from "../models/autofill-field";
import AutofillPageDetails from "../models/autofill-page-details";
import { getSubmitButtonKeywordsSet, sendExtensionMessage } from "../utils";
@@ -162,12 +160,14 @@ export class InlineMenuFieldQualificationService
private isExplicitIdentityEmailField(field: AutofillField): boolean {
const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder];
for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
if (!matchFieldAttributeValues[attrIndex]) {
const attributeValueToMatch = matchFieldAttributeValues[attrIndex];
if (!attributeValueToMatch) {
continue;
}
for (let keywordIndex = 0; keywordIndex < matchFieldAttributeValues.length; keywordIndex++) {
if (this.newEmailFieldKeywords.has(matchFieldAttributeValues[attrIndex])) {
if (this.newEmailFieldKeywords.has(attributeValueToMatch)) {
return true;
}
}
@@ -210,10 +210,7 @@ export class InlineMenuFieldQualificationService
}
constructor() {
void Promise.all([
sendExtensionMessage("getInlineMenuFieldQualificationFeatureFlag"),
sendExtensionMessage("getUserPremiumStatus"),
]).then(([fieldQualificationFlag, premiumStatus]) => {
void sendExtensionMessage("getUserPremiumStatus").then((premiumStatus) => {
this.premiumEnabled = !!premiumStatus?.result;
});
}
@@ -263,7 +260,13 @@ export class InlineMenuFieldQualificationService
return true;
}
const parentForm = pageDetails.forms[field.form];
let parentForm;
const fieldForm = field.form;
if (fieldForm) {
parentForm = pageDetails.forms[fieldForm];
}
// If the field does not have a parent form
if (!parentForm) {
@@ -321,7 +324,13 @@ export class InlineMenuFieldQualificationService
return false;
}
const parentForm = pageDetails.forms[field.form];
let parentForm;
const fieldForm = field.form;
if (fieldForm) {
parentForm = pageDetails.forms[fieldForm];
}
if (!parentForm) {
// If the field does not have a parent form, but we can identify that the page contains at least
@@ -374,7 +383,13 @@ export class InlineMenuFieldQualificationService
field: AutofillField,
pageDetails: AutofillPageDetails,
): boolean {
const parentForm = pageDetails.forms[field.form];
let parentForm;
const fieldForm = field.form;
if (fieldForm) {
parentForm = pageDetails.forms[fieldForm];
}
// If the provided field is set with an autocomplete value of "current-password", we should assume that
// the page developer intends for this field to be interpreted as a password field for a login form.
@@ -476,7 +491,13 @@ export class InlineMenuFieldQualificationService
// If the field is not explicitly set as a username field, we need to qualify
// the field based on the other fields that are present on the page.
const parentForm = pageDetails.forms[field.form];
let parentForm;
const fieldForm = field.form;
if (fieldForm) {
parentForm = pageDetails.forms[fieldForm];
}
const passwordFieldsInPageDetails = pageDetails.fields.filter(this.isCurrentPasswordField);
if (this.isNewsletterForm(parentForm)) {
@@ -919,8 +940,10 @@ export class InlineMenuFieldQualificationService
* @param field - The field to validate
*/
isUsernameField = (field: AutofillField): boolean => {
const fieldType = field.type;
if (
!this.usernameFieldTypes.has(field.type) ||
!fieldType ||
!this.usernameFieldTypes.has(fieldType) ||
this.isExcludedFieldType(field, this.excludedAutofillFieldTypesSet) ||
this.fieldHasDisqualifyingAttributeValue(field)
) {
@@ -1026,7 +1049,13 @@ export class InlineMenuFieldQualificationService
const testedValues = [field.htmlID, field.htmlName, field.placeholder];
for (let i = 0; i < testedValues.length; i++) {
if (this.valueIsLikePassword(testedValues[i])) {
const attributeValueToMatch = testedValues[i];
if (!attributeValueToMatch) {
continue;
}
if (this.valueIsLikePassword(attributeValueToMatch)) {
return true;
}
}
@@ -1101,7 +1130,9 @@ export class InlineMenuFieldQualificationService
* @param excludedTypes - The set of excluded types
*/
private isExcludedFieldType(field: AutofillField, excludedTypes: Set<string>): boolean {
if (excludedTypes.has(field.type)) {
const fieldType = field.type;
if (fieldType && excludedTypes.has(fieldType)) {
return true;
}
@@ -1116,12 +1147,14 @@ export class InlineMenuFieldQualificationService
private isSearchField(field: AutofillField): boolean {
const matchFieldAttributeValues = [field.type, field.htmlName, field.htmlID, field.placeholder];
for (let attrIndex = 0; attrIndex < matchFieldAttributeValues.length; attrIndex++) {
if (!matchFieldAttributeValues[attrIndex]) {
const attributeValueToMatch = matchFieldAttributeValues[attrIndex];
if (!attributeValueToMatch) {
continue;
}
// Separate camel case words and case them to lower case values
const camelCaseSeparatedFieldAttribute = matchFieldAttributeValues[attrIndex]
const camelCaseSeparatedFieldAttribute = attributeValueToMatch
.replace(/([a-z])([A-Z])/g, "$1 $2")
.toLowerCase();
// Split the attribute by non-alphabetical characters to get the keywords
@@ -1168,7 +1201,7 @@ export class InlineMenuFieldQualificationService
this.submitButtonKeywordsMap.set(element, Array.from(keywordsSet).join(","));
}
return this.submitButtonKeywordsMap.get(element);
return this.submitButtonKeywordsMap.get(element) || "";
}
/**
@@ -1222,8 +1255,9 @@ export class InlineMenuFieldQualificationService
];
const keywordsSet = new Set<string>();
for (let i = 0; i < keywords.length; i++) {
if (keywords[i] && typeof keywords[i] === "string") {
let keywordEl = keywords[i].toLowerCase();
const attributeValue = keywords[i];
if (attributeValue && typeof attributeValue === "string") {
let keywordEl = attributeValue.toLowerCase();
keywordsSet.add(keywordEl);
// Remove hyphens from all potential keywords, we want to treat these as a single word.
@@ -1253,7 +1287,7 @@ export class InlineMenuFieldQualificationService
}
const mapValues = this.autofillFieldKeywordsMap.get(autofillFieldData);
return returnStringValue ? mapValues.stringValue : mapValues.keywordsSet;
return mapValues ? (returnStringValue ? mapValues.stringValue : mapValues.keywordsSet) : "";
}
/**

View File

@@ -2,7 +2,7 @@ import { mock } from "jest-mock-extended";
import { EVENTS } from "@bitwarden/common/autofill/constants";
import AutofillScript, { FillScript, FillScriptActions } from "../models/autofill-script";
import AutofillScript, { FillScript, FillScriptActionTypes } from "../models/autofill-script";
import { mockQuerySelectorAllDefinedCall } from "../spec/testing-utils";
import { FillableFormFieldElement, FormElementWithAttribute, FormFieldElement } from "../types";
@@ -94,14 +94,13 @@ describe("InsertAutofillContentService", () => {
);
fillScript = {
script: [
["click_on_opid", "username"],
["focus_by_opid", "username"],
["fill_by_opid", "username", "test"],
[FillScriptActionTypes.click_on_opid, "username"],
[FillScriptActionTypes.focus_by_opid, "username"],
[FillScriptActionTypes.fill_by_opid, "username", "test"],
],
properties: {
delay_between_operations: 20,
},
metadata: {},
autosubmit: [],
savedUrls: ["https://bitwarden.com"],
untrustedIframe: false,
@@ -221,17 +220,14 @@ describe("InsertAutofillContentService", () => {
expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith(
1,
fillScript.script[0],
0,
);
expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith(
2,
fillScript.script[1],
1,
);
expect(insertAutofillContentService["runFillScriptAction"]).toHaveBeenNthCalledWith(
3,
fillScript.script[2],
2,
);
});
});
@@ -376,42 +372,62 @@ describe("InsertAutofillContentService", () => {
});
it("returns early if no opid is provided", async () => {
const action = "fill_by_opid";
const action = FillScriptActionTypes.fill_by_opid;
const opid = "";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
await insertAutofillContentService["runFillScriptAction"](scriptAction, 0);
await insertAutofillContentService["runFillScriptAction"](scriptAction);
jest.advanceTimersByTime(20);
expect(insertAutofillContentService["autofillInsertActions"][action]).not.toHaveBeenCalled();
});
describe("given a valid fill script action and opid", () => {
const fillScriptActions: FillScriptActions[] = [
"fill_by_opid",
"click_on_opid",
"focus_by_opid",
];
fillScriptActions.forEach((action) => {
it(`triggers a ${action} action`, () => {
const opid = "opid";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
it(`triggers a fill_by_opid action`, () => {
const action = FillScriptActionTypes.fill_by_opid;
const opid = "opid";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
insertAutofillContentService["runFillScriptAction"](scriptAction, 0);
jest.advanceTimersByTime(20);
void insertAutofillContentService["runFillScriptAction"](scriptAction);
jest.advanceTimersByTime(20);
expect(
insertAutofillContentService["autofillInsertActions"][action],
).toHaveBeenCalledWith({
opid,
value,
});
expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({
opid,
value,
});
});
it(`triggers a click_on_opid action`, () => {
const action = FillScriptActionTypes.click_on_opid;
const opid = "opid";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
void insertAutofillContentService["runFillScriptAction"](scriptAction);
jest.advanceTimersByTime(20);
expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({
opid,
});
});
it(`triggers a focus_by_opid action`, () => {
const action = FillScriptActionTypes.focus_by_opid;
const opid = "opid";
const value = "value";
const scriptAction: FillScript = [action, opid, value];
jest.spyOn(insertAutofillContentService["autofillInsertActions"], action);
void insertAutofillContentService["runFillScriptAction"](scriptAction);
jest.advanceTimersByTime(20);
expect(insertAutofillContentService["autofillInsertActions"][action]).toHaveBeenCalledWith({
opid,
});
});
});

View File

@@ -1,8 +1,10 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { EVENTS, TYPE_CHECK } from "@bitwarden/common/autofill/constants";
import AutofillScript, { AutofillInsertActions, FillScript } from "../models/autofill-script";
import AutofillScript, {
AutofillInsertActions,
FillScript,
FillScriptActionTypes,
} from "../models/autofill-script";
import { FormFieldElement } from "../types";
import {
currentlyInSandboxedIframe,
@@ -50,7 +52,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
}
for (let index = 0; index < fillScript.script.length; index++) {
await this.runFillScriptAction(fillScript.script[index], index);
await this.runFillScriptAction(fillScript.script[index]);
}
}
@@ -116,25 +118,26 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
/**
* Runs the autofill action based on the action type and the opid.
* Each action is subsequently delayed by 20 milliseconds.
* @param {"click_on_opid" | "focus_by_opid" | "fill_by_opid"} action
* @param {string} opid
* @param {string} value
* @param {number} actionIndex
* @param {FillScript} [action, opid, value]
* @returns {Promise<void>}
* @private
*/
private runFillScriptAction = (
[action, opid, value]: FillScript,
actionIndex: number,
): Promise<void> => {
private runFillScriptAction = ([action, opid, value]: FillScript): Promise<void> => {
if (!opid || !this.autofillInsertActions[action]) {
return;
return Promise.resolve();
}
const delayActionsInMilliseconds = 20;
return new Promise((resolve) =>
setTimeout(() => {
this.autofillInsertActions[action]({ opid, value });
if (action === FillScriptActionTypes.fill_by_opid && !!value?.length) {
this.autofillInsertActions.fill_by_opid({ opid, value });
} else if (action === FillScriptActionTypes.click_on_opid) {
this.autofillInsertActions.click_on_opid({ opid });
} else if (action === FillScriptActionTypes.focus_by_opid) {
this.autofillInsertActions.focus_by_opid({ opid });
}
resolve();
}, delayActionsInMilliseconds),
);
@@ -158,7 +161,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
*/
private handleClickOnFieldByOpidAction(opid: string) {
const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid);
this.triggerClickOnElement(element);
if (element) {
this.triggerClickOnElement(element);
}
}
/**
@@ -171,6 +177,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
private handleFocusOnFieldByOpidAction(opid: string) {
const element = this.collectAutofillContentService.getAutofillFieldElementByOpid(opid);
if (!element) {
return;
}
if (document.activeElement === element) {
element.blur();
}
@@ -187,6 +197,10 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private insertValueIntoField(element: FormFieldElement | null, value: string) {
if (!element || !value) {
return;
}
const elementCanBeReadonly =
elementIsInputElement(element) || elementIsTextAreaElement(element);
const elementCanBeFilled = elementCanBeReadonly || elementIsSelectElement(element);
@@ -195,8 +209,6 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
const elementAlreadyHasTheValue = !!(elementValue?.length && elementValue === value);
if (
!element ||
!value ||
elementAlreadyHasTheValue ||
(elementCanBeReadonly && element.readOnly) ||
(elementCanBeFilled && element.disabled)
@@ -298,7 +310,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private triggerClickOnElement(element?: HTMLElement): void {
if (typeof element?.click !== TYPE_CHECK.FUNCTION) {
if (!element || typeof element.click !== TYPE_CHECK.FUNCTION) {
return;
}
@@ -313,7 +325,7 @@ class InsertAutofillContentService implements InsertAutofillContentServiceInterf
* @private
*/
private triggerFocusOnElement(element: HTMLElement | undefined, shouldResetValue = false): void {
if (typeof element?.focus !== TYPE_CHECK.FUNCTION) {
if (!element || typeof element.focus !== TYPE_CHECK.FUNCTION) {
return;
}

View File

@@ -1,5 +1,3 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { mock } from "jest-mock-extended";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
@@ -144,7 +142,6 @@ export function createAutofillScriptMock(
return {
autosubmit: null,
metadata: {},
properties: {
delay_between_operations: 20,
},
@@ -299,7 +296,7 @@ export function createMutationRecordMock(customFields = {}): MutationRecord {
oldValue: "default-oldValue",
previousSibling: null,
removedNodes: mock<NodeList>(),
target: null,
target: mock<Node>(),
type: "attributes",
...customFields,
};

View File

@@ -1472,6 +1472,7 @@ export default class MainBackground {
this.configService,
this.logService,
this.phishingDataService,
messageListener,
);
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);

View File

@@ -6,7 +6,7 @@
</popup-header>
<div class="tw-flex tw-flex-col tw-p-2">
<h2 class="tw-font-bold">{{ "premiumFeatures" | i18n }}</h2>
<h2 class="tw-font-medium">{{ "premiumFeatures" | i18n }}</h2>
<bit-section>
<bit-card>
<div class="tw-flex tw-flex-col tw-p-2">

View File

@@ -9,7 +9,7 @@
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
<span class="tw-font-mono">{{ phishingHost$ | async }}</span>
<span class="tw-font-mono tw-break-all">{{ phishingHostname$ | async }}</span>
</bit-callout>
<bit-callout class="tw-mt-2" [icon]="null" type="default">

View File

@@ -4,9 +4,10 @@ import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
// eslint-disable-next-line no-restricted-imports
import { ActivatedRoute, RouterModule } from "@angular/router";
import { map } from "rxjs";
import { firstValueFrom, map } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { BrowserApi } from "@bitwarden/browser/platform/browser/browser-api";
import {
AsyncActionsModule,
ButtonModule,
@@ -18,8 +19,12 @@ import {
CalloutComponent,
TypographyModule,
} from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
import { PhishingDetectionService } from "../services/phishing-detection.service";
import {
PHISHING_DETECTION_CANCEL_COMMAND,
PHISHING_DETECTION_CONTINUE_COMMAND,
} from "../services/phishing-detection.service";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@@ -44,14 +49,29 @@ import { PhishingDetectionService } from "../services/phishing-detection.service
})
export class PhishingWarning {
private activatedRoute = inject(ActivatedRoute);
protected phishingHost$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingHost") || ""),
private messageSender = inject(MessageSender);
private phishingUrl$ = this.activatedRoute.queryParamMap.pipe(
map((params) => params.get("phishingUrl") || ""),
);
protected phishingHostname$ = this.phishingUrl$.pipe(map((url) => new URL(url).hostname));
async closeTab() {
await PhishingDetectionService.requestClosePhishingWarningPage();
const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CANCEL_COMMAND, {
tabId,
});
}
async continueAnyway() {
await PhishingDetectionService.requestContinueToDangerousUrl();
const url = await firstValueFrom(this.phishingUrl$);
const tabId = await this.getTabId();
this.messageSender.send(PHISHING_DETECTION_CONTINUE_COMMAND, {
tabId,
url,
});
}
private async getTabId() {
return BrowserApi.getCurrentTab()?.then((tab) => tab.id);
}
}

View File

@@ -10,6 +10,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components";
import { MessageSender } from "@bitwarden/messaging";
import { PhishingWarning } from "./phishing-warning.component";
import { ProtectedByComponent } from "./protected-by-component";
@@ -49,6 +50,13 @@ export default {
provide: PlatformUtilsService,
useClass: MockPlatformUtilsService,
},
{
provide: MessageSender,
useValue: {
// eslint-disable-next-line no-console
send: (...args: any[]) => console.debug("MessageSender called with:", args),
} as Partial<MessageSender>,
},
{
provide: I18nService,
useFactory: () =>
@@ -79,7 +87,7 @@ export default {
}).asObservable(),
},
},
mockActivatedRoute({ phishingHost: "malicious-example.com" }),
mockActivatedRoute({ phishingUrl: "http://malicious-example.com" }),
],
}),
],
@@ -95,14 +103,7 @@ export default {
</auth-anon-layout>
`,
}),
argTypes: {
phishingHost: {
control: "text",
description: "The suspicious host that was blocked",
},
},
args: {
phishingHost: "malicious-example.com",
pageIcon: DeactivatedOrg,
},
} satisfies Meta<StoryArgs & { pageIcon: any }>;
@@ -110,26 +111,20 @@ export default {
type Story = StoryObj<StoryArgs & { pageIcon: any }>;
export const Default: Story = {
args: {
phishingHost: "malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })],
providers: [mockActivatedRoute({ phishingUrl: "http://malicious-example.com" })],
}),
],
};
export const LongHostname: Story = {
args: {
phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
},
decorators: [
moduleMetadata({
providers: [
mockActivatedRoute({
phishingHost:
"very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com",
phishingUrl:
"http://verylongsuspiciousphishingdomainnamethatmightwrapmaliciousexample.com",
}),
],
}),

View File

@@ -1 +1 @@
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden phishing blocker" }}</span>

View File

@@ -5,6 +5,7 @@ import {
firstValueFrom,
map,
retry,
share,
startWith,
Subject,
switchMap,
@@ -67,7 +68,7 @@ export class PhishingDataService {
private _triggerUpdate$ = new Subject<void>();
update$ = this._triggerUpdate$.pipe(
startWith(), // Always emit once
startWith(undefined), // Always emit once
tap(() => this.logService.info(`[PhishingDataService] Update triggered...`)),
switchMap(() =>
this._cachedState.state$.pipe(
@@ -103,6 +104,7 @@ export class PhishingDataService {
),
),
),
share(),
);
constructor(
@@ -131,7 +133,6 @@ export class PhishingDataService {
const domains = await firstValueFrom(this._domains$);
const result = domains.has(url.hostname);
if (result) {
this.logService.debug("[PhishingDataService] Caught phishing domain:", url.hostname);
return true;
}
return false;

View File

@@ -1,9 +1,11 @@
import { of } from "rxjs";
import { mock, MockProxy } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { MessageListener } from "@bitwarden/messaging";
import { PhishingDataService } from "./phishing-data.service";
import { PhishingDetectionService } from "./phishing-detection.service";
@@ -13,14 +15,20 @@ describe("PhishingDetectionService", () => {
let billingAccountProfileStateService: BillingAccountProfileStateService;
let configService: ConfigService;
let logService: LogService;
let phishingDataService: PhishingDataService;
let phishingDataService: MockProxy<PhishingDataService>;
let messageListener: MockProxy<MessageListener>;
beforeEach(() => {
accountService = { getAccount$: jest.fn(() => of(null)) } as any;
billingAccountProfileStateService = {} as any;
configService = { getFeatureFlag$: jest.fn(() => of(false)) } as any;
logService = { info: jest.fn(), debug: jest.fn(), warning: jest.fn(), error: jest.fn() } as any;
phishingDataService = {} as any;
phishingDataService = mock();
messageListener = mock<MessageListener>({
messages$(_commandDefinition) {
return new Observable();
},
});
});
it("should initialize without errors", () => {
@@ -31,69 +39,48 @@ describe("PhishingDetectionService", () => {
configService,
logService,
phishingDataService,
messageListener,
);
}).not.toThrow();
});
it("should enable phishing detection for premium account", (done) => {
const premiumAccount = { id: "user1" };
accountService = { activeAccount$: of(premiumAccount) } as any;
configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
billingAccountProfileStateService = {
hasPremiumFromAnySource$: jest.fn(() => of(true)),
} as any;
// TODO
// it("should enable phishing detection for premium account", (done) => {
// const premiumAccount = { id: "user1" };
// accountService = { activeAccount$: of(premiumAccount) } as any;
// configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
// billingAccountProfileStateService = {
// hasPremiumFromAnySource$: jest.fn(() => of(true)),
// } as any;
// Patch _setup to call done
const setupSpy = jest
.spyOn(PhishingDetectionService as any, "_setup")
.mockImplementation(async () => {
expect(setupSpy).toHaveBeenCalled();
done();
});
// // Run the initialization
// PhishingDetectionService.initialize(
// accountService,
// billingAccountProfileStateService,
// configService,
// logService,
// phishingDataService,
// messageListener,
// );
// });
// Run the initialization
PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
);
});
// TODO
// it("should not enable phishing detection for non-premium account", (done) => {
// const nonPremiumAccount = { id: "user2" };
// accountService = { activeAccount$: of(nonPremiumAccount) } as any;
// configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
// billingAccountProfileStateService = {
// hasPremiumFromAnySource$: jest.fn(() => of(false)),
// } as any;
it("should not enable phishing detection for non-premium account", (done) => {
const nonPremiumAccount = { id: "user2" };
accountService = { activeAccount$: of(nonPremiumAccount) } as any;
configService = { getFeatureFlag$: jest.fn(() => of(true)) } as any;
billingAccountProfileStateService = {
hasPremiumFromAnySource$: jest.fn(() => of(false)),
} as any;
// Patch _setup to fail if called
// [FIXME] This test needs to check if the setupSpy fails or is called
// Refactor initialize in PhishingDetectionService to return a Promise or Observable that resolves/completes when initialization is done
// So that spy setups can be properly verified after initialization
// const setupSpy = jest
// .spyOn(PhishingDetectionService as any, "_setup")
// .mockImplementation(async () => {
// throw new Error("Should not call _setup");
// });
// Patch _cleanup to call done
const cleanupSpy = jest
.spyOn(PhishingDetectionService as any, "_cleanup")
.mockImplementation(() => {
expect(cleanupSpy).toHaveBeenCalled();
done();
});
// Run the initialization
PhishingDetectionService.initialize(
accountService,
billingAccountProfileStateService,
configService,
logService,
phishingDataService,
);
});
// // Run the initialization
// PhishingDetectionService.initialize(
// accountService,
// billingAccountProfileStateService,
// configService,
// logService,
// phishingDataService,
// messageListener,
// );
// });
});

View File

@@ -1,30 +1,53 @@
import { combineLatest, concatMap, delay, EMPTY, map, Subject, switchMap, takeUntil } from "rxjs";
import {
combineLatest,
concatMap,
distinctUntilChanged,
EMPTY,
filter,
map,
merge,
of,
Subject,
switchMap,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { CommandDefinition, MessageListener } from "@bitwarden/messaging";
import { BrowserApi } from "../../../platform/browser/browser-api";
import { PhishingDataService } from "./phishing-data.service";
import {
CaughtPhishingDomain,
isPhishingDetectionMessage,
PhishingDetectionMessage,
PhishingDetectionNavigationEvent,
PhishingDetectionTabId,
} from "./phishing-detection.types";
type PhishingDetectionNavigationEvent = {
tabId: number;
changeInfo: chrome.tabs.OnUpdatedInfo;
tab: chrome.tabs.Tab;
};
/**
* Sends a message to the phishing detection service to continue to the caught url
*/
export const PHISHING_DETECTION_CONTINUE_COMMAND = new CommandDefinition<{
tabId: number;
url: string;
}>("phishing-detection-continue");
/**
* Sends a message to the phishing detection service to close the warning page
*/
export const PHISHING_DETECTION_CANCEL_COMMAND = new CommandDefinition<{
tabId: number;
}>("phishing-detection-cancel");
export class PhishingDetectionService {
private static _destroy$ = new Subject<void>();
private static _logService: LogService;
private static _phishingDataService: PhishingDataService;
private static _navigationEventsSubject = new Subject<PhishingDetectionNavigationEvent>();
private static _caughtTabs: Map<PhishingDetectionTabId, CaughtPhishingDomain> = new Map();
private static _tabUpdated$ = new Subject<PhishingDetectionNavigationEvent>();
private static _ignoredHostnames = new Set<string>();
private static _didInit = false;
static initialize(
accountService: AccountService,
@@ -32,380 +55,139 @@ export class PhishingDetectionService {
configService: ConfigService,
logService: LogService,
phishingDataService: PhishingDataService,
): void {
this._logService = logService;
this._phishingDataService = phishingDataService;
messageListener: MessageListener,
) {
if (this._didInit) {
logService.debug("[PhishingDetectionService] Initialize already called. Aborting.");
return;
}
logService.info("[PhishingDetectionService] Initialize called. Checking prerequisites...");
logService.debug("[PhishingDetectionService] Initialize called. Checking prerequisites...");
combineLatest([
BrowserApi.addListener(chrome.tabs.onUpdated, this._handleTabUpdated.bind(this));
const onContinueCommand$ = messageListener.messages$(PHISHING_DETECTION_CONTINUE_COMMAND).pipe(
tap((message) =>
logService.debug(`[PhishingDetectionService] user selected continue for ${message.url}`),
),
concatMap(async (message) => {
const url = new URL(message.url);
this._ignoredHostnames.add(url.hostname);
await BrowserApi.navigateTabToUrl(message.tabId, url);
}),
);
const onTabUpdated$ = this._tabUpdated$.pipe(
filter(
(navEvent) =>
navEvent.changeInfo.status === "complete" &&
!!navEvent.tab.url &&
!this._isExtensionPage(navEvent.tab.url),
),
map(({ tab, tabId }) => {
const url = new URL(tab.url!);
return { tabId, url, ignored: this._ignoredHostnames.has(url.hostname) };
}),
distinctUntilChanged(
(prev, curr) =>
prev.url.toString() === curr.url.toString() &&
prev.tabId === curr.tabId &&
prev.ignored === curr.ignored,
),
tap((event) => logService.debug(`[PhishingDetectionService] processing event:`, event)),
concatMap(async ({ tabId, url, ignored }) => {
if (ignored) {
// The next time this host is visited, block again
this._ignoredHostnames.delete(url.hostname);
return;
}
const isPhishing = await phishingDataService.isPhishingDomain(url);
if (!isPhishing) {
return;
}
const phishingWarningPage = new URL(
BrowserApi.getRuntimeURL("popup/index.html#/security/phishing-warning") +
`?phishingUrl=${url.toString()}`,
);
await BrowserApi.navigateTabToUrl(tabId, phishingWarningPage);
}),
);
const onCancelCommand$ = messageListener
.messages$(PHISHING_DETECTION_CANCEL_COMMAND)
.pipe(switchMap((message) => BrowserApi.closeTab(message.tabId)));
const activeAccountHasAccess$ = combineLatest([
accountService.activeAccount$,
configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
])
]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account) {
logService.debug("[PhishingDetectionService] No active account.");
return of(false);
}
return billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => hasPremium && featureEnabled));
}),
);
const initSub = activeAccountHasAccess$
.pipe(
switchMap(([account, featureEnabled]) => {
if (!account) {
logService.info("[PhishingDetectionService] No active account.");
this._cleanup();
return EMPTY;
}
return billingAccountProfileStateService
.hasPremiumFromAnySource$(account.id)
.pipe(map((hasPremium) => ({ hasPremium, featureEnabled })));
}),
concatMap(async ({ hasPremium, featureEnabled }) => {
if (!hasPremium || !featureEnabled) {
logService.info(
distinctUntilChanged(),
switchMap((activeUserHasAccess) => {
if (!activeUserHasAccess) {
logService.debug(
"[PhishingDetectionService] User does not have access to phishing detection service.",
);
this._cleanup();
return EMPTY;
} else {
logService.info("[PhishingDetectionService] Enabling phishing detection service");
await this._setup();
logService.debug("[PhishingDetectionService] Enabling phishing detection service");
return merge(
phishingDataService.update$,
onContinueCommand$,
onTabUpdated$,
onCancelCommand$,
);
}
}),
)
.subscribe();
}
/**
* Sends a message to the phishing detection service to close the warning page
*/
static async requestClosePhishingWarningPage() {
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close });
}
this._didInit = true;
return () => {
initSub.unsubscribe();
this._didInit = false;
/**
* Sends a message to the phishing detection service to continue to the caught url
*/
static async requestContinueToDangerousUrl() {
await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue });
}
/**
* Continues to the dangerous URL if the user has requested it
*
* @param tabId The ID of the tab to continue to the dangerous URL
*/
static async _continueToDangerousUrl(tabId: PhishingDetectionTabId): Promise<void> {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab) {
this._logService.info(
"[PhishingDetectionService] Continuing to known phishing domain: ",
caughtTab,
caughtTab.url.href,
// Manually type cast to satisfy the listener signature due to the mixture
// of static and instance methods in this class. To be fixed when refactoring
// this class to be instance-based while providing a singleton instance in usage
BrowserApi.removeListener(
chrome.tabs.onUpdated,
PhishingDetectionService._handleTabUpdated as (...args: readonly unknown[]) => unknown,
);
await BrowserApi.navigateTabToUrl(tabId, caughtTab.url);
} else {
this._logService.warning("[PhishingDetectionService] No caught domain to continue to");
}
};
}
/**
* Sets up listeners for messages from the web page and web navigation events
*/
private static _setup(): void {
this._phishingDataService.update$.pipe(takeUntil(this._destroy$)).subscribe();
// Setup listeners from web page/content script
BrowserApi.addListener(chrome.runtime.onMessage, this._handleExtensionMessage.bind(this));
BrowserApi.addListener(chrome.tabs.onReplaced, this._handleReplacementEvent.bind(this));
BrowserApi.addListener(chrome.tabs.onUpdated, this._handleNavigationEvent.bind(this));
// When a navigation event occurs, check if a replace event for the same tabId exists,
// and call the replace handler before handling navigation.
this._navigationEventsSubject
.pipe(
delay(100), // Delay slightly to allow replace events to be caught
takeUntil(this._destroy$),
)
.subscribe(({ tabId, changeInfo, tab }) => {
void this._processNavigation(tabId, changeInfo, tab);
});
}
/**
* Handles messages from the phishing warning page
*
* @returns true if the message was handled, false otherwise
*/
private static _handleExtensionMessage(
message: unknown,
sender: chrome.runtime.MessageSender,
): boolean {
if (!isPhishingDetectionMessage(message)) {
return false;
}
const isValidSender = sender && sender.tab && sender.tab.id;
const senderTabId = isValidSender ? sender?.tab?.id : null;
// Only process messages from tab navigation
if (senderTabId == null) {
return false;
}
// Handle Dangerous Continue to Phishing Domain
if (message.command === PhishingDetectionMessage.Continue) {
this._logService.debug(
"[PhishingDetectionService] User requested continue to phishing domain on tab: ",
senderTabId,
);
this._setCaughtTabContinue(senderTabId);
void this._continueToDangerousUrl(senderTabId);
return true;
}
// Handle Close Phishing Warning Page
if (message.command === PhishingDetectionMessage.Close) {
this._logService.debug(
"[PhishingDetectionService] User requested to close phishing warning page on tab: ",
senderTabId,
);
void BrowserApi.closeTab(senderTabId);
this._removeCaughtTab(senderTabId);
return true;
}
return false;
}
/**
* Filter out navigation events that are to warning pages or not complete, check for phishing domains,
* then handle the navigation appropriately.
*/
private static async _processNavigation(
tabId: number,
changeInfo: chrome.tabs.OnUpdatedInfo,
tab: chrome.tabs.Tab,
): Promise<void> {
if (changeInfo.status !== "complete" || !tab.url) {
// Not a complete navigation or no URL to check
return;
}
// Check if navigating to a warning page to ignore
const isWarningPage = this._isWarningPage(tabId, tab.url);
if (isWarningPage) {
this._logService.debug(
`[PhishingDetectionService] Ignoring navigation to warning page for tab ${tabId}: ${tab.url}`,
);
return;
}
// Check if tab is navigating to a phishing url and handle navigation
await this._checkTabForPhishing(tabId, new URL(tab.url));
await this._handleTabNavigation(tabId);
}
private static _handleNavigationEvent(
private static _handleTabUpdated(
tabId: number,
changeInfo: chrome.tabs.OnUpdatedInfo,
tab: chrome.tabs.Tab,
): boolean {
this._navigationEventsSubject.next({ tabId, changeInfo, tab });
this._tabUpdated$.next({ tabId, changeInfo, tab });
// Return value for supporting BrowserApi event listener signature
return true;
}
/**
* Handles a replace event in Safari when redirecting to a warning page
*
* @returns true if the replacement was handled, false otherwise
*/
private static _handleReplacementEvent(newTabId: number, originalTabId: number): boolean {
if (this._caughtTabs.has(originalTabId)) {
this._logService.debug(
`[PhishingDetectionService] Handling original tab ${originalTabId} changing to new tab ${newTabId}`,
);
// Handle replacement
const originalCaughtTab = this._caughtTabs.get(originalTabId);
if (originalCaughtTab) {
this._caughtTabs.set(newTabId, originalCaughtTab);
this._caughtTabs.delete(originalTabId);
} else {
this._logService.debug(
`[PhishingDetectionService] Original caught tab not found, ignoring replacement.`,
);
}
return true;
}
return false;
}
/**
* Adds a tab to the caught tabs map with the requested continue status set to false
*
* @param tabId The ID of the tab that was caught
* @param url The URL of the tab that was caught
* @param redirectedTo The URL that the tab was redirected to
*/
private static _addCaughtTab(tabId: PhishingDetectionTabId, url: URL) {
const redirectedTo = this._createWarningPageUrl(url);
const newTab = { url, warningPageUrl: redirectedTo, requestedContinue: false };
this._caughtTabs.set(tabId, newTab);
this._logService.debug("[PhishingDetectionService] Tracking new tab:", tabId, newTab);
}
/**
* Removes a tab from the caught tabs map
*
* @param tabId The ID of the tab to remove
*/
private static _removeCaughtTab(tabId: PhishingDetectionTabId) {
this._logService.debug("[PhishingDetectionService] Removing tab from tracking: ", tabId);
this._caughtTabs.delete(tabId);
}
/**
* Sets the requested continue status for a caught tab
*
* @param tabId The ID of the tab to set the continue status for
*/
private static _setCaughtTabContinue(tabId: PhishingDetectionTabId) {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab) {
this._caughtTabs.set(tabId, {
url: caughtTab.url,
warningPageUrl: caughtTab.warningPageUrl,
requestedContinue: true,
});
}
}
/**
* Checks if the tab should continue to a dangerous domain
*
* @param tabId Tab to check if a domain was caught
* @returns True if the user requested to continue to the phishing domain
*/
private static _continueToCaughtDomain(tabId: PhishingDetectionTabId) {
const caughtDomain = this._caughtTabs.get(tabId);
const hasRequestedContinue = caughtDomain?.requestedContinue;
return caughtDomain && hasRequestedContinue;
}
/**
* Checks if the tab is going to a phishing domain and updates the caught tabs map
*
* @param tabId Tab to check for phishing domain
* @param url URL of the tab to check
*/
private static async _checkTabForPhishing(tabId: PhishingDetectionTabId, url: URL) {
// Check if the tab already being tracked
const caughtTab = this._caughtTabs.get(tabId);
const isPhishing = await this._phishingDataService.isPhishingDomain(url);
this._logService.debug(
`[PhishingDetectionService] Checking for phishing url. Result: ${isPhishing} on ${url}`,
);
// Add a new caught tab
if (!caughtTab && isPhishing) {
this._addCaughtTab(tabId, url);
}
// The tab was caught before but has an updated url
if (caughtTab && caughtTab.url.href !== url.href) {
if (isPhishing) {
this._logService.debug(
"[PhishingDetectionService] Caught tab going to a new phishing domain:",
caughtTab.url,
);
// The tab can be treated as a new tab, clear the old one and reset
this._removeCaughtTab(tabId);
this._addCaughtTab(tabId, url);
} else {
this._logService.debug(
"[PhishingDetectionService] Caught tab navigating away from a phishing domain",
);
// The tab is safe
this._removeCaughtTab(tabId);
}
}
}
/**
* Handles a phishing tab for redirection to a warning page if the user has not requested to continue
*
* @param tabId Tab to handle
* @param url URL of the tab
*/
private static async _handleTabNavigation(tabId: PhishingDetectionTabId) {
const caughtTab = this._caughtTabs.get(tabId);
if (caughtTab && !this._continueToCaughtDomain(tabId)) {
await this._redirectToWarningPage(tabId);
}
}
private static _isWarningPage(tabId: number, url: string): boolean {
const caughtTab = this._caughtTabs.get(tabId);
return !!caughtTab && caughtTab.warningPageUrl.href === url;
}
/**
* Constructs the phishing warning page URL with the caught URL as a query parameter
*
* @param caughtUrl The URL that was caught as phishing
* @returns The complete URL to the phishing warning page
*/
private static _createWarningPageUrl(caughtUrl: URL) {
const phishingWarningPage = BrowserApi.getRuntimeURL(
"popup/index.html#/security/phishing-warning",
);
const pageWithViewData = `${phishingWarningPage}?phishingHost=${caughtUrl.hostname}`;
this._logService.debug(
"[PhishingDetectionService] Created phishing warning page url:",
pageWithViewData,
);
return new URL(pageWithViewData);
}
/**
* Redirects the tab to the phishing warning page
*
* @param tabId The ID of the tab to redirect
*/
private static async _redirectToWarningPage(tabId: number) {
const tabToRedirect = this._caughtTabs.get(tabId);
if (tabToRedirect) {
this._logService.info("[PhishingDetectionService] Redirecting to warning page");
await BrowserApi.navigateTabToUrl(tabId, tabToRedirect.warningPageUrl);
} else {
this._logService.warning("[PhishingDetectionService] No caught tab found for redirection");
}
}
/**
* Cleans up the phishing detection service
* Unsubscribes from all subscriptions and clears caches
*/
private static _cleanup() {
this._destroy$.next();
this._destroy$.complete();
this._destroy$ = new Subject<void>();
this._caughtTabs.clear();
// Manually type cast to satisfy the listener signature due to the mixture
// of static and instance methods in this class. To be fixed when refactoring
// this class to be instance-based while providing a singleton instance in usage
BrowserApi.removeListener(
chrome.runtime.onMessage,
PhishingDetectionService._handleExtensionMessage as (...args: readonly unknown[]) => unknown,
);
BrowserApi.removeListener(
chrome.tabs.onReplaced,
PhishingDetectionService._handleReplacementEvent as (...args: readonly unknown[]) => unknown,
);
BrowserApi.removeListener(
chrome.tabs.onUpdated,
PhishingDetectionService._handleNavigationEvent as (...args: readonly unknown[]) => unknown,
private static _isExtensionPage(url: string): boolean {
// Check against all common extension protocols
return (
url.startsWith("chrome-extension://") ||
url.startsWith("moz-extension://") ||
url.startsWith("safari-extension://") ||
url.startsWith("safari-web-extension://")
);
}
}

View File

@@ -1,35 +0,0 @@
export const PhishingDetectionMessage = Object.freeze({
Close: "phishing-detection-close",
Continue: "phishing-detection-continue",
} as const);
export type PhishingDetectionMessageTypes =
(typeof PhishingDetectionMessage)[keyof typeof PhishingDetectionMessage];
export function isPhishingDetectionMessage(
input: unknown,
): input is { command: PhishingDetectionMessageTypes } {
if (!!input && typeof input === "object" && "command" in input) {
const command = (input as Record<string, unknown>)["command"];
if (typeof command === "string") {
return Object.values(PhishingDetectionMessage).includes(
command as PhishingDetectionMessageTypes,
);
}
}
return false;
}
export type PhishingDetectionTabId = number;
export type CaughtPhishingDomain = {
url: URL;
warningPageUrl: URL;
requestedContinue: boolean;
};
export type PhishingDetectionNavigationEvent = {
tabId: number;
changeInfo: chrome.tabs.OnUpdatedInfo;
tab: chrome.tabs.Tab;
};

View File

@@ -5,6 +5,7 @@ import { Observable } from "rxjs";
import { BrowserClientVendors } from "@bitwarden/common/autofill/constants";
import { BrowserClientVendor } from "@bitwarden/common/autofill/types";
import { DeviceType } from "@bitwarden/common/enums";
import { LogService } from "@bitwarden/logging";
import { isBrowserSafariApi } from "@bitwarden/platform";
import { TabMessage } from "../../types/tab-messages";
@@ -32,6 +33,53 @@ export class BrowserApi {
return BrowserApi.manifestVersion === expectedVersion;
}
/**
* Helper method that attempts to distinguish whether a message sender is internal to the extension or not.
*
* Currently this is done through source origin matching, and frameId checking (only top-level frames are internal).
* @param sender a message sender
* @param logger an optional logger to log validation results
* @returns whether or not the sender appears to be internal to the extension
*/
static senderIsInternal(
sender: chrome.runtime.MessageSender | undefined,
logger?: LogService,
): boolean {
if (!sender?.origin) {
logger?.warning("[BrowserApi] Message sender has no origin");
return false;
}
const extensionUrl =
(typeof chrome !== "undefined" && chrome.runtime?.getURL("")) ||
(typeof browser !== "undefined" && browser.runtime?.getURL("")) ||
"";
if (!extensionUrl) {
logger?.warning("[BrowserApi] Unable to determine extension URL");
return false;
}
// Normalize both URLs by removing trailing slashes
const normalizedOrigin = sender.origin.replace(/\/$/, "");
const normalizedExtensionUrl = extensionUrl.replace(/\/$/, "");
if (!normalizedOrigin.startsWith(normalizedExtensionUrl)) {
logger?.warning(
`[BrowserApi] Message sender origin (${normalizedOrigin}) does not match extension URL (${normalizedExtensionUrl})`,
);
return false;
}
// We only send messages from the top-level frame, but frameId is only set if tab is set, which for popups it is not.
if ("frameId" in sender && sender.frameId !== 0) {
logger?.warning("[BrowserApi] Message sender is not from the top-level frame");
return false;
}
logger?.info("[BrowserApi] Message sender appears to be internal");
return true;
}
/**
* Gets all open browser windows, including their tabs.
*

View File

@@ -43,6 +43,9 @@ export class LocalBackedSessionStorageService
if (port.name !== portName(chrome.storage.session)) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender, this.logService)) {
return;
}
this.ports.add(port);

View File

@@ -141,7 +141,9 @@ export class PopupViewCacheBackgroundService {
// on popup closed, with 2 minute delay that is cancelled by re-opening the popup
fromChromeEvent(chrome.runtime.onConnect)
.pipe(
filter(([port]) => port.name === popupClosedPortName),
filter(
([port]) => port.name === popupClosedPortName && BrowserApi.senderIsInternal(port.sender),
),
switchMap(([port]) =>
fromChromeEvent(port.onDisconnect).pipe(
delay(

View File

@@ -19,6 +19,25 @@ import {
import { BackgroundTaskSchedulerService } from "./background-task-scheduler.service";
function createInternalPortSpyMock(name: string) {
return mock<chrome.runtime.Port>({
name,
onMessage: {
addListener: jest.fn(),
removeListener: jest.fn(),
},
onDisconnect: {
addListener: jest.fn(),
},
postMessage: jest.fn(),
disconnect: jest.fn(),
sender: {
url: chrome.runtime.getURL(""),
origin: chrome.runtime.getURL(""),
},
});
}
describe("BackgroundTaskSchedulerService", () => {
let logService: MockProxy<LogService>;
let stateProvider: MockProxy<StateProvider>;
@@ -35,7 +54,7 @@ describe("BackgroundTaskSchedulerService", () => {
stateProvider = mock<StateProvider>({
getGlobal: jest.fn(() => globalStateMock),
});
portMock = createPortSpyMock(BrowserTaskSchedulerPortName);
portMock = createInternalPortSpyMock(BrowserTaskSchedulerPortName);
backgroundTaskSchedulerService = new BackgroundTaskSchedulerService(logService, stateProvider);
jest.spyOn(globalThis, "setTimeout");
});

View File

@@ -30,6 +30,9 @@ export class BackgroundTaskSchedulerService extends BrowserTaskSchedulerServiceI
if (port.name !== BrowserTaskSchedulerPortName) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender, this.logService)) {
return;
}
this.ports.add(port);
port.onMessage.addListener(this.handlePortMessage);

View File

@@ -18,6 +18,9 @@ export class BackgroundMemoryStorageService extends SerializedMemoryStorageServi
if (port.name !== portName(chrome.storage.session)) {
return;
}
if (!BrowserApi.senderIsInternal(port.sender)) {
return;
}
this._ports.push(port);

View File

@@ -10,7 +10,8 @@ import { mockPorts } from "../../../spec/mock-port.spec-util";
import { BackgroundMemoryStorageService } from "./background-memory-storage.service";
import { ForegroundMemoryStorageService } from "./foreground-memory-storage.service";
describe("foreground background memory storage interaction", () => {
// These are succeeding individually but failing in a batch run - skipping for now
describe.skip("foreground background memory storage interaction", () => {
let foreground: ForegroundMemoryStorageService;
let background: BackgroundMemoryStorageService;

View File

@@ -5,7 +5,7 @@
{{ "confirmAutofillDesc" | i18n }}
</p>
@if (savedUrls.length === 1) {
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-semibold">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-4 tw-font-medium">
{{ "savedWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="success" icon="bwi-globe">
@@ -16,14 +16,14 @@
}
@if (savedUrls.length > 1) {
<div class="tw-flex tw-justify-between tw-items-center tw-mt-4 tw-mb-1 tw-pt-2">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-semibold">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-font-medium">
{{ "savedWebsites" | i18n: savedUrls.length }}
</p>
<button
*ngIf="!savedUrlsExpanded"
type="button"
bitLink
class="tw-text-sm tw-font-bold tw-cursor-pointer"
class="tw-text-sm tw-font-medium tw-cursor-pointer"
(click)="viewAllSavedUrls()"
>
{{ "viewAll" | i18n }}
@@ -39,7 +39,7 @@
</div>
</div>
}
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-semibold">
<p class="tw-text-muted tw-text-xs tw-uppercase tw-mt-5 tw-font-medium">
{{ "currentWebsite" | i18n }}
</p>
<bit-callout [title]="null" type="warning" icon="bwi-globe">
@@ -61,7 +61,7 @@
bitLink
linkType="secondary"
(click)="close()"
class="tw-mt-2 tw-font-bold tw-text-sm tw-justify-center tw-text-center"
class="tw-mt-2 tw-font-medium tw-text-sm tw-justify-center tw-text-center"
>
{{ "doNotAutofill" | i18n }}
</button>

View File

@@ -199,7 +199,7 @@ describe("AutofillConfirmationDialogComponent", () => {
it("shows the 'view all' button when savedUrls > 1 and hides it after click", () => {
const findViewAll = () =>
fixture.nativeElement.querySelector(
"button.tw-text-sm.tw-font-bold.tw-cursor-pointer",
"button.tw-text-sm.tw-font-medium.tw-cursor-pointer",
) as HTMLButtonElement | null;
let btn = findViewAll();

View File

@@ -144,6 +144,15 @@ describe("ItemMoreOptionsComponent", () => {
}
describe("doAutofill", () => {
it("calls the passwordService to passwordRepromptCheck", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
});
it("calls the autofill service to autofill without showing the confirmation dialog when the feature flag is disabled or search text is not present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
@@ -160,15 +169,6 @@ describe("ItemMoreOptionsComponent", () => {
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
});
it("calls the passwordService to passwordRepromptCheck", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
});
it("does nothing if the user fails master password reprompt", async () => {
baseCipher.reprompt = 2; // Master Password reprompt enabled
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
@@ -199,6 +199,15 @@ describe("ItemMoreOptionsComponent", () => {
passwordRepromptService.passwordRepromptCheck.mockResolvedValue(true);
});
it("calls the passwordService to passwordRepromptCheck", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
});
it("opens the autofill confirmation dialog with filtered saved URLs when the feature flag is enabled and search text is present", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com/path" });
const openSpy = mockConfirmDialogResult(AutofillConfirmationDialogResult.Canceled);
@@ -259,7 +268,16 @@ describe("ItemMoreOptionsComponent", () => {
uriMatchStrategy$.next(UriMatchStrategy.Exact);
});
it("shows the exact match dialog and not the password dialog", async () => {
it("calls the passwordService to passwordRepromptCheck", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://page.example.com" });
mockConfirmDialogResult(AutofillConfirmationDialogResult.AutofilledOnly);
await component.doAutofill();
expect(passwordRepromptService.passwordRepromptCheck).toHaveBeenCalledWith(baseCipher);
});
it("shows the exact match dialog", async () => {
autofillSvc.currentAutofillTab$.next({ url: "https://no-match.example.com" });
await component.doAutofill();
@@ -273,7 +291,6 @@ describe("ItemMoreOptionsComponent", () => {
}),
);
expect(autofillSvc.doAutofill).not.toHaveBeenCalled();
expect(passwordRepromptService.passwordRepromptCheck).not.toHaveBeenCalled();
expect(autofillSvc.doAutofillAndSave).not.toHaveBeenCalled();
});
});

View File

@@ -202,6 +202,10 @@ export class ItemMoreOptionsComponent {
async doAutofill() {
const cipher = await this.cipherService.getFullCipherView(this.cipher);
if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) {
return;
}
const uris = cipher.login?.uris ?? [];
const cipherHasAllExactMatchLoginUris =
uris.length > 0 && uris.every((u) => u.uri && u.match === UriMatchStrategy.Exact);
@@ -223,10 +227,6 @@ export class ItemMoreOptionsComponent {
return;
}
if (!(await this.passwordRepromptService.passwordRepromptCheck(this.cipher))) {
return;
}
if (!showAutofillConfirmation) {
await this.vaultPopupAutofillService.doAutofill(cipher, true, true);
return;
@@ -291,7 +291,7 @@ export class ItemMoreOptionsComponent {
this.toastService.showToast({
variant: "success",
message: this.i18nService.t(
this.cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
cipher.favorite ? "itemAddedToFavorites" : "itemRemovedFromFavorites",
),
});
}

View File

@@ -84,7 +84,7 @@
<bit-item-group>
<ng-container *ngFor="let group of cipherGroups()">
<ng-container *ngIf="group.subHeaderKey">
<h3 class="tw-text-muted tw-text-xs tw-font-semibold tw-pl-1 tw-mb-1 bit-compact:tw-m-0">
<h3 class="tw-text-muted tw-text-xs tw-font-medium tw-pl-1 tw-mb-1 bit-compact:tw-m-0">
{{ group.subHeaderKey | i18n }}
</h3>
</ng-container>

View File

@@ -330,6 +330,7 @@ export class ViewV2Component {
const tab = await BrowserApi.getTab(senderTabId);
await sendExtensionMessage("bgHandleReprompt", {
tab,
cipherId: cipher.id,
success: repromptSuccess,
});

View File

@@ -41,7 +41,7 @@
<bit-label>{{ "showAnimations" | i18n }}</bit-label>
</bit-form-control>
</bit-card>
<h2 bitTypography="h6" class="tw-font-bold tw-mt-4">{{ "vaultCustomization" | i18n }}</h2>
<h2 bitTypography="h6" class="tw-font-medium tw-mt-4">{{ "vaultCustomization" | i18n }}</h2>
<bit-card>
<bit-form-control>
<input bitCheckbox formControlName="enableFavicon" type="checkbox" />

View File

@@ -1,6 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, switchMap } from "rxjs";
import { filter, firstValueFrom, map, switchMap } from "rxjs";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -448,7 +448,9 @@ export class GetCommand extends DownloadCommand {
this.collectionService.encryptedCollections$(activeUserId).pipe(getById(id)),
);
if (collection != null) {
const orgKeys = await firstValueFrom(this.keyService.activeUserOrgKeys$);
const orgKeys = await firstValueFrom(
this.keyService.orgKeys$(activeUserId).pipe(filter((orgKeys) => orgKeys != null)),
);
decCollection = await collection.decrypt(
orgKeys[collection.organizationId as OrganizationId],
this.encryptService,

View File

@@ -444,8 +444,10 @@ dependencies = [
name = "bitwarden_chromium_import_helper"
version = "0.0.0"
dependencies = [
"aes-gcm",
"anyhow",
"base64",
"chacha20poly1305",
"chromium_importer",
"clap",
"embed-resource",
@@ -606,7 +608,6 @@ dependencies = [
"async-trait",
"base64",
"cbc",
"chacha20poly1305",
"dirs",
"hex",
"oo7",
@@ -619,7 +620,6 @@ dependencies = [
"sha1",
"tokio",
"tracing",
"tracing-subscriber",
"verifysign",
"windows 0.61.1",
]

View File

@@ -20,6 +20,7 @@ publish = false
[workspace.dependencies]
aes = "=0.8.4"
aes-gcm = "=0.10.3"
anyhow = "=1.0.94"
arboard = { version = "=3.6.0", default-features = false }
ashpd = "=0.11.0"

View File

@@ -8,23 +8,14 @@ publish.workspace = true
[dependencies]
[target.'cfg(target_os = "windows")'.dependencies]
aes-gcm = { workspace = true }
chacha20poly1305 = { workspace = true }
chromium_importer = { path = "../chromium_importer" }
clap = { version = "=4.5.40", features = ["derive"] }
scopeguard = { workspace = true }
sysinfo = { workspace = true }
windows = { workspace = true, features = [
"Wdk_System_SystemServices",
"Win32_Security_Cryptography",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_ProcessStatus",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
anyhow = { workspace = true }
base64 = { workspace = true }

View File

@@ -1,482 +0,0 @@
mod windows_binary {
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use clap::Parser;
use scopeguard::defer;
use std::{
ffi::OsString,
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
path::{Path, PathBuf},
ptr,
time::Duration,
};
use sysinfo::System;
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
time,
};
use tracing::{debug, error, level_filters::LevelFilter};
use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
use windows::{
core::BOOL,
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
Win32::{
Foundation::{
CloseHandle, LocalFree, ERROR_PIPE_BUSY, HANDLE, HLOCAL, NTSTATUS, STATUS_SUCCESS,
},
Security::{
self,
Cryptography::{CryptUnprotectData, CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB},
DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
TOKEN_QUERY,
},
System::{
Pipes::GetNamedPipeServerProcessId,
Threading::{
OpenProcess, OpenProcessToken, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
},
},
UI::Shell::IsUserAnAdmin,
},
};
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
#[derive(Parser)]
#[command(name = "bitwarden_chromium_import_helper")]
#[command(about = "Admin tool for ABE service management")]
struct Args {
/// Base64 encoded encrypted data to process
#[arg(long, help = "Base64 encoded encrypted data string")]
encrypted: String,
}
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
// This is intended for development time only. All the logging is wrapped in `dbg_log!`` macro that compiles to
// no-op when logging is disabled. This is needed to avoid any sensitive data being logged in production. Normally
// all the logging code is present in the release build and could be enabled via RUST_LOG environment variable.
// We don't want that!
const ENABLE_DEVELOPER_LOGGING: bool = false;
const LOG_FILENAME: &str = "c:\\path\\to\\log.txt"; // This is an example filename, replace it with you own
// This should be enabled for production
const ENABLE_SERVER_SIGNATURE_VALIDATION: bool = true;
// List of SYSTEM process names to try to impersonate
const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];
// Macro wrapper around debug! that compiles to no-op when ENABLE_DEVELOPER_LOGGING is false
macro_rules! dbg_log {
($($arg:tt)*) => {
if ENABLE_DEVELOPER_LOGGING {
debug!($($arg)*);
}
};
}
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
let max_attempts = 5;
for _ in 0..max_attempts {
match ClientOptions::new().open(pipe_name) {
Ok(client) => {
dbg_log!("Successfully connected to the pipe!");
return Ok(client);
}
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
dbg_log!("Pipe is busy, retrying in 50ms...");
}
Err(e) => {
dbg_log!("Failed to connect to pipe: {}", &e);
return Err(e.into());
}
}
time::sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"Failed to connect to pipe after {} attempts",
max_attempts
))
}
async fn send_message_with_client(
client: &mut NamedPipeClient,
message: &str,
) -> Result<String> {
client.write_all(message.as_bytes()).await?;
// Try to receive a response for this message
let mut buffer = vec![0u8; 64 * 1024];
match client.read(&mut buffer).await {
Ok(0) => Err(anyhow!(
"Server closed the connection (0 bytes read) on message"
)),
Ok(bytes_received) => {
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
Ok(response.to_string())
}
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
}
}
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
let handle = HANDLE(client.as_raw_handle() as _);
let mut pid: u32 = 0;
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
Ok(pid)
}
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
dbg_log!("Resolving process executable path for PID {}", pid);
// Open the process handle
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
dbg_log!("Opened process handle for PID {}", pid);
// Close when no longer needed
defer! {
dbg_log!("Closing process handle for PID {}", pid);
unsafe {
_ = CloseHandle(hprocess);
}
};
let mut exe_name = vec![0u16; 32 * 1024];
let mut exe_name_length = exe_name.len() as u32;
unsafe {
QueryFullProcessImageNameW(
hprocess,
PROCESS_NAME_WIN32,
windows::core::PWSTR(exe_name.as_mut_ptr()),
&mut exe_name_length,
)
}?;
dbg_log!(
"QueryFullProcessImageNameW returned {} bytes",
exe_name_length
);
exe_name.truncate(exe_name_length as usize);
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
}
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
_ = send_to_user(client, &format!("!{}", error_message)).await
}
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
let _ = send_message_with_client(client, message).await?;
Ok(())
}
fn is_admin() -> bool {
unsafe { IsUserAnAdmin().as_bool() }
}
fn decrypt_data_base64(data_base64: &str, expect_appb: bool) -> Result<String> {
dbg_log!("Decrypting data base64: {}", data_base64);
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
dbg_log!("Failed to decode base64: {} APPB: {}", e, expect_appb);
e
})?;
let decrypted = decrypt_data(&data, expect_appb)?;
let decrypted_base64 = general_purpose::STANDARD.encode(decrypted);
Ok(decrypted_base64)
}
fn decrypt_data(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
if expect_appb && !data.starts_with(b"APPB") {
dbg_log!("Decoded data does not start with 'APPB'");
return Err(anyhow!("Decoded data does not start with 'APPB'"));
}
let data = if expect_appb { &data[4..] } else { data };
let in_blob = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB {
cbData: 0,
pbData: ptr::null_mut(),
};
let result = unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
let decrypted = unsafe {
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
};
// Free the memory allocated by CryptUnprotectData
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
Ok(decrypted)
} else {
dbg_log!("CryptUnprotectData failed");
Err(anyhow!("CryptUnprotectData failed"))
}
}
//
// Impersonate a SYSTEM process
//
fn start_impersonating() -> Result<HANDLE> {
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
enable_debug_privilege()?;
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
// Impersonate the SYSTEM process
unsafe {
ImpersonateLoggedOnUser(token)?;
};
dbg_log!("Impersonating system process '{}' (PID: {})", name, pid);
Ok(token)
}
fn stop_impersonating(token: HANDLE) -> Result<()> {
unsafe {
RevertToSelf()?;
CloseHandle(token)?;
};
Ok(())
}
fn find_system_process_with_token(
pids: Vec<(u32, &'static str)>,
) -> Result<(HANDLE, u32, &'static str)> {
for (pid, name) in pids {
match get_system_token_from_pid(pid) {
Err(_) => {
dbg_log!(
"Failed to open process handle '{}' (PID: {}), skipping",
name,
pid
);
continue;
}
Ok(system_handle) => {
return Ok((system_handle, pid, name));
}
}
}
Err(anyhow!("Failed to get system token from any process"))
}
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
let handle = get_process_handle(pid)?;
let token = get_system_token(handle)?;
unsafe {
CloseHandle(handle)?;
};
Ok(token)
}
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
let token_handle = unsafe {
let mut token_handle = HANDLE::default();
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
token_handle
};
let duplicate_token = unsafe {
let mut duplicate_token = HANDLE::default();
DuplicateToken(
token_handle,
Security::SECURITY_IMPERSONATION_LEVEL(2),
&mut duplicate_token,
)?;
CloseHandle(token_handle)?;
duplicate_token
};
Ok(duplicate_token)
}
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
let sys = System::new_all();
SYSTEM_PROCESS_NAMES
.iter()
.flat_map(|&name| {
sys.processes_by_exact_name(name.as_ref())
.map(move |process| (process.pid().as_u32(), name))
})
.collect()
}
fn get_process_handle(pid: u32) -> Result<HANDLE> {
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
Ok(hprocess)
}
#[link(name = "ntdll")]
unsafe extern "system" {
unsafe fn RtlAdjustPrivilege(
privilege: i32,
enable: BOOL,
current_thread: BOOL,
previous_value: *mut BOOL,
) -> NTSTATUS;
}
fn enable_debug_privilege() -> Result<()> {
let mut previous_value = BOOL(0);
let status = unsafe {
dbg_log!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
};
match status {
STATUS_SUCCESS => {
dbg_log!(
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
previous_value.as_bool()
);
Ok(())
}
_ => {
dbg_log!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
Err(anyhow!("Failed to adjust privilege"))
}
}
}
//
// Pipe
//
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
let client = open_pipe_client(pipe_name).await?;
if ENABLE_SERVER_SIGNATURE_VALIDATION {
let server_pid = get_named_pipe_server_pid(&client)?;
dbg_log!("Connected to pipe server PID {}", server_pid);
// Validate the server end process signature
let exe_path = resolve_process_executable_path(server_pid)?;
dbg_log!("Pipe server executable path: {}", exe_path.display());
if !verify_signature(&exe_path)? {
return Err(anyhow!("Pipe server signature is not valid"));
}
dbg_log!("Pipe server signature verified for PID {}", server_pid);
}
Ok(client)
}
fn run() -> Result<String> {
dbg_log!("Starting bitwarden_chromium_import_helper.exe");
let args = Args::try_parse()?;
if !is_admin() {
return Err(anyhow!("Expected to run with admin privileges"));
}
dbg_log!("Running as ADMINISTRATOR");
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
let system_decrypted_base64 = {
let system_token = start_impersonating()?;
defer! {
dbg_log!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
let system_decrypted_base64 = decrypt_data_base64(&args.encrypted, true)?;
dbg_log!("Decrypted data with system");
system_decrypted_base64
};
// This is just to check that we're decrypting Chrome keys and not something else sent to us by a malicious actor.
// Now that we're back from SYSTEM, we need to decrypt one more time just to verify.
// Chrome keys are double encrypted: once at SYSTEM level and once at USER level.
// When the decryption fails, it means that we're decrypting something unexpected.
// We don't send this result back since the library will decrypt again at USER level.
_ = decrypt_data_base64(&system_decrypted_base64, false).map_err(|e| {
dbg_log!("User level decryption check failed: {}", e);
e
})?;
dbg_log!("User level decryption check passed");
Ok(system_decrypted_base64)
}
fn init_logging(log_path: &Path, file_level: LevelFilter) {
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
match std::fs::File::create(log_path) {
Ok(file) => {
let file_filter = EnvFilter::builder()
.with_default_directive(file_level.into())
.from_env_lossy();
let file_layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.with_filter(file_filter);
tracing_subscriber::registry().with(file_layer).init();
}
Err(error) => {
error!(%error, ?log_path, "Could not create log file.");
}
}
}
pub(crate) async fn main() {
if ENABLE_DEVELOPER_LOGGING {
init_logging(LOG_FILENAME.as_ref(), LevelFilter::DEBUG);
}
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
Ok(client) => client,
Err(e) => {
error!(
"Failed to open pipe {} to send result/error: {}",
ADMIN_TO_USER_PIPE_NAME, e
);
return;
}
};
match run() {
Ok(system_decrypted_base64) => {
dbg_log!("Sending response back to user");
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
}
Err(e) => {
dbg_log!("Error: {}", e);
send_error_to_user(&mut client, &format!("{}", e)).await;
}
}
}
}
pub(crate) use windows_binary::*;

View File

@@ -0,0 +1,2 @@
// List of SYSTEM process names to try to impersonate
pub(crate) const SYSTEM_PROCESS_NAMES: [&str; 2] = ["services.exe", "winlogon.exe"];

View File

@@ -0,0 +1,312 @@
use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit};
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose, Engine as _};
use chacha20poly1305::ChaCha20Poly1305;
use scopeguard::defer;
use tracing::debug;
use windows::{
core::w,
Win32::{
Foundation::{LocalFree, HLOCAL},
Security::Cryptography::{
self, CryptUnprotectData, NCryptOpenKey, NCryptOpenStorageProvider, CERT_KEY_SPEC,
CRYPTPROTECT_UI_FORBIDDEN, CRYPT_INTEGER_BLOB, NCRYPT_FLAGS, NCRYPT_KEY_HANDLE,
NCRYPT_PROV_HANDLE, NCRYPT_SILENT_FLAG,
},
},
};
use super::impersonate::{start_impersonating, stop_impersonating};
//
// Base64
//
pub(crate) fn decode_base64(data_base64: &str) -> Result<Vec<u8>> {
debug!("Decoding base64 data: {}", data_base64);
let data = general_purpose::STANDARD.decode(data_base64).map_err(|e| {
debug!("Failed to decode base64: {}", e);
e
})?;
Ok(data)
}
pub(crate) fn encode_base64(data: &[u8]) -> String {
general_purpose::STANDARD.encode(data)
}
//
// DPAPI decryption
//
pub(crate) fn decrypt_with_dpapi_as_system(encrypted: &[u8]) -> Result<Vec<u8>> {
// Impersonate a SYSTEM process to be able to decrypt data encrypted for the machine
let system_token = start_impersonating()?;
defer! {
debug!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
decrypt_with_dpapi_as_user(encrypted, true)
}
pub(crate) fn decrypt_with_dpapi_as_user(encrypted: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
let system_decrypted = decrypt_with_dpapi(encrypted, expect_appb)?;
debug!(
"Decrypted data with SYSTEM {} bytes",
system_decrypted.len()
);
Ok(system_decrypted)
}
fn decrypt_with_dpapi(data: &[u8], expect_appb: bool) -> Result<Vec<u8>> {
if expect_appb && (data.len() < 5 || !data.starts_with(b"APPB")) {
const ERR_MSG: &str = "Ciphertext is too short or does not start with 'APPB'";
debug!("{}", ERR_MSG);
return Err(anyhow!(ERR_MSG));
}
let data = if expect_appb { &data[4..] } else { data };
let in_blob = CRYPT_INTEGER_BLOB {
cbData: data.len() as u32,
pbData: data.as_ptr() as *mut u8,
};
let mut out_blob = CRYPT_INTEGER_BLOB::default();
let result = unsafe {
CryptUnprotectData(
&in_blob,
None,
None,
None,
None,
CRYPTPROTECT_UI_FORBIDDEN,
&mut out_blob,
)
};
if result.is_ok() && !out_blob.pbData.is_null() && out_blob.cbData > 0 {
let decrypted = unsafe {
std::slice::from_raw_parts(out_blob.pbData, out_blob.cbData as usize).to_vec()
};
// Free the memory allocated by CryptUnprotectData
unsafe { LocalFree(Some(HLOCAL(out_blob.pbData as *mut _))) };
Ok(decrypted)
} else {
debug!("CryptUnprotectData failed");
Err(anyhow!("CryptUnprotectData failed"))
}
}
//
// Chromium key decoding
//
pub(crate) fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
// Parse and skip the header
let header_len = u32::from_le_bytes(get_safe(blob_data, 0, 4)?.try_into()?) as usize;
debug!("ABE key blob header length: {}", header_len);
// Parse content length
let content_len_offset = 4 + header_len;
let content_len =
u32::from_le_bytes(get_safe(blob_data, content_len_offset, 4)?.try_into()?) as usize;
debug!("ABE key blob content length: {}", content_len);
if content_len < 32 {
return Err(anyhow!(
"Corrupted ABE key blob: content length is less than 32"
));
}
let content_offset = content_len_offset + 4;
let content = get_safe(blob_data, content_offset, content_len)?;
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
if content_len == 32 {
return Ok(content.to_vec());
}
let version = content[0];
debug!("ABE key blob version: {}", version);
let key_blob = &content[1..];
match version {
// Google Chrome v1 key encrypted with a hardcoded AES key
1_u8 => decrypt_abe_key_blob_chrome_aes(key_blob),
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
2_u8 => decrypt_abe_key_blob_chrome_chacha20(key_blob),
// Google Chrome v3 key encrypted with CNG APIs
3_u8 => decrypt_abe_key_blob_chrome_cng(key_blob),
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
}
}
fn get_safe(data: &[u8], start: usize, len: usize) -> Result<&[u8]> {
let end = start + len;
data.get(start..end).ok_or_else(|| {
anyhow!(
"Corrupted ABE key blob: expected bytes {}..{}, got {}",
start,
end,
data.len()
)
})
}
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
const GOOGLE_AES_KEY: &[u8] = &[
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93, 0x66,
0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D, 0xA0, 0x28,
0x47, 0x87,
];
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
let cipher = Aes256Gcm::new(aes_key);
decrypt_abe_key_blob_with_aead(blob, &cipher, "v1 (AES flavor)")
}
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
const GOOGLE_CHACHA20_KEY: &[u8] = &[
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25, 0x80,
0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D, 0x08, 0x72,
0x96, 0x60,
];
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
let cipher = ChaCha20Poly1305::new(chacha20_key);
decrypt_abe_key_blob_with_aead(blob, &cipher, "v2 (ChaCha20 flavor)")
}
fn decrypt_abe_key_blob_with_aead<C>(blob: &[u8], cipher: &C, version: &str) -> Result<Vec<u8>>
where
C: Aead,
{
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let iv = &blob[0..12];
let ciphertext = &blob[12..12 + 48];
debug!("Google ABE {} detected: {:?} {:?}", version, iv, ciphertext);
let decrypted = cipher
.decrypt(iv.into(), ciphertext)
.map_err(|e| anyhow!("Failed to decrypt v20 key with {}: {}", version, e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 92 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
blob.len()
));
}
let encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
let iv: [u8; 12] = blob[32..32 + 12].try_into()?;
let ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
debug!(
"Google ABE v3 (CNG flavor) detected: {:?} {:?} {:?}",
encrypted_aes_key, iv, ciphertext
);
// First, decrypt the AES key with CNG API
let decrypted_aes_key: Vec<u8> = {
let system_token = start_impersonating()?;
defer! {
debug!("Stopping impersonation");
_ = stop_impersonating(system_token);
}
decrypt_with_cng(&encrypted_aes_key)?
};
const GOOGLE_XOR_KEY: [u8; 32] = [
0xCC, 0xF8, 0xA1, 0xCE, 0xC5, 0x66, 0x05, 0xB8, 0x51, 0x75, 0x52, 0xBA, 0x1A, 0x2D, 0x06,
0x1C, 0x03, 0xA2, 0x9E, 0x90, 0x27, 0x4F, 0xB2, 0xFC, 0xF5, 0x9B, 0xA4, 0xB7, 0x5C, 0x39,
0x23, 0x90,
];
// XOR the decrypted AES key with the hardcoded key
let aes_key: Vec<u8> = decrypted_aes_key
.into_iter()
.zip(GOOGLE_XOR_KEY)
.map(|(a, b)| a ^ b)
.collect();
// Decrypt the actual ABE key with the decrypted AES key
let cipher = Aes256Gcm::new(aes_key.as_slice().into());
let key = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with AES-GCM: {}", e))?;
Ok(key)
}
fn decrypt_with_cng(ciphertext: &[u8]) -> Result<Vec<u8>> {
// 1. Open the cryptographic provider
let mut provider = NCRYPT_PROV_HANDLE::default();
unsafe {
NCryptOpenStorageProvider(
&mut provider,
w!("Microsoft Software Key Storage Provider"),
0,
)?;
};
// Don't forget to free the provider
defer!(unsafe {
_ = Cryptography::NCryptFreeObject(provider.into());
});
// 2. Open the key
let mut key = NCRYPT_KEY_HANDLE::default();
unsafe {
NCryptOpenKey(
provider,
&mut key,
w!("Google Chromekey1"),
CERT_KEY_SPEC::default(),
NCRYPT_FLAGS::default(),
)?;
};
// Don't forget to free the key
defer!(unsafe {
_ = Cryptography::NCryptFreeObject(key.into());
});
// 3. Decrypt the data (assume the plaintext is not larger than the ciphertext)
let mut plaintext = vec![0; ciphertext.len()];
let mut plaintext_len = 0;
unsafe {
Cryptography::NCryptDecrypt(
key,
ciphertext.into(),
None,
Some(&mut plaintext),
&mut plaintext_len,
NCRYPT_SILENT_FLAG,
)?;
};
// In case the plaintext is smaller than the ciphertext
plaintext.truncate(plaintext_len as usize);
Ok(plaintext)
}

View File

@@ -0,0 +1,139 @@
use anyhow::{anyhow, Result};
use sysinfo::System;
use tracing::debug;
use windows::{
core::BOOL,
Wdk::System::SystemServices::SE_DEBUG_PRIVILEGE,
Win32::{
Foundation::{CloseHandle, HANDLE, NTSTATUS, STATUS_SUCCESS},
Security::{
self, DuplicateToken, ImpersonateLoggedOnUser, RevertToSelf, TOKEN_DUPLICATE,
TOKEN_QUERY,
},
System::Threading::{OpenProcess, OpenProcessToken, PROCESS_QUERY_LIMITED_INFORMATION},
},
};
use super::config::SYSTEM_PROCESS_NAMES;
#[link(name = "ntdll")]
unsafe extern "system" {
unsafe fn RtlAdjustPrivilege(
privilege: i32,
enable: BOOL,
current_thread: BOOL,
previous_value: *mut BOOL,
) -> NTSTATUS;
}
pub(crate) fn start_impersonating() -> Result<HANDLE> {
// Need to enable SE_DEBUG_PRIVILEGE to enumerate and open SYSTEM processes
enable_debug_privilege()?;
// Find a SYSTEM process and get its token. Not every SYSTEM process allows token duplication, so try several.
let (token, pid, name) = find_system_process_with_token(get_system_pid_list())?;
// Impersonate the SYSTEM process
unsafe {
ImpersonateLoggedOnUser(token)?;
};
debug!("Impersonating system process '{}' (PID: {})", name, pid);
Ok(token)
}
pub(crate) fn stop_impersonating(token: HANDLE) -> Result<()> {
unsafe {
RevertToSelf()?;
CloseHandle(token)?;
};
Ok(())
}
fn find_system_process_with_token(
pids: Vec<(u32, &'static str)>,
) -> Result<(HANDLE, u32, &'static str)> {
for (pid, name) in pids {
match get_system_token_from_pid(pid) {
Err(_) => {
debug!(
"Failed to open process handle '{}' (PID: {}), skipping",
name, pid
);
continue;
}
Ok(system_handle) => {
return Ok((system_handle, pid, name));
}
}
}
Err(anyhow!("Failed to get system token from any process"))
}
fn get_system_token_from_pid(pid: u32) -> Result<HANDLE> {
let handle = get_process_handle(pid)?;
let token = get_system_token(handle)?;
unsafe {
CloseHandle(handle)?;
};
Ok(token)
}
fn get_system_token(handle: HANDLE) -> Result<HANDLE> {
let token_handle = unsafe {
let mut token_handle = HANDLE::default();
OpenProcessToken(handle, TOKEN_DUPLICATE | TOKEN_QUERY, &mut token_handle)?;
token_handle
};
let duplicate_token = unsafe {
let mut duplicate_token = HANDLE::default();
DuplicateToken(
token_handle,
Security::SECURITY_IMPERSONATION_LEVEL(2),
&mut duplicate_token,
)?;
CloseHandle(token_handle)?;
duplicate_token
};
Ok(duplicate_token)
}
fn get_system_pid_list() -> Vec<(u32, &'static str)> {
let sys = System::new_all();
SYSTEM_PROCESS_NAMES
.iter()
.flat_map(|&name| {
sys.processes_by_exact_name(name.as_ref())
.map(move |process| (process.pid().as_u32(), name))
})
.collect()
}
fn get_process_handle(pid: u32) -> Result<HANDLE> {
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
Ok(hprocess)
}
fn enable_debug_privilege() -> Result<()> {
let mut previous_value = BOOL(0);
let status = unsafe {
debug!("Setting SE_DEBUG_PRIVILEGE to 1 via RtlAdjustPrivilege");
RtlAdjustPrivilege(SE_DEBUG_PRIVILEGE, BOOL(1), BOOL(0), &mut previous_value)
};
match status {
STATUS_SUCCESS => {
debug!(
"SE_DEBUG_PRIVILEGE set to 1, was {} before",
previous_value.as_bool()
);
Ok(())
}
_ => {
debug!("RtlAdjustPrivilege failed with status: 0x{:X}", status.0);
Err(anyhow!("Failed to adjust privilege"))
}
}
}

View File

@@ -0,0 +1,29 @@
use tracing::{error, level_filters::LevelFilter};
use tracing_subscriber::{
fmt, layer::SubscriberExt as _, util::SubscriberInitExt as _, EnvFilter, Layer as _,
};
use chromium_importer::config::{ENABLE_DEVELOPER_LOGGING, LOG_FILENAME};
pub(crate) fn init_logging() {
if ENABLE_DEVELOPER_LOGGING {
// We only log to a file. It's impossible to see stdout/stderr when this exe is launched from ShellExecuteW.
match std::fs::File::create(LOG_FILENAME) {
Ok(file) => {
let file_filter = EnvFilter::builder()
.with_default_directive(LevelFilter::DEBUG.into())
.from_env_lossy();
let file_layer = fmt::layer()
.with_writer(file)
.with_ansi(false)
.with_filter(file_filter);
tracing_subscriber::registry().with(file_layer).init();
}
Err(error) => {
error!(%error, ?LOG_FILENAME, "Could not create log file.");
}
}
}
}

View File

@@ -0,0 +1,225 @@
use anyhow::{anyhow, Result};
use clap::Parser;
use scopeguard::defer;
use std::{
ffi::OsString,
os::windows::{ffi::OsStringExt as _, io::AsRawHandle},
path::PathBuf,
time::Duration,
};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::windows::named_pipe::{ClientOptions, NamedPipeClient},
time,
};
use tracing::{debug, error};
use windows::Win32::{
Foundation::{CloseHandle, ERROR_PIPE_BUSY, HANDLE},
System::{
Pipes::GetNamedPipeServerProcessId,
Threading::{
OpenProcess, QueryFullProcessImageNameW, PROCESS_NAME_WIN32,
PROCESS_QUERY_LIMITED_INFORMATION,
},
},
UI::Shell::IsUserAnAdmin,
};
use chromium_importer::chromium::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
use super::{
crypto::{
decode_abe_key_blob, decode_base64, decrypt_with_dpapi_as_system,
decrypt_with_dpapi_as_user, encode_base64,
},
log::init_logging,
};
#[derive(Parser)]
#[command(name = "bitwarden_chromium_import_helper")]
#[command(about = "Admin tool for ABE service management")]
struct Args {
#[arg(long, help = "Base64 encoded encrypted data string")]
encrypted: String,
}
async fn open_pipe_client(pipe_name: &'static str) -> Result<NamedPipeClient> {
let max_attempts = 5;
for _ in 0..max_attempts {
match ClientOptions::new().open(pipe_name) {
Ok(client) => {
debug!("Successfully connected to the pipe!");
return Ok(client);
}
Err(e) if e.raw_os_error() == Some(ERROR_PIPE_BUSY.0 as i32) => {
debug!("Pipe is busy, retrying in 50ms...");
}
Err(e) => {
debug!("Failed to connect to pipe: {}", &e);
return Err(e.into());
}
}
time::sleep(Duration::from_millis(50)).await;
}
Err(anyhow!(
"Failed to connect to pipe after {} attempts",
max_attempts
))
}
async fn send_message_with_client(client: &mut NamedPipeClient, message: &str) -> Result<String> {
client.write_all(message.as_bytes()).await?;
// Try to receive a response for this message
let mut buffer = vec![0u8; 64 * 1024];
match client.read(&mut buffer).await {
Ok(0) => Err(anyhow!(
"Server closed the connection (0 bytes read) on message"
)),
Ok(bytes_received) => {
let response = String::from_utf8_lossy(&buffer[..bytes_received]);
Ok(response.to_string())
}
Err(e) => Err(anyhow!("Failed to receive response for message: {}", e)),
}
}
fn get_named_pipe_server_pid(client: &NamedPipeClient) -> Result<u32> {
let handle = HANDLE(client.as_raw_handle() as _);
let mut pid: u32 = 0;
unsafe { GetNamedPipeServerProcessId(handle, &mut pid) }?;
Ok(pid)
}
fn resolve_process_executable_path(pid: u32) -> Result<PathBuf> {
debug!("Resolving process executable path for PID {}", pid);
// Open the process handle
let hprocess = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }?;
debug!("Opened process handle for PID {}", pid);
// Close when no longer needed
defer! {
debug!("Closing process handle for PID {}", pid);
unsafe {
_ = CloseHandle(hprocess);
}
};
let mut exe_name = vec![0u16; 32 * 1024];
let mut exe_name_length = exe_name.len() as u32;
unsafe {
QueryFullProcessImageNameW(
hprocess,
PROCESS_NAME_WIN32,
windows::core::PWSTR(exe_name.as_mut_ptr()),
&mut exe_name_length,
)
}?;
debug!(
"QueryFullProcessImageNameW returned {} bytes",
exe_name_length
);
exe_name.truncate(exe_name_length as usize);
Ok(PathBuf::from(OsString::from_wide(&exe_name)))
}
async fn send_error_to_user(client: &mut NamedPipeClient, error_message: &str) {
_ = send_to_user(client, &format!("!{}", error_message)).await
}
async fn send_to_user(client: &mut NamedPipeClient, message: &str) -> Result<()> {
let _ = send_message_with_client(client, message).await?;
Ok(())
}
fn is_admin() -> bool {
unsafe { IsUserAnAdmin().as_bool() }
}
async fn open_and_validate_pipe_server(pipe_name: &'static str) -> Result<NamedPipeClient> {
let client = open_pipe_client(pipe_name).await?;
let server_pid = get_named_pipe_server_pid(&client)?;
debug!("Connected to pipe server PID {}", server_pid);
// Validate the server end process signature
let exe_path = resolve_process_executable_path(server_pid)?;
debug!("Pipe server executable path: {}", exe_path.display());
if !verify_signature(&exe_path)? {
return Err(anyhow!("Pipe server signature is not valid"));
}
debug!("Pipe server signature verified for PID {}", server_pid);
Ok(client)
}
fn run() -> Result<String> {
debug!("Starting bitwarden_chromium_import_helper.exe");
let args = Args::try_parse()?;
if !is_admin() {
return Err(anyhow!("Expected to run with admin privileges"));
}
debug!("Running as ADMINISTRATOR");
let encrypted = decode_base64(&args.encrypted)?;
debug!(
"Decoded encrypted data [{}] {:?}",
encrypted.len(),
encrypted
);
let system_decrypted = decrypt_with_dpapi_as_system(&encrypted)?;
debug!(
"Decrypted data with DPAPI as SYSTEM {} {:?}",
system_decrypted.len(),
system_decrypted
);
let user_decrypted = decrypt_with_dpapi_as_user(&system_decrypted, false)?;
debug!(
"Decrypted data with DPAPI as USER {} {:?}",
user_decrypted.len(),
user_decrypted
);
let key = decode_abe_key_blob(&user_decrypted)?;
debug!("Decoded ABE key blob {} {:?}", key.len(), key);
Ok(encode_base64(&key))
}
pub(crate) async fn main() {
init_logging();
let mut client = match open_and_validate_pipe_server(ADMIN_TO_USER_PIPE_NAME).await {
Ok(client) => client,
Err(e) => {
error!(
"Failed to open pipe {} to send result/error: {}",
ADMIN_TO_USER_PIPE_NAME, e
);
return;
}
};
match run() {
Ok(system_decrypted_base64) => {
debug!("Sending response back to user");
let _ = send_to_user(&mut client, &system_decrypted_base64).await;
}
Err(e) => {
debug!("Error: {}", e);
send_error_to_user(&mut client, &format!("{}", e)).await;
}
}
}

View File

@@ -0,0 +1,7 @@
mod config;
mod crypto;
mod impersonate;
mod log;
mod main;
pub(crate) use main::main;

View File

@@ -7,7 +7,7 @@ publish = { workspace = true }
[dependencies]
aes = { workspace = true }
aes-gcm = "=0.10.3"
aes-gcm = { workspace = true }
anyhow = { workspace = true }
async-trait = "=0.1.88"
base64 = { workspace = true }
@@ -22,24 +22,13 @@ serde_json = { workspace = true }
sha1 = "=0.10.6"
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
[target.'cfg(target_os = "macos")'.dependencies]
security-framework = { workspace = true }
[target.'cfg(target_os = "windows")'.dependencies]
chacha20poly1305 = { workspace = true }
windows = { workspace = true, features = [
"Wdk_System_SystemServices",
"Win32_Security_Cryptography",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_IO",
"Win32_System_Memory",
"Win32_System_Pipes",
"Win32_System_ProcessStatus",
"Win32_System_Services",
"Win32_System_Threading",
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }

View File

@@ -4,7 +4,7 @@ A rust library that allows you to directly import credentials from Chromium-base
## Windows ABE Architecture
On Windows chrome has additional protection measurements which needs to be circumvented in order to
On Windows Chrome has additional protection measurements which needs to be circumvented in order to
get access to the passwords.
### Overview
@@ -25,7 +25,9 @@ encryption scheme for some local profiles.
The general idea of this encryption scheme is as follows:
1. Chrome generates a unique random encryption key.
2. This key is first encrypted at the **user level** with a fixed key.
2. This key is first encrypted at the **user level** with a fixed key for v1/v2 of ABE. For ABE v3 a more complicated
scheme is used that encrypts the key with a combination of a fixed key and a randomly generated key at the **system
level** via Windows CNG API.
3. It is then encrypted at the **user level** again using the Windows **Data Protection API (DPAPI)**.
4. Finally, it is sent to a special service that encrypts it with DPAPI at the **system level**.
@@ -37,7 +39,7 @@ The following sections describe how the key is decrypted at each level.
This is a Rust module that is part of the Chromium importer. It compiles and runs only on Windows (see `abe.rs` and
`abe_config.rs`). Its main task is to launch `bitwarden_chromium_import_helper.exe` with elevated privileges, presenting
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `windows.rs`.
the user with the UAC prompt. See the `abe::decrypt_with_admin` call in `platform/windows/mod.rs`.
This function takes two arguments:
@@ -75,10 +77,26 @@ With the duplicated token, `ImpersonateLoggedOnUser` is called to impersonate a
> **At this point `bitwarden_chromium_import_helper.exe` is running as SYSTEM.**
The received encryption key can now be decrypted using DPAPI at the system level.
The received encryption key can now be decrypted using DPAPI at the **system level**.
The decrypted result is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to
the pipe and writes the result.
Next, the impersonation is stopped and the feshly decrypted key is decrypted at the **user level** with DPAPI one more
time.
At this point, for browsers not using the custom encryption/obfuscation layer like unbranded Chromium, the twice
decrypted key is the actual encryption key that could be used to decrypt the stored passwords.
For other browsers like Google Chrome, some additional processing is required. The decrypted key is actually a blob of structured data that could take multiple forms:
1. exactly 32 bytes: plain key, nothing to be done more in this case
2. blob starts with 0x01: the key is encrypted with a fixed AES key found in Google Chrome binary, a random IV is stored
in the blob as well
3. blob starts with 0x02: the key is encrypted with a fixed ChaCha20 key found in Google Chrome binary, a random IV is
stored in the blob as well
4. blob starts with 0x03: the blob contains a random key, encrypted with CNG API with a random key stored in the
**system keychain** under the name `Google Chromekey1`. After that key is decryped (under **system level** impersonation again), the key is xor'ed with a fixed key from the Chrome binary and the it is used to decrypt the key from the last DPAPI decryption stage.
The decrypted key is sent back to the client via the named pipe. `bitwarden_chromium_import_helper.exe` connects to the
pipe and writes the result.
The response can indicate success or failure:
@@ -92,17 +110,8 @@ Finally, `bitwarden_chromium_import_helper.exe` exits.
### 3. Back to the Client Library
The decrypted Base64-encoded string is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at
the user level. At this point it has been decrypted only once—at the system level.
Next, the string is decrypted at the **user level** with DPAPI.
Finally, for Google Chrome (but not Brave), it is decrypted again with a hard-coded key found in `elevation_service.exe`
from the Chrome installation. Based on the version of the encrypted string (encoded within the string itself), this step
uses either **AES-256-GCM** or **ChaCha20-Poly1305**. See `windows.rs` for details.
After these steps, the master key is available and can be used to decrypt the password information stored in the
browsers local database.
The decrypted Base64-encoded key is returned from `bitwarden_chromium_import_helper.exe` to the named pipe server at the
user level. The key is used to decrypt the stored passwords and notes.
### TL;DR Steps
@@ -120,13 +129,12 @@ browsers local database.
2. Ensure `SE_DEBUG_PRIVILEGE` is enabled (not strictly necessary in tests).
3. Impersonate a system process such as `services.exe` or `winlogon.exe`.
4. Decrypt the key using DPAPI at the **SYSTEM** level.
5. Decrypt it again with DPAPI at the **USER** level.
6. (For Chrome only) Decrypt again with the hard-coded key, possibly at the **system level** again (see above).
5. Send the result or error back via the named pipe.
6. Exit.
3. **Back on the client side:**
1. Receive the encryption key.
1. Receive the master key.
2. Shutdown the pipe server.
3. Decrypt it with DPAPI at the **USER** level.
4. (For Chrome only) Decrypt again with the hard-coded key.
5. Obtain the fully decrypted master key.
6. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.
3. Use the master key to read and decrypt stored passwords from Chrome, Brave, Edge, etc.

View File

@@ -0,0 +1,15 @@
include!("config_constants.rs");
fn main() {
println!("cargo:rerun-if-changed=config_constants.rs");
if cfg!(not(debug_assertions)) {
if ENABLE_DEVELOPER_LOGGING {
panic!("ENABLE_DEVELOPER_LOGGING must be false in release builds");
}
if !ENABLE_SIGNATURE_VALIDATION {
panic!("ENABLE_SIGNATURE_VALIDATION must be true in release builds");
}
}
}

View File

@@ -0,0 +1,12 @@
// Enable this to log to a file. The way this executable is used, it's not easy to debug and the stdout gets lost.
// This is intended for development time only.
pub const ENABLE_DEVELOPER_LOGGING: bool = false;
// The absolute path to log file when developer logging is enabled
// Change this to a suitable path for your environment
pub const LOG_FILENAME: &str = "c:\\path\\to\\log.txt";
/// Ensure the signature of the helper and main binary is validated in production builds
///
/// This must be true in release builds but may be disabled in debug builds for testing.
pub const ENABLE_SIGNATURE_VALIDATION: bool = true;

View File

@@ -10,9 +10,7 @@ use rusqlite::{params, Connection};
mod platform;
#[cfg(target_os = "windows")]
pub use platform::{
verify_signature, ADMIN_TO_USER_PIPE_NAME, EXPECTED_SIGNATURE_SHA256_THUMBPRINT,
};
pub use platform::{verify_signature, ADMIN_TO_USER_PIPE_NAME};
pub(crate) use platform::SUPPORTED_BROWSERS as PLATFORM_SUPPORTED_BROWSERS;

View File

@@ -2,7 +2,6 @@ use aes_gcm::{aead::Aead, Aes256Gcm, Key, KeyInit, Nonce};
use anyhow::{anyhow, Result};
use async_trait::async_trait;
use base64::{engine::general_purpose::STANDARD as BASE64_STANDARD, Engine as _};
use chacha20poly1305::ChaCha20Poly1305;
use std::path::{Path, PathBuf};
use windows::Win32::{
Foundation::{LocalFree, HLOCAL},
@@ -62,9 +61,6 @@ pub(crate) fn get_crypto_service(
const ADMIN_EXE_FILENAME: &str = "bitwarden_chromium_import_helper.exe";
// This should be enabled for production
const ENABLE_SIGNATURE_VALIDATION: bool = true;
//
// CryptoService
//
@@ -185,7 +181,7 @@ impl WindowsCryptoService {
let admin_exe_path = get_admin_exe_path()?;
if ENABLE_SIGNATURE_VALIDATION && !verify_signature(&admin_exe_path)? {
if !verify_signature(&admin_exe_path)? {
return Err(anyhow!("Helper executable signature is not valid"));
}
@@ -208,119 +204,8 @@ impl WindowsCryptoService {
));
}
let key_bytes = BASE64_STANDARD.decode(&key_base64)?;
let key = unprotect_data_win(&key_bytes)?;
Self::decode_abe_key_blob(key.as_slice())
}
fn decode_abe_key_blob(blob_data: &[u8]) -> Result<Vec<u8>> {
let header_len = u32::from_le_bytes(blob_data[0..4].try_into()?) as usize;
// Ignore the header
let content_len_offset = 4 + header_len;
let content_len =
u32::from_le_bytes(blob_data[content_len_offset..content_len_offset + 4].try_into()?)
as usize;
if content_len < 1 {
return Err(anyhow!(
"Corrupted ABE key blob: content length is less than 1"
));
}
let content_offset = content_len_offset + 4;
let content = &blob_data[content_offset..content_offset + content_len];
// When the size is exactly 32 bytes, it's a plain key. It's used in unbranded Chromium builds, Brave, possibly Edge
if content_len == 32 {
return Ok(content.to_vec());
}
let version = content[0];
let key_blob = &content[1..];
match version {
// Google Chrome v1 key encrypted with a hardcoded AES key
1_u8 => Self::decrypt_abe_key_blob_chrome_aes(key_blob),
// Google Chrome v2 key encrypted with a hardcoded ChaCha20 key
2_u8 => Self::decrypt_abe_key_blob_chrome_chacha20(key_blob),
// Google Chrome v3 key encrypted with CNG APIs
3_u8 => Self::decrypt_abe_key_blob_chrome_cng(key_blob),
v => Err(anyhow!("Unsupported ABE key blob version: {}", v)),
}
}
// TODO: DRY up with decrypt_abe_key_blob_chrome_chacha20
fn decrypt_abe_key_blob_chrome_aes(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let iv: [u8; 12] = blob[0..12].try_into()?;
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
const GOOGLE_AES_KEY: &[u8] = &[
0xB3, 0x1C, 0x6E, 0x24, 0x1A, 0xC8, 0x46, 0x72, 0x8D, 0xA9, 0xC1, 0xFA, 0xC4, 0x93,
0x66, 0x51, 0xCF, 0xFB, 0x94, 0x4D, 0x14, 0x3A, 0xB8, 0x16, 0x27, 0x6B, 0xCC, 0x6D,
0xA0, 0x28, 0x47, 0x87,
];
let aes_key = Key::<Aes256Gcm>::from_slice(GOOGLE_AES_KEY);
let cipher = Aes256Gcm::new(aes_key);
let decrypted = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google AES key: {}", e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_chacha20(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 60 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 60 bytes, got {} bytes",
blob.len()
));
}
let chacha20_key = chacha20poly1305::Key::from_slice(GOOGLE_CHACHA20_KEY);
let cipher = ChaCha20Poly1305::new(chacha20_key);
const GOOGLE_CHACHA20_KEY: &[u8] = &[
0xE9, 0x8F, 0x37, 0xD7, 0xF4, 0xE1, 0xFA, 0x43, 0x3D, 0x19, 0x30, 0x4D, 0xC2, 0x25,
0x80, 0x42, 0x09, 0x0E, 0x2D, 0x1D, 0x7E, 0xEA, 0x76, 0x70, 0xD4, 0x1F, 0x73, 0x8D,
0x08, 0x72, 0x96, 0x60,
];
let iv: [u8; 12] = blob[0..12].try_into()?;
let ciphertext: [u8; 48] = blob[12..12 + 48].try_into()?;
let decrypted = cipher
.decrypt((&iv).into(), ciphertext.as_ref())
.map_err(|e| anyhow!("Failed to decrypt v20 key with Google ChaCha20 key: {}", e))?;
Ok(decrypted)
}
fn decrypt_abe_key_blob_chrome_cng(blob: &[u8]) -> Result<Vec<u8>> {
if blob.len() < 92 {
return Err(anyhow!(
"Corrupted ABE key blob: expected at least 92 bytes, got {} bytes",
blob.len()
));
}
let _encrypted_aes_key: [u8; 32] = blob[0..32].try_into()?;
let _iv: [u8; 12] = blob[32..32 + 12].try_into()?;
let _ciphertext: [u8; 48] = blob[44..44 + 48].try_into()?;
// TODO: Decrypt the AES key using CNG APIs
// TODO: Implement this in the future once we run into a browser that uses this scheme
// There's no way to test this at the moment. This encryption scheme is not used in any of the browsers I've tested.
Err(anyhow!("Google ABE CNG flavor is not supported yet"))
let key = BASE64_STANDARD.decode(&key_base64)?;
Ok(key)
}
}

View File

@@ -3,10 +3,20 @@ use std::path::Path;
use tracing::{debug, info};
use verifysign::CodeSignVerifier;
use crate::config::ENABLE_SIGNATURE_VALIDATION;
pub const EXPECTED_SIGNATURE_SHA256_THUMBPRINT: &str =
"9f6680c4720dbf66d1cb8ed6e328f58e42523badc60d138c7a04e63af14ea40d";
pub fn verify_signature(path: &Path) -> Result<bool> {
if !ENABLE_SIGNATURE_VALIDATION {
info!(
"Signature validation is disabled. Skipping verification for: {}",
path.display()
);
return Ok(true);
}
info!("verifying signature of: {}", path.display());
let verifier = CodeSignVerifier::for_file(path)

View File

@@ -1,5 +1,9 @@
#![doc = include_str!("../README.md")]
pub mod config {
include!("../config_constants.rs");
}
pub mod chromium;
pub mod metadata;
mod util;

View File

@@ -40,7 +40,7 @@
"pack:dir": "npm run clean:dist && electron-builder --dir -p never",
"pack:lin:flatpak": "flatpak-builder --repo=../../.flatpak-repo ../../.flatpak ./resources/com.bitwarden.desktop.devel.yaml --install-deps-from=flathub --force-clean && flatpak build-bundle ../../.flatpak-repo/ ./dist/com.bitwarden.desktop.flatpak com.bitwarden.desktop",
"pack:lin": "npm run clean:dist && electron-builder --linux --x64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/",
"pack:lin:arm64": "npm run clean:dist && electron-builder --dir -p never && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:lin:arm64": "npm run clean:dist && electron-builder --linux --arm64 -p never && export SNAP_FILE=$(realpath ./dist/bitwarden_*.snap) && unsquashfs -d ./dist/tmp-snap/ $SNAP_FILE && mkdir -p ./dist/tmp-snap/meta/polkit/ && cp ./resources/com.bitwarden.desktop.policy ./dist/tmp-snap/meta/polkit/polkit.com.bitwarden.desktop.policy && rm $SNAP_FILE && snap pack --compression=lzo ./dist/tmp-snap/ && mv ./*.snap ./dist/ && rm -rf ./dist/tmp-snap/ && tar -czvf ./dist/bitwarden_desktop_arm64.tar.gz -C ./dist/linux-arm64-unpacked/ .",
"pack:mac": "npm run clean:dist && electron-builder --mac --universal -p never",
"pack:mac:with-extension": "npm run clean:dist && npm run build:macos-extension:mac && electron-builder --mac --universal -p never",
"pack:mac:arm64": "npm run clean:dist && electron-builder --mac --arm64 -p never",

View File

@@ -81,7 +81,10 @@ import {
LogService as LogServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/log.service";
import { MessagingService as MessagingServiceAbstraction } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import {
PlatformUtilsService,
PlatformUtilsService as PlatformUtilsServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SdkClientFactory } from "@bitwarden/common/platform/abstractions/sdk/sdk-client-factory";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { StateService as StateServiceAbstraction } from "@bitwarden/common/platform/abstractions/state.service";
@@ -341,6 +344,7 @@ const safeProviders: SafeProvider[] = [
ConfigService,
Fido2AuthenticatorServiceAbstraction,
AccountService,
PlatformUtilsService,
],
}),
safeProvider({

View File

@@ -3,6 +3,7 @@ import { Component } from "@angular/core";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DialogRef, AsyncActionsModule, ButtonModule, DialogModule } from "@bitwarden/components";
import type { chromium_importer } from "@bitwarden/desktop-napi";
import { ImportMetadataServiceAbstraction } from "@bitwarden/importer-core";
import {
ImportComponent,
@@ -47,11 +48,14 @@ export class ImportDesktopComponent {
this.dialogRef.close();
}
protected onLoadProfilesFromBrowser(browser: string): Promise<any[]> {
protected onLoadProfilesFromBrowser(browser: string): Promise<chromium_importer.ProfileInfo[]> {
return ipc.tools.chromiumImporter.getAvailableProfiles(browser);
}
protected onImportFromBrowser(browser: string, profile: string): Promise<any[]> {
protected onImportFromBrowser(
browser: string,
profile: string,
): Promise<chromium_importer.LoginImportResult[]> {
return ipc.tools.chromiumImporter.importLogins(browser, profile);
}
}

View File

@@ -5,9 +5,12 @@ import type { chromium_importer } from "@bitwarden/desktop-napi";
const chromiumImporter = {
getMetadata: (): Promise<Record<string, chromium_importer.NativeImporterMetadata>> =>
ipcRenderer.invoke("chromium_importer.getMetadata"),
getAvailableProfiles: (browser: string): Promise<any[]> =>
getAvailableProfiles: (browser: string): Promise<chromium_importer.ProfileInfo[]> =>
ipcRenderer.invoke("chromium_importer.getAvailableProfiles", browser),
importLogins: (browser: string, profileId: string): Promise<any[]> =>
importLogins: (
browser: string,
profileId: string,
): Promise<chromium_importer.LoginImportResult[]> =>
ipcRenderer.invoke("chromium_importer.importLogins", browser, profileId),
};

View File

@@ -13,6 +13,7 @@ import {
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getOptionalUserId } from "@bitwarden/common/auth/services/account.service";
import { DeviceType } from "@bitwarden/common/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { UriMatchStrategy } from "@bitwarden/common/models/domain/domain-service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
@@ -24,6 +25,7 @@ import {
Fido2AuthenticatorService as Fido2AuthenticatorServiceAbstraction,
} from "@bitwarden/common/platform/abstractions/fido2/fido2-authenticator.service.abstraction";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { parseCredentialId } from "@bitwarden/common/platform/services/fido2/credential-id-utils";
import { getCredentialsForAutofill } from "@bitwarden/common/platform/services/fido2/fido2-autofill-utils";
@@ -53,9 +55,15 @@ export class DesktopAutofillService implements OnDestroy {
private configService: ConfigService,
private fido2AuthenticatorService: Fido2AuthenticatorServiceAbstraction<NativeWindowObject>,
private accountService: AccountService,
private platformUtilsService: PlatformUtilsService,
) {}
async init() {
// Currently only supported for MacOS
if (this.platformUtilsService.getDevice() !== DeviceType.MacOsDesktop) {
return;
}
this.configService
.getFeatureFlag$(FeatureFlag.MacOsNativeCredentialSync)
.pipe(

View File

@@ -2,17 +2,12 @@
<app-side-nav variant="secondary" *ngIf="organization$ | async as organization">
<bit-nav-logo [openIcon]="logo" route="." [label]="'adminConsole' | i18n"></bit-nav-logo>
<org-switcher [filter]="orgFilter" [hideNewButton]="hideNewOrgButton$ | async"></org-switcher>
<bit-nav-group
<bit-nav-item
icon="bwi-dashboard"
*ngIf="organization.useRiskInsights && organization.canAccessReports"
*ngIf="organization.useAccessIntelligence && organization.canAccessReports"
[text]="'accessIntelligence' | i18n"
route="access-intelligence"
>
<bit-nav-item
[text]="'riskInsights' | i18n"
route="access-intelligence/risk-insights"
></bit-nav-item>
</bit-nav-group>
></bit-nav-item>
<bit-nav-item
icon="bwi-collection-shared"
[text]="'collections' | i18n"

View File

@@ -27,7 +27,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
enabled = false;
authed = false;
protected hashedSecret: string | undefined;
protected secret: string | undefined;
protected verificationType: VerificationType | undefined;
protected componentName = "";
@@ -42,7 +42,7 @@ export abstract class TwoFactorSetupMethodBaseComponent {
) {}
protected auth(authResponse: AuthResponseBase) {
this.hashedSecret = authResponse.secret;
this.secret = authResponse.secret;
this.verificationType = authResponse.verificationType;
this.authed = true;
}
@@ -132,12 +132,12 @@ export abstract class TwoFactorSetupMethodBaseComponent {
protected async buildRequestModel<T extends SecretVerificationRequest>(
requestClass: new () => T,
) {
if (this.hashedSecret === undefined || this.verificationType === undefined) {
if (this.secret === undefined || this.verificationType === undefined) {
throw new Error("User verification data is missing");
}
return this.userVerificationService.buildRequest(
{
secret: this.hashedSecret,
secret: this.secret,
type: this.verificationType,
},
requestClass,

View File

@@ -1,15 +1,14 @@
import { Component, EventEmitter, Inject, Output } from "@angular/core";
import { Component, Inject } from "@angular/core";
import { FormControl, FormGroup, ReactiveFormsModule } from "@angular/forms";
import { UserVerificationFormInputComponent } from "@bitwarden/auth/angular";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { VerificationType } from "@bitwarden/common/auth/enums/verification-type";
import { SecretVerificationRequest } from "@bitwarden/common/auth/models/request/secret-verification.request";
import { TwoFactorApiService } from "@bitwarden/common/auth/two-factor";
import { AuthResponse } from "@bitwarden/common/auth/types/auth-response";
import { TwoFactorResponse } from "@bitwarden/common/auth/types/two-factor-response";
import { Verification } from "@bitwarden/common/auth/types/verification";
import { VerificationWithSecret } from "@bitwarden/common/auth/types/verification";
import { ErrorResponse } from "@bitwarden/common/models/response/error.response";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import {
@@ -45,14 +44,10 @@ type TwoFactorVerifyDialogData = {
export class TwoFactorVerifyComponent {
type: TwoFactorProviderType;
organizationId: string;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-output-emitter-ref
@Output() onAuthed = new EventEmitter<AuthResponse<TwoFactorResponse>>();
formPromise: Promise<TwoFactorResponse> | undefined;
protected formGroup = new FormGroup({
secret: new FormControl<Verification | null>(null),
secret: new FormControl<VerificationWithSecret | null>(null),
});
invalidSecret: boolean = false;
@@ -69,24 +64,19 @@ export class TwoFactorVerifyComponent {
submit = async () => {
try {
let hashedSecret = "";
if (!this.formGroup.value.secret) {
throw new Error("Secret is required");
}
const secret = this.formGroup.value.secret!;
this.formPromise = this.userVerificationService.buildRequest(secret).then((request) => {
hashedSecret =
secret.type === VerificationType.MasterPassword
? request.masterPasswordHash
: request.otp;
return this.apiCall(request);
});
const response = await this.formPromise;
this.dialogRef.close({
response: response,
secret: hashedSecret,
secret: secret.secret,
verificationType: secret.type,
});
} catch (e) {

View File

@@ -16,7 +16,7 @@
</header>
<div class="tw-px-14 tw-pb-8">
<div class="tw-flex tw-text-center tw-flex-col tw-pb-4">
<h1 class="tw-font-semibold tw-text-[32px]">
<h1 class="tw-font-medium tw-text-[32px]">
{{ dialogTitle() | i18n }}
</h1>
<p bitTypography="body1" class="tw-text-muted">

View File

@@ -4,7 +4,7 @@
is not supported by the button in the CL. -->
<button
type="button"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-semibold tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
class="tw-py-1.5 tw-px-4 tw-flex tw-gap-2 tw-items-center tw-size-full focus-visible:tw-ring-2 focus-visible:tw-ring-offset-0 focus:tw-outline-none focus-visible:tw-outline-none focus-visible:tw-ring-text-alt2 focus-visible:tw-z-10 tw-font-medium tw-rounded-full tw-transition tw-border tw-border-solid tw-text-left tw-bg-primary-100 tw-text-primary-600 tw-border-primary-600 hover:tw-bg-hover-default hover:tw-text-primary-700 hover:tw-border-primary-700"
(click)="upgrade()"
>
<i class="bwi bwi-premium" aria-hidden="true"></i>

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading()">
<span bitDialogTitle class="tw-font-semibold">{{ upgradeToMessage }}</span>
<span bitDialogTitle class="tw-font-medium">{{ upgradeToMessage }}</span>
<ng-container bitDialogContent>
<section>
@if (isFamiliesPlan) {

View File

@@ -37,7 +37,7 @@
></button>
</bit-form-field>
<div class="tw-mt-2 tw-text-sm tw-text-muted" *ngIf="showLastSyncText">
<b class="tw-font-semibold">{{ "lastSync" | i18n }}:</b>
<b class="tw-font-medium">{{ "lastSync" | i18n }}:</b>
{{ lastSyncDate | date: "medium" }}
</div>
<div class="tw-mt-2 tw-text-sm tw-text-danger" *ngIf="showAwaitingSyncText">

View File

@@ -1,12 +1,12 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog dialogSize="large" [loading]="loading">
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ dialogHeaderName }}
</span>
<div bitDialogContent>
<p>{{ "upgradePlans" | i18n }}</p>
<div class="tw-mb-3 tw-flex tw-justify-between">
<span [hidden]="isSubscriptionCanceled" class="tw-text-lg tw-pr-1 tw-font-bold">{{
<span [hidden]="isSubscriptionCanceled" class="tw-text-lg tw-pr-1 tw-font-medium">{{
"selectAPlan" | i18n
}}</span>
<!-- Discount Badge -->
@@ -57,7 +57,7 @@
selectableProduct.productTier === productTypes.Enterprise &&
!isSubscriptionCanceled
"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-medium tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': selectableProduct === selectedPlan,
'tw-bg-secondary-100': !(selectableProduct === selectedPlan),
@@ -73,7 +73,7 @@
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-medium tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{
selectableProduct.nameLocalizationKey | i18n
@@ -91,7 +91,7 @@
<ng-container
*ngIf="selectableProduct.PasswordManager.basePrice && !acceptingSponsorship"
>
<b class="tw-text-lg tw-font-semibold">
<b class="tw-text-lg tw-font-medium">
{{
(selectableProduct.isAnnual
? selectableProduct.PasswordManager.basePrice / 12
@@ -106,7 +106,7 @@
: ("monthPerMember" | i18n)
}}</span
>
<b class="tw-text-sm tw-font-semibold">
<b class="tw-text-sm tw-font-medium">
<ng-container
*ngIf="selectableProduct.PasswordManager.hasAdditionalSeatsOption"
>
@@ -128,7 +128,7 @@
selectableProduct.PasswordManager.hasAdditionalSeatsOption
"
>
<b class="tw-text-lg tw-font-semibold"
<b class="tw-text-lg tw-font-medium"
>{{
"costPerMember"
| i18n
@@ -155,7 +155,7 @@
"
>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
@@ -182,7 +182,7 @@
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenSecretsManager" | i18n }}
@@ -222,7 +222,7 @@
</ng-container>
<ng-template #fullFeatureList>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="organization.useSecretsManager"
>
{{ "bitwardenPasswordManager" | i18n }}
@@ -274,7 +274,7 @@
</li>
</ul>
<p
class="tw-text-xs tw-px-2 tw-font-semibold tw-mb-1"
class="tw-text-xs tw-px-2 tw-font-medium tw-mb-1"
*ngIf="
organization.useSecretsManager &&
selectableProduct.productTier !== productTypes.Families
@@ -385,7 +385,7 @@
</ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
<span class="tw-font-medium"
>{{ "total" | i18n }}:
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }} USD</span
>
@@ -402,7 +402,7 @@
<!-- SM + PM and PM only cost summary -->
<div *ngIf="totalOpened && !isSecretsManagerTrial()" class="tw-flex tw-flex-wrap tw-gap-4">
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Annually">
<p class="tw-font-semibold tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -487,7 +487,7 @@
</ng-container>
</p>
<!-- secrets manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -569,7 +569,7 @@
</p>
</bit-hint>
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Monthly">
<p class="tw-font-semibold tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -642,7 +642,7 @@
</ng-container>
</p>
<!-- secrets manager summary for monthly -->
<p class="tw-font-semibold tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-1" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -727,7 +727,7 @@
<div *ngIf="totalOpened && isSecretsManagerTrial()" class="tw-flex tw-flex-wrap tw-gap-4">
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Annually">
<!-- secrets manager summary for annual -->
<p class="tw-font-semibold tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -788,7 +788,7 @@
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!-- password manager summary for annual -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -846,7 +846,7 @@
</bit-hint>
<bit-hint class="tw-w-1/2" *ngIf="selectedInterval == planIntervals.Monthly">
<!-- secrets manager summary for monthly -->
<p class="tw-font-semibold tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-2 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "secretsManager" | i18n }}
</p>
<p
@@ -903,7 +903,7 @@
<span>{{ additionalServiceAccountTotal(selectedPlan) | currency: "$" }}</span>
</p>
<!-- password manager summary for monthly -->
<p class="tw-font-semibold tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
<p class="tw-font-medium tw-mt-3 tw-mb-0" *ngIf="organization.useSecretsManager">
{{ "passwordManager" | i18n }}
</p>
<p
@@ -972,7 +972,7 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">
<span class="tw-font-medium">
{{ "estimatedTax" | i18n }}
</span>
<span>
@@ -986,14 +986,12 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">
<span class="tw-font-medium">
{{ "total" | i18n }}
</span>
<span>
{{ total - calculateTotalAppliedDiscount(total) | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold">
/ {{ selectedPlanInterval | i18n }}</span
>
<span class="tw-text-xs tw-font-medium"> / {{ selectedPlanInterval | i18n }}</span>
</span>
</p>
</bit-hint>

View File

@@ -241,7 +241,7 @@
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
</div>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: userOrg.providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>
</ng-template>

View File

@@ -130,7 +130,7 @@
<bit-label class="tw-mb-6 tw-block" *ngIf="!showAutomaticSyncAndManualUpload">
{{ "licenseAndBillingManagementDesc" | i18n }}
</bit-label>
<h3 *ngIf="showAutomaticSyncAndManualUpload" class="tw-font-semibold tw-mt-6">
<h3 *ngIf="showAutomaticSyncAndManualUpload" class="tw-font-medium tw-mt-6">
{{ "uploadLicense" | i18n }}
</h3>
<app-update-license

View File

@@ -12,7 +12,7 @@ import { GearIcon } from "@bitwarden/assets/svg";
<div class="tw-size-56 tw-content-center">
<bit-icon [icon]="gearIcon" aria-hidden="true"></bit-icon>
</div>
<p class="tw-font-bold">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p class="tw-font-medium">{{ "billingManagedByProvider" | i18n: providerName }}</p>
<p>{{ "billingContactProviderForAssistance" | i18n }}</p>
</div>`,
standalone: false,

View File

@@ -58,7 +58,7 @@ const positiveNumberValidator =
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "addCredit" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -24,7 +24,7 @@ type DialogParams = {
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "changePaymentMethod" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -41,7 +41,7 @@ type DialogResult =
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "editBillingAddress" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -35,7 +35,7 @@ type DialogParams = {
template: `
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "addPaymentMethod" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -1,6 +1,6 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-dialog>
<span bitDialogTitle class="tw-font-semibold">
<span bitDialogTitle class="tw-font-medium">
{{ "cancelSubscription" | i18n }}
</span>
<div bitDialogContent>

View File

@@ -11,7 +11,7 @@
<div class="tw-relative">
@if (isRecommended) {
<div
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-bold tw-py-1"
class="tw-bg-secondary-100 tw-text-center !tw-border-0 tw-text-sm tw-font-medium tw-py-1"
[ngClass]="{
'tw-bg-primary-700 !tw-text-contrast': plan().isSelected,
'tw-bg-secondary-100': !plan().isSelected,
@@ -28,12 +28,12 @@
}"
>
<h3
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-bold tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
class="tw-text-[1.25rem] tw-mt-[6px] tw-font-medium tw-mb-0 tw-leading-[2rem] tw-flex tw-items-center"
>
<span class="tw-capitalize tw-whitespace-nowrap">{{ plan().title }}</span>
</h3>
<span>
<b class="tw-text-lg tw-font-semibold">{{ plan().costPerMember | currency: "$" }} </b>
<b class="tw-text-lg tw-font-medium">{{ plan().costPerMember | currency: "$" }} </b>
<span class="tw-text-xs tw-px-0"> /{{ "monthPerMember" | i18n }}</span>
</span>
</div>

View File

@@ -1,7 +1,7 @@
<ng-container>
<div class="tw-mt-4">
<p class="tw-text-lg tw-mb-1">
<span class="tw-font-semibold"
<span class="tw-font-medium"
>{{ "total" | i18n }}:
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }} USD</span
>
@@ -37,7 +37,7 @@
<ng-container
*ngIf="!summaryData.isSecretsManagerTrial || summaryData.organization.useSecretsManager"
>
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
<p class="tw-font-medium tw-mt-3 tw-mb-1">{{ "passwordManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan.PasswordManager.basePrice">
@@ -137,7 +137,7 @@
<!-- Secrets Manager section -->
<ng-template #secretsManagerSection>
<ng-container *ngIf="summaryData.organization.useSecretsManager">
<p class="tw-font-semibold tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
<p class="tw-font-medium tw-mt-3 tw-mb-1">{{ "secretsManager" | i18n }}</p>
<!-- Base Price -->
<ng-container *ngIf="summaryData.selectedPlan?.SecretsManager?.basePrice">
@@ -236,7 +236,7 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "estimatedTax" | i18n }}</span>
<span class="tw-font-medium">{{ "estimatedTax" | i18n }}</span>
<span>{{ summaryData.estimatedTax | currency: "USD" : "$" }}</span>
</p>
</bit-hint>
@@ -247,10 +247,10 @@
<p
class="tw-flex tw-justify-between tw-border-0 tw-border-solid tw-border-t tw-border-secondary-300 tw-pt-2 tw-mb-0"
>
<span class="tw-font-semibold">{{ "total" | i18n }}</span>
<span class="tw-font-medium">{{ "total" | i18n }}</span>
<span>
{{ summaryData.total - summaryData.totalAppliedDiscount | currency: "USD" : "$" }}
<span class="tw-text-xs tw-font-semibold"
<span class="tw-text-xs tw-font-medium"
>/ {{ summaryData.selectedPlanInterval | i18n }}</span
>
</span>

Some files were not shown because too many files have changed in this diff Show More