1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-04 10:43:47 +00:00

Merge branch 'CL-896' into PM-26650

This commit is contained in:
jaasen-livefront
2025-10-15 10:33:27 -07:00
74 changed files with 2408 additions and 439 deletions

View File

@@ -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"

View File

@@ -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,

View File

@@ -1,4 +0,0 @@
<span>{{ "phishingPageLearnWhy"| i18n}}</span>
<a href="http://bitwarden.com/help/phishing-blocked/" bitLink block buttonType="primary">
{{ "learnMore" | i18n }}
</a>

View File

@@ -1,13 +1,46 @@
<div class="tw-flex tw-flex-col tw-gap-2">
<bit-form-field>
<bit-label>{{ "phishingPageTitle" | i18n }}</bit-label>
<input bitInput disabled type="text" [value]="phishingHost" />
</bit-form-field>
<div class="tw-flex tw-flex-col">
<div class="tw-flex tw-gap-4 tw-items-baseline">
<bit-icon-tile size="large" icon="bwi-exclamation-triangle" variant="danger"></bit-icon-tile>
<h1 bitTypography="h2" noMargin class="!tw-mb-0">{{ "phishingPageTitleV2" | i18n }}</h1>
</div>
<button type="button" (click)="closeTab()" bitButton buttonType="primary">
{{ "phishingPageCloseTab" | i18n }}
</button>
<button type="button" (click)="continueAnyway()" bitButton buttonType="danger">
{{ "phishingPageContinue" | i18n }}
</button>
<hr class="!tw-mt-6 !tw-mb-4 !tw-border-secondary-100" />
<p bitTypography="body1">{{ "phishingPageSummary" | i18n }}</p>
<bit-callout class="tw-mb-0" type="danger" icon="bwi-globe" [title]="null">
<span class="tw-font-mono">{{ phishingHost$ | async }}</span>
</bit-callout>
<bit-callout class="tw-mt-2" [icon]="null" type="default">
<p bitTypography="body2">
{{ "phishingPageExplanation1" | i18n }}<b>Phishing.Database</b
>{{ "phishingPageExplanation2" | i18n }}
</p>
<a
bitLink
linkType="primary"
rel="noreferrer"
target="_blank"
href="https://bitwarden.com/help/phishing-blocked/"
>
{{ "phishingPageLearnMore" | i18n }}<i class="bwi bwi-angle-right" aria-hidden="true"></i>
</a>
</bit-callout>
<div class="tw-flex tw-flex-col tw-gap-4 tw-items-center tw-mt-2">
<button type="button" (click)="closeTab()" bitButton buttonType="primary" [block]="true">
{{ "phishingPageCloseTabV2" | i18n }}
</button>
<button
class="tw-text-sm"
type="button"
(click)="continueAnyway()"
bitLink
linkType="secondary"
>
{{ "phishingPageContinueV2" | i18n }}
</button>
</div>
</div>

View File

@@ -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<void>();
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();
}
}

View File

@@ -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<PlatformUtilsService> {
getApplicationVersion = () => Promise.resolve("Version 2024.1.1");
getClientType = () => ClientType.Web;
}
/**
* Helper function to create ActivatedRoute mock with query parameters
*/
function mockActivatedRoute(queryParams: Record<string, string>) {
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*/ `
<auth-anon-layout
[hideIcon]="true"
[hideBackgroundIllustration]="true"
>
<dirt-phishing-warning></dirt-phishing-warning>
<dirt-phishing-protected-by slot="secondary"></dirt-phishing-protected-by>
</auth-anon-layout>
`,
}),
argTypes: {
phishingHost: {
control: "text",
description: "The suspicious host that was blocked",
},
},
args: {
phishingHost: "malicious-example.com",
pageIcon: DeactivatedOrg,
},
} satisfies Meta<StoryArgs & { pageIcon: any }>;
type Story = StoryObj<StoryArgs & { pageIcon: any }>;
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",
}),
],
}),
],
};

View File

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

View File

@@ -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 {}

View File

@@ -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 });
}
/**

View File

@@ -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,
},

View File

@@ -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;

View File

@@ -211,6 +211,7 @@ export class OssServeConfigurator {
this.serviceContainer.sendService,
this.serviceContainer.sendApiService,
this.serviceContainer.environmentService,
this.serviceContainer.accountService,
);
}

View File

@@ -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,

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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<SendView | SendView[]> {
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) {

View File

@@ -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);

View File

@@ -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);

View File

@@ -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() !== "";
}

View File

@@ -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<void> {
if (!this.currentServer) {
return;
}
return new Promise<void>((resolve) => {
this.currentServer!.close(() => {
this.currentServer = null;
resolve();
});
});
}
@@ -59,6 +81,7 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
this.currentServer = null;
callbackServer.close(() =>
resolve({
ssoCode: code,
@@ -73,41 +96,68 @@ export class SSOLocalhostCallbackService {
"<p>You may now close this tab and return to the app.</p>" +
"</body></html>",
);
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,

View File

@@ -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<CipherViewLike>;
const cipher1: Partial<CipherView> = {
id: "cipher-1",
name: "Cipher 1",
organizationId: undefined,
};
const cipher2: Partial<CipherView> = {
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<CipherView>[] = [
{ 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<CipherView>[] = [
{ 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<CipherView> = {
...cipher1,
archivedDate: new Date("2024-01-01"),
organizationId: undefined,
};
const archivedCipher2: Partial<CipherView> = {
...cipher2,
archivedDate: new Date("2024-01-02"),
organizationId: "org-1",
};
const items: VaultItem<CipherView>[] = [
{ 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<CipherView>[] = [
{ cipher: cipher1 as CipherView },
{ cipher: cipher2 as CipherView },
];
component["selection"].select(...items);
expect(component.bulkUnarchiveAllowed).toBe(false);
});
});
});

View File

@@ -213,7 +213,7 @@ export class VaultItemsComponent<C extends CipherViewLike> {
}
return !this.selection.selected.find(
(item) => !item.cipher.archivedDate || item.cipher.organizationId,
(item) => !item.cipher?.archivedDate || item.cipher?.organizationId,
);
}

View File

@@ -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",

View File

@@ -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,

View File

@@ -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$),
)

View File

@@ -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,

View File

@@ -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<UserKey>;
}

View File

@@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void>;
/**
* 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<MasterPasswordUnlockData | null>;
}

View File

@@ -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<InternalMasterPasswordServiceAbstraction>;
let keyService: MockProxy<KeyService>;
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<InternalMasterPasswordServiceAbstraction>();
keyService = mock<KeyService>();
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();
});
});
});

View File

@@ -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<UserKey> {
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<void> {
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);
}
}

View File

@@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
): Promise<void> {
return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
}
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
return this.mock.masterPasswordUnlockData$(userId);
}
}

View File

@@ -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<StateProvider>;
let stateService: MockProxy<StateService>;
let keyGenerationService: MockProxy<KeyGenerationService>;
let encryptService: MockProxy<EncryptService>;
let logService: MockProxy<LogService>;
let cryptoFunctionService: MockProxy<CryptoFunctionService>;
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<StateProvider>();
stateService = mock<StateService>();
keyGenerationService = mock<KeyGenerationService>();
encryptService = mock<EncryptService>();
logService = mock<LogService>();
cryptoFunctionService = mock<CryptoFunctionService>();
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();

View File

@@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition<string>(MASTER_PASSWORD_DISK, "mas
});
/** Disk to persist through lock */
const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
MASTER_PASSWORD_DISK,
"masterKeyEncryptedUserKey",
{
@@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition<EncryptedString>(
);
/** Disk to persist through lock and account switches */
const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
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<MasterPasswordUnlockData | null> {
assertNonNullish(userId, "userId");
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
}
}

View File

@@ -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<SendText>();
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(

View File

@@ -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<SendView> {
const model = new SendView(this);
async decrypt(userId: UserId): Promise<SendView> {
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<Send, SendView>(this, model, ["name", "notes"], null, model.cryptoKey);

View File

@@ -86,6 +86,7 @@ describe("SendService", () => {
decryptedState.nextState([testSendViewData("1", "Test Send")]);
sendService = new SendService(
accountService,
keyService,
i18nService,
keyGenerationService,

View File

@@ -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<SendView[]> {
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<any>[] = [];
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<EncArrayBuffer> {
private parseFile(
send: Send,
file: File,
key: SymmetricCryptoKey,
userId: UserId,
): Promise<EncArrayBuffer> {
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"));

View File

@@ -6,6 +6,7 @@
[maxWidth]="maxWidth"
[hideCardWrapper]="hideCardWrapper"
[hideIcon]="hideIcon"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<router-outlet></router-outlet>
<router-outlet slot="secondary" name="secondary"></router-outlet>

View File

@@ -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;
}
}

View File

@@ -68,16 +68,18 @@
</ng-container>
</footer>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
@if (!hideBackgroundIllustration()) {
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-start-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="leftIllustration"></bit-icon>
</div>
<div
class="tw-hidden md:tw-block [&_svg]:tw-absolute tw-z-[1] tw-opacity-[.11] [&_svg]:tw-bottom-0 [&_svg]:tw-end-0 [&_svg]:tw-w-[35%] [&_svg]:tw-max-w-[450px]"
>
<bit-icon [icon]="rightIllustration"></bit-icon>
</div>
}
</main>
<ng-template #defaultContent>

View File

@@ -51,6 +51,7 @@ export class AnonLayoutComponent implements OnInit, OnChanges {
readonly hideFooter = input<boolean>(false);
readonly hideIcon = input<boolean>(false);
readonly hideCardWrapper = input<boolean>(false);
readonly hideBackgroundIllustration = input<boolean>(false);
/**
* Max width of the anon layout title, subtitle, and content areas.

View File

@@ -79,6 +79,7 @@ export default {
[hideIcon]="hideIcon"
[hideLogo]="hideLogo"
[hideFooter]="hideFooter"
[hideBackgroundIllustration]="hideBackgroundIllustration"
>
<ng-container [ngSwitch]="contentLength">
<div *ngSwitchCase="'thin'" class="tw-text-center"> <div class="tw-font-bold">Thin Content</div></div>
@@ -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,
},
};

View File

@@ -36,12 +36,18 @@ let nextId = 0;
export class CalloutComponent {
readonly type = input<CalloutTypes>("info");
readonly icon = input<string>();
readonly title = input<string>();
readonly title = input<string | null>();
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]);

View File

@@ -0,0 +1,7 @@
<div
[ngClass]="containerClasses()"
[attr.aria-label]="ariaLabel()"
[attr.role]="ariaLabel() ? 'img' : null"
>
<i [ngClass]="iconClasses()" aria-hidden="true"></i>
</div>

View File

@@ -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<IconTileVariant, string[]> = {
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<IconTileSize, { container: string[]; icon: string[] }> = {
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<IconTileShape, Record<IconTileSize, string[]>> = {
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<BitwardenIcon>();
/**
* The visual theme of the icon tile
*/
readonly variant = input<IconTileVariant>("primary");
/**
* The size of the icon tile
*/
readonly size = input<IconTileSize>("default");
/**
* The shape of the icon tile
*/
readonly shape = input<IconTileShape>("square");
/**
* Optional aria-label for accessibility when the icon has semantic meaning
*/
readonly ariaLabel = input<string>();
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];
});
}

View File

@@ -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<IconTileComponent>;
type Story = StoryObj<IconTileComponent>;
export const Default: Story = {};
export const AllVariants: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center tw-flex-wrap">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-collection" variant="primary"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Primary</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-check-circle" variant="success"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Success</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-exclamation-triangle" variant="warning"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Warning</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-error" variant="danger"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Danger</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-question-circle" variant="muted"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Muted</span>
</div>
</div>
`,
}),
};
export const AllSizes: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="small"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Small</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="default"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Default</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-star" variant="primary" size="large"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Large</span>
</div>
</div>
`,
}),
};
export const AllShapes: Story = {
render: () => ({
template: `
<div class="tw-flex tw-gap-4 tw-items-center">
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-user" variant="primary" shape="square"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Square</span>
</div>
<div class="tw-flex tw-flex-col tw-items-center tw-gap-2">
<bit-icon-tile icon="bwi-user" variant="primary" shape="circle"></bit-icon-tile>
<span class="tw-text-sm tw-text-muted">Circle</span>
</div>
</div>
`,
}),
};

View File

@@ -0,0 +1 @@
export * from "./icon-tile.component";

View File

@@ -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";

View File

@@ -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;

View File

@@ -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;

View File

@@ -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];

View File

@@ -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;

View File

@@ -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>();
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<OrganizationId, OrgKey>),
@@ -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",

View File

@@ -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<ImportResult> {
@@ -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);

View File

@@ -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

View File

@@ -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];

View File

@@ -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];

View File

@@ -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];

View File

@@ -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];

View File

@@ -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;
}

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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));
}
}

View File

@@ -120,73 +120,87 @@
</ng-container>
<!-- MP Unlock -->
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
@if (
(unlockWithMasterPasswordUnlockDataFlag$ | async) &&
unlockOptions.masterPassword.enabled &&
activeUnlockOption === UnlockOption.MasterPassword
) {
<bit-master-password-lock
[(activeUnlockOption)]="activeUnlockOption"
[unlockOptions]="unlockOptions"
[biometricUnlockBtnText]="biometricUnlockBtnText"
(successfulUnlock)="successfulMasterPasswordUnlock($event)"
(logOut)="logOut()"
></bit-master-password-lock>
} @else {
<ng-container
*ngIf="
unlockOptions.masterPassword.enabled && activeUnlockOption === UnlockOption.MasterPassword
"
>
<form [bitSubmit]="submit" [formGroup]="formGroup">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
bitIconButton
bitSuffix
bitPasswordInputToggle
[(toggled)]="showPassword"
></button>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<!-- [attr.aria-pressed]="showPassword" -->
</bit-form-field>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
<ng-container *ngIf="showBiometrics">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable"
block
(click)="activeUnlockOption = UnlockOption.Biometrics"
>
<span> {{ biometricUnlockBtnText | i18n }}</span>
</button>
</ng-container>
<ng-container *ngIf="unlockOptions.pin.enabled">
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption = UnlockOption.Pin"
>
{{ "unlockWithPin" | i18n }}
</button>
</ng-container>
<button type="button" bitButton bitFormButton block (click)="logOut()">
{{ "logOut" | i18n }}
</button>
</div>
</form>
</ng-container>
}
</ng-container>

View File

@@ -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<LockComponentService>();
const mockAnonLayoutWrapperDataService = mock<AnonLayoutWrapperDataService>();
const mockBroadcasterService = mock<BroadcasterService>();
const mockConfigService = mock<ConfigService>();
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 = {

View File

@@ -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<Record<ClientType, string>> = {
@@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial<Record<ClientType, string>> = {
[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<void>();
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<void> {
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<void> {
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,

View File

@@ -0,0 +1,55 @@
<form [formGroup]="formGroup" [bitSubmit]="submit">
<bit-form-field>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input
type="password"
formControlName="masterPassword"
bitInput
appAutofocus
name="masterPassword"
class="tw-font-mono"
required
appInputVerbatim
/>
<button type="button" bitIconButton bitSuffix bitPasswordInputToggle></button>
</bit-form-field>
<div class="tw-flex tw-flex-col tw-space-y-3">
<button type="submit" bitButton bitFormButton buttonType="primary" block>
{{ "unlock" | i18n }}
</button>
<p class="tw-text-center">{{ "or" | i18n }}</p>
@if (showBiometricsSwap()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
[disabled]="!biometricsAvailable()"
block
(click)="activeUnlockOption.set(UnlockOption.Biometrics)"
>
<span> {{ biometricUnlockBtnText() | i18n }}</span>
</button>
}
@if (showPinSwap()) {
<button
type="button"
bitButton
bitFormButton
buttonType="secondary"
block
(click)="activeUnlockOption.set(UnlockOption.Pin)"
>
{{ "unlockWithPin" | i18n }}
</button>
}
<button type="button" bitButton bitFormButton block (click)="logOut.emit()">
{{ "logOut" | i18n }}
</button>
</div>
</form>

View File

@@ -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<MasterPasswordLockComponent>;
const accountService = mock<AccountService>();
const masterPasswordUnlockService = mock<MasterPasswordUnlockService>();
const i18nService = mock<I18nService>();
const toastService = mock<ToastService>();
const logService = mock<LogService>();
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<UnlockOptions> = {},
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<typeof setupComponent>;
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<typeof setupComponent>;
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,
);
});
});
});

View File

@@ -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<UnlockOptionValue>();
unlockOptions = input.required<UnlockOptions>();
biometricUnlockBtnText = input.required<string>();
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<void>();
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<void> {
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"),
});
}
}
}

View File

@@ -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<RestrictedItemTypesService>;
let fetchMock: jest.Mock;
const userId = "" as UserId;
const userId = emptyGuid as UserId;
beforeEach(() => {
cryptoFunctionService = mock<CryptoFunctionService>();

View File

@@ -201,6 +201,10 @@ export class IndividualVaultExportService
}
private async getEncryptedExport(activeUserId: UserId): Promise<ExportedVaultAsString> {
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 = {

View File

@@ -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];

View File

@@ -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<GeneratedPasswordHistory[]> {
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);

View File

@@ -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<SendView> {
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) {