From aa9a276591e47fc83696a4a7b98f23458beb48aa Mon Sep 17 00:00:00 2001 From: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com> Date: Wed, 15 Oct 2025 11:56:46 -0500 Subject: [PATCH] [PM-23246] Add unlock with master password unlock data for lock component (#16204) * Add unlocking with MasterPasswordUnlockData for angular lock component --- .../src/services/jslib-services.module.ts | 7 + libs/common/src/enums/feature-flag.enum.ts | 2 + .../master-password-unlock.service.ts | 13 + .../master-password.service.abstraction.ts | 8 + ...ult-master-password-unlock.service.spec.ts | 154 ++++++ .../default-master-password-unlock.service.ts | 75 +++ .../services/fake-master-password.service.ts | 4 + .../services/master-password.service.spec.ts | 128 +++-- .../services/master-password.service.ts | 10 +- .../src/lock/components/lock.component.html | 144 +++--- .../lock/components/lock.component.spec.ts | 134 ++++- .../src/lock/components/lock.component.ts | 61 ++- .../master-password-lock.component.html | 55 ++ .../master-password-lock.component.spec.ts | 472 ++++++++++++++++++ .../master-password-lock.component.ts | 111 ++++ 15 files changed, 1249 insertions(+), 129 deletions(-) create mode 100644 libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts create mode 100644 libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts create mode 100644 libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts create mode 100644 libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index d263e493b87..53da6e9fd8e 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -174,10 +174,12 @@ import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarde import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service"; import { KeyApiService } from "@bitwarden/common/key-management/keys/services/abstractions/key-api-service.abstraction"; import { DefaultKeyApiService } from "@bitwarden/common/key-management/keys/services/default-key-api-service.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; import { InternalMasterPasswordServiceAbstraction, MasterPasswordServiceAbstraction, } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; +import { DefaultMasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/services/default-master-password-unlock.service"; import { MasterPasswordService } from "@bitwarden/common/key-management/master-password/services/master-password.service"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { PinService } from "@bitwarden/common/key-management/pin/pin.service.implementation"; @@ -1077,6 +1079,11 @@ const safeProviders: SafeProvider[] = [ provide: MasterPasswordServiceAbstraction, useExisting: InternalMasterPasswordServiceAbstraction, }), + safeProvider({ + provide: MasterPasswordUnlockService, + useClass: DefaultMasterPasswordUnlockService, + deps: [InternalMasterPasswordServiceAbstraction, KeyService], + }), safeProvider({ provide: KeyConnectorServiceAbstraction, useClass: KeyConnectorService, diff --git a/libs/common/src/enums/feature-flag.enum.ts b/libs/common/src/enums/feature-flag.enum.ts index 8045a7b55f0..e2d4b000626 100644 --- a/libs/common/src/enums/feature-flag.enum.ts +++ b/libs/common/src/enums/feature-flag.enum.ts @@ -33,6 +33,7 @@ export enum FeatureFlag { PrivateKeyRegeneration = "pm-12241-private-key-regeneration", EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation", ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings", + UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data", /* Tools */ DesktopSendUIRefresh = "desktop-send-ui-refresh", @@ -110,6 +111,7 @@ export const DefaultFeatureFlagValue = { [FeatureFlag.PrivateKeyRegeneration]: FALSE, [FeatureFlag.EnrollAeadOnKeyRotation]: FALSE, [FeatureFlag.ForceUpdateKDFSettings]: FALSE, + [FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE, /* Platform */ [FeatureFlag.IpcChannelFramework]: FALSE, diff --git a/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts new file mode 100644 index 00000000000..4448206b2f6 --- /dev/null +++ b/libs/common/src/key-management/master-password/abstractions/master-password-unlock.service.ts @@ -0,0 +1,13 @@ +import { UserId } from "@bitwarden/user-core"; + +import { UserKey } from "../../../types/key"; + +export abstract class MasterPasswordUnlockService { + /** + * Unlocks the user's account using the master password. + * @param masterPassword The master password provided by the user. + * @param userId The ID of the active user. + * @returns the user's decrypted userKey. + */ + abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise; +} diff --git a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts index 8ef14904bce..f982c2c5ce8 100644 --- a/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts +++ b/libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts @@ -171,4 +171,12 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas masterPasswordUnlockData: MasterPasswordUnlockData, userId: UserId, ): Promise; + + /** + * An observable that emits the master password unlock data for the target user. + * @param userId The user ID. + * @throws If the user ID is null or undefined. + * @returns An observable that emits the master password unlock data or null if not found. + */ + abstract masterPasswordUnlockData$(userId: UserId): Observable; } diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts new file mode 100644 index 00000000000..75668e8e6bd --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.spec.ts @@ -0,0 +1,154 @@ +import { mock, MockProxy } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { newGuid } from "@bitwarden/guid"; +// eslint-disable-next-line no-restricted-imports +import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; +import { MasterKey, UserKey } from "../../../types/key"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { + MasterKeyWrappedUserKey, + MasterPasswordSalt, + MasterPasswordUnlockData, +} from "../types/master-password.types"; + +import { DefaultMasterPasswordUnlockService } from "./default-master-password-unlock.service"; + +describe("DefaultMasterPasswordUnlockService", () => { + let sut: DefaultMasterPasswordUnlockService; + + let masterPasswordService: MockProxy; + let keyService: MockProxy; + + const mockMasterPassword = "testExample"; + const mockUserId = newGuid() as UserId; + + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const mockMasterPasswordUnlockData: MasterPasswordUnlockData = new MasterPasswordUnlockData( + "user@example.com" as MasterPasswordSalt, + new Argon2KdfConfig(100000, 64, 1), + "encryptedMasterKeyWrappedUserKey" as MasterKeyWrappedUserKey, + ); + + //Legacy data for tests + const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(32)) as MasterKey; + const mockKeyHash = "localKeyHash"; + + beforeEach(() => { + masterPasswordService = mock(); + keyService = mock(); + + sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService); + + masterPasswordService.masterPasswordUnlockData$.mockReturnValue( + of(mockMasterPasswordUnlockData), + ); + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockResolvedValue(mockUserKey); + + // Legacy state mocking + keyService.makeMasterKey.mockResolvedValue(mockMasterKey); + keyService.hashMasterKey.mockResolvedValue(mockKeyHash); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe("unlockWithMasterPassword", () => { + test.each([null as unknown as string, undefined as unknown as string, ""])( + "throws when the provided master password is %s", + async (masterPassword) => { + await expect(sut.unlockWithMasterPassword(masterPassword, mockUserId)).rejects.toThrow( + "Master password is required", + ); + expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled(); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided master password is %s", + async (userId) => { + await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow( + "User ID is required", + ); + }, + ); + + it("throws an error when the user doesn't have masterPasswordUnlockData", async () => { + masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null)); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master password unlock data was not found for the user " + mockUserId, + ); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect( + masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData, + ).not.toHaveBeenCalled(); + }); + + it("returns userKey successfully", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + }); + + it("sets legacy state on success", async () => { + const result = await sut.unlockWithMasterPassword(mockMasterPassword, mockUserId); + + expect(result).toEqual(mockUserKey); + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterKey, + HashPurpose.LocalAuthorization, + ); + expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockKeyHash, mockUserId); + expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId); + }); + + it("throws an error if masterKey construction fails", async () => { + keyService.makeMasterKey.mockResolvedValue(null as unknown as MasterKey); + + await expect(sut.unlockWithMasterPassword(mockMasterPassword, mockUserId)).rejects.toThrow( + "Master key could not be created to set legacy master password state.", + ); + + expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId); + expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData, + ); + + expect(keyService.makeMasterKey).toHaveBeenCalledWith( + mockMasterPassword, + mockMasterPasswordUnlockData.salt, + mockMasterPasswordUnlockData.kdf, + ); + expect(keyService.hashMasterKey).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalled(); + expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts new file mode 100644 index 00000000000..87114000abf --- /dev/null +++ b/libs/common/src/key-management/master-password/services/default-master-password-unlock.service.ts @@ -0,0 +1,75 @@ +import { firstValueFrom } from "rxjs"; + +// eslint-disable-next-line no-restricted-imports +import { KeyService } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { HashPurpose } from "../../../platform/enums"; +import { UserKey } from "../../../types/key"; +import { MasterPasswordUnlockService } from "../abstractions/master-password-unlock.service"; +import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction"; +import { MasterPasswordUnlockData } from "../types/master-password.types"; + +export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockService { + constructor( + private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction, + private readonly keyService: KeyService, + ) {} + + async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise { + this.validateInput(masterPassword, userId); + + const masterPasswordUnlockData = await firstValueFrom( + this.masterPasswordService.masterPasswordUnlockData$(userId), + ); + + if (masterPasswordUnlockData == null) { + throw new Error("Master password unlock data was not found for the user " + userId); + } + + const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData( + masterPassword, + masterPasswordUnlockData, + ); + + await this.setLegacyState(masterPassword, masterPasswordUnlockData, userId); + + return userKey; + } + + private validateInput(masterPassword: string, userId: UserId): void { + if (masterPassword == null || masterPassword === "") { + throw new Error("Master password is required"); + } + if (userId == null) { + throw new Error("User ID is required"); + } + } + + // Previously unlocking had the side effect of setting the masterKey and masterPasswordHash in state. + // This is to preserve that behavior, once masterKey and masterPasswordHash state is removed this should be removed as well. + private async setLegacyState( + masterPassword: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + const masterKey = await this.keyService.makeMasterKey( + masterPassword, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + if (!masterKey) { + throw new Error("Master key could not be created to set legacy master password state."); + } + + const localKeyHash = await this.keyService.hashMasterKey( + masterPassword, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId); + await this.masterPasswordService.setMasterKey(masterKey, userId); + } +} diff --git a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts index 81aea5e480a..5db7f178b18 100644 --- a/libs/common/src/key-management/master-password/services/fake-master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/fake-master-password.service.ts @@ -119,4 +119,8 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA ): Promise { return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); } + + masterPasswordUnlockData$(userId: UserId): Observable { + return this.mock.masterPasswordUnlockData$(userId); + } } diff --git a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts index 02b4e9a895a..f5fee3be4c5 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.spec.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.spec.ts @@ -1,6 +1,5 @@ import { mock, MockProxy } from "jest-mock-extended"; -import * as rxjs from "rxjs"; -import { firstValueFrom, of } from "rxjs"; +import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; @@ -10,6 +9,7 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden import { FakeAccountService, + FakeStateProvider, makeSymmetricCryptoKey, mockAccountServiceWith, } from "../../../../spec"; @@ -17,7 +17,6 @@ import { ForceSetPasswordReason } from "../../../auth/models/domain/force-set-pa import { LogService } from "../../../platform/abstractions/log.service"; import { StateService } from "../../../platform/abstractions/state.service"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; -import { StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { MasterKey, UserKey } from "../../../types/key"; import { KeyGenerationService } from "../../crypto"; @@ -30,25 +29,30 @@ import { MasterPasswordUnlockData, } from "../types/master-password.types"; -import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service"; +import { + FORCE_SET_PASSWORD_REASON, + MASTER_KEY_ENCRYPTED_USER_KEY, + MASTER_PASSWORD_UNLOCK_KEY, + MasterPasswordService, +} from "./master-password.service"; describe("MasterPasswordService", () => { let sut: MasterPasswordService; - let stateProvider: MockProxy; let stateService: MockProxy; let keyGenerationService: MockProxy; let encryptService: MockProxy; let logService: MockProxy; let cryptoFunctionService: MockProxy; let accountService: FakeAccountService; + let stateProvider: FakeStateProvider; const userId = "00000000-0000-0000-0000-000000000000" as UserId; - const mockUserState = { - state$: of(null), - update: jest.fn().mockResolvedValue(null), - }; + const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); + const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); + const salt = "test@bitwarden.com" as MasterPasswordSalt; + const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; const testUserKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 1); const testMasterKey: MasterKey = makeSymmetricCryptoKey(32, 2); const testStretchedMasterKey: SymmetricCryptoKey = makeSymmetricCryptoKey(64, 3); @@ -58,17 +62,13 @@ describe("MasterPasswordService", () => { "2.gbauOANURUHqvhLTDnva1A==|nSW+fPumiuTaDB/s12+JO88uemV6rhwRSR+YR1ZzGr5j6Ei3/h+XEli2Unpz652NlZ9NTuRpHxeOqkYYJtp7J+lPMoclgteXuAzUu9kqlRc=|DeUFkhIwgkGdZA08bDnDqMMNmZk21D+H5g8IostPKAY="; beforeEach(() => { - stateProvider = mock(); stateService = mock(); keyGenerationService = mock(); encryptService = mock(); logService = mock(); cryptoFunctionService = mock(); accountService = mockAccountServiceWith(userId); - - stateProvider.getUser.mockReturnValue(mockUserState as any); - - mockUserState.update.mockReset(); + stateProvider = new FakeStateProvider(accountService); sut = new MasterPasswordService( stateProvider, @@ -88,6 +88,10 @@ describe("MasterPasswordService", () => { }); }); + afterEach(() => { + jest.resetAllMocks(); + }); + describe("saltForUser$", () => { it("throws when userid not present", async () => { expect(() => { @@ -111,12 +115,10 @@ describe("MasterPasswordService", () => { await sut.setForceSetPasswordReason(reason, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - - // Call the update function to verify it returns the correct reason - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toBe(reason); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(reason); }); it("throws an error if reason is null", async () => { @@ -132,31 +134,29 @@ describe("MasterPasswordService", () => { }); it("does not overwrite AdminForcePasswordReset with other reasons except None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.WeakMasterPassword, userId); - expect(mockUserState.update).not.toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.AdminForcePasswordReset); }); it("allows overwriting AdminForcePasswordReset with None", async () => { - jest - .spyOn(sut, "forceSetPasswordReason$") - .mockReturnValue(of(ForceSetPasswordReason.AdminForcePasswordReset)); - - jest - .spyOn(rxjs, "firstValueFrom") - .mockResolvedValue(ForceSetPasswordReason.AdminForcePasswordReset); + stateProvider.singleUser + .getFake(userId, FORCE_SET_PASSWORD_REASON) + .nextState(ForceSetPasswordReason.AdminForcePasswordReset); await sut.setForceSetPasswordReason(ForceSetPasswordReason.None, userId); - expect(mockUserState.update).toHaveBeenCalled(); + const state = await firstValueFrom( + stateProvider.getUser(userId, FORCE_SET_PASSWORD_REASON).state$, + ); + expect(state).toEqual(ForceSetPasswordReason.None); }); }); describe("decryptUserKeyWithMasterKey", () => { @@ -227,10 +227,10 @@ describe("MasterPasswordService", () => { await sut.setMasterKeyEncryptedUserKey(encryptedKey, userId); - expect(stateProvider.getUser).toHaveBeenCalled(); - expect(mockUserState.update).toHaveBeenCalled(); - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(encryptedKey.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$, + ); + expect(state).toEqual(encryptedKey.toJSON()); }); }); @@ -328,11 +328,6 @@ describe("MasterPasswordService", () => { }); describe("setMasterPasswordUnlockData", () => { - const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000); - const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3); - const salt = "test@bitwarden.com" as MasterPasswordSalt; - const userKey = makeSymmetricCryptoKey(64, 2) as UserKey; - it.each([kdfPBKDF2, kdfArgon2])( "sets the master password unlock data kdf %o in the state", async (kdfConfig) => { @@ -345,11 +340,10 @@ describe("MasterPasswordService", () => { await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); - expect(stateProvider.getUser).toHaveBeenCalledWith(userId, MASTER_PASSWORD_UNLOCK_KEY); - expect(mockUserState.update).toHaveBeenCalled(); - - const updateFn = mockUserState.update.mock.calls[0][0]; - expect(updateFn(null)).toEqual(masterPasswordUnlockData.toJSON()); + const state = await firstValueFrom( + stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$, + ); + expect(state).toEqual(masterPasswordUnlockData.toJSON()); }, ); @@ -373,6 +367,40 @@ describe("MasterPasswordService", () => { }); }); + describe("masterPasswordUnlockData$", () => { + test.each([null as unknown as UserId, undefined as unknown as UserId])( + "throws when the provided userId is %s", + async (userId) => { + expect(() => sut.masterPasswordUnlockData$(userId)).toThrow("userId is null or undefined."); + }, + ); + + it("returns null when no data is set", async () => { + stateProvider.singleUser.getFake(userId, MASTER_PASSWORD_UNLOCK_KEY).nextState(null); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toBeNull(); + }); + + it.each([kdfPBKDF2, kdfArgon2])( + "returns the master password unlock data for kdf %o from state", + async (kdfConfig) => { + const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData( + "test-password", + kdfConfig, + salt, + userKey, + ); + await sut.setMasterPasswordUnlockData(masterPasswordUnlockData, userId); + + const result = await firstValueFrom(sut.masterPasswordUnlockData$(userId)); + + expect(result).toEqual(masterPasswordUnlockData.toJSON()); + }, + ); + }); + describe("MASTER_PASSWORD_UNLOCK_KEY", () => { it("has the correct configuration", () => { expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined(); diff --git a/libs/common/src/key-management/master-password/services/master-password.service.ts b/libs/common/src/key-management/master-password/services/master-password.service.ts index 9f7e054d64c..5cb6bb96a45 100644 --- a/libs/common/src/key-management/master-password/services/master-password.service.ts +++ b/libs/common/src/key-management/master-password/services/master-password.service.ts @@ -50,7 +50,7 @@ const MASTER_KEY_HASH = new UserKeyDefinition(MASTER_PASSWORD_DISK, "mas }); /** Disk to persist through lock */ -const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( +export const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( MASTER_PASSWORD_DISK, "masterKeyEncryptedUserKey", { @@ -60,7 +60,7 @@ const MASTER_KEY_ENCRYPTED_USER_KEY = new UserKeyDefinition( ); /** Disk to persist through lock and account switches */ -const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( +export const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition( MASTER_PASSWORD_DISK, "forceSetPasswordReason", { @@ -344,4 +344,10 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr .getUser(userId, MASTER_PASSWORD_UNLOCK_KEY) .update(() => masterPasswordUnlockData.toJSON()); } + + masterPasswordUnlockData$(userId: UserId): Observable { + assertNonNullish(userId, "userId"); + + return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; + } } diff --git a/libs/key-management-ui/src/lock/components/lock.component.html b/libs/key-management-ui/src/lock/components/lock.component.html index 9a8e8c9f768..77f603204b3 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.html +++ b/libs/key-management-ui/src/lock/components/lock.component.html @@ -120,73 +120,87 @@ - -
- - {{ "masterPass" | i18n }} - - - - - - -
- - -

{{ "or" | i18n }}

- - + @if ( + (unlockWithMasterPasswordUnlockDataFlag$ | async) && + unlockOptions.masterPassword.enabled && + activeUnlockOption === UnlockOption.MasterPassword + ) { + + } @else { + + + + {{ "masterPass" | i18n }} + - + bitIconButton + bitSuffix + bitPasswordInputToggle + [(toggled)]="showPassword" + > - - - + + - -
-
-
+
+ + +

{{ "or" | i18n }}

+ + + + + + + + + + +
+ + + } diff --git a/libs/key-management-ui/src/lock/components/lock.component.spec.ts b/libs/key-management-ui/src/lock/components/lock.component.spec.ts index 8c8429d3788..69f949fb843 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.spec.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.spec.ts @@ -25,6 +25,7 @@ import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/ import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -91,9 +92,10 @@ describe("LockComponent", () => { const mockLockComponentService = mock(); const mockAnonLayoutWrapperDataService = mock(); const mockBroadcasterService = mock(); + const mockConfigService = mock(); beforeEach(async () => { - jest.clearAllMocks(); + jest.resetAllMocks(); // Setup default mock returns mockPlatformUtilsService.getClientType.mockReturnValue(ClientType.Web); @@ -148,6 +150,7 @@ describe("LockComponent", () => { { provide: LockComponentService, useValue: mockLockComponentService }, { provide: AnonLayoutWrapperDataService, useValue: mockAnonLayoutWrapperDataService }, { provide: BroadcasterService, useValue: mockBroadcasterService }, + { provide: ConfigService, useValue: mockConfigService }, ], }) .overrideProvider(DialogService, { useValue: mockDialogService }) @@ -358,6 +361,135 @@ describe("LockComponent", () => { }); }); + describe("successfulMasterPasswordUnlock", () => { + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + const masterPassword = "test-password"; + + beforeEach(async () => { + component.activeAccount = await firstValueFrom(mockAccountService.activeAccount$); + }); + + it.each([ + [undefined as unknown as UserKey, undefined as unknown as string], + [null as unknown as UserKey, null as unknown as string], + [mockUserKey, undefined as unknown as string], + [mockUserKey, null as unknown as string], + [mockUserKey, ""], + [undefined as unknown as UserKey, masterPassword], + [null as unknown as UserKey, masterPassword], + ])( + "logs an error and doesn't unlock when called with invalid data", + async (userKey, masterPassword) => { + await component.successfulMasterPasswordUnlock({ userKey, masterPassword }); + + expect(mockLogService.error).toHaveBeenCalledWith( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + expect(mockKeyService.setUserKey).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [false, undefined, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: false } as MasterPasswordPolicyOptions, true], + [true, { enforceOnLogin: true } as MasterPasswordPolicyOptions, false], + [false, { enforceOnLogin: true } as MasterPasswordPolicyOptions, true], + ])( + "unlocks and force set password change = %o when master password on login = %o and evaluated password against policy = %o and policy loaded from policy service", + async (forceSetPassword, masterPasswordPolicyOptions, evaluatedMasterPassword) => { + mockPolicyService.masterPasswordPolicyOptions$.mockReturnValue( + of(masterPasswordPolicyOptions), + ); + const passwordStrengthResult = { score: 1 } as ZXCVBNResult; + mockPasswordStrengthService.getPasswordStrength.mockReturnValue(passwordStrengthResult); + mockPolicyService.evaluateMasterPassword.mockReturnValue(evaluatedMasterPassword); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockPolicyService.masterPasswordPolicyOptions$).toHaveBeenCalledWith(userId); + if (masterPasswordPolicyOptions?.enforceOnLogin) { + expect(mockPasswordStrengthService.getPasswordStrength).toHaveBeenCalledWith( + masterPassword, + component.activeAccount!.email, + ); + expect(mockPolicyService.evaluateMasterPassword).toHaveBeenCalledWith( + passwordStrengthResult.score, + masterPassword, + masterPasswordPolicyOptions, + ); + } + if (forceSetPassword) { + expect(mockMasterPasswordService.setForceSetPasswordReason).toHaveBeenCalledWith( + ForceSetPasswordReason.WeakMasterPassword, + userId, + ); + } else { + expect(mockMasterPasswordService.setForceSetPasswordReason).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + [true, ClientType.Browser], + [false, ClientType.Cli], + [false, ClientType.Desktop], + [false, ClientType.Web], + ])( + "unlocks and navigate by url to previous url = %o when client type = %o and previous url was set", + async (shouldNavigate, clientType) => { + const previousUrl = "/test-url"; + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(previousUrl); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + if (shouldNavigate) { + expect(mockRouter.navigateByUrl).toHaveBeenCalledWith(previousUrl); + } else { + expect(mockRouter.navigateByUrl).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["/tabs/current", ClientType.Browser], + [undefined, ClientType.Cli], + ["vault", ClientType.Desktop], + ["vault", ClientType.Web], + ])( + "unlocks and navigate to success url = %o when client type = %o", + async (navigateUrl, clientType) => { + component.clientType = clientType; + mockLockComponentService.getPreviousUrl.mockReturnValue(null); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockRouter.navigate).toHaveBeenCalledWith([navigateUrl]); + }, + ); + + it("unlocks and close browser extension popout on firefox extension", async () => { + component.shouldClosePopout = true; + mockPlatformUtilsService.getDevice.mockReturnValue(DeviceType.FirefoxExtension); + + await component.successfulMasterPasswordUnlock({ userKey: mockUserKey, masterPassword }); + + assertUnlocked(); + expect(mockLockComponentService.closeBrowserExtensionPopout).toHaveBeenCalled(); + }); + + function assertUnlocked(): void { + expect(mockKeyService.setUserKey).toHaveBeenCalledWith( + mockUserKey, + component.activeAccount!.id, + ); + } + }); + describe("unlockViaMasterPassword", () => { const mockMasterKey = new SymmetricCryptoKey(new Uint8Array(64)) as MasterKey; const masterPasswordVerificationResponse: MasterPasswordVerificationResponse = { diff --git a/libs/key-management-ui/src/lock/components/lock.component.ts b/libs/key-management-ui/src/lock/components/lock.component.ts index c7aa8969660..e7550e34b9f 100644 --- a/libs/key-management-ui/src/lock/components/lock.component.ts +++ b/libs/key-management-ui/src/lock/components/lock.component.ts @@ -29,10 +29,12 @@ import { MasterPasswordVerificationResponse, } from "@bitwarden/common/auth/types/verification"; import { ClientType, DeviceType } from "@bitwarden/common/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction"; import { InternalMasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction"; import { PinServiceAbstraction } from "@bitwarden/common/key-management/pin/pin.service.abstraction"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service"; @@ -64,6 +66,8 @@ import { UnlockOptionValue, } from "../services/lock-component.service"; +import { MasterPasswordLockComponent } from "./master-password-lock/master-password-lock.component"; + const BroadcasterSubscriptionId = "LockComponent"; const clientTypeToSuccessRouteRecord: Partial> = { @@ -72,6 +76,12 @@ const clientTypeToSuccessRouteRecord: Partial> = { [ClientType.Browser]: "/tabs/current", }; +type AfterUnlockActions = { + passwordEvaluation?: { + masterPassword: string; + }; +}; + /// The minimum amount of time to wait after a process reload for a biometrics auto prompt to be possible /// Fixes safari autoprompt behavior const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; @@ -87,12 +97,17 @@ const AUTOPROMPT_BIOMETRICS_PROCESS_RELOAD_DELAY = 5000; FormFieldModule, AsyncActionsModule, IconButtonModule, + MasterPasswordLockComponent, ], }) export class LockComponent implements OnInit, OnDestroy { private destroy$ = new Subject(); protected loading = true; + protected unlockWithMasterPasswordUnlockDataFlag$ = this.configService.getFeatureFlag$( + FeatureFlag.UnlockWithMasterPasswordUnlockData, + ); + activeAccount: Account | null = null; clientType?: ClientType; @@ -160,6 +175,7 @@ export class LockComponent implements OnInit, OnDestroy { private logoutService: LogoutService, private lockComponentService: LockComponentService, private anonLayoutWrapperDataService: AnonLayoutWrapperDataService, + private configService: ConfigService, // desktop deps private broadcasterService: BroadcasterService, ) {} @@ -379,7 +395,7 @@ export class LockComponent implements OnInit, OnDestroy { // If user cancels biometric prompt, userKey is undefined. if (userKey) { - await this.setUserKeyAndContinue(userKey, false); + await this.setUserKeyAndContinue(userKey); } this.unlockingViaBiometrics = false; @@ -423,6 +439,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + //TODO PM-25385 This code isn't used and should be removed when removing the UnlockWithMasterPasswordUnlockData feature flag. togglePassword() { this.showPassword = !this.showPassword; const input = document.getElementById( @@ -498,6 +515,7 @@ export class LockComponent implements OnInit, OnDestroy { } } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. private validateMasterPassword(): boolean { if (this.formGroup?.invalid) { this.toastService.showToast({ @@ -511,6 +529,7 @@ export class LockComponent implements OnInit, OnDestroy { return true; } + // TODO PM-25385 remove when removing the UnlockWithMasterPasswordUnlockData feature flag. async unlockViaMasterPassword() { if (!this.validateMasterPassword() || this.formGroup == null || this.activeAccount == null) { return; @@ -568,10 +587,33 @@ export class LockComponent implements OnInit, OnDestroy { return; } - await this.setUserKeyAndContinue(userKey, true); + await this.setUserKeyAndContinue(userKey, { + passwordEvaluation: { masterPassword }, + }); } - private async setUserKeyAndContinue(key: UserKey, evaluatePasswordAfterUnlock = false) { + async successfulMasterPasswordUnlock(event: { + userKey: UserKey; + masterPassword: string; + }): Promise { + if (event.userKey == null || !event.masterPassword) { + this.logService.error( + "[LockComponent] successfulMasterPasswordUnlock called with invalid data.", + ); + return; + } + + await this.setUserKeyAndContinue(event.userKey, { + passwordEvaluation: { + masterPassword: event.masterPassword, + }, + }); + } + + protected async setUserKeyAndContinue( + key: UserKey, + afterUnlockActions: AfterUnlockActions = {}, + ): Promise { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -585,10 +627,10 @@ export class LockComponent implements OnInit, OnDestroy { // need to establish trust on the current device await this.deviceTrustService.trustDeviceIfRequired(this.activeAccount.id); - await this.doContinue(evaluatePasswordAfterUnlock); + await this.doContinue(afterUnlockActions); } - private async doContinue(evaluatePasswordAfterUnlock: boolean) { + private async doContinue(afterUnlockActions: AfterUnlockActions) { if (this.activeAccount == null) { throw new Error("No active user."); } @@ -596,7 +638,7 @@ export class LockComponent implements OnInit, OnDestroy { await this.biometricStateService.resetUserPromptCancelled(); this.messagingService.send("unlocked"); - if (evaluatePasswordAfterUnlock) { + if (afterUnlockActions.passwordEvaluation) { const userId = (await firstValueFrom(this.accountService.activeAccount$))?.id; if (userId == null) { throw new Error("No active user."); @@ -613,7 +655,7 @@ export class LockComponent implements OnInit, OnDestroy { ); } - if (this.requirePasswordChange()) { + if (this.requirePasswordChange(afterUnlockActions.passwordEvaluation.masterPassword)) { await this.masterPasswordService.setForceSetPasswordReason( ForceSetPasswordReason.WeakMasterPassword, userId, @@ -669,18 +711,15 @@ export class LockComponent implements OnInit, OnDestroy { * Checks if the master password meets the enforced policy requirements * If not, returns false */ - private requirePasswordChange(): boolean { + private requirePasswordChange(masterPassword: string): boolean { if ( this.enforcedMasterPasswordOptions == undefined || !this.enforcedMasterPasswordOptions.enforceOnLogin || - this.formGroup == null || this.activeAccount == null ) { return false; } - const masterPassword = this.formGroup.controls.masterPassword.value; - const passwordStrength = this.passwordStrengthService.getPasswordStrength( masterPassword, this.activeAccount.email, diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html new file mode 100644 index 00000000000..185fb0666c4 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.html @@ -0,0 +1,55 @@ +
+ + {{ "masterPass" | i18n }} + + + + +
+ + +

{{ "or" | i18n }}

+ + @if (showBiometricsSwap()) { + + } + + @if (showPinSwap()) { + + } + + +
+
diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts new file mode 100644 index 00000000000..d40cc98df11 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.spec.ts @@ -0,0 +1,472 @@ +import { DebugElement } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { By } from "@angular/platform-browser"; +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { UserId } from "@bitwarden/user-core"; + +import { UnlockOption, UnlockOptions } from "../../services/lock-component.service"; + +import { MasterPasswordLockComponent } from "./master-password-lock.component"; + +describe("MasterPasswordLockComponent", () => { + let component: MasterPasswordLockComponent; + let fixture: ComponentFixture; + + const accountService = mock(); + const masterPasswordUnlockService = mock(); + const i18nService = mock(); + const toastService = mock(); + const logService = mock(); + + const mockMasterPassword = "testExample"; + const activeAccount: Account = { + id: "user-id" as UserId, + email: "user@example.com", + emailVerified: true, + name: "User", + }; + const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey; + + const setupComponent = ( + unlockOptions: Partial = {}, + biometricUnlockBtnText: string = "default", + account: Account | null = activeAccount, + ) => { + const defaultOptions: UnlockOptions = { + masterPassword: { enabled: true }, + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.NotEnabledLocally, + }, + }; + + accountService.activeAccount$ = of(account); + fixture.componentRef.setInput("unlockOptions", { ...defaultOptions, ...unlockOptions }); + fixture.componentRef.setInput("biometricUnlockBtnText", biometricUnlockBtnText); + fixture.detectChanges(); + + return { + form: fixture.debugElement.query(By.css("form")), + component, + ...getFormElements(fixture.debugElement.query(By.css("form"))), + }; + }; + + const getFormElements = (form: DebugElement) => ({ + masterPasswordInput: form.query(By.css('input[formControlName="masterPassword"]')), + toggleButton: form.query(By.css("button[bitPasswordInputToggle]")), + submitButton: form.query(By.css('button[type="submit"]')), + logoutButton: form.query(By.css('button[type="button"]:not([bitPasswordInputToggle])')), + secondaryButton: form.query(By.css('button[buttonType="secondary"]')), + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + i18nService.t.mockImplementation((key: string) => key); + + await TestBed.configureTestingModule({ + imports: [ + MasterPasswordLockComponent, + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], + providers: [ + FormBuilder, + { provide: AccountService, useValue: accountService }, + { provide: MasterPasswordUnlockService, useValue: masterPasswordUnlockService }, + { provide: I18nService, useValue: i18nService }, + { provide: ToastService, useValue: toastService }, + { provide: LogService, useValue: logService }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(MasterPasswordLockComponent); + component = fixture.componentInstance; + }); + + describe("form rendering", () => { + let elements: ReturnType; + + beforeEach(() => { + elements = setupComponent(); + }); + + it("creates form with proper structure", () => { + expect(component.formGroup).toBeDefined(); + expect(component.formGroup.controls.masterPassword).toBeDefined(); + }); + + const formElementTests = [ + { + name: "master password input", + selector: "masterPasswordInput", + expectations: (el: HTMLInputElement) => { + expect(el).toMatchObject({ + type: "password", + name: "masterPassword", + required: true, + }); + expect(el.attributes).toHaveProperty("bitInput"); + }, + }, + { + name: "password toggle button", + selector: "toggleButton", + expectations: (el: HTMLButtonElement) => { + expect(el.type).toBe("button"); + expect(el.attributes).toHaveProperty("bitIconButton"); + }, + }, + { + name: "unlock submit button", + selector: "submitButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "submit", + textContent: expect.stringContaining("unlock"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + { + name: "logout button", + selector: "logoutButton", + expectations: (el: HTMLButtonElement) => { + expect(el).toMatchObject({ + type: "button", + textContent: expect.stringContaining("logOut"), + }); + expect(el.attributes).toHaveProperty("bitButton"); + }, + }, + ]; + + test.each(formElementTests)("renders $name correctly", ({ selector, expectations }) => { + const element = elements[selector as keyof typeof elements] as DebugElement; + expect(element).toBeTruthy(); + expectations(element.nativeElement); + }); + + const hiddenButtonTests = [ + { + case: "biometrics swap button when biometrics is undefined", + setup: () => + setupComponent( + { + pin: { enabled: false }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + "swapBiometrics", + ), + expectHidden: true, + }, + { + case: "biometrics swap button when biometrics is disabled", + setup: () => setupComponent({}, "swapBiometrics"), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is disabled", + setup: () => setupComponent({}), + expectHidden: true, + }, + { + case: "PIN swap button when PIN is undefined", + setup: () => + setupComponent({ + pin: { enabled: undefined as unknown as boolean }, + biometrics: { + enabled: undefined as unknown as boolean, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }), + expectHidden: true, + }, + ]; + + test.each(hiddenButtonTests)("doesn't render $case", ({ setup, expectHidden }) => { + const { secondaryButton } = setup(); + expect(!!secondaryButton).toBe(!expectHidden); + }); + }); + + describe("password input", () => { + let setup: ReturnType; + beforeEach(() => { + setup = setupComponent(); + }); + + it("should bind form input to masterPassword form control", async () => { + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + expect(component.formGroup).toBeTruthy(); + const masterPasswordControl = component.formGroup!.get("masterPassword"); + expect(masterPasswordControl).toBeTruthy(); + + masterPasswordControl!.setValue("test-password"); + fixture.detectChanges(); + + const inputElement = input.nativeElement as HTMLInputElement; + expect(inputElement.value).toEqual("test-password"); + }); + + it("should validate required master password field", async () => { + const formGroup = component.formGroup; + + // Initially form should be invalid (empty required field) + expect(formGroup?.invalid).toEqual(true); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(true); + + // Set a value + formGroup?.get("masterPassword")?.setValue("test-password"); + + expect(formGroup?.invalid).toEqual(false); + expect(formGroup?.get("masterPassword")?.hasError("required")).toBe(false); + }); + + it("should toggle password visibility when toggle button is clicked", async () => { + const toggleButton = setup.toggleButton; + expect(toggleButton).toBeTruthy(); + expect(toggleButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const toggleButtonElement = toggleButton.nativeElement as HTMLButtonElement; + const input = setup.masterPasswordInput; + expect(input).toBeTruthy(); + expect(input.nativeElement).toBeInstanceOf(HTMLInputElement); + const inputElement = input.nativeElement as HTMLInputElement; + + // Initially password should be hidden + expect(inputElement.type).toEqual("password"); + + // Click toggle button + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("text"); + + // Click toggle button again + toggleButtonElement.click(); + fixture.detectChanges(); + + expect(inputElement.type).toEqual("password"); + }); + }); + + describe("logout", () => { + it("emits logOut event when logout button is clicked", () => { + const setup = setupComponent(); + let logoutEmitted = false; + component.logOut.subscribe(() => { + logoutEmitted = true; + }); + + expect(setup.logoutButton).toBeTruthy(); + expect(setup.logoutButton.nativeElement).toBeInstanceOf(HTMLButtonElement); + const logoutButtonElement = setup.logoutButton.nativeElement as HTMLButtonElement; + + // Click logout button + logoutButtonElement.click(); + + expect(logoutEmitted).toBe(true); + }); + }); + + describe("swap buttons", () => { + const swapButtonScenarios = [ + { + name: "PIN swap button when PIN is enabled", + unlockOptions: { + pin: { enabled: true }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: true, + shouldEnable: true, + }, + { + name: "PIN swap button when PIN is disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { + enabled: false, + biometricsStatus: BiometricsStatus.PlatformUnsupported, + }, + }, + expectedText: "unlockWithPin", + expectedUnlockOption: UnlockOption.Pin, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is available and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: true, + }, + { + name: "biometrics swap button when biometrics status is available and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.Available }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: true, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics biometrics status is unsupported and enabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: true, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + { + name: "biometrics swap button when biometrics status is unsupported and disabled", + unlockOptions: { + pin: { enabled: false }, + biometrics: { enabled: false, biometricsStatus: BiometricsStatus.PlatformUnsupported }, + }, + expectedText: "swapBiometrics", + expectedUnlockOption: UnlockOption.Biometrics, + shouldShow: false, + shouldEnable: false, + }, + ]; + + test.each(swapButtonScenarios)( + "renders and handles $name", + ({ unlockOptions, expectedText, expectedUnlockOption, shouldShow, shouldEnable }) => { + const { secondaryButton, component } = setupComponent(unlockOptions, expectedText); + + if (shouldShow) { + expect(secondaryButton).toBeTruthy(); + expect(secondaryButton.nativeElement.textContent?.trim()).toBe(expectedText); + + if (shouldEnable) { + secondaryButton.nativeElement.click(); + expect(component.activeUnlockOption()).toBe(expectedUnlockOption); + } else { + expect(secondaryButton.nativeElement.getAttribute("aria-disabled")).toBe("true"); + } + } else { + expect(secondaryButton).toBeFalsy(); + } + }, + ); + }); + + describe("submit", () => { + test.each([null, undefined as unknown as string, ""])( + "won't unlock and show password invalid toast when master password is %s", + async (value) => { + component.formGroup.controls.masterPassword.setValue(value); + + await component.submit(); + + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("masterPasswordRequired"), + }); + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + test.each([null as unknown as Account, undefined as unknown as Account])( + "throws error when active account is %s", + async (value) => { + accountService.activeAccount$ = of(value); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await expect(component.submit()).rejects.toThrow("Null or undefined account"); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).not.toHaveBeenCalled(); + }, + ); + + it("shows an error toast and logs the error when unlock with master password fails", async () => { + const customError = new Error("Specialized error message"); + masterPasswordUnlockService.unlockWithMasterPassword.mockRejectedValue(customError); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + + await component.submit(); + + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + expect(toastService.showToast).toHaveBeenCalledWith({ + variant: "error", + title: i18nService.t("errorOccurred"), + message: i18nService.t("invalidMasterPassword"), + }); + expect(logService.error).toHaveBeenCalledWith( + "[MasterPasswordLockComponent] Failed to unlock via master password", + customError, + ); + }); + + it("emits userKey when unlock is successful", async () => { + masterPasswordUnlockService.unlockWithMasterPassword.mockResolvedValue(mockUserKey); + accountService.activeAccount$ = of(activeAccount); + component.formGroup.controls.masterPassword.setValue(mockMasterPassword); + let emittedEvent: { userKey: UserKey; masterPassword: string } | undefined; + component.successfulUnlock.subscribe( + (event: { userKey: UserKey; masterPassword: string }) => { + emittedEvent = event; + }, + ); + + await component.submit(); + + expect(emittedEvent?.userKey).toEqual(mockUserKey); + expect(emittedEvent?.masterPassword).toEqual(mockMasterPassword); + expect(masterPasswordUnlockService.unlockWithMasterPassword).toHaveBeenCalledWith( + mockMasterPassword, + activeAccount.id, + ); + }); + }); +}); diff --git a/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts new file mode 100644 index 00000000000..c9399cc3ab2 --- /dev/null +++ b/libs/key-management-ui/src/lock/components/master-password-lock/master-password-lock.component.ts @@ -0,0 +1,111 @@ +import { Component, computed, inject, input, model, output } from "@angular/core"; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from "@angular/forms"; +import { firstValueFrom } from "rxjs"; + +import { JslibModule } from "@bitwarden/angular/jslib.module"; +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { MasterPasswordUnlockService } from "@bitwarden/common/key-management/master-password/abstractions/master-password-unlock.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { UserKey } from "@bitwarden/common/types/key"; +import { + AsyncActionsModule, + ButtonModule, + FormFieldModule, + IconButtonModule, + ToastService, +} from "@bitwarden/components"; +import { BiometricsStatus } from "@bitwarden/key-management"; +import { LogService } from "@bitwarden/logging"; +import { UserId } from "@bitwarden/user-core"; + +import { + UnlockOption, + UnlockOptions, + UnlockOptionValue, +} from "../../services/lock-component.service"; + +@Component({ + selector: "bit-master-password-lock", + templateUrl: "master-password-lock.component.html", + imports: [ + JslibModule, + ReactiveFormsModule, + ButtonModule, + FormFieldModule, + AsyncActionsModule, + IconButtonModule, + ], +}) +export class MasterPasswordLockComponent { + private readonly accountService = inject(AccountService); + private readonly masterPasswordUnlockService = inject(MasterPasswordUnlockService); + private readonly i18nService = inject(I18nService); + private readonly toastService = inject(ToastService); + private readonly logService = inject(LogService); + UnlockOption = UnlockOption; + + activeUnlockOption = model.required(); + + unlockOptions = input.required(); + biometricUnlockBtnText = input.required(); + showPinSwap = computed(() => this.unlockOptions().pin.enabled ?? false); + biometricsAvailable = computed(() => this.unlockOptions().biometrics.enabled ?? false); + showBiometricsSwap = computed(() => { + const status = this.unlockOptions().biometrics.biometricsStatus; + return ( + status !== BiometricsStatus.PlatformUnsupported && + status !== BiometricsStatus.NotEnabledLocally + ); + }); + + successfulUnlock = output<{ userKey: UserKey; masterPassword: string }>(); + logOut = output(); + + formGroup = new FormGroup({ + masterPassword: new FormControl("", { + validators: [Validators.required], + updateOn: "submit", + }), + }); + + submit = async () => { + this.formGroup.markAllAsTouched(); + const masterPassword = this.formGroup.controls.masterPassword.value; + if (this.formGroup.invalid || !masterPassword) { + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("masterPasswordRequired"), + }); + return; + } + + const activeUserId = await firstValueFrom(getUserId(this.accountService.activeAccount$)); + + await this.unlockViaMasterPassword(masterPassword, activeUserId); + }; + + private async unlockViaMasterPassword( + masterPassword: string, + activeUserId: UserId, + ): Promise { + try { + const userKey = await this.masterPasswordUnlockService.unlockWithMasterPassword( + masterPassword, + activeUserId, + ); + this.successfulUnlock.emit({ userKey, masterPassword }); + } catch (error) { + this.logService.error( + "[MasterPasswordLockComponent] Failed to unlock via master password", + error, + ); + this.toastService.showToast({ + variant: "error", + title: this.i18nService.t("errorOccurred"), + message: this.i18nService.t("invalidMasterPassword"), + }); + } + } +}