1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-02 09:43:29 +00:00

Merge remote-tracking branch 'origin/main' into aj-test-workflow-update

This commit is contained in:
AJ Mabry
2025-11-07 19:05:14 -05:00
309 changed files with 5432 additions and 3221 deletions

View File

@@ -187,7 +187,6 @@
"json5",
"keytar",
"libc",
"log",
"lowdb",
"mini-css-extract-plugin",
"napi",
@@ -216,6 +215,8 @@
"simplelog",
"style-loader",
"sysinfo",
"tracing",
"tracing-subscriber",
"ts-node",
"ts-loader",
"tsconfig-paths-webpack-plugin",

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import { componentWrapperDecorator } from "@storybook/angular";
import type { Preview } from "@storybook/angular";
import docJson from "../documentation.json";
setCompodocJson(docJson);
const wrapperDecorator = componentWrapperDecorator((story) => {

View File

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

View File

@@ -4974,6 +4974,16 @@
}
}
},
"defaultLabelWithValue": {
"message": "Default ( $VALUE$ )",
"description": "A label that indicates the default value for a field with the current default value in parentheses.",
"placeholders": {
"value": {
"content": "$1",
"example": "Base domain"
}
}
},
"showMatchDetection": {
"message": "Show match detection $WEBSITE$",
"placeholders": {

View File

@@ -122,10 +122,8 @@ export class AccountSwitcherComponent implements OnInit, OnDestroy {
async lock(userId: string) {
this.loading = true;
await this.vaultTimeoutService.lock(userId);
// 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.router.navigate(["lock"]);
await this.lockService.lock(userId as UserId);
await this.router.navigate(["lock"]);
}
async lockAll() {

View File

@@ -25,7 +25,7 @@
<div class="tw-text-sm tw-italic" [attr.aria-hidden]="status.text === 'active'">
<span class="tw-sr-only">(</span>
<span [ngClass]="status.text === 'active' ? 'tw-font-bold tw-text-success' : ''">{{
<span [ngClass]="status.text === 'active' ? 'tw-font-medium tw-text-success' : ''">{{
status.text
}}</span>
<span class="tw-sr-only">)</span>

View File

@@ -6,10 +6,13 @@ import {
MessageListener,
MessageSender,
} from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { newGuid } from "@bitwarden/guid";
import { UserId } from "@bitwarden/user-core";
const LOCK_ALL_FINISHED = new CommandDefinition<{ requestId: string }>("lockAllFinished");
const LOCK_ALL = new CommandDefinition<{ requestId: string }>("lockAll");
const LOCK_USER_FINISHED = new CommandDefinition<{ requestId: string }>("lockUserFinished");
const LOCK_USER = new CommandDefinition<{ requestId: string; userId: UserId }>("lockUser");
export class ForegroundLockService implements LockService {
constructor(
@@ -18,7 +21,7 @@ export class ForegroundLockService implements LockService {
) {}
async lockAll(): Promise<void> {
const requestId = Utils.newGuid();
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_ALL_FINISHED)
@@ -29,4 +32,19 @@ export class ForegroundLockService implements LockService {
await finishMessage;
}
async lock(userId: UserId): Promise<void> {
const requestId = newGuid();
const finishMessage = firstValueFrom(
this.messageListener
.messages$(LOCK_USER_FINISHED)
.pipe(filter((m) => m.requestId === requestId)),
);
this.messageSender.send(LOCK_USER, { requestId, userId });
await finishMessage;
}
async runPlatformOnLockActions(): Promise<void> {}
}

View File

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

View File

@@ -6,6 +6,7 @@ import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { LockService } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
@@ -16,7 +17,6 @@ import { UserVerificationService } from "@bitwarden/common/auth/abstractions/use
import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction";
import {
VaultTimeoutSettingsService,
VaultTimeoutService,
VaultTimeoutStringType,
VaultTimeoutAction,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -63,6 +63,7 @@ describe("AccountSecurityComponent", () => {
const validationService = mock<ValidationService>();
const dialogService = mock<DialogService>();
const platformUtilsService = mock<PlatformUtilsService>();
const lockService = mock<LockService>();
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -83,7 +84,6 @@ describe("AccountSecurityComponent", () => {
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
{ provide: ToastService, useValue: mock<ToastService>() },
{ provide: UserVerificationService, useValue: mock<UserVerificationService>() },
{ provide: VaultTimeoutService, useValue: mock<VaultTimeoutService>() },
{ provide: VaultTimeoutSettingsService, useValue: vaultTimeoutSettingsService },
{ provide: StateProvider, useValue: mock<StateProvider>() },
{ provide: CipherService, useValue: mock<CipherService>() },
@@ -92,6 +92,7 @@ describe("AccountSecurityComponent", () => {
{ provide: OrganizationService, useValue: mock<OrganizationService>() },
{ provide: CollectionService, useValue: mock<CollectionService>() },
{ provide: ValidationService, useValue: validationService },
{ provide: LockService, useValue: lockService },
],
})
.overrideComponent(AccountSecurityComponent, {

View File

@@ -25,6 +25,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NudgesService, NudgeType } from "@bitwarden/angular/vault";
import { SpotlightComponent } from "@bitwarden/angular/vault/components/spotlight/spotlight.component";
import { FingerprintDialogComponent, VaultTimeoutInputComponent } from "@bitwarden/auth/angular";
import { LockService } from "@bitwarden/auth/common";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { getFirstPolicy } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
@@ -36,7 +37,6 @@ import {
VaultTimeout,
VaultTimeoutAction,
VaultTimeoutOption,
VaultTimeoutService,
VaultTimeoutSettingsService,
VaultTimeoutStringType,
} from "@bitwarden/common/key-management/vault-timeout";
@@ -143,7 +143,7 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
private formBuilder: FormBuilder,
private platformUtilsService: PlatformUtilsService,
private i18nService: I18nService,
private vaultTimeoutService: VaultTimeoutService,
private lockService: LockService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
public messagingService: MessagingService,
private environmentService: EnvironmentService,
@@ -695,7 +695,8 @@ export class AccountSecurityComponent implements OnInit, OnDestroy {
}
async lock() {
await this.vaultTimeoutService.lock();
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
}
async logOut() {

View File

@@ -0,0 +1,58 @@
import { DefaultLockService, LogoutService } from "@bitwarden/auth/common";
import MainBackground from "@bitwarden/browser/background/main.background";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "@bitwarden/common/key-management/vault-timeout";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "@bitwarden/common/vault/abstractions/search.service";
import { BiometricsService, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { StateEventRunnerService } from "@bitwarden/state";
export class ExtensionLockService extends DefaultLockService {
constructor(
accountService: AccountService,
biometricService: BiometricsService,
vaultTimeoutSettingsService: VaultTimeoutSettingsService,
logoutService: LogoutService,
messagingService: MessagingService,
searchService: SearchService,
folderService: FolderService,
masterPasswordService: InternalMasterPasswordServiceAbstraction,
stateEventRunnerService: StateEventRunnerService,
cipherService: CipherService,
authService: AuthService,
systemService: SystemService,
processReloadService: ProcessReloadServiceAbstraction,
logService: LogService,
keyService: KeyService,
private readonly main: MainBackground,
) {
super(
accountService,
biometricService,
vaultTimeoutSettingsService,
logoutService,
messagingService,
searchService,
folderService,
masterPasswordService,
stateEventRunnerService,
cipherService,
authService,
systemService,
processReloadService,
logService,
keyService,
);
}
async runPlatformOnLockActions(): Promise<void> {
await this.main.refreshMenu(true);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -68,7 +68,7 @@ const actionButtonStyles = ({
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
font-weight: 700;
font-weight: 500;
${disabled || isLoading
? `

View File

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

View File

@@ -115,7 +115,7 @@ const notificationConfirmationButtonTextStyles = (theme: Theme) => css`
${baseTextStyles}
color: ${themes[theme].primary[600]};
font-weight: 700;
font-weight: 500;
cursor: pointer;
`;

View File

@@ -21,5 +21,5 @@ const notificationHeaderMessageStyles = (theme: Theme) => css`
color: ${themes[theme].text.main};
font-family: Inter, sans-serif;
font-size: 18px;
font-weight: 600;
font-weight: 500;
`;

View File

@@ -94,7 +94,7 @@ const optionsLabelStyles = ({ theme }: { theme: Theme }) => css`
user-select: none;
padding: 0.375rem ${spacing["3"]};
color: ${themes[theme].text.muted};
font-weight: 600;
font-weight: 500;
`;
export const optionsMenuItemMaxWidth = 260;

View File

@@ -34,7 +34,7 @@ const actionRowStyles = (theme: Theme) => css`
min-height: 40px;
text-align: left;
color: ${themes[theme].primary["600"]};
font-weight: 700;
font-weight: 500;
> span {
display: block;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
<!-- eslint-disable tailwindcss/no-custom-classname -->
<!doctype html>
<!doctype html>
<html>
<head>
<title>Bitwarden</title>

View File

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

View File

@@ -82,7 +82,7 @@ body * {
width: 100%;
font-family: $font-family-sans-serif;
font-size: 1.6rem;
font-weight: 700;
font-weight: 500;
text-align: left;
background: transparent;
border: none;
@@ -187,7 +187,7 @@ body * {
top: 0;
z-index: 1;
font-family: $font-family-sans-serif;
font-weight: 600;
font-weight: 500;
font-size: 1rem;
line-height: 1.3;
letter-spacing: 0.025rem;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ExtensionCommand, ExtensionCommandType } from "@bitwarden/common/autofill/constants";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
// FIXME (PM-22628): Popup imports are forbidden in background
@@ -21,9 +25,10 @@ export default class CommandsBackground {
constructor(
private main: MainBackground,
private platformUtilsService: PlatformUtilsService,
private vaultTimeoutService: VaultTimeoutService,
private authService: AuthService,
private generatePasswordToClipboard: () => Promise<void>,
private accountService: AccountService,
private lockService: LockService,
) {
this.isSafari = this.platformUtilsService.isSafari();
this.isVivaldi = this.platformUtilsService.isVivaldi();
@@ -72,9 +77,11 @@ export default class CommandsBackground {
case "open_popup":
await this.openPopup();
break;
case "lock_vault":
await this.vaultTimeoutService.lock();
case "lock_vault": {
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
break;
}
default:
break;
}

View File

@@ -1,6 +1,6 @@
import { firstValueFrom } from "rxjs";
import { LogoutService } from "@bitwarden/auth/common";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import {
VaultTimeoutAction,
@@ -23,6 +23,7 @@ export default class IdleBackground {
private serverNotificationsService: ServerNotificationsService,
private accountService: AccountService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private lockService: LockService,
private logoutService: LogoutService,
) {
this.idle = chrome.idle || (browser != null ? browser.idle : null);
@@ -66,7 +67,7 @@ export default class IdleBackground {
if (action === VaultTimeoutAction.LogOut) {
await this.logoutService.logout(userId as UserId, "vaultTimeout");
} else {
await this.vaultTimeoutService.lock(userId);
await this.lockService.lock(userId as UserId);
}
}
}

View File

@@ -20,9 +20,9 @@ import {
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
InternalUserDecryptionOptionsServiceAbstraction,
LockService,
LoginEmailServiceAbstraction,
LogoutReason,
UserDecryptionOptionsService,
@@ -270,6 +270,7 @@ import {
} from "@bitwarden/vault-export-core";
import { AuthStatusBadgeUpdaterService } from "../auth/services/auth-status-badge-updater.service";
import { ExtensionLockService } from "../auth/services/extension-lock.service";
import { OverlayNotificationsBackground as OverlayNotificationsBackgroundInterface } from "../autofill/background/abstractions/overlay-notifications.background";
import { OverlayBackground as OverlayBackgroundInterface } from "../autofill/background/abstractions/overlay.background";
import { AutoSubmitLoginBackground } from "../autofill/background/auto-submit-login.background";
@@ -363,6 +364,7 @@ export default class MainBackground {
folderService: InternalFolderServiceAbstraction;
userDecryptionOptionsService: InternalUserDecryptionOptionsServiceAbstraction;
collectionService: CollectionService;
lockService: LockService;
vaultTimeoutService?: VaultTimeoutService;
vaultTimeoutSettingsService: VaultTimeoutSettingsService;
passwordGenerationService: PasswordGenerationServiceAbstraction;
@@ -496,16 +498,6 @@ export default class MainBackground {
private phishingDataService: PhishingDataService;
constructor() {
// Services
const lockedCallback = async (userId: UserId) => {
await this.refreshMenu(true);
if (this.systemService != null) {
await this.systemService.clearPendingClipboard();
await this.biometricsService.setShouldAutopromptNow(false);
await this.processReloadService.startProcessReload(this.authService);
}
};
const logoutCallback = async (logoutReason: LogoutReason, userId?: UserId) =>
await this.logout(logoutReason, userId);
@@ -987,27 +979,6 @@ export default class MainBackground {
this.restrictedItemTypesService,
);
const logoutService = new DefaultLogoutService(this.messagingService);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
this.biometricsService,
lockedCallback,
logoutService,
);
this.containerService = new ContainerService(this.keyService, this.encryptService);
this.sendStateProvider = new SendStateProvider(this.stateProvider);
@@ -1271,6 +1242,7 @@ export default class MainBackground {
this.biometricStateService,
this.accountService,
this.logService,
this.authService,
);
// Background
@@ -1284,7 +1256,36 @@ export default class MainBackground {
this.authService,
);
const lockService = new DefaultLockService(this.accountService, this.vaultTimeoutService);
const logoutService = new DefaultLogoutService(this.messagingService);
this.lockService = new ExtensionLockService(
this.accountService,
this.biometricsService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
this.systemService,
this.processReloadService,
this.logService,
this.keyService,
this,
);
this.vaultTimeoutService = new VaultTimeoutService(
this.accountService,
this.platformUtilsService,
this.authService,
this.vaultTimeoutSettingsService,
this.taskSchedulerService,
this.logService,
this.lockService,
logoutService,
);
this.runtimeBackground = new RuntimeBackground(
this,
@@ -1298,7 +1299,7 @@ export default class MainBackground {
this.configService,
messageListener,
this.accountService,
lockService,
this.lockService,
this.billingAccountProfileStateService,
this.browserInitialInstallService,
);
@@ -1318,9 +1319,10 @@ export default class MainBackground {
this.commandsBackground = new CommandsBackground(
this,
this.platformUtilsService,
this.vaultTimeoutService,
this.authService,
() => this.generatePasswordToClipboard(),
this.accountService,
this.lockService,
);
this.taskService = new DefaultTaskService(
@@ -1405,6 +1407,7 @@ export default class MainBackground {
this.serverNotificationsService,
this.accountService,
this.vaultTimeoutSettingsService,
this.lockService,
logoutService,
);
@@ -1469,6 +1472,7 @@ export default class MainBackground {
this.configService,
this.logService,
this.phishingDataService,
messageListener,
);
this.ipcContentScriptManagerService = new IpcContentScriptManagerService(this.configService);
@@ -1752,7 +1756,7 @@ export default class MainBackground {
}
await this.mainContextMenuHandler?.noAccess();
await this.systemService.clearPendingClipboard();
await this.processReloadService.startProcessReload(this.authService);
await this.processReloadService.startProcessReload();
}
private async needsStorageReseed(userId: UserId): Promise<boolean> {

View File

@@ -257,7 +257,7 @@ export default class RuntimeBackground {
this.lockedVaultPendingNotifications.push(msg.data);
break;
case "lockVault":
await this.main.vaultTimeoutService.lock(msg.userId);
await this.lockService.lock(msg.userId);
break;
case "lockAll":
{
@@ -265,6 +265,14 @@ export default class RuntimeBackground {
this.messagingService.send("lockAllFinished", { requestId: msg.requestId });
}
break;
case "lockUser":
{
await this.lockService.lock(msg.userId);
this.messagingService.send("lockUserFinished", {
requestId: msg.requestId,
});
}
break;
case "logout":
await this.main.logout(msg.expired, msg.userId);
break;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,10 @@
// @ts-strict-ignore
import { VaultTimeoutService as BaseVaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout/abstractions/vault-timeout.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { UserId } from "@bitwarden/common/types/guid";
export class ForegroundVaultTimeoutService implements BaseVaultTimeoutService {
constructor(protected messagingService: MessagingService) {}
// should only ever run in background
async checkVaultTimeout(): Promise<void> {}
async lock(userId?: UserId): Promise<void> {
this.messagingService.send("lockVault", { userId });
}
}

View File

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

View File

@@ -140,6 +140,11 @@ describe("BrowserPopupUtils", () => {
describe("openPopout", () => {
beforeEach(() => {
jest.spyOn(BrowserApi, "getPlatformInfo").mockResolvedValueOnce({
os: "linux",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
@@ -150,6 +155,8 @@ describe("BrowserPopupUtils", () => {
width: 380,
});
jest.spyOn(BrowserApi, "createWindow").mockImplementation();
jest.spyOn(BrowserApi, "updateWindowProperties").mockImplementation();
jest.spyOn(BrowserApi, "getPlatformInfo").mockImplementation();
});
it("creates a window with the default window options", async () => {
@@ -267,6 +274,63 @@ describe("BrowserPopupUtils", () => {
url: `chrome-extension://id/${url}?uilocation=popout&singleActionPopout=123`,
});
});
it("exits fullscreen and focuses popout window if the current window is fullscreen and platform is mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "mac",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockReset().mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
jest
.spyOn(BrowserApi, "createWindow")
.mockResolvedValueOnce({ id: 2 } as chrome.windows.Window);
await BrowserPopupUtils.openPopout(url, { senderWindowId: 1 });
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(1, {
state: "maximized",
});
expect(BrowserApi.updateWindowProperties).toHaveBeenCalledWith(2, {
focused: true,
});
});
it("doesnt exit fullscreen if the platform is not mac", async () => {
const url = "popup/index.html";
jest.spyOn(BrowserPopupUtils as any, "isSingleActionPopoutOpen").mockResolvedValueOnce(false);
jest.spyOn(BrowserApi, "getPlatformInfo").mockReset().mockResolvedValueOnce({
os: "win",
arch: "x86-64",
nacl_arch: "x86-64",
});
jest.spyOn(BrowserApi, "getWindow").mockResolvedValueOnce({
id: 1,
left: 100,
top: 100,
focused: false,
alwaysOnTop: false,
incognito: false,
width: 380,
state: "fullscreen",
});
await BrowserPopupUtils.openPopout(url);
expect(BrowserApi.updateWindowProperties).not.toHaveBeenCalledWith(1, {
state: "maximized",
});
});
});
describe("openCurrentPagePopout", () => {

View File

@@ -168,8 +168,29 @@ export default class BrowserPopupUtils {
) {
return;
}
const platform = await BrowserApi.getPlatformInfo();
const isMacOS = platform.os === "mac";
const isFullscreen = senderWindow.state === "fullscreen";
const isFullscreenAndMacOS = isFullscreen && isMacOS;
//macOS specific handling for improved UX when sender in fullscreen aka green button;
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(senderWindow.id, {
state: "maximized",
});
return await BrowserApi.createWindow(popoutWindowOptions);
//wait for macOS animation to finish
await new Promise((resolve) => setTimeout(resolve, 1000));
}
const newWindow = await BrowserApi.createWindow(popoutWindowOptions);
if (isFullscreenAndMacOS) {
await BrowserApi.updateWindowProperties(newWindow.id, {
focused: true,
});
}
return newWindow;
}
/**

View File

@@ -29,11 +29,9 @@ import {
SearchModule,
SectionComponent,
ScrollLayoutDirective,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
} from "@bitwarden/components";
import { VaultLoadingSkeletonComponent } from "../../../vault/popup/components/vault-loading-skeleton/vault-loading-skeleton.component";
import { PopupRouterCacheService } from "../view-cache/popup-router-cache.service";
import { PopupFooterComponent } from "./popup-footer.component";
@@ -366,9 +364,7 @@ export default {
SectionComponent,
IconButtonModule,
BadgeModule,
SkeletonComponent,
SkeletonTextComponent,
SkeletonGroupComponent,
VaultLoadingSkeletonComponent,
],
providers: [
{
@@ -634,21 +630,9 @@ export const SkeletonLoading: Story = {
template: /* HTML */ `
<extension-container>
<popup-tab-navigation>
<popup-page>
<popup-page hideOverflow>
<popup-header slot="header" pageTitle="Page Header"></popup-header>
<div>
<div class="tw-sr-only" role="status">Loading...</div>
<div class="tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-1/3"></bit-skeleton-text>
@for (num of data; track $index) {
<bit-skeleton-group>
<bit-skeleton class="tw-size-8" slot="start"></bit-skeleton>
<bit-skeleton-text [lines]="2" class="tw-w-1/2"></bit-skeleton-text>
</bit-skeleton-group>
<bit-skeleton class="tw-w-full tw-h-[1px]"></bit-skeleton>
}
</div>
</div>
<vault-loading-skeleton></vault-loading-skeleton>
</popup-page>
</popup-tab-navigation>
</extension-container>

View File

@@ -1,7 +1,7 @@
<ng-content select="[slot=header]"></ng-content>
<main class="tw-flex-1 tw-overflow-hidden tw-flex tw-flex-col tw-relative tw-bg-background-alt">
<ng-content select="[slot=full-width-notice]"></ng-content>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
@@ -10,26 +10,28 @@
#nonScrollable
class="tw-transition-colors tw-duration-200 tw-border-0 tw-border-b tw-border-solid tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]"
[ngClass]="{
'tw-invisible !tw-p-0 !tw-border-none': loading || nonScrollable.childElementCount === 0,
'tw-invisible !tw-p-0 !tw-border-none': loading() || nonScrollable.childElementCount === 0,
'tw-border-secondary-300': scrolled(),
'tw-border-transparent': !scrolled(),
}"
>
<ng-content select="[slot=above-scroll-area]"></ng-content>
</div>
<!--
<!--
x padding on this container is designed to always be a minimum of 0.75rem (equivalent to tailwind's tw-px-3), or 0.5rem (equivalent
to tailwind's tw-px-2) in compact mode, but stretch to fill the remainder of the container when the content reaches a maximum of
640px in width (equivalent to tailwind's `sm` breakpoint)
-->
<div
class="tw-overflow-y-auto tw-size-full tw-styled-scrollbar"
class="tw-size-full tw-styled-scrollbar"
data-testid="popup-layout-scroll-region"
(scroll)="handleScroll($event)"
[ngClass]="{
'tw-invisible': loading,
'tw-overflow-hidden': hideOverflow(),
'tw-overflow-y-auto': !hideOverflow(),
'tw-invisible': loading(),
'tw-py-3 bit-compact:tw-py-2 tw-px-[max(0.75rem,calc((100%-(var(--tw-sm-breakpoint)))/2))] bit-compact:tw-px-[max(0.5rem,calc((100%-(var(--tw-sm-breakpoint)))/2))]':
!disablePadding,
!disablePadding(),
}"
bitScrollLayoutHost
>
@@ -37,9 +39,9 @@
</div>
<span
class="tw-absolute tw-inset-0 tw-flex tw-items-center tw-justify-center tw-text-main"
[ngClass]="{ 'tw-invisible': !loading }"
[ngClass]="{ 'tw-invisible': !loading() }"
>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText"></i>
<i class="bwi bwi-spinner bwi-lg bwi-spin" [attr.aria-label]="loadingText()"></i>
</span>
</main>
<ng-content select="[slot=footer]"></ng-content>

View File

@@ -1,11 +1,16 @@
import { CommonModule } from "@angular/common";
import { booleanAttribute, Component, inject, Input, signal } from "@angular/core";
import {
booleanAttribute,
ChangeDetectionStrategy,
Component,
inject,
input,
signal,
} from "@angular/core";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ScrollLayoutHostDirective } from "@bitwarden/components";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "popup-page",
templateUrl: "popup-page.component.html",
@@ -13,28 +18,23 @@ import { ScrollLayoutHostDirective } from "@bitwarden/components";
class: "tw-h-full tw-flex tw-flex-col tw-overflow-y-hidden",
},
imports: [CommonModule, ScrollLayoutHostDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PopupPageComponent {
protected i18nService = inject(I18nService);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loading = false;
readonly loading = input<boolean>(false);
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input({ transform: booleanAttribute })
disablePadding = false;
readonly disablePadding = input(false, { transform: booleanAttribute });
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
protected scrolled = signal(false);
/** Hides any overflow within the page content */
readonly hideOverflow = input(false, { transform: booleanAttribute });
protected readonly scrolled = signal(false);
isScrolled = this.scrolled.asReadonly();
/** Accessible loading label for the spinner. Defaults to "loading" */
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@Input() loadingText?: string = this.i18nService.t("loading");
readonly loadingText = input<string | undefined>(this.i18nService.t("loading"));
handleScroll(event: Event) {
this.scrolled.set((event.currentTarget as HTMLElement).scrollTop !== 0);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,15 @@
<section aria-hidden="true">
<div class="tw-mt-1.5 tw-flex tw-flex-col tw-gap-4">
<bit-skeleton-text class="tw-w-[8.625rem] tw-max-w-full tw-mb-2.5"></bit-skeleton-text>
@for (num of numberOfItems; track $index) {
<bit-skeleton-group class="tw-mx-2">
<bit-skeleton class="tw-size-6" slot="start"></bit-skeleton>
<div class="tw-flex tw-flex-col tw-gap-1">
<bit-skeleton class="tw-w-40 tw-h-2.5 tw-max-w-full"></bit-skeleton>
<bit-skeleton class="tw-w-24 tw-h-2.5 tw-max-w-full"></bit-skeleton>
</div>
</bit-skeleton-group>
<hr class="tw-h-[1px] -tw-mr-3 tw-bg-secondary-100 tw-border-none" />
}
</div>
</section>

View File

@@ -0,0 +1,17 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import {
SkeletonComponent,
SkeletonGroupComponent,
SkeletonTextComponent,
} from "@bitwarden/components";
@Component({
selector: "vault-loading-skeleton",
templateUrl: "./vault-loading-skeleton.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [SkeletonGroupComponent, SkeletonComponent, SkeletonTextComponent],
})
export class VaultLoadingSkeletonComponent {
protected readonly numberOfItems: null[] = new Array(15).fill(null);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,16 +1,22 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { firstValueFrom } from "rxjs";
import { LockService } from "@bitwarden/auth/common";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { Response } from "../../models/response";
import { MessageResponse } from "../../models/response/message.response";
export class LockCommand {
constructor(private vaultTimeoutService: VaultTimeoutService) {}
constructor(
private lockService: LockService,
private accountService: AccountService,
) {}
async run() {
await this.vaultTimeoutService.lock();
process.env.BW_SESSION = null;
const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.lockService.lock(activeUserId);
process.env.BW_SESSION = undefined;
const res = new MessageResponse("Your vault is locked.", null);
return Response.success(res);
}

View File

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

View File

@@ -0,0 +1,10 @@
import { ProcessReloadServiceAbstraction } from "@bitwarden/common/key-management/abstractions/process-reload.service";
/**
* CLI implementation of ProcessReloadServiceAbstraction.
* This is NOOP since there is no effective way to process reload the CLI.
*/
export class CliProcessReloadService extends ProcessReloadServiceAbstraction {
async startProcessReload(): Promise<void> {}
async cancelProcessReload(): Promise<void> {}
}

View File

@@ -160,7 +160,10 @@ export class OssServeConfigurator {
this.serviceContainer.cipherService,
this.serviceContainer.accountService,
);
this.lockCommand = new LockCommand(this.serviceContainer.vaultTimeoutService);
this.lockCommand = new LockCommand(
serviceContainer.lockService,
serviceContainer.accountService,
);
this.unlockCommand = new UnlockCommand(
this.serviceContainer.accountService,
this.serviceContainer.masterPasswordService,

View File

@@ -0,0 +1,10 @@
import { SystemService } from "@bitwarden/common/platform/abstractions/system.service";
/**
* CLI implementation of SystemService.
* The implementation is NOOP since these functions are meant for GUI clients.
*/
export class CliSystemService extends SystemService {
async clearClipboard(clipboardValue: string, timeoutMs?: number): Promise<void> {}
async clearPendingClipboard(): Promise<any> {}
}

View File

@@ -250,7 +250,10 @@ export class Program extends BaseProgram {
return;
}
const command = new LockCommand(this.serviceContainer.vaultTimeoutService);
const command = new LockCommand(
this.serviceContainer.lockService,
this.serviceContainer.accountService,
);
const response = await command.run();
this.processResponse(response);
});

View File

@@ -20,6 +20,9 @@ import {
SsoUrlService,
AuthRequestApiServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLockService,
DefaultLogoutService,
LockService,
} from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { EventUploadService as EventUploadServiceAbstraction } from "@bitwarden/common/abstractions/event/event-upload.service";
@@ -199,9 +202,11 @@ import {
} from "@bitwarden/vault-export-core";
import { CliBiometricsService } from "../key-management/cli-biometrics-service";
import { CliProcessReloadService } from "../key-management/cli-process-reload.service";
import { flagEnabled } from "../platform/flags";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { CliSdkLoadService } from "../platform/services/cli-sdk-load.service";
import { CliSystemService } from "../platform/services/cli-system.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
@@ -318,6 +323,7 @@ export class ServiceContainer {
securityStateService: SecurityStateService;
masterPasswordUnlockService: MasterPasswordUnlockService;
cipherArchiveService: CipherArchiveService;
lockService: LockService;
constructor() {
let p = null;
@@ -778,9 +784,6 @@ export class ServiceContainer {
this.folderApiService = new FolderApiService(this.folderService, this.apiService);
const lockedCallback = async (userId: UserId) =>
await this.keyService.clearStoredUserKey(userId);
this.userVerificationApiService = new UserVerificationApiService(this.apiService);
this.userVerificationService = new UserVerificationService(
@@ -796,25 +799,35 @@ export class ServiceContainer {
);
const biometricService = new CliBiometricsService();
const logoutService = new DefaultLogoutService(this.messagingService);
const processReloadService = new CliProcessReloadService();
const systemService = new CliSystemService();
this.lockService = new DefaultLockService(
this.accountService,
biometricService,
this.vaultTimeoutSettingsService,
logoutService,
this.messagingService,
this.searchService,
this.folderService,
this.masterPasswordService,
this.stateEventRunnerService,
this.cipherService,
this.authService,
systemService,
processReloadService,
this.logService,
this.keyService,
);
this.vaultTimeoutService = new DefaultVaultTimeoutService(
this.accountService,
this.masterPasswordService,
this.cipherService,
this.folderService,
this.collectionService,
this.platformUtilsService,
this.messagingService,
this.searchService,
this.stateService,
this.tokenService,
this.authService,
this.vaultTimeoutSettingsService,
this.stateEventRunnerService,
this.taskSchedulerService,
this.logService,
biometricService,
lockedCallback,
this.lockService,
undefined,
);

View File

@@ -92,18 +92,18 @@ export class CreateCommand {
}
private async createCipher(req: CipherExport) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
try {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const cipherView = CipherExport.toView(req);
const isCipherTypeRestricted =
await this.cliRestrictedItemTypesService.isCipherRestricted(cipherView);
if (isCipherTypeRestricted) {
return Response.error("Creating this item type is restricted by organizational policy.");
}
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
const newCipher = await this.cipherService.createWithServer(cipher);
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
const res = new CipherResponse(decCipher);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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