1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-13 06:54:07 +00:00

Merge branch 'main' into ps/extension-refresh

This commit is contained in:
Vicki League
2024-08-07 11:08:31 -04:00
163 changed files with 4236 additions and 1148 deletions

View File

@@ -2000,6 +2000,12 @@
"nativeMessagingWrongUserTitle": {
"message": "Account missmatch"
},
"nativeMessagingWrongUserKeyDesc": {
"message": "Biometric unlock failed. The biometric secret key failed to unlock the vault. Please try to set up biometrics again."
},
"nativeMessagingWrongUserKeyTitle": {
"message": "Biometric key missmatch"
},
"biometricsNotEnabledTitle": {
"message": "Biometrics not set up"
},
@@ -4091,6 +4097,12 @@
"itemLocation": {
"message": "Item Location"
},
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
},
"bitwardenNewLook": {
"message": "Bitwarden has a new look!"
},

View File

@@ -0,0 +1,23 @@
import { Observable, Subject } from "rxjs";
import {
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
} from "@bitwarden/auth/angular";
import { ExtensionAnonLayoutWrapperData } from "./extension-anon-layout-wrapper.component";
export class ExtensionAnonLayoutWrapperDataService
extends DefaultAnonLayoutWrapperDataService
implements AnonLayoutWrapperDataService
{
protected override anonLayoutWrapperDataSubject = new Subject<ExtensionAnonLayoutWrapperData>();
override setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData): void {
this.anonLayoutWrapperDataSubject.next(data);
}
override anonLayoutWrapperData$(): Observable<ExtensionAnonLayoutWrapperData> {
return this.anonLayoutWrapperDataSubject.asObservable();
}
}

View File

@@ -0,0 +1,28 @@
<popup-page>
<popup-header
slot="header"
[background]="'alt'"
[showBackButton]="showBackButton"
[pageTitle]="''"
>
<bit-icon *ngIf="showLogo" [icon]="logo"></bit-icon>
<ng-container slot="end">
<app-pop-out></app-pop-out>
<app-current-account *ngIf="showAcctSwitcher"></app-current-account>
</ng-container>
</popup-header>
<auth-anon-layout
[title]="pageTitle"
[subtitle]="pageSubtitle"
[icon]="pageIcon"
[showReadonlyHostname]="showReadonlyHostname"
[hideLogo]="true"
[decreaseTopPadding]="true"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>
<router-outlet slot="environment-selector" name="environment-selector"></router-outlet>
</auth-anon-layout>
</popup-page>

View File

@@ -0,0 +1,190 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { ActivatedRoute, Data, NavigationEnd, Router, RouterModule } from "@angular/router";
import { Subject, filter, firstValueFrom, switchMap, takeUntil, tap } from "rxjs";
import {
AnonLayoutComponent,
AnonLayoutWrapperData,
AnonLayoutWrapperDataService,
} from "@bitwarden/auth/angular";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { Icon, IconModule } from "@bitwarden/components";
import { PopOutComponent } from "../../../platform/popup/components/pop-out.component";
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { CurrentAccountComponent } from "../account-switching/current-account.component";
import {
ExtensionBitwardenLogoPrimary,
ExtensionBitwardenLogoWhite,
} from "./extension-bitwarden-logo.icon";
export interface ExtensionAnonLayoutWrapperData extends AnonLayoutWrapperData {
showAcctSwitcher?: boolean;
showBackButton?: boolean;
showLogo?: boolean;
}
@Component({
standalone: true,
templateUrl: "extension-anon-layout-wrapper.component.html",
imports: [
AnonLayoutComponent,
CommonModule,
CurrentAccountComponent,
IconModule,
PopOutComponent,
PopupPageComponent,
PopupHeaderComponent,
RouterModule,
],
})
export class ExtensionAnonLayoutWrapperComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
protected showAcctSwitcher: boolean;
protected showBackButton: boolean;
protected showLogo: boolean = true;
protected pageTitle: string;
protected pageSubtitle: string;
protected pageIcon: Icon;
protected showReadonlyHostname: boolean;
protected maxWidth: "md" | "3xl";
protected theme: string;
protected logo: Icon;
constructor(
private router: Router,
private route: ActivatedRoute,
private i18nService: I18nService,
private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private themeStateService: ThemeStateService,
) {}
async ngOnInit(): Promise<void> {
// Set the initial page data on load
this.setAnonLayoutWrapperDataFromRouteData(this.route.snapshot.firstChild?.data);
// Listen for page changes and update the page data appropriately
this.listenForPageDataChanges();
this.listenForServiceDataChanges();
this.theme = await firstValueFrom(this.themeStateService.selectedTheme$);
if (this.theme === "dark") {
this.logo = ExtensionBitwardenLogoWhite;
} else {
this.logo = ExtensionBitwardenLogoPrimary;
}
}
private listenForPageDataChanges() {
this.router.events
.pipe(
filter((event) => event instanceof NavigationEnd),
// reset page data on page changes
tap(() => this.resetPageData()),
switchMap(() => this.route.firstChild?.data || null),
takeUntil(this.destroy$),
)
.subscribe((firstChildRouteData: Data | null) => {
this.setAnonLayoutWrapperDataFromRouteData(firstChildRouteData);
});
}
private setAnonLayoutWrapperDataFromRouteData(firstChildRouteData: Data | null) {
if (!firstChildRouteData) {
return;
}
if (firstChildRouteData["pageTitle"] !== undefined) {
this.pageTitle = this.i18nService.t(firstChildRouteData["pageTitle"]);
}
if (firstChildRouteData["pageSubtitle"] !== undefined) {
this.pageSubtitle = this.i18nService.t(firstChildRouteData["pageSubtitle"]);
}
if (firstChildRouteData["pageIcon"] !== undefined) {
this.pageIcon = firstChildRouteData["pageIcon"];
}
this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]);
this.maxWidth = firstChildRouteData["maxWidth"];
if (firstChildRouteData["showAcctSwitcher"] !== undefined) {
this.showAcctSwitcher = Boolean(firstChildRouteData["showAcctSwitcher"]);
}
if (firstChildRouteData["showBackButton"] !== undefined) {
this.showBackButton = Boolean(firstChildRouteData["showBackButton"]);
}
if (firstChildRouteData["showLogo"] !== undefined) {
this.showLogo = Boolean(firstChildRouteData["showLogo"]);
}
}
private listenForServiceDataChanges() {
this.extensionAnonLayoutWrapperDataService
.anonLayoutWrapperData$()
.pipe(takeUntil(this.destroy$))
.subscribe((data: ExtensionAnonLayoutWrapperData) => {
this.setAnonLayoutWrapperData(data);
});
}
private setAnonLayoutWrapperData(data: ExtensionAnonLayoutWrapperData) {
if (!data) {
return;
}
if (data.pageTitle) {
this.pageTitle = this.i18nService.t(data.pageTitle);
}
if (data.pageSubtitle) {
this.pageSubtitle = this.i18nService.t(data.pageSubtitle);
}
if (data.pageIcon) {
this.pageIcon = data.pageIcon;
}
if (data.showReadonlyHostname != null) {
this.showReadonlyHostname = data.showReadonlyHostname;
}
if (data.showAcctSwitcher != null) {
this.showAcctSwitcher = data.showAcctSwitcher;
}
if (data.showBackButton != null) {
this.showBackButton = data.showBackButton;
}
if (data.showLogo != null) {
this.showLogo = data.showLogo;
}
}
private resetPageData() {
this.pageTitle = null;
this.pageSubtitle = null;
this.pageIcon = null;
this.showReadonlyHostname = null;
this.showAcctSwitcher = null;
this.showBackButton = null;
this.showLogo = null;
}
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

View File

@@ -0,0 +1,294 @@
import { importProvidersFrom, Component } from "@angular/core";
import { RouterModule, Routes } from "@angular/router";
import {
Meta,
StoryObj,
applicationConfig,
componentWrapperDecorator,
moduleMetadata,
} from "@storybook/angular";
import { of } from "rxjs";
import { AnonLayoutWrapperDataService, LockIcon } from "@bitwarden/auth/angular";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ClientType } from "@bitwarden/common/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Environment,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { UserId } from "@bitwarden/common/types/guid";
import { ButtonModule, I18nMockService } from "@bitwarden/components";
import { RegistrationCheckEmailIcon } from "../../../../../../libs/auth/src/angular/icons/registration-check-email.icon";
import { ExtensionAnonLayoutWrapperDataService } from "./extension-anon-layout-wrapper-data.service";
import {
ExtensionAnonLayoutWrapperComponent,
ExtensionAnonLayoutWrapperData,
} from "./extension-anon-layout-wrapper.component";
export default {
title: "Auth/Extension Anon Layout Wrapper",
component: ExtensionAnonLayoutWrapperComponent,
} as Meta;
const decorators = (options: {
components: any[];
routes: Routes;
applicationVersion?: string;
clientType?: ClientType;
hostName?: string;
themeType?: ThemeType;
}) => {
return [
componentWrapperDecorator(
/**
* Applying a CSS transform makes a `position: fixed` element act like it is `position: relative`
* https://github.com/storybookjs/storybook/issues/8011#issue-490251969
*/
(story) => {
return /* HTML */ `<div class="tw-scale-100 ">${story}</div>`;
},
({ globals }) => {
/**
* avoid a bug with the way that we render the same component twice in the same iframe and how
* that interacts with the router-outlet
*/
const themeOverride = globals["theme"] === "both" ? "light" : globals["theme"];
return { theme: themeOverride };
},
),
moduleMetadata({
declarations: options.components,
imports: [RouterModule, ButtonModule],
providers: [
{
provide: AnonLayoutWrapperDataService,
useClass: ExtensionAnonLayoutWrapperDataService,
},
{
provide: AccountService,
useValue: {
activeAccount$: of({
id: "test-user-id" as UserId,
name: "Test User 1",
email: "test@email.com",
emailVerified: true,
}),
},
},
{
provide: AuthService,
useValue: {
activeAccountStatus$: of(AuthenticationStatus.Unlocked),
},
},
{
provide: AvatarService,
useValue: {
avatarColor$: of("#ab134a"),
} as Partial<AvatarService>,
},
{
provide: ConfigService,
useValue: {
getFeatureFlag: () => true,
},
},
{
provide: EnvironmentService,
useValue: {
environment$: of({
getHostname: () => options.hostName || "storybook.bitwarden.com",
} as Partial<Environment>),
} as Partial<EnvironmentService>,
},
{
provide: PlatformUtilsService,
useValue: {
getApplicationVersion: () =>
Promise.resolve(options.applicationVersion || "FAKE_APP_VERSION"),
getClientType: () => options.clientType || ClientType.Web,
} as Partial<PlatformUtilsService>,
},
{
provide: ThemeStateService,
useValue: {
selectedTheme$: of(options.themeType || ThemeType.Light),
} as Partial<ThemeStateService>,
},
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
setAStrongPassword: "Set a strong password",
finishCreatingYourAccountBySettingAPassword:
"Finish creating your account by setting a password",
enterpriseSingleSignOn: "Enterprise single sign-on",
checkYourEmail: "Check your email",
loading: "Loading",
popOutNewWindow: "Pop out to a new window",
switchAccounts: "Switch accounts",
back: "Back",
activeAccount: "Active account",
});
},
},
],
}),
applicationConfig({
providers: [importProvidersFrom(RouterModule.forRoot(options.routes))],
}),
];
};
type Story = StoryObj<ExtensionAnonLayoutWrapperComponent>;
// Default Example
@Component({
selector: "bit-default-primary-outlet-example-component",
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
})
class DefaultPrimaryOutletExampleComponent {}
@Component({
selector: "bit-default-secondary-outlet-example-component",
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
})
class DefaultSecondaryOutletExampleComponent {}
@Component({
selector: "bit-default-env-selector-outlet-example-component",
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
})
class DefaultEnvSelectorOutletExampleComponent {}
export const DefaultContentExample: Story = {
render: (args) => ({
props: args,
template: "<router-outlet></router-outlet>",
}),
decorators: decorators({
components: [
DefaultPrimaryOutletExampleComponent,
DefaultSecondaryOutletExampleComponent,
DefaultEnvSelectorOutletExampleComponent,
],
routes: [
{
path: "**",
redirectTo: "default-example",
pathMatch: "full",
},
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "default-example",
data: {},
children: [
{
path: "",
component: DefaultPrimaryOutletExampleComponent,
},
{
path: "",
component: DefaultSecondaryOutletExampleComponent,
outlet: "secondary",
},
{
path: "",
component: DefaultEnvSelectorOutletExampleComponent,
outlet: "environment-selector",
},
],
},
],
},
],
}),
};
// Dynamic Content Example
const initialData: ExtensionAnonLayoutWrapperData = {
pageTitle: "setAStrongPassword",
pageSubtitle: "finishCreatingYourAccountBySettingAPassword",
pageIcon: LockIcon,
showAcctSwitcher: true,
showBackButton: true,
showLogo: true,
};
const changedData: ExtensionAnonLayoutWrapperData = {
pageTitle: "enterpriseSingleSignOn",
pageSubtitle: "checkYourEmail",
pageIcon: RegistrationCheckEmailIcon,
showAcctSwitcher: false,
showBackButton: false,
showLogo: false,
};
@Component({
selector: "bit-dynamic-content-example-component",
template: `
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
`,
})
export class DynamicContentExampleComponent {
initialData = true;
constructor(private extensionAnonLayoutWrapperDataService: AnonLayoutWrapperDataService) {}
toggleData() {
if (this.initialData) {
this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData);
} else {
this.extensionAnonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData);
}
this.initialData = !this.initialData;
}
}
export const DynamicContentExample: Story = {
render: (args) => ({
props: args,
template: "<router-outlet></router-outlet>",
}),
decorators: decorators({
components: [DynamicContentExampleComponent],
routes: [
{
path: "**",
redirectTo: "dynamic-content-example",
pathMatch: "full",
},
{
path: "",
component: ExtensionAnonLayoutWrapperComponent,
children: [
{
path: "dynamic-content-example",
data: initialData,
children: [
{
path: "",
component: DynamicContentExampleComponent,
},
],
},
],
},
],
}),
};

File diff suppressed because one or more lines are too long

View File

@@ -95,6 +95,10 @@ export type OverlayAddNewItemMessage = {
identity?: NewIdentityCipherData;
};
export type CurrentAddNewItemData = OverlayAddNewItemMessage & {
sender: chrome.runtime.MessageSender;
};
export type CloseInlineMenuMessage = {
forceCloseInlineMenu?: boolean;
overlayElement?: string;
@@ -161,7 +165,7 @@ export type OverlayBackgroundExtensionMessageHandlers = {
triggerAutofillOverlayReposition: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
checkIsInlineMenuCiphersPopulated: ({ sender }: BackgroundSenderParam) => void;
updateFocusedFieldData: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
updateIsFieldCurrentlyFocused: ({ message }: BackgroundMessageParam) => void;
updateIsFieldCurrentlyFocused: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
checkIsFieldCurrentlyFocused: () => boolean;
updateIsFieldCurrentlyFilling: ({ message }: BackgroundMessageParam) => void;
checkIsFieldCurrentlyFilling: () => boolean;

View File

@@ -176,8 +176,12 @@ describe("OverlayBackground", () => {
parentFrameId: getFrameCounter,
});
});
tabsSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage");
tabSendMessageDataSpy = jest.spyOn(BrowserApi, "tabSendMessageData");
tabsSendMessageSpy = jest
.spyOn(BrowserApi, "tabSendMessage")
.mockImplementation(() => Promise.resolve());
tabSendMessageDataSpy = jest
.spyOn(BrowserApi, "tabSendMessageData")
.mockImplementation(() => Promise.resolve());
sendMessageSpy = jest.spyOn(BrowserApi, "sendMessage");
getTabFromCurrentWindowIdSpy = jest.spyOn(BrowserApi, "getTabFromCurrentWindowId");
getTabSpy = jest.spyOn(BrowserApi, "getTab");
@@ -526,10 +530,13 @@ describe("OverlayBackground", () => {
});
it("skips updating the position of either inline menu element if a field is not currently focused", async () => {
sendMockExtensionMessage({
command: "updateIsFieldCurrentlyFocused",
isFieldCurrentlyFocused: false,
});
sendMockExtensionMessage(
{
command: "updateIsFieldCurrentlyFocused",
isFieldCurrentlyFocused: false,
},
mock<chrome.runtime.MessageSender>({ frameId: 20 }),
);
sendMockExtensionMessage({ command: "triggerAutofillOverlayReposition" }, sender);
await flushUpdateInlineMenuPromises();
@@ -835,7 +842,7 @@ describe("OverlayBackground", () => {
it("posts an `updateOverlayListCiphers` message to the overlay list port, and send a `updateAutofillInlineMenuListCiphers` message to the tab indicating that the list of ciphers is populated", async () => {
overlayBackground["focusedFieldData"] = createFocusedFieldDataMock({ tabId: tab.id });
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1, cipher2]);
cipherService.getAllDecryptedForUrl.mockResolvedValue([cipher1]);
cipherService.sortCiphersByLastUsedThenName.mockReturnValue(-1);
getTabFromCurrentWindowIdSpy.mockResolvedValueOnce(tab);
@@ -854,7 +861,7 @@ describe("OverlayBackground", () => {
image: "https://icons.bitwarden.com//jest-testing-website.com/icon.png",
imageEnabled: true,
},
id: "inline-menu-cipher-1",
id: "inline-menu-cipher-0",
login: {
username: "username-1",
},
@@ -1116,10 +1123,12 @@ describe("OverlayBackground", () => {
let openAddEditVaultItemPopoutSpy: jest.SpyInstance;
beforeEach(() => {
jest.useFakeTimers();
sender = mock<chrome.runtime.MessageSender>({ tab: { id: 1 } });
openAddEditVaultItemPopoutSpy = jest
.spyOn(overlayBackground as any, "openAddEditVaultItemPopout")
.mockImplementation();
overlayBackground["currentAddNewItemData"] = { sender, addNewCipherType: CipherType.Login };
});
it("will not open the add edit popout window if the message does not have a login cipher provided", () => {
@@ -1129,6 +1138,28 @@ describe("OverlayBackground", () => {
expect(openAddEditVaultItemPopoutSpy).not.toHaveBeenCalled();
});
it("resets the currentAddNewItemData to null when a cipher view is not successfully created", async () => {
jest.spyOn(overlayBackground as any, "buildLoginCipherView").mockReturnValue(null);
sendMockExtensionMessage(
{
command: "autofillOverlayAddNewVaultItem",
addNewCipherType: CipherType.Login,
login: {
uri: "https://tacos.com",
hostname: "",
username: "username",
password: "password",
},
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(overlayBackground["currentAddNewItemData"]).toBeNull();
});
it("will open the add edit popout window after creating a new cipher", async () => {
sendMockExtensionMessage(
{
@@ -1143,6 +1174,7 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
@@ -1151,6 +1183,8 @@ describe("OverlayBackground", () => {
});
it("creates a new card cipher", async () => {
overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Card;
sendMockExtensionMessage(
{
command: "autofillOverlayAddNewVaultItem",
@@ -1166,6 +1200,7 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
@@ -1174,6 +1209,10 @@ describe("OverlayBackground", () => {
});
describe("creating a new identity cipher", () => {
beforeEach(() => {
overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Identity;
});
it("populates an identity cipher view and creates it", async () => {
sendMockExtensionMessage(
{
@@ -1200,6 +1239,7 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
@@ -1220,6 +1260,7 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
@@ -1238,6 +1279,7 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
@@ -1256,11 +1298,173 @@ describe("OverlayBackground", () => {
},
sender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(cipherService.setAddEditCipherInfo).toHaveBeenCalled();
});
});
describe("pulling cipher data from multiple frames of a tab", () => {
let subFrameSender: MockProxy<chrome.runtime.MessageSender>;
const command = "autofillOverlayAddNewVaultItem";
beforeEach(() => {
subFrameSender = mock<chrome.runtime.MessageSender>({ tab: sender.tab, frameId: 2 });
});
it("combines the login cipher data from all frames", async () => {
const buildLoginCipherViewSpy = jest.spyOn(
overlayBackground as any,
"buildLoginCipherView",
);
const addNewCipherType = CipherType.Login;
const loginCipherData = {
uri: "https://tacos.com",
hostname: "",
username: "username",
password: "",
};
const subFrameLoginCipherData = {
uri: "https://tacos.com",
hostname: "tacos.com",
username: "",
password: "password",
};
sendMockExtensionMessage({ command, addNewCipherType, login: loginCipherData }, sender);
sendMockExtensionMessage(
{ command, addNewCipherType, login: subFrameLoginCipherData },
subFrameSender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(buildLoginCipherViewSpy).toHaveBeenCalledWith({
uri: "https://tacos.com",
hostname: "tacos.com",
username: "username",
password: "password",
});
});
it("combines the card cipher data from all frames", async () => {
const buildCardCipherViewSpy = jest.spyOn(
overlayBackground as any,
"buildCardCipherView",
);
overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Card;
const addNewCipherType = CipherType.Card;
const cardCipherData = {
cardholderName: "cardholderName",
number: "",
expirationMonth: "",
expirationYear: "",
expirationDate: "12/25",
cvv: "123",
};
const subFrameCardCipherData = {
cardholderName: "",
number: "4242424242424242",
expirationMonth: "12",
expirationYear: "2025",
expirationDate: "",
cvv: "",
};
sendMockExtensionMessage({ command, addNewCipherType, card: cardCipherData }, sender);
sendMockExtensionMessage(
{ command, addNewCipherType, card: subFrameCardCipherData },
subFrameSender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(buildCardCipherViewSpy).toHaveBeenCalledWith({
cardholderName: "cardholderName",
number: "4242424242424242",
expirationMonth: "12",
expirationYear: "2025",
expirationDate: "12/25",
cvv: "123",
});
});
it("combines the identity cipher data from all frames", async () => {
const buildIdentityCipherViewSpy = jest.spyOn(
overlayBackground as any,
"buildIdentityCipherView",
);
overlayBackground["currentAddNewItemData"].addNewCipherType = CipherType.Identity;
const addNewCipherType = CipherType.Identity;
const identityCipherData = {
title: "title",
firstName: "firstName",
middleName: "middleName",
lastName: "",
fullName: "",
address1: "address1",
address2: "address2",
address3: "address3",
city: "city",
state: "state",
postalCode: "postalCode",
country: "country",
company: "company",
phone: "phone",
email: "email",
username: "username",
};
const subFrameIdentityCipherData = {
title: "",
firstName: "",
middleName: "",
lastName: "lastName",
fullName: "fullName",
address1: "",
address2: "",
address3: "",
city: "",
state: "",
postalCode: "",
country: "",
company: "",
phone: "",
email: "",
username: "",
};
sendMockExtensionMessage(
{ command, addNewCipherType, identity: identityCipherData },
sender,
);
sendMockExtensionMessage(
{ command, addNewCipherType, identity: subFrameIdentityCipherData },
subFrameSender,
);
jest.advanceTimersByTime(100);
await flushPromises();
expect(buildIdentityCipherViewSpy).toHaveBeenCalledWith({
title: "title",
firstName: "firstName",
middleName: "middleName",
lastName: "lastName",
fullName: "fullName",
address1: "address1",
address2: "address2",
address3: "address3",
city: "city",
state: "state",
postalCode: "postalCode",
country: "country",
company: "company",
phone: "phone",
email: "email",
username: "username",
});
});
});
});
describe("checkIsInlineMenuCiphersPopulated message handler", () => {
@@ -1360,6 +1564,70 @@ describe("OverlayBackground", () => {
showInlineMenuAccountCreation: true,
});
});
it("triggers an update of the inline menu ciphers when the new focused field's cipher type does not equal the previous focused field's cipher type", async () => {
const updateOverlayCiphersSpy = jest.spyOn(overlayBackground, "updateOverlayCiphers");
const tab = createChromeTabMock({ id: 2 });
const sender = mock<chrome.runtime.MessageSender>({ tab, frameId: 100 });
const focusedFieldData = createFocusedFieldDataMock({
tabId: tab.id,
frameId: sender.frameId,
filledByCipherType: CipherType.Login,
});
sendMockExtensionMessage({ command: "updateFocusedFieldData", focusedFieldData }, sender);
await flushPromises();
const newFocusedFieldData = createFocusedFieldDataMock({
tabId: tab.id,
frameId: sender.frameId,
filledByCipherType: CipherType.Card,
});
sendMockExtensionMessage(
{ command: "updateFocusedFieldData", focusedFieldData: newFocusedFieldData },
sender,
);
await flushPromises();
expect(updateOverlayCiphersSpy).toHaveBeenCalled();
});
});
describe("updateIsFieldCurrentlyFocused message handler", () => {
it("skips updating the isFiledCurrentlyFocused value when the focused field data is populated and the sender frame id does not equal the focused field's frame id", async () => {
const focusedFieldData = createFocusedFieldDataMock();
sendMockExtensionMessage(
{ command: "updateFocusedFieldData", focusedFieldData },
mock<chrome.runtime.MessageSender>({ tab: { id: 1 }, frameId: 10 }),
);
overlayBackground["isFieldCurrentlyFocused"] = true;
sendMockExtensionMessage(
{ command: "updateIsFieldCurrentlyFocused", isFieldCurrentlyFocused: false },
mock<chrome.runtime.MessageSender>({ tab: { id: 1 }, frameId: 20 }),
);
await flushPromises();
expect(overlayBackground["isFieldCurrentlyFocused"]).toBe(true);
});
});
describe("updateIsFieldCurrentlyFocused message handler", () => {
it("skips updating the isFiledCurrentlyFocused value when the focused field data is populated and the sender frame id does not equal the focused field's frame id", async () => {
const focusedFieldData = createFocusedFieldDataMock();
sendMockExtensionMessage(
{ command: "updateFocusedFieldData", focusedFieldData },
mock<chrome.runtime.MessageSender>({ tab: { id: 1 }, frameId: 10 }),
);
overlayBackground["isFieldCurrentlyFocused"] = true;
sendMockExtensionMessage(
{ command: "updateIsFieldCurrentlyFocused", isFieldCurrentlyFocused: false },
mock<chrome.runtime.MessageSender>({ tab: { id: 1 }, frameId: 20 }),
);
await flushPromises();
expect(overlayBackground["isFieldCurrentlyFocused"]).toBe(true);
});
});
describe("checkIsFieldCurrentlyFocused message handler", () => {
@@ -1819,7 +2087,6 @@ describe("OverlayBackground", () => {
overlayBackground["subFrameOffsetsForTab"][focusedFieldData.tabId] = new Map([
[focusedFieldData.frameId, null],
]);
tabsSendMessageSpy.mockImplementation();
jest.spyOn(overlayBackground as any, "updateInlineMenuPositionAfterRepositionEvent");
sendMockExtensionMessage(
@@ -2068,7 +2335,6 @@ describe("OverlayBackground", () => {
describe("autofillInlineMenuButtonClicked message handler", () => {
it("opens the unlock vault popout if the user auth status is not unlocked", async () => {
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
tabsSendMessageSpy.mockImplementation();
sendPortMessage(buttonMessageConnectorSpy, {
command: "autofillInlineMenuButtonClicked",
@@ -2269,7 +2535,6 @@ describe("OverlayBackground", () => {
describe("unlockVault message handler", () => {
it("opens the unlock vault popout", async () => {
activeAccountStatusMock$.next(AuthenticationStatus.Locked);
tabsSendMessageSpy.mockImplementation();
sendPortMessage(listMessageConnectorSpy, { command: "unlockVault", portKey });
await flushPromises();
@@ -2421,11 +2686,10 @@ describe("OverlayBackground", () => {
});
await flushPromises();
expect(tabsSendMessageSpy).toHaveBeenCalledWith(
sender.tab,
{ command: "addNewVaultItemFromOverlay", addNewCipherType: CipherType.Login },
{ frameId: sender.frameId },
);
expect(tabsSendMessageSpy).toHaveBeenCalledWith(sender.tab, {
command: "addNewVaultItemFromOverlay",
addNewCipherType: CipherType.Login,
});
});
});

View File

@@ -42,6 +42,7 @@ import { generateRandomChars } from "../utils";
import { LockedVaultPendingNotificationsData } from "./abstractions/notification.background";
import {
CloseInlineMenuMessage,
CurrentAddNewItemData,
FocusedFieldData,
InlineMenuButtonPortMessageHandlers,
InlineMenuCipherData,
@@ -83,6 +84,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
private cancelUpdateInlineMenuPositionSubject = new Subject<void>();
private repositionInlineMenuSubject = new Subject<chrome.runtime.MessageSender>();
private rebuildSubFrameOffsetsSubject = new Subject<chrome.runtime.MessageSender>();
private addNewVaultItemSubject = new Subject<CurrentAddNewItemData>();
private currentAddNewItemData: CurrentAddNewItemData;
private focusedFieldData: FocusedFieldData;
private isFieldCurrentlyFocused: boolean = false;
private isFieldCurrentlyFilling: boolean = false;
@@ -97,7 +100,8 @@ export class OverlayBackground implements OverlayBackgroundInterface {
checkIsInlineMenuCiphersPopulated: ({ sender }) =>
this.checkIsInlineMenuCiphersPopulated(sender),
updateFocusedFieldData: ({ message, sender }) => this.setFocusedFieldData(message, sender),
updateIsFieldCurrentlyFocused: ({ message }) => this.updateIsFieldCurrentlyFocused(message),
updateIsFieldCurrentlyFocused: ({ message, sender }) =>
this.updateIsFieldCurrentlyFocused(message, sender),
checkIsFieldCurrentlyFocused: () => this.checkIsFieldCurrentlyFocused(),
updateIsFieldCurrentlyFilling: ({ message }) => this.updateIsFieldCurrentlyFilling(message),
checkIsFieldCurrentlyFilling: () => this.checkIsFieldCurrentlyFilling(),
@@ -186,6 +190,14 @@ export class OverlayBackground implements OverlayBackgroundInterface {
switchMap((sender) => this.rebuildSubFrameOffsets(sender)),
)
.subscribe();
this.addNewVaultItemSubject
.pipe(
debounceTime(100),
switchMap((addNewItemData) =>
this.buildCipherAndOpenAddEditVaultItemPopout(addNewItemData),
),
)
.subscribe();
// Debounce used to update inline menu position
merge(
@@ -230,14 +242,14 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
if (this.focusedFieldData) {
void this.closeInlineMenuAfterCiphersUpdate();
this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error));
}
return;
}
const currentTab = await BrowserApi.getTabFromCurrentWindowId();
if (this.focusedFieldData && currentTab?.id !== this.focusedFieldData.tabId) {
void this.closeInlineMenuAfterCiphersUpdate();
this.closeInlineMenuAfterCiphersUpdate().catch((error) => this.logService.error(error));
}
this.inlineMenuCiphers = new Map();
@@ -318,7 +330,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
private async getInlineMenuCipherData(): Promise<InlineMenuCipherData[]> {
const showFavicons = await firstValueFrom(this.domainSettingsService.showFavicons$);
const inlineMenuCiphersArray = Array.from(this.inlineMenuCiphers);
let inlineMenuCipherData: InlineMenuCipherData[] = [];
let inlineMenuCipherData: InlineMenuCipherData[];
if (this.showInlineMenuAccountCreation()) {
inlineMenuCipherData = this.buildInlineMenuAccountCreationCiphers(
@@ -526,10 +538,14 @@ export class OverlayBackground implements OverlayBackgroundInterface {
};
if (pageDetails.frameId !== 0 && pageDetails.details.fields.length) {
void this.buildSubFrameOffsets(pageDetails.tab, pageDetails.frameId, pageDetails.details.url);
void BrowserApi.tabSendMessage(pageDetails.tab, {
this.buildSubFrameOffsets(
pageDetails.tab,
pageDetails.frameId,
pageDetails.details.url,
).catch((error) => this.logService.error(error));
BrowserApi.tabSendMessage(pageDetails.tab, {
command: "setupRebuildSubFrameOffsetsListeners",
});
}).catch((error) => this.logService.error(error));
}
const pageDetailsMap = this.pageDetailsForTab[sender.tab.id];
@@ -619,11 +635,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
if (!subFrameOffset) {
subFrameOffsetsForTab.set(frameId, null);
void BrowserApi.tabSendMessage(
BrowserApi.tabSendMessage(
tab,
{ command: "getSubFrameOffsetsFromWindowMessage", subFrameId: frameId },
{ frameId },
);
).catch((error) => this.logService.error(error));
return;
}
@@ -655,11 +671,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
frameId,
);
void BrowserApi.tabSendMessage(
BrowserApi.tabSendMessage(
tab,
{ command: "destroyAutofillInlineMenuListeners" },
{ frameId },
);
).catch((error) => this.logService.error(error));
}
/**
@@ -695,13 +711,15 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}
if (!this.checkIsInlineMenuButtonVisible()) {
void this.toggleInlineMenuHidden(
this.toggleInlineMenuHidden(
{ isInlineMenuHidden: false, setTransparentInlineMenu: true },
sender,
);
).catch((error) => this.logService.error(error));
}
void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender);
this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.Button }, sender).catch(
(error) => this.logService.error(error),
);
const mostRecentlyFocusedFieldHasValue = await BrowserApi.tabSendMessage(
sender.tab,
@@ -721,7 +739,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return;
}
void this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender);
this.updateInlineMenuPosition({ overlayElement: AutofillOverlayElement.List }, sender).catch(
(error) => this.logService.error(error),
);
}
/**
@@ -806,7 +826,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
const command = "closeAutofillInlineMenu";
const sendOptions = { frameId: 0 };
if (forceCloseInlineMenu) {
void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions);
BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch(
(error) => this.logService.error(error),
);
this.isInlineMenuButtonVisible = false;
this.isInlineMenuListVisible = false;
return;
@@ -817,11 +839,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
}
if (this.isFieldCurrentlyFilling) {
void BrowserApi.tabSendMessage(
BrowserApi.tabSendMessage(
sender.tab,
{ command, overlayElement: AutofillOverlayElement.List },
sendOptions,
);
).catch((error) => this.logService.error(error));
this.isInlineMenuListVisible = false;
return;
}
@@ -839,7 +861,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.isInlineMenuListVisible = false;
}
void BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions);
BrowserApi.tabSendMessage(sender.tab, { command, overlayElement }, sendOptions).catch((error) =>
this.logService.error(error),
);
}
/**
@@ -1090,23 +1114,34 @@ export class OverlayBackground implements OverlayBackgroundInterface {
{ focusedFieldData }: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
if (this.focusedFieldData?.frameId && this.focusedFieldData.frameId !== sender.frameId) {
void BrowserApi.tabSendMessage(
if (this.focusedFieldData && !this.senderFrameHasFocusedField(sender)) {
BrowserApi.tabSendMessage(
sender.tab,
{ command: "unsetMostRecentlyFocusedField" },
{ frameId: this.focusedFieldData.frameId },
);
).catch((error) => this.logService.error(error));
}
const previousFocusedFieldData = this.focusedFieldData;
this.focusedFieldData = { ...focusedFieldData, tabId: sender.tab.id, frameId: sender.frameId };
this.isFieldCurrentlyFocused = true;
const accountCreationFieldBlurred =
previousFocusedFieldData?.showInlineMenuAccountCreation &&
!this.focusedFieldData.showInlineMenuAccountCreation;
if (accountCreationFieldBlurred || this.showInlineMenuAccountCreation()) {
void this.updateIdentityCiphersOnLoginField(previousFocusedFieldData);
this.updateIdentityCiphersOnLoginField(previousFocusedFieldData).catch((error) =>
this.logService.error(error),
);
return;
}
if (previousFocusedFieldData?.filledByCipherType !== focusedFieldData?.filledByCipherType) {
const updateAllCipherTypes = focusedFieldData.filledByCipherType !== CipherType.Login;
this.updateOverlayCiphers(updateAllCipherTypes).catch((error) =>
this.logService.error(error),
);
}
}
@@ -1353,9 +1388,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return;
}
void BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", {
BrowserApi.tabSendMessageData(sender.tab, "redirectAutofillInlineMenuFocusOut", {
direction,
});
}).catch((error) => this.logService.error(error));
}
/**
@@ -1373,13 +1408,11 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return;
}
void BrowserApi.tabSendMessage(
sender.tab,
{ command: "addNewVaultItemFromOverlay", addNewCipherType },
{
frameId: this.focusedFieldData.frameId || 0,
},
);
this.currentAddNewItemData = { addNewCipherType, sender };
BrowserApi.tabSendMessage(sender.tab, {
command: "addNewVaultItemFromOverlay",
addNewCipherType,
}).catch((error) => this.logService.error(error));
}
/**
@@ -1396,18 +1429,154 @@ export class OverlayBackground implements OverlayBackgroundInterface {
{ addNewCipherType, login, card, identity }: OverlayAddNewItemMessage,
sender: chrome.runtime.MessageSender,
) {
if (!addNewCipherType) {
if (
!this.currentAddNewItemData ||
sender.tab.id !== this.currentAddNewItemData.sender.tab.id ||
!addNewCipherType ||
this.currentAddNewItemData.addNewCipherType !== addNewCipherType
) {
return;
}
if (login && this.isAddingNewLogin()) {
this.updateCurrentAddNewItemLogin(login);
}
if (card && this.isAddingNewCard()) {
this.updateCurrentAddNewItemCard(card);
}
if (identity && this.isAddingNewIdentity()) {
this.updateCurrentAddNewItemIdentity(identity);
}
this.addNewVaultItemSubject.next(this.currentAddNewItemData);
}
/**
* Identifies if the current add new item data is for adding a new login.
*/
private isAddingNewLogin() {
return this.currentAddNewItemData.addNewCipherType === CipherType.Login;
}
/**
* Identifies if the current add new item data is for adding a new card.
*/
private isAddingNewCard() {
return this.currentAddNewItemData.addNewCipherType === CipherType.Card;
}
/**
* Identifies if the current add new item data is for adding a new identity.
*/
private isAddingNewIdentity() {
return this.currentAddNewItemData.addNewCipherType === CipherType.Identity;
}
/**
* Updates the current add new item data with the provided login data. If the
* login data is already present, the data will be merged with the existing data.
*
* @param login - The login data captured from the extension message
*/
private updateCurrentAddNewItemLogin(login: NewLoginCipherData) {
if (!this.currentAddNewItemData.login) {
this.currentAddNewItemData.login = login;
return;
}
const currentLoginData = this.currentAddNewItemData.login;
this.currentAddNewItemData.login = {
uri: login.uri || currentLoginData.uri,
hostname: login.hostname || currentLoginData.hostname,
username: login.username || currentLoginData.username,
password: login.password || currentLoginData.password,
};
}
/**
* Updates the current add new item data with the provided card data. If the
* card data is already present, the data will be merged with the existing data.
*
* @param card - The card data captured from the extension message
*/
private updateCurrentAddNewItemCard(card: NewCardCipherData) {
if (!this.currentAddNewItemData.card) {
this.currentAddNewItemData.card = card;
return;
}
const currentCardData = this.currentAddNewItemData.card;
this.currentAddNewItemData.card = {
cardholderName: card.cardholderName || currentCardData.cardholderName,
number: card.number || currentCardData.number,
expirationMonth: card.expirationMonth || currentCardData.expirationMonth,
expirationYear: card.expirationYear || currentCardData.expirationYear,
expirationDate: card.expirationDate || currentCardData.expirationDate,
cvv: card.cvv || currentCardData.cvv,
};
}
/**
* Updates the current add new item data with the provided identity data. If the
* identity data is already present, the data will be merged with the existing data.
*
* @param identity - The identity data captured from the extension message
*/
private updateCurrentAddNewItemIdentity(identity: NewIdentityCipherData) {
if (!this.currentAddNewItemData.identity) {
this.currentAddNewItemData.identity = identity;
return;
}
const currentIdentityData = this.currentAddNewItemData.identity;
this.currentAddNewItemData.identity = {
title: identity.title || currentIdentityData.title,
firstName: identity.firstName || currentIdentityData.firstName,
middleName: identity.middleName || currentIdentityData.middleName,
lastName: identity.lastName || currentIdentityData.lastName,
fullName: identity.fullName || currentIdentityData.fullName,
address1: identity.address1 || currentIdentityData.address1,
address2: identity.address2 || currentIdentityData.address2,
address3: identity.address3 || currentIdentityData.address3,
city: identity.city || currentIdentityData.city,
state: identity.state || currentIdentityData.state,
postalCode: identity.postalCode || currentIdentityData.postalCode,
country: identity.country || currentIdentityData.country,
company: identity.company || currentIdentityData.company,
phone: identity.phone || currentIdentityData.phone,
email: identity.email || currentIdentityData.email,
username: identity.username || currentIdentityData.username,
};
}
/**
* Handles building a new cipher and opening the add/edit vault item popout.
*
* @param login - The login data captured from the extension message
* @param card - The card data captured from the extension message
* @param identity - The identity data captured from the extension message
* @param sender - The sender of the extension message
*/
private async buildCipherAndOpenAddEditVaultItemPopout({
login,
card,
identity,
sender,
}: CurrentAddNewItemData) {
const cipherView: CipherView = this.buildNewVaultItemCipherView({
addNewCipherType,
login,
card,
identity,
});
if (cipherView) {
if (!cipherView) {
this.currentAddNewItemData = null;
return;
}
try {
this.closeInlineMenu(sender);
await this.cipherService.setAddEditCipherInfo({
cipher: cipherView,
@@ -1416,32 +1585,30 @@ export class OverlayBackground implements OverlayBackgroundInterface {
await this.openAddEditVaultItemPopout(sender.tab, { cipherId: cipherView.id });
await BrowserApi.sendMessage("inlineAutofillMenuRefreshAddEditCipher");
} catch (error) {
this.logService.error("Error building cipher and opening add/edit vault item popout", error);
}
this.currentAddNewItemData = null;
}
/**
* Builds and returns a new cipher view with the provided vault item data.
*
* @param addNewCipherType - The type of cipher to add
* @param login - The login data captured from the extension message
* @param card - The card data captured from the extension message
* @param identity - The identity data captured from the extension message
*/
private buildNewVaultItemCipherView({
addNewCipherType,
login,
card,
identity,
}: OverlayAddNewItemMessage) {
if (login && addNewCipherType === CipherType.Login) {
private buildNewVaultItemCipherView({ login, card, identity }: OverlayAddNewItemMessage) {
if (login && this.isAddingNewLogin()) {
return this.buildLoginCipherView(login);
}
if (card && addNewCipherType === CipherType.Card) {
if (card && this.isAddingNewCard()) {
return this.buildCardCipherView(card);
}
if (identity && addNewCipherType === CipherType.Identity) {
if (identity && this.isAddingNewIdentity()) {
return this.buildIdentityCipherView(identity);
}
}
@@ -1558,8 +1725,16 @@ export class OverlayBackground implements OverlayBackgroundInterface {
* Updates the property that identifies if a form field set up for the inline menu is currently focused.
*
* @param message - The message received from the web page
* @param sender - The sender of the port message
*/
private updateIsFieldCurrentlyFocused(message: OverlayBackgroundExtensionMessage) {
private updateIsFieldCurrentlyFocused(
message: OverlayBackgroundExtensionMessage,
sender: chrome.runtime.MessageSender,
) {
if (this.focusedFieldData && !this.senderFrameHasFocusedField(sender)) {
return;
}
this.isFieldCurrentlyFocused = message.isFieldCurrentlyFocused;
}
@@ -1651,7 +1826,7 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return false;
}
if (this.focusedFieldData?.frameId === sender.frameId) {
if (this.senderFrameHasFocusedField(sender)) {
return true;
}
@@ -1676,6 +1851,15 @@ export class OverlayBackground implements OverlayBackgroundInterface {
return sender.tab.id === this.focusedFieldData?.tabId;
}
/**
* Identifies if the sender frame is the same as the focused field's frame.
*
* @param sender - The sender of the message
*/
private senderFrameHasFocusedField(sender: chrome.runtime.MessageSender) {
return sender.frameId === this.focusedFieldData?.frameId;
}
/**
* Triggers when a scroll or resize event occurs within a tab. Will reposition the inline menu
* if the focused field is within the viewport.
@@ -1689,7 +1873,9 @@ export class OverlayBackground implements OverlayBackgroundInterface {
this.resetFocusedFieldSubFrameOffsets(sender);
this.cancelInlineMenuFadeInAndPositionUpdate();
void this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender);
this.toggleInlineMenuHidden({ isInlineMenuHidden: true }, sender).catch((error) =>
this.logService.error(error),
);
this.repositionInlineMenuSubject.next(sender);
}
@@ -1879,14 +2065,14 @@ export class OverlayBackground implements OverlayBackgroundInterface {
filledByCipherType: this.focusedFieldData?.filledByCipherType,
showInlineMenuAccountCreation: this.showInlineMenuAccountCreation(),
});
void this.updateInlineMenuPosition(
this.updateInlineMenuPosition(
{
overlayElement: isInlineMenuListPort
? AutofillOverlayElement.List
: AutofillOverlayElement.Button,
},
port.sender,
);
).catch((error) => this.logService.error(error));
};
/**

View File

@@ -61,10 +61,13 @@ describe("AutofillInit", () => {
autofillInit.init();
jest.advanceTimersByTime(250);
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "bgCollectPageDetails",
sender: "autofillInit",
});
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith(
{
command: "bgCollectPageDetails",
sender: "autofillInit",
},
expect.any(Function),
);
});
it("registers a window load listener to collect the page details if the document is not in a `complete` ready state", () => {

View File

@@ -9,8 +9,8 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { BrowserFido2UserInterfaceSession } from "../../../fido2/browser-fido2-user-interface.service";
import { fido2PopoutSessionData$ } from "../../utils/fido2-popout-session-data";
import { BrowserFido2UserInterfaceSession } from "../../../vault/fido2/browser-fido2-user-interface.service";
import { fido2PopoutSessionData$ } from "../../../vault/popup/utils/fido2-popout-session-data";
@Component({
selector: "app-fido2-use-browser-link",

View File

@@ -29,13 +29,13 @@ import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.
import { DialogService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ZonedMessageListenerService } from "../../../../platform/browser/zoned-message-listener.service";
import { ZonedMessageListenerService } from "../../../platform/browser/zoned-message-listener.service";
import {
BrowserFido2Message,
BrowserFido2UserInterfaceSession,
} from "../../../fido2/browser-fido2-user-interface.service";
import { Fido2UserVerificationService } from "../../../services/fido2-user-verification.service";
import { VaultPopoutType } from "../../utils/vault-popout-window";
} from "../../../vault/fido2/browser-fido2-user-interface.service";
import { VaultPopoutType } from "../../../vault/popup/utils/vault-popout-window";
import { Fido2UserVerificationService } from "../../../vault/services/fido2-user-verification.service";
interface ViewData {
message: BrowserFido2Message;

View File

@@ -76,6 +76,11 @@ export class AutoFillConstants {
"textarea",
...AutoFillConstants.ExcludedAutofillTypes,
];
static readonly ExcludedIdentityAutocompleteTypes: Set<string> = new Set([
"current-password",
"new-password",
]);
}
export class CreditCardAutoFillConstants {

View File

@@ -37,10 +37,9 @@ describe("AutofillOverlayContentService", () => {
);
autofillInit = new AutofillInit(autofillOverlayContentService);
autofillInit.init();
sendExtensionMessageSpy = jest.spyOn(
autofillOverlayContentService as any,
"sendExtensionMessage",
);
sendExtensionMessageSpy = jest
.spyOn(autofillOverlayContentService as any, "sendExtensionMessage")
.mockResolvedValue(undefined);
Object.defineProperty(document, "readyState", {
value: defaultWindowReadyState,
writable: true,
@@ -1099,7 +1098,9 @@ describe("AutofillOverlayContentService", () => {
selectFieldElement.dispatchEvent(new Event("focus"));
await flushPromises();
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu");
expect(sendExtensionMessageSpy).toHaveBeenCalledWith("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
});
it("updates the most recently focused field", async () => {
@@ -1986,6 +1987,19 @@ describe("AutofillOverlayContentService", () => {
expect(autofillFieldFocusSpy).not.toHaveBeenCalled();
expect(nextFocusableElement.focus).toHaveBeenCalled();
});
it("focuses the most recently focused input field if no other tabbable elements are found", async () => {
autofillOverlayContentService["focusableElements"] = [];
findTabsSpy.mockReturnValue([]);
sendMockExtensionMessage({
command: "redirectAutofillInlineMenuFocusOut",
data: { direction: RedirectFocusDirection.Next },
});
await flushPromises();
expect(autofillFieldFocusSpy).toHaveBeenCalled();
});
});
describe("updateAutofillInlineMenuVisibility message handler", () => {

View File

@@ -249,10 +249,6 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
* to the background script to add a new cipher.
*/
async addNewVaultItem({ addNewCipherType }: AutofillExtensionMessage) {
if (!(await this.isInlineMenuListVisible())) {
return;
}
const command = "autofillOverlayAddNewVaultItem";
if (addNewCipherType === CipherType.Login) {
@@ -338,7 +334,12 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
const indexOffset = direction === RedirectFocusDirection.Previous ? -1 : 1;
const redirectFocusElement = this.focusableElements[focusedElementIndex + indexOffset];
redirectFocusElement?.focus();
if (redirectFocusElement) {
redirectFocusElement.focus();
return;
}
this.focusMostRecentlyFocusedField();
}
/**
@@ -675,7 +676,9 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
}
if (elementIsSelectElement(formFieldElement)) {
await this.sendExtensionMessage("closeAutofillInlineMenu");
await this.sendExtensionMessage("closeAutofillInlineMenu", {
forceCloseInlineMenu: true,
});
return;
}
@@ -758,7 +761,11 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
private async updateMostRecentlyFocusedField(
formFieldElement: ElementWithOpId<FormFieldElement>,
) {
if (!formFieldElement || !elementIsFillableFormField(formFieldElement)) {
if (
!formFieldElement ||
!elementIsFillableFormField(formFieldElement) ||
elementIsSelectElement(formFieldElement)
) {
return;
}
@@ -1418,8 +1425,8 @@ export class AutofillOverlayContentService implements AutofillOverlayContentServ
focusedFieldRectsTop + this.focusedFieldData?.focusedFieldRects?.height;
const viewportHeight = globalThis.innerHeight + globalThis.scrollY;
return (
focusedFieldRectsTop &&
focusedFieldRectsTop > 0 &&
!globalThis.isNaN(focusedFieldRectsTop) &&
focusedFieldRectsTop >= 0 &&
focusedFieldRectsTop < viewportHeight &&
focusedFieldRectsBottom < viewportHeight
);

View File

@@ -60,7 +60,7 @@ import {
GenerateFillScriptOptions,
PageDetail,
} from "./abstractions/autofill.service";
import { AutoFillConstants, IdentityAutoFillConstants } from "./autofill-constants";
import { AutoFillConstants } from "./autofill-constants";
import AutofillService from "./autofill.service";
const mockEquivalentDomains = [
@@ -3056,12 +3056,12 @@ describe("AutofillService", () => {
options.cipher.identity = mock<IdentityView>();
});
it("returns null if an identify is not found within the cipher", () => {
it("returns null if an identify is not found within the cipher", async () => {
options.cipher.identity = null;
jest.spyOn(autofillService as any, "makeScriptAction");
jest.spyOn(autofillService as any, "makeScriptActionWithValue");
const value = autofillService["generateIdentityFillScript"](
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
@@ -3087,432 +3087,389 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "makeScriptActionWithValue");
});
it("will not attempt to match custom fields", () => {
const customField = createAutofillFieldMock({ tagName: "span" });
pageDetails.fields.push(customField);
let isRefactorFeatureFlagSet = false;
for (let index = 0; index < 2; index++) {
describe(`when the isRefactorFeatureFlagSet is ${isRefactorFeatureFlagSet}`, () => {
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(isRefactorFeatureFlagSet);
});
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
afterAll(() => {
isRefactorFeatureFlagSet = true;
});
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled();
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
it("will not attempt to match custom fields", async () => {
const customField = createAutofillFieldMock({ tagName: "span" });
pageDetails.fields.push(customField);
it("will not attempt to match a field that is of an excluded type", () => {
const excludedField = createAutofillFieldMock({ type: "hidden" });
pageDetails.fields.push(excludedField);
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(customField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled();
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalledWith(
excludedField,
AutoFillConstants.ExcludedAutofillTypes,
);
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
it("will not attempt to match a field that is of an excluded type", async () => {
const excludedField = createAutofillFieldMock({ type: "hidden" });
pageDetails.fields.push(excludedField);
it("will not attempt to match a field that is not viewable", () => {
const viewableField = createAutofillFieldMock({ viewable: false });
pageDetails.fields.push(viewableField);
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(excludedField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalledWith(
excludedField,
AutoFillConstants.ExcludedAutofillTypes,
);
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(viewableField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled();
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
it("will not attempt to match a field that is not viewable", async () => {
const viewableField = createAutofillFieldMock({ viewable: false });
pageDetails.fields.push(viewableField);
it("will match a full name field to the vault item identity value", () => {
const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" });
pageDetails.fields = [fullNameField];
options.cipher.identity.firstName = firstName;
options.cipher.identity.middleName = middleName;
options.cipher.identity.lastName = lastName;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService.forCustomFieldsOnly).toHaveBeenCalledWith(viewableField);
expect(AutofillService["isExcludedFieldType"]).toHaveBeenCalled();
expect(AutofillService["isFieldMatch"]).not.toHaveBeenCalled();
expect(value.script).toStrictEqual([]);
});
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
fullNameField.htmlName,
IdentityAutoFillConstants.FullNameFieldNames,
IdentityAutoFillConstants.FullNameFieldNameValues,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
`${firstName} ${middleName} ${lastName}`,
fullNameField,
filledFields,
);
expect(value.script[2]).toStrictEqual([
"fill_by_opid",
fullNameField.opid,
`${firstName} ${middleName} ${lastName}`,
]);
});
it("will match a full name field to the vault item identity value", async () => {
const fullNameField = createAutofillFieldMock({
opid: "fullName",
htmlName: "full-name",
});
pageDetails.fields = [fullNameField];
options.cipher.identity.firstName = firstName;
options.cipher.identity.middleName = middleName;
options.cipher.identity.lastName = lastName;
it("will match a full name field to the a vault item that only has a last name", () => {
const fullNameField = createAutofillFieldMock({ opid: "fullName", htmlName: "full-name" });
pageDetails.fields = [fullNameField];
options.cipher.identity.firstName = "";
options.cipher.identity.middleName = "";
options.cipher.identity.lastName = lastName;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
`${firstName} ${middleName} ${lastName}`,
fullNameField,
filledFields,
);
expect(value.script[2]).toStrictEqual([
"fill_by_opid",
fullNameField.opid,
`${firstName} ${middleName} ${lastName}`,
]);
});
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
fullNameField.htmlName,
IdentityAutoFillConstants.FullNameFieldNames,
IdentityAutoFillConstants.FullNameFieldNameValues,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
lastName,
fullNameField,
filledFields,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", fullNameField.opid, lastName]);
});
it("will match a full name field to the a vault item that only has a last name", async () => {
const fullNameField = createAutofillFieldMock({
opid: "fullName",
htmlName: "full-name",
});
pageDetails.fields = [fullNameField];
options.cipher.identity.firstName = "";
options.cipher.identity.middleName = "";
options.cipher.identity.lastName = lastName;
it("will match first name, middle name, and last name fields to the vault item identity value", () => {
const firstNameField = createAutofillFieldMock({
opid: "firstName",
htmlName: "first-name",
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
lastName,
fullNameField,
filledFields,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", fullNameField.opid, lastName]);
});
it("will match first name, middle name, and last name fields to the vault item identity value", async () => {
const firstNameField = createAutofillFieldMock({
opid: "firstName",
htmlName: "first-name",
});
const middleNameField = createAutofillFieldMock({
opid: "middleName",
htmlName: "middle-name",
});
const lastNameField = createAutofillFieldMock({
opid: "lastName",
htmlName: "last-name",
});
pageDetails.fields = [firstNameField, middleNameField, lastNameField];
options.cipher.identity.firstName = firstName;
options.cipher.identity.middleName = middleName;
options.cipher.identity.lastName = lastName;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity.firstName,
firstNameField,
filledFields,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity.middleName,
middleNameField,
filledFields,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity.lastName,
lastNameField,
filledFields,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", firstNameField.opid, firstName]);
expect(value.script[5]).toStrictEqual([
"fill_by_opid",
middleNameField.opid,
middleName,
]);
expect(value.script[8]).toStrictEqual(["fill_by_opid", lastNameField.opid, lastName]);
});
it("will match title and email fields to the vault item identity value", async () => {
const titleField = createAutofillFieldMock({ opid: "title", htmlName: "title" });
const emailField = createAutofillFieldMock({ opid: "email", htmlName: "email" });
pageDetails.fields = [titleField, emailField];
const title = "Mr.";
const email = "email@example.com";
options.cipher.identity.title = title;
options.cipher.identity.email = email;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity.title,
titleField,
filledFields,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity.email,
emailField,
filledFields,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", titleField.opid, title]);
expect(value.script[5]).toStrictEqual(["fill_by_opid", emailField.opid, email]);
});
it("will match a full address field to the vault item identity values", async () => {
const fullAddressField = createAutofillFieldMock({
opid: "fullAddress",
htmlName: "address",
});
pageDetails.fields = [fullAddressField];
const address1 = "123 Main St.";
const address2 = "Apt. 1";
const address3 = "P.O. Box 123";
options.cipher.identity.address1 = address1;
options.cipher.identity.address2 = address2;
options.cipher.identity.address3 = address3;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
`${address1}, ${address2}, ${address3}`,
fullAddressField,
filledFields,
);
expect(value.script[2]).toStrictEqual([
"fill_by_opid",
fullAddressField.opid,
`${address1}, ${address2}, ${address3}`,
]);
});
it("will match address1, address2, address3, postalCode, city, state, country, phone, username, and company fields to their corresponding vault item identity values", async () => {
const address1Field = createAutofillFieldMock({
opid: "address1",
htmlName: "address-1",
});
const address2Field = createAutofillFieldMock({
opid: "address2",
htmlName: "address-2",
});
const address3Field = createAutofillFieldMock({
opid: "address3",
htmlName: "address-3",
});
const postalCodeField = createAutofillFieldMock({
opid: "postalCode",
htmlName: "postal-code",
});
const cityField = createAutofillFieldMock({ opid: "city", htmlName: "city" });
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" });
const phoneField = createAutofillFieldMock({ opid: "phone", htmlName: "phone" });
const usernameField = createAutofillFieldMock({
opid: "username",
htmlName: "username",
});
const companyField = createAutofillFieldMock({ opid: "company", htmlName: "company" });
pageDetails.fields = [
address1Field,
address2Field,
address3Field,
postalCodeField,
cityField,
stateField,
countryField,
phoneField,
usernameField,
companyField,
];
const address1 = "123 Main St.";
const address2 = "Apt. 1";
const address3 = "P.O. Box 123";
const postalCode = "12345";
const city = "City";
const state = "TX";
const country = "US";
const phone = "123-456-7890";
const username = "username";
const company = "Company";
options.cipher.identity.address1 = address1;
options.cipher.identity.address2 = address2;
options.cipher.identity.address3 = address3;
options.cipher.identity.postalCode = postalCode;
options.cipher.identity.city = city;
options.cipher.identity.state = state;
options.cipher.identity.country = country;
options.cipher.identity.phone = phone;
options.cipher.identity.username = username;
options.cipher.identity.company = company;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(value.script).toContainEqual(["fill_by_opid", address1Field.opid, address1]);
expect(value.script).toContainEqual(["fill_by_opid", address2Field.opid, address2]);
expect(value.script).toContainEqual(["fill_by_opid", address3Field.opid, address3]);
expect(value.script).toContainEqual(["fill_by_opid", postalCodeField.opid, postalCode]);
expect(value.script).toContainEqual(["fill_by_opid", cityField.opid, city]);
expect(value.script).toContainEqual(["fill_by_opid", stateField.opid, state]);
expect(value.script).toContainEqual(["fill_by_opid", countryField.opid, country]);
expect(value.script).toContainEqual(["fill_by_opid", phoneField.opid, phone]);
expect(value.script).toContainEqual(["fill_by_opid", usernameField.opid, username]);
expect(value.script).toContainEqual(["fill_by_opid", companyField.opid, company]);
});
it("will find the two character IsoState value for an identity cipher that contains the full name of a state", async () => {
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
pageDetails.fields = [stateField];
const state = "California";
options.cipher.identity.state = state;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"CA",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "CA"]);
});
it("will find the two character IsoProvince value for an identity cipher that contains the full name of a province", async () => {
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
pageDetails.fields = [stateField];
const state = "Ontario";
options.cipher.identity.state = state;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"ON",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "ON"]);
});
it("will find the two character IsoCountry value for an identity cipher that contains the full name of a country", async () => {
const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" });
pageDetails.fields = [countryField];
const country = "Somalia";
options.cipher.identity.country = country;
const value = await autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"SO",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", countryField.opid, "SO"]);
});
});
const middleNameField = createAutofillFieldMock({
opid: "middleName",
htmlName: "middle-name",
});
const lastNameField = createAutofillFieldMock({ opid: "lastName", htmlName: "last-name" });
pageDetails.fields = [firstNameField, middleNameField, lastNameField];
options.cipher.identity.firstName = firstName;
options.cipher.identity.middleName = middleName;
options.cipher.identity.lastName = lastName;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
firstNameField.htmlName,
IdentityAutoFillConstants.FirstnameFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
middleNameField.htmlName,
IdentityAutoFillConstants.MiddlenameFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
lastNameField.htmlName,
IdentityAutoFillConstants.LastnameFieldNames,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity,
expect.anything(),
filledFields,
firstNameField.opid,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity,
expect.anything(),
filledFields,
middleNameField.opid,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity,
expect.anything(),
filledFields,
lastNameField.opid,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", firstNameField.opid, firstName]);
expect(value.script[5]).toStrictEqual(["fill_by_opid", middleNameField.opid, middleName]);
expect(value.script[8]).toStrictEqual(["fill_by_opid", lastNameField.opid, lastName]);
});
it("will match title and email fields to the vault item identity value", () => {
const titleField = createAutofillFieldMock({ opid: "title", htmlName: "title" });
const emailField = createAutofillFieldMock({ opid: "email", htmlName: "email" });
pageDetails.fields = [titleField, emailField];
const title = "Mr.";
const email = "email@example.com";
options.cipher.identity.title = title;
options.cipher.identity.email = email;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
titleField.htmlName,
IdentityAutoFillConstants.TitleFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
emailField.htmlName,
IdentityAutoFillConstants.EmailFieldNames,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity,
expect.anything(),
filledFields,
titleField.opid,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalledWith(
fillScript,
options.cipher.identity,
expect.anything(),
filledFields,
emailField.opid,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", titleField.opid, title]);
expect(value.script[5]).toStrictEqual(["fill_by_opid", emailField.opid, email]);
});
it("will match a full address field to the vault item identity values", () => {
const fullAddressField = createAutofillFieldMock({
opid: "fullAddress",
htmlName: "address",
});
pageDetails.fields = [fullAddressField];
const address1 = "123 Main St.";
const address2 = "Apt. 1";
const address3 = "P.O. Box 123";
options.cipher.identity.address1 = address1;
options.cipher.identity.address2 = address2;
options.cipher.identity.address3 = address3;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
fullAddressField.htmlName,
IdentityAutoFillConstants.AddressFieldNames,
IdentityAutoFillConstants.AddressFieldNameValues,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
`${address1}, ${address2}, ${address3}`,
fullAddressField,
filledFields,
);
expect(value.script[2]).toStrictEqual([
"fill_by_opid",
fullAddressField.opid,
`${address1}, ${address2}, ${address3}`,
]);
});
it("will match address1, address2, address3, postalCode, city, state, country, phone, username, and company fields to their corresponding vault item identity values", () => {
const address1Field = createAutofillFieldMock({ opid: "address1", htmlName: "address-1" });
const address2Field = createAutofillFieldMock({ opid: "address2", htmlName: "address-2" });
const address3Field = createAutofillFieldMock({ opid: "address3", htmlName: "address-3" });
const postalCodeField = createAutofillFieldMock({
opid: "postalCode",
htmlName: "postal-code",
});
const cityField = createAutofillFieldMock({ opid: "city", htmlName: "city" });
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" });
const phoneField = createAutofillFieldMock({ opid: "phone", htmlName: "phone" });
const usernameField = createAutofillFieldMock({ opid: "username", htmlName: "username" });
const companyField = createAutofillFieldMock({ opid: "company", htmlName: "company" });
pageDetails.fields = [
address1Field,
address2Field,
address3Field,
postalCodeField,
cityField,
stateField,
countryField,
phoneField,
usernameField,
companyField,
];
const address1 = "123 Main St.";
const address2 = "Apt. 1";
const address3 = "P.O. Box 123";
const postalCode = "12345";
const city = "City";
const state = "State";
const country = "Country";
const phone = "123-456-7890";
const username = "username";
const company = "Company";
options.cipher.identity.address1 = address1;
options.cipher.identity.address2 = address2;
options.cipher.identity.address3 = address3;
options.cipher.identity.postalCode = postalCode;
options.cipher.identity.city = city;
options.cipher.identity.state = state;
options.cipher.identity.country = country;
options.cipher.identity.phone = phone;
options.cipher.identity.username = username;
options.cipher.identity.company = company;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
address1Field.htmlName,
IdentityAutoFillConstants.Address1FieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
address2Field.htmlName,
IdentityAutoFillConstants.Address2FieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
address3Field.htmlName,
IdentityAutoFillConstants.Address3FieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
postalCodeField.htmlName,
IdentityAutoFillConstants.PostalCodeFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
cityField.htmlName,
IdentityAutoFillConstants.CityFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
stateField.htmlName,
IdentityAutoFillConstants.StateFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
countryField.htmlName,
IdentityAutoFillConstants.CountryFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
phoneField.htmlName,
IdentityAutoFillConstants.PhoneFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
usernameField.htmlName,
IdentityAutoFillConstants.UserNameFieldNames,
);
expect(AutofillService["isFieldMatch"]).toHaveBeenCalledWith(
companyField.htmlName,
IdentityAutoFillConstants.CompanyFieldNames,
);
expect(autofillService["makeScriptAction"]).toHaveBeenCalled();
expect(value.script[2]).toStrictEqual(["fill_by_opid", address1Field.opid, address1]);
expect(value.script[5]).toStrictEqual(["fill_by_opid", address2Field.opid, address2]);
expect(value.script[8]).toStrictEqual(["fill_by_opid", address3Field.opid, address3]);
expect(value.script[11]).toStrictEqual(["fill_by_opid", cityField.opid, city]);
expect(value.script[14]).toStrictEqual(["fill_by_opid", postalCodeField.opid, postalCode]);
expect(value.script[17]).toStrictEqual(["fill_by_opid", companyField.opid, company]);
expect(value.script[20]).toStrictEqual(["fill_by_opid", phoneField.opid, phone]);
expect(value.script[23]).toStrictEqual(["fill_by_opid", usernameField.opid, username]);
expect(value.script[26]).toStrictEqual(["fill_by_opid", stateField.opid, state]);
expect(value.script[29]).toStrictEqual(["fill_by_opid", countryField.opid, country]);
});
it("will find the two character IsoState value for an identity cipher that contains the full name of a state", () => {
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
pageDetails.fields = [stateField];
const state = "California";
options.cipher.identity.state = state;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"CA",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "CA"]);
});
it("will find the two character IsoProvince value for an identity cipher that contains the full name of a province", () => {
const stateField = createAutofillFieldMock({ opid: "state", htmlName: "state" });
pageDetails.fields = [stateField];
const state = "Ontario";
options.cipher.identity.state = state;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"ON",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", stateField.opid, "ON"]);
});
it("will find the two character IsoCountry value for an identity cipher that contains the full name of a country", () => {
const countryField = createAutofillFieldMock({ opid: "country", htmlName: "country" });
pageDetails.fields = [countryField];
const country = "Somalia";
options.cipher.identity.country = country;
const value = autofillService["generateIdentityFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(autofillService["makeScriptActionWithValue"]).toHaveBeenCalledWith(
fillScript,
"SO",
expect.anything(),
expect.anything(),
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", countryField.opid, "SO"]);
});
}
});
});

View File

@@ -26,6 +26,7 @@ import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view";
import { BrowserApi } from "../../platform/browser/browser-api";
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
@@ -478,6 +479,12 @@ export default class AutofillService implements AutofillServiceInterface {
return totpCode;
}
/**
* Checks if the cipher requires password reprompt and opens the password reprompt popout if necessary.
*
* @param cipher - The cipher to autofill
* @param tab - The tab to autofill
*/
async isPasswordRepromptRequired(cipher: CipherView, tab: chrome.tabs.Tab): Promise<boolean> {
const userHasMasterPasswordAndKeyHash =
await this.userVerificationService.hasMasterPasswordAndMasterKeyHash();
@@ -654,7 +661,7 @@ export default class AutofillService implements AutofillServiceInterface {
fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options);
break;
case CipherType.Identity:
fillScript = this.generateIdentityFillScript(
fillScript = await this.generateIdentityFillScript(
fillScript,
pageDetails,
filledFields,
@@ -1243,12 +1250,16 @@ export default class AutofillService implements AutofillServiceInterface {
* @returns {AutofillScript}
* @private
*/
private generateIdentityFillScript(
private async generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions,
): AutofillScript {
): Promise<AutofillScript> {
if (await this.configService.getFeatureFlag(FeatureFlag.GenerateIdentityFillScriptRefactor)) {
return this._generateIdentityFillScript(fillScript, pageDetails, filledFields, options);
}
if (!options.cipher.identity) {
return null;
}
@@ -1476,6 +1487,589 @@ export default class AutofillService implements AutofillServiceInterface {
return fillScript;
}
/**
* Generates the autofill script for the specified page details and identity cipher item.
*
* @param fillScript - Object to store autofill script, passed between method references
* @param pageDetails - The details of the page to autofill
* @param filledFields - The fields that have already been filled, passed between method references
* @param options - Contains data used to fill cipher items
*/
private _generateIdentityFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions,
): AutofillScript {
const identity = options.cipher.identity;
if (!identity) {
return null;
}
for (let fieldsIndex = 0; fieldsIndex < pageDetails.fields.length; fieldsIndex++) {
const field = pageDetails.fields[fieldsIndex];
if (this.excludeFieldFromIdentityFill(field)) {
continue;
}
const keywordsList = this.getIdentityAutofillFieldKeywords(field);
const keywordsCombined = keywordsList.join(",");
if (this.shouldMakeIdentityTitleFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.title, field, filledFields);
continue;
}
if (this.shouldMakeIdentityNameFillScript(filledFields, keywordsList)) {
this.makeIdentityNameFillScript(fillScript, filledFields, field, identity);
continue;
}
if (this.shouldMakeIdentityFirstNameFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.firstName, field, filledFields);
continue;
}
if (this.shouldMakeIdentityMiddleNameFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.middleName, field, filledFields);
continue;
}
if (this.shouldMakeIdentityLastNameFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.lastName, field, filledFields);
continue;
}
if (this.shouldMakeIdentityEmailFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.email, field, filledFields);
continue;
}
if (this.shouldMakeIdentityAddressFillScript(filledFields, keywordsList)) {
this.makeIdentityAddressFillScript(fillScript, filledFields, field, identity);
continue;
}
if (this.shouldMakeIdentityAddress1FillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.address1, field, filledFields);
continue;
}
if (this.shouldMakeIdentityAddress2FillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.address2, field, filledFields);
continue;
}
if (this.shouldMakeIdentityAddress3FillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.address3, field, filledFields);
continue;
}
if (this.shouldMakeIdentityPostalCodeFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.postalCode, field, filledFields);
continue;
}
if (this.shouldMakeIdentityCityFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.city, field, filledFields);
continue;
}
if (this.shouldMakeIdentityStateFillScript(filledFields, keywordsCombined)) {
this.makeIdentityStateFillScript(fillScript, filledFields, field, identity);
continue;
}
if (this.shouldMakeIdentityCountryFillScript(filledFields, keywordsCombined)) {
this.makeIdentityCountryFillScript(fillScript, filledFields, field, identity);
continue;
}
if (this.shouldMakeIdentityPhoneFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.phone, field, filledFields);
continue;
}
if (this.shouldMakeIdentityUserNameFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.username, field, filledFields);
continue;
}
if (this.shouldMakeIdentityCompanyFillScript(filledFields, keywordsCombined)) {
this.makeScriptActionWithValue(fillScript, identity.company, field, filledFields);
}
}
return fillScript;
}
/**
* Identifies if the current field should be excluded from triggering autofill of the identity cipher.
*
* @param field - The field to check
*/
private excludeFieldFromIdentityFill(field: AutofillField): boolean {
return (
AutofillService.isExcludedFieldType(field, AutoFillConstants.ExcludedAutofillTypes) ||
AutoFillConstants.ExcludedIdentityAutocompleteTypes.has(field.autoCompleteType) ||
!field.viewable
);
}
/**
* Gathers all unique keyword identifiers from a field that can be used to determine what
* identity value should be filled.
*
* @param field - The field to gather keywords from
*/
private getIdentityAutofillFieldKeywords(field: AutofillField): string[] {
const keywords: Set<string> = new Set();
for (let index = 0; index < IdentityAutoFillConstants.IdentityAttributes.length; index++) {
const attribute = IdentityAutoFillConstants.IdentityAttributes[index];
if (field[attribute]) {
keywords.add(
field[attribute]
.trim()
.toLowerCase()
.replace(/[^a-zA-Z0-9]+/g, ""),
);
}
}
return Array.from(keywords);
}
/**
* Identifies if a fill script action for the identity title
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityTitleFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.title &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.TitleFieldNames)
);
}
/**
* Identifies if a fill script action for the identity name
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityNameFillScript(
filledFields: Record<string, AutofillField>,
keywords: string[],
): boolean {
return (
!filledFields.name &&
keywords.some((keyword) =>
AutofillService.isFieldMatch(
keyword,
IdentityAutoFillConstants.FullNameFieldNames,
IdentityAutoFillConstants.FullNameFieldNameValues,
),
)
);
}
/**
* Identifies if a fill script action for the identity first name
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityFirstNameFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.firstName &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.FirstnameFieldNames)
);
}
/**
* Identifies if a fill script action for the identity middle name
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityMiddleNameFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.middleName &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.MiddlenameFieldNames)
);
}
/**
* Identifies if a fill script action for the identity last name
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityLastNameFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.lastName &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.LastnameFieldNames)
);
}
/**
* Identifies if a fill script action for the identity email
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityEmailFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.email &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.EmailFieldNames)
);
}
/**
* Identifies if a fill script action for the identity address
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityAddressFillScript(
filledFields: Record<string, AutofillField>,
keywords: string[],
): boolean {
return (
!filledFields.address &&
keywords.some((keyword) =>
AutofillService.isFieldMatch(
keyword,
IdentityAutoFillConstants.AddressFieldNames,
IdentityAutoFillConstants.AddressFieldNameValues,
),
)
);
}
/**
* Identifies if a fill script action for the identity address1
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityAddress1FillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.address1 &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address1FieldNames)
);
}
/**
* Identifies if a fill script action for the identity address2
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityAddress2FillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.address2 &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address2FieldNames)
);
}
/**
* Identifies if a fill script action for the identity address3
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityAddress3FillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.address3 &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.Address3FieldNames)
);
}
/**
* Identifies if a fill script action for the identity postal code
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityPostalCodeFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.postalCode &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.PostalCodeFieldNames)
);
}
/**
* Identifies if a fill script action for the identity city
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityCityFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.city &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CityFieldNames)
);
}
/**
* Identifies if a fill script action for the identity state
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityStateFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.state &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.StateFieldNames)
);
}
/**
* Identifies if a fill script action for the identity country
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityCountryFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.country &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CountryFieldNames)
);
}
/**
* Identifies if a fill script action for the identity phone
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityPhoneFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.phone &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.PhoneFieldNames)
);
}
/**
* Identifies if a fill script action for the identity username
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityUserNameFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.username &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.UserNameFieldNames)
);
}
/**
* Identifies if a fill script action for the identity company
* field should be created for the provided field.
*
* @param filledFields - The fields that have already been filled
* @param keywords - The keywords from the field
*/
private shouldMakeIdentityCompanyFillScript(
filledFields: Record<string, AutofillField>,
keywords: string,
): boolean {
return (
!filledFields.company &&
AutofillService.isFieldMatch(keywords, IdentityAutoFillConstants.CompanyFieldNames)
);
}
/**
* Creates an identity name fill script action for the provided field. This is used
* when filling a `full name` field, using the first, middle, and last name from the
* identity cipher item.
*
* @param fillScript - The autofill script to add the action to
* @param filledFields - The fields that have already been filled
* @param field - The field to fill
* @param identity - The identity cipher item
*/
private makeIdentityNameFillScript(
fillScript: AutofillScript,
filledFields: Record<string, AutofillField>,
field: AutofillField,
identity: IdentityView,
) {
let name = "";
if (identity.firstName) {
name += identity.firstName;
}
if (identity.middleName) {
name += !name ? identity.middleName : ` ${identity.middleName}`;
}
if (identity.lastName) {
name += !name ? identity.lastName : ` ${identity.lastName}`;
}
this.makeScriptActionWithValue(fillScript, name, field, filledFields);
}
/**
* Creates an identity address fill script action for the provided field. This is used
* when filling a generic `address` field, using the address1, address2, and address3
* from the identity cipher item.
*
* @param fillScript - The autofill script to add the action to
* @param filledFields - The fields that have already been filled
* @param field - The field to fill
* @param identity - The identity cipher item
*/
private makeIdentityAddressFillScript(
fillScript: AutofillScript,
filledFields: Record<string, AutofillField>,
field: AutofillField,
identity: IdentityView,
) {
if (!identity.address1) {
return;
}
let address = identity.address1;
if (identity.address2) {
address += `, ${identity.address2}`;
}
if (identity.address3) {
address += `, ${identity.address3}`;
}
this.makeScriptActionWithValue(fillScript, address, field, filledFields);
}
/**
* Creates an identity state fill script action for the provided field. This is used
* when filling a `state` field, using the state value from the identity cipher item.
* If the state value is a full name, it will be converted to an ISO code.
*
* @param fillScript - The autofill script to add the action to
* @param filledFields - The fields that have already been filled
* @param field - The field to fill
* @param identity - The identity cipher item
*/
private makeIdentityStateFillScript(
fillScript: AutofillScript,
filledFields: Record<string, AutofillField>,
field: AutofillField,
identity: IdentityView,
) {
if (!identity.state) {
return;
}
if (identity.state.length <= 2) {
this.makeScriptActionWithValue(fillScript, identity.state, field, filledFields);
return;
}
const stateLower = identity.state.toLowerCase();
const isoState =
IdentityAutoFillConstants.IsoStates[stateLower] ||
IdentityAutoFillConstants.IsoProvinces[stateLower];
if (isoState) {
this.makeScriptActionWithValue(fillScript, isoState, field, filledFields);
}
}
/**
* Creates an identity country fill script action for the provided field. This is used
* when filling a `country` field, using the country value from the identity cipher item.
* If the country value is a full name, it will be converted to an ISO code.
*
* @param fillScript - The autofill script to add the action to
* @param filledFields - The fields that have already been filled
* @param field - The field to fill
* @param identity - The identity cipher item
*/
private makeIdentityCountryFillScript(
fillScript: AutofillScript,
filledFields: Record<string, AutofillField>,
field: AutofillField,
identity: IdentityView,
) {
if (!identity.country) {
return;
}
if (identity.country.length <= 2) {
this.makeScriptActionWithValue(fillScript, identity.country, field, filledFields);
return;
}
const countryLower = identity.country.toLowerCase();
const isoCountry = IdentityAutoFillConstants.IsoCountries[countryLower];
if (isoCountry) {
this.makeScriptActionWithValue(fillScript, isoCountry, field, filledFields);
}
}
/**
* Accepts an HTMLInputElement type value and a list of
* excluded types and returns true if the type is excluded.

View File

@@ -16,10 +16,8 @@ describe("InlineMenuFieldQualificationService", () => {
forms: {},
fields: [],
});
chrome.runtime.sendMessage = jest.fn().mockImplementation((message) => ({
result: message.command === "getInlineMenuFieldQualificationFeatureFlag",
}));
inlineMenuFieldQualificationService = new InlineMenuFieldQualificationService();
inlineMenuFieldQualificationService["inlineMenuFieldQualificationFlagSet"] = true;
});
describe("isFieldForLoginForm", () => {

View File

@@ -209,10 +209,7 @@ export class InlineMenuFieldQualificationService
return false;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, this.creditCardFieldKeywords)
);
return this.keywordsFoundInFieldData(field, this.creditCardFieldKeywords);
}
// If the field has a parent form, check the fields from that form exclusively
@@ -232,10 +229,7 @@ export class InlineMenuFieldQualificationService
return false;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords])
);
return this.keywordsFoundInFieldData(field, [...this.creditCardFieldKeywords]);
}
/** Validates the provided field as a field for an account creation form.
@@ -264,10 +258,7 @@ export class InlineMenuFieldQualificationService
// If no password fields are found on the page, check for keywords that indicate the field is
// part of an account creation form.
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
);
return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords);
}
// If the field has a parent form, check the fields from that form exclusively
@@ -277,10 +268,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords)
);
return this.keywordsFoundInFieldData(field, this.accountCreationFieldKeywords);
}
/**
@@ -480,9 +468,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardHolderFieldNames, false)
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardHolderFieldNames,
false,
);
};
@@ -496,9 +485,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardNumberFieldNames, false)
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardNumberFieldNames,
false,
);
};
@@ -514,9 +504,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CardExpiryFieldNames, false)
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.CardExpiryFieldNames,
false,
);
};
@@ -532,9 +523,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryMonthFieldNames, false)
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.ExpiryMonthFieldNames,
false,
);
};
@@ -550,9 +542,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.ExpiryYearFieldNames, false)
return this.keywordsFoundInFieldData(
field,
CreditCardAutoFillConstants.ExpiryYearFieldNames,
false,
);
};
@@ -566,10 +559,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false)
);
return this.keywordsFoundInFieldData(field, CreditCardAutoFillConstants.CVVFieldNames, false);
};
/**
@@ -584,10 +574,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.TitleFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.TitleFieldNames, false);
};
/**
@@ -600,9 +587,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.FirstnameFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.FirstnameFieldNames,
false,
);
};
@@ -616,9 +604,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.MiddlenameFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.MiddlenameFieldNames,
false,
);
};
@@ -632,9 +621,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.LastnameFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.LastnameFieldNames,
false,
);
};
@@ -648,9 +638,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.FullNameFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.FullNameFieldNames,
false,
);
};
@@ -664,16 +655,13 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(
field,
[
...IdentityAutoFillConstants.AddressFieldNames,
...IdentityAutoFillConstants.Address1FieldNames,
],
false,
)
return this.keywordsFoundInFieldData(
field,
[
...IdentityAutoFillConstants.AddressFieldNames,
...IdentityAutoFillConstants.Address1FieldNames,
],
false,
);
};
@@ -687,9 +675,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.Address2FieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.Address2FieldNames,
false,
);
};
@@ -703,9 +692,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.Address3FieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.Address3FieldNames,
false,
);
};
@@ -719,10 +709,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CityFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CityFieldNames, false);
};
/**
@@ -735,10 +722,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.StateFieldNames, false);
};
/**
@@ -751,9 +735,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PostalCodeFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.PostalCodeFieldNames,
false,
);
};
@@ -767,10 +752,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CountryFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CountryFieldNames, false);
};
/**
@@ -783,10 +765,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CompanyFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.CompanyFieldNames, false);
};
/**
@@ -799,10 +778,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PhoneFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.PhoneFieldNames, false);
};
/**
@@ -818,10 +794,7 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.EmailFieldNames, false)
);
return this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.EmailFieldNames, false);
};
/**
@@ -834,9 +807,10 @@ export class InlineMenuFieldQualificationService
return true;
}
return (
!this.fieldContainsAutocompleteValues(field, this.autocompleteDisabledValues) &&
this.keywordsFoundInFieldData(field, IdentityAutoFillConstants.UserNameFieldNames, false)
return this.keywordsFoundInFieldData(
field,
IdentityAutoFillConstants.UserNameFieldNames,
false,
);
};
@@ -1039,11 +1013,13 @@ export class InlineMenuFieldQualificationService
fuzzyMatchKeywords = true,
) {
const searchedValues = this.getAutofillFieldDataKeywords(autofillFieldData, fuzzyMatchKeywords);
const parsedKeywords = keywords.map((keyword) => keyword.replace(/-/g, ""));
if (typeof searchedValues === "string") {
return keywords.some((keyword) => searchedValues.indexOf(keyword) > -1);
return parsedKeywords.some((keyword) => searchedValues.indexOf(keyword) > -1);
}
return keywords.some((keyword) => searchedValues.has(keyword));
return parsedKeywords.some((keyword) => searchedValues.has(keyword));
}
/**
@@ -1072,8 +1048,19 @@ export class InlineMenuFieldQualificationService
autofillFieldData["label-tag"],
autofillFieldData["label-top"],
];
const keywordsSet = new Set<string>(keywords);
const stringValue = keywords.join(",").toLowerCase();
const keywordsSet = new Set<string>();
for (let i = 0; i < keywords.length; i++) {
if (typeof keywords[i] === "string") {
keywords[i]
.toLowerCase()
.replace(/-/g, "")
.replace(/[^a-zA-Z0-9]+/g, "|")
.split("|")
.forEach((keyword) => keywordsSet.add(keyword));
}
}
const stringValue = Array.from(keywordsSet).join(",");
this.autofillFieldKeywordsMap.set(autofillFieldData, { keywordsSet, stringValue });
}

View File

@@ -38,14 +38,24 @@ describe("generateRandomCustomElementName", () => {
describe("sendExtensionMessage", () => {
it("sends a message to the extension", async () => {
chrome.runtime.sendMessage = jest.fn().mockResolvedValue("sendMessageResponse");
const extensionMessagePromise = sendExtensionMessage("some-extension-message");
const response = await sendExtensionMessage("some-extension-message", { value: "value" });
// Jest doesn't give anyway to select the typed overload of "sendMessage",
// a cast is needed to get the correct spy type.
const sendMessageSpy = jest.spyOn(chrome.runtime, "sendMessage") as unknown as jest.SpyInstance<
void,
[message: string, responseCallback: (response: string) => void],
unknown
>;
expect(chrome.runtime.sendMessage).toHaveBeenCalledWith({
command: "some-extension-message",
value: "value",
});
expect(sendMessageSpy).toHaveBeenCalled();
const [latestCall] = sendMessageSpy.mock.calls;
const responseCallback = latestCall[1];
responseCallback("sendMessageResponse");
const response = await extensionMessagePromise;
expect(response).toEqual("sendMessageResponse");
});
});

View File

@@ -105,7 +105,19 @@ export async function sendExtensionMessage(
command: string,
options: Record<string, any> = {},
): Promise<any> {
return chrome.runtime.sendMessage({ command, ...options });
if (typeof browser !== "undefined") {
return browser.runtime.sendMessage({ command, ...options });
}
return new Promise((resolve) =>
chrome.runtime.sendMessage(Object.assign({ command }, options), (response) => {
if (chrome.runtime.lastError) {
resolve(null);
}
resolve(response);
}),
);
}
/**

View File

@@ -452,6 +452,9 @@ export default class MainBackground {
return new ForegroundMemoryStorageService();
}
// For local backed session storage, we expect that the encrypted data on disk will persist longer than the encryption key in memory
// and failures to decrypt because of that are completely expected. For this reason, we pass in `false` to the `EncryptServiceImplementation`
// so that MAC failures are not logged.
return new LocalBackedSessionStorageService(
sessionKey,
this.storageService,
@@ -849,6 +852,7 @@ export default class MainBackground {
this.sendService,
this.sendApiService,
messageListener,
this.stateProvider,
);
} else {
this.syncService = new DefaultSyncService(
@@ -876,6 +880,7 @@ export default class MainBackground {
this.billingAccountProfileStateService,
this.tokenService,
this.authService,
this.stateProvider,
);
this.syncServiceListener = new SyncServiceListener(
@@ -1047,6 +1052,7 @@ export default class MainBackground {
this.logService,
this.authService,
this.biometricStateService,
this.accountService,
);
this.commandsBackground = new CommandsBackground(
this,
@@ -1358,7 +1364,6 @@ export default class MainBackground {
);
await Promise.all([
this.syncService.setLastSync(new Date(0), userBeingLoggedOut),
this.cryptoService.clearKeys(userBeingLoggedOut),
this.cipherService.clear(userBeingLoggedOut),
this.folderService.clear(userBeingLoggedOut),

View File

@@ -1,5 +1,6 @@
import { firstValueFrom } from "rxjs";
import { firstValueFrom, map } from "rxjs";
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 { AppIdService } from "@bitwarden/common/platform/abstractions/app-id.service";
@@ -81,6 +82,7 @@ export class NativeMessagingBackground {
private logService: LogService,
private authService: AuthService,
private biometricStateService: BiometricStateService,
private accountService: AccountService,
) {
if (chrome?.permissions?.onAdded) {
// Reload extension to activate nativeMessaging
@@ -223,6 +225,16 @@ export class NativeMessagingBackground {
});
}
showIncorrectUserKeyDialog() {
this.messagingService.send("showDialog", {
title: { key: "nativeMessagingWrongUserKeyTitle" },
content: { key: "nativeMessagingWrongUserKeyDesc" },
acceptButtonText: { key: "ok" },
cancelButtonText: null,
type: "danger",
});
}
async send(message: Message) {
if (!this.connected) {
await this.connect();
@@ -350,7 +362,26 @@ export class NativeMessagingBackground {
const userKey = new SymmetricCryptoKey(
Utils.fromB64ToArray(message.userKeyB64),
) as UserKey;
await this.cryptoService.setUserKey(userKey);
const activeUserId = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => a?.id)),
);
const isUserKeyValid = await this.cryptoService.validateUserKey(
userKey,
activeUserId,
);
if (isUserKeyValid) {
await this.cryptoService.setUserKey(userKey, activeUserId);
} else {
this.logService.error("Unable to verify biometric unlocked userkey");
await this.cryptoService.clearKeys(activeUserId);
this.showIncorrectUserKeyDialog();
// Exit early
if (this.resolver) {
this.resolver(message);
}
return;
}
} else {
throw new Error("No key received");
}
@@ -371,21 +402,6 @@ export class NativeMessagingBackground {
return;
}
// Verify key is correct by attempting to decrypt a secret
try {
await this.cryptoService.getFingerprint(await this.stateService.getUserId());
} catch (e) {
this.logService.error("Unable to verify key: " + e);
await this.cryptoService.clearKeys();
this.showWrongUserDialog();
// Exit early
if (this.resolver) {
this.resolver(message);
}
return;
}
// 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.runtimeBackground.processMessage({ command: "unlocked" });

View File

@@ -278,12 +278,24 @@ export abstract class BrowserPlatformUtilsService implements PlatformUtilsServic
async supportsBiometric() {
const platformInfo = await BrowserApi.getPlatformInfo();
if (platformInfo.os === "mac" || platformInfo.os === "win") {
if (platformInfo.os === "mac" || platformInfo.os === "win" || platformInfo.os === "linux") {
return true;
}
return false;
}
async biometricsNeedsSetup(): Promise<boolean> {
return false;
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return false;
}
async biometricsSetup(): Promise<void> {
return;
}
authenticateBiometric() {
return this.biometricCallback();
}

View File

@@ -7,8 +7,11 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { MessageListener, MessageSender } from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeStateProvider, mockAccountServiceWith } from "@bitwarden/common/spec";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { CollectionService } from "@bitwarden/common/vault/abstractions/collection.service";
import { FolderApiServiceAbstraction } from "@bitwarden/common/vault/abstractions/folder/folder-api.service.abstraction";
@@ -18,6 +21,7 @@ import { DO_FULL_SYNC, ForegroundSyncService, FullSyncMessage } from "./foregrou
import { FullSyncFinishedMessage } from "./sync-service.listener";
describe("ForegroundSyncService", () => {
const userId = Utils.newGuid() as UserId;
const stateService = mock<StateService>();
const folderService = mock<InternalFolderService>();
const folderApiService = mock<FolderApiServiceAbstraction>();
@@ -31,6 +35,7 @@ describe("ForegroundSyncService", () => {
const sendService = mock<InternalSendService>();
const sendApiService = mock<SendApiService>();
const messageListener = mock<MessageListener>();
const stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
const sut = new ForegroundSyncService(
stateService,
@@ -46,6 +51,7 @@ describe("ForegroundSyncService", () => {
sendService,
sendApiService,
messageListener,
stateProvider,
);
beforeEach(() => {

View File

@@ -11,6 +11,7 @@ import {
MessageSender,
} from "@bitwarden/common/platform/messaging";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { StateProvider } from "@bitwarden/common/platform/state";
import { CoreSyncService } from "@bitwarden/common/platform/sync/internal";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@@ -40,6 +41,7 @@ export class ForegroundSyncService extends CoreSyncService {
sendService: InternalSendService,
sendApiService: SendApiService,
private readonly messageListener: MessageListener,
stateProvider: StateProvider,
) {
super(
stateService,
@@ -54,6 +56,7 @@ export class ForegroundSyncService extends CoreSyncService {
authService,
sendService,
sendApiService,
stateProvider,
);
}

View File

@@ -39,6 +39,7 @@ import { TwoFactorAuthComponent } from "../auth/popup/two-factor-auth.component"
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component";
@@ -63,7 +64,6 @@ import { ImportBrowserV2Component } from "../tools/popup/settings/import/import-
import { ImportBrowserComponent } from "../tools/popup/settings/import/import-browser.component";
import { SettingsV2Component } from "../tools/popup/settings/settings-v2.component";
import { SettingsComponent } from "../tools/popup/settings/settings.component";
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";
import { CollectionsComponent } from "../vault/popup/components/vault/collections.component";

View File

@@ -35,6 +35,9 @@ import { SsoComponent } from "../auth/popup/sso.component";
import { TwoFactorOptionsComponent } from "../auth/popup/two-factor-options.component";
import { TwoFactorComponent } from "../auth/popup/two-factor.component";
import { UpdateTempPasswordComponent } from "../auth/popup/update-temp-password.component";
import { Fido2CipherRowComponent } from "../autofill/popup/fido2/fido2-cipher-row.component";
import { Fido2UseBrowserLinkComponent } from "../autofill/popup/fido2/fido2-use-browser-link.component";
import { Fido2Component } from "../autofill/popup/fido2/fido2.component";
import { AutofillV1Component } from "../autofill/popup/settings/autofill-v1.component";
import { AutofillComponent } from "../autofill/popup/settings/autofill.component";
import { ExcludedDomainsV1Component } from "../autofill/popup/settings/excluded-domains-v1.component";
@@ -58,9 +61,6 @@ import { SendTypeComponent } from "../tools/popup/send/send-type.component";
import { SettingsComponent } from "../tools/popup/settings/settings.component";
import { ActionButtonsComponent } from "../vault/popup/components/action-buttons.component";
import { CipherRowComponent } from "../vault/popup/components/cipher-row.component";
import { Fido2CipherRowComponent } from "../vault/popup/components/fido2/fido2-cipher-row.component";
import { Fido2UseBrowserLinkComponent } from "../vault/popup/components/fido2/fido2-use-browser-link.component";
import { Fido2Component } from "../vault/popup/components/fido2/fido2.component";
import { AddEditCustomFieldsComponent } from "../vault/popup/components/vault/add-edit-custom-fields.component";
import { AddEditComponent } from "../vault/popup/components/vault/add-edit.component";
import { AttachmentsComponent } from "../vault/popup/components/vault/attachments.component";

View File

@@ -15,6 +15,7 @@ import {
CLIENT_TYPE,
} from "@bitwarden/angular/services/injection-tokens";
import { JslibServicesModule } from "@bitwarden/angular/services/jslib-services.module";
import { AnonLayoutWrapperDataService } from "@bitwarden/auth/angular";
import { PinServiceAbstraction } from "@bitwarden/auth/common";
import { EventCollectionService as EventCollectionServiceAbstraction } from "@bitwarden/common/abstractions/event/event-collection.service";
import { NotificationsService } from "@bitwarden/common/abstractions/notifications.service";
@@ -82,6 +83,7 @@ import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { PasswordRepromptService } from "@bitwarden/vault";
import { ExtensionAnonLayoutWrapperDataService } from "../../auth/popup/extension-anon-layout-wrapper/extension-anon-layout-wrapper-data.service";
import { AutofillService as AutofillServiceAbstraction } from "../../autofill/services/abstractions/autofill.service";
import AutofillService from "../../autofill/services/autofill.service";
import MainBackground from "../../background/main.background";
@@ -521,6 +523,11 @@ const safeProviders: SafeProvider[] = [
useFactory: getBgService<ForegroundTaskSchedulerService>("taskSchedulerService"),
deps: [],
}),
safeProvider({
provide: AnonLayoutWrapperDataService,
useClass: ExtensionAnonLayoutWrapperDataService,
deps: [],
}),
];
@NgModule({

View File

@@ -8,12 +8,32 @@
</ng-container>
</popup-header>
<div *ngIf="sends.length === 0" class="tw-flex tw-flex-col tw-h-full tw-justify-center">
<div
*ngIf="listState === sendState.Empty"
class="tw-flex tw-flex-col tw-h-full tw-justify-center"
>
<bit-no-items [icon]="noItemIcon" class="tw-text-main">
<ng-container slot="title">{{ "sendsNoItemsTitle" | i18n }}</ng-container>
<ng-container slot="description">{{ "sendsNoItemsMessage" | i18n }}</ng-container>
<tools-new-send-dropdown slot="button"></tools-new-send-dropdown>
</bit-no-items>
</div>
<app-send-list-items-container [sends]="sends" />
<ng-container *ngIf="listState !== sendState.Empty">
<div
*ngIf="listState === sendState.NoResults"
class="tw-flex tw-flex-col tw-justify-center tw-h-auto tw-pt-12"
>
<bit-no-items [icon]="noResultsIcon">
<ng-container slot="title">{{ "noItemsMatchSearch" | i18n }}</ng-container>
<ng-container slot="description">{{ "clearFiltersOrTryAnother" | i18n }}</ng-container>
</bit-no-items>
</div>
<app-send-list-items-container [headerText]="title | i18n" [sends]="sends$ | async" />
</ng-container>
<div slot="above-scroll-area" class="tw-p-4" *ngIf="listState !== sendState.Empty">
<tools-send-search></tools-send-search>
<app-send-list-filters></app-send-list-filters>
</div>
</popup-page>

View File

@@ -1,11 +1,12 @@
import { CommonModule } from "@angular/common";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { RouterLink } from "@angular/router";
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
import { RouterTestingModule } from "@angular/router/testing";
import { mock } from "jest-mock-extended";
import { Observable, of } from "rxjs";
import { MockProxy, mock } from "jest-mock-extended";
import { of, BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SearchService } from "@bitwarden/common/abstractions/search.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AvatarService } from "@bitwarden/common/auth/abstractions/avatar.service";
@@ -15,6 +16,7 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
@@ -22,7 +24,10 @@ import { ButtonModule, NoItemsModule } from "@bitwarden/components";
import {
NewSendDropdownComponent,
SendListItemsContainerComponent,
SendItemsService,
SendSearchComponent,
SendListFiltersComponent,
SendListFiltersService,
} from "@bitwarden/send-ui";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
@@ -30,31 +35,49 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
import { SendV2Component } from "./send-v2.component";
import { SendV2Component, SendState } from "./send-v2.component";
describe("SendV2Component", () => {
let component: SendV2Component;
let fixture: ComponentFixture<SendV2Component>;
let sendViews$: Observable<SendView[]>;
let sendItemsService: MockProxy<SendItemsService>;
let sendListFiltersService: SendListFiltersService;
let sendListFiltersServiceFilters$: BehaviorSubject<{ sendType: SendType | null }>;
let sendItemsServiceEmptyList$: BehaviorSubject<boolean>;
let sendItemsServiceNoFilteredResults$: BehaviorSubject<boolean>;
beforeEach(async () => {
sendViews$ = of([
{ id: "1", name: "Send A" },
{ id: "2", name: "Send B" },
] as SendView[]);
sendListFiltersServiceFilters$ = new BehaviorSubject({ sendType: null });
sendItemsServiceEmptyList$ = new BehaviorSubject(false);
sendItemsServiceNoFilteredResults$ = new BehaviorSubject(false);
sendItemsService = mock<SendItemsService>({
filteredAndSortedSends$: of([
{ id: "1", name: "Send A" },
{ id: "2", name: "Send B" },
] as SendView[]),
latestSearchText$: of(""),
});
sendListFiltersService = new SendListFiltersService(mock(), new FormBuilder());
sendListFiltersService.filters$ = sendListFiltersServiceFilters$;
sendItemsService.emptyList$ = sendItemsServiceEmptyList$;
sendItemsService.noFilteredResults$ = sendItemsServiceNoFilteredResults$;
await TestBed.configureTestingModule({
imports: [
CommonModule,
RouterTestingModule,
JslibModule,
NoItemsModule,
ReactiveFormsModule,
ButtonModule,
NoItemsModule,
RouterLink,
NewSendDropdownComponent,
SendListItemsContainerComponent,
SendListFiltersComponent,
SendSearchComponent,
SendV2Component,
PopupPageComponent,
PopupHeaderComponent,
PopOutComponent,
@@ -66,21 +89,24 @@ describe("SendV2Component", () => {
{ provide: AvatarService, useValue: mock<AvatarService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
useValue: { hasPremiumFromAnySource$: of(false) },
},
{ provide: ConfigService, useValue: mock<ConfigService>() },
{ provide: EnvironmentService, useValue: mock<EnvironmentService>() },
{ provide: LogService, useValue: mock<LogService>() },
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
{ provide: SendApiService, useValue: mock<SendApiService>() },
{ provide: SendService, useValue: { sendViews$ } },
{ provide: SendItemsService, useValue: mock<SendItemsService>() },
{ provide: SearchService, useValue: mock<SearchService>() },
{ provide: SendService, useValue: { sendViews$: new BehaviorSubject<SendView[]>([]) } },
{ provide: SendItemsService, useValue: sendItemsService },
{ provide: I18nService, useValue: { t: (key: string) => key } },
{ provide: SendListFiltersService, useValue: sendListFiltersService },
],
}).compileComponents();
fixture = TestBed.createComponent(SendV2Component);
component = fixture.componentInstance;
fixture.detectChanges();
});
@@ -88,14 +114,21 @@ describe("SendV2Component", () => {
expect(component).toBeTruthy();
});
it("should sort sends by name on initialization", async () => {
const sortedSends = [
{ id: "1", name: "Send A" },
{ id: "2", name: "Send B" },
] as SendView[];
it("should update the title based on the current filter", () => {
sendListFiltersServiceFilters$.next({ sendType: SendType.File });
fixture.detectChanges();
expect(component["title"]).toBe("fileSends");
});
await component.ngOnInit();
it("should set listState to Empty when emptyList$ emits true", () => {
sendItemsServiceEmptyList$.next(true);
fixture.detectChanges();
expect(component["listState"]).toBe(SendState.Empty);
});
expect(component.sends).toEqual(sortedSends);
it("should set listState to NoResults when noFilteredResults$ emits true", () => {
sendItemsServiceNoFilteredResults$.next(true);
fixture.detectChanges();
expect(component["listState"]).toBe(SendState.NoResults);
});
});

View File

@@ -1,18 +1,20 @@
import { CommonModule } from "@angular/common";
import { Component, OnDestroy, OnInit } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { RouterLink } from "@angular/router";
import { mergeMap, Subject, takeUntil } from "rxjs";
import { combineLatest } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { SendType } from "@bitwarden/common/tools/send/enums/send-type";
import { SendView } from "@bitwarden/common/tools/send/models/view/send.view";
import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction";
import { ButtonModule, NoItemsModule } from "@bitwarden/components";
import { ButtonModule, Icons, NoItemsModule } from "@bitwarden/components";
import {
NoSendsIcon,
NewSendDropdownComponent,
SendListItemsContainerComponent,
SendItemsService,
SendSearchComponent,
SendListFiltersComponent,
SendListFiltersService,
} from "@bitwarden/send-ui";
import { CurrentAccountComponent } from "../../../auth/popup/account-switching/current-account.component";
@@ -20,6 +22,11 @@ import { PopOutComponent } from "../../../platform/popup/components/pop-out.comp
import { PopupHeaderComponent } from "../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.component";
export enum SendState {
Empty,
NoResults,
}
@Component({
templateUrl: "send-v2.component.html",
standalone: true,
@@ -36,29 +43,56 @@ import { PopupPageComponent } from "../../../platform/popup/layout/popup-page.co
NewSendDropdownComponent,
SendListItemsContainerComponent,
SendListFiltersComponent,
SendSearchComponent,
],
})
export class SendV2Component implements OnInit, OnDestroy {
sendType = SendType;
private destroy$ = new Subject<void>();
sendState = SendState;
sends: SendView[] = [];
protected listState: SendState | null = null;
protected sends$ = this.sendItemsService.filteredAndSortedSends$;
protected title: string = "allSends";
protected noItemIcon = NoSendsIcon;
constructor(protected sendService: SendService) {}
protected noResultsIcon = Icons.NoResults;
async ngOnInit() {
this.sendService.sendViews$
.pipe(
mergeMap(async (sends) => {
this.sends = sends.sort((a, b) => a.name.localeCompare(b.name));
}),
takeUntil(this.destroy$),
)
.subscribe();
constructor(
protected sendItemsService: SendItemsService,
protected sendListFiltersService: SendListFiltersService,
) {
combineLatest([
this.sendItemsService.emptyList$,
this.sendItemsService.noFilteredResults$,
this.sendListFiltersService.filters$,
])
.pipe(takeUntilDestroyed())
.subscribe(([emptyList, noFilteredResults, currentFilter]) => {
if (currentFilter?.sendType !== null) {
this.title = `${this.sendType[currentFilter.sendType].toLowerCase()}Sends`;
} else {
this.title = "allSends";
}
if (emptyList) {
this.listState = SendState.Empty;
return;
}
if (noFilteredResults) {
this.listState = SendState.NoResults;
return;
}
this.listState = null;
});
}
ngOnInit(): void {}
ngOnDestroy(): void {}
}

View File

@@ -30,6 +30,22 @@ import { closeFido2Popout, openFido2Popout } from "../popup/utils/vault-popout-w
const BrowserFido2MessageName = "BrowserFido2UserInterfaceServiceMessage";
export const BrowserFido2MessageTypes = {
ConnectResponse: "ConnectResponse",
NewSessionCreatedRequest: "NewSessionCreatedRequest",
PickCredentialRequest: "PickCredentialRequest",
PickCredentialResponse: "PickCredentialResponse",
ConfirmNewCredentialRequest: "ConfirmNewCredentialRequest",
ConfirmNewCredentialResponse: "ConfirmNewCredentialResponse",
InformExcludedCredentialRequest: "InformExcludedCredentialRequest",
InformCredentialNotFoundRequest: "InformCredentialNotFoundRequest",
AbortRequest: "AbortRequest",
AbortResponse: "AbortResponse",
} as const;
export type BrowserFido2MessageTypeValue =
(typeof BrowserFido2MessageTypes)[keyof typeof BrowserFido2MessageTypes];
export class SessionClosedError extends Error {
constructor() {
super("Fido2UserInterfaceSession was closed");
@@ -39,30 +55,30 @@ export class SessionClosedError extends Error {
export type BrowserFido2Message = { sessionId: string } & (
| /**
* This message is used by popouts to announce that they are ready
* to recieve messages.
* to receive messages.
**/ {
type: "ConnectResponse";
type: typeof BrowserFido2MessageTypes.ConnectResponse;
}
/**
* This message is used to announce the creation of a new session.
* It is used by popouts to know when to close.
**/
| {
type: "NewSessionCreatedRequest";
type: typeof BrowserFido2MessageTypes.NewSessionCreatedRequest;
}
| {
type: "PickCredentialRequest";
type: typeof BrowserFido2MessageTypes.PickCredentialRequest;
cipherIds: string[];
userVerification: boolean;
fallbackSupported: boolean;
}
| {
type: "PickCredentialResponse";
type: typeof BrowserFido2MessageTypes.PickCredentialResponse;
cipherId?: string;
userVerified: boolean;
}
| {
type: "ConfirmNewCredentialRequest";
type: typeof BrowserFido2MessageTypes.ConfirmNewCredentialRequest;
credentialName: string;
userName: string;
userHandle: string;
@@ -71,24 +87,24 @@ export type BrowserFido2Message = { sessionId: string } & (
rpId: string;
}
| {
type: "ConfirmNewCredentialResponse";
type: typeof BrowserFido2MessageTypes.ConfirmNewCredentialResponse;
cipherId: string;
userVerified: boolean;
}
| {
type: "InformExcludedCredentialRequest";
type: typeof BrowserFido2MessageTypes.InformExcludedCredentialRequest;
existingCipherIds: string[];
fallbackSupported: boolean;
}
| {
type: "InformCredentialNotFoundRequest";
type: typeof BrowserFido2MessageTypes.InformCredentialNotFoundRequest;
fallbackSupported: boolean;
}
| {
type: "AbortRequest";
type: typeof BrowserFido2MessageTypes.AbortRequest;
}
| {
type: "AbortResponse";
type: typeof BrowserFido2MessageTypes.AbortResponse;
fallbackRequested: boolean;
}
);
@@ -138,7 +154,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
static abortPopout(sessionId: string, fallbackRequested = false) {
this.sendMessage({
sessionId: sessionId,
type: "AbortResponse",
type: BrowserFido2MessageTypes.AbortResponse,
fallbackRequested: fallbackRequested,
});
}
@@ -146,7 +162,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
static confirmNewCredentialResponse(sessionId: string, cipherId: string, userVerified: boolean) {
this.sendMessage({
sessionId: sessionId,
type: "ConfirmNewCredentialResponse",
type: BrowserFido2MessageTypes.ConfirmNewCredentialResponse,
cipherId,
userVerified,
});
@@ -169,7 +185,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
) {
this.messages$
.pipe(
filter((msg) => msg.type === "ConnectResponse"),
filter((msg) => msg.type === BrowserFido2MessageTypes.ConnectResponse),
take(1),
takeUntil(this.destroy$),
)
@@ -185,7 +201,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
// eslint-disable-next-line @typescript-eslint/no-floating-promises
this.close();
BrowserFido2UserInterfaceSession.sendMessage({
type: "AbortRequest",
type: BrowserFido2MessageTypes.AbortRequest,
sessionId: this.sessionId,
});
});
@@ -193,12 +209,12 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
// Handle session aborted by user
this.messages$
.pipe(
filter((msg) => msg.type === "AbortResponse"),
filter((msg) => msg.type === BrowserFido2MessageTypes.AbortResponse),
take(1),
takeUntil(this.destroy$),
)
.subscribe((msg) => {
if (msg.type === "AbortResponse") {
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();
@@ -217,7 +233,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
);
BrowserFido2UserInterfaceSession.sendMessage({
type: "NewSessionCreatedRequest",
type: BrowserFido2MessageTypes.NewSessionCreatedRequest,
sessionId,
});
}
@@ -227,7 +243,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
userVerification,
}: PickCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
const data: BrowserFido2Message = {
type: "PickCredentialRequest",
type: BrowserFido2MessageTypes.PickCredentialRequest,
cipherIds,
sessionId: this.sessionId,
userVerification,
@@ -235,7 +251,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
};
await this.send(data);
const response = await this.receive("PickCredentialResponse");
const response = await this.receive(BrowserFido2MessageTypes.PickCredentialResponse);
return { cipherId: response.cipherId, userVerified: response.userVerified };
}
@@ -248,7 +264,7 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
rpId,
}: NewCredentialParams): Promise<{ cipherId: string; userVerified: boolean }> {
const data: BrowserFido2Message = {
type: "ConfirmNewCredentialRequest",
type: BrowserFido2MessageTypes.ConfirmNewCredentialRequest,
sessionId: this.sessionId,
credentialName,
userName,
@@ -259,21 +275,21 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
};
await this.send(data);
const response = await this.receive("ConfirmNewCredentialResponse");
const response = await this.receive(BrowserFido2MessageTypes.ConfirmNewCredentialResponse);
return { cipherId: response.cipherId, userVerified: response.userVerified };
}
async informExcludedCredential(existingCipherIds: string[]): Promise<void> {
const data: BrowserFido2Message = {
type: "InformExcludedCredentialRequest",
type: BrowserFido2MessageTypes.InformExcludedCredentialRequest,
sessionId: this.sessionId,
existingCipherIds,
fallbackSupported: this.fallbackSupported,
};
await this.send(data);
await this.receive("AbortResponse");
await this.receive(BrowserFido2MessageTypes.AbortResponse);
}
async ensureUnlockedVault(): Promise<void> {
@@ -284,13 +300,13 @@ export class BrowserFido2UserInterfaceSession implements Fido2UserInterfaceSessi
async informCredentialNotFound(): Promise<void> {
const data: BrowserFido2Message = {
type: "InformCredentialNotFoundRequest",
type: BrowserFido2MessageTypes.InformCredentialNotFoundRequest,
sessionId: this.sessionId,
fallbackSupported: this.fallbackSupported,
};
await this.send(data);
await this.receive("AbortResponse");
await this.receive(BrowserFido2MessageTypes.AbortResponse);
}
async close() {

View File

@@ -4,7 +4,9 @@
[pageTitle]="headerText"
[backAction]="handleBackButton.bind(this)"
showBackButton
></popup-header>
>
<app-pop-out slot="end" />
</popup-header>
<vault-cipher-form
*ngIf="!loading"

View File

@@ -21,6 +21,7 @@ import {
} from "@bitwarden/vault";
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "../../../../../platform/popup/layout/popup-header.component";
import { PopupPageComponent } from "../../../../../platform/popup/layout/popup-page.component";
@@ -118,6 +119,7 @@ export type AddEditQueryParams = Partial<Record<keyof QueryParams, string>>;
PopupFooterComponent,
CipherFormModule,
AsyncActionsModule,
PopOutComponent,
],
})
export class AddEditV2Component implements OnInit {

View File

@@ -1,5 +1,7 @@
<popup-page>
<popup-header slot="header" [pageTitle]="headerText" showBackButton> </popup-header>
<popup-header slot="header" [pageTitle]="headerText" showBackButton>
<app-pop-out slot="end" />
</popup-header>
<app-cipher-view *ngIf="cipher" [cipher]="cipher"></app-cipher-view>

View File

@@ -24,6 +24,7 @@ import {
} from "@bitwarden/components";
import { CipherViewComponent } from "../../../../../../../../libs/vault/src/cipher-view";
import { PopOutComponent } from "../../../../../platform/popup/components/pop-out.component";
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
import { PopupHeaderComponent } from "./../../../../../platform/popup/layout/popup-header.component";
@@ -45,6 +46,7 @@ import { PopupPageComponent } from "./../../../../../platform/popup/layout/popup
IconButtonModule,
CipherViewComponent,
AsyncActionsModule,
PopOutComponent,
],
})
export class ViewV2Component {

View File

@@ -80,7 +80,7 @@
"papaparse": "5.4.1",
"proper-lockfile": "4.1.2",
"rxjs": "7.8.1",
"tldts": "6.1.34",
"tldts": "6.1.38",
"zxcvbn": "4.4.2"
}
}

View File

@@ -10,7 +10,7 @@ import { ListResponse } from "./models/response/list.response";
import { MessageResponse } from "./models/response/message.response";
import { StringResponse } from "./models/response/string.response";
import { TemplateResponse } from "./models/response/template.response";
import { ServiceContainer } from "./service-container";
import { ServiceContainer } from "./service-container/service-container";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;

View File

@@ -3,7 +3,7 @@ import { program } from "commander";
import { OssServeConfigurator } from "./oss-serve-configurator";
import { registerOssPrograms } from "./register-oss-programs";
import { ServeProgram } from "./serve.program";
import { ServiceContainer } from "./service-container";
import { ServiceContainer } from "./service-container/service-container";
async function main() {
const serviceContainer = new ServiceContainer();

View File

@@ -7,7 +7,7 @@ import * as koaJson from "koa-json";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { OssServeConfigurator } from "../oss-serve-configurator";
import { ServiceContainer } from "../service-container";
import { ServiceContainer } from "../service-container/service-container";
export class ServeCommand {
constructor(

View File

@@ -13,7 +13,7 @@ import { RestoreCommand } from "./commands/restore.command";
import { StatusCommand } from "./commands/status.command";
import { Response } from "./models/response";
import { FileResponse } from "./models/response/file.response";
import { ServiceContainer } from "./service-container";
import { ServiceContainer } from "./service-container/service-container";
import { GenerateCommand } from "./tools/generate.command";
import {
SendEditCommand,

View File

@@ -139,6 +139,18 @@ export class CliPlatformUtilsService implements PlatformUtilsService {
return Promise.resolve(false);
}
biometricsNeedsSetup(): Promise<boolean> {
return Promise.resolve(false);
}
biometricsSupportsAutoSetup(): Promise<boolean> {
return Promise.resolve(false);
}
biometricsSetup(): Promise<void> {
return Promise.resolve();
}
supportsSecureStorage(): boolean {
return false;
}

View File

@@ -1,5 +1,5 @@
import { Program } from "./program";
import { ServiceContainer } from "./service-container";
import { ServiceContainer } from "./service-container/service-container";
import { SendProgram } from "./tools/send/send.program";
import { VaultProgram } from "./vault.program";

View File

@@ -3,7 +3,7 @@ import { program } from "commander";
import { BaseProgram } from "./base-program";
import { ServeCommand } from "./commands/serve.command";
import { OssServeConfigurator } from "./oss-serve-configurator";
import { ServiceContainer } from "./service-container";
import { ServiceContainer } from "./service-container/service-container";
import { CliUtils } from "./utils";
const writeLn = CliUtils.writeLn;

View File

@@ -44,7 +44,10 @@ import { TokenService } from "@bitwarden/common/auth/services/token.service";
import { TwoFactorService } from "@bitwarden/common/auth/services/two-factor.service";
import { UserVerificationApiService } from "@bitwarden/common/auth/services/user-verification/user-verification-api.service";
import { UserVerificationService } from "@bitwarden/common/auth/services/user-verification/user-verification.service";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
DefaultDomainSettingsService,
DomainSettingsService,
@@ -147,18 +150,18 @@ import {
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { CliPlatformUtilsService } from "./platform/services/cli-platform-utils.service";
import { ConsoleLogService } from "./platform/services/console-log.service";
import { I18nService } from "./platform/services/i18n.service";
import { LowdbStorageService } from "./platform/services/lowdb-storage.service";
import { NodeApiService } from "./platform/services/node-api.service";
import { NodeEnvSecureStorageService } from "./platform/services/node-env-secure-storage.service";
import { CliPlatformUtilsService } from "../platform/services/cli-platform-utils.service";
import { ConsoleLogService } from "../platform/services/console-log.service";
import { I18nService } from "../platform/services/i18n.service";
import { LowdbStorageService } from "../platform/services/lowdb-storage.service";
import { NodeApiService } from "../platform/services/node-api.service";
import { NodeEnvSecureStorageService } from "../platform/services/node-env-secure-storage.service";
// Polyfills
global.DOMParser = new jsdom.JSDOM().window.DOMParser;
// eslint-disable-next-line
const packageJson = require("../package.json");
const packageJson = require("../../package.json");
/**
* Instantiates services and makes them available for dependency injection.
@@ -254,13 +257,13 @@ export class ServiceContainer {
} else if (process.env.BITWARDENCLI_APPDATA_DIR) {
p = path.resolve(process.env.BITWARDENCLI_APPDATA_DIR);
} else if (process.platform === "darwin") {
p = path.join(process.env.HOME, "Library/Application Support/Bitwarden CLI");
p = path.join(process.env.HOME ?? "", "Library/Application Support/Bitwarden CLI");
} else if (process.platform === "win32") {
p = path.join(process.env.APPDATA, "Bitwarden CLI");
p = path.join(process.env.APPDATA ?? "", "Bitwarden CLI");
} else if (process.env.XDG_CONFIG_HOME) {
p = path.join(process.env.XDG_CONFIG_HOME, "Bitwarden CLI");
} else {
p = path.join(process.env.HOME, ".config/Bitwarden CLI");
p = path.join(process.env.HOME ?? "", ".config/Bitwarden CLI");
}
const logoutCallback = async () => await this.logout();
@@ -452,8 +455,6 @@ export class ServiceContainer {
customUserAgent,
);
this.organizationApiService = new OrganizationApiService(this.apiService, this.syncService);
this.containerService = new ContainerService(this.cryptoService, this.encryptService);
this.domainSettingsService = new DefaultDomainSettingsService(this.stateProvider);
@@ -524,6 +525,40 @@ export class ServiceContainer {
this.stateProvider,
);
this.authRequestService = new AuthRequestService(
this.appIdService,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.stateProvider,
);
this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService(
this.stateProvider,
);
this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService);
this.authService = new AuthService(
this.accountService,
this.messagingService,
this.cryptoService,
this.apiService,
this.stateService,
this.tokenService,
);
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
this.configService = new DefaultConfigService(
this.configApiService,
this.environmentService,
this.logService,
this.stateProvider,
this.authService,
);
this.devicesApiService = new DevicesApiServiceImplementation(this.apiService);
this.deviceTrustService = new DeviceTrustService(
this.keyGenerationService,
@@ -541,20 +576,6 @@ export class ServiceContainer {
this.configService,
);
this.authRequestService = new AuthRequestService(
this.appIdService,
this.accountService,
this.masterPasswordService,
this.cryptoService,
this.apiService,
this.stateProvider,
);
this.billingAccountProfileStateService = new DefaultBillingAccountProfileStateService(
this.stateProvider,
);
this.taskSchedulerService = new DefaultTaskSchedulerService(this.logService);
this.loginStrategyService = new LoginStrategyService(
this.accountService,
this.masterPasswordService,
@@ -583,23 +604,10 @@ export class ServiceContainer {
this.taskSchedulerService,
);
this.authService = new AuthService(
this.accountService,
this.messagingService,
this.cryptoService,
this.apiService,
this.stateService,
this.tokenService,
);
this.configApiService = new ConfigApiService(this.apiService, this.tokenService);
this.configService = new DefaultConfigService(
this.configApiService,
this.environmentService,
this.logService,
// FIXME: CLI does not support autofill
this.autofillSettingsService = new AutofillSettingsService(
this.stateProvider,
this.authService,
this.policyService,
);
this.cipherService = new CipherService(
@@ -661,7 +669,7 @@ export class ServiceContainer {
this.taskSchedulerService,
this.logService,
lockedCallback,
null,
undefined,
);
this.avatarService = new AvatarService(this.apiService, this.stateProvider);
@@ -691,6 +699,7 @@ export class ServiceContainer {
this.billingAccountProfileStateService,
this.tokenService,
this.authService,
this.stateProvider,
);
this.totpService = new TotpService(this.cryptoFunctionService, this.logService);
@@ -752,6 +761,8 @@ export class ServiceContainer {
this.accountService,
);
this.organizationApiService = new OrganizationApiService(this.apiService, this.syncService);
this.providerApiService = new ProviderApiService(this.apiService);
}
@@ -762,7 +773,6 @@ export class ServiceContainer {
const userId = (await this.stateService.getUserId()) as UserId;
await Promise.all([
this.eventUploadService.uploadEvents(userId as UserId),
this.syncService.setLastSync(new Date(0)),
this.cryptoService.clearKeys(),
this.cipherService.clear(userId),
this.folderService.clear(userId),
@@ -774,7 +784,7 @@ export class ServiceContainer {
await this.stateService.clean();
await this.accountService.clean(userId);
await this.accountService.switchAccount(null);
process.env.BW_SESSION = null;
process.env.BW_SESSION = undefined;
}
async init() {
@@ -790,7 +800,7 @@ export class ServiceContainer {
this.twoFactorService.init();
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount) {
if (activeAccount?.id) {
await this.userAutoUnlockKeyService.setUserKeyInMemoryIfAutoUserKeySet(activeAccount.id);
}

View File

@@ -0,0 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"strictNullChecks": true,
"strictPropertyInitialization": true
}
}

View File

@@ -282,12 +282,6 @@ dependencies = [
"piper",
]
[[package]]
name = "bytes"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "514de17de45fdb8dc022b1a7975556c53c86f9f0aa5f534b98977b171857c2c9"
[[package]]
name = "cbc"
version = "0.1.2"
@@ -496,6 +490,7 @@ dependencies = [
"core-foundation",
"gio",
"keytar",
"libc",
"libsecret",
"rand",
"retry",
@@ -509,6 +504,7 @@ dependencies = [
"widestring",
"windows",
"zbus",
"zbus_polkit",
]
[[package]]
@@ -2282,6 +2278,19 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zbus_polkit"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00a29bfa927b29f91b7feb4e1990f2dd1b4604072f493dc2f074cf59e4e0ba90"
dependencies = [
"enumflags2",
"serde",
"serde_repr",
"static_assertions",
"zbus",
]
[[package]]
name = "zvariant"
version = "4.1.2"

View File

@@ -17,6 +17,7 @@ arboard = { version = "=3.4.0", default-features = false, features = [
] }
base64 = "=0.22.1"
cbc = { version = "=0.1.2", features = ["alloc"] }
libc = "0.2.155"
rand = "=0.8.5"
retry = "=2.0.0"
scopeguard = "=1.2.0"
@@ -51,3 +52,4 @@ security-framework-sys = "=2.11.0"
gio = "=0.19.5"
libsecret = "=0.5.0"
zbus = "4.3.1"
zbus_polkit = "4.0.0"

View File

@@ -6,11 +6,11 @@ use crate::biometric::{KeyMaterial, OsDerivedKey};
pub struct Biometric {}
impl super::BiometricTrait for Biometric {
fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
async fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
bail!("platform not supported");
}
fn available() -> Result<bool> {
async fn available() -> Result<bool> {
bail!("platform not supported");
}

View File

@@ -1,4 +1,5 @@
use anyhow::Result;
use aes::cipher::generic_array::GenericArray;
use anyhow::{anyhow, Result};
#[cfg_attr(target_os = "linux", path = "unix.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
@@ -6,6 +7,10 @@ use anyhow::Result;
mod biometric;
pub use biometric::Biometric;
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use sha2::{Digest, Sha256};
use crate::crypto::{self, CipherString};
pub struct KeyMaterial {
pub os_key_part_b64: String,
@@ -18,8 +23,10 @@ pub struct OsDerivedKey {
}
pub trait BiometricTrait {
fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
fn available() -> Result<bool>;
#[allow(async_fn_in_trait)]
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool>;
#[allow(async_fn_in_trait)]
async fn available() -> Result<bool>;
fn derive_key_material(secret: Option<&str>) -> Result<OsDerivedKey>;
fn set_biometric_secret(
service: &str,
@@ -34,3 +41,40 @@ pub trait BiometricTrait {
key_material: Option<KeyMaterial>,
) -> Result<String>;
}
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
let iv = base64_engine
.decode(iv_b64)?
.try_into()
.map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?;
let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?;
Ok(encrypted.to_string())
}
fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result<String> {
if let CipherString::AesCbc256_B64 { iv, data } = secret {
let decrypted = crypto::decrypt_aes256(&iv, &data, key_material.derive_key()?)?;
Ok(String::from_utf8(decrypted)?)
} else {
Err(anyhow!("Invalid cipher string"))
}
}
impl KeyMaterial {
fn digest_material(&self) -> String {
match self.client_key_part_b64.as_deref() {
Some(client_key_part_b64) => {
format!("{}|{}", self.os_key_part_b64, client_key_part_b64)
}
None => self.os_key_part_b64.clone(),
}
}
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
Ok(Sha256::digest(self.digest_material()))
}
}

View File

@@ -1,38 +1,109 @@
use anyhow::{bail, Result};
use std::str::FromStr;
use crate::biometric::{KeyMaterial, OsDerivedKey};
use anyhow::Result;
use base64::Engine;
use rand::RngCore;
use sha2::{Digest, Sha256};
use crate::biometric::{KeyMaterial, OsDerivedKey, base64_engine};
use zbus::Connection;
use zbus_polkit::policykit1::*;
use super::{decrypt, encrypt};
use anyhow::anyhow;
use crate::crypto::CipherString;
/// The Unix implementation of the biometric trait.
pub struct Biometric {}
impl super::BiometricTrait for Biometric {
fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
bail!("platform not supported");
async fn prompt(_hwnd: Vec<u8>, _message: String) -> Result<bool> {
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let subject = Subject::new_for_owner(std::process::id(), None, None)?;
let details = std::collections::HashMap::new();
let result = proxy.check_authorization(
&subject,
"com.bitwarden.Bitwarden.unlock",
&details,
CheckAuthorizationFlags::AllowUserInteraction.into(),
"",
).await;
match result {
Ok(result) => {
return Ok(result.is_authorized);
}
Err(e) => {
println!("polkit biometric error: {:?}", e);
return Ok(false);
}
}
}
fn available() -> Result<bool> {
bail!("platform not supported");
async fn available() -> Result<bool> {
let connection = Connection::system().await?;
let proxy = AuthorityProxy::new(&connection).await?;
let res = proxy.enumerate_actions("en").await?;
for action in res {
if action.action_id == "com.bitwarden.Bitwarden.unlock" {
return Ok(true);
}
}
return Ok(false);
}
fn derive_key_material(_iv_str: Option<&str>) -> Result<OsDerivedKey> {
bail!("platform not supported");
}
fn derive_key_material(challenge_str: Option<&str>) -> Result<OsDerivedKey> {
let challenge: [u8; 16] = match challenge_str {
Some(challenge_str) => base64_engine
.decode(challenge_str)?
.try_into()
.map_err(|e: Vec<_>| anyhow!("Expect length {}, got {}", 16, e.len()))?,
None => random_challenge(),
};
fn get_biometric_secret(
_service: &str,
_account: &str,
_key_material: Option<KeyMaterial>,
) -> Result<String> {
bail!("platform not supported");
// there is no windows hello like interactive bio protected secret at the moment on linux
// so we use a a key derived from the iv. this key is not intended to add any security
// but only a place-holder
let key = Sha256::digest(challenge);
let key_b64 = base64_engine.encode(&key);
let iv_b64 = base64_engine.encode(&challenge);
Ok(OsDerivedKey { key_b64, iv_b64 })
}
fn set_biometric_secret(
_service: &str,
_account: &str,
_secret: &str,
_key_material: Option<KeyMaterial>,
_iv_b64: &str,
service: &str,
account: &str,
secret: &str,
key_material: Option<KeyMaterial>,
iv_b64: &str,
) -> Result<String> {
bail!("platform not supported");
let key_material = key_material.ok_or(anyhow!(
"Key material is required for polkit protected keys"
))?;
let encrypted_secret = encrypt(secret, &key_material, iv_b64)?;
crate::password::set_password(service, account, &encrypted_secret)?;
Ok(encrypted_secret)
}
fn get_biometric_secret(
service: &str,
account: &str,
key_material: Option<KeyMaterial>,
) -> Result<String> {
let key_material = key_material.ok_or(anyhow!(
"Key material is required for polkit protected keys"
))?;
let encrypted_secret = crate::password::get_password(service, account)?;
let secret = CipherString::from_str(&encrypted_secret)?;
return Ok(decrypt(&secret, &key_material)?);
}
}
fn random_challenge() -> [u8; 16] {
let mut challenge = [0u8; 16];
rand::thread_rng().fill_bytes(&mut challenge);
challenge
}

View File

@@ -1,6 +1,5 @@
use std::str::FromStr;
use aes::cipher::generic_array::GenericArray;
use anyhow::{anyhow, Result};
use base64::{engine::general_purpose::STANDARD as base64_engine, Engine};
use rand::RngCore;
@@ -30,14 +29,16 @@ use windows::{
use crate::{
biometric::{KeyMaterial, OsDerivedKey},
crypto::{self, CipherString},
crypto::CipherString,
};
use super::{decrypt, encrypt};
/// The Windows OS implementation of the biometric trait.
pub struct Biometric {}
impl super::BiometricTrait for Biometric {
fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
async fn prompt(hwnd: Vec<u8>, message: String) -> Result<bool> {
let h = isize::from_le_bytes(hwnd.clone().try_into().unwrap());
let window = HWND(h);
@@ -56,7 +57,7 @@ impl super::BiometricTrait for Biometric {
}
}
fn available() -> Result<bool> {
async fn available() -> Result<bool> {
let ucv_available = UserConsentVerifier::CheckAvailabilityAsync()?.get()?;
match ucv_available {
@@ -159,26 +160,6 @@ impl super::BiometricTrait for Biometric {
}
}
fn encrypt(secret: &str, key_material: &KeyMaterial, iv_b64: &str) -> Result<String> {
let iv = base64_engine
.decode(iv_b64)?
.try_into()
.map_err(|e: Vec<_>| anyhow!("Expected length {}, got {}", 16, e.len()))?;
let encrypted = crypto::encrypt_aes256(secret.as_bytes(), iv, key_material.derive_key()?)?;
Ok(encrypted.to_string())
}
fn decrypt(secret: &CipherString, key_material: &KeyMaterial) -> Result<String> {
if let CipherString::AesCbc256_B64 { iv, data } = secret {
let decrypted = crypto::decrypt_aes256(&iv, &data, key_material.derive_key()?)?;
Ok(String::from_utf8(decrypted)?)
} else {
Err(anyhow!("Invalid cipher string"))
}
}
fn random_challenge() -> [u8; 16] {
let mut challenge = [0u8; 16];
@@ -237,26 +218,11 @@ fn set_focus(window: HWND) {
}
}
impl KeyMaterial {
fn digest_material(&self) -> String {
match self.client_key_part_b64.as_deref() {
Some(client_key_part_b64) => {
format!("{}|{}", self.os_key_part_b64, client_key_part_b64)
}
None => self.os_key_part_b64.clone(),
}
}
pub fn derive_key(&self) -> Result<GenericArray<u8, typenum::U32>> {
Ok(Sha256::digest(self.digest_material()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::biometric::BiometricTrait;
use crate::biometric::{encrypt, BiometricTrait};
#[test]
#[cfg(feature = "manual_test")]

View File

@@ -3,4 +3,5 @@ pub mod clipboard;
pub mod crypto;
pub mod error;
pub mod password;
pub mod process_isolation;
pub mod powermonitor;

View File

@@ -22,6 +22,10 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
Ok(result)
}
pub fn is_available() -> Result<bool> {
Ok(true)
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -40,6 +40,17 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
Ok(result)
}
pub fn is_available() -> Result<bool> {
let result = password_clear_sync(Some(&get_schema()), build_attributes("bitwardenSecretsAvailabilityTest", "test"), gio::Cancellable::NONE);
match result {
Ok(_) => Ok(true),
Err(_) => {
println!("secret-service unavailable: {:?}", result);
Ok(false)
}
}
}
fn get_schema() -> Schema {
let mut attributes = std::collections::HashMap::new();
attributes.insert("service", libsecret::SchemaAttributeType::String);

View File

@@ -122,6 +122,10 @@ pub fn delete_password(service: &str, account: &str) -> Result<()> {
Ok(())
}
pub fn is_available() -> Result<bool> {
Ok(true)
}
fn target_name(service: &str, account: &str) -> String {
format!("{}/{}", service, account)
}

View File

@@ -0,0 +1,51 @@
use anyhow::Result;
use libc::{c_int, self};
#[cfg(target_env = "gnu")]
use libc::c_uint;
// RLIMIT_CORE is the maximum size of a core dump file. Setting both to 0 disables core dumps, on crashes
// https://github.com/torvalds/linux/blob/1613e604df0cd359cf2a7fbd9be7a0bcfacfabd0/include/uapi/asm-generic/resource.h#L20
#[cfg(target_env = "musl")]
const RLIMIT_CORE: c_int = 4;
#[cfg(target_env = "gnu")]
const RLIMIT_CORE: c_uint = 4;
// PR_SET_DUMPABLE makes it so no other running process (root or same user) can dump the memory of this process
// or attach a debugger to it.
// https://github.com/torvalds/linux/blob/a38297e3fb012ddfa7ce0321a7e5a8daeb1872b6/include/uapi/linux/prctl.h#L14
const PR_SET_DUMPABLE: c_int = 4;
pub fn disable_coredumps() -> Result<()> {
let rlimit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if unsafe { libc::setrlimit(RLIMIT_CORE, &rlimit) } != 0 {
let e = std::io::Error::last_os_error();
return Err(anyhow::anyhow!("failed to disable core dumping, memory might be persisted to disk on crashes {}", e))
}
Ok(())
}
pub fn is_core_dumping_disabled() -> Result<bool> {
let mut rlimit = libc::rlimit {
rlim_cur: 0,
rlim_max: 0,
};
if unsafe { libc::getrlimit(RLIMIT_CORE, &mut rlimit) } != 0 {
let e = std::io::Error::last_os_error();
return Err(anyhow::anyhow!("failed to get core dump limit {}", e))
}
Ok(rlimit.rlim_cur == 0 && rlimit.rlim_max == 0)
}
pub fn disable_memory_access() -> Result<()> {
if unsafe { libc::prctl(PR_SET_DUMPABLE, 0) } != 0 {
let e = std::io::Error::last_os_error();
return Err(anyhow::anyhow!("failed to disable memory dumping, memory is dumpable by other processes {}", e))
}
Ok(())
}

View File

@@ -0,0 +1,13 @@
use anyhow::{bail, Result};
pub fn disable_coredumps() -> Result<()> {
bail!("Not implemented on Mac")
}
pub fn is_core_dumping_disabled() -> Result<bool> {
bail!("Not implemented on Mac")
}
pub fn disable_memory_access() -> Result<()> {
bail!("Not implemented on Mac")
}

View File

@@ -0,0 +1,5 @@
#[cfg_attr(target_os = "linux", path = "linux.rs")]
#[cfg_attr(target_os = "windows", path = "windows.rs")]
#[cfg_attr(target_os = "macos", path = "macos.rs")]
mod process_isolation;
pub use process_isolation::*;

View File

@@ -0,0 +1,13 @@
use anyhow::{bail, Result};
pub fn disable_coredumps() -> Result<()> {
bail!("Not implemented on Windows")
}
pub fn is_core_dumping_disabled() -> Result<bool> {
bail!("Not implemented on Windows")
}
pub fn disable_memory_access() -> Result<()> {
bail!("Not implemented on Windows")
}

View File

@@ -12,6 +12,7 @@ export namespace passwords {
export function setPassword(service: string, account: string, password: string): Promise<void>
/** Delete the stored password from the keychain. */
export function deletePassword(service: string, account: string): Promise<void>
export function isAvailable(): Promise<boolean>
}
export namespace biometrics {
export function prompt(hwnd: Buffer, message: string): Promise<boolean>
@@ -41,6 +42,12 @@ export namespace clipboards {
export function read(): Promise<string>
export function write(text: string, password: boolean): Promise<void>
}
export namespace processisolations {
export function disableCoredumps(): Promise<void>
export function isCoreDumpingDisabled(): Promise<boolean>
export function disableMemoryAccess(): Promise<void>
}
export namespace powermonitors {
export function onLock(callback: (err: Error | null, ) => any): Promise<void>
export function isLockMonitorAvailable(): Promise<boolean>

View File

@@ -206,9 +206,10 @@ if (!nativeBinding) {
throw new Error(`Failed to load native binding`)
}
const { passwords, biometrics, clipboards, powermonitors } = nativeBinding
const { passwords, biometrics, clipboards, processisolations, powermonitors } = nativeBinding
module.exports.passwords = passwords
module.exports.biometrics = biometrics
module.exports.clipboards = clipboards
module.exports.processisolations = processisolations
module.exports.powermonitors = powermonitors

View File

@@ -33,6 +33,12 @@ pub mod passwords {
desktop_core::password::delete_password(&service, &account)
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
// Checks if the os secure storage is available
#[napi]
pub async fn is_available() -> napi::Result<bool> {
desktop_core::password::is_available().map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
@@ -45,12 +51,12 @@ pub mod biometrics {
hwnd: napi::bindgen_prelude::Buffer,
message: String,
) -> napi::Result<bool> {
Biometric::prompt(hwnd.into(), message).map_err(|e| napi::Error::from_reason(e.to_string()))
Biometric::prompt(hwnd.into(), message).await.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn available() -> napi::Result<bool> {
Biometric::available().map_err(|e| napi::Error::from_reason(e.to_string()))
Biometric::available().await.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
@@ -142,6 +148,25 @@ pub mod clipboards {
}
}
#[napi]
pub mod processisolations {
#[napi]
pub async fn disable_coredumps() -> napi::Result<()> {
desktop_core::process_isolation::disable_coredumps()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn is_core_dumping_disabled() -> napi::Result<bool> {
desktop_core::process_isolation::is_core_dumping_disabled()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
#[napi]
pub async fn disable_memory_access() -> napi::Result<()> {
desktop_core::process_isolation::disable_memory_access()
.map_err(|e| napi::Error::from_reason(e.to_string()))
}
}
#[napi]
pub mod powermonitors {
use napi::{threadsafe_function::{ErrorStrategy::CalleeHandled, ThreadsafeFunction, ThreadsafeFunctionCallMode}, tokio};

View File

@@ -3,6 +3,10 @@
# disable core dumps
ulimit -c 0
APP_PATH=$(dirname "$0")
# might be behind symlink
RAW_PATH=$(readlink -f "$0")
APP_PATH=$(dirname $RAW_PATH)
# pass through all args
$APP_PATH/bitwarden-app "$@"
$APP_PATH/bitwarden-app "$@"

View File

@@ -126,11 +126,14 @@
{{ biometricText | i18n }}
</label>
</div>
<small class="help-block" *ngIf="this.form.value.biometric">{{
<small class="help-block" *ngIf="this.form.value.biometric && !this.isLinux">{{
additionalBiometricSettingsText | i18n
}}</small>
</div>
<div class="form-group" *ngIf="supportsBiometric && this.form.value.biometric">
<div
class="form-group"
*ngIf="supportsBiometric && this.form.value.biometric && !this.isLinux"
>
<div class="checkbox form-group-child">
<label for="autoPromptBiometrics">
<input
@@ -148,7 +151,8 @@
*ngIf="
supportsBiometric &&
this.form.value.biometric &&
(userHasMasterPassword || (this.form.value.pin && userHasPinSet))
(userHasMasterPassword || (this.form.value.pin && userHasPinSet)) &&
!this.isLinux
"
>
<div class="checkbox form-group-child">

View File

@@ -55,6 +55,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
requireEnableTray = false;
showDuckDuckGoIntegrationOption = false;
isWindows: boolean;
isLinux: boolean;
enableTrayText: string;
enableTrayDescText: string;
@@ -197,6 +198,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.userHasMasterPassword = await this.userVerificationService.hasMasterPassword();
this.isWindows = (await this.platformUtilsService.getDevice()) === DeviceType.WindowsDesktop;
this.isLinux = (await this.platformUtilsService.getDevice()) === DeviceType.LinuxDesktop;
if ((await this.stateService.getUserId()) == null) {
return;
@@ -464,6 +466,26 @@ export class SettingsComponent implements OnInit, OnDestroy {
return;
}
const needsSetup = await this.platformUtilsService.biometricsNeedsSetup();
const supportsBiometricAutoSetup =
await this.platformUtilsService.biometricsSupportsAutoSetup();
if (needsSetup) {
if (supportsBiometricAutoSetup) {
await this.platformUtilsService.biometricsSetup();
} else {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "biometricsManualSetupTitle" },
content: { key: "biometricsManualSetupDesc" },
type: "warning",
});
if (confirmed) {
this.platformUtilsService.launchUri("https://bitwarden.com/help/biometrics/");
}
return;
}
}
await this.biometricStateService.setBiometricUnlockEnabled(true);
if (this.isWindows) {
// Recommended settings for Windows Hello
@@ -472,6 +494,13 @@ export class SettingsComponent implements OnInit, OnDestroy {
await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
} else if (this.isLinux) {
// Similar to Windows
this.form.controls.requirePasswordOnStart.setValue(true);
this.form.controls.autoPromptBiometrics.setValue(false);
await this.biometricStateService.setPromptAutomatically(false);
await this.biometricStateService.setRequirePasswordOnStart(true);
await this.biometricStateService.setDismissedRequirePasswordOnStartCallout();
}
await this.cryptoService.refreshAdditionalKeys();
@@ -624,7 +653,7 @@ export class SettingsComponent implements OnInit, OnDestroy {
this.form.controls.enableBrowserIntegration.setValue(false);
return;
} else if (ipc.platform.deviceType === DeviceType.LinuxDesktop) {
} else if (ipc.platform.isSnapStore || ipc.platform.isFlatpak) {
await this.dialogService.openSimpleDialog({
title: { key: "browserIntegrationUnsupportedTitle" },
content: { key: "browserIntegrationLinuxDesc" },
@@ -735,6 +764,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}
@@ -746,6 +777,8 @@ export class SettingsComponent implements OnInit, OnDestroy {
return "autoPromptTouchId";
case DeviceType.WindowsDesktop:
return "autoPromptWindowsHello";
case DeviceType.LinuxDesktop:
return "autoPromptPolkit";
default:
throw new Error("Unsupported platform");
}

View File

@@ -650,7 +650,6 @@ export class AppComponent implements OnInit, OnDestroy {
// Provide the userId of the user to upload events for
await this.eventUploadService.uploadEvents(userBeingLoggedOut);
await this.syncService.setLastSync(new Date(0), userBeingLoggedOut);
await this.cryptoService.clearKeys(userBeingLoggedOut);
await this.cipherService.clear(userBeingLoggedOut);
await this.folderService.clear(userBeingLoggedOut);

View File

@@ -217,6 +217,8 @@ export class LockComponent extends BaseLockComponent implements OnInit, OnDestro
return "unlockWithTouchId";
case DeviceType.WindowsDesktop:
return "unlockWithWindowsHello";
case DeviceType.LinuxDesktop:
return "unlockWithPolkit";
default:
throw new Error("Unsupported platform");
}

View File

@@ -1510,9 +1510,15 @@
"additionalWindowsHelloSettings": {
"message": "Additional Windows Hello settings"
},
"unlockWithPolkit": {
"message": "Unlock with system authentication"
},
"windowsHelloConsentMessage": {
"message": "Verify for Bitwarden."
},
"polkitConsentMessage": {
"message": "Authenticate to unlock Bitwarden."
},
"unlockWithTouchId": {
"message": "Unlock with Touch ID"
},
@@ -1525,6 +1531,9 @@
"autoPromptWindowsHello": {
"message": "Ask for Windows Hello on app start"
},
"autoPromptPolkit": {
"message": "Ask for system authentication on launch"
},
"autoPromptTouchId": {
"message": "Ask for Touch ID on app start"
},
@@ -1804,6 +1813,12 @@
"biometricsNotEnabledDesc": {
"message": "Browser biometrics requires desktop biometrics to be set up in the settings first."
},
"biometricsManualSetupTitle": {
"message": "Autometic setup not available"
},
"biometricsManualSetupDesc": {
"message": "Due to the installation method, biometrics support could not be automatically enabled. Would you like to open the documentation on how to do this manually?"
},
"personalOwnershipSubmitError": {
"message": "Due to an enterprise policy, you are restricted from saving items to your individual vault. Change the ownership option to an organization and choose from available collections."
},
@@ -3028,5 +3043,11 @@
},
"data": {
"message": "Data"
},
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
}
}

View File

@@ -8,6 +8,7 @@ import { firstValueFrom } from "rxjs";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { BiometricStateService } from "@bitwarden/common/platform/biometrics/biometric-state.service";
import { processisolations } from "@bitwarden/desktop-napi";
import { WindowState } from "../platform/models/domain/window-state";
import { DesktopSettingsService } from "../platform/services/desktop-settings.service";
@@ -31,6 +32,7 @@ export class WindowMain {
private windowStateChangeTimer: NodeJS.Timeout;
private windowStates: { [key: string]: WindowState } = {};
private enableAlwaysOnTop = false;
private enableRendererProcessForceCrashReload = false;
session: Electron.Session;
readonly defaultWidth = 950;
@@ -53,9 +55,11 @@ export class WindowMain {
this.win.setBackgroundColor(await this.getBackgroundColor());
// By default some linux distro collect core dumps on crashes which gets written to disk.
const crashEvent = once(this.win.webContents, "render-process-gone");
this.win.webContents.forcefullyCrashRenderer();
await crashEvent;
if (this.enableRendererProcessForceCrashReload) {
const crashEvent = once(this.win.webContents, "render-process-gone");
this.win.webContents.forcefullyCrashRenderer();
await crashEvent;
}
this.win.webContents.reloadIgnoringCache();
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
@@ -101,6 +105,31 @@ export class WindowMain {
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", async () => {
if (isMac() || isWindows()) {
this.enableRendererProcessForceCrashReload = true;
} else if (isLinux() && !isDev()) {
if (await processisolations.isCoreDumpingDisabled()) {
this.logService.info("Coredumps are disabled in renderer process");
this.enableRendererProcessForceCrashReload = true;
} else {
this.logService.info("Disabling coredumps in main process");
try {
await processisolations.disableCoredumps();
} catch (e) {
this.logService.error("Failed to disable coredumps", e);
}
}
this.logService.info(
"Disabling external memory dumps & debugger access in main process",
);
try {
await processisolations.disableMemoryAccess();
} catch (e) {
this.logService.error("Failed to disable memory access", e);
}
}
await this.createWindow();
resolve();
if (this.argvCallback != null) {

View File

@@ -51,4 +51,14 @@ export default class BiometricDarwinMain implements OsBiometricService {
return false;
}
}
async osBiometricsNeedsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
}

View File

@@ -9,6 +9,16 @@ export default class NoopBiometricsService implements OsBiometricService {
return false;
}
async osBiometricsNeedsSetup(): Promise<boolean> {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
async getBiometricKey(
service: string,
storageKey: string,

View File

@@ -0,0 +1,160 @@
import { spawn } from "child_process";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { biometrics, passwords } from "@bitwarden/desktop-napi";
import { WindowMain } from "../../../main/window.main";
import { isFlatpak, isLinux, isSnapStore } from "../../../utils";
import { OsBiometricService } from "./biometrics.service.abstraction";
const polkitPolicy = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC
"-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
"http://www.freedesktop.org/standards/PolicyKit/1.0/policyconfig.dtd">
<policyconfig>
<action id="com.bitwarden.Bitwarden.unlock">
<description>Unlock Bitwarden</description>
<message>Authenticate to unlock Bitwarden</message>
<defaults>
<allow_any>no</allow_any>
<allow_inactive>no</allow_inactive>
<allow_active>auth_self</allow_active>
</defaults>
</action>
</policyconfig>`;
const policyFileName = "com.bitwarden.Bitwarden.policy";
const policyPath = "/usr/share/polkit-1/actions/";
export default class BiometricUnixMain implements OsBiometricService {
constructor(
private i18nservice: I18nService,
private windowMain: WindowMain,
) {}
private _iv: string | null = null;
// Use getKeyMaterial helper instead of direct access
private _osKeyHalf: string | null = null;
async setBiometricKey(
service: string,
key: string,
value: string,
clientKeyPartB64: string | undefined,
): Promise<void> {
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
await biometrics.setBiometricSecret(
service,
key,
value,
storageDetails.key_material,
storageDetails.ivB64,
);
}
async deleteBiometricKey(service: string, key: string): Promise<void> {
await passwords.deletePassword(service, key);
}
async getBiometricKey(
service: string,
storageKey: string,
clientKeyPartB64: string | undefined,
): Promise<string | null> {
const success = await this.authenticateBiometric();
if (!success) {
throw new Error("Biometric authentication failed");
}
const value = await passwords.getPassword(service, storageKey);
if (value == null || value == "") {
return null;
} else {
const encValue = new EncString(value);
this.setIv(encValue.iv);
const storageDetails = await this.getStorageDetails({ clientKeyHalfB64: clientKeyPartB64 });
const storedValue = await biometrics.getBiometricSecret(
service,
storageKey,
storageDetails.key_material,
);
return storedValue;
}
}
async authenticateBiometric(): Promise<boolean> {
const hwnd = this.windowMain.win.getNativeWindowHandle();
return await biometrics.prompt(hwnd, this.i18nservice.t("polkitConsentMessage"));
}
async osSupportsBiometric(): Promise<boolean> {
// We assume all linux distros have some polkit implementation
// that either has bitwarden set up or not, which is reflected in osBiomtricsNeedsSetup.
// Snap does not have access at the moment to polkit
// This could be dynamically detected on dbus in the future.
// We should check if a libsecret implementation is available on the system
// because otherwise we cannot offlod the protected userkey to secure storage.
return (await passwords.isAvailable()) && !isSnapStore();
}
async osBiometricsNeedsSetup(): Promise<boolean> {
// check whether the polkit policy is loaded via dbus call to polkit
return !(await biometrics.available());
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
// We cannot auto setup on snap or flatpak since the filesystem is sandboxed.
// The user needs to manually set up the polkit policy outside of the sandbox
// since we allow access to polkit via dbus for the sandboxed clients, the authentication works from
// the sandbox, once the policy is set up outside of the sandbox.
return isLinux() && !isSnapStore() && !isFlatpak();
}
async osBiometricsSetup(): Promise<void> {
const process = spawn("pkexec", [
"bash",
"-c",
`echo '${polkitPolicy}' > ${policyPath + policyFileName} && chown root:root ${policyPath + policyFileName} && chcon system_u:object_r:usr_t:s0 ${policyPath + policyFileName}`,
]);
await new Promise((resolve, reject) => {
process.on("close", (code) => {
if (code !== 0) {
reject("Failed to set up polkit policy");
} else {
resolve(null);
}
});
});
}
// Nulls out key material in order to force a re-derive. This should only be used in getBiometricKey
// when we want to force a re-derive of the key material.
private setIv(iv: string) {
this._iv = iv;
this._osKeyHalf = null;
}
private async getStorageDetails({
clientKeyHalfB64,
}: {
clientKeyHalfB64: string;
}): Promise<{ key_material: biometrics.KeyMaterial; ivB64: string }> {
if (this._osKeyHalf == null) {
const keyMaterial = await biometrics.deriveKeyMaterial(this._iv);
// osKeyHalf is based on the iv and in contrast to windows is not locked behind user verefication!
this._osKeyHalf = keyMaterial.keyB64;
this._iv = keyMaterial.ivB64;
}
return {
key_material: {
osKeyPartB64: this._osKeyHalf,
clientKeyPartB64: clientKeyHalfB64,
},
ivB64: this._iv,
};
}
}

View File

@@ -214,4 +214,14 @@ export default class BiometricWindowsMain implements OsBiometricService {
clientKeyPartB64,
};
}
async osBiometricsNeedsSetup() {
return false;
}
async osBiometricsCanAutoSetup(): Promise<boolean> {
return false;
}
async osBiometricsSetup(): Promise<void> {}
}

View File

@@ -1,5 +1,8 @@
export abstract class BiometricsServiceAbstraction {
abstract osSupportsBiometric(): Promise<boolean>;
abstract osBiometricsNeedsSetup: () => Promise<boolean>;
abstract osBiometricsCanAutoSetup: () => Promise<boolean>;
abstract osBiometricsSetup: () => Promise<void>;
abstract canAuthBiometric({
service,
key,
@@ -26,6 +29,22 @@ export abstract class BiometricsServiceAbstraction {
export interface OsBiometricService {
osSupportsBiometric(): Promise<boolean>;
/**
* Check whether support for biometric unlock requires setup. This can be automatic or manual.
*
* @returns true if biometrics support requires setup, false if it does not (is already setup, or did not require it in the first place)
*/
osBiometricsNeedsSetup: () => Promise<boolean>;
/**
* Check whether biometrics can be automatically setup, or requires user interaction.
*
* @returns true if biometrics support can be automatically setup, false if it requires user interaction.
*/
osBiometricsCanAutoSetup: () => Promise<boolean>;
/**
* Starts automatic biometric setup, which places the required configuration files / changes the required settings.
*/
osBiometricsSetup: () => Promise<void>;
authenticateBiometric(): Promise<boolean>;
getBiometricKey(
service: string,

View File

@@ -28,6 +28,8 @@ export class BiometricsService implements BiometricsServiceAbstraction {
this.loadWindowsHelloService();
} else if (platform === "darwin") {
this.loadMacOSService();
} else if (platform === "linux") {
this.loadUnixService();
} else {
this.loadNoopBiometricsService();
}
@@ -49,6 +51,12 @@ export class BiometricsService implements BiometricsServiceAbstraction {
this.platformSpecificService = new BiometricDarwinMain(this.i18nService);
}
private loadUnixService() {
// eslint-disable-next-line
const BiometricUnixMain = require("./biometric.unix.main").default;
this.platformSpecificService = new BiometricUnixMain(this.i18nService, this.windowMain);
}
private loadNoopBiometricsService() {
// eslint-disable-next-line
const NoopBiometricsService = require("./biometric.noop.main").default;
@@ -59,6 +67,18 @@ export class BiometricsService implements BiometricsServiceAbstraction {
return await this.platformSpecificService.osSupportsBiometric();
}
async osBiometricsNeedsSetup() {
return await this.platformSpecificService.osBiometricsNeedsSetup();
}
async osBiometricsCanAutoSetup() {
return await this.platformSpecificService.osBiometricsCanAutoSetup();
}
async osBiometricsSetup() {
await this.platformSpecificService.osBiometricsSetup();
}
async canAuthBiometric({
service,
key,

View File

@@ -79,6 +79,15 @@ export class DesktopCredentialStorageListener {
case BiometricAction.OsSupported:
val = await this.biometricService.osSupportsBiometric();
break;
case BiometricAction.NeedsSetup:
val = await this.biometricService.osBiometricsNeedsSetup();
break;
case BiometricAction.Setup:
await this.biometricService.osBiometricsSetup();
break;
case BiometricAction.CanAutoSetup:
val = await this.biometricService.osBiometricsCanAutoSetup();
break;
default:
}

View File

@@ -11,7 +11,7 @@ import {
UnencryptedMessageResponse,
} from "../models/native-messaging";
import { BiometricMessage, BiometricAction } from "../types/biometric-message";
import { isDev, isMacAppStore, isWindowsStore } from "../utils";
import { isDev, isFlatpak, isMacAppStore, isSnapStore, isWindowsStore } from "../utils";
import { ClipboardWriteMessage } from "./types/clipboard";
@@ -48,6 +48,18 @@ const biometric = {
ipcRenderer.invoke("biometric", {
action: BiometricAction.OsSupported,
} satisfies BiometricMessage),
biometricsNeedsSetup: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.NeedsSetup,
} satisfies BiometricMessage),
biometricsSetup: (): Promise<void> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Setup,
} satisfies BiometricMessage),
biometricsCanAutoSetup: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.CanAutoSetup,
} satisfies BiometricMessage),
authenticate: (): Promise<boolean> =>
ipcRenderer.invoke("biometric", {
action: BiometricAction.Authenticate,
@@ -115,6 +127,8 @@ export default {
isDev: isDev(),
isMacAppStore: isMacAppStore(),
isWindowsStore: isWindowsStore(),
isFlatpak: isFlatpak(),
isSnapStore: isSnapStore(),
reloadProcess: () => ipcRenderer.send("reload-process"),
log: (level: LogLevelType, message?: any, ...optionalParams: any[]) =>
ipcRenderer.invoke("ipc.log", { level, message, optionalParams }),

View File

@@ -135,6 +135,18 @@ export class ElectronPlatformUtilsService implements PlatformUtilsService {
return await ipc.platform.biometric.osSupported();
}
async biometricsNeedsSetup(): Promise<boolean> {
return await ipc.platform.biometric.biometricsNeedsSetup();
}
async biometricsSupportsAutoSetup(): Promise<boolean> {
return await ipc.platform.biometric.biometricsCanAutoSetup();
}
async biometricsSetup(): Promise<void> {
return await ipc.platform.biometric.biometricsSetup();
}
/** This method is used to authenticate the user presence _only_.
* It should not be used in the process to retrieve
* biometric keys, which has a separate authentication mechanism.

View File

@@ -2,6 +2,9 @@ export enum BiometricAction {
EnabledForUser = "enabled",
OsSupported = "osSupported",
Authenticate = "authenticate",
NeedsSetup = "needsSetup",
Setup = "setup",
CanAutoSetup = "canAutoSetup",
}
export type BiometricMessage = {

View File

@@ -62,6 +62,10 @@ export function isWindowsStore() {
return windows && windowsStore === true;
}
export function isFlatpak() {
return process.platform === "linux" && process.env.container != null;
}
export function isWindowsPortable() {
return isWindows() && process.env.PORTABLE_EXECUTABLE_DIR != null;
}

View File

@@ -1,6 +1,6 @@
{
"name": "@bitwarden/web-vault",
"version": "2024.8.0",
"version": "2024.7.3",
"scripts": {
"build:oss": "webpack",
"build:bit": "webpack -c ../../bitwarden_license/bit-web/webpack.config.js",

View File

@@ -6,59 +6,70 @@
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6 tw-mb-0">
<bit-label>{{ "defaultType" | i18n }}</bit-label>
<bit-select formControlName="defaultType" id="defaultType">
<bit-option *ngFor="let o of defaultTypes" [value]="o.value" [label]="o.name"></bit-option>
<bit-label>{{ "overridePasswordTypePolicy" | i18n }}</bit-label>
<bit-select formControlName="overridePasswordType" id="overrideType">
<bit-option
*ngFor="let o of overridePasswordTypeOptions"
[value]="o.value"
[label]="o.name"
></bit-option>
</bit-select>
</bit-form-field>
</div>
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input bitInput type="number" min="5" max="128" formControlName="minLength" />
</bit-form-field>
<!-- password-specific policies -->
<div *ngIf="showPasswordPolicies$ | async">
<h3 bitTypography="h3" class="tw-mt-4">{{ "password" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minLength" | i18n }}</bit-label>
<input bitInput type="number" min="5" max="128" formControlName="minLength" />
</bit-form-field>
</div>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minNumbers" />
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minSpecial" />
</bit-form-field>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
<bit-label>A-Z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useLower" id="useLower" />
<bit-label>a-z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useNumbers" id="useNumbers" />
<bit-label>0-9</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useSpecial" id="useSpecial" />
<bit-label>!&#64;#$%^&amp;*</bit-label>
</bit-form-control>
</div>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minNumbers" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minNumbers" />
</bit-form-field>
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minSpecial" | i18n }}</bit-label>
<input bitInput type="number" min="0" max="9" formControlName="minSpecial" />
</bit-form-field>
<!-- passphrase-specific policies -->
<div *ngIf="showPassphrasePolicies$ | async">
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
<input bitInput type="number" min="3" max="20" formControlName="minNumberWords" />
</bit-form-field>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="includeNumber" id="includeNumber" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useUpper" id="useUpper" />
<bit-label>A-Z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useLower" id="useLower" />
<bit-label>a-z</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useNumbers" id="useNumbers" />
<bit-label>0-9</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="useSpecial" id="useSpecial" />
<bit-label>!&#64;#$%^&amp;*</bit-label>
</bit-form-control>
<h3 bitTypography="h3" class="tw-mt-4">{{ "passphrase" | i18n }}</h3>
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
<bit-form-field class="tw-col-span-6">
<bit-label>{{ "minimumNumberOfWords" | i18n }}</bit-label>
<input bitInput type="number" min="3" max="20" formControlName="minNumberWords" />
</bit-form-field>
</div>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="capitalize" id="capitalize" />
<bit-label>{{ "capitalize" | i18n }}</bit-label>
</bit-form-control>
<bit-form-control>
<input type="checkbox" bitCheckbox formControlName="includeNumber" id="includeNumber" />
<bit-label>{{ "includeNumber" | i18n }}</bit-label>
</bit-form-control>
</div>

View File

@@ -1,8 +1,11 @@
import { Component } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { UntypedFormBuilder, Validators } from "@angular/forms";
import { BehaviorSubject, map } from "rxjs";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { DefaultPassphraseBoundaries, DefaultPasswordBoundaries } from "@bitwarden/generator-core";
import { BasePolicy, BasePolicyComponent } from "./base-policy.component";
@@ -19,20 +22,59 @@ export class PasswordGeneratorPolicy extends BasePolicy {
})
export class PasswordGeneratorPolicyComponent extends BasePolicyComponent {
data = this.formBuilder.group({
defaultType: [null],
minLength: [null, [Validators.min(5), Validators.max(128)]],
overridePasswordType: [null],
minLength: [
null,
[
Validators.min(DefaultPasswordBoundaries.length.min),
Validators.max(DefaultPasswordBoundaries.length.max),
],
],
useUpper: [null],
useLower: [null],
useNumbers: [null],
useSpecial: [null],
minNumbers: [null, [Validators.min(0), Validators.max(9)]],
minSpecial: [null, [Validators.min(0), Validators.max(9)]],
minNumberWords: [null, [Validators.min(3), Validators.max(20)]],
minNumbers: [
null,
[
Validators.min(DefaultPasswordBoundaries.minDigits.min),
Validators.max(DefaultPasswordBoundaries.minDigits.max),
],
],
minSpecial: [
null,
[
Validators.min(DefaultPasswordBoundaries.minSpecialCharacters.min),
Validators.max(DefaultPasswordBoundaries.minSpecialCharacters.max),
],
],
minNumberWords: [
null,
[
Validators.min(DefaultPassphraseBoundaries.numWords.min),
Validators.max(DefaultPassphraseBoundaries.numWords.max),
],
],
capitalize: [null],
includeNumber: [null],
});
defaultTypes: { name: string; value: string }[];
overridePasswordTypeOptions: { name: string; value: string }[];
// These subjects cache visibility of the sub-options for passwords
// and passphrases; without them policy controls don't show up at all.
private showPasswordPolicies = new BehaviorSubject<boolean>(true);
private showPassphrasePolicies = new BehaviorSubject<boolean>(true);
/** Emits `true` when the password policy options should be displayed */
get showPasswordPolicies$() {
return this.showPasswordPolicies.asObservable();
}
/** Emits `true` when the passphrase policy options should be displayed */
get showPassphrasePolicies$() {
return this.showPassphrasePolicies.asObservable();
}
constructor(
private formBuilder: UntypedFormBuilder,
@@ -40,10 +82,27 @@ export class PasswordGeneratorPolicyComponent extends BasePolicyComponent {
) {
super();
this.defaultTypes = [
this.overridePasswordTypeOptions = [
{ name: i18nService.t("userPreference"), value: null },
{ name: i18nService.t("password"), value: "password" },
{ name: i18nService.t("password"), value: PASSWORD_POLICY_VALUE },
{ name: i18nService.t("passphrase"), value: "passphrase" },
];
this.data.valueChanges
.pipe(isEnabled(PASSWORD_POLICY_VALUE), takeUntilDestroyed())
.subscribe(this.showPasswordPolicies);
this.data.valueChanges
.pipe(isEnabled(PASSPHRASE_POLICY_VALUE), takeUntilDestroyed())
.subscribe(this.showPassphrasePolicies);
}
}
const PASSWORD_POLICY_VALUE = "password";
const PASSPHRASE_POLICY_VALUE = "passphrase";
function isEnabled(enabledValue: string) {
return map((d: { overridePasswordType: string }) => {
const type = d?.overridePasswordType ?? enabledValue;
return type === enabledValue;
});
}

View File

@@ -323,7 +323,6 @@ export class AppComponent implements OnDestroy, OnInit {
);
await Promise.all([
this.syncService.setLastSync(new Date(0)),
this.cryptoService.clearKeys(),
this.cipherService.clear(userId),
this.folderService.clear(userId),

View File

@@ -26,11 +26,12 @@ import { EnvironmentService } from "@bitwarden/common/platform/abstractions/envi
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { PasswordStrengthServiceAbstraction } from "@bitwarden/common/tools/password-strength";
import { PasswordGenerationServiceAbstraction } from "@bitwarden/generator-legacy";
import { flagEnabled } from "../../../utils/flags";
import { RouterService, StateService } from "../../core";
import { RouterService } from "../../core";
import { AcceptOrganizationInviteService } from "../organization-invite/accept-organization.service";
import { OrganizationInvite } from "../organization-invite/organization-invite";

View File

@@ -187,6 +187,7 @@ export class OrganizationPlansComponent implements OnInit, OnDestroy {
if (this.hasProvider) {
this.formGroup.controls.businessOwned.setValue(true);
this.formGroup.controls.clientOwnerEmail.addValidators(Validators.required);
this.changedOwnedBusiness();
this.provider = await this.providerApiService.getProvider(this.providerId);
const providerDefaultPlan = this.passwordManagerPlans.find(

View File

@@ -38,7 +38,6 @@ import { FileDownloadService } from "@bitwarden/common/platform/abstractions/fil
import { I18nService as I18nServiceAbstraction } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { MemoryStorageService } from "@bitwarden/common/platform/services/memory-storage.service";
@@ -71,7 +70,6 @@ import { EventService } from "./event.service";
import { InitService } from "./init.service";
import { ModalService } from "./modal.service";
import { RouterService } from "./router.service";
import { StateService as WebStateService } from "./state";
import { WebFileDownloadService } from "./web-file-download.service";
import { WebPlatformUtilsService } from "./web-platform-utils.service";
@@ -135,11 +133,6 @@ const safeProviders: SafeProvider[] = [
useClass: ModalService,
useAngularDecorators: true,
}),
safeProvider(WebStateService),
safeProvider({
provide: StateService,
useExisting: WebStateService,
}),
safeProvider({
provide: FileDownloadService,
useClass: WebFileDownloadService,

View File

@@ -1,4 +1,3 @@
export * from "./core.module";
export * from "./event.service";
export * from "./router.service";
export * from "./state/state.service";

View File

@@ -1 +0,0 @@
export * from "./state.service";

View File

@@ -1,55 +0,0 @@
import { Inject, Injectable } from "@angular/core";
import {
MEMORY_STORAGE,
SECURE_STORAGE,
STATE_FACTORY,
} from "@bitwarden/angular/services/injection-tokens";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { TokenService } from "@bitwarden/common/auth/abstractions/token.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { AbstractStorageService } from "@bitwarden/common/platform/abstractions/storage.service";
import { StateFactory } from "@bitwarden/common/platform/factories/state-factory";
import { Account } from "@bitwarden/common/platform/models/domain/account";
import { GlobalState } from "@bitwarden/common/platform/models/domain/global-state";
import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options";
import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner";
import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service";
@Injectable()
export class StateService extends BaseStateService<GlobalState, Account> {
constructor(
storageService: AbstractStorageService,
@Inject(SECURE_STORAGE) secureStorageService: AbstractStorageService,
@Inject(MEMORY_STORAGE) memoryStorageService: AbstractStorageService,
logService: LogService,
@Inject(STATE_FACTORY) stateFactory: StateFactory<GlobalState, Account>,
accountService: AccountService,
environmentService: EnvironmentService,
tokenService: TokenService,
migrationRunner: MigrationRunner,
) {
super(
storageService,
secureStorageService,
memoryStorageService,
logService,
stateFactory,
accountService,
environmentService,
tokenService,
migrationRunner,
);
}
override async getLastSync(options?: StorageOptions): Promise<string> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.getLastSync(options);
}
override async setLastSync(value: string, options?: StorageOptions): Promise<void> {
options = this.reconcileOptions(options, await this.defaultInMemoryOptions());
return await super.setLastSync(value, options);
}
}

View File

@@ -194,6 +194,12 @@ export class WebPlatformUtilsService implements PlatformUtilsService {
return Promise.resolve(false);
}
biometricsNeedsSetup: () => Promise<boolean>;
biometricsSupportsAutoSetup(): Promise<boolean> {
throw new Error("Method not implemented.");
}
biometricsSetup: () => Promise<void>;
supportsSecureStorage() {
return false;
}

View File

@@ -4145,8 +4145,9 @@
"minimumNumberOfWords": {
"message": "Minimum number of words"
},
"defaultType": {
"message": "Default type"
"overridePasswordTypePolicy": {
"message": "Password Type",
"description": "Name of the password generator policy that overrides the user's password/passphrase selection."
},
"userPreference": {
"message": "User preference"
@@ -8794,6 +8795,12 @@
"purchasedSeatsRemoved": {
"message": "purchased seats removed"
},
"fileSends": {
"message": "File Sends"
},
"textSends": {
"message": "Text Sends"
},
"includesXMembers": {
"message": "for $COUNT$ member",
"placeholders": {

View File

@@ -2,7 +2,7 @@ import {
OrganizationAuthRequestService,
OrganizationAuthRequestApiService,
} from "@bitwarden/bit-common/admin-console/auth-requests";
import { ServiceContainer as OssServiceContainer } from "@bitwarden/cli/service-container";
import { ServiceContainer as OssServiceContainer } from "@bitwarden/cli/service-container/service-container";
/**
* Instantiates services and makes them available for dependency injection.

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