diff --git a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts index 56f2bfe2112..20faa861fc0 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.spec.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.spec.ts @@ -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; - let apiService: MockProxy; + let changeEmailService: MockProxy; let twoFactorService: MockProxy; let accountService: FakeAccountService; - let keyService: MockProxy; - let kdfConfigService: MockProxy; beforeEach(async () => { - apiService = mock(); + changeEmailService = mock(); twoFactorService = mock(); - keyService = mock(); - kdfConfigService = mock(); 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() }, - { provide: KdfConfigService, useValue: kdfConfigService }, - { provide: ToastService, useValue: mock() }, { provide: FormBuilder, useClass: FormBuilder }, + { provide: ToastService, useValue: mock() }, + { 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, + ); }); }); }); diff --git a/apps/web/src/app/auth/settings/account/change-email.component.ts b/apps/web/src/app/auth/settings/account/change-email.component.ts index 3daf2240fb2..af7264f45f6 100644 --- a/apps/web/src/app/auth/settings/account/change-email.component.ts +++ b/apps/web/src/app/auth/settings/account/change-email.component.ts @@ -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", diff --git a/apps/web/src/app/core/core.module.ts b/apps/web/src/app/core/core.module.ts index d270162f99d..feffc28150e 100644 --- a/apps/web/src/app/core/core.module.ts +++ b/apps/web/src/app/core/core.module.ts @@ -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({ diff --git a/libs/common/src/abstractions/api.service.ts b/libs/common/src/abstractions/api.service.ts index 8a87d33a589..679798b51d9 100644 --- a/libs/common/src/abstractions/api.service.ts +++ b/libs/common/src/abstractions/api.service.ts @@ -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; abstract putAvatar(request: UpdateAvatarRequest): Promise; abstract postPrelogin(request: PreloginRequest): Promise; - abstract postEmailToken(request: EmailTokenRequest): Promise; - abstract postEmail(request: EmailRequest): Promise; abstract postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise; abstract postSecurityStamp(request: SecretVerificationRequest): Promise; abstract getAccountRevisionDate(): Promise; diff --git a/libs/common/src/auth/models/request/email-token.request.ts b/libs/common/src/auth/models/request/email-token.request.ts index 71d7d68b208..f18126c6636 100644 --- a/libs/common/src/auth/models/request/email-token.request.ts +++ b/libs/common/src/auth/models/request/email-token.request.ts @@ -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; + } } diff --git a/libs/common/src/auth/services/change-email/change-email.service.ts b/libs/common/src/auth/services/change-email/change-email.service.ts new file mode 100644 index 00000000000..b0950a6f630 --- /dev/null +++ b/libs/common/src/auth/services/change-email/change-email.service.ts @@ -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; + + /** + * 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; +} diff --git a/libs/common/src/auth/services/change-email/default-change-email.service.spec.ts b/libs/common/src/auth/services/change-email/default-change-email.service.spec.ts new file mode 100644 index 00000000000..f4d269edc1d --- /dev/null +++ b/libs/common/src/auth/services/change-email/default-change-email.service.spec.ts @@ -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; + let masterPasswordService: FakeMasterPasswordService; + let kdfConfigService: MockProxy; + let apiService: MockProxy; + let keyService: MockProxy; + + 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(); + masterPasswordService = new FakeMasterPasswordService(); + kdfConfigService = mock(); + apiService = mock(); + keyService = mock(); + + 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(); + }); + }); + }); +}); diff --git a/libs/common/src/auth/services/change-email/default-change-email.service.ts b/libs/common/src/auth/services/change-email/default-change-email.service.ts new file mode 100644 index 00000000000..1bbfa91c6b3 --- /dev/null +++ b/libs/common/src/auth/services/change-email/default-change-email.service.ts @@ -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 { + 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 { + 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, + ); + } + } +} diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 2d34c48e240..59ef6eb0a02 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -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 */ diff --git a/libs/common/src/services/api.service.ts b/libs/common/src/services/api.service.ts index 8b50f14004d..66c343a1930 100644 --- a/libs/common/src/services/api.service.ts +++ b/libs/common/src/services/api.service.ts @@ -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 { - return this.send("POST", "/accounts/email-token", request, true, false); - } - - postEmail(request: EmailRequest): Promise { - return this.send("POST", "/accounts/email", request, true, false); - } - postSetKeyConnectorKey(request: SetKeyConnectorKeyRequest): Promise { return this.send("POST", "/accounts/set-key-connector-key", request, true, false); }