From 077c932fad0fa2436107550ee7981d6730f7f30f Mon Sep 17 00:00:00 2001 From: rr-bw <102181210+rr-bw@users.noreply.github.com> Date: Tue, 3 Feb 2026 10:41:25 -0800 Subject: [PATCH] refactor(input-password-flows): [Auth/PM-27086] Use new KM Data Types in InputPasswordComponent flows - Emergency Access (#18425) Update the Emergency Access Takeover flow to use new KM data types from `master-password.types.ts` / `MasterPasswordService`: - `MasterPasswordAuthenticationData` - `MasterPasswordUnlockData` This allows us to move away from the deprecated `makeMasterKey()` method (which takes email as salt) as we seek to eventually separate the email from the salt. Changes are behind feature flag: `pm-27086-update-authentication-apis-for-input-password` --- .../emergency-access-password.request.ts | 19 ++ .../services/emergency-access.service.spec.ts | 209 +++++++++++++++++- .../services/emergency-access.service.ts | 42 +++- 3 files changed, 267 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts b/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts index ba9f1d1bc5a..68b6f4146d8 100644 --- a/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts +++ b/apps/web/src/app/auth/emergency-access/request/emergency-access-password.request.ts @@ -1,6 +1,25 @@ // FIXME: Update this file to be type safe and remove this and next line + +import { + MasterPasswordAuthenticationData, + MasterPasswordUnlockData, +} from "@bitwarden/common/key-management/master-password/types/master-password.types"; + // @ts-strict-ignore export class EmergencyAccessPasswordRequest { newMasterPasswordHash: string; key: string; + + // This will eventually be changed to be an actual constructor, once all callers are updated. + // The body of this request will be changed to carry the authentication data and unlock data. + // https://bitwarden.atlassian.net/browse/PM-23234 + static newConstructor( + authenticationData: MasterPasswordAuthenticationData, + unlockData: MasterPasswordUnlockData, + ): EmergencyAccessPasswordRequest { + const request = new EmergencyAccessPasswordRequest(); + request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash; + request.key = unlockData.masterKeyWrappedUserKey; + return request; + } } diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts index 05d6094745c..717e21e246c 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.spec.ts @@ -7,8 +7,17 @@ import { of } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordAuthenticationData, + MasterPasswordAuthenticationHash, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "@bitwarden/common/key-management/master-password/types/master-password.types"; import { ListResponse } from "@bitwarden/common/models/response/list.response"; import { UserKeyResponse } from "@bitwarden/common/models/response/user-key.response"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { EncryptionType } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; @@ -18,7 +27,13 @@ import { UserId } from "@bitwarden/common/types/guid"; import { UserKey, MasterKey, UserPrivateKey } from "@bitwarden/common/types/key"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { newGuid } from "@bitwarden/guid"; -import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management"; +import { + Argon2KdfConfig, + DEFAULT_KDF_CONFIG, + KdfType, + KeyService, + PBKDF2KdfConfig, +} from "@bitwarden/key-management"; import { EmergencyAccessStatusType } from "../enums/emergency-access-status-type"; import { EmergencyAccessType } from "../enums/emergency-access-type"; @@ -42,6 +57,8 @@ describe("EmergencyAccessService", () => { let cipherService: MockProxy; let logService: MockProxy; let emergencyAccessService: EmergencyAccessService; + let masterPasswordService: MockProxy; + let configService: MockProxy; const mockNewUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; const mockTrustedPublicKeys = [Utils.fromUtf8ToArray("trustedPublicKey")]; @@ -54,6 +71,8 @@ describe("EmergencyAccessService", () => { encryptService = mock(); cipherService = mock(); logService = mock(); + masterPasswordService = mock(); + configService = mock(); emergencyAccessService = new EmergencyAccessService( emergencyAccessApiService, @@ -62,6 +81,8 @@ describe("EmergencyAccessService", () => { encryptService, cipherService, logService, + masterPasswordService, + configService, ); }); @@ -215,7 +236,13 @@ describe("EmergencyAccessService", () => { }); }); - describe("takeover", () => { + /** + * @deprecated This 'describe' to be removed in PM-28143. When you remove this, check also if there are any imports/properties + * in the test setup above that are now un-used and can also be removed. + */ + describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag DISABLED]", () => { + const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = false; + const params = { id: "emergencyAccessId", masterPassword: "mockPassword", @@ -242,6 +269,10 @@ describe("EmergencyAccessService", () => { ); beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue( + PM27086_UpdateAuthenticationApisForInputPasswordEnabled, + ); + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValueOnce(takeoverResponse); keyService.userPrivateKey$.mockReturnValue(of(userPrivateKey)); @@ -450,6 +481,180 @@ describe("EmergencyAccessService", () => { }); }); + describe("takeover [PM27086_UpdateAuthenticationApisForInputPassword flag ENABLED]", () => { + // Mock feature flag value + const PM27086_UpdateAuthenticationApisForInputPasswordEnabled = true; + + // Mock sut method params + const id = "emergency-access-id"; + const masterPassword = "mockPassword"; + const email = "user@example.com"; + const activeUserId = newGuid() as UserId; + + // Mock method data + const kdfConfig = DEFAULT_KDF_CONFIG; + + const takeoverResponse = { + keyEncrypted: "EncryptedKey", + kdf: kdfConfig.kdfType, + kdfIterations: kdfConfig.iterations, + } as EmergencyAccessTakeoverResponse; + + const activeUserPrivateKey = new Uint8Array(64) as UserPrivateKey; + let mockGrantorUserKey: UserKey; + let salt: MasterPasswordSalt; + let authenticationData: MasterPasswordAuthenticationData; + let unlockData: MasterPasswordUnlockData; + + beforeEach(() => { + configService.getFeatureFlag.mockResolvedValue( + PM27086_UpdateAuthenticationApisForInputPasswordEnabled, + ); + + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue(takeoverResponse); + keyService.userPrivateKey$.mockReturnValue(of(activeUserPrivateKey)); + + const mockDecryptedGrantorUserKey = new SymmetricCryptoKey(new Uint8Array(64)); + encryptService.decapsulateKeyUnsigned.mockResolvedValue(mockDecryptedGrantorUserKey); + mockGrantorUserKey = mockDecryptedGrantorUserKey as UserKey; + + salt = email as MasterPasswordSalt; + masterPasswordService.emailToSalt.mockReturnValue(salt); + + authenticationData = { + salt, + kdf: kdfConfig, + masterPasswordAuthenticationHash: + "masterPasswordAuthenticationHash" as MasterPasswordAuthenticationHash, + }; + + unlockData = { + salt, + kdf: kdfConfig, + masterKeyWrappedUserKey: "masterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + } as MasterPasswordUnlockData; + + masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue( + authenticationData, + ); + masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValue(unlockData); + }); + + it("should throw if active user private key is not found", async () => { + // Arrange + keyService.userPrivateKey$.mockReturnValue(of(null)); + + // Act + const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + await expect(promise).rejects.toThrow( + "Active user does not have a private key, cannot complete a takeover.", + ); + expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); + }); + + it("should throw if the grantor user key cannot be decrypted via the active user private key", async () => { + // Arrange + encryptService.decapsulateKeyUnsigned.mockResolvedValue(null); + + // Act + const promise = emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + await expect(promise).rejects.toThrow("Failed to decrypt grantor key"); + expect(emergencyAccessApiService.postEmergencyAccessPassword).not.toHaveBeenCalled(); + }); + + it("should use PBKDF2 if takeover response contains KdfType.PBKDF2_SHA256", async () => { + // Act + await emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith( + masterPassword, + kdfConfig, // default config (PBKDF2) + salt, + ); + }); + + it("should use Argon2 if takeover response contains KdfType.Argon2id", async () => { + // Arrange + const argon2TakeoverResponse = { + keyEncrypted: "EncryptedKey", + kdf: KdfType.Argon2id, + kdfIterations: 3, + kdfMemory: 64, + kdfParallelism: 4, + } as EmergencyAccessTakeoverResponse; + + emergencyAccessApiService.postEmergencyAccessTakeover.mockResolvedValue( + argon2TakeoverResponse, + ); + + const expectedKdfConfig = new Argon2KdfConfig( + argon2TakeoverResponse.kdfIterations, + argon2TakeoverResponse.kdfMemory, + argon2TakeoverResponse.kdfParallelism, + ); + + // Act + await emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith( + masterPassword, + expectedKdfConfig, + salt, + ); + expect(masterPasswordService.makeMasterPasswordAuthenticationData).not.toHaveBeenCalledWith( + masterPassword, + kdfConfig, // default config (PBKDF2) + salt, + ); + }); + + it("should call makeMasterPasswordAuthenticationData and makeMasterPasswordUnlockData with the correct parameters", async () => { + // Act + await emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData); + + expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith( + masterPassword, + kdfConfig, + salt, + ); + + expect(masterPasswordService.makeMasterPasswordUnlockData).toHaveBeenCalledWith( + masterPassword, + kdfConfig, + salt, + mockGrantorUserKey, + ); + + expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith( + id, + request, + ); + }); + + it("should call the API method to change the grantor's master password", async () => { + // Act + await emergencyAccessService.takeover(id, masterPassword, email, activeUserId); + + // Assert + const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData); + + expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledTimes(1); + expect(emergencyAccessApiService.postEmergencyAccessPassword).toHaveBeenCalledWith( + id, + request, + ); + }); + }); + describe("getRotatedData", () => { const allowedStatuses = [ EmergencyAccessStatusType.Confirmed, diff --git a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts index 80b1b27116b..81e7275af23 100644 --- a/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts +++ b/apps/web/src/app/auth/emergency-access/services/emergency-access.service.ts @@ -4,11 +4,19 @@ import { firstValueFrom } from "rxjs"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; import { PolicyData } from "@bitwarden/common/admin-console/models/data/policy.data"; import { Policy } from "@bitwarden/common/admin-console/models/domain/policy"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service"; import { EncryptedString, EncString, } from "@bitwarden/common/key-management/crypto/models/enc-string"; +import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { + MasterPasswordAuthenticationData, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "@bitwarden/common/key-management/master-password/types/master-password.types"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; @@ -56,6 +64,8 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide private encryptService: EncryptService, private cipherService: CipherService, private logService: LogService, + private masterPasswordService: MasterPasswordServiceAbstraction, + private configService: ConfigService, ) {} /** @@ -270,7 +280,7 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide * Intended for grantee. * @param id emergency access id * @param masterPassword new master password - * @param email email address of grantee (must be consistent or login will fail) + * @param email email address of grantor (must be consistent or login will fail) * @param activeUserId the user id of the active user */ async takeover(id: string, masterPassword: string, email: string, activeUserId: UserId) { @@ -309,6 +319,36 @@ export class EmergencyAccessService implements UserKeyRotationKeyRecoveryProvide break; } + // When you unwind the flag in PM-28143, also remove the ConfigService if it is un-used. + const newApisWithInputPasswordFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.PM27086_UpdateAuthenticationApisForInputPassword, + ); + + if (newApisWithInputPasswordFlagEnabled) { + const salt: MasterPasswordSalt = this.masterPasswordService.emailToSalt(email); + + const authenticationData: MasterPasswordAuthenticationData = + await this.masterPasswordService.makeMasterPasswordAuthenticationData( + masterPassword, + config, + salt, + ); + + const unlockData: MasterPasswordUnlockData = + await this.masterPasswordService.makeMasterPasswordUnlockData( + masterPassword, + config, + salt, + grantorUserKey, + ); + + const request = EmergencyAccessPasswordRequest.newConstructor(authenticationData, unlockData); + + await this.emergencyAccessApiService.postEmergencyAccessPassword(id, request); + + return; // EARLY RETURN for flagged logic + } + const masterKey = await this.keyService.makeMasterKey(masterPassword, email, config); const masterKeyHash = await this.keyService.hashMasterKey(masterPassword, masterKey);