mirror of
https://github.com/bitwarden/browser
synced 2025-12-13 14:53:33 +00:00
[PM-23995] Updated change kdf component for Forced update KDF settings (#16516)
* move change-kdf into KM ownership * Change kdf component update for Forced KDF update * correct validators load on init * incorrect feature flag observable check * unit test coverage * unit test coverage * remove Close button, wrong icon * change to `pm-23995-no-logout-on-kdf-change` feature flag * updated unit tests * revert bad merge Signed-off-by: Maciej Zieniuk <mzieniuk@bitwarden.com> * updated wording, TS strict enabled, use form controls, updated tests * use localisation for button label * small margin in confirmation dialog * simpler I18nService mock --------- Signed-off-by: Maciej Zieniuk <mzieniuk@bitwarden.com>
This commit is contained in:
@@ -1,12 +1,14 @@
|
|||||||
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
|
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
|
||||||
<bit-dialog>
|
<bit-dialog>
|
||||||
<span bitDialogTitle>
|
<span bitDialogTitle>
|
||||||
{{ "changeKdf" | i18n }}
|
{{ "updateYourEncryptionSettings" | i18n }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<span bitDialogContent>
|
<span bitDialogContent>
|
||||||
|
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
|
||||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||||
<bit-form-field>
|
}
|
||||||
|
<bit-form-field disableMargin>
|
||||||
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
<bit-label>{{ "masterPass" | i18n }}</bit-label>
|
||||||
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
|
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
|
||||||
<button
|
<button
|
||||||
@@ -18,12 +20,12 @@
|
|||||||
></button>
|
></button>
|
||||||
<bit-hint>
|
<bit-hint>
|
||||||
{{ "confirmIdentity" | i18n }}
|
{{ "confirmIdentity" | i18n }}
|
||||||
</bit-hint></bit-form-field
|
</bit-hint>
|
||||||
>
|
</bit-form-field>
|
||||||
</span>
|
</span>
|
||||||
<ng-container bitDialogFooter>
|
<ng-container bitDialogFooter>
|
||||||
<button bitButton buttonType="primary" type="submit" bitFormButton>
|
<button bitButton buttonType="primary" type="submit" bitFormButton>
|
||||||
<span>{{ "changeKdf" | i18n }}</span>
|
<span>{{ "updateSettings" | i18n }}</span>
|
||||||
</button>
|
</button>
|
||||||
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
|
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
|
||||||
{{ "cancel" | i18n }}
|
{{ "cancel" | i18n }}
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { FormControl } from "@angular/forms";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||||
|
import { KdfType, PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import { SharedModule } from "../../shared";
|
||||||
|
|
||||||
|
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
||||||
|
|
||||||
|
describe("ChangeKdfConfirmationComponent", () => {
|
||||||
|
let component: ChangeKdfConfirmationComponent;
|
||||||
|
let fixture: ComponentFixture<ChangeKdfConfirmationComponent>;
|
||||||
|
|
||||||
|
// Mock Services
|
||||||
|
let mockI18nService: MockProxy<I18nService>;
|
||||||
|
let mockMessagingService: MockProxy<MessagingService>;
|
||||||
|
let mockToastService: MockProxy<ToastService>;
|
||||||
|
let mockDialogRef: MockProxy<DialogRef<ChangeKdfConfirmationComponent>>;
|
||||||
|
let mockConfigService: MockProxy<ConfigService>;
|
||||||
|
let accountService: FakeAccountService;
|
||||||
|
let mockChangeKdfService: MockProxy<ChangeKdfService>;
|
||||||
|
|
||||||
|
const mockUserId = "user-id" as UserId;
|
||||||
|
const mockEmail = "email";
|
||||||
|
const mockMasterPassword = "master-password";
|
||||||
|
const mockDialogData = jest.fn();
|
||||||
|
const kdfConfig = new PBKDF2KdfConfig(600_001);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockI18nService = mock<I18nService>();
|
||||||
|
mockMessagingService = mock<MessagingService>();
|
||||||
|
mockToastService = mock<ToastService>();
|
||||||
|
mockDialogRef = mock<DialogRef<ChangeKdfConfirmationComponent>>();
|
||||||
|
mockConfigService = mock<ConfigService>();
|
||||||
|
accountService = mockAccountServiceWith(mockUserId, { email: mockEmail });
|
||||||
|
mockChangeKdfService = mock<ChangeKdfService>();
|
||||||
|
|
||||||
|
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||||
|
|
||||||
|
// Mock config service feature flag
|
||||||
|
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
|
mockDialogData.mockReturnValue({
|
||||||
|
kdf: KdfType.PBKDF2_SHA256,
|
||||||
|
kdfConfig,
|
||||||
|
});
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ChangeKdfConfirmationComponent],
|
||||||
|
imports: [SharedModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: I18nService, useValue: mockI18nService },
|
||||||
|
{ provide: MessagingService, useValue: mockMessagingService },
|
||||||
|
{ provide: AccountService, useValue: accountService },
|
||||||
|
{ provide: ToastService, useValue: mockToastService },
|
||||||
|
{ provide: DialogRef, useValue: mockDialogRef },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: ChangeKdfService, useValue: mockChangeKdfService },
|
||||||
|
{
|
||||||
|
provide: DIALOG_DATA,
|
||||||
|
useFactory: mockDialogData,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Component Initialization", () => {
|
||||||
|
it("should create component with PBKDF2 config", () => {
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
expect(component.kdfConfig).toBeInstanceOf(PBKDF2KdfConfig);
|
||||||
|
expect(component.kdfConfig.iterations).toBe(600_001);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create component with Argon2id config", () => {
|
||||||
|
mockDialogData.mockReturnValue({
|
||||||
|
kdf: KdfType.Argon2id,
|
||||||
|
kdfConfig: new Argon2KdfConfig(4, 65, 5),
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
expect(component.kdfConfig).toBeInstanceOf(Argon2KdfConfig);
|
||||||
|
const kdfConfig = component.kdfConfig as Argon2KdfConfig;
|
||||||
|
expect(kdfConfig.iterations).toBe(4);
|
||||||
|
expect(kdfConfig.memory).toBe(65);
|
||||||
|
expect(kdfConfig.parallelism).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize form with required master password field", () => {
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
expect(component.form.controls.masterPassword).toBeInstanceOf(FormControl);
|
||||||
|
expect(component.form.controls.masterPassword.value).toEqual(null);
|
||||||
|
expect(component.form.controls.masterPassword.hasError("required")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Form Validation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be invalid when master password is empty", () => {
|
||||||
|
component.form.controls.masterPassword.setValue("");
|
||||||
|
expect(component.form.invalid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be valid when master password is provided", () => {
|
||||||
|
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
expect(component.form.valid).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("submit method", () => {
|
||||||
|
describe("should not update kdf and not show success toast", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when form is invalid", async () => {
|
||||||
|
// Arrange
|
||||||
|
component.form.controls.masterPassword.setValue("");
|
||||||
|
expect(component.form.invalid).toBe(true);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when no active account", async () => {
|
||||||
|
accountService.activeAccount$ = of(null);
|
||||||
|
|
||||||
|
await expect(component.submit()).rejects.toThrow("Null or undefined account");
|
||||||
|
|
||||||
|
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("when kdf is invalid", async () => {
|
||||||
|
// Arrange
|
||||||
|
component.kdfConfig = new PBKDF2KdfConfig(1);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await expect(component.submit()).rejects.toThrow();
|
||||||
|
|
||||||
|
expect(mockChangeKdfService.updateUserKdfParams).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("should update kdf and show success toast", () => {
|
||||||
|
it("should set loading to true during submission", async () => {
|
||||||
|
// Arrange
|
||||||
|
let loadingDuringExecution = false;
|
||||||
|
mockChangeKdfService.updateUserKdfParams.mockImplementation(async () => {
|
||||||
|
loadingDuringExecution = component.loading;
|
||||||
|
});
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
expect(loadingDuringExecution).toBe(true);
|
||||||
|
expect(component.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("doesn't logout and closes the dialog when feature flag is enabled", async () => {
|
||||||
|
// Arrange
|
||||||
|
mockConfigService.getFeatureFlag$.mockReturnValue(of(true));
|
||||||
|
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
kdfConfig,
|
||||||
|
mockUserId,
|
||||||
|
);
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
message: "encKeySettingsChanged-used-i18n",
|
||||||
|
});
|
||||||
|
expect(mockDialogRef.close).toHaveBeenCalled();
|
||||||
|
expect(mockMessagingService.send).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a logout and displays a log back in toast when feature flag is disabled", async () => {
|
||||||
|
// Arrange
|
||||||
|
const fixture = TestBed.createComponent(ChangeKdfConfirmationComponent);
|
||||||
|
const component = fixture.componentInstance;
|
||||||
|
|
||||||
|
component.form.controls.masterPassword.setValue(mockMasterPassword);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.submit();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
|
||||||
|
mockMasterPassword,
|
||||||
|
kdfConfig,
|
||||||
|
mockUserId,
|
||||||
|
);
|
||||||
|
expect(mockToastService.showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
title: "encKeySettingsChanged-used-i18n",
|
||||||
|
message: "logBackIn-used-i18n",
|
||||||
|
});
|
||||||
|
expect(mockMessagingService.send).toHaveBeenCalledWith("logout");
|
||||||
|
expect(mockDialogRef.close).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
|
||||||
// @ts-strict-ignore
|
|
||||||
import { Component, Inject } from "@angular/core";
|
import { Component, Inject } from "@angular/core";
|
||||||
import { FormGroup, FormControl, Validators } from "@angular/forms";
|
import { FormGroup, FormControl, Validators } from "@angular/forms";
|
||||||
import { firstValueFrom } from "rxjs";
|
import { firstValueFrom, Observable } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||||
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
|
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
|
||||||
import { KdfConfig, KdfType } from "@bitwarden/key-management";
|
import { KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||||
|
|
||||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||||
@@ -23,12 +23,13 @@ export class ChangeKdfConfirmationComponent {
|
|||||||
kdfConfig: KdfConfig;
|
kdfConfig: KdfConfig;
|
||||||
|
|
||||||
form = new FormGroup({
|
form = new FormGroup({
|
||||||
masterPassword: new FormControl(null, Validators.required),
|
masterPassword: new FormControl<string | null>(null, Validators.required),
|
||||||
});
|
});
|
||||||
showPassword = false;
|
showPassword = false;
|
||||||
masterPassword: string;
|
|
||||||
loading = false;
|
loading = false;
|
||||||
|
|
||||||
|
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
private messagingService: MessagingService,
|
private messagingService: MessagingService,
|
||||||
@@ -36,9 +37,13 @@ export class ChangeKdfConfirmationComponent {
|
|||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private toastService: ToastService,
|
private toastService: ToastService,
|
||||||
private changeKdfService: ChangeKdfService,
|
private changeKdfService: ChangeKdfService,
|
||||||
|
private dialogRef: DialogRef<ChangeKdfConfirmationComponent>,
|
||||||
|
configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.kdfConfig = params.kdfConfig;
|
this.kdfConfig = params.kdfConfig;
|
||||||
this.masterPassword = null;
|
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.NoLogoutOnKdfChange,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
submit = async () => {
|
submit = async () => {
|
||||||
@@ -46,24 +51,32 @@ export class ChangeKdfConfirmationComponent {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
await this.makeKeyAndSaveAsync();
|
await this.makeKeyAndSave();
|
||||||
|
if (await firstValueFrom(this.noLogoutOnKdfChangeFeatureFlag$)) {
|
||||||
|
this.toastService.showToast({
|
||||||
|
variant: "success",
|
||||||
|
message: this.i18nService.t("encKeySettingsChanged"),
|
||||||
|
});
|
||||||
|
this.dialogRef.close();
|
||||||
|
} else {
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
variant: "success",
|
variant: "success",
|
||||||
title: this.i18nService.t("encKeySettingsChanged"),
|
title: this.i18nService.t("encKeySettingsChanged"),
|
||||||
message: this.i18nService.t("logBackIn"),
|
message: this.i18nService.t("logBackIn"),
|
||||||
});
|
});
|
||||||
this.messagingService.send("logout");
|
this.messagingService.send("logout");
|
||||||
|
}
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
private async makeKeyAndSaveAsync() {
|
private async makeKeyAndSave() {
|
||||||
const masterPassword = this.form.value.masterPassword;
|
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||||
|
|
||||||
|
const masterPassword = this.form.value.masterPassword!;
|
||||||
|
|
||||||
// Ensure the KDF config is valid.
|
// Ensure the KDF config is valid.
|
||||||
this.kdfConfig.validateKdfConfigForSetting();
|
this.kdfConfig.validateKdfConfigForSetting();
|
||||||
|
|
||||||
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
|
||||||
|
|
||||||
await this.changeKdfService.updateUserKdfParams(
|
await this.changeKdfService.updateUserKdfParams(
|
||||||
masterPassword,
|
masterPassword,
|
||||||
this.kdfConfig,
|
this.kdfConfig,
|
||||||
|
|||||||
@@ -1,31 +1,30 @@
|
|||||||
<h2 bitTypography="h2">{{ "encKeySettings" | i18n }}</h2>
|
<h2 bitTypography="h2" class="tw-mt-6">
|
||||||
|
{{ "encKeySettings" | i18n }}
|
||||||
|
</h2>
|
||||||
|
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
|
||||||
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
|
||||||
<p bitTypography="body1">
|
}
|
||||||
{{ "higherKDFIterations" | i18n }}
|
<p bitTypography="body1" class="tw-mt-4">
|
||||||
|
{{ "encryptionKeySettingsHowShouldWeEncryptYourData" | i18n }}
|
||||||
</p>
|
</p>
|
||||||
<p bitTypography="body1">
|
<p bitTypography="body1">
|
||||||
{{
|
{{ "encryptionKeySettingsIncreaseImproveSecurity" | i18n }}
|
||||||
"kdfToHighWarningIncreaseInIncrements"
|
|
||||||
| i18n: (isPBKDF2(kdfConfig) ? ("incrementsOf100,000" | i18n) : ("smallIncrements" | i18n))
|
|
||||||
}}
|
|
||||||
</p>
|
</p>
|
||||||
<form [formGroup]="formGroup" autocomplete="off">
|
<form [formGroup]="formGroup" autocomplete="off">
|
||||||
<div class="tw-grid tw-grid-cols-12 tw-gap-4">
|
<div class="tw-grid tw-grid-cols-12 tw-gap-x-4">
|
||||||
<div class="tw-col-span-6">
|
<div class="tw-col-span-6">
|
||||||
<bit-form-field>
|
<bit-form-field>
|
||||||
<bit-label
|
<bit-label>
|
||||||
>{{ "kdfAlgorithm" | i18n }}
|
{{ "algorithm" | i18n }}
|
||||||
<a
|
<button
|
||||||
class="tw-ml-auto"
|
type="button"
|
||||||
|
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
|
||||||
|
[bitPopoverTriggerFor]="algorithmPopover"
|
||||||
|
appA11yTitle="{{ 'encryptionKeySettingsAlgorithmPopoverTitle' | i18n }}"
|
||||||
bitLink
|
bitLink
|
||||||
href="https://bitwarden.com/help/kdf-algorithms"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
|
|
||||||
slot="end"
|
|
||||||
>
|
>
|
||||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
||||||
</a>
|
</button>
|
||||||
</bit-label>
|
</bit-label>
|
||||||
<bit-select formControlName="kdf">
|
<bit-select formControlName="kdf">
|
||||||
<bit-option
|
<bit-option
|
||||||
@@ -35,33 +34,12 @@
|
|||||||
></bit-option>
|
></bit-option>
|
||||||
</bit-select>
|
</bit-select>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
<bit-form-field formGroupName="kdfConfig" *ngIf="isArgon2(kdfConfig)">
|
|
||||||
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
|
|
||||||
<input
|
|
||||||
bitInput
|
|
||||||
formControlName="memory"
|
|
||||||
type="number"
|
|
||||||
[min]="ARGON2_MEMORY.min"
|
|
||||||
[max]="ARGON2_MEMORY.max"
|
|
||||||
/>
|
|
||||||
</bit-form-field>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="tw-col-span-6">
|
<div class="tw-col-span-6">
|
||||||
<div class="tw-mb-0">
|
@if (isPBKDF2(kdfConfig)) {
|
||||||
<bit-form-field formGroupName="kdfConfig" *ngIf="isPBKDF2(kdfConfig)">
|
<bit-form-field formGroupName="kdfConfig">
|
||||||
<bit-label>
|
<bit-label>
|
||||||
{{ "kdfIterations" | i18n }}
|
{{ "kdfIterations" | i18n }}
|
||||||
<a
|
|
||||||
bitLink
|
|
||||||
class="tw-ml-auto"
|
|
||||||
href="https://bitwarden.com/help/what-encryption-is-used/#changing-kdf-iterations"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
appA11yTitle="{{ 'learnMoreAboutKDFIterations' | i18n }}"
|
|
||||||
slot="end"
|
|
||||||
>
|
|
||||||
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
|
|
||||||
</a>
|
|
||||||
</bit-label>
|
</bit-label>
|
||||||
<input
|
<input
|
||||||
bitInput
|
bitInput
|
||||||
@@ -72,7 +50,21 @@
|
|||||||
/>
|
/>
|
||||||
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
|
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
<ng-container *ngIf="isArgon2(kdfConfig)">
|
} @else if (isArgon2(kdfConfig)) {
|
||||||
|
<bit-form-field formGroupName="kdfConfig">
|
||||||
|
<bit-label>{{ "kdfMemory" | i18n }}</bit-label>
|
||||||
|
<input
|
||||||
|
bitInput
|
||||||
|
formControlName="memory"
|
||||||
|
type="number"
|
||||||
|
[min]="ARGON2_MEMORY.min"
|
||||||
|
[max]="ARGON2_MEMORY.max"
|
||||||
|
/>
|
||||||
|
</bit-form-field>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (isArgon2(kdfConfig)) {
|
||||||
|
<div class="tw-col-span-6">
|
||||||
<bit-form-field formGroupName="kdfConfig">
|
<bit-form-field formGroupName="kdfConfig">
|
||||||
<bit-label>
|
<bit-label>
|
||||||
{{ "kdfIterations" | i18n }}
|
{{ "kdfIterations" | i18n }}
|
||||||
@@ -85,6 +77,8 @@
|
|||||||
[max]="ARGON2_ITERATIONS.max"
|
[max]="ARGON2_ITERATIONS.max"
|
||||||
/>
|
/>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
|
</div>
|
||||||
|
<div class="tw-col-span-6">
|
||||||
<bit-form-field formGroupName="kdfConfig">
|
<bit-form-field formGroupName="kdfConfig">
|
||||||
<bit-label>
|
<bit-label>
|
||||||
{{ "kdfParallelism" | i18n }}
|
{{ "kdfParallelism" | i18n }}
|
||||||
@@ -97,9 +91,8 @@
|
|||||||
[max]="ARGON2_PARALLELISM.max"
|
[max]="ARGON2_PARALLELISM.max"
|
||||||
/>
|
/>
|
||||||
</bit-form-field>
|
</bit-form-field>
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
(click)="openConfirmationModal()"
|
(click)="openConfirmationModal()"
|
||||||
@@ -107,7 +100,27 @@
|
|||||||
buttonType="primary"
|
buttonType="primary"
|
||||||
bitButton
|
bitButton
|
||||||
bitFormButton
|
bitFormButton
|
||||||
|
class="tw-mt-2"
|
||||||
>
|
>
|
||||||
{{ "changeKdf" | i18n }}
|
{{ "updateEncryptionSettings" | i18n }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<bit-popover [title]="'encryptionKeySettingsAlgorithmPopoverTitle' | i18n" #algorithmPopover>
|
||||||
|
<ul class="tw-mt-2 tw-mb-0 tw-ps-4">
|
||||||
|
<li class="tw-mb-2">{{ "encryptionKeySettingsAlgorithmPopoverPBKDF2" | i18n }}</li>
|
||||||
|
<li>{{ "encryptionKeySettingsAlgorithmPopoverArgon2Id" | i18n }}</li>
|
||||||
|
</ul>
|
||||||
|
<div class="tw-mt-4 tw-mb-1">
|
||||||
|
<a
|
||||||
|
href="https://bitwarden.com/help/kdf-algorithms/"
|
||||||
|
bitLink
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
appA11yTitle="{{ 'learnMoreAboutEncryptionAlgorithms' | i18n }}"
|
||||||
|
>
|
||||||
|
{{ "learnMore" | i18n }}
|
||||||
|
<i class="bwi bwi-external-link tw-ml-1" aria-hidden="true"></i>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</bit-popover>
|
||||||
|
|||||||
@@ -0,0 +1,365 @@
|
|||||||
|
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||||
|
import { FormBuilder, FormControl } from "@angular/forms";
|
||||||
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
|
import { of } from "rxjs";
|
||||||
|
|
||||||
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
|
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||||
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
|
import { DialogService, PopoverModule, CalloutModule } from "@bitwarden/components";
|
||||||
|
import {
|
||||||
|
KdfConfigService,
|
||||||
|
Argon2KdfConfig,
|
||||||
|
PBKDF2KdfConfig,
|
||||||
|
KdfType,
|
||||||
|
} from "@bitwarden/key-management";
|
||||||
|
|
||||||
|
import { SharedModule } from "../../shared";
|
||||||
|
|
||||||
|
import { ChangeKdfComponent } from "./change-kdf.component";
|
||||||
|
|
||||||
|
describe("ChangeKdfComponent", () => {
|
||||||
|
let component: ChangeKdfComponent;
|
||||||
|
let fixture: ComponentFixture<ChangeKdfComponent>;
|
||||||
|
|
||||||
|
// Mock Services
|
||||||
|
let mockDialogService: MockProxy<DialogService>;
|
||||||
|
let mockKdfConfigService: MockProxy<KdfConfigService>;
|
||||||
|
let mockConfigService: MockProxy<ConfigService>;
|
||||||
|
let mockI18nService: MockProxy<I18nService>;
|
||||||
|
let accountService: FakeAccountService;
|
||||||
|
let formBuilder: FormBuilder;
|
||||||
|
|
||||||
|
const mockUserId = "user-id" as UserId;
|
||||||
|
|
||||||
|
// Helper functions for validation testing
|
||||||
|
function expectPBKDF2Validation(
|
||||||
|
iterationsControl: FormControl<number | null>,
|
||||||
|
memoryControl: FormControl<number | null>,
|
||||||
|
parallelismControl: FormControl<number | null>,
|
||||||
|
) {
|
||||||
|
// Assert current validators state
|
||||||
|
expect(iterationsControl.hasError("required")).toBe(false);
|
||||||
|
expect(iterationsControl.hasError("min")).toBe(false);
|
||||||
|
expect(iterationsControl.hasError("max")).toBe(false);
|
||||||
|
expect(memoryControl.validator).toBeNull();
|
||||||
|
expect(parallelismControl.validator).toBeNull();
|
||||||
|
|
||||||
|
// Test validation boundaries
|
||||||
|
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.min - 1);
|
||||||
|
expect(iterationsControl.hasError("min")).toBe(true);
|
||||||
|
|
||||||
|
iterationsControl.setValue(PBKDF2KdfConfig.ITERATIONS.max + 1);
|
||||||
|
expect(iterationsControl.hasError("max")).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectArgon2Validation(
|
||||||
|
iterationsControl: FormControl<number | null>,
|
||||||
|
memoryControl: FormControl<number | null>,
|
||||||
|
parallelismControl: FormControl<number | null>,
|
||||||
|
) {
|
||||||
|
// Assert current validators state
|
||||||
|
expect(iterationsControl.hasError("required")).toBe(false);
|
||||||
|
expect(memoryControl.hasError("required")).toBe(false);
|
||||||
|
expect(parallelismControl.hasError("required")).toBe(false);
|
||||||
|
|
||||||
|
// Test validation boundaries - min values
|
||||||
|
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.min - 1);
|
||||||
|
expect(iterationsControl.hasError("min")).toBe(true);
|
||||||
|
|
||||||
|
memoryControl.setValue(Argon2KdfConfig.MEMORY.min - 1);
|
||||||
|
expect(memoryControl.hasError("min")).toBe(true);
|
||||||
|
|
||||||
|
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.min - 1);
|
||||||
|
expect(parallelismControl.hasError("min")).toBe(true);
|
||||||
|
|
||||||
|
// Test validation boundaries - max values
|
||||||
|
iterationsControl.setValue(Argon2KdfConfig.ITERATIONS.max + 1);
|
||||||
|
expect(iterationsControl.hasError("max")).toBe(true);
|
||||||
|
|
||||||
|
memoryControl.setValue(Argon2KdfConfig.MEMORY.max + 1);
|
||||||
|
expect(memoryControl.hasError("max")).toBe(true);
|
||||||
|
|
||||||
|
parallelismControl.setValue(Argon2KdfConfig.PARALLELISM.max + 1);
|
||||||
|
expect(parallelismControl.hasError("max")).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDialogService = mock<DialogService>();
|
||||||
|
mockKdfConfigService = mock<KdfConfigService>();
|
||||||
|
mockConfigService = mock<ConfigService>();
|
||||||
|
mockI18nService = mock<I18nService>();
|
||||||
|
accountService = mockAccountServiceWith(mockUserId);
|
||||||
|
formBuilder = new FormBuilder();
|
||||||
|
|
||||||
|
mockConfigService.getFeatureFlag$.mockReturnValue(of(false));
|
||||||
|
|
||||||
|
mockI18nService.t.mockImplementation((key) => `${key}-used-i18n`);
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
declarations: [ChangeKdfComponent],
|
||||||
|
imports: [SharedModule, PopoverModule, CalloutModule],
|
||||||
|
providers: [
|
||||||
|
{ provide: DialogService, useValue: mockDialogService },
|
||||||
|
{ provide: KdfConfigService, useValue: mockKdfConfigService },
|
||||||
|
{ provide: AccountService, useValue: accountService },
|
||||||
|
{ provide: FormBuilder, useValue: formBuilder },
|
||||||
|
{ provide: ConfigService, useValue: mockConfigService },
|
||||||
|
{ provide: I18nService, useValue: mockI18nService },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Component Initialization", () => {
|
||||||
|
describe("given PBKDF2 configuration", () => {
|
||||||
|
it("should initialize form with PBKDF2 values and validators when component loads", async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
// Extract form controls
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Assert form values
|
||||||
|
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000);
|
||||||
|
expect(kdfConfigFormGroup.controls.memory.value).toBeNull();
|
||||||
|
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull();
|
||||||
|
expect(component.kdfConfig).toEqual(mockPBKDF2Config);
|
||||||
|
|
||||||
|
// Assert validators
|
||||||
|
expectPBKDF2Validation(
|
||||||
|
kdfConfigFormGroup.controls.iterations,
|
||||||
|
kdfConfigFormGroup.controls.memory,
|
||||||
|
kdfConfigFormGroup.controls.parallelism,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("given Argon2id configuration", () => {
|
||||||
|
it("should initialize form with Argon2id values and validators when component loads", async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockArgon2Config = new Argon2KdfConfig(3, 64, 4);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
// Extract form controls
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Assert form values
|
||||||
|
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3);
|
||||||
|
expect(kdfConfigFormGroup.controls.memory.value).toBe(64);
|
||||||
|
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4);
|
||||||
|
expect(component.kdfConfig).toEqual(mockArgon2Config);
|
||||||
|
|
||||||
|
// Assert validators
|
||||||
|
expectArgon2Validation(
|
||||||
|
kdfConfigFormGroup.controls.iterations,
|
||||||
|
kdfConfigFormGroup.controls.memory,
|
||||||
|
kdfConfigFormGroup.controls.parallelism,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[true, false],
|
||||||
|
[false, true],
|
||||||
|
])(
|
||||||
|
"should show log out banner = %s when feature flag observable is %s",
|
||||||
|
async (showLogOutBanner, forceUpgradeKdfFeatureFlag) => {
|
||||||
|
// Arrange
|
||||||
|
const mockPBKDF2Config = new PBKDF2KdfConfig(600_000);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||||
|
mockConfigService.getFeatureFlag$.mockReturnValue(of(forceUpgradeKdfFeatureFlag));
|
||||||
|
|
||||||
|
// Act
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
fixture.detectChanges();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
const calloutElement = fixture.debugElement.query((el) =>
|
||||||
|
el.nativeElement.textContent?.includes("kdfSettingsChangeLogoutWarning"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (showLogOutBanner) {
|
||||||
|
expect(calloutElement).not.toBeNull();
|
||||||
|
expect(calloutElement.nativeElement.textContent).toContain(
|
||||||
|
"kdfSettingsChangeLogoutWarning-used-i18n",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
expect(calloutElement).toBeNull();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("KDF Type Switching", () => {
|
||||||
|
describe("switching from PBKDF2 to Argon2id", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup component with initial PBKDF2 configuration
|
||||||
|
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update form structure and default values when KDF type changes to Argon2id", () => {
|
||||||
|
// Arrange
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Act - change KDF type to Argon2id
|
||||||
|
formGroup.controls.kdf.setValue(KdfType.Argon2id);
|
||||||
|
|
||||||
|
// Assert form values update to Argon2id defaults
|
||||||
|
expect(formGroup.controls.kdf.value).toBe(KdfType.Argon2id);
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expect(kdfConfigFormGroup.controls.iterations.value).toBe(3); // Argon2id default
|
||||||
|
expect(kdfConfigFormGroup.controls.memory.value).toBe(64); // Argon2id default
|
||||||
|
expect(kdfConfigFormGroup.controls.parallelism.value).toBe(4); // Argon2id default
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update validators when KDF type changes to Argon2id", () => {
|
||||||
|
// Arrange
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Act - change KDF type to Argon2id
|
||||||
|
formGroup.controls.kdf.setValue(KdfType.Argon2id);
|
||||||
|
|
||||||
|
// Assert validators update to Argon2id validation rules
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expectArgon2Validation(
|
||||||
|
kdfConfigFormGroup.controls.iterations,
|
||||||
|
kdfConfigFormGroup.controls.memory,
|
||||||
|
kdfConfigFormGroup.controls.parallelism,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("switching from Argon2id to PBKDF2", () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Setup component with initial Argon2id configuration
|
||||||
|
const mockArgon2IdConfig = new Argon2KdfConfig(4, 65, 5);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2IdConfig);
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update form structure and default values when KDF type changes to PBKDF2", () => {
|
||||||
|
// Arrange
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Act - change KDF type back to PBKDF2
|
||||||
|
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
|
||||||
|
|
||||||
|
// Assert form values update to PBKDF2 defaults
|
||||||
|
expect(formGroup.controls.kdf.value).toBe(KdfType.PBKDF2_SHA256);
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expect(kdfConfigFormGroup.controls.iterations.value).toBe(600_000); // PBKDF2 default
|
||||||
|
expect(kdfConfigFormGroup.controls.memory.value).toBeNull(); // PBKDF2 doesn't use memory
|
||||||
|
expect(kdfConfigFormGroup.controls.parallelism.value).toBeNull(); // PBKDF2 doesn't use parallelism
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update validators when KDF type changes to PBKDF2", () => {
|
||||||
|
// Arrange
|
||||||
|
const formGroup = component["formGroup"];
|
||||||
|
|
||||||
|
// Act - change KDF type back to PBKDF2
|
||||||
|
formGroup.controls.kdf.setValue(KdfType.PBKDF2_SHA256);
|
||||||
|
|
||||||
|
// Assert validators update to PBKDF2 validation rules
|
||||||
|
const kdfConfigFormGroup = formGroup.controls.kdfConfig;
|
||||||
|
expectPBKDF2Validation(
|
||||||
|
kdfConfigFormGroup.controls.iterations,
|
||||||
|
kdfConfigFormGroup.controls.memory,
|
||||||
|
kdfConfigFormGroup.controls.parallelism,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openConfirmationModal", () => {
|
||||||
|
describe("when form is valid", () => {
|
||||||
|
it("should open confirmation modal with PBKDF2 config when form is submitted", async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockPBKDF2Config = new PBKDF2KdfConfig(600_001);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.openConfirmationModal();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
kdfConfig: mockPBKDF2Config,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should open confirmation modal with Argon2id config when form is submitted", async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockArgon2Config = new Argon2KdfConfig(4, 65, 5);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockArgon2Config);
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.openConfirmationModal();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockDialogService.open).toHaveBeenCalledWith(
|
||||||
|
expect.any(Function),
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
kdfConfig: mockArgon2Config,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not open modal when form is invalid", async () => {
|
||||||
|
// Arrange
|
||||||
|
const mockPBKDF2Config = new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.min - 1);
|
||||||
|
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockPBKDF2Config);
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ChangeKdfComponent);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await component.ngOnInit();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await component.openConfirmationModal();
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
expect(mockDialogService.open).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
|
||||||
// @ts-strict-ignore
|
|
||||||
import { Component, OnDestroy, OnInit } from "@angular/core";
|
import { Component, OnDestroy, OnInit } from "@angular/core";
|
||||||
import { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
|
import { FormBuilder, FormControl, Validators } from "@angular/forms";
|
||||||
import { Subject, firstValueFrom, takeUntil } from "rxjs";
|
import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs";
|
||||||
|
|
||||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||||
|
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||||
|
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import {
|
import {
|
||||||
KdfConfigService,
|
KdfConfigService,
|
||||||
@@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
|||||||
private destroy$ = new Subject<void>();
|
private destroy$ = new Subject<void>();
|
||||||
|
|
||||||
protected formGroup = this.formBuilder.group({
|
protected formGroup = this.formBuilder.group({
|
||||||
kdf: new FormControl(KdfType.PBKDF2_SHA256, [Validators.required]),
|
kdf: new FormControl<KdfType>(KdfType.PBKDF2_SHA256, [Validators.required]),
|
||||||
kdfConfig: this.formBuilder.group({
|
kdfConfig: this.formBuilder.group({
|
||||||
iterations: [this.kdfConfig.iterations],
|
iterations: new FormControl<number | null>(null),
|
||||||
memory: [null as number],
|
memory: new FormControl<number | null>(null),
|
||||||
parallelism: [null as number],
|
parallelism: new FormControl<number | null>(null),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
|||||||
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
|
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
|
||||||
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
|
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
|
||||||
|
|
||||||
|
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
private kdfConfigService: KdfConfigService,
|
private kdfConfigService: KdfConfigService,
|
||||||
private accountService: AccountService,
|
private accountService: AccountService,
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
|
configService: ConfigService,
|
||||||
) {
|
) {
|
||||||
this.kdfOptions = [
|
this.kdfOptions = [
|
||||||
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
|
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
|
||||||
{ name: "Argon2id", value: KdfType.Argon2id },
|
{ name: "Argon2id", value: KdfType.Argon2id },
|
||||||
];
|
];
|
||||||
|
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
|
||||||
|
FeatureFlag.NoLogoutOnKdfChange,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
|
||||||
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
this.kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
|
||||||
this.formGroup.get("kdf").setValue(this.kdfConfig.kdfType);
|
this.formGroup.controls.kdf.setValue(this.kdfConfig.kdfType);
|
||||||
this.setFormControlValues(this.kdfConfig);
|
this.setFormControlValues(this.kdfConfig);
|
||||||
|
this.setFormValidators(this.kdfConfig.kdfType);
|
||||||
|
|
||||||
this.formGroup
|
this.formGroup.controls.kdf.valueChanges
|
||||||
.get("kdf")
|
.pipe(takeUntil(this.destroy$))
|
||||||
.valueChanges.pipe(takeUntil(this.destroy$))
|
|
||||||
.subscribe((newValue) => {
|
.subscribe((newValue) => {
|
||||||
this.updateKdfConfig(newValue);
|
this.updateKdfConfig(newValue!);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
private updateKdfConfig(newValue: KdfType) {
|
private updateKdfConfig(newValue: KdfType) {
|
||||||
let config: KdfConfig;
|
let config: KdfConfig;
|
||||||
const validators: { [key: string]: ValidatorFn[] } = {
|
|
||||||
iterations: [],
|
|
||||||
memory: [],
|
|
||||||
parallelism: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (newValue) {
|
switch (newValue) {
|
||||||
case KdfType.PBKDF2_SHA256:
|
case KdfType.PBKDF2_SHA256:
|
||||||
config = new PBKDF2KdfConfig();
|
config = new PBKDF2KdfConfig();
|
||||||
validators.iterations = [
|
|
||||||
Validators.required,
|
|
||||||
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
|
|
||||||
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
case KdfType.Argon2id:
|
case KdfType.Argon2id:
|
||||||
config = new Argon2KdfConfig();
|
config = new Argon2KdfConfig();
|
||||||
validators.iterations = [
|
|
||||||
Validators.required,
|
|
||||||
Validators.min(Argon2KdfConfig.ITERATIONS.min),
|
|
||||||
Validators.max(Argon2KdfConfig.ITERATIONS.max),
|
|
||||||
];
|
|
||||||
validators.memory = [
|
|
||||||
Validators.required,
|
|
||||||
Validators.min(Argon2KdfConfig.MEMORY.min),
|
|
||||||
Validators.max(Argon2KdfConfig.MEMORY.max),
|
|
||||||
];
|
|
||||||
validators.parallelism = [
|
|
||||||
Validators.required,
|
|
||||||
Validators.min(Argon2KdfConfig.PARALLELISM.min),
|
|
||||||
Validators.max(Argon2KdfConfig.PARALLELISM.max),
|
|
||||||
];
|
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error("Unknown KDF type.");
|
throw new Error("Unknown KDF type.");
|
||||||
}
|
}
|
||||||
|
|
||||||
this.kdfConfig = config;
|
this.kdfConfig = config;
|
||||||
this.setFormValidators(validators);
|
this.setFormValidators(newValue);
|
||||||
this.setFormControlValues(this.kdfConfig);
|
this.setFormControlValues(this.kdfConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
private setFormValidators(validators: { [key: string]: ValidatorFn[] }) {
|
private setFormValidators(kdfType: KdfType) {
|
||||||
this.setValidators("kdfConfig.iterations", validators.iterations);
|
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||||
this.setValidators("kdfConfig.memory", validators.memory);
|
switch (kdfType) {
|
||||||
this.setValidators("kdfConfig.parallelism", validators.parallelism);
|
case KdfType.PBKDF2_SHA256:
|
||||||
}
|
kdfConfigFormGroup.controls.iterations.setValidators([
|
||||||
private setValidators(controlName: string, validators: ValidatorFn[]) {
|
Validators.required,
|
||||||
const control = this.formGroup.get(controlName);
|
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
|
||||||
if (control) {
|
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
|
||||||
control.setValidators(validators);
|
]);
|
||||||
control.updateValueAndValidity();
|
kdfConfigFormGroup.controls.memory.setValidators([]);
|
||||||
|
kdfConfigFormGroup.controls.parallelism.setValidators([]);
|
||||||
|
break;
|
||||||
|
case KdfType.Argon2id:
|
||||||
|
kdfConfigFormGroup.controls.iterations.setValidators([
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(Argon2KdfConfig.ITERATIONS.min),
|
||||||
|
Validators.max(Argon2KdfConfig.ITERATIONS.max),
|
||||||
|
]);
|
||||||
|
kdfConfigFormGroup.controls.memory.setValidators([
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(Argon2KdfConfig.MEMORY.min),
|
||||||
|
Validators.max(Argon2KdfConfig.MEMORY.max),
|
||||||
|
]);
|
||||||
|
kdfConfigFormGroup.controls.parallelism.setValidators([
|
||||||
|
Validators.required,
|
||||||
|
Validators.min(Argon2KdfConfig.PARALLELISM.min),
|
||||||
|
Validators.max(Argon2KdfConfig.PARALLELISM.max),
|
||||||
|
]);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error("Unknown KDF type.");
|
||||||
}
|
}
|
||||||
|
kdfConfigFormGroup.controls.iterations.updateValueAndValidity();
|
||||||
|
kdfConfigFormGroup.controls.memory.updateValueAndValidity();
|
||||||
|
kdfConfigFormGroup.controls.parallelism.updateValueAndValidity();
|
||||||
}
|
}
|
||||||
|
|
||||||
private setFormControlValues(kdfConfig: KdfConfig) {
|
private setFormControlValues(kdfConfig: KdfConfig) {
|
||||||
this.formGroup.get("kdfConfig").reset();
|
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||||
|
kdfConfigFormGroup.reset();
|
||||||
if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
if (kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
||||||
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
|
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
|
||||||
} else if (kdfConfig.kdfType === KdfType.Argon2id) {
|
} else if (kdfConfig.kdfType === KdfType.Argon2id) {
|
||||||
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
|
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
|
||||||
this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory);
|
kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory);
|
||||||
this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism);
|
kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
|
|||||||
if (this.formGroup.invalid) {
|
if (this.formGroup.invalid) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
|
||||||
if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
if (this.kdfConfig.kdfType === KdfType.PBKDF2_SHA256) {
|
||||||
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
|
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
|
||||||
} else if (this.kdfConfig.kdfType === KdfType.Argon2id) {
|
} else if (this.kdfConfig.kdfType === KdfType.Argon2id) {
|
||||||
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
|
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
|
||||||
this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value;
|
this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!;
|
||||||
this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value;
|
this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!;
|
||||||
}
|
}
|
||||||
this.dialogService.open(ChangeKdfConfirmationComponent, {
|
this.dialogService.open(ChangeKdfConfirmationComponent, {
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { NgModule } from "@angular/core";
|
import { NgModule } from "@angular/core";
|
||||||
|
|
||||||
|
import { PopoverModule } from "@bitwarden/components";
|
||||||
|
|
||||||
import { SharedModule } from "../../shared";
|
import { SharedModule } from "../../shared";
|
||||||
|
|
||||||
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
|
||||||
import { ChangeKdfComponent } from "./change-kdf.component";
|
import { ChangeKdfComponent } from "./change-kdf.component";
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, SharedModule],
|
imports: [CommonModule, SharedModule, PopoverModule],
|
||||||
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
||||||
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1719,7 +1719,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"dontAskAgainOnThisDeviceFor30Days": {
|
"dontAskAgainOnThisDeviceFor30Days": {
|
||||||
"message": "Don't ask again on this device for 30 days"
|
"message": "Don't ask again on this device for 30 days"
|
||||||
},
|
},
|
||||||
@@ -2090,9 +2089,6 @@
|
|||||||
"encKeySettings": {
|
"encKeySettings": {
|
||||||
"message": "Encryption key settings"
|
"message": "Encryption key settings"
|
||||||
},
|
},
|
||||||
"kdfAlgorithm": {
|
|
||||||
"message": "KDF algorithm"
|
|
||||||
},
|
|
||||||
"kdfIterations": {
|
"kdfIterations": {
|
||||||
"message": "KDF iterations"
|
"message": "KDF iterations"
|
||||||
},
|
},
|
||||||
@@ -2127,9 +2123,6 @@
|
|||||||
"argon2Desc": {
|
"argon2Desc": {
|
||||||
"message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker."
|
"message": "Higher KDF iterations, memory, and parallelism can help protect your master password from being brute forced by an attacker."
|
||||||
},
|
},
|
||||||
"changeKdf": {
|
|
||||||
"message": "Change KDF"
|
|
||||||
},
|
|
||||||
"encKeySettingsChanged": {
|
"encKeySettingsChanged": {
|
||||||
"message": "Encryption key settings saved"
|
"message": "Encryption key settings saved"
|
||||||
},
|
},
|
||||||
@@ -10374,27 +10367,9 @@
|
|||||||
"memberAccessReportAuthenticationEnabledFalse": {
|
"memberAccessReportAuthenticationEnabledFalse": {
|
||||||
"message": "Off"
|
"message": "Off"
|
||||||
},
|
},
|
||||||
"higherKDFIterations": {
|
|
||||||
"message": "Higher KDF iterations can help protect your master password from being brute forced by an attacker."
|
|
||||||
},
|
|
||||||
"incrementsOf100,000": {
|
|
||||||
"message": "increments of 100,000"
|
|
||||||
},
|
|
||||||
"smallIncrements": {
|
|
||||||
"message": "small increments"
|
|
||||||
},
|
|
||||||
"kdfIterationRecommends": {
|
"kdfIterationRecommends": {
|
||||||
"message": "We recommend 600,000 or more"
|
"message": "We recommend 600,000 or more"
|
||||||
},
|
},
|
||||||
"kdfToHighWarningIncreaseInIncrements": {
|
|
||||||
"message": "For older devices, setting your KDF too high may lead to performance issues. Increase the value in $VALUE$ and test your devices.",
|
|
||||||
"placeholders": {
|
|
||||||
"value": {
|
|
||||||
"content": "$1",
|
|
||||||
"example": "increments of 100,000"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"providerReinstate": {
|
"providerReinstate": {
|
||||||
"message": " Contact Customer Support to reinstate your subscription."
|
"message": " Contact Customer Support to reinstate your subscription."
|
||||||
},
|
},
|
||||||
@@ -11878,5 +11853,32 @@
|
|||||||
},
|
},
|
||||||
"viewbusinessplans": {
|
"viewbusinessplans": {
|
||||||
"message": "View business plans"
|
"message": "View business plans"
|
||||||
|
},
|
||||||
|
"updateEncryptionSettings": {
|
||||||
|
"message": "Update encryption settings"
|
||||||
|
},
|
||||||
|
"updateYourEncryptionSettings": {
|
||||||
|
"message": "Update your encryption settings"
|
||||||
|
},
|
||||||
|
"updateSettings": {
|
||||||
|
"message": "Update settings"
|
||||||
|
},
|
||||||
|
"algorithm": {
|
||||||
|
"message": "Algorithm"
|
||||||
|
},
|
||||||
|
"encryptionKeySettingsHowShouldWeEncryptYourData": {
|
||||||
|
"message": "Choose how Bitwarden should encrypt your vault data. All options are secure, but stronger methods offer better protection - especially against brute-force attacks. Bitwarden recommends the default setting for most users."
|
||||||
|
},
|
||||||
|
"encryptionKeySettingsIncreaseImproveSecurity": {
|
||||||
|
"message": "Increasing the values above the default will improve security, but your vault may take longer to unlock as a result."
|
||||||
|
},
|
||||||
|
"encryptionKeySettingsAlgorithmPopoverTitle": {
|
||||||
|
"message": "About encryption algorithms"
|
||||||
|
},
|
||||||
|
"encryptionKeySettingsAlgorithmPopoverPBKDF2": {
|
||||||
|
"message": "PBKDF2-SHA256 is a well-tested encryption method that balances security and performance. Good for all users."
|
||||||
|
},
|
||||||
|
"encryptionKeySettingsAlgorithmPopoverArgon2Id": {
|
||||||
|
"message": "Argon2id offers stronger protection against modern attacks. Best for advanced users with powerful devices."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user