diff --git a/apps/browser/src/_locales/en/messages.json b/apps/browser/src/_locales/en/messages.json index 3bcca910194..e82a6f0d107 100644 --- a/apps/browser/src/_locales/en/messages.json +++ b/apps/browser/src/_locales/en/messages.json @@ -5600,17 +5600,37 @@ "hasItemsVaultNudgeTitle": { "message": "Welcome to your vault!" }, - "phishingPageTitle":{ - "message": "Phishing website" + "phishingPageTitleV2":{ + "message": "Phishing attempt detected" }, - "phishingPageCloseTab": { - "message": "Close tab" + "phishingPageSummary": { + "message": "The site you are attempting to visit is a known malicious site and a security risk." }, - "phishingPageContinue": { - "message": "Continue" + "phishingPageCloseTabV2": { + "message": "Close this tab" }, - "phishingPageLearnWhy": { - "message": "Why are you seeing this?" + "phishingPageContinueV2": { + "message": "Continue to this site (not recommended)" + }, + "phishingPageExplanation1": { + "message": "This site was found in ", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name follows this." + }, + "phishingPageExplanation2": { + "message": ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + "description": "This is in multiple parts to allow for bold text in the middle of the sentence. A proper name precedes this." + }, + "phishingPageLearnMore" : { + "message": "Learn more about phishing detection" + }, + "protectedBy": { + "message": "Protected by $PRODUCT$", + "placeholders": { + "product": { + "content": "$1", + "example": "Bitwarden Phishing Blocker" + } + } }, "hasItemsVaultNudgeBodyOne": { "message": "Autofill items for the current page" diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 21609432a4b..4cd61ebead1 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -990,6 +990,7 @@ export default class MainBackground { this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( + this.accountService, this.keyService, this.i18nService, this.keyGenerationService, diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html b/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html deleted file mode 100644 index 5ea79c3f840..00000000000 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.html +++ /dev/null @@ -1,4 +0,0 @@ -{{ "phishingPageLearnWhy"| i18n}} - - {{ "learnMore" | i18n }} - diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html index f6e3baf8766..5cac567c5c3 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.html @@ -1,13 +1,46 @@ -
- - {{ "phishingPageTitle" | i18n }} - - +
+
+ +

{{ "phishingPageTitleV2" | i18n }}

+
- - +
+ +

{{ "phishingPageSummary" | i18n }}

+ + + {{ phishingHost$ | async }} + + + +

+ {{ "phishingPageExplanation1" | i18n }}Phishing.Database{{ "phishingPageExplanation2" | i18n }} +

+ + + {{ "phishingPageLearnMore" | i18n }} + +
+ +
+ + +
diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts index dc6ab2d329e..4712c94c89e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.component.ts @@ -1,10 +1,10 @@ // eslint-disable-next-line no-restricted-imports import { CommonModule } from "@angular/common"; // eslint-disable-next-line no-restricted-imports -import { Component, OnDestroy } from "@angular/core"; +import { Component, inject } from "@angular/core"; // eslint-disable-next-line no-restricted-imports import { ActivatedRoute, RouterModule } from "@angular/router"; -import { Subject, takeUntil } from "rxjs"; +import { map } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { @@ -13,12 +13,16 @@ import { CheckboxModule, FormFieldModule, IconModule, + IconTileComponent, LinkModule, + CalloutComponent, + TypographyModule, } from "@bitwarden/components"; import { PhishingDetectionService } from "../services/phishing-detection.service"; @Component({ + selector: "dirt-phishing-warning", standalone: true, templateUrl: "phishing-warning.component.html", imports: [ @@ -31,18 +35,16 @@ import { PhishingDetectionService } from "../services/phishing-detection.service CheckboxModule, ButtonModule, RouterModule, + IconTileComponent, + CalloutComponent, + TypographyModule, ], }) -export class PhishingWarning implements OnDestroy { - phishingHost = ""; - - private destroy$ = new Subject(); - - constructor(private activatedRoute: ActivatedRoute) { - this.activatedRoute.queryParamMap.pipe(takeUntil(this.destroy$)).subscribe((params) => { - this.phishingHost = params.get("phishingHost") || ""; - }); - } +export class PhishingWarning { + private activatedRoute = inject(ActivatedRoute); + protected phishingHost$ = this.activatedRoute.queryParamMap.pipe( + map((params) => params.get("phishingHost") || ""), + ); async closeTab() { await PhishingDetectionService.requestClosePhishingWarningPage(); @@ -50,9 +52,4 @@ export class PhishingWarning implements OnDestroy { async continueAnyway() { await PhishingDetectionService.requestContinueToDangerousUrl(); } - - ngOnDestroy(): void { - this.destroy$.next(); - this.destroy$.complete(); - } } diff --git a/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts new file mode 100644 index 00000000000..30d3b7faeee --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/phishing-warning.stories.ts @@ -0,0 +1,137 @@ +// TODO: This needs to be dealt with by moving this folder or updating the lint rule. +/* eslint-disable no-restricted-imports */ +import { ActivatedRoute, RouterModule } from "@angular/router"; +import { Meta, StoryObj, moduleMetadata } from "@storybook/angular"; +import { BehaviorSubject, of } from "rxjs"; + +import { DeactivatedOrg } from "@bitwarden/assets/svg"; +import { ClientType } from "@bitwarden/common/enums"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; +import { AnonLayoutComponent, I18nMockService } from "@bitwarden/components"; + +import { PhishingWarning } from "./phishing-warning.component"; +import { ProtectedByComponent } from "./protected-by-component"; + +class MockPlatformUtilsService implements Partial { + getApplicationVersion = () => Promise.resolve("Version 2024.1.1"); + getClientType = () => ClientType.Web; +} + +/** + * Helper function to create ActivatedRoute mock with query parameters + */ +function mockActivatedRoute(queryParams: Record) { + return { + provide: ActivatedRoute, + useValue: { + queryParamMap: of({ + get: (key: string) => queryParams[key] || null, + }), + queryParams: of(queryParams), + }, + }; +} + +type StoryArgs = { + phishingHost: string; +}; + +export default { + title: "Browser/DIRT/Phishing Warning", + component: PhishingWarning, + decorators: [ + moduleMetadata({ + imports: [AnonLayoutComponent, ProtectedByComponent, RouterModule], + providers: [ + { + provide: PlatformUtilsService, + useClass: MockPlatformUtilsService, + }, + { + provide: I18nService, + useFactory: () => + new I18nMockService({ + accessing: "Accessing", + appLogoLabel: "Bitwarden logo", + phishingPageTitleV2: "Phishing attempt detected", + phishingPageCloseTabV2: "Close this tab", + phishingPageSummary: + "The site you are attempting to visit is a known malicious site and a security risk.", + phishingPageContinueV2: "Continue to this site (not recommended)", + phishingPageExplanation1: "This site was found in ", + phishingPageExplanation2: + ", an open-source list of known phishing sites used for stealing personal and sensitive information.", + phishingPageLearnMore: "Learn more about phishing detection", + protectedBy: (product) => `Protected by ${product}`, + learnMore: "Learn more", + danger: "error", + }), + }, + { + provide: EnvironmentService, + useValue: { + environment$: new BehaviorSubject({ + getHostname() { + return "bitwarden.com"; + }, + }).asObservable(), + }, + }, + mockActivatedRoute({ phishingHost: "malicious-example.com" }), + ], + }), + ], + render: (args) => ({ + props: args, + template: /*html*/ ` + + + + + `, + }), + argTypes: { + phishingHost: { + control: "text", + description: "The suspicious host that was blocked", + }, + }, + args: { + phishingHost: "malicious-example.com", + pageIcon: DeactivatedOrg, + }, +} satisfies Meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + phishingHost: "malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [mockActivatedRoute({ phishingHost: "malicious-example.com" })], + }), + ], +}; + +export const LongHostname: Story = { + args: { + phishingHost: "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }, + decorators: [ + moduleMetadata({ + providers: [ + mockActivatedRoute({ + phishingHost: + "very-long-suspicious-phishing-domain-name-that-might-wrap.malicious-example.com", + }), + ], + }), + ], +}; diff --git a/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html new file mode 100644 index 00000000000..d9f26bc9c90 --- /dev/null +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.html @@ -0,0 +1 @@ +{{ "protectedBy" | i18n: "Bitwarden Phishing Blocker" }} diff --git a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts similarity index 63% rename from apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts rename to apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts index 1a1e6059204..298c7acd38e 100644 --- a/apps/browser/src/dirt/phishing-detection/pages/learn-more-component.ts +++ b/apps/browser/src/dirt/phishing-detection/pages/protected-by-component.ts @@ -4,13 +4,12 @@ import { CommonModule } from "@angular/common"; import { Component } from "@angular/core"; import { JslibModule } from "@bitwarden/angular/jslib.module"; -import { ButtonModule } from "@bitwarden/components"; +import { ButtonModule, LinkModule } from "@bitwarden/components"; @Component({ + selector: "dirt-phishing-protected-by", standalone: true, - templateUrl: "learn-more-component.html", - imports: [CommonModule, CommonModule, JslibModule, ButtonModule], + templateUrl: "protected-by-component.html", + imports: [CommonModule, CommonModule, JslibModule, ButtonModule, LinkModule], }) -export class LearnMoreComponent { - constructor() {} -} +export class ProtectedByComponent {} diff --git a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts index 54245ae17b4..179431b155c 100644 --- a/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts +++ b/apps/browser/src/dirt/phishing-detection/services/phishing-detection.service.ts @@ -116,15 +116,15 @@ export class PhishingDetectionService { /** * Sends a message to the phishing detection service to close the warning page */ - static requestClosePhishingWarningPage(): void { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); + static async requestClosePhishingWarningPage() { + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Close }); } /** * Sends a message to the phishing detection service to continue to the caught url */ static async requestContinueToDangerousUrl() { - void chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); + await chrome.runtime.sendMessage({ command: PhishingDetectionMessage.Continue }); } /** diff --git a/apps/browser/src/popup/app-routing.module.ts b/apps/browser/src/popup/app-routing.module.ts index 17a812f451c..cb5e597e78c 100644 --- a/apps/browser/src/popup/app-routing.module.ts +++ b/apps/browser/src/popup/app-routing.module.ts @@ -24,7 +24,6 @@ import { VaultIcon, LockIcon, TwoFactorAuthSecurityKeyIcon, - DeactivatedOrg, } from "@bitwarden/assets/svg"; import { LoginComponent, @@ -54,8 +53,8 @@ import { BlockedDomainsComponent } from "../autofill/popup/settings/blocked-doma import { ExcludedDomainsComponent } from "../autofill/popup/settings/excluded-domains.component"; import { NotificationsSettingsComponent } from "../autofill/popup/settings/notifications.component"; import { PremiumV2Component } from "../billing/popup/settings/premium-v2.component"; -import { LearnMoreComponent } from "../dirt/phishing-detection/pages/learn-more-component"; import { PhishingWarning } from "../dirt/phishing-detection/pages/phishing-warning.component"; +import { ProtectedByComponent } from "../dirt/phishing-detection/pages/protected-by-component"; import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component"; import BrowserPopupUtils from "../platform/browser/browser-popup-utils"; import { popupRouterCacheGuard } from "../platform/popup/view-cache/popup-router-cache.service"; @@ -718,14 +717,13 @@ const routes: Routes = [ }, { path: "", - component: LearnMoreComponent, + component: ProtectedByComponent, outlet: "secondary", }, ], data: { - pageIcon: DeactivatedOrg, - pageTitle: "Bitwarden blocked it!", - pageSubtitle: "Bitwarden blocked a known phishing site from loading.", + hideIcon: true, + hideBackgroundIllustration: true, showReadonlyHostname: true, } satisfies AnonLayoutWrapperData, }, diff --git a/apps/browser/src/popup/scss/base.scss b/apps/browser/src/popup/scss/base.scss index b3d14e65061..01b9d3f05d5 100644 --- a/apps/browser/src/popup/scss/base.scss +++ b/apps/browser/src/popup/scss/base.scss @@ -382,7 +382,7 @@ app-root { } } -main:not(popup-page main) { +main:not(popup-page main):not(auth-anon-layout main) { position: absolute; top: 44px; bottom: 0; diff --git a/apps/cli/src/oss-serve-configurator.ts b/apps/cli/src/oss-serve-configurator.ts index 3c80d12af2f..ccc2f3705b9 100644 --- a/apps/cli/src/oss-serve-configurator.ts +++ b/apps/cli/src/oss-serve-configurator.ts @@ -211,6 +211,7 @@ export class OssServeConfigurator { this.serviceContainer.sendService, this.serviceContainer.sendApiService, this.serviceContainer.environmentService, + this.serviceContainer.accountService, ); } diff --git a/apps/cli/src/service-container/service-container.ts b/apps/cli/src/service-container/service-container.ts index d13d251bce0..26d07b774b2 100644 --- a/apps/cli/src/service-container/service-container.ts +++ b/apps/cli/src/service-container/service-container.ts @@ -552,6 +552,7 @@ export class ServiceContainer { this.sendStateProvider = new SendStateProvider(this.stateProvider); this.sendService = new SendService( + this.accountService, this.keyService, this.i18nService, this.keyGenerationService, diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index d4f544d39b7..7803f6f94d4 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -6,6 +6,7 @@ import * as path from "path"; import { firstValueFrom, switchMap } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; @@ -142,7 +143,8 @@ export class SendCreateCommand { await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); - const decSend = await newSend.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decSend = await newSend.decrypt(activeUserId); const env = await firstValueFrom(this.environmentService.environment$); const res = new SendResponse(decSend, env.getWebVaultUrl()); return Response.success(res); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 09f89041cc5..bf53c8a5cb9 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendType } from "@bitwarden/common/tools/send/enums/send-type"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -83,7 +84,8 @@ export class SendEditCommand { return Response.error("Premium status is required to use this feature."); } - let sendView = await send.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + let sendView = await send.decrypt(activeUserId); sendView = SendResponse.toView(req, sendView); try { diff --git a/apps/cli/src/tools/send/commands/get.command.ts b/apps/cli/src/tools/send/commands/get.command.ts index 2d6cc93c781..d5248733490 100644 --- a/apps/cli/src/tools/send/commands/get.command.ts +++ b/apps/cli/src/tools/send/commands/get.command.ts @@ -12,6 +12,7 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; import { SearchService } from "@bitwarden/common/vault/abstractions/search.service"; +import { isGuid } from "@bitwarden/guid"; import { DownloadCommand } from "../../../commands/download.command"; import { Response } from "../../../models/response"; @@ -74,13 +75,13 @@ export class SendGetCommand extends DownloadCommand { } private async getSendView(id: string): Promise { - if (Utils.isGuid(id)) { + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + if (isGuid(id)) { const send = await this.sendService.getFromState(id); if (send != null) { - return await send.decrypt(); + return await send.decrypt(activeUserId); } } else if (id.trim() !== "") { - const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); let sends = await this.sendService.getAllDecryptedFromState(activeUserId); sends = this.searchService.searchSends(sends, id); if (sends.length > 1) { diff --git a/apps/cli/src/tools/send/commands/remove-password.command.ts b/apps/cli/src/tools/send/commands/remove-password.command.ts index 4f7add366be..74676d84a77 100644 --- a/apps/cli/src/tools/send/commands/remove-password.command.ts +++ b/apps/cli/src/tools/send/commands/remove-password.command.ts @@ -2,6 +2,8 @@ // @ts-strict-ignore import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendService } from "@bitwarden/common/tools/send/services//send.service.abstraction"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -14,6 +16,7 @@ export class SendRemovePasswordCommand { private sendService: SendService, private sendApiService: SendApiService, private environmentService: EnvironmentService, + private accountService: AccountService, ) {} async run(id: string) { @@ -21,7 +24,8 @@ export class SendRemovePasswordCommand { await this.sendApiService.removePassword(id); const updatedSend = await firstValueFrom(this.sendService.get$(id)); - const decSend = await updatedSend.decrypt(); + const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + const decSend = await updatedSend.decrypt(activeUserId); const env = await firstValueFrom(this.environmentService.environment$); const webVaultUrl = env.getWebVaultUrl(); const res = new SendResponse(decSend, webVaultUrl); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 2ea73f8c5c8..6c643e04cd0 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -297,6 +297,7 @@ export class SendProgram extends BaseProgram { this.serviceContainer.sendService, this.serviceContainer.sendApiService, this.serviceContainer.environmentService, + this.serviceContainer.accountService, ); const response = await cmd.run(id); this.processResponse(response); diff --git a/apps/desktop/src/app/tools/send/add-edit.component.ts b/apps/desktop/src/app/tools/send/add-edit.component.ts index 025bab66539..bee4f920eda 100644 --- a/apps/desktop/src/app/tools/send/add-edit.component.ts +++ b/apps/desktop/src/app/tools/send/add-edit.component.ts @@ -3,11 +3,13 @@ import { CommonModule, DatePipe } from "@angular/common"; import { Component } from "@angular/core"; import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; import { JslibModule } from "@bitwarden/angular/jslib.module"; import { AddEditComponent as BaseAddEditComponent } from "@bitwarden/angular/tools/send/add-edit.component"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -63,7 +65,8 @@ export class AddEditComponent extends BaseAddEditComponent { async refresh() { const send = await this.loadSend(); - this.send = await send.decrypt(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + this.send = await send.decrypt(userId); this.updateFormValues(); this.hasPassword = this.send.password != null && this.send.password.trim() !== ""; } diff --git a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts index 14baee51b90..75a84919b07 100644 --- a/apps/desktop/src/platform/services/sso-localhost-callback.service.ts +++ b/apps/desktop/src/platform/services/sso-localhost-callback.service.ts @@ -12,10 +12,13 @@ import { MessageSender } from "@bitwarden/common/platform/messaging"; /** * The SSO Localhost login service uses a local host listener as fallback in case scheme handling deeplinks does not work. - * This way it is possible to log in with SSO on appimage, snap, and electron dev using the same methods that the cli uses. + * This way it is possible to log in with SSO on appimage and electron dev using the same methods that the cli uses. */ export class SSOLocalhostCallbackService { private ssoRedirectUri = ""; + // We will only track one server at a time for use-case and performance considerations. + // This will result in a last-one-wins behavior if multiple SSO flows are started simultaneously. + private currentServer: http.Server | null = null; constructor( private environmentService: EnvironmentService, @@ -23,11 +26,30 @@ export class SSOLocalhostCallbackService { private ssoUrlService: SsoUrlService, ) { ipcMain.handle("openSsoPrompt", async (event, { codeChallenge, state, email }) => { - const { ssoCode, recvState } = await this.openSsoPrompt(codeChallenge, state, email); - this.messagingService.send("ssoCallback", { - code: ssoCode, - state: recvState, - redirectUri: this.ssoRedirectUri, + // Close any existing server before starting new one + if (this.currentServer) { + await this.closeCurrentServer(); + } + + return this.openSsoPrompt(codeChallenge, state, email).then(({ ssoCode, recvState }) => { + this.messagingService.send("ssoCallback", { + code: ssoCode, + state: recvState, + redirectUri: this.ssoRedirectUri, + }); + }); + }); + } + + private async closeCurrentServer(): Promise { + if (!this.currentServer) { + return; + } + + return new Promise((resolve) => { + this.currentServer!.close(() => { + this.currentServer = null; + resolve(); }); }); } @@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService { "

You may now close this tab and return to the app.

" + "", ); + this.currentServer = null; callbackServer.close(() => resolve({ ssoCode: code, @@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService { "

You may now close this tab and return to the app.

" + "", ); + this.currentServer = null; callbackServer.close(() => reject()); } }); - let foundPort = false; - const webUrl = env.getWebVaultUrl(); - for (let port = 8065; port <= 8070; port++) { - try { - this.ssoRedirectUri = "http://localhost:" + port; - const ssoUrl = this.ssoUrlService.buildSsoUrl( - webUrl, - ClientType.Desktop, - this.ssoRedirectUri, - state, - codeChallenge, - email, - ); - callbackServer.listen(port, () => { - this.messagingService.send("launchUri", { - url: ssoUrl, - }); - }); - foundPort = true; - break; - } catch { - // Ignore error since we run the same command up to 5 times. - } - } - if (!foundPort) { - reject(); - } + // Store reference to current server + this.currentServer = callbackServer; - // after 5 minutes, close the server + const webUrl = env.getWebVaultUrl(); + + const tryNextPort = (port: number) => { + if (port > 8070) { + this.currentServer = null; + reject("All available SSO ports in use"); + return; + } + + this.ssoRedirectUri = "http://localhost:" + port; + const ssoUrl = this.ssoUrlService.buildSsoUrl( + webUrl, + ClientType.Desktop, + this.ssoRedirectUri, + state, + codeChallenge, + email, + ); + + // Set up error handler before attempting to listen + callbackServer.once("error", (err: any) => { + if (err.code === "EADDRINUSE") { + // Port is in use, try next port + tryNextPort(port + 1); + } else { + // Another error - reject and set the current server to null + // (one server alive at a time) + this.currentServer = null; + reject(); + } + }); + + // Attempt to listen on the port + callbackServer.listen(port, () => { + // Success - remove error listener and launch SSO + callbackServer.removeAllListeners("error"); + + this.messagingService.send("launchUri", { + url: ssoUrl, + }); + }); + }; + + // Start trying from port 8065 + tryNextPort(8065); + + // Don't allow any server to stay up for more than 5 minutes; + // this gives plenty of time to complete SSO but ensures we don't + // have a server running indefinitely. setTimeout( () => { + if (this.currentServer === callbackServer) { + this.currentServer = null; + } callbackServer.close(() => reject()); }, 5 * 60 * 1000, diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts new file mode 100644 index 00000000000..902fc2eb5a2 --- /dev/null +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.spec.ts @@ -0,0 +1,139 @@ +import { ScrollingModule } from "@angular/cdk/scrolling"; +import { TestBed } from "@angular/core/testing"; +import { of } from "rxjs"; + +import { CollectionView } from "@bitwarden/admin-console/common"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service"; +import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service"; +import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils"; +import { MenuModule, TableModule } from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { VaultItem } from "./vault-item"; +import { VaultItemsComponent } from "./vault-items.component"; + +describe("VaultItemsComponent", () => { + let component: VaultItemsComponent; + + const cipher1: Partial = { + id: "cipher-1", + name: "Cipher 1", + organizationId: undefined, + }; + + const cipher2: Partial = { + id: "cipher-2", + name: "Cipher 2", + organizationId: undefined, + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [VaultItemsComponent], + imports: [ScrollingModule, TableModule, I18nPipe, MenuModule], + providers: [ + { + provide: CipherAuthorizationService, + useValue: { + canDeleteCipher$: jest.fn(), + canRestoreCipher$: jest.fn(), + }, + }, + { + provide: RestrictedItemTypesService, + useValue: { + restricted$: of([]), + isCipherRestricted: jest.fn().mockReturnValue(false), + }, + }, + { + provide: I18nService, + useValue: { + t: (key: string) => key, + }, + }, + ], + }); + + const fixture = TestBed.createComponent(VaultItemsComponent); + component = fixture.componentInstance; + }); + + describe("bulkUnarchiveAllowed", () => { + it("returns false when no items are selected", () => { + component["selection"].clear(); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns false when selecting collections only", () => { + const collection1 = { id: "col-1", name: "Collection 1" } as CollectionView; + const collection2 = { id: "col-2", name: "Collection 2" } as CollectionView; + + const items: VaultItem[] = [ + { collection: collection1 }, + { collection: collection2 }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns true when selecting archived ciphers without organization", () => { + const archivedCipher1 = { + ...cipher1, + archivedDate: new Date("2024-01-01"), + }; + const archivedCipher2 = { + ...cipher2, + archivedDate: new Date("2024-01-02"), + }; + + const items: VaultItem[] = [ + { cipher: archivedCipher1 as CipherView }, + { cipher: archivedCipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(true); + }); + + it("returns false when any selected cipher has an organizationId", () => { + const archivedCipher1: Partial = { + ...cipher1, + archivedDate: new Date("2024-01-01"), + organizationId: undefined, + }; + + const archivedCipher2: Partial = { + ...cipher2, + archivedDate: new Date("2024-01-02"), + organizationId: "org-1", + }; + + const items: VaultItem[] = [ + { cipher: archivedCipher1 as CipherView }, + { cipher: archivedCipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + + it("returns false when any selected cipher is not archived", () => { + const items: VaultItem[] = [ + { cipher: cipher1 as CipherView }, + { cipher: cipher2 as CipherView }, + ]; + + component["selection"].select(...items); + + expect(component.bulkUnarchiveAllowed).toBe(false); + }); + }); +}); diff --git a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts index b40124c39e4..67a5069034f 100644 --- a/apps/web/src/app/vault/components/vault-items/vault-items.component.ts +++ b/apps/web/src/app/vault/components/vault-items/vault-items.component.ts @@ -213,7 +213,7 @@ export class VaultItemsComponent { } return !this.selection.selected.find( - (item) => !item.cipher.archivedDate || item.cipher.organizationId, + (item) => !item.cipher?.archivedDate || item.cipher?.organizationId, ); } diff --git a/libs/angular/src/scss/bwicons/styles/style.scss b/libs/angular/src/scss/bwicons/styles/style.scss index 755088a92a0..93f5856e3df 100644 --- a/libs/angular/src/scss/bwicons/styles/style.scss +++ b/libs/angular/src/scss/bwicons/styles/style.scss @@ -100,6 +100,7 @@ $icomoon-font-path: "~@bitwarden/angular/src/scss/bwicons/fonts/" !default; } // For new icons - add their glyph name and value to the map below +// Also add to `libs/components/src/shared/icon.ts` $icons: ( "angle-down": "\e900", "angle-left": "\e901", diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index c66c74a3ea9..53da6e9fd8e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -174,10 +174,12 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; @@ -791,6 +793,7 @@ const safeProviders: SafeProvider[] = [ provide: InternalSendService, useClass: SendService, deps: [ + AccountServiceAbstraction, KeyService, I18nServiceAbstraction, KeyGenerationService, @@ -1076,6 +1079,11 @@ const safeProviders: SafeProvider[] = [ provide: MasterPasswordServiceAbstraction, useExisting: InternalMasterPasswordServiceAbstraction, }), + safeProvider({ + provide: MasterPasswordUnlockService, + useClass: DefaultMasterPasswordUnlockService, + deps: [InternalMasterPasswordServiceAbstraction, KeyService], + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, diff --git a/libs/angular/src/tools/send/add-edit.component.ts b/libs/angular/src/tools/send/add-edit.component.ts index 221b751528a..f87b5f9bf86 100644 --- a/libs/angular/src/tools/send/add-edit.component.ts +++ b/libs/angular/src/tools/send/add-edit.component.ts @@ -260,12 +260,19 @@ export class AddEditComponent implements OnInit, OnDestroy { }); if (this.editMode) { - this.sendService - .get$(this.sendId) + this.accountService.activeAccount$ .pipe( - //Promise.reject will complete the BehaviourSubject, if desktop starts relying only on BehaviourSubject, this should be changed. - concatMap((s) => - s instanceof Send ? s.decrypt() : Promise.reject(new Error("Failed to load send.")), + getUserId, + switchMap((userId) => + this.sendService + .get$(this.sendId) + .pipe( + concatMap((s) => + s instanceof Send + ? s.decrypt(userId) + : Promise.reject(new Error("Failed to load send.")), + ), + ), ), takeUntil(this.destroy$), ) diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 631875258ec..bf156a96c60 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -33,6 +33,7 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", + UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", @@ -112,6 +113,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, + [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts new file mode 100644 index 00000000000..4448206b2f6 --- /dev/null +++ b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts @@ -0,0 +1,13 @@ +import { UserId } from "@bitwarden/user-core"; + +import { UserKey } from "../../../types/key"; + +export abstract class MasterPasswordUnlockService { + /** + * Unlocks the user's account using the master password. + * @param masterPassword The master password provided by the user. + * @param userId The ID of the active user. + * @returns the user's decrypted userKey. + */ + abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise; +} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 8ef14904bce..f982c2c5ce8 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas masterPasswordUnlockData: MasterPasswordUnlockData, userId: UserId, ): Promise; + + /** + * An observable that emits the master password unlock data for the target user. + * @param userId The user ID. + * @throws If the user ID is null or undefined. + * @returns An observable that emits the master password unlock data or null if not found. + */ + abstract masterPasswordUnlockData$(userId: UserId): Observable; } diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts new file mode 100644 index 00000000000..75668e8e6bd --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts @@ -0,0 +1,154 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { newGuid } from "@bitwarden/guid"; +// eslint-disable-next-line no-restricted-imports +import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; + +import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service"; + +describe("DefaultMasterPasswordUnlockService", () => { + let sut: DefaultMasterPasswordUnlockService; + + let masterPasswordService: MockProxy; + let keyService: MockProxy; + + const mockMasterPassword = "testExample"; + const mockUserId = newGuid() as UserId; + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData( + "user@example.com" as MasterPasswordSalt, + new Argon2KdfConfig(100000, 64, 1), + "encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + ); + + //Legacy data for tests + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + const mockKeyHash = "localKeyHash"; + + beforeEach(() => { + masterPasswordService = mock(); + keyService = mock(); + + sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService); + + masterPasswordService.masterPasswordUnlockData$.mockReturnValue( + of(mockMasterPasswordUnlockData), + ); + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey); + + // Legacy state mocking + keyService.makeMasterKey.mockResolvedValue(mockMasterKey); + keyService.hashMasterKey.mockResolvedValue(mockKeyHash); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("unlockWithMasterPassword", () => { + test.each([null as unknown as string, undefined as unknown as string, ""])( + "throws when the provided master password is %s", + async (masterPassword) => { + await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow( + "Master password is required", + ); + expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled(); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided master password is %s", + async (userId) => { + await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow( + "User ID is required", + ); + }, + ); + + it("throws an error when the user doesn't have masterPasswordUnlockData", async () => { + masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null)); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master password unlock data was not found for the user " + mockUserId, + ); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }); + + it("returns userKey successfully", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + }); + + it("sets legacy state on success", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterKey, + HashPurpose.LocalAuthorization, + ); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId); + }); + + it("throws an error if masterKey construction fails", async () => { + keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master key could not be created to set legacy master password state.", + ); + + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts new file mode 100644 index 00000000000..87114000abf --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts @@ -0,0 +1,75 @@ +import { firstValueFrom } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { UserKey } from "../../../types/key"; +import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { MasterPasswordUnlockData } from "../types/master-password.types"; + +export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService { + constructor( + private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction, + private readonly keyService: KeyService, + ) {} + + async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise { + this.validateInput(masterPassword, userId); + + const masterPasswordUnlockData = await firstValueFrom( + this.masterPasswordService.masterPasswordUnlockData$(userId), + ); + + if (masterPasswordUnlockData == null) { + throw new Error("Master password unlock data was not found for the user " + userId); + } + + const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData( + masterPassword, + masterPasswordUnlockData, + ); + + await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId); + + return userKey; + } + + private validateInput(masterPassword: string, userId: UserId): void { + if (masterPassword == null || masterPassword === "") { + throw new Error("Master password is required"); + } + if (userId == null) { + throw new Error("User ID is required"); + } + } + + // Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state. + // This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well. + private async setLegacyState( + masterPassword: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + const masterKey = await this.keyService.makeMasterKey( + masterPassword, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + if (!masterKey) { + throw new Error("Master key could not be created to set legacy master password state."); + } + + const localKeyHash = await this.keyService.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); + await this.masterPasswordService.setMasterKey(masterKey, userId); + } +} diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 81aea5e480a..5db7f178b18 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA ): Promise { return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); } + + masterPasswordUnlockData$(userId: UserId): Observable { + return this.mock.masterPasswordUnlockData$(userId); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index 02b4e9a895a..f5fee3be4c5 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -1,6 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden import { FakeAccountService, + FakeStateProvider, makeSymmetricCryptoKey, mockAccountServiceWith, } from "../../../../spec"; @@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; @@ -30,25 +29,30 @@ import { MasterPasswordUnlockData, } from "../types/master-password.types"; -import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service"; +import { + FORCE_SET_PASSWORD_REASON, + MASTER_KEY_ENCRYPTED_USER_KEY, + MASTER_PASSWORD_UNLOCK_KEY, + MasterPasswordService, +} from "./master-password.service"; describe("MasterPasswordService", () => { let sut: MasterPasswordService; - let stateProvider: MockProxy; let stateService: MockProxy; let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; let cryptoFunctionService: MockProxy; let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; const userId = "00000000-0000-0000-0000-000000000000" as UserId; - const mockUserState = { - state$: of(null), - update: jest.fn().mockResolvedValue(null), - }; + const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); + const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1); const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2); const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3); @@ -58,17 +62,13 @@ describe("MasterPasswordService", () => { "2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY="; beforeEach(() => { - stateProvider = mock(); stateService = mock(); keyGenerationService = mock(); encryptService = mock(); logService = mock(); cryptoFunctionService = mock(); accountService = mockAccountServiceWith(userId); - - stateProvider.getUser.mockReturnValue(mockUserState as any); - - mockUserState.update.mockReset(); + stateProvider = new FakeStateProvider(accountService); sut = new MasterPasswordService( stateProvider, @@ -88,6 +88,10 @@ describe("MasterPasswordService", () => { }); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe("saltForUser$", () => { it("throws when userid not present", async () => { expect(() => { @@ -111,12 +115,10 @@ describe("MasterPasswordService", () => { await sut.setForceSetPasswordReason(reason, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - - // Call the update function to verify it returns the correct reason - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toBe(reason); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(reason); }); it("throws an error if reason is null", async () => { @@ -132,31 +134,29 @@ describe("MasterPasswordService", () => { }); it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId); - expect(mockUserState.update).not.toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset); }); it("allows overwriting AdminForcePasswordReset with None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - expect(mockUserState.update).toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.None); }); }); describe("decryptUserKeyWithMasterKey", () => { @@ -227,10 +227,10 @@ describe("MasterPasswordService", () => { await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(encryptedKey.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + expect(state).toEqual(encryptedKey.toJSON()); }); }); @@ -328,11 +328,6 @@ describe("MasterPasswordService", () => { }); describe("setMasterPasswordUnlockData", () => { - const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); - const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); - const salt = "test@bitwarden.com" as MasterPasswordSalt; - const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; - it.each([kdfPBKDF2, kdfArgon2])( "sets the master password unlock data kdf %o in the state", async (kdfConfig) => { @@ -345,11 +340,10 @@ describe("MasterPasswordService", () => { await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); - expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY); - expect(mockUserState.update).toHaveBeenCalled(); - - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$, + ); + expect(state).toEqual(masterPasswordUnlockData.toJSON()); }, ); @@ -373,6 +367,40 @@ describe("MasterPasswordService", () => { }); }); + describe("masterPasswordUnlockData$", () => { + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined."); + }, + ); + + it("returns null when no data is set", async () => { + stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toBeNull(); + }); + + it.each([kdfPBKDF2, kdfArgon2])( + "returns the master password unlock data for kdf %o from state", + async (kdfConfig) => { + const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData( + "test-password", + kdfConfig, + salt, + userKey, + ); + await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toEqual(masterPasswordUnlockData.toJSON()); + }, + ); + }); + describe("MASTER_PASSWORD_UNLOCK_KEY", () => { it("has the correct configuration", () => { expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined(); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 9f7e054d64c..5cb6bb96a45 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "mas }); /** Disk to persist through lock */ -const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( +export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( MASTER_PASSWORD_DISK, "masterKeyEncryptedUserKey", { @@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( ); /** Disk to persist through lock and account switches */ -const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( +export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( MASTER_PASSWORD_DISK, "forceSetPasswordReason", { @@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr .getUser(userId, MASTER_PASSWORD_UNLOCK_KEY) .update(() => masterPasswordUnlockData.toJSON()); } + + masterPasswordUnlockData$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; + } } diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index e9b0ae7b3b8..d465aa97924 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,5 +1,7 @@ import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; +import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { KeyService } from "@bitwarden/key-management"; @@ -97,6 +99,7 @@ describe("Send", () => { const text = mock(); text.decrypt.mockResolvedValue("textView" as any); const userKey = new SymmetricCryptoKey(new Uint8Array(32)) as UserKey; + const userId = emptyGuid as UserId; const send = new Send(); send.id = "id"; @@ -120,11 +123,11 @@ describe("Send", () => { .calledWith(send.key, userKey) .mockResolvedValue(makeStaticByteArray(32)); keyService.makeSendKey.mockResolvedValue("cryptoKey" as any); - keyService.getUserKey.mockResolvedValue(userKey); + keyService.userKey$.calledWith(userId).mockReturnValue(of(userKey)); (window as any).bitwardenContainerService = new ContainerService(keyService, encryptService); - const view = await send.decrypt(); + const view = await send.decrypt(userId); expect(text.decrypt).toHaveBeenNthCalledWith(1, "cryptoKey"); expect(send.name.decrypt).toHaveBeenNthCalledWith( diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 48057aedd2d..48129d4314a 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -1,7 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; +import { UserId } from "@bitwarden/common/types/guid"; + import { EncString } from "../../../../key-management/crypto/models/enc-string"; import { Utils } from "../../../../platform/misc/utils"; import Domain from "../../../../platform/models/domain/domain-base"; @@ -73,22 +76,18 @@ export class Send extends Domain { } } - async decrypt(): Promise { - const model = new SendView(this); + async decrypt(userId: UserId): Promise { + if (!userId) { + throw new Error("User ID must not be null or undefined"); + } + const model = new SendView(this); const keyService = Utils.getContainerService().getKeyService(); const encryptService = Utils.getContainerService().getEncryptService(); - - try { - const sendKeyEncryptionKey = await keyService.getUserKey(); - // model.key is a seed used to derive a key, not a SymmetricCryptoKey - model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); - model.cryptoKey = await keyService.makeSendKey(model.key); - // FIXME: Remove when updating file. Eslint update - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // TODO: error? - } + const sendKeyEncryptionKey = await firstValueFrom(keyService.userKey$(userId)); + // model.key is a seed used to derive a key, not a SymmetricCryptoKey + model.key = await encryptService.decryptBytes(this.key, sendKeyEncryptionKey); + model.cryptoKey = await keyService.makeSendKey(model.key); await this.decryptObj(this, model, ["name", "notes"], null, model.cryptoKey); diff --git a/libs/common/src/tools/send/services/send.service.spec.ts b/libs/common/src/tools/send/services/send.service.spec.ts index 8b080089c3c..96fb2f43c88 100644 --- a/libs/common/src/tools/send/services/send.service.spec.ts +++ b/libs/common/src/tools/send/services/send.service.spec.ts @@ -86,6 +86,7 @@ describe("SendService", () => { decryptedState.nextState([testSendViewData("1", "Test Send")]); sendService = new SendService( + accountService, keyService, i18nService, keyGenerationService, diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index 2664b0d4351..810dbc05a2f 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Observable, concatMap, distinctUntilChanged, firstValueFrom, map } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports import { PBKDF2KdfConfig, KeyService } from "@bitwarden/key-management"; @@ -35,12 +36,16 @@ export class SendService implements InternalSendServiceAbstraction { map(([, record]) => Object.values(record || {}).map((data) => new Send(data))), ); sendViews$ = this.stateProvider.encryptedState$.pipe( - concatMap(([, record]) => - this.decryptSends(Object.values(record || {}).map((data) => new Send(data))), + concatMap(([userId, record]) => + this.decryptSends( + Object.values(record || {}).map((data) => new Send(data)), + userId, + ), ), ); constructor( + private accountService: AccountService, private keyService: KeyService, private i18nService: I18nService, private keyGenerationService: KeyGenerationService, @@ -89,8 +94,9 @@ export class SendService implements InternalSendServiceAbstraction { ); send.password = passwordKey.keyB64; } + const userId = (await firstValueFrom(this.accountService.activeAccount$)).id; if (userKey == null) { - userKey = await this.keyService.getUserKey(); + userKey = await firstValueFrom(this.keyService.userKey$(userId)); } // Key is not a SymmetricCryptoKey, but key material used to derive the cryptoKey send.key = await this.encryptService.encryptBytes(model.key, userKey); @@ -111,11 +117,12 @@ export class SendService implements InternalSendServiceAbstraction { model.file.fileName, file, model.cryptoKey, + userId, ); send.file.fileName = name; fileData = data; } else { - fileData = await this.parseFile(send, file, model.cryptoKey); + fileData = await this.parseFile(send, file, model.cryptoKey, userId); } } } @@ -208,6 +215,9 @@ export class SendService implements InternalSendServiceAbstraction { } async getAllDecryptedFromState(userId: UserId): Promise { + if (!userId) { + throw new Error("User ID must not be null or undefined"); + } let decSends = await this.stateProvider.getDecryptedSends(); if (decSends != null) { return decSends; @@ -222,7 +232,7 @@ export class SendService implements InternalSendServiceAbstraction { const promises: Promise[] = []; const sends = await this.getAll(); sends.forEach((send) => { - promises.push(send.decrypt().then((f) => decSends.push(f))); + promises.push(send.decrypt(userId).then((f) => decSends.push(f))); }); await Promise.all(promises); @@ -311,7 +321,12 @@ export class SendService implements InternalSendServiceAbstraction { return requests; } - private parseFile(send: Send, file: File, key: SymmetricCryptoKey): Promise { + private parseFile( + send: Send, + file: File, + key: SymmetricCryptoKey, + userId: UserId, + ): Promise { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsArrayBuffer(file); @@ -321,6 +336,7 @@ export class SendService implements InternalSendServiceAbstraction { file.name, evt.target.result as ArrayBuffer, key, + userId, ); send.file.fileName = name; resolve(data); @@ -338,17 +354,18 @@ export class SendService implements InternalSendServiceAbstraction { fileName: string, data: ArrayBuffer, key: SymmetricCryptoKey, + userId: UserId, ): Promise<[EncString, EncArrayBuffer]> { if (key == null) { - key = await this.keyService.getUserKey(); + key = await firstValueFrom(this.keyService.userKey$(userId)); } const encFileName = await this.encryptService.encryptString(fileName, key); const encFileData = await this.encryptService.encryptFileData(new Uint8Array(data), key); return [encFileName, encFileData]; } - private async decryptSends(sends: Send[]) { - const decryptSendPromises = sends.map((s) => s.decrypt()); + private async decryptSends(sends: Send[], userId: UserId) { + const decryptSendPromises = sends.map((s) => s.decrypt(userId)); const decryptedSends = await Promise.all(decryptSendPromises); decryptedSends.sort(Utils.getSortFunction(this.i18nService, "name")); diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.html b/libs/components/src/anon-layout/anon-layout-wrapper.component.html index 3509e4dcdb0..b08418c39a1 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.html +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.html @@ -6,6 +6,7 @@ [maxWidth]="maxWidth" [hideCardWrapper]="hideCardWrapper" [hideIcon]="hideIcon" + [hideBackgroundIllustration]="hideBackgroundIllustration" > diff --git a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts index 13c2e727477..5785609189c 100644 --- a/libs/components/src/anon-layout/anon-layout-wrapper.component.ts +++ b/libs/components/src/anon-layout/anon-layout-wrapper.component.ts @@ -44,6 +44,10 @@ export interface AnonLayoutWrapperData { * Hide the card that wraps the default content. Defaults to false. */ hideCardWrapper?: boolean; + /** + * Hides the background illustration. Defaults to false. + */ + hideBackgroundIllustration?: boolean; } @Component({ @@ -60,6 +64,7 @@ export class AnonLayoutWrapperComponent implements OnInit { protected maxWidth?: AnonLayoutMaxWidth | null; protected hideCardWrapper?: boolean | null; protected hideIcon?: boolean | null; + protected hideBackgroundIllustration?: boolean | null; constructor( private router: Router, @@ -117,6 +122,7 @@ export class AnonLayoutWrapperComponent implements OnInit { this.showReadonlyHostname = Boolean(firstChildRouteData["showReadonlyHostname"]); this.maxWidth = firstChildRouteData["maxWidth"]; this.hideCardWrapper = Boolean(firstChildRouteData["hideCardWrapper"]); + this.hideBackgroundIllustration = Boolean(firstChildRouteData["hideBackgroundIllustration"]); } private listenForServiceDataChanges() { @@ -157,6 +163,10 @@ export class AnonLayoutWrapperComponent implements OnInit { this.hideCardWrapper = data.hideCardWrapper; } + if (data.hideBackgroundIllustration !== undefined) { + this.hideBackgroundIllustration = data.hideBackgroundIllustration; + } + if (data.hideIcon !== undefined) { this.hideIcon = data.hideIcon; } @@ -188,5 +198,6 @@ export class AnonLayoutWrapperComponent implements OnInit { this.maxWidth = null; this.hideCardWrapper = null; this.hideIcon = null; + this.hideBackgroundIllustration = null; } } diff --git a/libs/components/src/anon-layout/anon-layout.component.html b/libs/components/src/anon-layout/anon-layout.component.html index c66647c482d..84ad8742051 100644 --- a/libs/components/src/anon-layout/anon-layout.component.html +++ b/libs/components/src/anon-layout/anon-layout.component.html @@ -68,16 +68,18 @@ -
- -
-
- -
+ @if (!hideBackgroundIllustration()) { +
+ +
+
+ +
+ } diff --git a/libs/components/src/anon-layout/anon-layout.component.ts b/libs/components/src/anon-layout/anon-layout.component.ts index c0beb5bce56..9decb7cb4f7 100644 --- a/libs/components/src/anon-layout/anon-layout.component.ts +++ b/libs/components/src/anon-layout/anon-layout.component.ts @@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges { readonly hideFooter = input(false); readonly hideIcon = input(false); readonly hideCardWrapper = input(false); + readonly hideBackgroundIllustration = input(false); /** * Max width of the anon layout title, subtitle, and content areas. diff --git a/libs/components/src/anon-layout/anon-layout.stories.ts b/libs/components/src/anon-layout/anon-layout.stories.ts index 15cce39d8b7..3593cb4f30e 100644 --- a/libs/components/src/anon-layout/anon-layout.stories.ts +++ b/libs/components/src/anon-layout/anon-layout.stories.ts @@ -79,6 +79,7 @@ export default { [hideIcon]="hideIcon" [hideLogo]="hideLogo" [hideFooter]="hideFooter" + [hideBackgroundIllustration]="hideBackgroundIllustration" >
Thin Content
@@ -125,6 +126,7 @@ export default { hideIcon: { control: "boolean" }, hideLogo: { control: "boolean" }, hideFooter: { control: "boolean" }, + hideBackgroundIllustration: { control: "boolean" }, contentLength: { control: "radio", @@ -145,6 +147,7 @@ export default { hideIcon: false, hideLogo: false, hideFooter: false, + hideBackgroundIllustration: false, contentLength: "normal", showSecondary: false, }, @@ -221,6 +224,10 @@ export const NoFooter: Story = { args: { hideFooter: true }, }; +export const NoBackgroundIllustration: Story = { + args: { hideBackgroundIllustration: true }, +}; + export const ReadonlyHostname: Story = { args: { showReadonlyHostname: true }, }; @@ -234,5 +241,6 @@ export const MinimalState: Story = { hideIcon: true, hideLogo: true, hideFooter: true, + hideBackgroundIllustration: true, }, }; diff --git a/libs/components/src/callout/callout.component.ts b/libs/components/src/callout/callout.component.ts index 3d6c9f480dc..fd6e3e13d0d 100644 --- a/libs/components/src/callout/callout.component.ts +++ b/libs/components/src/callout/callout.component.ts @@ -36,12 +36,18 @@ let nextId = 0; export class CalloutComponent { readonly type = input("info"); readonly icon = input(); - readonly title = input(); + readonly title = input(); readonly truncate = input(false); readonly useAlertRole = input(false); - readonly iconComputed = computed(() => this.icon() ?? defaultIcon[this.type()]); + readonly iconComputed = computed(() => + this.icon() === undefined ? defaultIcon[this.type()] : this.icon(), + ); readonly titleComputed = computed(() => { const title = this.title(); + if (title === null) { + return undefined; + } + const type = this.type(); if (title == null && defaultI18n[type] != null) { return this.i18nService.t(defaultI18n[type]); diff --git a/libs/components/src/icon-tile/icon-tile.component.html b/libs/components/src/icon-tile/icon-tile.component.html new file mode 100644 index 00000000000..2dff588243e --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.html @@ -0,0 +1,7 @@ +
+ +
diff --git a/libs/components/src/icon-tile/icon-tile.component.ts b/libs/components/src/icon-tile/icon-tile.component.ts new file mode 100644 index 00000000000..54e92f9f004 --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.component.ts @@ -0,0 +1,111 @@ +import { NgClass } from "@angular/common"; +import { Component, computed, input } from "@angular/core"; + +import { BitwardenIcon } from "../shared/icon"; + +export type IconTileVariant = "primary" | "success" | "warning" | "danger" | "muted"; + +export type IconTileSize = "small" | "default" | "large"; + +export type IconTileShape = "square" | "circle"; + +const variantStyles: Record = { + primary: ["tw-bg-primary-100", "tw-text-primary-700"], + success: ["tw-bg-success-100", "tw-text-success-700"], + warning: ["tw-bg-warning-100", "tw-text-warning-700"], + danger: ["tw-bg-danger-100", "tw-text-danger-700"], + muted: ["tw-bg-secondary-100", "tw-text-secondary-700"], +}; + +const sizeStyles: Record = { + small: { + container: ["tw-w-6", "tw-h-6"], + icon: ["tw-text-sm"], + }, + default: { + container: ["tw-w-8", "tw-h-8"], + icon: ["tw-text-base"], + }, + large: { + container: ["tw-w-10", "tw-h-10"], + icon: ["tw-text-lg"], + }, +}; + +const shapeStyles: Record> = { + square: { + small: ["tw-rounded"], + default: ["tw-rounded-md"], + large: ["tw-rounded-lg"], + }, + circle: { + small: ["tw-rounded-full"], + default: ["tw-rounded-full"], + large: ["tw-rounded-full"], + }, +}; + +/** + * Icon tiles are static containers that display an icon with a colored background. + * They are similar to icon buttons but are not interactive and are used for visual + * indicators, status representations, or decorative elements. + * + * Use icon tiles to: + * - Display status or category indicators + * - Represent different types of content + * - Create visual hierarchy in lists or cards + * - Show app or service icons in a consistent format + */ +@Component({ + selector: "bit-icon-tile", + templateUrl: "icon-tile.component.html", + imports: [NgClass], +}) +export class IconTileComponent { + /** + * The BWI icon name + */ + readonly icon = input.required(); + + /** + * The visual theme of the icon tile + */ + readonly variant = input("primary"); + + /** + * The size of the icon tile + */ + readonly size = input("default"); + + /** + * The shape of the icon tile + */ + readonly shape = input("square"); + + /** + * Optional aria-label for accessibility when the icon has semantic meaning + */ + readonly ariaLabel = input(); + + protected readonly containerClasses = computed(() => { + const variant = this.variant(); + const size = this.size(); + const shape = this.shape(); + + return [ + "tw-inline-flex", + "tw-items-center", + "tw-justify-center", + "tw-flex-shrink-0", + ...variantStyles[variant], + ...sizeStyles[size].container, + ...shapeStyles[shape][size], + ]; + }); + + protected readonly iconClasses = computed(() => { + const size = this.size(); + + return ["bwi", this.icon(), ...sizeStyles[size].icon]; + }); +} diff --git a/libs/components/src/icon-tile/icon-tile.stories.ts b/libs/components/src/icon-tile/icon-tile.stories.ts new file mode 100644 index 00000000000..2daa0d4289a --- /dev/null +++ b/libs/components/src/icon-tile/icon-tile.stories.ts @@ -0,0 +1,114 @@ +import { Meta, StoryObj } from "@storybook/angular"; + +import { BITWARDEN_ICONS } from "../shared/icon"; + +import { IconTileComponent } from "./icon-tile.component"; + +export default { + title: "Component Library/Icon Tile", + component: IconTileComponent, + args: { + icon: "bwi-star", + variant: "primary", + size: "default", + shape: "square", + }, + argTypes: { + variant: { + options: ["primary", "success", "warning", "danger", "muted"], + control: { type: "select" }, + }, + size: { + options: ["small", "default", "large"], + control: { type: "select" }, + }, + shape: { + options: ["square", "circle"], + control: { type: "select" }, + }, + icon: { + options: BITWARDEN_ICONS, + control: { type: "select" }, + }, + ariaLabel: { + control: { type: "text" }, + }, + }, + parameters: { + design: { + type: "figma", + url: "https://atlassian.design/components/icon/icon-tile/examples", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = {}; + +export const AllVariants: Story = { + render: () => ({ + template: ` +
+
+ + Primary +
+
+ + Success +
+
+ + Warning +
+
+ + Danger +
+
+ + Muted +
+
+ `, + }), +}; + +export const AllSizes: Story = { + render: () => ({ + template: ` +
+
+ + Small +
+
+ + Default +
+
+ + Large +
+
+ `, + }), +}; + +export const AllShapes: Story = { + render: () => ({ + template: ` +
+
+ + Square +
+
+ + Circle +
+
+ `, + }), +}; diff --git a/libs/components/src/icon-tile/index.ts b/libs/components/src/icon-tile/index.ts new file mode 100644 index 00000000000..415c9e478cc --- /dev/null +++ b/libs/components/src/icon-tile/index.ts @@ -0,0 +1 @@ +export * from "./icon-tile.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index d231048563c..2384696b770 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -21,6 +21,7 @@ export * from "./drawer"; export * from "./form-field"; export * from "./icon-button"; export * from "./icon"; +export * from "./icon-tile"; export * from "./input"; export * from "./item"; export * from "./layout"; diff --git a/libs/components/src/navigation/nav-group.stories.ts b/libs/components/src/navigation/nav-group.stories.ts index 7aa9279e0e2..8bfd8007ac0 100644 --- a/libs/components/src/navigation/nav-group.stories.ts +++ b/libs/components/src/navigation/nav-group.stories.ts @@ -64,7 +64,8 @@ export default { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4", }, - chromatic: { viewports: [640, 1280] }, + // remove disableSnapshots in CL-890 + chromatic: { viewports: [640, 1280], disableSnapshot: true }, }, } as Meta; diff --git a/libs/components/src/navigation/nav-item.stories.ts b/libs/components/src/navigation/nav-item.stories.ts index 7e88e16ccac..56f99502710 100644 --- a/libs/components/src/navigation/nav-item.stories.ts +++ b/libs/components/src/navigation/nav-item.stories.ts @@ -42,7 +42,8 @@ export default { type: "figma", url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/Tailwind-Component-Library?node-id=16329-40145&t=b5tDKylm5sWm2yKo-4", }, - chromatic: { viewports: [640, 1280] }, + // remove disableSnapshots in CL-890 + chromatic: { viewports: [640, 1280], disableSnapshot: true }, }, } as Meta; diff --git a/libs/components/src/shared/icon.ts b/libs/components/src/shared/icon.ts new file mode 100644 index 00000000000..6830ce49d7c --- /dev/null +++ b/libs/components/src/shared/icon.ts @@ -0,0 +1,110 @@ +/** + * Array of available Bitwarden Web Icons (bwi) font names. + * These correspond to the actual icon names defined in the bwi-font. + * This array serves as the single source of truth for all available icons. + */ +export const BITWARDEN_ICONS = [ + "bwi-angle-down", + "bwi-angle-left", + "bwi-angle-right", + "bwi-angle-up", + "bwi-archive", + "bwi-bell", + "bwi-billing", + "bwi-bitcoin", + "bwi-browser", + "bwi-browser-alt", + "bwi-brush", + "bwi-bug", + "bwi-business", + "bwi-camera", + "bwi-check", + "bwi-check-circle", + "bwi-cli", + "bwi-clock", + "bwi-clone", + "bwi-close", + "bwi-cog", + "bwi-cog-f", + "bwi-collection", + "bwi-collection-shared", + "bwi-credit-card", + "bwi-dashboard", + "bwi-desktop", + "bwi-dollar", + "bwi-down-solid", + "bwi-download", + "bwi-drag-and-drop", + "bwi-ellipsis-h", + "bwi-ellipsis-v", + "bwi-envelope", + "bwi-error", + "bwi-exclamation-triangle", + "bwi-external-link", + "bwi-eye", + "bwi-eye-slash", + "bwi-family", + "bwi-file", + "bwi-file-text", + "bwi-files", + "bwi-filter", + "bwi-folder", + "bwi-generate", + "bwi-globe", + "bwi-hashtag", + "bwi-id-card", + "bwi-import", + "bwi-info-circle", + "bwi-key", + "bwi-list", + "bwi-list-alt", + "bwi-lock", + "bwi-lock-encrypted", + "bwi-lock-f", + "bwi-minus-circle", + "bwi-mobile", + "bwi-msp", + "bwi-numbered-list", + "bwi-paperclip", + "bwi-passkey", + "bwi-paypal", + "bwi-pencil", + "bwi-pencil-square", + "bwi-plus", + "bwi-plus-circle", + "bwi-popout", + "bwi-provider", + "bwi-puzzle", + "bwi-question-circle", + "bwi-refresh", + "bwi-search", + "bwi-send", + "bwi-share", + "bwi-shield", + "bwi-sign-in", + "bwi-sign-out", + "bwi-sliders", + "bwi-spinner", + "bwi-star", + "bwi-star-f", + "bwi-sticky-note", + "bwi-tag", + "bwi-trash", + "bwi-undo", + "bwi-universal-access", + "bwi-unlock", + "bwi-up-down-btn", + "bwi-up-solid", + "bwi-user", + "bwi-user-monitor", + "bwi-users", + "bwi-vault", + "bwi-wireless", + "bwi-wrench", +] as const; + +/** + * Type-safe icon names derived from the BITWARDEN_ICONS array. + * This ensures type safety while allowing runtime iteration and validation. + */ +export type BitwardenIcon = (typeof BITWARDEN_ICONS)[number]; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts index 14a16211deb..76965a364eb 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-json-importer.ts @@ -88,7 +88,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { for (const c of results.items) { const cipher = CipherWithIdExport.toDomain(c); - // reset ids incase they were set for some reason + // reset ids in case they were set for some reason cipher.id = null; cipher.organizationId = this.organizationId; cipher.collectionIds = null; @@ -131,7 +131,7 @@ export class BitwardenJsonImporter extends BaseImporter implements Importer { results.items.forEach((c) => { const cipher = CipherWithIdExport.toView(c); - // reset ids incase they were set for some reason + // reset ids in case they were set for some reason cipher.id = null; cipher.organizationId = null; cipher.collectionIds = null; diff --git a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts index dfdcef51735..46c8ef79769 100644 --- a/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts +++ b/libs/importer/src/importers/bitwarden/bitwarden-password-protected-importer.spec.ts @@ -9,7 +9,6 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { emptyGuid, OrganizationId } from "@bitwarden/common/types/guid"; import { OrgKey, UserKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; -import { newGuid } from "@bitwarden/guid"; import { KdfType, KeyService } from "@bitwarden/key-management"; import { UserId } from "@bitwarden/user-core"; @@ -41,7 +40,7 @@ describe("BitwardenPasswordProtectedImporter", () => { accountService = mock(); accountService.activeAccount$ = of({ - id: newGuid() as UserId, + id: emptyGuid as UserId, email: "test@example.com", emailVerified: true, name: "Test User", @@ -52,8 +51,8 @@ describe("BitwardenPasswordProtectedImporter", () => { The key values below are never read, empty objects are cast as types for compilation type checking only. Tests specific to key contents are in key-service.spec.ts */ - const mockOrgKey = {} as unknown as OrgKey; - const mockUserKey = {} as unknown as UserKey; + const mockOrgKey = {} as OrgKey; + const mockUserKey = {} as UserKey; keyService.orgKeys$.mockImplementation(() => of({ [mockOrgId]: mockOrgKey } as Record), @@ -99,7 +98,7 @@ describe("BitwardenPasswordProtectedImporter", () => { beforeEach(() => { accountService.activeAccount$ = of({ - id: newGuid() as UserId, + id: emptyGuid as UserId, email: "test@example.com", emailVerified: true, name: "Test User", diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts index f572997cfe7..092a80c3cf0 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-importer.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { CipherType } from "@bitwarden/common/vault/enums"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; @@ -8,7 +6,7 @@ import { ImportResult } from "../../models/import-result"; import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; -import { FskEntry, FskEntryTypesEnum, FskFile } from "./fsecure-fsk-types"; +import { FskEntry, FskEntryType, FskFile } from "./fsecure-fsk-types"; export class FSecureFskImporter extends BaseImporter implements Importer { parse(data: string): Promise { @@ -19,37 +17,32 @@ export class FSecureFskImporter extends BaseImporter implements Importer { return Promise.resolve(result); } - for (const key in results.data) { - // eslint-disable-next-line - if (!results.data.hasOwnProperty(key)) { - continue; - } - - const value = results.data[key]; + for (const [, value] of Object.entries(results.data)) { const cipher = this.parseEntry(value); - result.ciphers.push(cipher); + if (cipher != undefined) { + result.ciphers.push(cipher); + } } result.success = true; return Promise.resolve(result); } - private parseEntry(entry: FskEntry): CipherView { + private parseEntry(entry: FskEntry): CipherView | undefined { const cipher = this.initLoginCipher(); cipher.name = this.getValueOrDefault(entry.service); cipher.notes = this.getValueOrDefault(entry.notes); cipher.favorite = entry.favorite > 0; switch (entry.type) { - case FskEntryTypesEnum.Login: + case FskEntryType.Login: this.handleLoginEntry(entry, cipher); break; - case FskEntryTypesEnum.CreditCard: + case FskEntryType.CreditCard: this.handleCreditCardEntry(entry, cipher); break; default: - return; - break; + return undefined; } this.convertToNoteIfNeeded(cipher); diff --git a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts index 1235426d683..919ae4e8c82 100644 --- a/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts +++ b/libs/importer/src/importers/fsecure/fsecure-fsk-types.ts @@ -6,12 +6,18 @@ export interface Data { [key: string]: FskEntry; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum FskEntryTypesEnum { - Login = 1, - CreditCard = 2, -} +/** + * Represents the different types of FSK entries. + */ +export const FskEntryType = Object.freeze({ + Login: 1, + CreditCard: 2, +}); + +/** + * Type representing valid FSK entry type values. + */ +export type FskEntryType = (typeof FskEntryType)[keyof typeof FskEntryType]; export interface FskEntry { color: string; @@ -26,7 +32,7 @@ export interface FskEntry { rev: string | number; service: string; style: string; - type: FskEntryTypesEnum; + type: FskEntryType; url: string; username: string; createdDate: number; // UNIX timestamp diff --git a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts index 01c4572fcf9..ace0cda71a1 100644 --- a/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts +++ b/libs/importer/src/importers/lastpass/access/enums/idp-provider.ts @@ -1,10 +1,16 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum IdpProvider { - Azure = 0, - OktaAuthServer = 1, - OktaNoAuthServer = 2, - Google = 3, - PingOne = 4, - OneLogin = 5, -} +/** + * Represents the different identity providers supported for authentication. + */ +export const IdpProvider = Object.freeze({ + Azure: 0, + OktaAuthServer: 1, + OktaNoAuthServer: 2, + Google: 3, + PingOne: 4, + OneLogin: 5, +} as const); + +/** + * Type representing valid identity provider values. + */ +export type IdpProvider = (typeof IdpProvider)[keyof typeof IdpProvider]; diff --git a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts index a3be36c790e..8f45852c759 100644 --- a/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts +++ b/libs/importer/src/importers/lastpass/access/enums/lastpass-login-type.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LastpassLoginType { - MasterPassword = 0, +/** + * Represents LastPass login types. + */ +export const LastpassLoginType = Object.freeze({ + MasterPassword: 0, // Not sure what Types 1 and 2 are? - Federated = 3, -} + Federated: 3, +} as const); + +/** + * Type representing valid LastPass login type values. + */ +export type LastpassLoginType = (typeof LastpassLoginType)[keyof typeof LastpassLoginType]; diff --git a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts index f1237160179..9d7e88798d4 100644 --- a/libs/importer/src/importers/lastpass/access/enums/otp-method.ts +++ b/libs/importer/src/importers/lastpass/access/enums/otp-method.ts @@ -1,7 +1,13 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum OtpMethod { - GoogleAuth, - MicrosoftAuth, - Yubikey, -} +/** + * Represents OTP authentication methods. + */ +export const OtpMethod = Object.freeze({ + GoogleAuth: 0, + MicrosoftAuth: 1, + Yubikey: 2, +} as const); + +/** + * Type representing valid OTP method values. + */ +export type OtpMethod = (typeof OtpMethod)[keyof typeof OtpMethod]; diff --git a/libs/importer/src/importers/lastpass/access/enums/platform.ts b/libs/importer/src/importers/lastpass/access/enums/platform.ts index 6870fc28c24..a58ba37958a 100644 --- a/libs/importer/src/importers/lastpass/access/enums/platform.ts +++ b/libs/importer/src/importers/lastpass/access/enums/platform.ts @@ -1,6 +1,12 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum Platform { - Desktop, - Mobile, -} +/** + * Platform types representing different device categories. + */ +export const Platform = Object.freeze({ + Desktop: 0, + Mobile: 1, +} as const); + +/** + * Type representing valid platform values. + */ +export type Platform = (typeof Platform)[keyof typeof Platform]; diff --git a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts index 053c83f2347..d19b5e7d0f3 100644 --- a/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts +++ b/libs/importer/src/importers/onepassword/onepassword-1pux-importer.ts @@ -14,12 +14,12 @@ import { BaseImporter } from "../base-importer"; import { Importer } from "../importer"; import { - CategoryEnum, + Category, Details, ExportData, FieldsEntity, Item, - LoginFieldTypeEnum, + LoginFieldType, Overview, PasswordHistoryEntity, SectionsEntity, @@ -45,38 +45,38 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { const cipher = this.initLoginCipher(); - const category = item.categoryUuid as CategoryEnum; + const category = item.categoryUuid as Category; switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.Password: - case CategoryEnum.WirelessRouter: - case CategoryEnum.Server: - case CategoryEnum.API_Credential: + case Category.Login: + case Category.Database: + case Category.Password: + case Category.WirelessRouter: + case Category.Server: + case Category.API_Credential: cipher.type = CipherType.Login; cipher.login = new LoginView(); break; - case CategoryEnum.CreditCard: - case CategoryEnum.BankAccount: + case Category.CreditCard: + case Category.BankAccount: cipher.type = CipherType.Card; cipher.card = new CardView(); break; - case CategoryEnum.SecureNote: - case CategoryEnum.SoftwareLicense: - case CategoryEnum.EmailAccount: - case CategoryEnum.MedicalRecord: + case Category.SecureNote: + case Category.SoftwareLicense: + case Category.EmailAccount: + case Category.MedicalRecord: // case CategoryEnum.Document: cipher.type = CipherType.SecureNote; cipher.secureNote = new SecureNoteView(); cipher.secureNote.type = SecureNoteType.Generic; break; - case CategoryEnum.Identity: - case CategoryEnum.DriversLicense: - case CategoryEnum.OutdoorLicense: - case CategoryEnum.Membership: - case CategoryEnum.Passport: - case CategoryEnum.RewardsProgram: - case CategoryEnum.SocialSecurityNumber: + case Category.Identity: + case Category.DriversLicense: + case Category.OutdoorLicense: + case Category.Membership: + case Category.Passport: + case Category.RewardsProgram: + case Category.SocialSecurityNumber: cipher.type = CipherType.Identity; cipher.identity = new IdentityView(); break; @@ -166,10 +166,10 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { let fieldValue = loginField.value; let fieldType: FieldType = FieldType.Text; switch (loginField.fieldType) { - case LoginFieldTypeEnum.Password: + case LoginFieldType.Password: fieldType = FieldType.Hidden; break; - case LoginFieldTypeEnum.CheckBox: + case LoginFieldType.CheckBox: fieldValue = loginField.value !== "" ? "true" : "false"; fieldType = FieldType.Boolean; break; @@ -180,8 +180,8 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { }); } - private processDetails(category: CategoryEnum, details: Details, cipher: CipherView) { - if (category !== CategoryEnum.Password) { + private processDetails(category: Category, details: Details, cipher: CipherView) { + if (category !== Category.Password) { return; } @@ -191,7 +191,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { cipher.login.password = details.password; } - private processSections(category: CategoryEnum, sections: SectionsEntity[], cipher: CipherView) { + private processSections(category: Category, sections: SectionsEntity[], cipher: CipherView) { if (sections == null || sections.length === 0) { return; } @@ -206,7 +206,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } private parseSectionFields( - category: CategoryEnum, + category: Category, fields: FieldsEntity[], cipher: CipherView, sectionTitle: string, @@ -232,20 +232,20 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Login: - case CategoryEnum.Database: - case CategoryEnum.EmailAccount: - case CategoryEnum.WirelessRouter: + case Category.Login: + case Category.Database: + case Category.EmailAccount: + case Category.WirelessRouter: break; - case CategoryEnum.Server: + case Category.Server: if (this.isNullOrWhitespace(cipher.login.uri) && field.id === "url") { cipher.login.uris = this.makeUriArray(fieldValue); return; } break; - case CategoryEnum.API_Credential: + case Category.API_Credential: if (this.fillApiCredentials(field, fieldValue, cipher)) { return; } @@ -258,7 +258,7 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { return; } - if (category === CategoryEnum.BankAccount) { + if (category === Category.BankAccount) { if (this.fillBankAccount(field, fieldValue, cipher)) { return; } @@ -281,34 +281,34 @@ export class OnePassword1PuxImporter extends BaseImporter implements Importer { } switch (category) { - case CategoryEnum.Identity: + case Category.Identity: break; - case CategoryEnum.DriversLicense: + case Category.DriversLicense: if (this.fillDriversLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.OutdoorLicense: + case Category.OutdoorLicense: if (this.fillOutdoorLicense(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Membership: + case Category.Membership: if (this.fillMembership(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.Passport: + case Category.Passport: if (this.fillPassport(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.RewardsProgram: + case Category.RewardsProgram: if (this.fillRewardsProgram(field, fieldValue, cipher)) { return; } break; - case CategoryEnum.SocialSecurityNumber: + case Category.SocialSecurityNumber: if (this.fillSSN(field, fieldValue, cipher)) { return; } diff --git a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts index d7f4dec8f95..43f3bc4f7d6 100644 --- a/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts +++ b/libs/importer/src/importers/onepassword/types/onepassword-1pux-importer-types.ts @@ -25,30 +25,36 @@ export interface VaultAttributes { type: string; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum CategoryEnum { - Login = "001", - CreditCard = "002", - SecureNote = "003", - Identity = "004", - Password = "005", - Document = "006", - SoftwareLicense = "100", - BankAccount = "101", - Database = "102", - DriversLicense = "103", - OutdoorLicense = "104", - Membership = "105", - Passport = "106", - RewardsProgram = "107", - SocialSecurityNumber = "108", - WirelessRouter = "109", - Server = "110", - EmailAccount = "111", - API_Credential = "112", - MedicalRecord = "113", -} +/** + * Represents the different types of items that can be stored in 1Password. + */ +export const Category = Object.freeze({ + Login: "001", + CreditCard: "002", + SecureNote: "003", + Identity: "004", + Password: "005", + Document: "006", + SoftwareLicense: "100", + BankAccount: "101", + Database: "102", + DriversLicense: "103", + OutdoorLicense: "104", + Membership: "105", + Passport: "106", + RewardsProgram: "107", + SocialSecurityNumber: "108", + WirelessRouter: "109", + Server: "110", + EmailAccount: "111", + API_Credential: "112", + MedicalRecord: "113", +} as const); + +/** + * Represents valid 1Password category values. + */ +export type Category = (typeof Category)[keyof typeof Category]; export interface Item { uuid: string; @@ -69,23 +75,30 @@ export interface Details { password?: string | null; } -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum LoginFieldTypeEnum { - TextOrHtml = "T", - EmailAddress = "E", - URL = "U", - Number = "N", - Password = "P", - TextArea = "A", - PhoneNumber = "TEL", - CheckBox = "C", -} +/** + * Represents 1Password login field types that can be stored in login items. + */ +export const LoginFieldType = Object.freeze({ + TextOrHtml: "T", + EmailAddress: "E", + URL: "U", + Number: "N", + Password: "P", + TextArea: "A", + PhoneNumber: "TEL", + CheckBox: "C", +} as const); + +/** + * Type representing valid 1Password login field type values. + */ +export type LoginFieldType = (typeof LoginFieldType)[keyof typeof LoginFieldType]; + export interface LoginFieldsEntity { value: string; id: string; name: string; - fieldType: LoginFieldTypeEnum | string; + fieldType: LoginFieldType | string; designation?: string | null; } export interface SectionsEntity { diff --git a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts index af2eb15a740..124c95a3d69 100644 --- a/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts +++ b/libs/importer/src/importers/protonpass/types/protonpass-json-type.ts @@ -27,12 +27,19 @@ export type ProtonPassItem = { pinned: boolean; }; -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum ProtonPassItemState { - ACTIVE = 1, - TRASHED = 2, -} +/** + * Proton Pass item states as a const object. + * Represents the different states an item can be in (active or trashed). + */ +export const ProtonPassItemState = Object.freeze({ + ACTIVE: 1, + TRASHED: 2, +} as const); + +/** + * Type representing valid Proton Pass item state values. + */ +export type ProtonPassItemState = (typeof ProtonPassItemState)[keyof typeof ProtonPassItemState]; export type ProtonPassItemData = { metadata: ProtonPassItemMetadata; diff --git a/libs/importer/src/services/import.service.ts b/libs/importer/src/services/import.service.ts index 4050ae9fb4b..c17490ed4a4 100644 --- a/libs/importer/src/services/import.service.ts +++ b/libs/importer/src/services/import.service.ts @@ -10,6 +10,7 @@ import { CollectionView, } from "@bitwarden/admin-console/common"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { DeviceType } from "@bitwarden/common/enums"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; @@ -21,7 +22,7 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SemanticLogger } from "@bitwarden/common/tools/log"; import { SystemServiceProvider } from "@bitwarden/common/tools/providers"; -import { OrganizationId } from "@bitwarden/common/types/guid"; +import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType, toCipherTypeName } from "@bitwarden/common/vault/enums"; @@ -238,10 +239,11 @@ export class ImportService implements ImportServiceAbstraction { try { await this.setImportTarget(importResult, organizationId, selectedImportTarget); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); if (organizationId != null) { - await this.handleOrganizationalImport(importResult, organizationId); + await this.handleOrganizationalImport(importResult, organizationId, userId); } else { - await this.handleIndividualImport(importResult); + await this.handleIndividualImport(importResult, userId); } } catch (error) { const errorResponse = new ErrorResponse(error, 400); @@ -419,16 +421,14 @@ export class ImportService implements ImportServiceAbstraction { } } - private async handleIndividualImport(importResult: ImportResult) { + private async handleIndividualImport(importResult: ImportResult, userId: UserId) { const request = new ImportCiphersRequest(); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); for (let i = 0; i < importResult.ciphers.length; i++) { - const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId); + const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); request.ciphers.push(new CipherRequest(c)); } - const userKey = await this.keyService.getUserKey(activeUserId); + const userKey = await firstValueFrom(this.keyService.userKey$(userId)); + if (importResult.folders != null) { for (let i = 0; i < importResult.folders.length; i++) { const f = await this.folderService.encrypt(importResult.folders[i], userKey); @@ -446,20 +446,18 @@ export class ImportService implements ImportServiceAbstraction { private async handleOrganizationalImport( importResult: ImportResult, organizationId: OrganizationId, + userId: UserId, ) { const request = new ImportOrganizationCiphersRequest(); - const activeUserId = await firstValueFrom( - this.accountService.activeAccount$.pipe(map((a) => a?.id)), - ); for (let i = 0; i < importResult.ciphers.length; i++) { importResult.ciphers[i].organizationId = organizationId; - const c = await this.cipherService.encrypt(importResult.ciphers[i], activeUserId); + const c = await this.cipherService.encrypt(importResult.ciphers[i], userId); request.ciphers.push(new CipherRequest(c)); } if (importResult.collections != null) { for (let i = 0; i < importResult.collections.length; i++) { importResult.collections[i].organizationId = organizationId; - const c = await this.collectionService.encrypt(importResult.collections[i], activeUserId); + const c = await this.collectionService.encrypt(importResult.collections[i], userId); request.collections.push(new CollectionWithIdRequest(c)); } } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 9a8e8c9f768..77f603204b3 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -120,73 +120,87 @@
- -
- - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "or" | i18n }}

- - + @if ( + (unlockWithMasterPasswordUnlockDataFlag$ | async) && + unlockOptions.masterPassword.enabled && + activeUnlockOption === UnlockOption.MasterPassword + ) { + + } @else { + + + + {{ "masterPass" | i18n }} + - + bitIconButton + bitSuffix + bitPasswordInputToggle + [(toggled)]="showPassword" + > - - - + + - -
-
-
+
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+ + + } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 8c8429d3788..69f949fb843 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -91,9 +92,10 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockConfigService = mock(); beforeEach(async () => { - jest.clearAllMocks(); + jest.resetAllMocks(); // Setup default mock returns mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); @@ -148,6 +150,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -358,6 +361,135 @@ describe("LockComponent", () => { }); }); + describe("successfulMasterPasswordUnlock", () => { + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const masterPassword = "test-password"; + + beforeEach(async () => { + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + }); + + it.each([ + [undefined as unknown as UserKey, undefined as unknown as string], + [null as unknown as UserKey, null as unknown as string], + [mockUserKey, undefined as unknown as string], + [mockUserKey, null as unknown as string], + [mockUserKey, ""], + [undefined as unknown as UserKey, masterPassword], + [null as unknown as UserKey, masterPassword], + ])( + "logs an error and doesn't unlock when called with invalid data", + async (userKey, masterPassword) => { + await component.successfulMasterPasswordUnlock({ userKey, masterPassword }); + + expect(mockLogService.error).toHaveBeenCalledWith( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [true, ClientType.Browser], + [false, ClientType.Cli], + [false, ClientType.Desktop], + [false, ClientType.Web], + ])( + "unlocks and navigate by url to previous url = %o when client type = %o and previous url was set", + async (shouldNavigate, clientType) => { + const previousUrl = "/test-url"; + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + if (shouldNavigate) { + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); + } else { + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["/tabs/current", ClientType.Browser], + [undefined, ClientType.Cli], + ["vault", ClientType.Desktop], + ["vault", ClientType.Web], + ])( + "unlocks and navigate to success url = %o when client type = %o", + async (navigateUrl, clientType) => { + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(null); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); + }, + ); + + it("unlocks and close browser extension popout on firefox extension", async () => { + component.shouldClosePopout = true; + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); + }); + + function assertUnlocked(): void { + expect(mockKeyService.setUserKey).toHaveBeenCalledWith( + mockUserKey, + component.activeAccount!.id, + ); + } + }); + describe("unlockViaMasterPassword", () => { const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index c7aa8969660..e7550e34b9f 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -29,10 +29,12 @@ import { MasterPasswordVerificationResponse, } from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -64,6 +66,8 @@ import { UnlockOptionValue, } from "../services/lock-component.service"; +import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; + const BroadcasterSubscriptionId = "LockComponent"; const clientTypeToSuccessRouteRecord: Partial> = { @@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial> = { [ClientType.Browser]: "/tabs/current", }; +type AfterUnlockActions = { + passwordEvaluation?: { + masterPassword: string; + }; +}; + /// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; @@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; FormFieldModule, AsyncActionsModule, IconButtonModule, + MasterPasswordLockComponent, ], }) export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; + protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.UnlockWithMasterPasswordUnlockData, + ); + activeAccount: Account | null = null; clientType?: ClientType; @@ -160,6 +175,7 @@ export class LockComponent implements OnInit, OnDestroy { private logoutService: LogoutService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -379,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy { // If user cancels biometric prompt, userKey is undefined. if (userKey) { - await this.setUserKeyAndContinue(userKey, false); + await this.setUserKeyAndContinue(userKey); } this.unlockingViaBiometrics = false; @@ -423,6 +439,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. togglePassword() { this.showPassword = !this.showPassword; const input = document.getElementById( @@ -498,6 +515,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. private validateMasterPassword(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -511,6 +529,7 @@ export class LockComponent implements OnInit, OnDestroy { return true; } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. async unlockViaMasterPassword() { if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { return; @@ -568,10 +587,33 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.setUserKeyAndContinue(userKey, true); + await this.setUserKeyAndContinue(userKey, { + passwordEvaluation: { masterPassword }, + }); } - private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + async successfulMasterPasswordUnlock(event: { + userKey: UserKey; + masterPassword: string; + }): Promise { + if (event.userKey == null || !event.masterPassword) { + this.logService.error( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + return; + } + + await this.setUserKeyAndContinue(event.userKey, { + passwordEvaluation: { + masterPassword: event.masterPassword, + }, + }); + } + + protected async setUserKeyAndContinue( + key: UserKey, + afterUnlockActions: AfterUnlockActions = {}, + ): Promise { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -585,10 +627,10 @@ export class LockComponent implements OnInit, OnDestroy { // need to establish trust on the current device await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); - await this.doContinue(evaluatePasswordAfterUnlock); + await this.doContinue(afterUnlockActions); } - private async doContinue(evaluatePasswordAfterUnlock: boolean) { + private async doContinue(afterUnlockActions: AfterUnlockActions) { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -596,7 +638,7 @@ export class LockComponent implements OnInit, OnDestroy { await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); - if (evaluatePasswordAfterUnlock) { + if (afterUnlockActions.passwordEvaluation) { const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) { throw new Error("No active user."); @@ -613,7 +655,7 @@ export class LockComponent implements OnInit, OnDestroy { ); } - if (this.requirePasswordChange()) { + if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) { await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, userId, @@ -669,18 +711,15 @@ export class LockComponent implements OnInit, OnDestroy { * Checks if the master password meets the enforced policy requirements * If not, returns false */ - private requirePasswordChange(): boolean { + private requirePasswordChange(masterPassword: string): boolean { if ( this.enforcedMasterPasswordOptions == undefined || !this.enforcedMasterPasswordOptions.enforceOnLogin || - this.formGroup == null || this.activeAccount == null ) { return false; } - const masterPassword = this.formGroup.controls.masterPassword.value; - const passwordStrength = this.passwordStrengthService.getPasswordStrength( masterPassword, this.activeAccount.email, diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html new file mode 100644 index 00000000000..185fb0666c4 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -0,0 +1,55 @@ +
+ + {{ "masterPass" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + @if (showBiometricsSwap()) { + + } + + @if (showPinSwap()) { + + } + + +
+
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts new file mode 100644 index 00000000000..d40cc98df11 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -0,0 +1,472 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; + +import { MasterPasswordLockComponent } from "./master-password-lock.component"; + +describe("MasterPasswordLockComponent", () => { + let component: MasterPasswordLockComponent; + let fixture: ComponentFixture; + + const accountService = mock(); + const masterPasswordUnlockService = mock(); + const i18nService = mock(); + const toastService = mock(); + const logService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + + const setupComponent = ( + unlockOptions: Partial = {}, + biometricUnlockBtnText: string = "default", + account: Account | null = activeAccount, + ) => { + const defaultOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledLocally, + }, + }; + + accountService.activeAccount$ = of(account); + fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions }); + fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText); + fixture.detectChanges(); + + return { + form: fixture.debugElement.query(By.css("form")), + component, + ...getFormElements(fixture.debugElement.query(By.css("form"))), + }; + }; + + const getFormElements = (form: DebugElement) => ({ + masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')), + toggleButton: form.query(By.css("button[bitPasswordInputToggle]")), + submitButton: form.query(By.css('button[type="submit"]')), + logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')), + secondaryButton: form.query(By.css('button[buttonType="secondary"]')), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [ + MasterPasswordLockComponent, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], + providers: [ + FormBuilder, + { provide: AccountService, useValue: accountService }, + { provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService }, + { provide: I18nService, useValue: i18nService }, + { provide: ToastService, useValue: toastService }, + { provide: LogService, useValue: logService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordLockComponent); + component = fixture.componentInstance; + }); + + describe("form rendering", () => { + let elements: ReturnType; + + beforeEach(() => { + elements = setupComponent(); + }); + + it("creates form with proper structure", () => { + expect(component.formGroup).toBeDefined(); + expect(component.formGroup.controls.masterPassword).toBeDefined(); + }); + + const formElementTests = [ + { + name: "master password input", + selector: "masterPasswordInput", + expectations: (el: HTMLInputElement) => { + expect(el).toMatchObject({ + type: "password", + name: "masterPassword", + required: true, + }); + expect(el.attributes).toHaveProperty("bitInput"); + }, + }, + { + name: "password toggle button", + selector: "toggleButton", + expectations: (el: HTMLButtonElement) => { + expect(el.type).toBe("button"); + expect(el.attributes).toHaveProperty("bitIconButton"); + }, + }, + { + name: "unlock submit button", + selector: "submitButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "submit", + textContent: expect.stringContaining("unlock"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + { + name: "logout button", + selector: "logoutButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "button", + textContent: expect.stringContaining("logOut"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + ]; + + test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => { + const element = elements[selector as keyof typeof elements] as DebugElement; + expect(element).toBeTruthy(); + expectations(element.nativeElement); + }); + + const hiddenButtonTests = [ + { + case: "biometrics swap button when biometrics is undefined", + setup: () => + setupComponent( + { + pin: { enabled: false }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + "swapBiometrics", + ), + expectHidden: true, + }, + { + case: "biometrics swap button when biometrics is disabled", + setup: () => setupComponent({}, "swapBiometrics"), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is disabled", + setup: () => setupComponent({}), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is undefined", + setup: () => + setupComponent({ + pin: { enabled: undefined as unknown as boolean }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }), + expectHidden: true, + }, + ]; + + test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => { + const { secondaryButton } = setup(); + expect(!!secondaryButton).toBe(!expectHidden); + }); + }); + + describe("password input", () => { + let setup: ReturnType; + beforeEach(() => { + setup = setupComponent(); + }); + + it("should bind form input to masterPassword form control", async () => { + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + expect(component.formGroup).toBeTruthy(); + const masterPasswordControl = component.formGroup!.get("masterPassword"); + expect(masterPasswordControl).toBeTruthy(); + + masterPasswordControl!.setValue("test-password"); + fixture.detectChanges(); + + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.value).toEqual("test-password"); + }); + + it("should validate required master password field", async () => { + const formGroup = component.formGroup; + + // Initially form should be invalid (empty required field) + expect(formGroup?.invalid).toEqual(true); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); + + // Set a value + formGroup?.get("masterPassword")?.setValue("test-password"); + + expect(formGroup?.invalid).toEqual(false); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); + }); + + it("should toggle password visibility when toggle button is clicked", async () => { + const toggleButton = setup.toggleButton; + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + + // Initially password should be hidden + expect(inputElement.type).toEqual("password"); + + // Click toggle button + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("text"); + + // Click toggle button again + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("password"); + }); + }); + + describe("logout", () => { + it("emits logOut event when logout button is clicked", () => { + const setup = setupComponent(); + let logoutEmitted = false; + component.logOut.subscribe(() => { + logoutEmitted = true; + }); + + expect(setup.logoutButton).toBeTruthy(); + expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement; + + // Click logout button + logoutButtonElement.click(); + + expect(logoutEmitted).toBe(true); + }); + }); + + describe("swap buttons", () => { + const swapButtonScenarios = [ + { + name: "PIN swap button when PIN is enabled", + unlockOptions: { + pin: { enabled: true }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: true, + shouldEnable: true, + }, + { + name: "PIN swap button when PIN is disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is available and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: true, + }, + { + name: "biometrics swap button when biometrics status is available and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics biometrics status is unsupported and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is unsupported and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + ]; + + test.each(swapButtonScenarios)( + "renders and handles $name", + ({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => { + const { secondaryButton, component } = setupComponent(unlockOptions, expectedText); + + if (shouldShow) { + expect(secondaryButton).toBeTruthy(); + expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText); + + if (shouldEnable) { + secondaryButton.nativeElement.click(); + expect(component.activeUnlockOption()).toBe(expectedUnlockOption); + } else { + expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true"); + } + } else { + expect(secondaryButton).toBeFalsy(); + } + }, + ); + }); + + describe("submit", () => { + test.each([null, undefined as unknown as string, ""])( + "won't unlock and show password invalid toast when master password is %s", + async (value) => { + component.formGroup.controls.masterPassword.setValue(value); + + await component.submit(); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("masterPasswordRequired"), + }); + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as Account, undefined as unknown as Account])( + "throws error when active account is %s", + async (value) => { + accountService.activeAccount$ = of(value); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await expect(component.submit()).rejects.toThrow("Null or undefined account"); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + it("shows an error toast and logs the error when unlock with master password fails", async () => { + const customError = new Error("Specialized error message"); + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await component.submit(); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("invalidMasterPassword"), + }); + expect(logService.error).toHaveBeenCalledWith( + "[MasterPasswordLockComponent] Failed to unlock via master password", + customError, + ); + }); + + it("emits userKey when unlock is successful", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined; + component.successfulUnlock.subscribe( + (event: { userKey: UserKey; masterPassword: string }) => { + emittedEvent = event; + }, + ); + + await component.submit(); + + expect(emittedEvent?.userKey).toEqual(mockUserKey); + expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); + }); +}); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts new file mode 100644 index 00000000000..c9399cc3ab2 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -0,0 +1,111 @@ +import { Component, computed, inject, input, model, output } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + UnlockOption, + UnlockOptions, + UnlockOptionValue, +} from "../../services/lock-component.service"; + +@Component({ + selector: "bit-master-password-lock", + templateUrl: "master-password-lock.component.html", + imports: [ + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class MasterPasswordLockComponent { + private readonly accountService = inject(AccountService); + private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + UnlockOption = UnlockOption; + + activeUnlockOption = model.required(); + + unlockOptions = input.required(); + biometricUnlockBtnText = input.required(); + showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); + biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); + showBiometricsSwap = computed(() => { + const status = this.unlockOptions().biometrics.biometricsStatus; + return ( + status !== BiometricsStatus.PlatformUnsupported && + status !== BiometricsStatus.NotEnabledLocally + ); + }); + + successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + logOut = output(); + + formGroup = new FormGroup({ + masterPassword: new FormControl("", { + validators: [Validators.required], + updateOn: "submit", + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + const masterPassword = this.formGroup.controls.masterPassword.value; + if (this.formGroup.invalid || !masterPassword) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return; + } + + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + await this.unlockViaMasterPassword(masterPassword, activeUserId); + }; + + private async unlockViaMasterPassword( + masterPassword: string, + activeUserId: UserId, + ): Promise { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + masterPassword, + activeUserId, + ); + this.successfulUnlock.emit({ userKey, masterPassword }); + } catch (error) { + this.logService.error( + "[MasterPasswordLockComponent] Failed to unlock via master password", + error, + ); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + } + } +} diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts index a2df4ec27dc..df317835392 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.spec.ts @@ -12,7 +12,7 @@ import { import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { CipherWithIdExport } from "@bitwarden/common/models/export/cipher-with-ids.export"; import { Utils } from "@bitwarden/common/platform/misc/utils"; -import { CipherId, UserId } from "@bitwarden/common/types/guid"; +import { CipherId, emptyGuid, UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -179,7 +179,7 @@ describe("VaultExportService", () => { let restrictedItemTypesService: Partial; let fetchMock: jest.Mock; - const userId = "" as UserId; + const userId = emptyGuid as UserId; beforeEach(() => { cryptoFunctionService = mock(); diff --git a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts index e51c9543bba..e7a97801e09 100644 --- a/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts +++ b/libs/tools/export/vault-export/vault-export-core/src/services/individual-vault-export.service.ts @@ -201,6 +201,10 @@ export class IndividualVaultExportService } private async getEncryptedExport(activeUserId: UserId): Promise { + if (!activeUserId) { + throw new Error("User ID must not be null or undefined"); + } + let folders: Folder[] = []; let ciphers: Cipher[] = []; const promises = []; @@ -225,7 +229,7 @@ export class IndividualVaultExportService await Promise.all(promises); - const userKey = await this.keyService.getUserKey(activeUserId); + const userKey = await firstValueFrom(this.keyService.userKey$(activeUserId)); const encKeyValidation = await this.encryptService.encryptString(Utils.newGuid(), userKey); const jsonDoc: BitwardenEncryptedIndividualJsonExport = { diff --git a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts index 2f416e4a49a..c321a487515 100644 --- a/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts +++ b/libs/tools/export/vault-export/vault-export-ui/src/enums/encrypted-export-type.enum.ts @@ -1,6 +1,10 @@ -// FIXME: update to use a const object instead of a typescript enum -// eslint-disable-next-line @bitwarden/platform/no-enums -export enum EncryptedExportType { - AccountEncrypted = 0, - FileEncrypted = 1, -} +/** A type of encrypted export. */ +export const EncryptedExportType = Object.freeze({ + /** Export is encrypted using the Bitwarden account key. */ + AccountEncrypted: 0, + /** Export is encrypted using a separate file password/key. */ + FileEncrypted: 1, +} as const); + +/** A type of encrypted export. */ +export type EncryptedExportType = (typeof EncryptedExportType)[keyof typeof EncryptedExportType]; diff --git a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts index 91998589bdd..c0c32824cea 100644 --- a/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts +++ b/libs/tools/generator/extensions/history/src/legacy-password-history-decryptor.ts @@ -1,3 +1,5 @@ +import { firstValueFrom } from "rxjs"; + import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; import { UserId } from "@bitwarden/common/types/guid"; @@ -15,7 +17,11 @@ export class LegacyPasswordHistoryDecryptor { /** Decrypts a password history. */ async decrypt(history: GeneratedPasswordHistory[]): Promise { - const key = await this.keyService.getUserKey(this.userId); + const key = await firstValueFrom(this.keyService.userKey$(this.userId)); + + if (key == undefined) { + throw new Error("No user key found for decryption"); + } const promises = (history ?? []).map(async (item) => { const encrypted = new EncString(item.password); diff --git a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts index ab8d7bae9fe..1eb9e864fb7 100644 --- a/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts +++ b/libs/tools/send/send-ui/src/send-form/services/default-send-form.service.ts @@ -1,7 +1,10 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { inject, Injectable } from "@angular/core"; +import { firstValueFrom } from "rxjs"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; @@ -12,11 +15,13 @@ import { SendFormService } from "../abstractions/send-form.service"; @Injectable() export class DefaultSendFormService implements SendFormService { + private accountService = inject(AccountService); private sendApiService: SendApiService = inject(SendApiService); private sendService = inject(SendService); async decryptSend(send: Send): Promise { - return await send.decrypt(); + const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); + return await send.decrypt(userId); } async saveSend(send: SendView, file: File | ArrayBuffer, config: SendFormConfig) {