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:
75
.github/workflows/build-desktop.yml
vendored
75
.github/workflows/build-desktop.yml
vendored
@@ -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 }}
|
||||
|
||||
|
||||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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> & {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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] : [],
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -26,7 +26,7 @@ export type AutofillOverlayContentExtensionMessageHandlers = {
|
||||
destroyAutofillInlineMenuListeners: () => void;
|
||||
getInlineMenuFormFieldData: ({
|
||||
message,
|
||||
}: AutofillExtensionMessageParam) => Promise<ModifyLoginCipherFormData>;
|
||||
}: AutofillExtensionMessageParam) => Promise<ModifyLoginCipherFormData | void>;
|
||||
};
|
||||
|
||||
export interface AutofillOverlayContentService {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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) : "";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1472,6 +1472,7 @@ export default class MainBackground {
|
||||
this.configService,
|
||||
this.logService,
|
||||
this.phishingDataService,
|
||||
messageListener,
|
||||
);
|
||||
|
||||
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -1 +1 @@
|
||||
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }}</span>
|
||||
<span class="tw-text-muted">{{ "protectedBy" | i18n: "Bitwarden phishing blocker" }}</span>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
// );
|
||||
// });
|
||||
});
|
||||
|
||||
@@ -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://")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -330,6 +330,7 @@ export class ViewV2Component {
|
||||
const tab = await BrowserApi.getTab(senderTabId);
|
||||
await sendExtensionMessage("bgHandleReprompt", {
|
||||
tab,
|
||||
cipherId: cipher.id,
|
||||
success: repromptSuccess,
|
||||
});
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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,
|
||||
|
||||
4
apps/desktop/desktop_native/Cargo.lock
generated
4
apps/desktop/desktop_native/Cargo.lock
generated
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::*;
|
||||
@@ -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"];
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod config;
|
||||
mod crypto;
|
||||
mod impersonate;
|
||||
mod log;
|
||||
mod main;
|
||||
|
||||
pub(crate) use main::main;
|
||||
@@ -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",
|
||||
] }
|
||||
|
||||
@@ -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
|
||||
browser’s 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 @@ browser’s 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.
|
||||
|
||||
15
apps/desktop/desktop_native/chromium_importer/build.rs
Normal file
15
apps/desktop/desktop_native/chromium_importer/build.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
#![doc = include_str!("../README.md")]
|
||||
|
||||
pub mod config {
|
||||
include!("../config_constants.rs");
|
||||
}
|
||||
|
||||
pub mod chromium;
|
||||
pub mod metadata;
|
||||
mod util;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user