1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-12 22:33:35 +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:
Maciej Zieniuk
2025-10-22 20:29:36 +02:00
committed by GitHub
parent 91be36bfcf
commit 8154613462
8 changed files with 848 additions and 199 deletions

View File

@@ -1,12 +1,14 @@
<form [formGroup]="form" [bitSubmit]="submit" autocomplete="off">
<bit-dialog>
<span bitDialogTitle>
{{ "changeKdf" | i18n }}
{{ "updateYourEncryptionSettings" | i18n }}
</span>
<span bitDialogContent>
@if (!(noLogoutOnKdfChangeFeatureFlag$ | async)) {
<bit-callout type="warning">{{ "kdfSettingsChangeLogoutWarning" | i18n }}</bit-callout>
<bit-form-field>
}
<bit-form-field disableMargin>
<bit-label>{{ "masterPass" | i18n }}</bit-label>
<input bitInput type="password" formControlName="masterPassword" appAutofocus />
<button
@@ -18,12 +20,12 @@
></button>
<bit-hint>
{{ "confirmIdentity" | i18n }}
</bit-hint></bit-form-field
>
</bit-hint>
</bit-form-field>
</span>
<ng-container bitDialogFooter>
<button bitButton buttonType="primary" type="submit" bitFormButton>
<span>{{ "changeKdf" | i18n }}</span>
<span>{{ "updateSettings" | i18n }}</span>
</button>
<button bitButton buttonType="secondary" type="button" bitFormButton bitDialogClose>
{{ "cancel" | i18n }}

View File

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

View File

@@ -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 { 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 { 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 { 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 { DIALOG_DATA, ToastService } from "@bitwarden/components";
import { DIALOG_DATA, DialogRef, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
@@ -23,12 +23,13 @@ export class ChangeKdfConfirmationComponent {
kdfConfig: KdfConfig;
form = new FormGroup({
masterPassword: new FormControl(null, Validators.required),
masterPassword: new FormControl<string | null>(null, Validators.required),
});
showPassword = false;
masterPassword: string;
loading = false;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private i18nService: I18nService,
private messagingService: MessagingService,
@@ -36,9 +37,13 @@ export class ChangeKdfConfirmationComponent {
private accountService: AccountService,
private toastService: ToastService,
private changeKdfService: ChangeKdfService,
private dialogRef: DialogRef<ChangeKdfConfirmationComponent>,
configService: ConfigService,
) {
this.kdfConfig = params.kdfConfig;
this.masterPassword = null;
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
submit = async () => {
@@ -46,24 +51,32 @@ export class ChangeKdfConfirmationComponent {
return;
}
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({
variant: "success",
title: this.i18nService.t("encKeySettingsChanged"),
message: this.i18nService.t("logBackIn"),
});
this.messagingService.send("logout");
}
this.loading = false;
};
private async makeKeyAndSaveAsync() {
const masterPassword = this.form.value.masterPassword;
private async makeKeyAndSave() {
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const masterPassword = this.form.value.masterPassword!;
// Ensure the KDF config is valid.
this.kdfConfig.validateKdfConfigForSetting();
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
await this.changeKdfService.updateUserKdfParams(
masterPassword,
this.kdfConfig,

View File

@@ -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>
<p bitTypography="body1">
{{ "higherKDFIterations" | i18n }}
}
<p bitTypography="body1" class="tw-mt-4">
{{ "encryptionKeySettingsHowShouldWeEncryptYourData" | i18n }}
</p>
<p bitTypography="body1">
{{
"kdfToHighWarningIncreaseInIncrements"
| i18n: (isPBKDF2(kdfConfig) ? ("incrementsOf100,000" | i18n) : ("smallIncrements" | i18n))
}}
{{ "encryptionKeySettingsIncreaseImproveSecurity" | i18n }}
</p>
<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">
<bit-form-field>
<bit-label
>{{ "kdfAlgorithm" | i18n }}
<a
class="tw-ml-auto"
<bit-label>
{{ "algorithm" | i18n }}
<button
type="button"
class="tw-border-none tw-bg-transparent tw-text-primary-600 tw-p-0"
[bitPopoverTriggerFor]="algorithmPopover"
appA11yTitle="{{ 'encryptionKeySettingsAlgorithmPopoverTitle' | i18n }}"
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>
</a>
</button>
</bit-label>
<bit-select formControlName="kdf">
<bit-option
@@ -35,33 +34,12 @@
></bit-option>
</bit-select>
</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 class="tw-col-span-6">
<div class="tw-mb-0">
<bit-form-field formGroupName="kdfConfig" *ngIf="isPBKDF2(kdfConfig)">
@if (isPBKDF2(kdfConfig)) {
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "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>
<input
bitInput
@@ -72,7 +50,21 @@
/>
<bit-hint>{{ "kdfIterationRecommends" | i18n }}</bit-hint>
</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-label>
{{ "kdfIterations" | i18n }}
@@ -85,6 +77,8 @@
[max]="ARGON2_ITERATIONS.max"
/>
</bit-form-field>
</div>
<div class="tw-col-span-6">
<bit-form-field formGroupName="kdfConfig">
<bit-label>
{{ "kdfParallelism" | i18n }}
@@ -97,9 +91,8 @@
[max]="ARGON2_PARALLELISM.max"
/>
</bit-form-field>
</ng-container>
</div>
</div>
}
</div>
<button
(click)="openConfirmationModal()"
@@ -107,7 +100,27 @@
buttonType="primary"
bitButton
bitFormButton
class="tw-mt-2"
>
{{ "changeKdf" | i18n }}
{{ "updateEncryptionSettings" | i18n }}
</button>
</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>

View File

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

View File

@@ -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 { FormBuilder, FormControl, ValidatorFn, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil } from "rxjs";
import { FormBuilder, FormControl, Validators } from "@angular/forms";
import { Subject, firstValueFrom, takeUntil, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/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 {
KdfConfigService,
@@ -31,11 +31,11 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
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({
iterations: [this.kdfConfig.iterations],
memory: [null as number],
parallelism: [null as number],
iterations: new FormControl<number | null>(null),
memory: new FormControl<number | null>(null),
parallelism: new FormControl<number | null>(null),
}),
});
@@ -45,95 +45,102 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
protected ARGON2_MEMORY = Argon2KdfConfig.MEMORY;
protected ARGON2_PARALLELISM = Argon2KdfConfig.PARALLELISM;
noLogoutOnKdfChangeFeatureFlag$: Observable<boolean>;
constructor(
private dialogService: DialogService,
private kdfConfigService: KdfConfigService,
private accountService: AccountService,
private formBuilder: FormBuilder,
configService: ConfigService,
) {
this.kdfOptions = [
{ name: "PBKDF2 SHA-256", value: KdfType.PBKDF2_SHA256 },
{ name: "Argon2id", value: KdfType.Argon2id },
];
this.noLogoutOnKdfChangeFeatureFlag$ = configService.getFeatureFlag$(
FeatureFlag.NoLogoutOnKdfChange,
);
}
async ngOnInit() {
const userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
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.setFormValidators(this.kdfConfig.kdfType);
this.formGroup
.get("kdf")
.valueChanges.pipe(takeUntil(this.destroy$))
this.formGroup.controls.kdf.valueChanges
.pipe(takeUntil(this.destroy$))
.subscribe((newValue) => {
this.updateKdfConfig(newValue);
this.updateKdfConfig(newValue!);
});
}
private updateKdfConfig(newValue: KdfType) {
let config: KdfConfig;
const validators: { [key: string]: ValidatorFn[] } = {
iterations: [],
memory: [],
parallelism: [],
};
switch (newValue) {
case KdfType.PBKDF2_SHA256:
config = new PBKDF2KdfConfig();
validators.iterations = [
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
];
break;
case KdfType.Argon2id:
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;
default:
throw new Error("Unknown KDF type.");
}
this.kdfConfig = config;
this.setFormValidators(validators);
this.setFormValidators(newValue);
this.setFormControlValues(this.kdfConfig);
}
private setFormValidators(validators: { [key: string]: ValidatorFn[] }) {
this.setValidators("kdfConfig.iterations", validators.iterations);
this.setValidators("kdfConfig.memory", validators.memory);
this.setValidators("kdfConfig.parallelism", validators.parallelism);
}
private setValidators(controlName: string, validators: ValidatorFn[]) {
const control = this.formGroup.get(controlName);
if (control) {
control.setValidators(validators);
control.updateValueAndValidity();
private setFormValidators(kdfType: KdfType) {
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
switch (kdfType) {
case KdfType.PBKDF2_SHA256:
kdfConfigFormGroup.controls.iterations.setValidators([
Validators.required,
Validators.min(PBKDF2KdfConfig.ITERATIONS.min),
Validators.max(PBKDF2KdfConfig.ITERATIONS.max),
]);
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) {
this.formGroup.get("kdfConfig").reset();
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
kdfConfigFormGroup.reset();
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) {
this.formGroup.get("kdfConfig.iterations").setValue(kdfConfig.iterations);
this.formGroup.get("kdfConfig.memory").setValue(kdfConfig.memory);
this.formGroup.get("kdfConfig.parallelism").setValue(kdfConfig.parallelism);
kdfConfigFormGroup.controls.iterations.setValue(kdfConfig.iterations);
kdfConfigFormGroup.controls.memory.setValue(kdfConfig.memory);
kdfConfigFormGroup.controls.parallelism.setValue(kdfConfig.parallelism);
}
}
@@ -155,12 +162,14 @@ export class ChangeKdfComponent implements OnInit, OnDestroy {
if (this.formGroup.invalid) {
return;
}
const kdfConfigFormGroup = this.formGroup.controls.kdfConfig;
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) {
this.kdfConfig.iterations = this.formGroup.get("kdfConfig.iterations").value;
this.kdfConfig.memory = this.formGroup.get("kdfConfig.memory").value;
this.kdfConfig.parallelism = this.formGroup.get("kdfConfig.parallelism").value;
this.kdfConfig.iterations = kdfConfigFormGroup.controls.iterations.value!;
this.kdfConfig.memory = kdfConfigFormGroup.controls.memory.value!;
this.kdfConfig.parallelism = kdfConfigFormGroup.controls.parallelism.value!;
}
this.dialogService.open(ChangeKdfConfirmationComponent, {
data: {

View File

@@ -1,13 +1,15 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PopoverModule } from "@bitwarden/components";
import { SharedModule } from "../../shared";
import { ChangeKdfConfirmationComponent } from "./change-kdf-confirmation.component";
import { ChangeKdfComponent } from "./change-kdf.component";
@NgModule({
imports: [CommonModule, SharedModule],
imports: [CommonModule, SharedModule, PopoverModule],
declarations: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
exports: [ChangeKdfComponent, ChangeKdfConfirmationComponent],
})

View File

@@ -1719,7 +1719,6 @@
}
}
},
"dontAskAgainOnThisDeviceFor30Days": {
"message": "Don't ask again on this device for 30 days"
},
@@ -2090,9 +2089,6 @@
"encKeySettings": {
"message": "Encryption key settings"
},
"kdfAlgorithm": {
"message": "KDF algorithm"
},
"kdfIterations": {
"message": "KDF iterations"
},
@@ -2127,9 +2123,6 @@
"argon2Desc": {
"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": {
"message": "Encryption key settings saved"
},
@@ -10374,27 +10367,9 @@
"memberAccessReportAuthenticationEnabledFalse": {
"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": {
"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": {
"message": " Contact Customer Support to reinstate your subscription."
},
@@ -11878,5 +11853,32 @@
},
"viewbusinessplans": {
"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."
}
}