From 53345979592a780636d25ef8d48e0b4b4b03ce87 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 14 Jan 2026 19:06:13 +0100 Subject: [PATCH] [PM-30633] Add setLegacyMasterKeyFromUnlockData (#18316) * Add setMasterKeyFromunlockData * Rename method * Add comment * Add tests * Fix build * Fix linting * Add set local master key hash * Add tests * Cleanup * Move function * Prettier * Fix tests * Eslint * Fix tests --- .../master-password.service.abstraction.ts | 17 +++ .../services/fake-master-password.service.ts | 8 ++ .../services/master-password.service.spec.ts | 120 ++++++++++++++++++ .../services/master-password.service.ts | 48 +++++++ 4 files changed, 193 insertions(+) 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 0e86761685f..9a5b39993e8 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 @@ -113,6 +113,23 @@ export abstract class MasterPasswordServiceAbstraction { * @throws If the user ID is missing. */ abstract userHasMasterPassword(userId: UserId): Promise; + + /** + * Derives a master key from the provided password and master password unlock data, + * then sets it to state for the specified user. This is a temporary backwards compatibility function + * to support existing code that relies on direct master key access. + * Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676 + * + * @param password The master password. + * @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt. + * @param userId The user ID. + * @throws If the password, master password unlock data, or user ID is missing. + */ + abstract setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise; } export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction { 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 90fcaddb1a5..40be7025d89 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 @@ -127,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA masterPasswordUnlockData$(userId: UserId): Observable { return this.mock.masterPasswordUnlockData$(userId); } + + setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + return this.mock.setLegacyMasterKeyFromUnlockData(password, 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 e3d0bf51d67..f72ae0e7c5e 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 @@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs"; import { Jsonify } from "type-fest"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; // eslint-disable-next-line no-restricted-imports import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management"; @@ -415,6 +416,125 @@ describe("MasterPasswordService", () => { ); }); + describe("setLegacyMasterKeyFromUnlockData", () => { + const password = "test-password"; + + it("derives master key from password and sets it in state", async () => { + const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$); + expect(state).toEqual(masterKey); + }); + + it("works with argon2 kdf config", async () => { + const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32)); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfArgon2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + ); + + const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$); + expect(state).toEqual(masterKey); + }); + + it("computes and sets master key hash in state", async () => { + const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey; + const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + const expectedHashB64 = "AQIDBAUGBwg="; + keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey); + cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes); + jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64); + + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId); + + expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith( + masterKey.inner().encryptionKey, + password, + "sha256", + HashPurpose.LocalAuthorization, + ); + + const hashState = await firstValueFrom(sut.masterKeyHash$(userId)); + expect(hashState).toEqual(expectedHashB64); + }); + + it("throws if password is null", async () => { + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await expect( + sut.setLegacyMasterKeyFromUnlockData( + null as unknown as string, + masterPasswordUnlockData, + userId, + ), + ).rejects.toThrow("password is null or undefined."); + }); + + it("throws if masterPasswordUnlockData is null", async () => { + await expect( + sut.setLegacyMasterKeyFromUnlockData( + password, + null as unknown as MasterPasswordUnlockData, + userId, + ), + ).rejects.toThrow("masterPasswordUnlockData is null or undefined."); + }); + + it("throws if userId is null", async () => { + const masterPasswordUnlockData = new MasterPasswordUnlockData( + salt, + kdfPBKDF2, + makeEncString().toSdk() as MasterKeyWrappedUserKey, + ); + + await expect( + sut.setLegacyMasterKeyFromUnlockData( + password, + masterPasswordUnlockData, + null as unknown as UserId, + ), + ).rejects.toThrow("userId is null or undefined."); + }); + }); + 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 c2947b2263d..28d4f58d7dc 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 @@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { assertNonNullish } from "@bitwarden/common/auth/utils"; import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service"; +import { HashPurpose } from "@bitwarden/common/platform/enums"; import { Utils } from "@bitwarden/common/platform/misc/utils"; // eslint-disable-next-line no-restricted-imports import { KdfConfig } from "@bitwarden/key-management"; @@ -342,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$; } + + async setLegacyMasterKeyFromUnlockData( + password: string, + masterPasswordUnlockData: MasterPasswordUnlockData, + userId: UserId, + ): Promise { + assertNonNullish(password, "password"); + assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData"); + assertNonNullish(userId, "userId"); + + const masterKey = (await this.keyGenerationService.deriveKeyFromPassword( + password, + masterPasswordUnlockData.salt, + masterPasswordUnlockData.kdf, + )) as MasterKey; + const localKeyHash = await this.hashMasterKey( + password, + masterKey, + HashPurpose.LocalAuthorization, + ); + + await this.setMasterKey(masterKey, userId); + await this.setMasterKeyHash(localKeyHash, userId); + } + + // Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`. + private async hashMasterKey( + password: string, + key: MasterKey, + hashPurpose: HashPurpose, + ): Promise { + if (password == null) { + throw new Error("password is required."); + } + if (key == null) { + throw new Error("key is required."); + } + + const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1; + const hash = await this.cryptoFunctionService.pbkdf2( + key.inner().encryptionKey, + password, + "sha256", + iterations, + ); + return Utils.fromBufferToB64(hash); + } }