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

[PM-23243] In sync response and identity success response add MasterPasswordUnlockDataResponse in decryption options response model. (#15916)

* added master password unlock and decryption option fields into identity token connect response

* incorrect master password unlock response parsing

* use sdk

* use sdk

* better type checking on response parsing

* not using sdk

* revert of bad merge conflicts

* revert of bad merge conflicts

* master password unlock setter in state

* unit test coverage for responses processing

* master password unlock in identity user decryption options

* unit test coverage

* unit test coverage

* unit test coverage

* unit test coverage

* lint error

* set master password unlock data in state on identity response and sync response

* revert change in auth's user decryption options

* remove unnecessary cast

* better docs

* change to relative imports

* MasterPasswordUnlockData serialization issue

* explicit undefined type for `syncUserDecryption`

* incorrect identity token response tests
This commit is contained in:
Maciej Zieniuk
2025-09-05 16:13:56 +02:00
committed by GitHub
parent 6c5e15eb28
commit 203a24723b
24 changed files with 852 additions and 37 deletions

View File

@@ -20,6 +20,11 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { FakeMasterPasswordService } from "@bitwarden/common/key-management/master-password/services/fake-master-password.service";
import {
MasterKeyWrappedUserKey,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "@bitwarden/common/key-management/master-password/types/master-password.types";
import {
VaultTimeoutAction,
VaultTimeoutSettingsService,
@@ -33,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, mockAccountServiceWith } from "@bitwarden/common/spec";
import { FakeAccountService, makeEncString, mockAccountServiceWith } from "@bitwarden/common/spec";
import {
PasswordStrengthServiceAbstraction,
PasswordStrengthService,
@@ -41,7 +46,7 @@ import {
import { CsprngArray } from "@bitwarden/common/types/csprng";
import { UserId } from "@bitwarden/common/types/guid";
import { UserKey, MasterKey } from "@bitwarden/common/types/key";
import { KdfConfigService, KeyService } from "@bitwarden/key-management";
import { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LoginStrategyServiceAbstraction } from "../abstractions";
import { InternalUserDecryptionOptionsServiceAbstraction } from "../abstractions/user-decryption-options.service.abstraction";
@@ -56,7 +61,7 @@ const masterPassword = "password";
const deviceId = Utils.newGuid();
const accessToken = "ACCESS_TOKEN";
const refreshToken = "REFRESH_TOKEN";
const userKey = "USER_KEY";
const encryptedUserKey = makeEncString("USER_KEY");
const privateKey = "PRIVATE_KEY";
const kdf = 0;
const kdfIterations = 10000;
@@ -65,6 +70,14 @@ const masterPasswordHash = "MASTER_PASSWORD_HASH";
const name = "NAME";
const defaultUserDecryptionOptionsServerResponse: IUserDecryptionOptionsServerResponse = {
HasMasterPassword: true,
MasterPasswordUnlock: {
Salt: email,
Kdf: {
KdfType: kdf,
Iterations: kdfIterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
},
};
const decodedToken = {
@@ -86,7 +99,7 @@ export function identityTokenResponseFactory(
ForcePasswordReset: false,
Kdf: kdf,
KdfIterations: kdfIterations,
Key: userKey,
Key: encryptedUserKey.encryptedString,
PrivateKey: privateKey,
ResetMasterPassword: false,
access_token: accessToken,
@@ -247,6 +260,14 @@ describe("LoginStrategy", () => {
expect(userDecryptionOptionsService.setUserDecryptionOptions).toHaveBeenCalledWith(
UserDecryptionOptions.fromResponse(idTokenResponse),
);
expect(masterPasswordService.mock.setMasterPasswordUnlockData).toHaveBeenCalledWith(
new MasterPasswordUnlockData(
email as MasterPasswordSalt,
new PBKDF2KdfConfig(kdfIterations),
encryptedUserKey as MasterKeyWrappedUserKey,
),
userId,
);
expect(messagingService.send).toHaveBeenCalledWith("loggedIn");
});

View File

@@ -199,6 +199,15 @@ export abstract class LoginStrategy {
UserDecryptionOptions.fromResponse(tokenResponse),
);
if (tokenResponse.userDecryptionOptions?.masterPasswordUnlock != null) {
const masterPasswordUnlockData =
tokenResponse.userDecryptionOptions.masterPasswordUnlock.toMasterPasswordUnlockData();
await this.masterPasswordService.setMasterPasswordUnlockData(
masterPasswordUnlockData,
userId,
);
}
const vaultTimeoutAction = await firstValueFrom(
this.vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$(userId),
);

View File

@@ -2,11 +2,9 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { IdentityTokenResponse } from "@bitwarden/common/auth/models/response/identity-token.response";
import { KeyConnectorUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/key-connector-user-decryption-option.response";
import { TrustedDeviceUserDecryptionOptionResponse } from "@bitwarden/common/auth/models/response/user-decryption-options/trusted-device-user-decryption-option.response";
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { IdentityTokenResponse } from "@bitwarden/common/src/auth/models/response/identity-token.response";
/**
* Key Connector decryption options. Intended to be sent to the client for use after authentication.

View File

@@ -0,0 +1,119 @@
import { makeEncString } from "../../../../spec";
import { IdentityTokenResponse } from "./identity-token.response";
describe("IdentityTokenResponse", () => {
const accessToken = "testAccessToken";
const tokenType = "Bearer";
const expiresIn = 3600;
const refreshToken = "testRefreshToken";
const encryptedUserKey = makeEncString("testUserKey");
it("should throw an error when access token is missing", () => {
const response = {
access_token: undefined as unknown,
token_type: tokenType,
};
expect(() => new IdentityTokenResponse(response)).toThrow(
"Identity response does not contain a valid access token",
);
});
it("should throw an error when token type is missing", () => {
const response = {
access_token: accessToken,
token_type: undefined as unknown,
};
expect(() => new IdentityTokenResponse(response)).toThrow(
"Identity response does not contain a valid token type",
);
});
it("should create response without optional fields", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accessToken).toEqual(accessToken);
expect(identityTokenResponse.tokenType).toEqual(tokenType);
expect(identityTokenResponse.expiresIn).toBeUndefined();
expect(identityTokenResponse.refreshToken).toBeUndefined();
});
it("should create response with expires_in present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accessToken).toEqual(accessToken);
expect(identityTokenResponse.tokenType).toEqual(tokenType);
expect(identityTokenResponse.expiresIn).toEqual(expiresIn);
expect(identityTokenResponse.refreshToken).toBeUndefined();
});
it("should create response with refresh_token present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
expires_in: expiresIn,
refresh_token: refreshToken,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accessToken).toEqual(accessToken);
expect(identityTokenResponse.tokenType).toEqual(tokenType);
expect(identityTokenResponse.expiresIn).toEqual(expiresIn);
expect(identityTokenResponse.refreshToken).toEqual(refreshToken);
});
it("should create response with key is not present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
Key: undefined as unknown,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.key).toBeUndefined();
});
it("should create response with key present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
Key: encryptedUserKey.encryptedString,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.key).toEqual(encryptedUserKey);
});
it("should create response with user decryption options is not present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
UserDecryptionOptions: undefined as unknown,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.userDecryptionOptions).toBeUndefined();
});
it("should create response with user decryption options present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
UserDecryptionOptions: {},
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
});
});

View File

@@ -12,8 +12,8 @@ import { UserDecryptionOptionsResponse } from "./user-decryption-options/user-de
export class IdentityTokenResponse extends BaseResponse {
accessToken: string;
expiresIn: number;
refreshToken: string;
expiresIn?: number;
refreshToken?: string;
tokenType: string;
resetMasterPassword: boolean;
@@ -26,14 +26,30 @@ export class IdentityTokenResponse extends BaseResponse {
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions: UserDecryptionOptionsResponse;
userDecryptionOptions?: UserDecryptionOptionsResponse;
constructor(response: any) {
constructor(response: unknown) {
super(response);
this.accessToken = response.access_token;
this.expiresIn = response.expires_in;
this.refreshToken = response.refresh_token;
this.tokenType = response.token_type;
const accessToken = this.getResponseProperty("access_token");
if (accessToken == null || typeof accessToken !== "string") {
throw new Error("Identity response does not contain a valid access token");
}
const tokenType = this.getResponseProperty("token_type");
if (tokenType == null || typeof tokenType !== "string") {
throw new Error("Identity response does not contain a valid token type");
}
this.accessToken = accessToken;
this.tokenType = tokenType;
const expiresIn = this.getResponseProperty("expires_in");
if (expiresIn != null && typeof expiresIn === "number") {
this.expiresIn = expiresIn;
}
const refreshToken = this.getResponseProperty("refresh_token");
if (refreshToken != null && typeof refreshToken === "string") {
this.refreshToken = refreshToken;
}
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
this.privateKey = this.getResponseProperty("PrivateKey");
@@ -57,10 +73,9 @@ export class IdentityTokenResponse extends BaseResponse {
this.getResponseProperty("MasterPasswordPolicy"),
);
if (response.UserDecryptionOptions) {
this.userDecryptionOptions = new UserDecryptionOptionsResponse(
this.getResponseProperty("UserDecryptionOptions"),
);
const userDecryptionOptions = this.getResponseProperty("UserDecryptionOptions");
if (userDecryptionOptions != null && typeof userDecryptionOptions === "object") {
this.userDecryptionOptions = new UserDecryptionOptionsResponse(userDecryptionOptions);
}
}

View File

@@ -0,0 +1,47 @@
// 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 response = new UserDecryptionOptionsResponse({
HasMasterPassword: true,
MasterPasswordUnlock: {
Salt: salt,
Kdf: {
KdfType: KdfType.PBKDF2_SHA256,
Iterations: 600_000,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
},
});
expect(response.hasMasterPassword).toBe(true);
expect(response.masterPasswordUnlock).toBeDefined();
expect(response.masterPasswordUnlock!.salt).toEqual(salt);
expect(response.masterPasswordUnlock!.kdf.kdfType).toEqual(KdfType.PBKDF2_SHA256);
expect(response.masterPasswordUnlock!.kdf.iterations).toEqual(600_000);
expect(response.masterPasswordUnlock!.masterKeyWrappedUserKey).toEqual(encryptedUserKey);
expect(response.trustedDeviceOption).toBeUndefined();
expect(response.keyConnectorOption).toBeUndefined();
expect(response.webAuthnPrfOption).toBeUndefined();
});
it("should create response when master password unlock is not present", () => {
const response = new UserDecryptionOptionsResponse({
HasMasterPassword: false,
});
expect(response.hasMasterPassword).toBe(false);
expect(response.masterPasswordUnlock).toBeUndefined();
expect(response.trustedDeviceOption).toBeUndefined();
expect(response.keyConnectorOption).toBeUndefined();
expect(response.webAuthnPrfOption).toBeUndefined();
});
});

View File

@@ -1,3 +1,4 @@
import { MasterPasswordUnlockResponse } from "../../../../key-management/master-password/models/response/master-password-unlock.response";
import { BaseResponse } from "../../../../models/response/base.response";
import {
@@ -15,6 +16,7 @@ import {
export interface IUserDecryptionOptionsServerResponse {
HasMasterPassword: boolean;
MasterPasswordUnlock?: unknown;
TrustedDeviceOption?: ITrustedDeviceUserDecryptionOptionServerResponse;
KeyConnectorOption?: IKeyConnectorUserDecryptionOptionServerResponse;
WebAuthnPrfOption?: IWebAuthnPrfDecryptionOptionServerResponse;
@@ -22,6 +24,7 @@ export interface IUserDecryptionOptionsServerResponse {
export class UserDecryptionOptionsResponse extends BaseResponse {
hasMasterPassword: boolean;
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
@@ -31,6 +34,11 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
this.hasMasterPassword = this.getResponseProperty("HasMasterPassword");
const masterPasswordUnlock = this.getResponseProperty("MasterPasswordUnlock");
if (masterPasswordUnlock != null && typeof masterPasswordUnlock === "object") {
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
}
if (response.TrustedDeviceOption) {
this.trustedDeviceOption = new TrustedDeviceUserDecryptionOptionResponse(
this.getResponseProperty("TrustedDeviceOption"),

View File

@@ -154,4 +154,16 @@ export abstract class InternalMasterPasswordServiceAbstraction extends MasterPas
reason: ForceSetPasswordReason,
userId: UserId,
) => Promise<void>;
/**
* Sets the master password unlock data for the user.
* This data is used to unlock the user key with the master password.
* @param masterPasswordUnlockData The master password unlock data containing the KDF settings, salt, and encrypted user key.
* @param userId The user ID.
* @throws Error If the user ID or master password unlock data is missing.
*/
abstract setMasterPasswordUnlockData(
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void>;
}

View File

@@ -0,0 +1,63 @@
// 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 testKdfResponse = { KdfType: KdfType.PBKDF2_SHA256, Iterations: 600_000 };
it("should throw error when salt is not provided", () => {
expect(() => {
new MasterPasswordUnlockResponse({
Salt: undefined,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
});
}).toThrow("MasterPasswordUnlockResponse does not contain a valid salt");
});
it("should throw error when master key encrypted user key is not provided", () => {
expect(() => {
new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: undefined,
});
}).toThrow(
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
);
});
it("should create response", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
});
expect(response.salt).toBe(salt);
expect(response.kdf).toBeDefined();
expect(response.kdf.toKdfConfig()).toEqual(new PBKDF2KdfConfig(600_000));
expect(response.masterKeyWrappedUserKey).toEqual(encryptedUserKey);
});
describe("toMasterPasswordUnlockData", () => {
it("should return MasterPasswordUnlockData", () => {
const response = new MasterPasswordUnlockResponse({
Salt: salt,
Kdf: testKdfResponse,
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
});
const unlockData = response.toMasterPasswordUnlockData();
expect(unlockData).toBeDefined();
expect(unlockData.salt).toBe(salt);
expect(unlockData.kdf).toEqual(new PBKDF2KdfConfig(600_000));
expect(unlockData.masterKeyWrappedUserKey).toEqual(encryptedUserKey);
});
});
});

View File

@@ -0,0 +1,44 @@
import { BaseResponse } from "../../../../models/response/base.response";
import { EncString } from "../../../crypto/models/enc-string";
import { KdfConfigResponse } from "../../../models/response/kdf-config.response";
import {
MasterKeyWrappedUserKey,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../../types/master-password.types";
export class MasterPasswordUnlockResponse extends BaseResponse {
salt: MasterPasswordSalt;
kdf: KdfConfigResponse;
masterKeyWrappedUserKey: MasterKeyWrappedUserKey;
constructor(response: unknown) {
super(response);
const salt = this.getResponseProperty("Salt");
if (salt == null || typeof salt !== "string") {
throw new Error("MasterPasswordUnlockResponse does not contain a valid salt");
}
this.salt = salt as MasterPasswordSalt;
this.kdf = new KdfConfigResponse(this.getResponseProperty("Kdf"));
const masterKeyEncryptedUserKey = this.getResponseProperty("MasterKeyEncryptedUserKey");
if (masterKeyEncryptedUserKey == null || typeof masterKeyEncryptedUserKey !== "string") {
throw new Error(
"MasterPasswordUnlockResponse does not contain a valid master key encrypted user key",
);
}
this.masterKeyWrappedUserKey = new EncString(
masterKeyEncryptedUserKey,
) as MasterKeyWrappedUserKey;
}
toMasterPasswordUnlockData() {
return new MasterPasswordUnlockData(
this.salt,
this.kdf.toKdfConfig(),
this.masterKeyWrappedUserKey,
);
}
}

View File

@@ -108,4 +108,11 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
): Promise<UserKey> {
return this.mock.unwrapUserKeyFromMasterPasswordUnlockData(password, masterPasswordUnlockData);
}
setMasterPasswordUnlockData(
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
return this.mock.setMasterPasswordUnlockData(masterPasswordUnlockData, userId);
}
}

View File

@@ -1,14 +1,16 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import * as rxjs from "rxjs";
import { firstValueFrom, of } from "rxjs";
import { Jsonify } from "type-fest";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
FakeAccountService,
makeEncString,
makeSymmetricCryptoKey,
mockAccountServiceWith,
} from "../../../../spec";
@@ -23,9 +25,13 @@ import { KeyGenerationService } from "../../crypto";
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import { EncString } from "../../crypto/models/enc-string";
import { MasterPasswordSalt } from "../types/master-password.types";
import {
MasterKeyWrappedUserKey,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../types/master-password.types";
import { MasterPasswordService } from "./master-password.service";
import { MASTER_PASSWORD_UNLOCK_KEY, MasterPasswordService } from "./master-password.service";
describe("MasterPasswordService", () => {
let sut: MasterPasswordService;
@@ -182,7 +188,7 @@ describe("MasterPasswordService", () => {
expect(keyGenerationService.stretchKey).toHaveBeenCalledWith(testMasterKey);
});
it("returns null if failed to decrypt", async () => {
encryptService.unwrapSymmetricKey.mockResolvedValue(null);
encryptService.unwrapSymmetricKey.mockRejectedValue(new Error("Decryption failed"));
const result = await sut.decryptUserKeyWithMasterKey(
testMasterKey,
userId,
@@ -321,4 +327,106 @@ describe("MasterPasswordService", () => {
).rejects.toThrow();
});
});
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) => {
const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData(
"test-password",
kdfConfig,
salt,
userKey,
);
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());
},
);
it("throws if masterPasswordUnlockData is null", async () => {
await expect(
sut.setMasterPasswordUnlockData(null as unknown as MasterPasswordUnlockData, userId),
).rejects.toThrow("masterPasswordUnlockData is null or undefined.");
});
it("throws if userId is null", async () => {
const masterPasswordUnlockData = await sut.makeMasterPasswordUnlockData(
"test-password",
kdfPBKDF2,
salt,
userKey,
);
await expect(
sut.setMasterPasswordUnlockData(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();
expect(MASTER_PASSWORD_UNLOCK_KEY.key).toBe("masterPasswordUnlockKey");
expect(MASTER_PASSWORD_UNLOCK_KEY.clearOn).toEqual(["logout"]);
});
describe("deserializer", () => {
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;
it("returns null when value is null", () => {
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(
null as unknown as Jsonify<MasterPasswordUnlockData>,
);
expect(deserialized).toBeNull();
});
it("returns master password unlock data when value is present and kdf type is pbkdf2", () => {
const data: Jsonify<MasterPasswordUnlockData> = {
salt: salt,
kdf: {
kdfType: KdfType.PBKDF2_SHA256,
iterations: kdfPBKDF2.iterations,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);
expect(deserialized).toEqual(
new MasterPasswordUnlockData(salt, kdfPBKDF2, encryptedUserKey),
);
});
it("returns master password unlock data when value is present and kdf type is argon2", () => {
const data: Jsonify<MasterPasswordUnlockData> = {
salt: salt,
kdf: {
kdfType: KdfType.Argon2id,
iterations: kdfArgon2.iterations,
memory: kdfArgon2.memory,
parallelism: kdfArgon2.parallelism,
},
masterKeyWrappedUserKey: encryptedUserKey.encryptedString as string,
};
const deserialized = MASTER_PASSWORD_UNLOCK_KEY.deserializer(data);
expect(deserialized).toEqual(
new MasterPasswordUnlockData(salt, kdfArgon2, encryptedUserKey),
);
});
});
});
});

View File

@@ -18,6 +18,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
import {
MASTER_PASSWORD_DISK,
MASTER_PASSWORD_MEMORY,
MASTER_PASSWORD_UNLOCK_DISK,
StateProvider,
UserKeyDefinition,
} from "../../../platform/state";
@@ -68,6 +69,16 @@ const FORCE_SET_PASSWORD_REASON = new UserKeyDefinition<ForceSetPasswordReason>(
},
);
/** Disk to persist through lock */
export const MASTER_PASSWORD_UNLOCK_KEY = new UserKeyDefinition<MasterPasswordUnlockData>(
MASTER_PASSWORD_UNLOCK_DISK,
"masterPasswordUnlockKey",
{
deserializer: (obj) => MasterPasswordUnlockData.fromJSON(obj),
clearOn: ["logout"],
},
);
export class MasterPasswordService implements InternalMasterPasswordServiceAbstraction {
constructor(
private stateProvider: StateProvider,
@@ -296,11 +307,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
kdf.toSdkConfig(),
),
) as MasterKeyWrappedUserKey;
return {
salt,
kdf,
masterKeyWrappedUserKey,
};
return new MasterPasswordUnlockData(salt, kdf, masterKeyWrappedUserKey);
}
async unwrapUserKeyFromMasterPasswordUnlockData(
@@ -321,4 +328,16 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
);
return userKey as UserKey;
}
async setMasterPasswordUnlockData(
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
assertNonNullish(userId, "userId");
await this.stateProvider
.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY)
.update(() => masterPasswordUnlockData.toJSON());
}
}

View File

@@ -1,7 +1,7 @@
import { Opaque } from "type-fest";
import { Jsonify, Opaque } from "type-fest";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "../../crypto/models/enc-string";
@@ -18,11 +18,35 @@ export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterPasswordSalt">;
/**
* The data required to unlock with the master password.
*/
export type MasterPasswordUnlockData = {
salt: MasterPasswordSalt;
kdf: KdfConfig;
masterKeyWrappedUserKey: MasterKeyWrappedUserKey;
};
export class MasterPasswordUnlockData {
constructor(
readonly salt: MasterPasswordSalt,
readonly kdf: KdfConfig,
readonly masterKeyWrappedUserKey: MasterKeyWrappedUserKey,
) {}
toJSON(): any {
return {
salt: this.salt,
kdf: this.kdf,
masterKeyWrappedUserKey: this.masterKeyWrappedUserKey.toJSON(),
};
}
static fromJSON(obj: Jsonify<MasterPasswordUnlockData>): MasterPasswordUnlockData | null {
if (obj == null) {
return null;
}
return new MasterPasswordUnlockData(
obj.salt,
obj.kdf.kdfType === KdfType.PBKDF2_SHA256
? PBKDF2KdfConfig.fromJSON(obj.kdf)
: Argon2KdfConfig.fromJSON(obj.kdf),
EncString.fromJSON(obj.masterKeyWrappedUserKey) as MasterKeyWrappedUserKey,
);
}
}
/**
* The data required to authenticate with the master password.

View File

@@ -0,0 +1,111 @@
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KdfConfigResponse } from "./kdf-config.response";
describe("KdfConfigResponse", () => {
it("should throw error when kdf type not provided", () => {
expect(() => {
new KdfConfigResponse({
KdfType: undefined,
Iterations: 1,
});
}).toThrow("KDF config response does not contain a valid KDF type");
});
it("should throw error when kdf type is PBKDF2 and iterations not provided", () => {
expect(() => {
new KdfConfigResponse({
KdfType: KdfType.PBKDF2_SHA256,
Iterations: undefined,
});
}).toThrow("KDF config response does not contain a valid number of iterations");
});
it("should throw error when kdf type is Argon2Id and iterations not provided", () => {
expect(() => {
new KdfConfigResponse({
KdfType: KdfType.Argon2id,
Iterations: undefined,
});
}).toThrow("KDF config response does not contain a valid number of iterations");
});
it("should throw error when kdf type is Argon2Id and memory not provided", () => {
expect(() => {
new KdfConfigResponse({
KdfType: KdfType.Argon2id,
Iterations: 3,
Memory: undefined,
Parallelism: 4,
});
}).toThrow("KDF config response does not contain a valid memory size for Argon2id");
});
it("should throw error when kdf type is Argon2Id and parallelism not provided", () => {
expect(() => {
new KdfConfigResponse({
KdfType: KdfType.Argon2id,
Iterations: 3,
Memory: 64,
Parallelism: undefined,
});
}).toThrow("KDF config response does not contain a valid parallelism for Argon2id");
});
it("should create response when kdf type is PBKDF2", () => {
const response = new KdfConfigResponse({
KdfType: KdfType.PBKDF2_SHA256,
Iterations: 600_000,
});
expect(response.kdfType).toBe(KdfType.PBKDF2_SHA256);
expect(response.iterations).toBe(600_000);
expect(response.memory).toBeUndefined();
expect(response.parallelism).toBeUndefined();
});
it("should create response when kdf type is Argon2Id", () => {
const response = new KdfConfigResponse({
KdfType: KdfType.Argon2id,
Iterations: 3,
Memory: 64,
Parallelism: 4,
});
expect(response.kdfType).toBe(KdfType.Argon2id);
expect(response.iterations).toBe(3);
expect(response.memory).toBe(64);
expect(response.parallelism).toBe(4);
});
describe("toKdfConfig", () => {
it("should convert to PBKDF2KdfConfig", () => {
const response = new KdfConfigResponse({
KdfType: KdfType.PBKDF2_SHA256,
Iterations: 600_000,
});
const kdfConfig = response.toKdfConfig();
expect(kdfConfig).toBeInstanceOf(PBKDF2KdfConfig);
const pbkdf2Config = kdfConfig as PBKDF2KdfConfig;
expect(pbkdf2Config.iterations).toBe(600_000);
});
it("should convert to Argon2KdfConfig", () => {
const response = new KdfConfigResponse({
KdfType: KdfType.Argon2id,
Iterations: 3,
Memory: 64,
Parallelism: 4,
});
const kdfConfig = response.toKdfConfig();
expect(kdfConfig).toBeInstanceOf(Argon2KdfConfig);
const argon2Config = kdfConfig as Argon2KdfConfig;
expect(argon2Config.iterations).toBe(3);
expect(argon2Config.memory).toBe(64);
expect(argon2Config.parallelism).toBe(4);
});
});
});

View File

@@ -0,0 +1,49 @@
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BaseResponse } from "../../../models/response/base.response";
export class KdfConfigResponse extends BaseResponse {
kdfType: KdfType;
iterations: number;
memory?: number;
parallelism?: number;
constructor(response: unknown) {
super(response);
const kdfType = this.getResponseProperty("KdfType");
if (kdfType == null || typeof kdfType !== "number") {
throw new Error("KDF config response does not contain a valid KDF type");
}
this.kdfType = kdfType as KdfType;
const iterations = this.getResponseProperty("Iterations");
if (iterations == null || typeof iterations !== "number") {
throw new Error("KDF config response does not contain a valid number of iterations");
}
this.iterations = iterations;
if (this.kdfType === KdfType.Argon2id) {
const memory = this.getResponseProperty("Memory");
if (memory == null || typeof memory !== "number") {
throw new Error("KDF config response does not contain a valid memory size for Argon2id");
}
const parallelism = this.getResponseProperty("Parallelism");
if (parallelism == null || typeof parallelism !== "number") {
throw new Error("KDF config response does not contain a valid parallelism for Argon2id");
}
this.memory = memory;
this.parallelism = parallelism;
}
}
toKdfConfig(): KdfConfig {
switch (this.kdfType) {
case KdfType.Argon2id:
return new Argon2KdfConfig(this.iterations, this.memory!, this.parallelism!);
case KdfType.PBKDF2_SHA256:
return new PBKDF2KdfConfig(this.iterations);
}
}
}

View File

@@ -0,0 +1,43 @@
// 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 kdfIterations = 600_000;
const response = {
MasterPasswordUnlock: {
Salt: salt,
Kdf: {
KdfType: KdfType.PBKDF2_SHA256 as number,
Iterations: kdfIterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
},
};
const userDecryptionResponse = new UserDecryptionResponse(response);
expect(userDecryptionResponse.masterPasswordUnlock).toBeDefined();
expect(userDecryptionResponse.masterPasswordUnlock!.salt).toEqual(salt);
expect(userDecryptionResponse.masterPasswordUnlock!.kdf).toBeDefined();
expect(userDecryptionResponse.masterPasswordUnlock!.kdf!.kdfType).toEqual(
KdfType.PBKDF2_SHA256,
);
expect(userDecryptionResponse.masterPasswordUnlock!.kdf!.iterations).toEqual(kdfIterations);
expect(userDecryptionResponse.masterPasswordUnlock!.masterKeyWrappedUserKey).toEqual(
encryptedUserKey,
);
});
it("should create response when masterPasswordUnlock is not provided", () => {
const userDecryptionResponse = new UserDecryptionResponse({});
expect(userDecryptionResponse.masterPasswordUnlock).toBeUndefined();
});
});

View File

@@ -0,0 +1,15 @@
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordUnlockResponse } from "../../master-password/models/response/master-password-unlock.response";
export class UserDecryptionResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
constructor(response: unknown) {
super(response);
const masterPasswordUnlock = this.getResponseProperty("MasterPasswordUnlock");
if (masterPasswordUnlock != null || typeof masterPasswordUnlock === "object") {
this.masterPasswordUnlock = new MasterPasswordUnlockResponse(masterPasswordUnlock);
}
}
}

View File

@@ -13,8 +13,9 @@ import {
} from "@bitwarden/auth/common";
// 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 { KeyService } from "@bitwarden/key-management";
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";
@@ -29,6 +30,11 @@ import { DomainSettingsService } from "../../autofill/services/domain-settings.s
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordSalt,
MasterPasswordUnlockData,
} from "../../key-management/master-password/types/master-password.types";
import { SendApiService } from "../../tools/send/services/send-api.service.abstraction";
import { InternalSendService } from "../../tools/send/services/send.service.abstraction";
import { UserId } from "../../types/guid";
@@ -84,6 +90,7 @@ describe("DefaultSyncService", () => {
sendService = mock();
logService = mock();
keyConnectorService = mock();
keyConnectorService.convertAccountRequired$ = of(false);
providerService = mock();
folderApiService = mock();
organizationService = mock();
@@ -236,5 +243,55 @@ describe("DefaultSyncService", () => {
expect(sut["inFlightApiCalls"].sync).toBeNull();
});
});
describe("syncUserDecryption", () => {
const salt = "test@example.com";
const kdf = new PBKDF2KdfConfig(600_000);
const encryptedUserKey = makeEncString("testUserKey");
it("should set master password unlock when present in user decryption", async () => {
const syncResponse = new SyncResponse({
Profile: {
Id: user1,
},
UserDecryption: {
MasterPasswordUnlock: {
Salt: salt,
Kdf: {
KdfType: kdf.kdfType,
Iterations: kdf.iterations,
},
MasterKeyEncryptedUserKey: encryptedUserKey.encryptedString,
},
},
});
apiService.getSync.mockResolvedValue(syncResponse);
await sut.fullSync(true, true);
expect(masterPasswordAbstraction.setMasterPasswordUnlockData).toHaveBeenCalledWith(
new MasterPasswordUnlockData(
salt as MasterPasswordSalt,
kdf,
encryptedUserKey as MasterKeyWrappedUserKey,
),
user1,
);
});
it("should not set master password unlock when not present in user decryption", async () => {
const syncResponse = new SyncResponse({
Profile: {
Id: user1,
},
UserDecryption: {},
});
apiService.getSync.mockResolvedValue(syncResponse);
await sut.fullSync(true, true);
expect(masterPasswordAbstraction.setMasterPasswordUnlockData).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -38,6 +38,7 @@ import { DomainSettingsService } from "../../autofill/services/domain-settings.s
import { BillingAccountProfileStateService } from "../../billing/abstractions";
import { KeyConnectorService } from "../../key-management/key-connector/abstractions/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../../key-management/master-password/abstractions/master-password.service.abstraction";
import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response";
import { DomainsResponse } from "../../models/response/domains.response";
import { ProfileResponse } from "../../models/response/profile.response";
import { SendData } from "../../tools/send/models/data/send.data";
@@ -168,6 +169,7 @@ export class DefaultSyncService extends CoreSyncService {
const response = await this.inFlightApiCalls.sync;
await this.syncUserDecryption(response.profile.id, response.userDecryption);
await this.syncProfile(response.profile);
await this.syncFolders(response.folders, response.profile.id);
await this.syncCollections(response.collections, response.profile.id);
@@ -390,4 +392,21 @@ export class DefaultSyncService extends CoreSyncService {
}
return await this.policyService.replace(policies, userId);
}
private async syncUserDecryption(
userId: UserId,
userDecryption: UserDecryptionResponse | undefined,
) {
if (userDecryption == null) {
return;
}
if (userDecryption.masterPasswordUnlock != null) {
const masterPasswordUnlockData =
userDecryption.masterPasswordUnlock.toMasterPasswordUnlockData();
await this.masterPasswordService.setMasterPasswordUnlockData(
masterPasswordUnlockData,
userId,
);
}
}
}

View File

@@ -0,0 +1,18 @@
import { SyncResponse } from "./sync.response";
describe("SyncResponse", () => {
it("should create response when user decryption is not provided", () => {
const response = new SyncResponse({
UserDecryption: undefined,
});
expect(response.userDecryption).toBeUndefined();
});
it("should create response when user decryption is provided", () => {
const response = new SyncResponse({
UserDecryption: {},
});
expect(response.userDecryption).toBeDefined();
expect(response.userDecryption!.masterPasswordUnlock).toBeUndefined();
});
});

View File

@@ -3,6 +3,7 @@
import { CollectionDetailsResponse } from "@bitwarden/admin-console/common";
import { PolicyResponse } from "../../admin-console/models/response/policy.response";
import { UserDecryptionResponse } from "../../key-management/models/response/user-decryption.response";
import { BaseResponse } from "../../models/response/base.response";
import { DomainsResponse } from "../../models/response/domains.response";
import { ProfileResponse } from "../../models/response/profile.response";
@@ -18,6 +19,7 @@ export class SyncResponse extends BaseResponse {
domains?: DomainsResponse;
policies?: PolicyResponse[] = [];
sends: SendResponse[] = [];
userDecryption?: UserDecryptionResponse;
constructor(response: any) {
super(response);
@@ -56,5 +58,10 @@ export class SyncResponse extends BaseResponse {
if (sends != null) {
this.sends = sends.map((s: any) => new SendResponse(s));
}
const userDecryption = this.getResponseProperty("UserDecryption");
if (userDecryption != null && typeof userDecryption === "object") {
this.userDecryption = new UserDecryptionResponse(userDecryption);
}
}
}

View File

@@ -295,6 +295,7 @@ describe("ApiService", () => {
json: () =>
Promise.resolve({
access_token: `${expectedEffectiveUser}_new_access_token`,
token_type: "Bearer",
refresh_token: `${expectedEffectiveUser}_new_refresh_token`,
}),
} satisfies Partial<Response> as Response);

View File

@@ -61,6 +61,7 @@ export const LOGIN_EMAIL_MEMORY = new StateDefinition("loginEmail", "memory");
export const LOGIN_STRATEGY_MEMORY = new StateDefinition("loginStrategy", "memory");
export const MASTER_PASSWORD_DISK = new StateDefinition("masterPassword", "disk");
export const MASTER_PASSWORD_MEMORY = new StateDefinition("masterPassword", "memory");
export const MASTER_PASSWORD_UNLOCK_DISK = new StateDefinition("masterPasswordUnlock", "disk");
export const PIN_DISK = new StateDefinition("pinUnlock", "disk");
export const PIN_MEMORY = new StateDefinition("pinUnlock", "memory");
export const ROUTER_DISK = new StateDefinition("router", "disk");