1
0
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:
Dave
2026-02-25 12:41:29 -05:00
committed by GitHub
parent 0bbdcb69c7
commit 4f706746d6
10 changed files with 1059 additions and 128 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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