1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-24683] Move change kdf service to SDK implementation (#16001)

* Add new mp service api

* Fix tests

* Add test coverage

* Add newline

* Fix type

* Rename to "unwrapUserKeyFromMasterPasswordUnlockData"

* Fix build

* Fix build on cli

* Fix linting

* Re-sort spec

* Add tests

* Fix test and build issues

* Fix build

* Clean up

* Remove introduced function

* Clean up comments

* Fix abstract class types

* Fix comments

* Cleanup

* Cleanup

* Update libs/common/src/key-management/master-password/types/master-password.types.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/services/master-password.service.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/types/master-password.types.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Add comments

* Fix build

* Add arg null check

* Cleanup

* Fix build

* Fix build on browser

* Implement KDF change service

* Deprecate encryptUserKeyWithMasterKey

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Update libs/common/src/key-management/master-password/abstractions/master-password.service.abstraction.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Add tests for null params

* Fix builds

* Cleanup and deprecate more functions

* Fix formatting

* Prettier

* Clean up

* Update libs/key-management/src/abstractions/key.service.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Make emailToSalt private and expose abstract saltForUser

* Add tests

* Add docs

* Fix build

* Fix tests

* Fix tests

* Address feedback and fix primitive obsession

* Consolidate active account checks in change kdf confirmation component

* Update libs/common/src/key-management/kdf/services/change-kdf-service.spec.ts

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>

* Add defensive parameter checks

* Add tests

* Add comment for follow-up epic

* Move change kdf service, remove abstraction and add api service

* Fix test

* Drop redundant null check

* Address feedback

* Add throw on empty password

* Fix tests

* Mark change kdf service as internal

* Add abstract classes

* Switch to abstraction

* Move change kdf to sdk

* Update tests

* Fix tests

* Clean up sdk mapping

* Clean up tests

* Check the argument to make_update_kdf

* Fix mock data

* Fix tests

* Fix relative imports

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
This commit is contained in:
Bernd Schoolmann
2025-10-20 12:37:19 +02:00
committed by GitHub
parent d5f5052c11
commit fa584f76b4
8 changed files with 175 additions and 109 deletions

View File

@@ -1,14 +1,14 @@
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey } from "@bitwarden/common/types/key";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { makeEncString } from "../../../spec";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { EncString } from "../crypto/models/enc-string";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationHash,
@@ -21,35 +21,63 @@ import { DefaultChangeKdfService } from "./change-kdf-service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const keyService = mock<KeyService>();
const kdfConfigService = mock<KdfConfigService>();
const sdkService = mock<SdkService>();
let sut: DefaultChangeKdfService = mock<DefaultChangeKdfService>();
let sut: DefaultChangeKdfService;
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockOldKdfConfig = new PBKDF2KdfConfig(100000);
const mockNewKdfConfig = new PBKDF2KdfConfig(200000);
const mockOldKdfConfig = new PBKDF2KdfConfig(100000);
const mockOldHash = "oldHash" as MasterPasswordAuthenticationHash;
const mockNewHash = "newHash" as MasterPasswordAuthenticationHash;
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockSalt = "test@bitwarden.com" as MasterPasswordSalt;
const mockWrappedUserKey = "wrappedUserKey";
const mockWrappedUserKey: EncString = makeEncString("wrappedUserKey");
const mockSdkClient = {
crypto: jest.fn().mockReturnValue({
make_update_kdf: jest.fn(),
}),
};
const mockRef = {
value: mockSdkClient,
[Symbol.dispose]: jest.fn(),
};
const mockSdk = {
take: jest.fn().mockReturnValue(mockRef),
};
beforeEach(() => {
sut = new DefaultChangeKdfService(
masterPasswordService,
keyService,
kdfConfigService,
changeKdfApiService,
);
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
sut = new DefaultChangeKdfService(changeKdfApiService, sdkService);
});
afterEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
});
describe("updateUserKdfParams", () => {
const mockUpdateKdfResult = {
masterPasswordAuthenticationData: {
kdf: mockNewKdfConfig.toSdkConfig(),
salt: mockSalt,
masterPasswordAuthenticationHash: mockNewHash,
},
masterPasswordUnlockData: {
kdf: mockNewKdfConfig.toSdkConfig(),
salt: mockSalt,
masterKeyWrappedUserKey: mockWrappedUserKey.encryptedString,
},
oldMasterPasswordAuthenticationData: {
kdf: mockOldKdfConfig.toSdkConfig(),
salt: mockSalt,
masterPasswordAuthenticationHash: mockOldHash,
},
};
beforeEach(() => {
mockSdkClient.crypto().make_update_kdf.mockReturnValue(mockUpdateKdfResult);
});
it("should throw an error if masterPassword is null", async () => {
await expect(
sut.updateUserKdfParams(null as unknown as string, mockNewKdfConfig, mockUserId),
@@ -90,61 +118,31 @@ describe("ChangeKdfService", () => {
).rejects.toThrow("userId");
});
it("should throw an error if userKey is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(null));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
it("should throw an error if SDK is not available", async () => {
sdkService.userClient$ = jest.fn().mockReturnValue(of(null)) as any;
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow();
).rejects.toThrow("SDK not available");
});
it("should throw an error if salt is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(null));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow("Failed to get salt");
});
it("should call SDK update_kdf with correct parameters", async () => {
const masterPassword = "masterPassword";
it("should throw an error if oldKdfConfig is null", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(null));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow("Failed to get oldKdfConfig");
});
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
it("should call apiService.send with correct parameters", async () => {
keyService.userKey$.mockReturnValueOnce(of(mockUserKey));
masterPasswordService.saltForUser$.mockReturnValueOnce(of(mockSalt));
kdfConfigService.getKdfConfig$.mockReturnValueOnce(of(mockOldKdfConfig));
masterPasswordService.makeMasterPasswordAuthenticationData
.mockResolvedValueOnce({
salt: mockSalt,
kdf: mockOldKdfConfig,
masterPasswordAuthenticationHash: mockOldHash,
})
.mockResolvedValueOnce({
salt: mockSalt,
kdf: mockNewKdfConfig,
masterPasswordAuthenticationHash: mockNewHash,
});
masterPasswordService.makeMasterPasswordUnlockData.mockResolvedValueOnce(
new MasterPasswordUnlockData(
mockSalt,
mockNewKdfConfig,
mockWrappedUserKey as MasterKeyWrappedUserKey,
),
expect(mockSdkClient.crypto().make_update_kdf).toHaveBeenCalledWith(
masterPassword,
mockNewKdfConfig.toSdkConfig(),
);
});
await sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId);
it("should call changeKdfApiService.updateUserKdfParams with correct request", async () => {
const masterPassword = "masterPassword";
const expected = new KdfRequest(
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
const expectedRequest = new KdfRequest(
{
salt: mockSalt,
kdf: mockNewKdfConfig,
@@ -153,15 +151,38 @@ describe("ChangeKdfService", () => {
new MasterPasswordUnlockData(
mockSalt,
mockNewKdfConfig,
mockWrappedUserKey as MasterKeyWrappedUserKey,
mockWrappedUserKey.encryptedString as MasterKeyWrappedUserKey,
),
).authenticateWith({
);
expectedRequest.authenticateWith({
salt: mockSalt,
kdf: mockOldKdfConfig,
masterPasswordAuthenticationHash: mockOldHash,
});
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expected);
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expectedRequest);
});
it("should properly dispose of SDK resources", async () => {
const masterPassword = "masterPassword";
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
expect(mockRef[Symbol.dispose]).toHaveBeenCalled();
});
it("should handle SDK errors properly", async () => {
const masterPassword = "masterPassword";
const sdkError = new Error("SDK update_kdf failed");
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);
mockSdkClient.crypto().make_update_kdf.mockImplementation(() => {
throw sdkError;
});
await expect(
sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId),
).rejects.toThrow("SDK update_kdf failed");
});
});
});

View File

@@ -1,55 +1,56 @@
import { firstValueFrom, map } from "rxjs";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig, KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KdfConfig } from "@bitwarden/key-management";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { firstValueFromOrThrow } from "../utils";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import {
fromSdkAuthenticationData,
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { ChangeKdfService } from "./change-kdf-service.abstraction";
export class DefaultChangeKdfService implements ChangeKdfService {
constructor(
private masterPasswordService: MasterPasswordServiceAbstraction,
private keyService: KeyService,
private kdfConfigService: KdfConfigService,
private changeKdfApiService: ChangeKdfApiService,
private sdkService: SdkService,
) {}
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
assertNonNullish(masterPassword, "masterPassword");
assertNonNullish(kdf, "kdf");
assertNonNullish(userId, "userId");
const updateKdfResult = await firstValueFrom(
this.sdkService.userClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
const userKey = await firstValueFromOrThrow(this.keyService.userKey$(userId), "userKey");
const salt = await firstValueFromOrThrow(
this.masterPasswordService.saltForUser$(userId),
"salt",
);
const oldKdfConfig = await firstValueFromOrThrow(
this.kdfConfigService.getKdfConfig$(userId),
"oldKdfConfig",
using ref = sdk.take();
const updateKdfResponse = ref.value
.crypto()
.make_update_kdf(masterPassword, kdf.toSdkConfig());
return updateKdfResponse;
}),
),
);
const oldAuthenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
oldKdfConfig,
salt,
);
const authenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
masterPassword,
kdf,
salt,
);
const unlockData = await this.masterPasswordService.makeMasterPasswordUnlockData(
masterPassword,
kdf,
salt,
userKey,
const authenticationData: MasterPasswordAuthenticationData = fromSdkAuthenticationData(
updateKdfResult.masterPasswordAuthenticationData,
);
const unlockData: MasterPasswordUnlockData = MasterPasswordUnlockData.fromSdk(
updateKdfResult.masterPasswordUnlockData,
);
const oldAuthenticationData: MasterPasswordAuthenticationData = fromSdkAuthenticationData(
updateKdfResult.oldMasterPasswordAuthenticationData,
);
const request = new KdfRequest(authenticationData, unlockData);

View File

@@ -1,8 +1,18 @@
import { Jsonify, Opaque } from "type-fest";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "@bitwarden/sdk-internal";
import {
fromSdkKdfConfig,
Argon2KdfConfig,
KdfConfig,
KdfType,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import {
EncString,
MasterPasswordUnlockData as SdkMasterPasswordUnlockData,
MasterPasswordAuthenticationData as SdkMasterPasswordAuthenticationData,
} from "@bitwarden/sdk-internal";
/**
* The Base64-encoded master password authentication hash, that is sent to the server for authentication.
@@ -24,6 +34,14 @@ export class MasterPasswordUnlockData {
readonly masterKeyWrappedUserKey: MasterKeyWrappedUserKey,
) {}
static fromSdk(sdkData: SdkMasterPasswordUnlockData): MasterPasswordUnlockData {
return new MasterPasswordUnlockData(
sdkData.salt as MasterPasswordSalt,
fromSdkKdfConfig(sdkData.kdf),
sdkData.masterKeyWrappedUserKey as MasterKeyWrappedUserKey,
);
}
toJSON(): any {
return {
salt: this.salt,
@@ -55,3 +73,14 @@ export type MasterPasswordAuthenticationData = {
kdf: KdfConfig;
masterPasswordAuthenticationHash: MasterPasswordAuthenticationHash;
};
export function fromSdkAuthenticationData(
sdkData: SdkMasterPasswordAuthenticationData,
): MasterPasswordAuthenticationData {
return {
salt: sdkData.salt as MasterPasswordSalt,
kdf: fromSdkKdfConfig(sdkData.kdf),
masterPasswordAuthenticationHash:
sdkData.masterPasswordAuthenticationHash as MasterPasswordAuthenticationHash,
};
}