1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-10 21:33:27 +00:00

[PM-23230] Implement KDF Change Service (#15748)

* 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

* use sdk EncString in MasterPasswordUnlockData

* fix remaining tests

---------

Co-authored-by: Thomas Avery <43214426+Thomas-Avery@users.noreply.github.com>
Co-authored-by: Jake Fink <jfink@bitwarden.com>
This commit is contained in:
Bernd Schoolmann
2025-09-24 05:10:54 +09:00
committed by GitHub
parent 6001980dc5
commit 4b73198ce5
33 changed files with 507 additions and 117 deletions

View File

@@ -127,8 +127,7 @@ describe("DesktopSetInitialPasswordService", () => {
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
keysRequest,
credentials.kdfConfig.kdfType,
credentials.kdfConfig.iterations,
credentials.kdfConfig,
);
});

View File

@@ -131,8 +131,7 @@ describe("WebSetInitialPasswordService", () => {
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
keysRequest,
credentials.kdfConfig.kdfType,
credentials.kdfConfig.iterations,
credentials.kdfConfig,
);
});

View File

@@ -4,13 +4,13 @@ import { Component, Inject } from "@angular/core";
import { FormGroup, FormControl, Validators } from "@angular/forms";
import { firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { DIALOG_DATA, ToastService } from "@bitwarden/components";
import { KdfConfig, KdfType, KeyService } from "@bitwarden/key-management";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
@Component({
selector: "app-change-kdf-confirmation",
@@ -28,13 +28,12 @@ export class ChangeKdfConfirmationComponent {
loading = false;
constructor(
private apiService: ApiService,
private i18nService: I18nService,
private keyService: KeyService,
private messagingService: MessagingService,
@Inject(DIALOG_DATA) params: { kdf: KdfType; kdfConfig: KdfConfig },
private accountService: AccountService,
private toastService: ToastService,
private changeKdfService: ChangeKdfService,
) {
this.kdfConfig = params.kdfConfig;
this.masterPassword = null;
@@ -56,37 +55,17 @@ export class ChangeKdfConfirmationComponent {
};
private async makeKeyAndSaveAsync() {
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (activeAccount == null) {
throw new Error("No active account found.");
}
const masterPassword = this.form.value.masterPassword;
// Ensure the KDF config is valid.
this.kdfConfig.validateKdfConfigForSetting();
const request = new KdfRequest();
request.kdf = this.kdfConfig.kdfType;
request.kdfIterations = this.kdfConfig.iterations;
if (this.kdfConfig.kdfType === KdfType.Argon2id) {
request.kdfMemory = this.kdfConfig.memory;
request.kdfParallelism = this.kdfConfig.parallelism;
}
const masterKey = await this.keyService.getOrDeriveMasterKey(masterPassword, activeAccount.id);
request.masterPasswordHash = await this.keyService.hashMasterKey(masterPassword, masterKey);
const activeAccountId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
const newMasterKey = await this.keyService.makeMasterKey(
await this.changeKdfService.updateUserKdfParams(
masterPassword,
activeAccount.email,
this.kdfConfig,
activeAccountId,
);
request.newMasterPasswordHash = await this.keyService.hashMasterKey(
masterPassword,
newMasterKey,
);
const newUserKey = await this.keyService.encryptUserKeyWithMasterKey(newMasterKey);
request.key = newUserKey[1].encryptedString;
await this.apiService.postAccountKdf(request);
}
}

View File

@@ -1,5 +1,5 @@
import { Injectable } from "@angular/core";
import { firstValueFrom, Observable } from "rxjs";
import { firstValueFrom } from "rxjs";
import { Account } from "@bitwarden/common/auth/abstractions/account.service";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
@@ -7,6 +7,7 @@ import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/a
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { firstValueFromOrThrow } from "@bitwarden/common/key-management/utils";
import { VaultTimeoutService } from "@bitwarden/common/key-management/vault-timeout";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
@@ -103,18 +104,18 @@ export class UserKeyRotationService {
}
// Read current cryptographic state / settings
const masterKeyKdfConfig: KdfConfig = (await this.firstValueFromOrThrow(
const masterKeyKdfConfig: KdfConfig = (await firstValueFromOrThrow(
this.kdfConfigService.getKdfConfig$(user.id),
"KDF config",
))!;
// The masterkey salt used for deriving the masterkey always needs to be trimmed and lowercased.
const masterKeySalt = user.email.trim().toLowerCase();
const currentUserKey: UserKey = (await this.firstValueFromOrThrow(
const currentUserKey: UserKey = (await firstValueFromOrThrow(
this.keyService.userKey$(user.id),
"User key",
))!;
const currentUserKeyWrappedPrivateKey = new EncString(
(await this.firstValueFromOrThrow(
(await firstValueFromOrThrow(
this.keyService.userEncryptedPrivateKey$(user.id),
"User encrypted private key",
))!,
@@ -515,12 +516,4 @@ export class UserKeyRotationService {
HashPurpose.ServerAuthorization,
);
}
async firstValueFromOrThrow<T>(value: Observable<T>, name: string): Promise<T> {
const result = await firstValueFrom(value);
if (result == null) {
throw new Error(`Failed to get ${name}`);
}
return result;
}
}

View File

@@ -1,6 +1,25 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
export class OrganizationUserResetPasswordRequest {
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,
): OrganizationUserResetPasswordRequest {
const request = new OrganizationUserResetPasswordRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
return request;
}
}

View File

@@ -137,8 +137,7 @@ export class DefaultSetInitialPasswordService implements SetInitialPasswordServi
newPasswordHint,
orgSsoIdentifier,
keysRequest,
kdfConfig.kdfType,
kdfConfig.iterations,
kdfConfig,
);
await this.masterPasswordApiService.setPassword(request);

View File

@@ -157,8 +157,7 @@ describe("DefaultSetInitialPasswordService", () => {
credentials.newPasswordHint,
credentials.orgSsoIdentifier,
keysRequest,
credentials.kdfConfig.kdfType,
credentials.kdfConfig.iterations,
credentials.kdfConfig,
);
enrollmentRequest = new OrganizationUserResetPasswordEnrollmentRequest();

View File

@@ -163,6 +163,10 @@ import { EncryptServiceImplementation } from "@bitwarden/common/key-management/c
import { WebCryptoFunctionService } from "@bitwarden/common/key-management/crypto/services/web-crypto-function.service";
import { DeviceTrustServiceAbstraction } from "@bitwarden/common/key-management/device-trust/abstractions/device-trust.service.abstraction";
import { DeviceTrustService } from "@bitwarden/common/key-management/device-trust/services/device-trust.service.implementation";
import { DefaultChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service";
import { ChangeKdfApiService } from "@bitwarden/common/key-management/kdf/change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service";
import { ChangeKdfService } from "@bitwarden/common/key-management/kdf/change-kdf-service.abstraction";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "@bitwarden/common/key-management/key-connector/abstractions/key-connector.service";
import { KeyConnectorService } from "@bitwarden/common/key-management/key-connector/services/key-connector.service";
import {
@@ -1242,6 +1246,16 @@ const safeProviders: SafeProvider[] = [
ConfigService,
],
}),
safeProvider({
provide: ChangeKdfApiService,
useClass: DefaultChangeKdfApiService,
deps: [ApiServiceAbstraction],
}),
safeProvider({
provide: ChangeKdfService,
useClass: DefaultChangeKdfService,
deps: [MasterPasswordServiceAbstraction, KeyService, KdfConfigService, ChangeKdfApiService],
}),
safeProvider({
provide: AuthRequestServiceAbstraction,
useClass: AuthRequestService,

View File

@@ -303,6 +303,14 @@ export class InputPasswordComponent implements OnInit {
throw new Error("KdfConfig is required to create master key.");
}
const salt =
this.userId != null
? await firstValueFrom(this.masterPasswordService.saltForUser$(this.userId))
: this.masterPasswordService.emailToSalt(this.email);
if (salt == null) {
throw new Error("Salt is required to create master key.");
}
// 2. Verify current password is correct (if necessary)
if (
this.flow === InputPasswordFlow.ChangePassword ||
@@ -348,6 +356,7 @@ export class InputPasswordComponent implements OnInit {
const passwordInputResult: PasswordInputResult = {
newPassword,
salt,
newMasterKey,
newServerMasterKeyHash,
newLocalMasterKeyHash,

View File

@@ -1,18 +1,26 @@
import { MasterPasswordSalt } from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { MasterKey } from "@bitwarden/common/types/key";
import { KdfConfig } from "@bitwarden/key-management";
export interface PasswordInputResult {
currentPassword?: string;
newPassword: string;
kdfConfig?: KdfConfig;
salt?: MasterPasswordSalt;
newPasswordHint?: string;
rotateUserKey?: boolean;
/** @deprecated This low-level cryptographic state will be removed. It will be replaced by high level calls to masterpassword service, in the consumers of this interface. */
currentMasterKey?: MasterKey;
/** @deprecated */
currentServerMasterKeyHash?: string;
/** @deprecated */
currentLocalMasterKeyHash?: string;
newPassword: string;
newPasswordHint?: string;
/** @deprecated */
newMasterKey?: MasterKey;
/** @deprecated */
newServerMasterKeyHash?: string;
/** @deprecated */
newLocalMasterKeyHash?: string;
kdfConfig?: KdfConfig;
rotateUserKey?: boolean;
}

View File

@@ -38,7 +38,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl
import { StateService } from "@bitwarden/common/platform/abstractions/state.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
PasswordStrengthServiceAbstraction,
PasswordStrengthService,
@@ -61,7 +61,7 @@ const masterPassword = "password";
const deviceId = Utils.newGuid();
const accessToken = "ACCESS_TOKEN";
const refreshToken = "REFRESH_TOKEN";
const encryptedUserKey = makeEncString("USER_KEY");
const encryptedUserKey = "USER_KEY";
const privateKey = "PRIVATE_KEY";
const kdf = 0;
const kdfIterations = 10000;
@@ -76,7 +76,7 @@ const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerRe
KdfType: kdf,
Iterations: kdfIterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
};
@@ -99,7 +99,7 @@ export function identityTokenResponseFactory(
ForcePasswordReset: false,
Kdf: kdf,
KdfIterations: kdfIterations,
Key: encryptedUserKey.encryptedString,
Key: encryptedUserKey,
PrivateKey: privateKey,
ResetMasterPassword: false,
access_token: accessToken,

View File

@@ -1,9 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { EmailTokenRequest } from "./email-token.request";
export class EmailRequest extends EmailTokenRequest {
newMasterPasswordHash: string;
token: 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,
): EmailRequest {
const request = new EmailRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
return request;
}
}

View File

@@ -1,9 +1,31 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { SecretVerificationRequest } from "./secret-verification.request";
export class PasswordRequest extends SecretVerificationRequest {
newMasterPasswordHash: string;
masterPasswordHint: string;
key: string;
authenticationData?: MasterPasswordAuthenticationData;
unlockData?: MasterPasswordUnlockData;
// This will eventually be changed to be an actual constructor, once all callers are updated.
// https://bitwarden.atlassian.net/browse/PM-23234
static newConstructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
): PasswordRequest {
const request = new PasswordRequest();
request.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
request.key = unlockData.masterKeyWrappedUserKey;
request.authenticationData = authenticationData;
request.unlockData = unlockData;
return request;
}
}

View File

@@ -1,7 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
import { MasterPasswordAuthenticationData } from "@bitwarden/common/key-management/master-password/types/master-password.types";
// @ts-strict-ignore
export class SecretVerificationRequest {
masterPasswordHash: string;
otp: string;
authRequestAccessCode: string;
/**
* Mutates this request to include the master password authentication data, to authenticate the request.
*/
authenticateWith(
masterPasswordAuthenticationData: MasterPasswordAuthenticationData,
): SecretVerificationRequest {
this.masterPasswordHash = masterPasswordAuthenticationData.masterPasswordAuthenticationHash;
return this;
}
}

View File

@@ -1,6 +1,11 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { KdfConfig, KdfType } from "@bitwarden/key-management";
import { KeysRequest } from "../../../models/request/keys.request";
@@ -21,19 +26,45 @@ export class SetPasswordRequest {
masterPasswordHint: string,
orgIdentifier: string,
keys: KeysRequest | null,
kdf: KdfType,
kdfIterations: number,
kdfMemory?: number,
kdfParallelism?: number,
kdf: KdfConfig,
) {
this.masterPasswordHash = masterPasswordHash;
this.key = key;
this.masterPasswordHint = masterPasswordHint;
this.kdf = kdf;
this.kdfIterations = kdfIterations;
this.kdfMemory = kdfMemory;
this.kdfParallelism = kdfParallelism;
this.orgIdentifier = orgIdentifier;
this.keys = keys;
if (kdf.kdfType === KdfType.PBKDF2_SHA256) {
this.kdf = KdfType.PBKDF2_SHA256;
this.kdfIterations = kdf.iterations;
} else if (kdf.kdfType === KdfType.Argon2id) {
this.kdf = KdfType.Argon2id;
this.kdfIterations = kdf.iterations;
this.kdfMemory = kdf.memory;
this.kdfParallelism = kdf.parallelism;
} else {
throw new Error(`Unsupported KDF type: ${kdf}`);
}
}
// 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,
masterPasswordHint: string,
orgIdentifier: string,
keys: KeysRequest | null,
): SetPasswordRequest {
const request = new SetPasswordRequest(
authenticationData.masterPasswordAuthenticationHash,
unlockData.masterKeyWrappedUserKey,
masterPasswordHint,
orgIdentifier,
keys,
unlockData.kdf,
);
return request;
}
}

View File

@@ -1,14 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { makeEncString } from "../../../../../spec";
import { UserDecryptionOptionsResponse } from "./user-decryption-options.response";
describe("UserDecryptionOptionsResponse", () => {
it("should create response when master password unlock is present", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const response = new UserDecryptionOptionsResponse({
HasMasterPassword: true,
@@ -18,7 +16,7 @@ describe("UserDecryptionOptionsResponse", () => {
KdfType: KdfType.PBKDF2_SHA256,
Iterations: 600_000,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
});

View File

@@ -4,7 +4,7 @@ import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { PasswordRequest } from "../../models/request/password.request";
import { SetPasswordRequest } from "../../models/request/set-password.request";
@@ -42,8 +42,7 @@ describe("MasterPasswordApiService", () => {
publicKey: "publicKey",
encryptedPrivateKey: "encryptedPrivateKey",
},
KdfType.PBKDF2_SHA256,
600_000,
new PBKDF2KdfConfig(600_000),
);
// Act

View File

@@ -0,0 +1,9 @@
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
export abstract class ChangeKdfApiService {
/**
* Sends a request to update the user's KDF parameters.
* @param request The KDF request containing authentication data, unlock data, and old authentication data
*/
abstract updateUserKdfParams(request: KdfRequest): Promise<void>;
}

View File

@@ -0,0 +1,15 @@
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { KdfRequest } from "@bitwarden/common/models/request/kdf.request";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
/**
* @internal
*/
export class DefaultChangeKdfApiService implements ChangeKdfApiService {
constructor(private apiService: ApiService) {}
async updateUserKdfParams(request: KdfRequest): Promise<void> {
return this.apiService.send("POST", "/accounts/kdf", request, true, false);
}
}

View File

@@ -0,0 +1,20 @@
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
export abstract class ChangeKdfService {
/**
* Updates the user's KDF parameters
* @param masterPassword The user's current master password
* @param kdf The new KDF configuration to apply
* @param userId The ID of the user whose KDF parameters are being updated
* @throws If any of the parameters is null
* @throws If the user is locked or logged out
* @throws If the kdf change request fails
*/
abstract updateUserKdfParams(
masterPassword: string,
kdf: KdfConfig,
userId: UserId,
): Promise<void>;
}

View File

@@ -0,0 +1,167 @@
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 { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationHash,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "./change-kdf-service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const keyService = mock<KeyService>();
const kdfConfigService = mock<KdfConfigService>();
let sut: DefaultChangeKdfService = mock<DefaultChangeKdfService>();
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockOldKdfConfig = new PBKDF2KdfConfig(100000);
const mockNewKdfConfig = new PBKDF2KdfConfig(200000);
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";
beforeEach(() => {
sut = new DefaultChangeKdfService(
masterPasswordService,
keyService,
kdfConfigService,
changeKdfApiService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("updateUserKdfParams", () => {
it("should throw an error if masterPassword is null", async () => {
await expect(
sut.updateUserKdfParams(null as unknown as string, mockNewKdfConfig, mockUserId),
).rejects.toThrow("masterPassword");
});
it("should throw an error if masterPassword is undefined", async () => {
await expect(
sut.updateUserKdfParams(undefined as unknown as string, mockNewKdfConfig, mockUserId),
).rejects.toThrow("masterPassword");
});
it("should throw an error if kdf is null", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", null as unknown as PBKDF2KdfConfig, mockUserId),
).rejects.toThrow("kdf");
});
it("should throw an error if kdf is undefined", async () => {
await expect(
sut.updateUserKdfParams(
"masterPassword",
undefined as unknown as PBKDF2KdfConfig,
mockUserId,
),
).rejects.toThrow("kdf");
});
it("should throw an error if userId is null", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, null as unknown as UserId),
).rejects.toThrow("userId");
});
it("should throw an error if userId is undefined", async () => {
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, undefined as unknown as UserId),
).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));
await expect(
sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId),
).rejects.toThrow();
});
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 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");
});
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,
),
);
await sut.updateUserKdfParams("masterPassword", mockNewKdfConfig, mockUserId);
const expected = new KdfRequest(
{
salt: mockSalt,
kdf: mockNewKdfConfig,
masterPasswordAuthenticationHash: mockNewHash,
},
new MasterPasswordUnlockData(
mockSalt,
mockNewKdfConfig,
mockWrappedUserKey as MasterKeyWrappedUserKey,
),
).authenticateWith({
salt: mockSalt,
kdf: mockOldKdfConfig,
masterPasswordAuthenticationHash: mockOldHash,
});
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expected);
});
});
});

View File

@@ -0,0 +1,59 @@
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 { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { firstValueFromOrThrow } from "../utils";
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,
) {}
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
assertNonNullish(masterPassword, "masterPassword");
assertNonNullish(kdf, "kdf");
assertNonNullish(userId, "userId");
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",
);
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 request = new KdfRequest(authenticationData, unlockData);
request.authenticateWith(oldAuthenticationData);
await this.changeKdfApiService.updateUserKdfParams(request);
}
}

View File

@@ -27,6 +27,11 @@ export abstract class MasterPasswordServiceAbstraction {
* @throws If the user ID is provided, but the user is not found.
*/
abstract saltForUser$: (userId: UserId) => Observable<MasterPasswordSalt>;
/**
* Converts an email to a master password salt. This is a canonical encoding of the
* email, no matter how the email is capitalized.
*/
abstract emailToSalt(email: string): MasterPasswordSalt;
/**
* An observable that emits the master key for the user.
* @deprecated Interacting with the master-key directly is deprecated. Please use {@link makeMasterPasswordUnlockData}, {@link makeMasterPasswordAuthenticationData} or {@link unwrapUserKeyFromMasterPasswordUnlockData} instead.

View File

@@ -1,13 +1,11 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { makeEncString } from "../../../../../spec";
import { MasterPasswordUnlockResponse } from "./master-password-unlock.response";
describe("MasterPasswordUnlockResponse", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const testKdfResponse = { KdfType: KdfType.PBKDF2_SHA256, Iterations: 600_000 };
it("should throw error when salt is not provided", () => {
@@ -15,7 +13,7 @@ describe("MasterPasswordUnlockResponse", () => {
new MasterPasswordUnlockResponse({
Salt: undefined,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
}).toThrow("MasterPasswordUnlockResponse does not contain a valid salt");
});
@@ -36,7 +34,7 @@ describe("MasterPasswordUnlockResponse", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
expect(response.salt).toBe(salt);
@@ -50,7 +48,7 @@ describe("MasterPasswordUnlockResponse", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
});
const unlockData = response.toMasterPasswordUnlockData();

View File

@@ -1,5 +1,4 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { EncString } from "../../../crypto/models/enc-string";
import { KdfConfigResponse } from "../../../models/response/kdf-config.response";
import {
MasterKeyWrappedUserKey,
@@ -29,9 +28,7 @@ export class MasterPasswordUnlockResponse extends BaseResponse {
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
);
}
this.masterKeyWrappedUserKey = new EncString(
masterKeyEncryptedUserKey,
) as MasterKeyWrappedUserKey;
this.masterKeyWrappedUserKey = masterKeyEncryptedUserKey as MasterKeyWrappedUserKey;
}
toMasterPasswordUnlockData() {

View File

@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
this.masterKeyHashSubject.next(initialMasterKeyHash);
}
emailToSalt(email: string): MasterPasswordSalt {
return this.mock.emailToSalt(email);
}
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
return this.mock.saltForUser$(userId);
}

View File

@@ -10,7 +10,6 @@ import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden
import {
FakeAccountService,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../../spec";
@@ -385,7 +384,7 @@ describe("MasterPasswordService", () => {
const kdfPBKDF2: KdfConfig = new PBKDF2KdfConfig(600_000);
const kdfArgon2: KdfConfig = new Argon2KdfConfig(4, 64, 3);
const salt = "test@bitwarden.com" as MasterPasswordSalt;
const encryptedUserKey = makeEncString("testUserKet") as MasterKeyWrappedUserKey;
const encryptedUserKey = "testUserKet" as MasterKeyWrappedUserKey;
it("returns null when value is null", () => {
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(
@@ -401,7 +400,7 @@ describe("MasterPasswordService", () => {
kdfType: KdfType.PBKDF2_SHA256,
iterations: kdfPBKDF2.iterations,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
masterKeyWrappedUserKey: encryptedUserKey as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);
@@ -419,7 +418,7 @@ describe("MasterPasswordService", () => {
memory: kdfArgon2.memory,
parallelism: kdfArgon2.parallelism,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
masterKeyWrappedUserKey: encryptedUserKey as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);

View File

@@ -132,7 +132,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
return EncString.fromJSON(key);
}
private emailToSalt(email: string): MasterPasswordSalt {
emailToSalt(email: string): MasterPasswordSalt {
return email.toLowerCase().trim() as MasterPasswordSalt;
}
@@ -256,6 +256,9 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
assertNonNullish(password, "password");
assertNonNullish(kdf, "kdf");
assertNonNullish(salt, "salt");
if (password === "") {
throw new Error("Master password cannot be empty.");
}
// We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly.
salt = salt.toLowerCase().trim() as MasterPasswordSalt;
@@ -294,18 +297,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
assertNonNullish(kdf, "kdf");
assertNonNullish(salt, "salt");
assertNonNullish(userKey, "userKey");
if (password === "") {
throw new Error("Master password cannot be empty.");
}
// We don't trust callers to use masterpasswordsalt correctly. They may type assert incorrectly.
salt = salt.toLowerCase().trim() as MasterPasswordSalt;
await SdkLoadService.Ready;
const masterKeyWrappedUserKey = new EncString(
PureCrypto.encrypt_user_key_with_master_password(
userKey.toEncoded(),
password,
salt,
kdf.toSdkConfig(),
),
const masterKeyWrappedUserKey = PureCrypto.encrypt_user_key_with_master_password(
userKey.toEncoded(),
password,
salt,
kdf.toSdkConfig(),
) as MasterKeyWrappedUserKey;
return new MasterPasswordUnlockData(salt, kdf, masterKeyWrappedUserKey);
}
@@ -320,7 +324,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
await SdkLoadService.Ready;
const userKey = new SymmetricCryptoKey(
PureCrypto.decrypt_user_key_with_master_password(
masterPasswordUnlockData.masterKeyWrappedUserKey.encryptedString,
masterPasswordUnlockData.masterKeyWrappedUserKey,
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf.toSdkConfig(),

View File

@@ -2,8 +2,7 @@ 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 "../../crypto/models/enc-string";
import { EncString } from "@bitwarden/sdk-internal";
/**
* The Base64-encoded master password authentication hash, that is sent to the server for authentication.
@@ -13,7 +12,7 @@ export type MasterPasswordAuthenticationHash = Opaque<string, "MasterPasswordAut
* You MUST obtain this through the emailToSalt function in MasterPasswordService
*/
export type MasterPasswordSalt = Opaque<string, "MasterPasswordSalt">;
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterPasswordSalt">;
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterKeyWrappedUserKey">;
/**
* The data required to unlock with the master password.
@@ -29,7 +28,7 @@ export class MasterPasswordUnlockData {
return {
salt: this.salt,
kdf: this.kdf,
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey.toJSON(),
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey,
};
}
@@ -43,7 +42,7 @@ export class MasterPasswordUnlockData {
obj.kdf.kdfType === KdfType.PBKDF2_SHA256
? PBKDF2KdfConfig.fromJSON(obj.kdf)
: Argon2KdfConfig.fromJSON(obj.kdf),
EncString.fromJSON(obj.masterKeyWrappedUserKey) as MasterKeyWrappedUserKey,
obj.masterKeyWrappedUserKey as MasterKeyWrappedUserKey,
);
}
}

View File

@@ -1,14 +1,12 @@
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import { makeEncString } from "../../../../spec";
import { UserDecryptionResponse } from "./user-decryption.response";
describe("UserDecryptionResponse", () => {
it("should create response when masterPasswordUnlock provided", () => {
const salt = "test@example.com";
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
const kdfIterations = 600_000;
const response = {
@@ -18,7 +16,7 @@ describe("UserDecryptionResponse", () => {
KdfType: KdfType.PBKDF2_SHA256 as number,
Iterations: kdfIterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
};

View File

@@ -0,0 +1,12 @@
import { firstValueFrom, Observable } from "rxjs";
export async function firstValueFromOrThrow<T>(
value: Observable<T | null>,
name: string,
): Promise<T> {
const result = await firstValueFrom(value);
if (result == null) {
throw new Error(`Failed to get ${name}`);
}
return result;
}

View File

@@ -1,14 +1,20 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { KdfType } from "@bitwarden/key-management";
import {
MasterPasswordAuthenticationData,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import { PasswordRequest } from "../../auth/models/request/password.request";
export class KdfRequest extends PasswordRequest {
kdf: KdfType;
kdfIterations: number;
kdfMemory?: number;
kdfParallelism?: number;
constructor(
authenticationData: MasterPasswordAuthenticationData,
unlockData: MasterPasswordUnlockData,
) {
super();
// Note, this init code should be in the super constructor, once PasswordRequest's constructor is updated.
this.newMasterPasswordHash = authenticationData.masterPasswordAuthenticationHash;
this.key = unlockData.masterKeyWrappedUserKey;
this.authenticationData = authenticationData;
this.unlockData = unlockData;
}
}

View File

@@ -15,7 +15,6 @@ import {
// eslint-disable-next-line no-restricted-imports
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { makeEncString } from "../../../spec";
import { Matrix } from "../../../spec/matrix";
import { ApiService } from "../../abstractions/api.service";
import { InternalOrganizationServiceAbstraction } from "../../admin-console/abstractions/organization/organization.service.abstraction";
@@ -247,7 +246,7 @@ describe("DefaultSyncService", () => {
describe("syncUserDecryption", () => {
const salt = "test@example.com";
const kdf = new PBKDF2KdfConfig(600_000);
const encryptedUserKey = makeEncString("testUserKey");
const encryptedUserKey = "testUserKey";
it("should set master password unlock when present in user decryption", async () => {
const syncResponse = new SyncResponse({
@@ -261,7 +260,7 @@ describe("DefaultSyncService", () => {
KdfType: kdf.kdfType,
Iterations: kdf.iterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
MasterKeyEncryptedUserKey: encryptedUserKey,
},
},
});