mirror of
https://github.com/bitwarden/browser
synced 2026-02-26 17:43:22 +00:00
[PM-30811] Update change email flow to use new Key Management APIs (#18857)
* feat(change-email) [PM-30811]: Add feature flag. * feat(change-email) [PM-30811]: Add new constructor to EmailTokenRequest. * feat(change-email) [PM-30811]: Update import. * feat(change-email) [PM-30811]: Stub new ChangeEmailService. * feat(change-email) [PM-30811]: Provide ChangeEmailService. * feat(change-email) [PM-30811]: Add ChangeEmailService impl. * feat(change-email) [PM-30811]: Add ChangeEmailService to component. * feat(change-email) [PM-30811]: Remove change-email methods from ApiService. * feat(change-email) [PM-30811]: Update EmailTokenRequest for new APIs. * feat(change-email) [PM-30811]: Finish implementation of both paths in ChangeEmailService. * feat(change-email) [PM-30811]: Wire-up service in ChangeEmailComponent. * test(change-email) [PM-30811]: Add ChangeEmailService tests. * test(change-email) [PM-30811]: Update tests. * refactor(change-email) [PM-30811]: EmailTokenRequest strict-ignore until legacy support can be unwound. * refactor(change-email) [PM-30811]: Re-order imports. * test(change-email) [PM-30811]: Update component tests to reflect new implementation. * refactor(change-email) [PM-30811]: Formatting. * test(change-email-service) [PM-30811]: Improve accuracy of null-checking tests: kdf, userKey, salt, in order.
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormBuilder, ReactiveFormsModule } from "@angular/forms";
|
||||
import mock, { MockProxy } from "jest-mock-extended/lib/Mock";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { TwoFactorProviderResponse } from "@bitwarden/common/auth/models/response/two-factor-provider.response";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -14,7 +14,6 @@ import { MessagingService } from "@bitwarden/common/platform/abstractions/messag
|
||||
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { ChangeEmailComponent } from "@bitwarden/web-vault/app/auth/settings/account/change-email.component";
|
||||
import { SharedModule } from "@bitwarden/web-vault/app/shared";
|
||||
|
||||
@@ -22,31 +21,25 @@ describe("ChangeEmailComponent", () => {
|
||||
let component: ChangeEmailComponent;
|
||||
let fixture: ComponentFixture<ChangeEmailComponent>;
|
||||
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let changeEmailService: MockProxy<ChangeEmailService>;
|
||||
let twoFactorService: MockProxy<TwoFactorService>;
|
||||
let accountService: FakeAccountService;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
apiService = mock<ApiService>();
|
||||
changeEmailService = mock<ChangeEmailService>();
|
||||
twoFactorService = mock<TwoFactorService>();
|
||||
keyService = mock<KeyService>();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
accountService = mockAccountServiceWith("UserId" as UserId);
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ReactiveFormsModule, SharedModule, ChangeEmailComponent],
|
||||
providers: [
|
||||
{ provide: AccountService, useValue: accountService },
|
||||
{ provide: ApiService, useValue: apiService },
|
||||
{ provide: TwoFactorService, useValue: twoFactorService },
|
||||
{ provide: I18nService, useValue: { t: (key: string) => key } },
|
||||
{ provide: KeyService, useValue: keyService },
|
||||
{ provide: MessagingService, useValue: mock<MessagingService>() },
|
||||
{ provide: KdfConfigService, useValue: kdfConfigService },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: FormBuilder, useClass: FormBuilder },
|
||||
{ provide: ToastService, useValue: mock<ToastService>() },
|
||||
{ provide: ChangeEmailService, useValue: changeEmailService },
|
||||
],
|
||||
}).compileComponents();
|
||||
|
||||
@@ -87,17 +80,11 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
describe("submit", () => {
|
||||
beforeEach(() => {
|
||||
component.userId = "UserId" as UserId;
|
||||
component.formGroup.controls.step1.setValue({
|
||||
masterPassword: "password",
|
||||
newEmail: "test@example.com",
|
||||
});
|
||||
|
||||
keyService.getOrDeriveMasterKey
|
||||
.calledWith("password", "UserId" as UserId)
|
||||
.mockResolvedValue("getOrDeriveMasterKey" as any);
|
||||
keyService.hashMasterKey
|
||||
.calledWith("password", "getOrDeriveMasterKey" as any)
|
||||
.mockResolvedValue("existingHash");
|
||||
});
|
||||
|
||||
it("throws if userId is null on submit", async () => {
|
||||
@@ -115,16 +102,17 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmailToken).not.toHaveBeenCalled();
|
||||
expect(changeEmailService.requestEmailToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends email token in step 1 if tokenSent is false", async () => {
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmailToken).toHaveBeenCalledWith({
|
||||
newEmail: "test@example.com",
|
||||
masterPasswordHash: "existingHash",
|
||||
});
|
||||
expect(changeEmailService.requestEmailToken).toHaveBeenCalledWith(
|
||||
"password",
|
||||
"test@example.com",
|
||||
"UserId" as UserId,
|
||||
);
|
||||
// should activate step 2
|
||||
expect(component.tokenSent).toBe(true);
|
||||
expect(component.formGroup.controls.step1.disabled).toBe(true);
|
||||
@@ -138,23 +126,6 @@ describe("ChangeEmailComponent", () => {
|
||||
component.formGroup.controls.step1.disable();
|
||||
component.formGroup.controls.token.enable();
|
||||
component.formGroup.controls.token.setValue("token");
|
||||
|
||||
kdfConfigService.getKdfConfig$
|
||||
.calledWith("UserId" as any)
|
||||
.mockReturnValue(of("kdfConfig" as any));
|
||||
keyService.userKey$.calledWith("UserId" as any).mockReturnValue(of("userKey" as any));
|
||||
|
||||
keyService.makeMasterKey
|
||||
.calledWith("password", "test@example.com", "kdfConfig" as any)
|
||||
.mockResolvedValue("newMasterKey" as any);
|
||||
keyService.hashMasterKey
|
||||
.calledWith("password", "newMasterKey" as any)
|
||||
.mockResolvedValue("newMasterKeyHash");
|
||||
|
||||
// Important: make sure this is called with new master key, not existing
|
||||
keyService.encryptUserKeyWithMasterKey
|
||||
.calledWith("newMasterKey" as any, "userKey" as any)
|
||||
.mockResolvedValue(["userKey" as any, { encryptedString: "newEncryptedUserKey" } as any]);
|
||||
});
|
||||
|
||||
it("does not post email if token is missing on submit", async () => {
|
||||
@@ -162,38 +133,18 @@ describe("ChangeEmailComponent", () => {
|
||||
|
||||
await component.submit();
|
||||
|
||||
expect(apiService.postEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws if kdfConfig is missing on submit", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Missing kdf config");
|
||||
});
|
||||
|
||||
it("throws if userKey can't be found", async () => {
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Can't find UserKey");
|
||||
});
|
||||
|
||||
it("throws if encryptedUserKey is missing", async () => {
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue(["userKey" as any, null as any]);
|
||||
|
||||
await expect(component.submit()).rejects.toThrow("Missing Encrypted User Key");
|
||||
expect(changeEmailService.confirmEmailChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("submits if step 2 is valid", async () => {
|
||||
await component.submit();
|
||||
|
||||
// validate that hashes are correct
|
||||
expect(apiService.postEmail).toHaveBeenCalledWith({
|
||||
masterPasswordHash: "existingHash",
|
||||
newMasterPasswordHash: "newMasterKeyHash",
|
||||
token: "token",
|
||||
newEmail: "test@example.com",
|
||||
key: "newEncryptedUserKey",
|
||||
});
|
||||
expect(changeEmailService.confirmEmailChange).toHaveBeenCalledWith(
|
||||
"password",
|
||||
"test@example.com",
|
||||
"token",
|
||||
"UserId" as UserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,18 +2,16 @@ import { Component, OnInit } from "@angular/core";
|
||||
import { FormBuilder, Validators } from "@angular/forms";
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { TwoFactorProviderType } from "@bitwarden/common/auth/enums/two-factor-provider-type";
|
||||
import { EmailTokenRequest } from "@bitwarden/common/auth/models/request/email-token.request";
|
||||
import { EmailRequest } from "@bitwarden/common/auth/models/request/email.request";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { TwoFactorService } from "@bitwarden/common/auth/two-factor";
|
||||
import { assertNonNullish } from "@bitwarden/common/auth/utils";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { SharedModule } from "../../../shared";
|
||||
|
||||
@@ -39,14 +37,12 @@ export class ChangeEmailComponent implements OnInit {
|
||||
|
||||
constructor(
|
||||
private accountService: AccountService,
|
||||
private apiService: ApiService,
|
||||
private twoFactorService: TwoFactorService,
|
||||
private i18nService: I18nService,
|
||||
private keyService: KeyService,
|
||||
private messagingService: MessagingService,
|
||||
private formBuilder: FormBuilder,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private toastService: ToastService,
|
||||
private changeEmailService: ChangeEmailService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -79,53 +75,25 @@ export class ChangeEmailComponent implements OnInit {
|
||||
const newEmail = step1Value.newEmail?.trim().toLowerCase();
|
||||
const masterPassword = step1Value.masterPassword;
|
||||
|
||||
if (newEmail == null || masterPassword == null) {
|
||||
throw new Error("Missing email or password");
|
||||
}
|
||||
|
||||
const existingHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, this.userId),
|
||||
);
|
||||
const ctx = "Could not update email.";
|
||||
assertNonNullish(newEmail, "email", ctx);
|
||||
assertNonNullish(masterPassword, "password", ctx);
|
||||
|
||||
if (!this.tokenSent) {
|
||||
const request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = existingHash;
|
||||
await this.apiService.postEmailToken(request);
|
||||
await this.changeEmailService.requestEmailToken(masterPassword, newEmail, this.userId);
|
||||
this.activateStep2();
|
||||
} else {
|
||||
const token = this.formGroup.value.token;
|
||||
if (token == null) {
|
||||
throw new Error("Missing token");
|
||||
}
|
||||
const request = new EmailRequest();
|
||||
request.token = token;
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = existingHash;
|
||||
|
||||
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(this.userId));
|
||||
if (kdfConfig == null) {
|
||||
throw new Error("Missing kdf config");
|
||||
}
|
||||
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
|
||||
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
|
||||
await this.changeEmailService.confirmEmailChange(
|
||||
masterPassword,
|
||||
newMasterKey,
|
||||
newEmail,
|
||||
token,
|
||||
this.userId,
|
||||
);
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(this.userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("Can't find UserKey");
|
||||
}
|
||||
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
|
||||
const encryptedUserKey = newUserKey[1]?.encryptedString;
|
||||
if (encryptedUserKey == null) {
|
||||
throw new Error("Missing Encrypted User Key");
|
||||
}
|
||||
request.key = encryptedUserKey;
|
||||
|
||||
await this.apiService.postEmail(request);
|
||||
this.reset();
|
||||
this.toastService.showToast({
|
||||
variant: "success",
|
||||
|
||||
@@ -60,6 +60,8 @@ import { MasterPasswordApiService } from "@bitwarden/common/auth/abstractions/ma
|
||||
import { SsoLoginServiceAbstraction } from "@bitwarden/common/auth/abstractions/sso-login.service.abstraction";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "@bitwarden/common/auth/abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
import { NoopAuthRequestAnsweringService } from "@bitwarden/common/auth/services/auth-request-answering/noop-auth-request-answering.service";
|
||||
import { ChangeEmailService } from "@bitwarden/common/auth/services/change-email/change-email.service";
|
||||
import { DefaultChangeEmailService } from "@bitwarden/common/auth/services/change-email/default-change-email.service";
|
||||
import { OrganizationInviteService } from "@bitwarden/common/auth/services/organization-invite/organization-invite.service";
|
||||
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
|
||||
import { ClientType } from "@bitwarden/common/enums";
|
||||
@@ -494,6 +496,17 @@ const safeProviders: SafeProvider[] = [
|
||||
ConfigService,
|
||||
],
|
||||
}),
|
||||
safeProvider({
|
||||
provide: ChangeEmailService,
|
||||
useClass: DefaultChangeEmailService,
|
||||
deps: [
|
||||
ConfigService,
|
||||
InternalMasterPasswordServiceAbstraction,
|
||||
KdfConfigService,
|
||||
ApiService,
|
||||
KeyServiceAbstraction,
|
||||
],
|
||||
}),
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -36,8 +36,6 @@ import {
|
||||
ProviderUserUserDetailsResponse,
|
||||
} from "../admin-console/models/response/provider/provider-user.response";
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
import { EmailRequest } from "../auth/models/request/email.request";
|
||||
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
|
||||
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
|
||||
import { UserApiTokenRequest } from "../auth/models/request/identity-token/user-api-token.request";
|
||||
@@ -153,8 +151,6 @@ export abstract class ApiService {
|
||||
abstract putProfile(request: UpdateProfileRequest): Promise<ProfileResponse>;
|
||||
abstract putAvatar(request: UpdateAvatarRequest): Promise<ProfileResponse>;
|
||||
abstract postPrelogin(request: PreloginRequest): Promise<PreloginResponse>;
|
||||
abstract postEmailToken(request: EmailTokenRequest): Promise<any>;
|
||||
abstract postEmail(request: EmailRequest): Promise<any>;
|
||||
abstract postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any>;
|
||||
abstract postSecurityStamp(request: SecretVerificationRequest): Promise<any>;
|
||||
abstract getAccountRevisionDate(): Promise<number>;
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { MasterPasswordAuthenticationData } from "../../../key-management/master-password/types/master-password.types";
|
||||
|
||||
import { SecretVerificationRequest } from "./secret-verification.request";
|
||||
|
||||
export class EmailTokenRequest extends SecretVerificationRequest {
|
||||
newEmail: string;
|
||||
masterPasswordHash: string;
|
||||
|
||||
/**
|
||||
* Creates an EmailTokenRequest using new KM data types.
|
||||
* This will eventually become the primary constructor once all callers are updated.
|
||||
* @see https://bitwarden.atlassian.net/browse/PM-30811
|
||||
*/
|
||||
static forNewEmail(
|
||||
authenticationData: MasterPasswordAuthenticationData,
|
||||
newEmail: string,
|
||||
): EmailTokenRequest {
|
||||
const request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.authenticateWith(authenticationData);
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
export abstract class ChangeEmailService {
|
||||
/**
|
||||
* Requests an email change token from the server.
|
||||
*
|
||||
* @param masterPassword The user's current master password
|
||||
* @param newEmail The new email address
|
||||
* @param userId The user's ID
|
||||
* @throws if master password verification fails
|
||||
*/
|
||||
abstract requestEmailToken(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
|
||||
/**
|
||||
* Confirms the email change with the token received via email.
|
||||
*
|
||||
* @param masterPassword The user's current master password
|
||||
* @param newEmail The new email address
|
||||
* @param token The verification token received via email
|
||||
* @param userId The user's ID
|
||||
* @throws if master password verification fails
|
||||
*/
|
||||
abstract confirmEmailChange(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
userId: UserId,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,803 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
|
||||
import {
|
||||
MasterKeyWrappedUserKey,
|
||||
MasterPasswordAuthenticationData,
|
||||
MasterPasswordAuthenticationHash,
|
||||
MasterPasswordSalt,
|
||||
MasterPasswordUnlockData,
|
||||
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { MasterKey, UserKey } from "@bitwarden/common/types/key";
|
||||
import { newGuid } from "@bitwarden/guid";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// Marked for removal when PM-30811 feature flag is unwound.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import {
|
||||
DEFAULT_KDF_CONFIG,
|
||||
KdfConfig,
|
||||
KdfConfigService,
|
||||
KeyService,
|
||||
} from "@bitwarden/key-management";
|
||||
|
||||
import { DefaultChangeEmailService } from "./default-change-email.service";
|
||||
|
||||
describe("DefaultChangeEmailService", () => {
|
||||
let sut: DefaultChangeEmailService;
|
||||
|
||||
let configService: MockProxy<ConfigService>;
|
||||
let masterPasswordService: FakeMasterPasswordService;
|
||||
let kdfConfigService: MockProxy<KdfConfigService>;
|
||||
let apiService: MockProxy<ApiService>;
|
||||
let keyService: MockProxy<KeyService>;
|
||||
|
||||
const mockUserId = newGuid() as UserId;
|
||||
const mockMasterPassword = "master-password";
|
||||
const mockNewEmail = "new@example.com";
|
||||
const mockToken = "verification-token";
|
||||
const kdfConfig: KdfConfig = DEFAULT_KDF_CONFIG;
|
||||
const existingSalt = "existing@example.com" as MasterPasswordSalt;
|
||||
|
||||
beforeEach(() => {
|
||||
configService = mock<ConfigService>();
|
||||
masterPasswordService = new FakeMasterPasswordService();
|
||||
kdfConfigService = mock<KdfConfigService>();
|
||||
apiService = mock<ApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
|
||||
sut = new DefaultChangeEmailService(
|
||||
configService,
|
||||
masterPasswordService,
|
||||
kdfConfigService,
|
||||
apiService,
|
||||
keyService,
|
||||
);
|
||||
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("should be created", () => {
|
||||
expect(sut).toBeTruthy();
|
||||
});
|
||||
|
||||
describe("requestEmailToken", () => {
|
||||
/**
|
||||
* The email token request verifies that the user knows their master password
|
||||
* by computing a hash from the password and their current (existing) salt.
|
||||
* This proves identity before allowing email change to proceed.
|
||||
*/
|
||||
describe("verifies user identity with existing email credentials", () => {
|
||||
it("should use MasterPasswordService APIs", async () => {
|
||||
// Arrange: Flag enabled - use new KM APIs
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert: Verifies identity using existing salt
|
||||
expect(masterPasswordService.mock.saltForUser$).toHaveBeenCalledWith(mockUserId);
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenCalledWith(mockMasterPassword, kdfConfig, existingSalt);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should use KeyService APIs for legacy support", async () => {
|
||||
// Arrange: Flag disabled - use legacy KeyService
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert: Legacy path derives and hashes master key
|
||||
expect(keyService.getOrDeriveMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockUserId,
|
||||
);
|
||||
expect(keyService.hashMasterKey).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* After verifying identity, the service sends a request to the server
|
||||
* to generate a verification token for the new email address.
|
||||
*/
|
||||
describe("sends token request to server", () => {
|
||||
it("should send request with authentication hash", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
|
||||
const authenticationData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue(
|
||||
authenticationData,
|
||||
);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email-token",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
masterPasswordHash: authenticationData.masterPasswordAuthenticationHash,
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should send request with hashed master key for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email-token",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
masterPasswordHash: "existing-master-key-hash",
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Critical preconditions must be met before attempting the operation.
|
||||
* These guard against invalid state that would cause cryptographic failures.
|
||||
*/
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId),
|
||||
).rejects.toThrow("kdf is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if salt is null", async () => {
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(
|
||||
of(null as unknown as MasterPasswordSalt),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId),
|
||||
).rejects.toThrow("salt is null or undefined.");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures clean separation between old and new code paths.
|
||||
* When one path is active, the other's APIs should not be invoked.
|
||||
*/
|
||||
describe("API isolation", () => {
|
||||
it("should NOT call legacy KeyService APIs", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(keyService.getOrDeriveMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated To be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should NOT call new MasterPasswordService APIs for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-master-key-hash");
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.requestEmailToken(mockMasterPassword, mockNewEmail, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmEmailChange", () => {
|
||||
/**
|
||||
* The confirm request requires TWO authentication hashes:
|
||||
* 1. Existing salt hash - proves user knows their password (verification)
|
||||
* 2. New salt hash - will become the new authentication hash after email change
|
||||
*
|
||||
* This is because the master key derivation includes the email (as salt),
|
||||
* so changing email changes the derived master key.
|
||||
*/
|
||||
describe("verifies user identity with existing email credentials", () => {
|
||||
it("should create auth data with EXISTING salt for verification", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: First call uses EXISTING salt for verification
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenNthCalledWith(1, mockMasterPassword, kdfConfig, existingSalt);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should derive and hash master key with existing credentials for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
const mockNewMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(2)) as MasterKey;
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey
|
||||
.mockResolvedValueOnce("existing-hash")
|
||||
.mockResolvedValueOnce("new-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockNewMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue([
|
||||
mockUserKey,
|
||||
{ encryptedString: "encrypted-user-key" } as any,
|
||||
]);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: Legacy path derives master key from existing user
|
||||
expect(keyService.getOrDeriveMasterKey).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* When email changes, the salt changes (email IS the salt in Bitwarden).
|
||||
* This means the master key changes, so we must:
|
||||
* 1. Compute new authentication hash with new salt
|
||||
* 2. Re-wrap the user key with the new master key
|
||||
*/
|
||||
describe("creates new credentials with new email salt", () => {
|
||||
let mockUserKey: UserKey;
|
||||
let existingAuthData: MasterPasswordAuthenticationData;
|
||||
let newAuthData: MasterPasswordAuthenticationData;
|
||||
let newUnlockData: MasterPasswordUnlockData;
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
mockUserKey = new SymmetricCryptoKey(new Uint8Array(64).fill(3) as CsprngArray) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
existingAuthData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
newAuthData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
newUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("should derive new salt from new email", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
expect(masterPasswordService.mock.emailToSalt).toHaveBeenCalledWith(mockNewEmail);
|
||||
});
|
||||
|
||||
it("should create auth data with NEW salt for new password hash", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Second call uses NEW salt for the new authentication hash
|
||||
expect(
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData,
|
||||
).toHaveBeenNthCalledWith(2, mockMasterPassword, kdfConfig, newSalt);
|
||||
});
|
||||
|
||||
it("should create unlock data with NEW salt to re-wrap user key", async () => {
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
expect(masterPasswordService.mock.makeMasterPasswordUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
kdfConfig,
|
||||
newSalt,
|
||||
mockUserKey,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The confirmation request carries all the data the server needs
|
||||
* to update the user's email and re-encrypt their keys.
|
||||
*/
|
||||
describe("sends confirmation request to server", () => {
|
||||
it("should send request with all required fields", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
token: mockToken,
|
||||
masterPasswordHash: existingAuthData.masterPasswordAuthenticationHash,
|
||||
newMasterPasswordHash: newAuthData.masterPasswordAuthenticationHash,
|
||||
key: newUnlockData.masterKeyWrappedUserKey,
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy path - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
it("should send request with hashed keys for legacy support", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
const mockNewMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(2)) as MasterKey;
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey
|
||||
.mockResolvedValueOnce("existing-hash")
|
||||
.mockResolvedValueOnce("new-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockNewMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
keyService.encryptUserKeyWithMasterKey.mockResolvedValue([
|
||||
mockUserKey,
|
||||
{ encryptedString: "encrypted-user-key" } as any,
|
||||
]);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/accounts/email",
|
||||
expect.objectContaining({
|
||||
newEmail: mockNewEmail,
|
||||
token: mockToken,
|
||||
masterPasswordHash: "existing-hash",
|
||||
newMasterPasswordHash: "new-hash",
|
||||
key: "encrypted-user-key",
|
||||
}),
|
||||
mockUserId,
|
||||
false, // hasResponse: false - server returns no body
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* After the server confirms the email change, we must update local state
|
||||
* so the application can continue operating with the new credentials.
|
||||
* This is a transitional requirement that will be removed in PM-30676.
|
||||
*/
|
||||
describe("maintains backwards compatibility", () => {
|
||||
it("should call setLegacyMasterKeyFromUnlockData after successful change", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
const existingAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash:
|
||||
"existing-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newAuthData: MasterPasswordAuthenticationData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "new-auth-hash" as MasterPasswordAuthenticationHash,
|
||||
};
|
||||
const newUnlockData: MasterPasswordUnlockData = {
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-user-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData;
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData
|
||||
.mockResolvedValueOnce(existingAuthData)
|
||||
.mockResolvedValueOnce(newAuthData);
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue(newUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: Sets legacy master key for backwards compat (remove in PM-30676)
|
||||
expect(masterPasswordService.mock.setLegacyMasterKeyFromUnlockData).toHaveBeenCalledWith(
|
||||
mockMasterPassword,
|
||||
newUnlockData,
|
||||
mockUserId,
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* The legacy master key MUST be set AFTER the API call succeeds.
|
||||
* If set before and the API fails, local state would be inconsistent with the server,
|
||||
* making the operation non-retry-able without logging out.
|
||||
*/
|
||||
it("should set legacy master key AFTER the API call succeeds", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Track call order
|
||||
const callOrder: string[] = [];
|
||||
apiService.send.mockImplementation(async () => {
|
||||
callOrder.push("apiService.send");
|
||||
});
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockImplementation(async () => {
|
||||
callOrder.push("setLegacyMasterKeyFromUnlockData");
|
||||
});
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert: API call must happen BEFORE legacy key update
|
||||
expect(callOrder).toEqual(["apiService.send", "setLegacyMasterKeyFromUnlockData"]);
|
||||
});
|
||||
|
||||
it("should NOT set legacy master key if API call fails", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
|
||||
// API call fails
|
||||
apiService.send.mockRejectedValue(new Error("Server error"));
|
||||
|
||||
// Act & Assert
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("Server error");
|
||||
|
||||
// Legacy key should NOT have been set (preserves retry-ability)
|
||||
expect(masterPasswordService.mock.setLegacyMasterKeyFromUnlockData).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Critical preconditions must be met before attempting the operation.
|
||||
* These guard against invalid state that would cause cryptographic failures.
|
||||
*/
|
||||
describe("error handling", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("kdf is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if user key is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("userKey is null or undefined.");
|
||||
});
|
||||
|
||||
it("should throw if existing salt is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(
|
||||
of(null as unknown as MasterPasswordSalt),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow("salt is null or undefined.");
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated Legacy error cases - to be removed when PM-30811 flag is unwound
|
||||
*/
|
||||
describe("legacy path errors", () => {
|
||||
beforeEach(() => {
|
||||
configService.getFeatureFlag.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
it("should throw if KDF config is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("should throw if user key is null", async () => {
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64).fill(1)) as MasterKey;
|
||||
keyService.getOrDeriveMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.hashMasterKey.mockResolvedValue("existing-hash");
|
||||
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
|
||||
keyService.userKey$.mockReturnValue(of(null));
|
||||
|
||||
await expect(
|
||||
sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ensures clean separation between old and new code paths.
|
||||
* When one path is active, the other's APIs should not be invoked.
|
||||
*/
|
||||
describe("API isolation", () => {
|
||||
it("should NOT call legacy KeyService APIs", async () => {
|
||||
// Arrange
|
||||
configService.getFeatureFlag.mockResolvedValue(true);
|
||||
kdfConfigService.getKdfConfig$.mockReturnValue(of(kdfConfig));
|
||||
|
||||
const mockUserKey = new SymmetricCryptoKey(
|
||||
new Uint8Array(64).fill(3) as CsprngArray,
|
||||
) as UserKey;
|
||||
keyService.userKey$.mockReturnValue(of(mockUserKey));
|
||||
|
||||
const newSalt = "new@example.com" as MasterPasswordSalt;
|
||||
masterPasswordService.mock.saltForUser$.mockReturnValue(of(existingSalt));
|
||||
masterPasswordService.mock.emailToSalt.mockReturnValue(newSalt);
|
||||
|
||||
masterPasswordService.mock.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
||||
salt: existingSalt,
|
||||
kdf: kdfConfig,
|
||||
masterPasswordAuthenticationHash: "auth-hash" as MasterPasswordAuthenticationHash,
|
||||
});
|
||||
masterPasswordService.mock.makeMasterPasswordUnlockData.mockResolvedValue({
|
||||
salt: newSalt,
|
||||
kdf: kdfConfig,
|
||||
masterKeyWrappedUserKey: "wrapped-key" as MasterKeyWrappedUserKey,
|
||||
} as MasterPasswordUnlockData);
|
||||
masterPasswordService.mock.setLegacyMasterKeyFromUnlockData.mockResolvedValue(undefined);
|
||||
apiService.send.mockResolvedValue(undefined);
|
||||
|
||||
// Act
|
||||
await sut.confirmEmailChange(mockMasterPassword, mockNewEmail, mockToken, mockUserId);
|
||||
|
||||
// Assert
|
||||
expect(keyService.getOrDeriveMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.hashMasterKey).not.toHaveBeenCalled();
|
||||
expect(keyService.encryptUserKeyWithMasterKey).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
|
||||
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
|
||||
import { MasterPasswordUnlockData } from "@bitwarden/common/key-management/master-password/types/master-password.types";
|
||||
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
|
||||
// Marked for removal when PM-30811 feature flag is unwound.
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
|
||||
import { UserId } from "@bitwarden/user-core";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { EmailTokenRequest } from "../../models/request/email-token.request";
|
||||
import { EmailRequest } from "../../models/request/email.request";
|
||||
import { assertNonNullish } from "../../utils";
|
||||
|
||||
import { ChangeEmailService } from "./change-email.service";
|
||||
|
||||
export class DefaultChangeEmailService implements ChangeEmailService {
|
||||
constructor(
|
||||
private configService: ConfigService,
|
||||
private masterPasswordService: MasterPasswordServiceAbstraction,
|
||||
private kdfConfigService: KdfConfigService,
|
||||
private apiService: ApiService,
|
||||
private keyService: KeyService,
|
||||
) {}
|
||||
|
||||
async requestEmailToken(masterPassword: string, newEmail: string, userId: UserId): Promise<void> {
|
||||
let request: EmailTokenRequest;
|
||||
|
||||
if (
|
||||
await this.configService.getFeatureFlag(FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis)
|
||||
) {
|
||||
const saltForUser = await firstValueFrom(this.masterPasswordService.saltForUser$(userId));
|
||||
assertNonNullish(saltForUser, "salt");
|
||||
|
||||
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
assertNonNullish(kdf, "kdf");
|
||||
|
||||
const authenticationData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
saltForUser,
|
||||
);
|
||||
|
||||
request = EmailTokenRequest.forNewEmail(authenticationData, newEmail);
|
||||
} else {
|
||||
// Legacy path: marked for removal when PM-30811 flag is unwound.
|
||||
// See: https://bitwarden.atlassian.net/browse/PM-30811
|
||||
|
||||
request = new EmailTokenRequest();
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, userId),
|
||||
);
|
||||
}
|
||||
|
||||
await this.apiService.send("POST", "/accounts/email-token", request, userId, false);
|
||||
}
|
||||
|
||||
async confirmEmailChange(
|
||||
masterPassword: string,
|
||||
newEmail: string,
|
||||
token: string,
|
||||
userId: UserId,
|
||||
): Promise<void> {
|
||||
let request: EmailRequest;
|
||||
let unlockDataForLegacyUpdate: MasterPasswordUnlockData | null = null;
|
||||
|
||||
if (
|
||||
await this.configService.getFeatureFlag(FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis)
|
||||
) {
|
||||
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
assertNonNullish(kdf, "kdf");
|
||||
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
assertNonNullish(userKey, "userKey");
|
||||
|
||||
// Existing salt required for verification
|
||||
const existingSalt = await firstValueFrom(this.masterPasswordService.saltForUser$(userId));
|
||||
assertNonNullish(existingSalt, "salt");
|
||||
|
||||
// Create auth data with existing salt (proves user knows password)
|
||||
const existingAuthData =
|
||||
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
existingSalt,
|
||||
);
|
||||
|
||||
const newSalt = this.masterPasswordService.emailToSalt(newEmail);
|
||||
const newAuthData = await this.masterPasswordService.makeMasterPasswordAuthenticationData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
newSalt,
|
||||
);
|
||||
const newUnlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
|
||||
masterPassword,
|
||||
kdf,
|
||||
newSalt,
|
||||
userKey,
|
||||
);
|
||||
|
||||
request = EmailRequest.newConstructor(newAuthData, newUnlockData);
|
||||
request.newEmail = newEmail;
|
||||
request.token = token;
|
||||
request.authenticateWith(existingAuthData);
|
||||
|
||||
// Track unlock data for legacy update after successful API call
|
||||
unlockDataForLegacyUpdate = newUnlockData;
|
||||
} else {
|
||||
// Legacy path: marked for removal when PM-30811 flag is unwound.
|
||||
// See: https://bitwarden.atlassian.net/browse/PM-30811
|
||||
|
||||
request = new EmailRequest();
|
||||
request.token = token;
|
||||
request.newEmail = newEmail;
|
||||
request.masterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
await this.keyService.getOrDeriveMasterKey(masterPassword, userId),
|
||||
);
|
||||
|
||||
const kdfConfig = await firstValueFrom(this.kdfConfigService.getKdfConfig$(userId));
|
||||
if (kdfConfig == null) {
|
||||
throw new Error("Missing kdf config");
|
||||
}
|
||||
const newMasterKey = await this.keyService.makeMasterKey(masterPassword, newEmail, kdfConfig);
|
||||
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
|
||||
masterPassword,
|
||||
newMasterKey,
|
||||
);
|
||||
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
|
||||
if (userKey == null) {
|
||||
throw new Error("Can't find UserKey");
|
||||
}
|
||||
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey, userKey);
|
||||
const encryptedUserKey = newUserKey[1]?.encryptedString;
|
||||
if (encryptedUserKey == null) {
|
||||
throw new Error("Missing Encrypted User Key");
|
||||
}
|
||||
request.key = encryptedUserKey;
|
||||
}
|
||||
|
||||
await this.apiService.send("POST", "/accounts/email", request, userId, false);
|
||||
|
||||
// Set legacy master key only AFTER successful API call to prevent inconsistent state on failure.
|
||||
// This ensures the operation is retry-able if the server request fails.
|
||||
// Remove in PM-30676.
|
||||
if (unlockDataForLegacyUpdate != null) {
|
||||
await this.masterPasswordService.setLegacyMasterKeyFromUnlockData(
|
||||
masterPassword,
|
||||
unlockDataForLegacyUpdate,
|
||||
userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export enum FeatureFlag {
|
||||
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
|
||||
PM27086_UpdateAuthenticationApisForInputPassword = "pm-27086-update-authentication-apis-for-input-password",
|
||||
SafariAccountSwitching = "pm-5594-safari-account-switching",
|
||||
PM30811_ChangeEmailNewAuthenticationApis = "pm-30811-change-email-new-authentication-apis",
|
||||
PM31088_MasterPasswordServiceEmitSalt = "pm-31088-master-password-service-emit-salt",
|
||||
|
||||
/* Autofill */
|
||||
@@ -146,6 +147,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
|
||||
[FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword]: FALSE,
|
||||
[FeatureFlag.SafariAccountSwitching]: FALSE,
|
||||
[FeatureFlag.PM30811_ChangeEmailNewAuthenticationApis]: FALSE,
|
||||
[FeatureFlag.PM31088_MasterPasswordServiceEmitSalt]: FALSE,
|
||||
|
||||
/* Billing */
|
||||
|
||||
@@ -47,8 +47,6 @@ import {
|
||||
import { SelectionReadOnlyResponse } from "../admin-console/models/response/selection-read-only.response";
|
||||
import { AccountService } from "../auth/abstractions/account.service";
|
||||
import { TokenService } from "../auth/abstractions/token.service";
|
||||
import { EmailTokenRequest } from "../auth/models/request/email-token.request";
|
||||
import { EmailRequest } from "../auth/models/request/email.request";
|
||||
import { DeviceRequest } from "../auth/models/request/identity-token/device.request";
|
||||
import { PasswordTokenRequest } from "../auth/models/request/identity-token/password-token.request";
|
||||
import { SsoTokenRequest } from "../auth/models/request/identity-token/sso-token.request";
|
||||
@@ -299,15 +297,6 @@ export class ApiService implements ApiServiceAbstraction {
|
||||
);
|
||||
return new PreloginResponse(r);
|
||||
}
|
||||
|
||||
postEmailToken(request: EmailTokenRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/email-token", request, true, false);
|
||||
}
|
||||
|
||||
postEmail(request: EmailRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/email", request, true, false);
|
||||
}
|
||||
|
||||
postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise<any> {
|
||||
return this.send("POST", "/accounts/set-key-connector-key", request, true, false);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user