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

Merge branch 'main' into PM-19741

This commit is contained in:
Miles Blackwood
2025-04-28 11:51:45 -04:00
60 changed files with 2004 additions and 555 deletions

View File

@@ -133,12 +133,34 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Get Latest Server Version
id: latest-server-version
uses: bitwarden/gh-actions/get-release-version@main
with:
repository: bitwarden/server
trim: false
- name: Set Server Ref
id: set-server-ref
run: |
SERVER_REF="${{ steps.latest-server-version.outputs.version }}"
echo "Latest server release version: $SERVER_REF"
if [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
SERVER_REF="$GITHUB_REF"
elif [[ "$GITHUB_REF" == "refs/heads/rc" ]]; then
SERVER_REF="$GITHUB_REF"
elif [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
SERVER_REF="refs/heads/main"
fi
echo "Server ref: $SERVER_REF"
echo "server_ref=$SERVER_REF" >> $GITHUB_OUTPUT
- name: Check out Server repo
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
path: server
repository: bitwarden/server
ref: ${{ github.event.pull_request.head.sha && 'main' || github.ref }}
ref: ${{ steps.set-server-ref.outputs.server_ref }}
- name: Check Branch to Publish
env:
@@ -160,7 +182,7 @@ jobs:
VERSION=$( jq -r ".version" package.json)
jq --arg version "$VERSION+${GITHUB_SHA:0:7}" '.version = $version' package.json > package.json.tmp
mv package.json.tmp package.json
########## Set up Docker ##########
- name: Set up Docker
uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4.3.0
@@ -304,7 +326,7 @@ jobs:
- name: Log out of Docker
run: docker logout $_AZ_REGISTRY
crowdin-push:
name: Crowdin Push
if: github.event_name != 'pull_request_target' && github.ref == 'refs/heads/main'

View File

@@ -1,5 +1,6 @@
import { MockProxy, mock } from "jest-mock-extended";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
// Must mock modules before importing
@@ -26,22 +27,26 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
let dialogService: MockProxy<DialogService>;
let window: MockProxy<Window>;
let configService: MockProxy<ConfigService>;
beforeEach(() => {
jest.clearAllMocks();
dialogService = mock<DialogService>();
window = mock<Window>();
configService = mock<ConfigService>();
extensionTwoFactorAuthEmailComponentService = new ExtensionTwoFactorAuthEmailComponentService(
dialogService,
window,
configService,
);
});
describe("openPopoutIfApprovedForEmail2fa", () => {
it("should open a popout if the user confirms the warning to popout the extension when in the popup", async () => {
// Arrange
configService.getFeatureFlag.mockResolvedValue(false);
dialogService.openSimpleDialog.mockResolvedValue(true);
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
@@ -61,6 +66,7 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
it("should not open a popout if the user cancels the warning to popout the extension when in the popup", async () => {
// Arrange
configService.getFeatureFlag.mockResolvedValue(false);
dialogService.openSimpleDialog.mockResolvedValue(false);
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
@@ -80,6 +86,7 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
it("should not open a popout if not in the popup", async () => {
// Arrange
configService.getFeatureFlag.mockResolvedValue(false);
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(false);
// Act
@@ -89,5 +96,15 @@ describe("ExtensionTwoFactorAuthEmailComponentService", () => {
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled();
});
it("does not prompt or open a popout if the feature flag is enabled", async () => {
configService.getFeatureFlag.mockResolvedValue(true);
jest.spyOn(BrowserPopupUtils, "inPopup").mockReturnValue(true);
await extensionTwoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa();
expect(dialogService.openSimpleDialog).not.toHaveBeenCalled();
expect(openTwoFactorAuthEmailPopout).not.toHaveBeenCalled();
});
});
});

View File

@@ -2,6 +2,8 @@ import {
DefaultTwoFactorAuthEmailComponentService,
TwoFactorAuthEmailComponentService,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { DialogService } from "@bitwarden/components";
import { openTwoFactorAuthEmailPopout } from "../../auth/popup/utils/auth-popout-window";
@@ -15,11 +17,21 @@ export class ExtensionTwoFactorAuthEmailComponentService
constructor(
private dialogService: DialogService,
private window: Window,
private configService: ConfigService,
) {
super();
}
async openPopoutIfApprovedForEmail2fa(): Promise<void> {
const isTwoFactorFormPersistenceEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
if (isTwoFactorFormPersistenceEnabled) {
// If the feature flag is enabled, we don't need to prompt the user to open the popout
return;
}
if (BrowserPopupUtils.inPopup(this.window)) {
const confirmed = await this.dialogService.openSimpleDialog({
title: { key: "warning" },

View File

@@ -2,6 +2,7 @@ import { NeverDomains } from "@bitwarden/common/models/domain/domain-service";
import { ServerConfig } from "@bitwarden/common/platform/abstractions/config/server-config";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { CollectionView } from "../../content/components/common-types";
import { NotificationQueueMessageTypes } from "../../enums/notification-queue-message-type.enum";
import AutofillPageDetails from "../../models/autofill-page-details";
@@ -83,6 +84,7 @@ type NotificationBackgroundExtensionMessage = {
tab?: chrome.tabs.Tab;
sender?: string;
notificationType?: string;
organizationId?: string;
fadeOutNotification?: boolean;
};
@@ -94,6 +96,10 @@ type NotificationBackgroundExtensionMessageHandlers = {
[key: string]: CallableFunction;
unlockCompleted: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgGetFolderData: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<FolderView[]>;
bgGetCollectionData: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<CollectionView[]>;
bgCloseNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenAtRisksPasswords: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgAdjustNotificationBar: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
@@ -101,11 +107,14 @@ type NotificationBackgroundExtensionMessageHandlers = {
bgChangedPassword: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgRemoveTabFromNotificationQueue: ({ sender }: BackgroundSenderParam) => void;
bgSaveCipher: ({ message, sender }: BackgroundOnMessageHandlerParams) => void;
bgOpenAddEditVaultItemPopout: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenViewVaultItemPopout: ({
message,
sender,
}: BackgroundOnMessageHandlerParams) => Promise<void>;
bgOpenVault: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgNeverSave: ({ sender }: BackgroundSenderParam) => Promise<void>;
bgUnlockPopoutOpened: ({ message, sender }: BackgroundOnMessageHandlerParams) => Promise<void>;
bgReopenUnlockPopout: ({ sender }: BackgroundSenderParam) => Promise<void>;

View File

@@ -1,6 +1,7 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { DefaultPolicyService } from "@bitwarden/common/admin-console/services/policy/default-policy.service";
import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
@@ -53,6 +54,7 @@ describe("NotificationBackground", () => {
let notificationBackground: NotificationBackground;
const autofillService = mock<AutofillService>();
const cipherService = mock<CipherService>();
const collectionService = mock<CollectionService>();
let activeAccountStatusMock$: BehaviorSubject<AuthenticationStatus>;
let authService: MockProxy<AuthService>;
const policyService = mock<DefaultPolicyService>();
@@ -83,6 +85,7 @@ describe("NotificationBackground", () => {
authService,
autofillService,
cipherService,
collectionService,
configService,
domainSettingsService,
environmentService,

View File

@@ -2,6 +2,7 @@
// @ts-strict-ignore
import { firstValueFrom, switchMap, map, of } from "rxjs";
import { CollectionService } from "@bitwarden/admin-console/common";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
@@ -50,6 +51,7 @@ import {
OrganizationCategories,
NotificationCipherData,
} from "../content/components/cipher/types";
import { CollectionView } from "../content/components/common-types";
import { NotificationQueueMessageType } from "../enums/notification-queue-message-type.enum";
import { AutofillService } from "../services/abstractions/autofill.service";
@@ -92,9 +94,11 @@ export default class NotificationBackground {
bgGetEnableAddedLoginPrompt: () => this.getEnableAddedLoginPrompt(),
bgGetExcludedDomains: () => this.getExcludedDomains(),
bgGetFolderData: () => this.getFolderData(),
bgGetCollectionData: ({ message }) => this.getCollectionData(message),
bgGetOrgData: () => this.getOrgData(),
bgNeverSave: ({ sender }) => this.saveNever(sender.tab),
bgOpenVault: ({ message, sender }) => this.openVault(message, sender.tab),
bgOpenAddEditVaultItemPopout: ({ message, sender }) =>
this.openAddEditVaultItem(message, sender.tab),
bgOpenViewVaultItemPopout: ({ message, sender }) => this.viewItem(message, sender.tab),
bgRemoveTabFromNotificationQueue: ({ sender }) =>
this.removeTabFromNotificationQueue(sender.tab),
@@ -114,6 +118,7 @@ export default class NotificationBackground {
private authService: AuthService,
private autofillService: AutofillService,
private cipherService: CipherService,
private collectionService: CollectionService,
private configService: ConfigService,
private domainSettingsService: DomainSettingsService,
private environmentService: EnvironmentService,
@@ -789,17 +794,36 @@ export default class NotificationBackground {
userId,
);
await this.openAddEditVaultItemPopout(senderTab, { cipherId: cipherView.id });
await this.openAddEditVaultItemPopout(senderTab, { cipherId: cipherView?.id });
}
private async openVault(
private async openAddEditVaultItem(
message: NotificationBackgroundExtensionMessage,
senderTab: chrome.tabs.Tab,
) {
if (!message.cipherId) {
await this.openAddEditVaultItemPopout(senderTab);
const { cipherId, organizationId } = message;
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getOptionalUserId));
if (cipherId) {
await this.openAddEditVaultItemPopout(senderTab, { cipherId });
return;
}
await this.openAddEditVaultItemPopout(senderTab, { cipherId: message.cipherId });
const queueItem = this.notificationQueue.find((item) => item.tab.id === senderTab.id);
if (queueItem?.type === NotificationQueueMessageType.AddLogin) {
const cipherView = this.convertAddLoginQueueMessageToCipherView(queueItem);
cipherView.organizationId = organizationId;
if (userId) {
await this.cipherService.setAddEditCipherInfo({ cipher: cipherView }, userId);
}
await this.openAddEditVaultItemPopout(senderTab);
this.removeTabFromNotificationQueue(senderTab);
return;
}
await this.openAddEditVaultItemPopout(senderTab);
}
private async viewItem(
@@ -898,6 +922,25 @@ export default class NotificationBackground {
return await firstValueFrom(this.folderService.folderViews$(activeUserId));
}
private async getCollectionData(
message: NotificationBackgroundExtensionMessage,
): Promise<CollectionView[]> {
const collections = (await this.collectionService.getAllDecrypted()).reduce<CollectionView[]>(
(acc, collection) => {
if (collection.organizationId === message?.orgId) {
acc.push({
id: collection.id,
name: collection.name,
organizationId: collection.organizationId,
});
}
return acc;
},
[],
);
return collections;
}
private async getWebVaultUrl(): Promise<string> {
const env = await firstValueFrom(this.environmentService.environment$);
return env.getWebVaultUrl();
@@ -924,6 +967,7 @@ export default class NotificationBackground {
const organizations = await firstValueFrom(
this.organizationService.organizations$(activeUserId),
);
return organizations.map((org) => {
const { id, name, productTierType } = org;
return {
@@ -1054,6 +1098,7 @@ export default class NotificationBackground {
cipherView.folderId = folderId;
cipherView.type = CipherType.Login;
cipherView.login = loginView;
cipherView.organizationId = null;
return cipherView;
}

View File

@@ -26,3 +26,9 @@ export type OrgView = {
name: string;
productTierType?: ProductTierType;
};
export type CollectionView = {
id: string;
name: string;
organizationId: string;
};

View File

@@ -3,9 +3,12 @@ import { html } from "lit";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { Theme } from "@bitwarden/common/platform/enums";
import { Option, OrgView, FolderView } from "../common-types";
import { Business, Family, Folder, User } from "../icons";
import { Option, OrgView, FolderView, CollectionView } from "../common-types";
import { Business, Family, Folder, User, CollectionShared } from "../icons";
import { ButtonRow } from "../rows/button-row";
import { selectedCollection as selectedCollectionSignal } from "../signals/selected-collection";
import { selectedFolder as selectedFolderSignal } from "../signals/selected-folder";
import { selectedVault as selectedVaultSignal } from "../signals/selected-vault";
function getVaultIconByProductTier(productTierType?: ProductTierType): Option["icon"] {
switch (productTierType) {
@@ -29,11 +32,13 @@ export type NotificationButtonRowProps = {
text: string;
handlePrimaryButtonClick: (args: any) => void;
};
collections?: CollectionView[];
theme: Theme;
};
export function NotificationButtonRow({
folders,
collections,
i18n,
organizations,
primaryButton,
@@ -77,6 +82,21 @@ export function NotificationButtonRow({
)
: [];
const collectionOptions: Option[] = collections?.length
? collections.reduce<Option[]>(
(options, { id, name }: any) => [
...options,
{
icon: CollectionShared,
text: name,
value: id === null ? "0" : id,
default: id === null,
},
],
[],
)
: [];
return html`
${ButtonRow({
theme,
@@ -88,15 +108,27 @@ export function NotificationButtonRow({
id: "organization",
label: i18n.vault,
options: organizationOptions,
selectedSignal: selectedVaultSignal,
},
]
: []),
...(folderOptions.length > 1
...(folderOptions.length > 1 && !collectionOptions.length
? [
{
id: "folder",
label: i18n.folder,
options: folderOptions,
selectedSignal: selectedFolderSignal,
},
]
: []),
...(collectionOptions.length > 1
? [
{
id: "collection",
label: "Collection", // @TODO localize
options: collectionOptions,
selectedSignal: selectedCollectionSignal,
},
]
: []),

View File

@@ -9,7 +9,7 @@ import {
NotificationType,
} from "../../../notification/abstractions/notification-bar";
import { NotificationCipherData } from "../cipher/types";
import { FolderView, OrgView } from "../common-types";
import { CollectionView, FolderView, OrgView } from "../common-types";
import { themes, spacing } from "../constants/styles";
import { NotificationBody, componentClassPrefix as notificationBodyClassPrefix } from "./body";
@@ -25,6 +25,7 @@ export type NotificationContainerProps = NotificationBarIframeInitData & {
handleEditOrUpdateAction: (e: Event) => void;
} & {
ciphers?: NotificationCipherData[];
collections?: CollectionView[];
folders?: FolderView[];
i18n: { [key: string]: string };
organizations?: OrgView[];
@@ -36,6 +37,7 @@ export function NotificationContainer({
handleEditOrUpdateAction,
handleSaveAction,
ciphers,
collections,
folders,
i18n,
organizations,
@@ -64,6 +66,7 @@ export function NotificationContainer({
: null}
${NotificationFooter({
handleSaveAction,
collections,
folders,
i18n,
notificationType: type,

View File

@@ -7,12 +7,13 @@ import {
NotificationType,
NotificationTypes,
} from "../../../notification/abstractions/notification-bar";
import { OrgView, FolderView } from "../common-types";
import { OrgView, FolderView, CollectionView } from "../common-types";
import { spacing, themes } from "../constants/styles";
import { NotificationButtonRow } from "./button-row";
export type NotificationFooterProps = {
collections?: CollectionView[];
folders?: FolderView[];
i18n: { [key: string]: string };
notificationType?: NotificationType;
@@ -22,6 +23,7 @@ export type NotificationFooterProps = {
};
export function NotificationFooter({
collections,
folders,
i18n,
notificationType,
@@ -36,6 +38,7 @@ export function NotificationFooter({
<div class=${notificationFooterStyles({ theme })}>
${!isChangeNotification
? NotificationButtonRow({
collections,
folders,
organizations,
i18n,

View File

@@ -32,6 +32,9 @@ export class OptionSelection extends LitElement {
@property({ type: (selectedOption: Option["value"]) => selectedOption })
handleSelectionUpdate?: (args: any) => void;
@property({ attribute: false })
selectedSignal?: { set: (value: any) => void };
@state()
private showMenu = false;
@@ -77,7 +80,7 @@ export class OptionSelection extends LitElement {
private handleOptionSelection = (selectedOption: Option) => {
this.showMenu = false;
this.selection = selectedOption;
this.selectedSignal?.set(selectedOption.value);
// Any side-effects that should occur from the selection
this.handleSelectionUpdate?.(selectedOption.value);
};

View File

@@ -19,6 +19,7 @@ export type ButtonRowProps = {
label?: string;
options: Option[];
handleSelectionUpdate?: (args: any) => void;
selectedSignal?: { set: (value: any) => void };
}[];
};
@@ -32,7 +33,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
})}
<div class=${optionSelectionsStyles}>
${selectButtons?.map(
({ id, label, options, handleSelectionUpdate }) =>
({ id, label, options, handleSelectionUpdate, selectedSignal }) =>
html`
<option-selection
key=${id}
@@ -40,6 +41,7 @@ export function ButtonRow({ theme, primaryButton, selectButtons }: ButtonRowProp
.label=${label}
.options=${options}
.handleSelectionUpdate=${handleSelectionUpdate}
.selectedSignal=${selectedSignal}
></option-selection>
` || nothing,
)}

View File

@@ -0,0 +1,3 @@
import { signal } from "@lit-labs/signals";
export const selectedCollection = signal<string>("0");

View File

@@ -0,0 +1,3 @@
import { signal } from "@lit-labs/signals";
export const selectedFolder = signal<string>("0");

View File

@@ -0,0 +1,3 @@
import { signal } from "@lit-labs/signals";
export const selectedVault = signal<string>("0");

View File

@@ -1,7 +1,11 @@
import { Theme } from "@bitwarden/common/platform/enums";
import { NotificationCipherData } from "../../../autofill/content/components/cipher/types";
import { FolderView, OrgView } from "../../../autofill/content/components/common-types";
import {
FolderView,
OrgView,
CollectionView,
} from "../../../autofill/content/components/common-types";
const NotificationTypes = {
Add: "add",
@@ -20,6 +24,7 @@ type NotificationTaskInfo = {
type NotificationBarIframeInitData = {
ciphers?: NotificationCipherData[];
folders?: FolderView[];
collections?: CollectionView[];
importType?: string;
isVaultLocked?: boolean;
launchTimestamp?: number;

View File

@@ -6,9 +6,11 @@ import type { FolderView } from "@bitwarden/common/vault/models/view/folder.view
import { AdjustNotificationBarMessageData } from "../background/abstractions/notification.background";
import { NotificationCipherData } from "../content/components/cipher/types";
import { OrgView } from "../content/components/common-types";
import { CollectionView, OrgView } from "../content/components/common-types";
import { NotificationConfirmationContainer } from "../content/components/notification/confirmation/container";
import { NotificationContainer } from "../content/components/notification/container";
import { selectedFolder as selectedFolderSignal } from "../content/components/signals/selected-folder";
import { selectedVault as selectedVaultSignal } from "../content/components/signals/selected-vault";
import { buildSvgDomElement } from "../utils";
import { circleCheckIcon } from "../utils/svg-icons";
@@ -41,6 +43,7 @@ function load() {
applyNotificationBarStyle();
});
}
function applyNotificationBarStyle() {
if (!useComponentBar) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -140,7 +143,7 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
document.body.innerHTML = "";
// Current implementations utilize a require for scss files which creates the need to remove the node.
document.head.querySelectorAll('link[rel="stylesheet"]').forEach((node) => node.remove());
const orgId = selectedVaultSignal.get();
await Promise.all([
new Promise<OrgView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetOrgData" }, resolve),
@@ -151,13 +154,18 @@ async function initNotificationBar(message: NotificationBarWindowMessage) {
new Promise<NotificationCipherData[]>((resolve) =>
sendPlatformMessage({ command: "bgGetDecryptedCiphers" }, resolve),
),
]).then(([organizations, folders, ciphers]) => {
new Promise<CollectionView[]>((resolve) =>
sendPlatformMessage({ command: "bgGetCollectionData", orgId }, resolve),
),
]).then(([organizations, folders, ciphers, collections]) => {
notificationBarIframeInitData = {
...notificationBarIframeInitData,
organizations,
folders,
ciphers,
organizations,
collections,
};
// @TODO use context to avoid prop drilling
return render(
NotificationContainer({
@@ -254,9 +262,18 @@ function handleCloseNotification(e: Event) {
}
function handleSaveAction(e: Event) {
const selectedVault = selectedVaultSignal.get();
if (selectedVault.length > 1) {
openAddEditVaultItemPopout(e, { organizationId: selectedVault });
handleCloseNotification(e);
return;
}
e.preventDefault();
sendSaveCipherMessage(removeIndividualVault());
const selectedFolder = selectedFolderSignal.get();
sendSaveCipherMessage(removeIndividualVault(), selectedFolder);
if (removeIndividualVault()) {
return;
}
@@ -351,6 +368,17 @@ function handleSaveCipherAttemptCompletedMessage(message: NotificationBarWindowM
);
}
function openAddEditVaultItemPopout(
e: Event,
options: { cipherId?: string; organizationId?: string },
) {
e.preventDefault();
sendPlatformMessage({
command: "bgOpenAddEditVaultItemPopout",
...options,
});
}
function openViewVaultItemPopout(cipherId: string) {
sendPlatformMessage({
command: "bgOpenViewVaultItemPopout",

View File

@@ -1206,6 +1206,7 @@ export default class MainBackground {
this.authService,
this.autofillService,
this.cipherService,
this.collectionService,
this.configService,
this.domainSettingsService,
this.environmentService,

View File

@@ -557,7 +557,7 @@ const safeProviders: SafeProvider[] = [
safeProvider({
provide: TwoFactorAuthEmailComponentService,
useClass: ExtensionTwoFactorAuthEmailComponentService,
deps: [DialogService, WINDOW],
deps: [DialogService, WINDOW, ConfigService],
}),
safeProvider({
provide: TwoFactorAuthWebAuthnComponentService,

View File

@@ -57,7 +57,7 @@ export class ConfirmCommand {
}
const publicKeyResponse = await this.apiService.getUserPublicKey(orgUser.userId);
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
const key = await this.encryptService.rsaEncrypt(orgKey.key, publicKey);
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
const req = new OrganizationUserConfirmRequest();
req.key = key.encryptedString;
await this.organizationUserApiService.postOrganizationUserConfirm(

View File

@@ -2110,18 +2110,18 @@ dependencies = [
[[package]]
name = "pin-project"
version = "1.1.8"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e2ec53ad785f4d35dac0adea7f7dc6f1bb277ad84a680c7afefeae05d1f5916"
checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
dependencies = [
"pin-project-internal",
]
[[package]]
name = "pin-project-internal"
version = "1.1.8"
version = "1.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d56a66c0c55993aa927429d0f8a0abfd74f084e4d9c192cffed01e418d83eefb"
checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
dependencies = [
"proc-macro2",
"quote",
@@ -3022,9 +3022,9 @@ dependencies = [
[[package]]
name = "typenum"
version = "1.17.0"
version = "1.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"
[[package]]
name = "uds_windows"

View File

@@ -35,7 +35,7 @@ napi-build = "=2.1.4"
napi-derive = "=2.16.13"
oo7 = "=0.3.3"
oslog = "=0.2.0"
pin-project = "=1.1.8"
pin-project = "=1.1.10"
pkcs8 = "=0.10.2"
rand = "=0.8.5"
rsa = "=0.9.8"
@@ -54,7 +54,7 @@ thiserror = "=1.0.69"
tokio = "=1.43.1"
tokio-stream = "=0.1.15"
tokio-util = "=0.7.13"
typenum = "=1.17.0"
typenum = "=1.18.0"
uniffi = "=0.28.3"
widestring = "=1.1.0"
windows = "=0.61.1"

View File

@@ -3,6 +3,21 @@ const child_process = require("child_process");
const fs = require("fs");
const path = require("path");
const process = require("process");
// Map of the Node arch equivalents for the rust target triplets, used to move the file to the correct location
const rustTargetsMap = {
"i686-pc-windows-msvc": { nodeArch: 'ia32', platform: 'win32' },
"x86_64-pc-windows-msvc": { nodeArch: 'x64', platform: 'win32' },
"aarch64-pc-windows-msvc": { nodeArch: 'arm64', platform: 'win32' },
"x86_64-apple-darwin": { nodeArch: 'x64', platform: 'darwin' },
"aarch64-apple-darwin": { nodeArch: 'arm64', platform: 'darwin' },
'x86_64-unknown-linux-musl': { nodeArch: 'x64', platform: 'linux' },
'aarch64-unknown-linux-musl': { nodeArch: 'arm64', platform: 'linux' },
}
// Ensure the dist directory exists
fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true });
const args = process.argv.slice(2); // Get arguments passed to the script
const mode = args.includes("--release") ? "release" : "debug";
const targetArg = args.find(arg => arg.startsWith("--target="));
@@ -13,13 +28,21 @@ let crossPlatform = process.argv.length > 2 && process.argv[2] === "cross-platfo
function buildNapiModule(target, release = true) {
const targetArg = target ? `--target ${target}` : "";
const releaseArg = release ? "--release" : "";
return child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") });
child_process.execSync(`npm run build -- ${releaseArg} ${targetArg}`, { stdio: 'inherit', cwd: path.join(__dirname, "napi") });
}
function buildProxyBin(target, release = true) {
const targetArg = target ? `--target ${target}` : "";
const releaseArg = release ? "--release" : "";
return child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
child_process.execSync(`cargo build --bin desktop_proxy ${releaseArg} ${targetArg}`, {stdio: 'inherit', cwd: path.join(__dirname, "proxy")});
if (target) {
// Copy the resulting binary to the dist folder
const targetFolder = release ? "release" : "debug";
const ext = process.platform === "win32" ? ".exe" : "";
const nodeArch = rustTargetsMap[target].nodeArch;
fs.copyFileSync(path.join(__dirname, "target", target, targetFolder, `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`));
}
}
if (!crossPlatform && !target) {
@@ -36,45 +59,17 @@ if (target) {
return;
}
// Note that targets contains pairs of [rust target, node arch]
// We do this to move the output binaries to a location that can
// be easily accessed from electron-builder using ${os} and ${arch}
let targets = [];
switch (process.platform) {
case "win32":
targets = [
["i686-pc-windows-msvc", 'ia32'],
["x86_64-pc-windows-msvc", 'x64'],
["aarch64-pc-windows-msvc", 'arm64']
];
break;
// Filter the targets based on the current platform, and build for each of them
let platformTargets = Object.entries(rustTargetsMap).filter(([_, { platform: p }]) => p === process.platform);
console.log("Cross building native modules for the targets: ", platformTargets.map(([target, _]) => target).join(", "));
case "darwin":
targets = [
["x86_64-apple-darwin", 'x64'],
["aarch64-apple-darwin", 'arm64']
];
break;
default:
targets = [
['x86_64-unknown-linux-musl', 'x64'],
['aarch64-unknown-linux-musl', 'arm64']
];
process.env["PKG_CONFIG_ALLOW_CROSS"] = "1";
process.env["PKG_CONFIG_ALL_STATIC"] = "1";
break;
// When building for Linux, we need to set some environment variables to allow cross-compilation
if (process.platform === "linux") {
process.env["PKG_CONFIG_ALLOW_CROSS"] = "1";
process.env["PKG_CONFIG_ALL_STATIC"] = "1";
}
console.log("Cross building native modules for the targets: ", targets.map(([target, _]) => target).join(", "));
fs.mkdirSync(path.join(__dirname, "dist"), { recursive: true });
targets.forEach(([target, nodeArch]) => {
platformTargets.forEach(([target, _]) => {
buildNapiModule(target);
buildProxyBin(target);
const ext = process.platform === "win32" ? ".exe" : "";
fs.copyFileSync(path.join(__dirname, "target", target, "release", `desktop_proxy${ext}`), path.join(__dirname, "dist", `desktop_proxy.${process.platform}-${nodeArch}${ext}`));
});

View File

@@ -9,58 +9,14 @@ use windows::Win32::System::Com::*;
use windows::Win32::System::LibraryLoader::*;
use windows_core::*;
mod pluginauthenticator;
mod webauthn;
const AUTHENTICATOR_NAME: &str = "Bitwarden Desktop Authenticator";
//const AAGUID: &str = "d548826e-79b4-db40-a3d8-11116f7e8349";
const CLSID: &str = "0f7dc5d9-69ce-4652-8572-6877fd695062";
const RPID: &str = "bitwarden.com";
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST {
pub transactionId: GUID,
pub cbRequestSignature: Dword,
pub pbRequestSignature: *mut byte,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST {
pub hWnd: HWND,
pub transactionId: GUID,
pub cbRequestSignature: Dword,
pub pbRequestSignature: *mut byte,
pub cbEncodedRequest: Dword,
pub pbEncodedRequest: *mut byte,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE {
pub cbOpSignPubKey: Dword,
pub pbOpSignPubKey: PByte,
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE {
pub cbEncodedResponse: Dword,
pub pbEncodedResponse: *mut byte,
}
type Dword = u32;
type Byte = u8;
type byte = u8;
pub type PByte = *mut Byte;
type EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST =
*const EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST;
pub type EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST =
*const EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST;
pub type EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE =
*mut EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE;
pub type EXPERIMENTAL_PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE =
*mut EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE;
/// Handles initialization and registration for the Bitwarden desktop app as a
/// plugin authenticator with Windows.
/// For now, also adds the authenticator
@@ -109,7 +65,8 @@ fn initialize_com_library() -> std::result::Result<(), String> {
/// Registers the Bitwarden Plugin Authenticator COM library with Windows.
fn register_com_library() -> std::result::Result<(), String> {
static FACTORY: windows_core::StaticComObject<Factory> = Factory().into_static();
static FACTORY: windows_core::StaticComObject<pluginauthenticator::Factory> =
pluginauthenticator::Factory().into_static();
let clsid: *const GUID = &GUID::from_u128(0xa98925d161f640de9327dc418fcb2ff4);
match unsafe {
@@ -146,25 +103,25 @@ fn add_authenticator() -> std::result::Result<(), String> {
let cbor_authenticator_info = "A60182684649444F5F325F30684649444F5F325F310282637072666B686D61632D7365637265740350D548826E79B4DB40A3D811116F7E834904A362726BF5627570F5627576F5098168696E7465726E616C0A81A263616C672664747970656A7075626C69632D6B6579";
let mut authenticator_info_bytes = hex::decode(cbor_authenticator_info).unwrap();
let add_authenticator_options = EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS {
pwszAuthenticatorName: authenticator_name_ptr,
pwszPluginClsId: clsid_ptr,
pwszPluginRpId: relying_party_id_ptr,
pwszLightThemeLogo: ptr::null(), // unused by Windows
pwszDarkThemeLogo: ptr::null(), // unused by Windows
cbAuthenticatorInfo: authenticator_info_bytes.len() as u32,
pbAuthenticatorInfo: authenticator_info_bytes.as_mut_ptr(),
let add_authenticator_options = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions {
authenticator_name: authenticator_name_ptr,
com_clsid: clsid_ptr,
rpid: relying_party_id_ptr,
light_theme_logo: ptr::null(), // unused by Windows
dark_theme_logo: ptr::null(), // unused by Windows
cbor_authenticator_info_byte_count: authenticator_info_bytes.len() as u32,
cbor_authenticator_info: authenticator_info_bytes.as_mut_ptr(),
};
let plugin_signing_public_key_byte_count: Dword = 0;
let plugin_signing_public_key_byte_count: u32 = 0;
let mut plugin_signing_public_key: c_uchar = 0;
let plugin_signing_public_key_ptr: PByte = &mut plugin_signing_public_key;
let plugin_signing_public_key_ptr = &mut plugin_signing_public_key;
let mut add_response = EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE {
cbOpSignPubKey: plugin_signing_public_key_byte_count,
pbOpSignPubKey: plugin_signing_public_key_ptr,
let mut add_response = webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse {
plugin_operation_signing_key_byte_count: plugin_signing_public_key_byte_count,
plugin_operation_signing_key: plugin_signing_public_key_ptr,
};
let mut add_response_ptr: *mut EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE =
let mut add_response_ptr: *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse =
&mut add_response;
let result = unsafe {
@@ -193,23 +150,10 @@ fn add_authenticator() -> std::result::Result<(), String> {
}
}
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS {
pub pwszAuthenticatorName: *const u16,
pub pwszPluginClsId: *const u16,
pub pwszPluginRpId: *const u16,
pub pwszLightThemeLogo: *const u16,
pub pwszDarkThemeLogo: *const u16,
pub cbAuthenticatorInfo: u32,
pub pbAuthenticatorInfo: *const u8,
}
type EXPERIMENTAL_WebAuthNPluginAddAuthenticatorFnDeclaration = unsafe extern "cdecl" fn(
pPluginAddAuthenticatorOptions: *const EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS,
ppPluginAddAuthenticatorResponse: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE,
)
-> HRESULT;
pPluginAddAuthenticatorOptions: *const webauthn::ExperimentalWebAuthnPluginAddAuthenticatorOptions,
ppPluginAddAuthenticatorResponse: *mut *mut webauthn::ExperimentalWebAuthnPluginAddAuthenticatorResponse,
) -> HRESULT;
unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
let library = LoadLibraryExA(library, None, LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
@@ -228,70 +172,3 @@ unsafe fn delay_load<T>(library: PCSTR, function: PCSTR) -> Option<T> {
None
}
#[interface("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")]
unsafe trait EXPERIMENTAL_IPluginAuthenticator: IUnknown {
fn EXPERIMENTAL_PluginMakeCredential(
&self,
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
) -> HRESULT;
fn EXPERIMENTAL_PluginGetAssertion(
&self,
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
) -> HRESULT;
fn EXPERIMENTAL_PluginCancelOperation(
&self,
request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST,
) -> HRESULT;
}
#[implement(EXPERIMENTAL_IPluginAuthenticator)]
struct PACOMObject;
impl EXPERIMENTAL_IPluginAuthenticator_Impl for PACOMObject_Impl {
unsafe fn EXPERIMENTAL_PluginMakeCredential(
&self,
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
_response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
) -> HRESULT {
HRESULT(0)
}
unsafe fn EXPERIMENTAL_PluginGetAssertion(
&self,
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_OPERATION_REQUEST,
_response: *mut EXPERIMENTAL_PWEBAUTHN_PLUGIN_OPERATION_RESPONSE,
) -> HRESULT {
HRESULT(0)
}
unsafe fn EXPERIMENTAL_PluginCancelOperation(
&self,
_request: EXPERIMENTAL_PCWEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST,
) -> HRESULT {
HRESULT(0)
}
}
#[implement(IClassFactory)]
struct Factory();
impl IClassFactory_Impl for Factory_Impl {
fn CreateInstance(
&self,
outer: Ref<IUnknown>,
iid: *const GUID,
object: *mut *mut core::ffi::c_void,
) -> Result<()> {
assert!(outer.is_null());
let unknown: IInspectable = PACOMObject.into();
unsafe { unknown.query(iid, object).ok() }
}
fn LockServer(&self, lock: BOOL) -> Result<()> {
assert!(lock.as_bool());
Ok(())
}
}

View File

@@ -0,0 +1,110 @@
/*
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/pluginauthenticator.h
*/
use windows::Win32::System::Com::*;
use windows_core::*;
/// Used when creating and asserting credentials.
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_REQUEST
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
/// EXPERIMENTAL_PluginGetAssertion()
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct ExperimentalWebAuthnPluginOperationRequest {
pub window_handle: windows::Win32::Foundation::HWND,
pub transaction_id: windows_core::GUID,
pub request_signature_byte_count: u32,
pub request_signature_pointer: *mut u8,
pub encoded_request_byte_count: u32,
pub encoded_request_pointer: *mut u8,
}
/// Used as a response when creating and asserting credentials.
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_OPERATION_RESPONSE
/// Header File Usage: EXPERIMENTAL_PluginMakeCredential()
/// EXPERIMENTAL_PluginGetAssertion()
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct ExperimentalWebAuthnPluginOperationResponse {
pub encoded_response_byte_count: u32,
pub encoded_response_pointer: *mut u8,
}
/// Used to cancel an operation.
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_CANCEL_OPERATION_REQUEST
/// Header File Usage: EXPERIMENTAL_PluginCancelOperation()
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct ExperimentalWebAuthnPluginCancelOperationRequest {
pub transaction_id: windows_core::GUID,
pub request_signature_byte_count: u32,
pub request_signature_pointer: *mut u8,
}
#[interface("e6466e9a-b2f3-47c5-b88d-89bc14a8d998")]
pub unsafe trait EXPERIMENTAL_IPluginAuthenticator: IUnknown {
fn EXPERIMENTAL_PluginMakeCredential(
&self,
request: *const ExperimentalWebAuthnPluginOperationRequest,
response: *mut ExperimentalWebAuthnPluginOperationResponse,
) -> HRESULT;
fn EXPERIMENTAL_PluginGetAssertion(
&self,
request: *const ExperimentalWebAuthnPluginOperationRequest,
response: *mut ExperimentalWebAuthnPluginOperationResponse,
) -> HRESULT;
fn EXPERIMENTAL_PluginCancelOperation(
&self,
request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
) -> HRESULT;
}
#[implement(EXPERIMENTAL_IPluginAuthenticator)]
pub struct PluginAuthenticatorComObject;
#[implement(IClassFactory)]
pub struct Factory();
impl EXPERIMENTAL_IPluginAuthenticator_Impl for PluginAuthenticatorComObject_Impl {
unsafe fn EXPERIMENTAL_PluginMakeCredential(
&self,
_request: *const ExperimentalWebAuthnPluginOperationRequest,
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
) -> HRESULT {
HRESULT(0)
}
unsafe fn EXPERIMENTAL_PluginGetAssertion(
&self,
_request: *const ExperimentalWebAuthnPluginOperationRequest,
_response: *mut ExperimentalWebAuthnPluginOperationResponse,
) -> HRESULT {
HRESULT(0)
}
unsafe fn EXPERIMENTAL_PluginCancelOperation(
&self,
_request: *const ExperimentalWebAuthnPluginCancelOperationRequest,
) -> HRESULT {
HRESULT(0)
}
}
impl IClassFactory_Impl for Factory_Impl {
fn CreateInstance(
&self,
outer: Ref<IUnknown>,
iid: *const GUID,
object: *mut *mut core::ffi::c_void,
) -> Result<()> {
assert!(outer.is_null());
let unknown: IInspectable = PluginAuthenticatorComObject.into();
unsafe { unknown.query(iid, object).ok() }
}
fn LockServer(&self, lock: BOOL) -> Result<()> {
assert!(lock.as_bool());
Ok(())
}
}

View File

@@ -0,0 +1,29 @@
/*
This file exposes the functions and types defined here: https://github.com/microsoft/webauthn/blob/master/experimental/webauthn.h
*/
/// Used when adding a Windows plugin authenticator.
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_OPTIONS
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct ExperimentalWebAuthnPluginAddAuthenticatorOptions {
pub authenticator_name: *const u16,
pub com_clsid: *const u16,
pub rpid: *const u16,
pub light_theme_logo: *const u16,
pub dark_theme_logo: *const u16,
pub cbor_authenticator_info_byte_count: u32,
pub cbor_authenticator_info: *const u8,
}
/// Used as a response type when adding a Windows plugin authenticator.
/// Header File Name: _EXPERIMENTAL_WEBAUTHN_PLUGIN_ADD_AUTHENTICATOR_RESPONSE
/// Header File Usage: EXPERIMENTAL_WebAuthNPluginAddAuthenticator()
/// EXPERIMENTAL_WebAuthNPluginFreeAddAuthenticatorResponse()
#[repr(C)]
#[derive(Debug, Copy, Clone)]
pub struct ExperimentalWebAuthnPluginAddAuthenticatorResponse {
pub plugin_operation_signing_key_byte_count: u32,
pub plugin_operation_signing_key: *mut u8,
}

View File

@@ -15,18 +15,16 @@
</head>
<body class="layout_frontend">
<div class="mt-5 d-flex justify-content-center">
<div>
<img src="../images/logo-dark@2x.png" class="mb-4 logo" alt="Bitwarden" />
<div id="content">
<p class="text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x text-muted"
title="Loading"
aria-hidden="true"
></i>
</p>
</div>
<div class="tw-p-8 tw-flex tw-flex-col tw-items-center">
<img class="new-logo-themed tw-mb-4" alt="Bitwarden" />
<div id="content">
<p class="tw-text-center">
<i
class="bwi bwi-spinner bwi-spin bwi-2x tw-text-muted"
title="Loading"
aria-hidden="true"
></i>
</p>
</div>
</div>
</body>

View File

@@ -1 +0,0 @@
@import "../scss/styles.scss";

View File

@@ -0,0 +1,119 @@
import { initiateWebAppSso, initiateBrowserSso } from "./sso";
describe("sso", () => {
let originalLocation: Location;
let originalPostMessage: any;
let postMessageSpy: jest.SpyInstance;
beforeEach(() => {
// Save original window methods
originalLocation = window.location;
originalPostMessage = window.postMessage;
// Mock location
Object.defineProperty(window, "location", {
value: {
href: "",
origin: "https://test.bitwarden.com",
},
writable: true,
});
// Mock postMessage
postMessageSpy = jest.spyOn(window, "postMessage");
// Set up document
document.cookie = "ssoHandOffMessage=SSO login successful;SameSite=strict";
const contentElement = document.createElement("div");
contentElement.id = "content";
document.body.appendChild(contentElement);
});
afterEach(() => {
// Restore original window methods
Object.defineProperty(window, "location", { value: originalLocation });
window.postMessage = originalPostMessage;
// Clean up document
const contentElement = document.getElementById("content");
if (contentElement) {
document.body.removeChild(contentElement);
}
document.cookie = "ssoHandOffMessage=;SameSite=strict;max-age=0";
// Clear mocks
jest.clearAllMocks();
});
describe("initiateWebAppSso", () => {
it("redirects to the SSO component with code and state", () => {
const code = "testcode";
const state = "teststate";
initiateWebAppSso(code, state);
expect(window.location.href).toBe(
"https://test.bitwarden.com/#/sso?code=testcode&state=teststate",
);
});
it("redirects to the return URI when included in state", () => {
const code = "testcode";
const state = "teststate_returnUri='/organizations'";
initiateWebAppSso(code, state);
expect(window.location.href).toBe("https://test.bitwarden.com/#/organizations");
});
it("handles empty code parameter", () => {
initiateWebAppSso("", "teststate");
expect(window.location.href).toBe("https://test.bitwarden.com/#/sso?code=&state=teststate");
});
it("handles empty state parameter", () => {
initiateWebAppSso("testcode", "");
expect(window.location.href).toBe("https://test.bitwarden.com/#/sso?code=testcode&state=");
});
});
describe("initiateBrowserSso", () => {
it("posts message with code and state", () => {
const code = "testcode";
const state = "teststate";
const lastpass = false;
initiateBrowserSso(code, state, lastpass);
expect(postMessageSpy).toHaveBeenCalledWith(
{ command: "authResult", code, state, lastpass },
window.location.origin,
);
});
it("updates content with message from cookie", () => {
const code = "testcode";
const state = "teststate";
const lastpass = false;
initiateBrowserSso(code, state, lastpass);
const contentElement = document.getElementById("content");
const paragraphElement = contentElement?.querySelector("p");
expect(paragraphElement?.innerText).toBe("SSO login successful");
});
it("handles lastpass flag correctly", () => {
const code = "testcode";
const state = "teststate";
const lastpass = true;
initiateBrowserSso(code, state, lastpass);
expect(postMessageSpy).toHaveBeenCalledWith(
{ command: "authResult", code, state, lastpass },
window.location.origin,
);
});
});
});

View File

@@ -2,10 +2,6 @@
// @ts-strict-ignore
import { getQsParam } from "./common";
// FIXME: Remove when updating file. Eslint update
// eslint-disable-next-line @typescript-eslint/no-require-imports
require("./sso.scss");
window.addEventListener("load", () => {
const code = getQsParam("code");
const state = getQsParam("state");
@@ -20,7 +16,7 @@ window.addEventListener("load", () => {
}
});
function initiateWebAppSso(code: string, state: string) {
export function initiateWebAppSso(code: string, state: string) {
// If we've initiated SSO from somewhere other than the SSO component on the web app, the SSO component will add
// a _returnUri to the state variable. Here we're extracting that URI and sending the user there instead of to the SSO component.
const returnUri = extractFromRegex(state, "(?<=_returnUri=')(.*)(?=')");
@@ -31,7 +27,7 @@ function initiateWebAppSso(code: string, state: string) {
}
}
function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
export function initiateBrowserSso(code: string, state: string, lastpass: boolean) {
window.postMessage({ command: "authResult", code, state, lastpass }, window.location.origin);
const handOffMessage = ("; " + document.cookie)
.split("; ssoHandOffMessage=")

View File

@@ -122,7 +122,7 @@ const plugins = [
new HtmlWebpackPlugin({
template: "./src/connectors/sso.html",
filename: "sso-connector.html",
chunks: ["connectors/sso"],
chunks: ["connectors/sso", "styles"],
}),
new HtmlWebpackPlugin({
template: "./src/connectors/redirect.html",

View File

@@ -84,10 +84,8 @@ export class SetupBusinessUnitComponent extends BaseAcceptComponent {
const organizationKey = await firstValueFrom(organizationKey$);
const { encryptedString: encryptedOrganizationKey } = await this.encryptService.encrypt(
organizationKey.key,
providerKey,
);
const { encryptedString: encryptedOrganizationKey } =
await this.encryptService.wrapSymmetricKey(organizationKey, providerKey);
if (!encryptedProviderKey || !encryptedOrganizationKey) {
return await fail();

View File

@@ -1,6 +1,13 @@
<ng-container>
<bit-form-field>
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
<input
bitInput
type="text"
appAutofocus
appInputVerbatim
[formControl]="tokenFormControl"
(keyup)="onTokenChange($event)"
/>
</bit-form-field>
</ng-container>

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input } from "@angular/core";
import { Component, Input, Output, EventEmitter } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -32,4 +32,10 @@ import {
})
export class TwoFactorAuthAuthenticatorComponent {
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
@Output() tokenChange = new EventEmitter<{ token: string }>();
onTokenChange(event: Event) {
const tokenValue = (event.target as HTMLInputElement).value || "";
this.tokenChange.emit({ token: tokenValue });
}
}

View File

@@ -0,0 +1,165 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
TwoFactorAuthEmailComponentCache,
TwoFactorAuthEmailComponentCacheService,
} from "./two-factor-auth-email-component-cache.service";
describe("TwoFactorAuthEmailCache", () => {
describe("fromJSON", () => {
it("returns null when input is null", () => {
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
expect(result).toBeNull();
});
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
const jsonData = { emailSent: true };
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
expect(result?.emailSent).toBe(true);
});
});
});
describe("TwoFactorAuthEmailComponentCacheService", () => {
let service: TwoFactorAuthEmailComponentCacheService;
let mockViewCacheService: MockProxy<ViewCacheService>;
let mockConfigService: MockProxy<ConfigService>;
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
let mockSignal: any;
beforeEach(() => {
mockViewCacheService = mock<ViewCacheService>();
mockConfigService = mock<ConfigService>();
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
mockSignal = jest.fn(() => cacheData.getValue());
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
cacheData.next(value),
);
mockViewCacheService.signal.mockReturnValue(mockSignal);
TestBed.configureTestingModule({
providers: [
TwoFactorAuthEmailComponentCacheService,
{ provide: ViewCacheService, useValue: mockViewCacheService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("init", () => {
it("sets featureEnabled to true when flag is enabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalled();
});
it("sets featureEnabled to false when flag is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("cacheData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("caches email sent state when feature is enabled", () => {
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalledWith({
emailSent: true,
});
});
it("does not cache data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("clearCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("clears cached data when feature is enabled", () => {
service.clearCachedData();
expect(mockSignal.set).toHaveBeenCalledWith(null);
});
it("does not clear cached data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.clearCachedData();
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("getCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("returns cached data when feature is enabled", () => {
const testData = new TwoFactorAuthEmailComponentCache();
testData.emailSent = true;
cacheData.next(testData);
const result = service.getCachedData();
expect(result).toEqual(testData);
expect(mockSignal).toHaveBeenCalled();
});
it("returns null when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
const result = service.getCachedData();
expect(result).toBeNull();
expect(mockSignal).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,165 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
TwoFactorAuthEmailComponentCache,
TwoFactorAuthEmailComponentCacheService,
} from "./two-factor-auth-email-component-cache.service";
describe("TwoFactorAuthEmailComponentCache", () => {
describe("fromJSON", () => {
it("returns null when input is null", () => {
const result = TwoFactorAuthEmailComponentCache.fromJSON(null as any);
expect(result).toBeNull();
});
it("creates a TwoFactorAuthEmailCache instance from valid JSON", () => {
const jsonData = { emailSent: true };
const result = TwoFactorAuthEmailComponentCache.fromJSON(jsonData);
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(TwoFactorAuthEmailComponentCache);
expect(result?.emailSent).toBe(true);
});
});
});
describe("TwoFactorAuthEmailComponentCacheService", () => {
let service: TwoFactorAuthEmailComponentCacheService;
let mockViewCacheService: MockProxy<ViewCacheService>;
let mockConfigService: MockProxy<ConfigService>;
let cacheData: BehaviorSubject<TwoFactorAuthEmailComponentCache | null>;
let mockSignal: any;
beforeEach(() => {
mockViewCacheService = mock<ViewCacheService>();
mockConfigService = mock<ConfigService>();
cacheData = new BehaviorSubject<TwoFactorAuthEmailComponentCache | null>(null);
mockSignal = jest.fn(() => cacheData.getValue());
mockSignal.set = jest.fn((value: TwoFactorAuthEmailComponentCache | null) =>
cacheData.next(value),
);
mockViewCacheService.signal.mockReturnValue(mockSignal);
TestBed.configureTestingModule({
providers: [
TwoFactorAuthEmailComponentCacheService,
{ provide: ViewCacheService, useValue: mockViewCacheService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
service = TestBed.inject(TwoFactorAuthEmailComponentCacheService);
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("init", () => {
it("sets featureEnabled to true when flag is enabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalled();
});
it("sets featureEnabled to false when flag is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("cacheData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("caches email sent state when feature is enabled", () => {
service.cacheData({ emailSent: true });
expect(mockSignal.set).toHaveBeenCalledWith({
emailSent: true,
});
});
it("does not cache data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.cacheData({ emailSent: true });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("clearCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("clears cached data when feature is enabled", () => {
service.clearCachedData();
expect(mockSignal.set).toHaveBeenCalledWith(null);
});
it("does not clear cached data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.clearCachedData();
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("getCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("returns cached data when feature is enabled", () => {
const testData = new TwoFactorAuthEmailComponentCache();
testData.emailSent = true;
cacheData.next(testData);
const result = service.getCachedData();
expect(result).toEqual(testData);
expect(mockSignal).toHaveBeenCalled();
});
it("returns null when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
const result = service.getCachedData();
expect(result).toBeNull();
expect(mockSignal).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,95 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
/**
* The key for the email two factor auth component cache.
*/
export const TWO_FACTOR_AUTH_EMAIL_COMPONENT_CACHE_KEY = "two-factor-auth-email-component-cache";
/**
* Cache model for the email two factor auth component.
*/
export class TwoFactorAuthEmailComponentCache {
emailSent: boolean = false;
static fromJSON(
obj: Partial<Jsonify<TwoFactorAuthEmailComponentCache>>,
): TwoFactorAuthEmailComponentCache | null {
// Return null if the cache is empty
if (obj == null) {
return null;
}
return Object.assign(new TwoFactorAuthEmailComponentCache(), obj);
}
}
/**
* Cache service for the two factor auth email component.
*/
@Injectable()
export class TwoFactorAuthEmailComponentCacheService {
private viewCacheService: ViewCacheService = inject(ViewCacheService);
private configService: ConfigService = inject(ConfigService);
/** True when the feature flag is enabled */
private featureEnabled: boolean = false;
/**
* Signal for the cached email state.
*/
private emailCache: WritableSignal<TwoFactorAuthEmailComponentCache | null> =
this.viewCacheService.signal<TwoFactorAuthEmailComponentCache | null>({
key: TWO_FACTOR_AUTH_EMAIL_COMPONENT_CACHE_KEY,
initialValue: null,
deserializer: TwoFactorAuthEmailComponentCache.fromJSON,
});
/**
* Must be called once before interacting with the cached data.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
}
/**
* Cache the email sent state.
*/
cacheData(data: { emailSent: boolean }): void {
if (!this.featureEnabled) {
return;
}
this.emailCache.set({
emailSent: data.emailSent,
} as TwoFactorAuthEmailComponentCache);
}
/**
* Clear the cached email data.
*/
clearCachedData(): void {
if (!this.featureEnabled) {
return;
}
this.emailCache.set(null);
}
/**
* Get whether the email has been sent.
*/
getCachedData(): TwoFactorAuthEmailComponentCache | null {
if (!this.featureEnabled) {
return null;
}
return this.emailCache();
}
}

View File

@@ -1,6 +1,13 @@
<bit-form-field class="!tw-mb-0">
<bit-label>{{ "verificationCode" | i18n }}</bit-label>
<input bitInput type="text" appAutofocus appInputVerbatim [formControl]="tokenFormControl" />
<input
bitInput
type="text"
appAutofocus
appInputVerbatim
[formControl]="tokenFormControl"
(keyup)="onTokenChange($event)"
/>
</bit-form-field>
<div class="tw-mb-4">

View File

@@ -1,5 +1,5 @@
import { CommonModule } from "@angular/common";
import { Component, Input, OnInit } from "@angular/core";
import { Component, Input, OnInit, Output, EventEmitter } from "@angular/core";
import { ReactiveFormsModule, FormsModule, FormControl } from "@angular/forms";
import { JslibModule } from "@bitwarden/angular/jslib.module";
@@ -22,6 +22,7 @@ import {
ToastService,
} from "@bitwarden/components";
import { TwoFactorAuthEmailComponentCacheService } from "./two-factor-auth-email-component-cache.service";
import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-component.service";
@Component({
@@ -40,14 +41,20 @@ import { TwoFactorAuthEmailComponentService } from "./two-factor-auth-email-comp
AsyncActionsModule,
FormsModule,
],
providers: [],
providers: [
{
provide: TwoFactorAuthEmailComponentCacheService,
useClass: TwoFactorAuthEmailComponentCacheService,
},
],
})
export class TwoFactorAuthEmailComponent implements OnInit {
@Input({ required: true }) tokenFormControl: FormControl | undefined = undefined;
@Output() tokenChange = new EventEmitter<{ token: string }>();
twoFactorEmail: string | undefined = undefined;
emailPromise: Promise<any> | undefined = undefined;
tokenValue: string = "";
emailPromise: Promise<any> | undefined;
emailSent = false;
constructor(
protected i18nService: I18nService,
@@ -59,14 +66,22 @@ export class TwoFactorAuthEmailComponent implements OnInit {
protected appIdService: AppIdService,
private toastService: ToastService,
private twoFactorAuthEmailComponentService: TwoFactorAuthEmailComponentService,
private cacheService: TwoFactorAuthEmailComponentCacheService,
) {}
async ngOnInit(): Promise<void> {
await this.twoFactorAuthEmailComponentService.openPopoutIfApprovedForEmail2fa?.();
await this.cacheService.init();
// Check if email was already sent
const cachedData = this.cacheService.getCachedData();
if (cachedData?.emailSent) {
this.emailSent = true;
}
const providers = await this.twoFactorService.getProviders();
if (!providers) {
if (!providers || providers.size === 0) {
throw new Error("User has no 2FA Providers");
}
@@ -78,11 +93,20 @@ export class TwoFactorAuthEmailComponent implements OnInit {
this.twoFactorEmail = email2faProviderData.Email;
if (providers.size > 1) {
if (!this.emailSent) {
await this.sendEmail(false);
}
}
/**
* Emits the token value to the parent component
* @param event - The event object from the input field
*/
onTokenChange(event: Event) {
const tokenValue = (event.target as HTMLInputElement).value || "";
this.tokenChange.emit({ token: tokenValue });
}
async sendEmail(doToast: boolean) {
if (this.emailPromise !== undefined) {
return;
@@ -113,6 +137,10 @@ export class TwoFactorAuthEmailComponent implements OnInit {
request.authRequestId = (await this.loginStrategyService.getAuthRequestId()) ?? "";
this.emailPromise = this.apiService.postTwoFactorEmail(request);
await this.emailPromise;
this.emailSent = true;
this.cacheService.cacheData({ emailSent: this.emailSent });
if (doToast) {
this.toastService.showToast({
variant: "success",

View File

@@ -0,0 +1,191 @@
import { TestBed } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
TwoFactorAuthComponentCache,
TwoFactorAuthComponentCacheService,
TwoFactorAuthComponentData,
} from "./two-factor-auth-component-cache.service";
describe("TwoFactorAuthCache", () => {
describe("fromJSON", () => {
it("returns null when input is null", () => {
const result = TwoFactorAuthComponentCache.fromJSON(null as any);
expect(result).toBeNull();
});
it("creates a TwoFactorAuthCache instance from valid JSON", () => {
const jsonData = {
token: "123456",
remember: true,
selectedProviderType: TwoFactorProviderType.Email,
};
const result = TwoFactorAuthComponentCache.fromJSON(jsonData as any);
expect(result).not.toBeNull();
expect(result).toBeInstanceOf(TwoFactorAuthComponentCache);
expect(result?.token).toBe("123456");
expect(result?.remember).toBe(true);
expect(result?.selectedProviderType).toBe(TwoFactorProviderType.Email);
});
});
});
describe("TwoFactorAuthComponentCacheService", () => {
let service: TwoFactorAuthComponentCacheService;
let mockViewCacheService: MockProxy<ViewCacheService>;
let mockConfigService: MockProxy<ConfigService>;
let cacheData: BehaviorSubject<TwoFactorAuthComponentCache | null>;
let mockSignal: any;
beforeEach(() => {
mockViewCacheService = mock<ViewCacheService>();
mockConfigService = mock<ConfigService>();
cacheData = new BehaviorSubject<TwoFactorAuthComponentCache | null>(null);
mockSignal = jest.fn(() => cacheData.getValue());
mockSignal.set = jest.fn((value: TwoFactorAuthComponentCache | null) => cacheData.next(value));
mockViewCacheService.signal.mockReturnValue(mockSignal);
TestBed.configureTestingModule({
providers: [
TwoFactorAuthComponentCacheService,
{ provide: ViewCacheService, useValue: mockViewCacheService },
{ provide: ConfigService, useValue: mockConfigService },
],
});
service = TestBed.inject(TwoFactorAuthComponentCacheService);
});
it("creates the service", () => {
expect(service).toBeTruthy();
});
describe("init", () => {
it("sets featureEnabled to true when flag is enabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ token: "123456" });
expect(mockSignal.set).toHaveBeenCalled();
});
it("sets featureEnabled to false when flag is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
service.cacheData({ token: "123456" });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("cacheData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("caches complete data when feature is enabled", () => {
const testData: TwoFactorAuthComponentData = {
token: "123456",
remember: true,
selectedProviderType: TwoFactorProviderType.Email,
};
service.cacheData(testData);
expect(mockSignal.set).toHaveBeenCalledWith({
token: "123456",
remember: true,
selectedProviderType: TwoFactorProviderType.Email,
});
});
it("caches partial data when feature is enabled", () => {
service.cacheData({ token: "123456" });
expect(mockSignal.set).toHaveBeenCalledWith({
token: "123456",
remember: undefined,
selectedProviderType: undefined,
});
});
it("does not cache data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.cacheData({ token: "123456" });
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("clearCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("clears cached data when feature is enabled", () => {
service.clearCachedData();
expect(mockSignal.set).toHaveBeenCalledWith(null);
});
it("does not clear cached data when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
service.clearCachedData();
expect(mockSignal.set).not.toHaveBeenCalled();
});
});
describe("getCachedData", () => {
beforeEach(async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(true);
await service.init();
});
it("returns cached data when feature is enabled", () => {
const testData = new TwoFactorAuthComponentCache();
testData.token = "123456";
testData.remember = true;
testData.selectedProviderType = TwoFactorProviderType.Email;
cacheData.next(testData);
const result = service.getCachedData();
expect(result).toEqual(testData);
expect(mockSignal).toHaveBeenCalled();
});
it("returns null when feature is disabled", async () => {
mockConfigService.getFeatureFlag.mockResolvedValue(false);
await service.init();
const result = service.getCachedData();
expect(result).toBeNull();
expect(mockSignal).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,105 @@
import { inject, Injectable, WritableSignal } from "@angular/core";
import { Jsonify } from "type-fest";
import { ViewCacheService } from "@bitwarden/angular/platform/abstractions/view-cache.service";
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
const TWO_FACTOR_AUTH_COMPONENT_CACHE_KEY = "two-factor-auth-component-cache";
/**
* Cache model for the two factor authentication data.
*/
export class TwoFactorAuthComponentCache {
token: string | undefined = undefined;
remember: boolean | undefined = undefined;
selectedProviderType: TwoFactorProviderType | undefined = undefined;
static fromJSON(
obj: Partial<Jsonify<TwoFactorAuthComponentCache>>,
): TwoFactorAuthComponentCache | null {
// Return null if the cache is empty
if (obj == null) {
return null;
}
return Object.assign(new TwoFactorAuthComponentCache(), obj);
}
}
export interface TwoFactorAuthComponentData {
token?: string;
remember?: boolean;
selectedProviderType?: TwoFactorProviderType;
}
/**
* Cache service used for the two factor auth component.
*/
@Injectable()
export class TwoFactorAuthComponentCacheService {
private viewCacheService: ViewCacheService = inject(ViewCacheService);
private configService: ConfigService = inject(ConfigService);
/** True when the `PM9115_TwoFactorExtensionDataPersistence` flag is enabled */
private featureEnabled: boolean = false;
/**
* Signal for the cached TwoFactorAuthData.
*/
private twoFactorAuthComponentCache: WritableSignal<TwoFactorAuthComponentCache | null> =
this.viewCacheService.signal<TwoFactorAuthComponentCache | null>({
key: TWO_FACTOR_AUTH_COMPONENT_CACHE_KEY,
initialValue: null,
deserializer: TwoFactorAuthComponentCache.fromJSON,
});
constructor() {}
/**
* Must be called once before interacting with the cached data.
*/
async init() {
this.featureEnabled = await this.configService.getFeatureFlag(
FeatureFlag.PM9115_TwoFactorExtensionDataPersistence,
);
}
/**
* Update the cache with the new TwoFactorAuthData.
*/
cacheData(data: TwoFactorAuthComponentData): void {
if (!this.featureEnabled) {
return;
}
this.twoFactorAuthComponentCache.set({
token: data.token,
remember: data.remember,
selectedProviderType: data.selectedProviderType,
} as TwoFactorAuthComponentCache);
}
/**
* Clears the cached TwoFactorAuthData.
*/
clearCachedData(): void {
if (!this.featureEnabled) {
return;
}
this.twoFactorAuthComponentCache.set(null);
}
/**
* Returns the cached TwoFactorAuthData (when available).
*/
getCachedData(): TwoFactorAuthComponentCache | null {
if (!this.featureEnabled) {
return null;
}
return this.twoFactorAuthComponentCache();
}
}

View File

@@ -13,11 +13,13 @@
>
<app-two-factor-auth-email
[tokenFormControl]="tokenFormControl"
(tokenChange)="saveFormDataWithPartialData($event)"
*ngIf="selectedProviderType === providerType.Email"
/>
<app-two-factor-auth-authenticator
[tokenFormControl]="tokenFormControl"
(tokenChange)="saveFormDataWithPartialData($event)"
*ngIf="selectedProviderType === providerType.Authenticator"
/>
<app-two-factor-auth-yubikey
@@ -36,7 +38,7 @@
/>
<bit-form-control *ngIf="!hideRememberMe()">
<bit-label>{{ "dontAskAgainOnThisDeviceFor30Days" | i18n }}</bit-label>
<input type="checkbox" bitCheckbox formControlName="remember" />
<input type="checkbox" bitCheckbox formControlName="remember" (change)="onRememberChange()" />
</bit-form-control>
<app-two-factor-auth-webauthn

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { ActivatedRoute, convertToParamMap, Router } from "@angular/router";
import { ActivatedRoute, Router, convertToParamMap } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
@@ -38,6 +36,7 @@ import { DialogService, ToastService } from "@bitwarden/components";
import { AnonLayoutWrapperDataService } from "../anon-layout/anon-layout-wrapper-data.service";
import { TwoFactorAuthComponentCacheService } from "./two-factor-auth-component-cache.service";
import { TwoFactorAuthComponentService } from "./two-factor-auth-component.service";
import { TwoFactorAuthComponent } from "./two-factor-auth.component";
@@ -72,6 +71,7 @@ describe("TwoFactorAuthComponent", () => {
let anonLayoutWrapperDataService: MockProxy<AnonLayoutWrapperDataService>;
let mockEnvService: MockProxy<EnvironmentService>;
let mockLoginSuccessHandlerService: MockProxy<LoginSuccessHandlerService>;
let mockTwoFactorAuthCompCacheService: MockProxy<TwoFactorAuthComponentCacheService>;
let mockUserDecryptionOpts: {
noMasterPassword: UserDecryptionOptions;
@@ -112,6 +112,10 @@ describe("TwoFactorAuthComponent", () => {
anonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
mockTwoFactorAuthCompCacheService = mock<TwoFactorAuthComponentCacheService>();
mockTwoFactorAuthCompCacheService.getCachedData.mockReturnValue(null);
mockTwoFactorAuthCompCacheService.init.mockResolvedValue();
mockUserDecryptionOpts = {
noMasterPassword: new UserDecryptionOptions({
hasMasterPassword: false,
@@ -155,7 +159,9 @@ describe("TwoFactorAuthComponent", () => {
}),
};
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(undefined);
selectedUserDecryptionOptions = new BehaviorSubject<UserDecryptionOptions>(
mockUserDecryptionOpts.withMasterPassword,
);
mockUserDecryptionOptionsService.userDecryptionOptions$ = selectedUserDecryptionOptions;
TestBed.configureTestingModule({
@@ -194,6 +200,10 @@ describe("TwoFactorAuthComponent", () => {
{ provide: EnvironmentService, useValue: mockEnvService },
{ provide: AnonLayoutWrapperDataService, useValue: anonLayoutWrapperDataService },
{ provide: LoginSuccessHandlerService, useValue: mockLoginSuccessHandlerService },
{
provide: TwoFactorAuthComponentCacheService,
useValue: mockTwoFactorAuthCompCacheService,
},
],
});

View File

@@ -60,6 +60,10 @@ import { TwoFactorAuthDuoComponent } from "./child-components/two-factor-auth-du
import { TwoFactorAuthEmailComponent } from "./child-components/two-factor-auth-email/two-factor-auth-email.component";
import { TwoFactorAuthWebAuthnComponent } from "./child-components/two-factor-auth-webauthn/two-factor-auth-webauthn.component";
import { TwoFactorAuthYubikeyComponent } from "./child-components/two-factor-auth-yubikey.component";
import {
TwoFactorAuthComponentCacheService,
TwoFactorAuthComponentData,
} from "./two-factor-auth-component-cache.service";
import {
DuoLaunchAction,
LegacyKeyMigrationAction,
@@ -90,7 +94,11 @@ import {
TwoFactorAuthYubikeyComponent,
TwoFactorAuthWebAuthnComponent,
],
providers: [],
providers: [
{
provide: TwoFactorAuthComponentCacheService,
},
],
})
export class TwoFactorAuthComponent implements OnInit, OnDestroy {
@ViewChild("continueButton", { read: ElementRef, static: false }) continueButton:
@@ -160,6 +168,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
private anonLayoutWrapperDataService: AnonLayoutWrapperDataService,
private environmentService: EnvironmentService,
private loginSuccessHandlerService: LoginSuccessHandlerService,
private twoFactorAuthComponentCacheService: TwoFactorAuthComponentCacheService,
) {}
async ngOnInit() {
@@ -168,7 +177,33 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.listenForAuthnSessionTimeout();
await this.setSelected2faProviderType();
// Initialize the cache
await this.twoFactorAuthComponentCacheService.init();
// Load cached form data if available
let loadedCachedProviderType = false;
const cachedData = this.twoFactorAuthComponentCacheService.getCachedData();
if (cachedData) {
if (cachedData.token) {
this.form.patchValue({ token: cachedData.token });
}
if (cachedData.remember !== undefined) {
this.form.patchValue({ remember: cachedData.remember });
}
if (cachedData.selectedProviderType !== undefined) {
this.selectedProviderType = cachedData.selectedProviderType;
loadedCachedProviderType = true;
}
}
// If we don't have a cached provider type, set it to the default and cache it
if (!loadedCachedProviderType) {
this.selectedProviderType = await this.initializeSelected2faProviderType();
this.twoFactorAuthComponentCacheService.cacheData({
selectedProviderType: this.selectedProviderType,
});
}
await this.set2faProvidersAndData();
await this.setAnonLayoutDataByTwoFactorProviderType();
@@ -181,7 +216,29 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.loading = false;
}
private async setSelected2faProviderType() {
/**
* Save specific form data fields to the cache
*/
async saveFormDataWithPartialData(data: Partial<TwoFactorAuthComponentData>) {
// Get current cached data
const currentData = this.twoFactorAuthComponentCacheService.getCachedData();
this.twoFactorAuthComponentCacheService.cacheData({
token: data?.token ?? currentData?.token ?? "",
remember: data?.remember ?? currentData?.remember ?? false,
selectedProviderType: data?.selectedProviderType ?? currentData?.selectedProviderType,
});
}
/**
* Save the remember value to the cache when the checkbox is checked or unchecked
*/
async onRememberChange() {
const rememberValue = !!this.rememberFormControl.value;
await this.saveFormDataWithPartialData({ remember: rememberValue });
}
private async initializeSelected2faProviderType(): Promise<TwoFactorProviderType> {
const webAuthnSupported = this.platformUtilsService.supportsWebAuthn(this.win);
if (
@@ -190,18 +247,19 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
) {
const webAuthn2faResponse = this.activatedRoute.snapshot.paramMap.get("webAuthnResponse");
if (webAuthn2faResponse) {
this.selectedProviderType = TwoFactorProviderType.WebAuthn;
return;
return TwoFactorProviderType.WebAuthn;
}
}
this.selectedProviderType = await this.twoFactorService.getDefaultProvider(webAuthnSupported);
return await this.twoFactorService.getDefaultProvider(webAuthnSupported);
}
private async set2faProvidersAndData() {
this.twoFactorProviders = await this.twoFactorService.getProviders();
const providerData = this.twoFactorProviders?.get(this.selectedProviderType);
this.selectedProviderData = providerData;
if (this.selectedProviderType !== undefined) {
const providerData = this.twoFactorProviders?.get(this.selectedProviderType);
this.selectedProviderData = providerData;
}
}
private listenForAuthnSessionTimeout() {
@@ -267,6 +325,13 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
// In all flows but WebAuthn, the remember value is taken from the form.
const rememberValue = remember ?? this.rememberFormControl.value ?? false;
// Cache form data before submitting
this.twoFactorAuthComponentCacheService.cacheData({
token: tokenValue,
remember: rememberValue,
selectedProviderType: this.selectedProviderType,
});
try {
this.formPromise = this.loginStrategyService.logInTwoFactor(
new TokenTwoFactorRequest(this.selectedProviderType, tokenValue, rememberValue),
@@ -274,6 +339,7 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
);
const authResult: AuthResult = await this.formPromise;
this.logService.info("Successfully submitted two factor token");
await this.handleAuthResult(authResult);
} catch {
this.logService.error("Error submitting two factor token");
@@ -299,6 +365,13 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
this.selectedProviderType = response.type;
await this.setAnonLayoutDataByTwoFactorProviderType();
// Update the cached provider type when a new one is chosen
this.twoFactorAuthComponentCacheService.cacheData({
token: "",
remember: false,
selectedProviderType: response.type,
});
this.form.reset();
this.form.updateValueAndValidity();
}
@@ -376,6 +449,9 @@ export class TwoFactorAuthComponent implements OnInit, OnDestroy {
}
private async handleAuthResult(authResult: AuthResult) {
// Clear form cache
this.twoFactorAuthComponentCacheService.clearCachedData();
if (await this.handleMigrateEncryptionKey(authResult)) {
return; // stop login process
}

View File

@@ -123,7 +123,9 @@ describe("AuthRequestService", () => {
});
it("should use the user key if the master key and hash do not exist", async () => {
keyService.getUserKey.mockResolvedValueOnce({ key: new Uint8Array(64) } as UserKey);
keyService.getUserKey.mockResolvedValueOnce(
new SymmetricCryptoKey(new Uint8Array(64)) as UserKey,
);
await sut.approveOrDenyAuthRequest(
true,
@@ -131,7 +133,7 @@ describe("AuthRequestService", () => {
);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
{ key: new Uint8Array(64) },
new SymmetricCryptoKey(new Uint8Array(64)),
expect.anything(),
);
});

View File

@@ -434,7 +434,7 @@ describe("PinService", () => {
.fn()
.mockResolvedValue(pinKeyEncryptedUserKeyPersistant);
sut.makePinKey = jest.fn().mockResolvedValue(mockPinKey);
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.key);
encryptService.decryptToBytes.mockResolvedValue(mockUserKey.toEncoded());
}
function mockPinEncryptedKeyDataByPinLockType(pinLockType: PinLockType) {

View File

@@ -21,7 +21,7 @@ describe("WebAuthnLoginPrfKeyService", () => {
const result = await service.createSymmetricKeyFromPrf(randomBytes(32));
expect(result.key.length).toBe(64);
expect(result.toEncoded().length).toBe(64);
});
});
});

View File

@@ -17,6 +17,7 @@ export enum FeatureFlag {
/* Auth */
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
PM9115_TwoFactorExtensionDataPersistence = "pm-9115-two-factor-extension-data-persistence",
/* Autofill */
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
@@ -54,7 +55,6 @@ export enum FeatureFlag {
PM9111ExtensionPersistAddEditForm = "pm-9111-extension-persist-add-edit-form",
NewDeviceVerificationTemporaryDismiss = "new-device-temporary-dismiss",
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
VaultBulkManagementAction = "vault-bulk-management-action",
SecurityTasks = "security-tasks",
CipherKeyEncryption = "cipher-key-encryption",
PM18520_UpdateDesktopCipherForm = "pm-18520-desktop-cipher-forms",
@@ -108,7 +108,6 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM9111ExtensionPersistAddEditForm]: FALSE,
[FeatureFlag.NewDeviceVerificationTemporaryDismiss]: FALSE,
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
[FeatureFlag.VaultBulkManagementAction]: FALSE,
[FeatureFlag.SecurityTasks]: FALSE,
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM18520_UpdateDesktopCipherForm]: FALSE,
@@ -116,6 +115,7 @@ export const DefaultFeatureFlagValue = {
/* Auth */
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
[FeatureFlag.PM9115_TwoFactorExtensionDataPersistence]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,

View File

@@ -47,7 +47,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
if (this.blockType0) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
if (key.inner().type === EncryptionType.AesCbc256_B64) {
throw new Error("Type 0 encryption is not supported.");
}
}
@@ -105,7 +105,7 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No wrappingKey provided for wrapping.");
}
return await this.encryptUint8Array(keyToBeWrapped.key, wrappingKey);
return await this.encryptUint8Array(keyToBeWrapped.toEncoded(), wrappingKey);
}
private async encryptUint8Array(
@@ -117,7 +117,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
if (this.blockType0) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
if (key.inner().type === EncryptionType.AesCbc256_B64) {
throw new Error("Type 0 encryption is not supported.");
}
}
@@ -147,7 +147,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
if (this.blockType0) {
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
if (key.inner().type === EncryptionType.AesCbc256_B64) {
throw new Error("Type 0 encryption is not supported.");
}
}

View File

@@ -58,10 +58,9 @@ describe("EncryptService", () => {
it("fails if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
encryptionKey: makeStaticByteArray(32),
});
await expect(encryptService.wrapSymmetricKey(mock32Key, mock32Key)).rejects.toThrow(
@@ -99,10 +98,9 @@ describe("EncryptService", () => {
it("throws if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
encryptionKey: makeStaticByteArray(32),
});
await expect(
@@ -140,10 +138,9 @@ describe("EncryptService", () => {
it("throws if type 0 key is provided with flag turned on", async () => {
(encryptService as any).blockType0 = true;
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
encryptionKey: makeStaticByteArray(32),
});
await expect(
@@ -187,10 +184,9 @@ describe("EncryptService", () => {
(encryptService as any).blockType0 = true;
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
encryptionKey: makeStaticByteArray(32),
});
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
@@ -273,10 +269,9 @@ describe("EncryptService", () => {
(encryptService as any).blockType0 = true;
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
const mock32Key = mock<SymmetricCryptoKey>();
mock32Key.key = makeStaticByteArray(32);
mock32Key.inner.mockReturnValue({
type: 0,
encryptionKey: mock32Key.key,
encryptionKey: makeStaticByteArray(32),
});
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
@@ -574,7 +569,7 @@ describe("EncryptService", () => {
const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey);
expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith(
expect.toEqualBuffer(testKey.key),
expect.toEqualBuffer(testKey.toEncoded()),
expect.toEqualBuffer(publicKey),
"sha1",
);
@@ -625,7 +620,7 @@ describe("EncryptService", () => {
"sha1",
);
expect(actual.key).toEqualBuffer(data);
expect(actual.toEncoded()).toEqualBuffer(data);
});
});
});

View File

@@ -221,8 +221,8 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
newUserKey.key,
const newEncryptedUserKey = await this.encryptService.encapsulateKeyUnsigned(
newUserKey,
publicKey,
);

View File

@@ -450,7 +450,7 @@ describe("deviceTrustService", () => {
// RsaEncrypt must be called w/ a user key array buffer of 64 bytes
const userKey = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
expect(userKey.key.byteLength).toBe(64);
expect(userKey.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
expect(encryptServiceWrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
expect(encryptServiceWrapEncapsulationKeySpy).toHaveBeenCalledTimes(1);
@@ -706,7 +706,9 @@ describe("deviceTrustService", () => {
);
encryptService.decryptToBytes.mockResolvedValue(null);
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
encryptService.encapsulateKeyUnsigned.mockResolvedValue(
new EncString("test_encrypted_data"),
);
const protectedDeviceResponse = new ProtectedDeviceResponse({
id: "id",
@@ -861,8 +863,8 @@ describe("deviceTrustService", () => {
// Mock the decryption of the public key with the old user key
encryptService.decryptToBytes.mockImplementationOnce((_encValue, privateKeyValue) => {
expect(privateKeyValue.key.byteLength).toBe(64);
expect(new Uint8Array(privateKeyValue.key)[0]).toBe(FakeOldUserKeyMarker);
expect(privateKeyValue.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64);
expect(new Uint8Array(privateKeyValue.toEncoded())[0]).toBe(FakeOldUserKeyMarker);
const data = new Uint8Array(250);
data.fill(FakeDecryptedPublicKeyMarker, 0, 1);
return Promise.resolve(data);
@@ -870,8 +872,8 @@ describe("deviceTrustService", () => {
// Mock the encryption of the new user key with the decrypted public key
encryptService.encapsulateKeyUnsigned.mockImplementationOnce((data, publicKey) => {
expect(data.key.byteLength).toBe(64); // New key should also be 64 bytes
expect(new Uint8Array(data.key)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
expect(data.inner().type).toBe(EncryptionType.AesCbc256_HmacSha256_B64); // New key should also be 64 bytes
expect(new Uint8Array(data.toEncoded())[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
expect(new Uint8Array(publicKey)[0]).toBe(FakeDecryptedPublicKeyMarker);
return Promise.resolve(new EncString("4.ZW5jcnlwdGVkdXNlcg=="));
@@ -882,7 +884,7 @@ describe("deviceTrustService", () => {
expect(plainValue).toBeInstanceOf(Uint8Array);
expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker);
expect(new Uint8Array(key.key)[0]).toBe(FakeNewUserKeyMarker);
expect(new Uint8Array(key.toEncoded())[0]).toBe(FakeNewUserKeyMarker);
return Promise.resolve(
new EncString("2.ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj|ZW5jcnlwdGVkcHVibGlj"),
);

View File

@@ -19,7 +19,6 @@ describe("SymmetricCryptoKey", () => {
const cryptoKey = new SymmetricCryptoKey(key);
expect(cryptoKey).toEqual({
key: key,
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
innerKey: {
type: EncryptionType.AesCbc256_B64,
@@ -33,7 +32,6 @@ describe("SymmetricCryptoKey", () => {
const cryptoKey = new SymmetricCryptoKey(key);
expect(cryptoKey).toEqual({
key: key,
keyB64:
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
innerKey: {

View File

@@ -24,7 +24,6 @@ export type Aes256CbcKey = {
export class SymmetricCryptoKey {
private innerKey: Aes256CbcHmacKey | Aes256CbcKey;
key: Uint8Array;
keyB64: string;
/**
@@ -40,7 +39,6 @@ export class SymmetricCryptoKey {
type: EncryptionType.AesCbc256_B64,
encryptionKey: key,
};
this.key = key;
this.keyB64 = this.toBase64();
} else if (key.byteLength === 64) {
this.innerKey = {
@@ -48,7 +46,6 @@ export class SymmetricCryptoKey {
encryptionKey: key.slice(0, 32),
authenticationKey: key.slice(32),
};
this.key = key;
this.keyB64 = this.toBase64();
} else {
throw new Error(`Unsupported encType/key length ${key.byteLength}`);

View File

@@ -4,6 +4,7 @@ import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
import { CsprngArray } from "../../types/csprng";
import { EncryptionType } from "../enums";
import { KeyGenerationService } from "./key-generation.service";
@@ -52,7 +53,7 @@ describe("KeyGenerationService", () => {
expect(salt).toEqual(inputSalt);
expect(material).toEqual(inputMaterial);
expect(derivedKey.key.length).toEqual(64);
expect(derivedKey.inner().type).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
},
);
});
@@ -67,7 +68,7 @@ describe("KeyGenerationService", () => {
const key = await sut.deriveKeyFromMaterial(material, salt, purpose);
expect(key.key.length).toEqual(64);
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
});
});
@@ -81,7 +82,7 @@ describe("KeyGenerationService", () => {
const key = await sut.deriveKeyFromPassword(password, salt, kdfConfig);
expect(key.key.length).toEqual(32);
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_B64);
});
it("should derive a 32 byte key from a password using argon2id", async () => {
@@ -94,7 +95,7 @@ describe("KeyGenerationService", () => {
const key = await sut.deriveKeyFromPassword(password, salt, kdfConfig);
expect(key.key.length).toEqual(32);
expect(key.inner().type).toEqual(EncryptionType.AesCbc256_B64);
});
});
});

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { MasterKey, PinKey } from "@bitwarden/common/types/key";
import { KdfConfig, PBKDF2KdfConfig, Argon2KdfConfig, KdfType } from "@bitwarden/key-management";
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
@@ -78,10 +79,21 @@ export class KeyGenerationService implements KeyGenerationServiceAbstraction {
return new SymmetricCryptoKey(key);
}
async stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey> {
async stretchKey(key: MasterKey | PinKey): Promise<SymmetricCryptoKey> {
const newKey = new Uint8Array(64);
const encKey = await this.cryptoFunctionService.hkdfExpand(key.key, "enc", 32, "sha256");
const macKey = await this.cryptoFunctionService.hkdfExpand(key.key, "mac", 32, "sha256");
// Master key and pin key are always 32 bytes
const encKey = await this.cryptoFunctionService.hkdfExpand(
key.inner().encryptionKey,
"enc",
32,
"sha256",
);
const macKey = await this.cryptoFunctionService.hkdfExpand(
key.inner().encryptionKey,
"mac",
32,
"sha256",
);
newKey.set(new Uint8Array(encKey));
newKey.set(new Uint8Array(macKey), 32);

View File

@@ -497,7 +497,7 @@ describe("keyService", () => {
const output = new Uint8Array(64);
output.set(encryptedPrivateKey.dataBytes);
output.set(
key.key.subarray(0, 64 - encryptedPrivateKey.dataBytes.length),
key.toEncoded().subarray(0, 64 - encryptedPrivateKey.dataBytes.length),
encryptedPrivateKey.dataBytes.length,
);
return output;
@@ -827,7 +827,7 @@ describe("keyService", () => {
masterPasswordService.masterKeyHashSubject.next(storedMasterKeyHash);
cryptoFunctionService.pbkdf2
.calledWith(masterKey.key, masterPassword as string, "sha256", 2)
.calledWith(masterKey.inner().encryptionKey, masterPassword as string, "sha256", 2)
.mockResolvedValue(Utils.fromB64ToArray(mockReturnedHash));
const actualDidMatch = await keyService.compareKeyHash(

View File

@@ -26,7 +26,7 @@ import { KeyGenerationService } from "@bitwarden/common/platform/abstractions/ke
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 { KeySuffixOptions, HashPurpose } from "@bitwarden/common/platform/enums";
import { KeySuffixOptions, HashPurpose, EncryptionType } from "@bitwarden/common/platform/enums";
import { convertValues } from "@bitwarden/common/platform/misc/convert-values";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { EFFLongWordList } from "@bitwarden/common/platform/misc/wordlist";
@@ -346,7 +346,12 @@ export class DefaultKeyService implements KeyServiceAbstraction {
}
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
const hash = await this.cryptoFunctionService.pbkdf2(key.key, password, "sha256", iterations);
const hash = await this.cryptoFunctionService.pbkdf2(
key.inner().encryptionKey,
password,
"sha256",
iterations,
);
return Utils.fromBufferToB64(hash);
}
@@ -823,13 +828,13 @@ export class DefaultKeyService implements KeyServiceAbstraction {
newSymKey: SymmetricCryptoKey,
): Promise<[T, EncString]> {
let protectedSymKey: EncString;
if (encryptionKey.key.byteLength === 32) {
if (encryptionKey.inner().type === EncryptionType.AesCbc256_B64) {
const stretchedEncryptionKey = await this.keyGenerationService.stretchKey(encryptionKey);
protectedSymKey = await this.encryptService.wrapSymmetricKey(
newSymKey,
stretchedEncryptionKey,
);
} else if (encryptionKey.key.byteLength === 64) {
} else if (encryptionKey.inner().type === EncryptionType.AesCbc256_HmacSha256_B64) {
protectedSymKey = await this.encryptService.wrapSymmetricKey(newSymKey, encryptionKey);
} else {
throw new Error("Invalid key size.");

735
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@
"@babel/preset-env": "7.24.8",
"@compodoc/compodoc": "1.1.26",
"@electron/notarize": "2.5.0",
"@electron/rebuild": "3.7.1",
"@electron/rebuild": "3.7.2",
"@lit-labs/signals": "0.1.2",
"@ngtools/webpack": "18.2.12",
"@storybook/addon-a11y": "8.5.2",
@@ -100,12 +100,12 @@
"electron-store": "8.2.0",
"electron-updater": "6.3.9",
"eslint": "8.57.1",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-typescript": "3.7.0",
"eslint-config-prettier": "10.1.2",
"eslint-import-resolver-typescript": "3.10.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-rxjs": "5.0.3",
"eslint-plugin-rxjs-angular": "2.0.1",
"eslint-plugin-storybook": "0.11.2",
"eslint-plugin-storybook": "0.12.0",
"eslint-plugin-tailwindcss": "3.18.0",
"html-loader": "5.1.0",
"html-webpack-injector": "1.1.4",
@@ -116,7 +116,7 @@
"jest-mock-extended": "3.0.7",
"jest-preset-angular": "14.1.1",
"json5": "2.2.3",
"lint-staged": "15.4.1",
"lint-staged": "15.5.1",
"mini-css-extract-plugin": "2.9.2",
"nx": "20.8.0",
"postcss": "8.5.1",
@@ -136,7 +136,7 @@
"tsconfig-paths-webpack-plugin": "4.2.0",
"type-fest": "2.19.0",
"typescript": "5.4.2",
"typescript-eslint": "8.20.0",
"typescript-eslint": "8.30.1",
"typescript-strict-plugin": "2.4.4",
"url": "0.11.4",
"util": "0.12.5",