1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-20 10:13:31 +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

@@ -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);
}
}
}