mirror of
https://github.com/bitwarden/clients.git
synced 2025-12-10 10:27:10 -06:00
* feat(user-decryption-options) [PM-26413]: Update UserDecryptionOptionsService and tests to use UserId-only APIs. * feat(user-decryption-options) [PM-26413]: Update InternalUserDecryptionOptionsService call sites to use UserId-only API. * feat(user-decryption-options) [PM-26413] Update userDecryptionOptions$ call sites to use the UserId-only API. * feat(user-decryption-options) [PM-26413]: Update additional call sites. * feat(user-decryption-options) [PM-26413]: Update dependencies and an additional call site. * feat(user-verification-service) [PM-26413]: Replace where allowed by unrestricted imports invocation of UserVerificationService.hasMasterPassword (deprecated) with UserDecryptionOptions.hasMasterPasswordById$. Additional work to complete as tech debt tracked in PM-27009. * feat(user-decryption-options) [PM-26413]: Update for non-null strict adherence. * feat(user-decryption-options) [PM-26413]: Update type safety and defensive returns. * chore(user-decryption-options) [PM-26413]: Comment cleanup. * feat(user-decryption-options) [PM-26413]: Update tests. * feat(user-decryption-options) [PM-26413]: Standardize null-checking on active account id for new API consumption. * feat(vault-timeout-settings-service) [PM-26413]: Add test cases to illustrate null active account from AccountService. * fix(fido2-user-verification-service-spec) [PM-26413]: Update test harness to use FakeAccountService. * fix(downstream-components) [PM-26413]: Prefer use of the getUserId operator in all authenticated contexts for user id provided to UserDecryptionOptionsService. --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
538 lines
18 KiB
TypeScript
538 lines
18 KiB
TypeScript
import { mock } from "jest-mock-extended";
|
|
import { of } from "rxjs";
|
|
|
|
// 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 { UserDecryptionOptionsServiceAbstraction } 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 {
|
|
BiometricsService,
|
|
BiometricsStatus,
|
|
KdfConfig,
|
|
KeyService,
|
|
KdfConfigService,
|
|
} from "@bitwarden/key-management";
|
|
|
|
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
|
|
import { InternalMasterPasswordServiceAbstraction } from "../../../key-management/master-password/abstractions/master-password.service.abstraction";
|
|
import { PinLockType } from "../../../key-management/pin/pin-lock-type";
|
|
import { PinServiceAbstraction } from "../../../key-management/pin/pin.service.abstraction";
|
|
import { VaultTimeoutSettingsService } from "../../../key-management/vault-timeout";
|
|
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
|
import { HashPurpose } from "../../../platform/enums";
|
|
import { Utils } from "../../../platform/misc/utils";
|
|
import { UserId } from "../../../types/guid";
|
|
import { MasterKey } from "../../../types/key";
|
|
import { UserVerificationApiServiceAbstraction } from "../../abstractions/user-verification/user-verification-api.service.abstraction";
|
|
import { VerificationType } from "../../enums/verification-type";
|
|
import { MasterPasswordPolicyResponse } from "../../models/response/master-password-policy.response";
|
|
import { MasterPasswordVerification } from "../../types/verification";
|
|
|
|
import { UserVerificationService } from "./user-verification.service";
|
|
|
|
describe("UserVerificationService", () => {
|
|
let sut: UserVerificationService;
|
|
|
|
const keyService = mock<KeyService>();
|
|
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
|
|
const i18nService = mock<I18nService>();
|
|
const userVerificationApiService = mock<UserVerificationApiServiceAbstraction>();
|
|
const userDecryptionOptionsService = mock<UserDecryptionOptionsServiceAbstraction>();
|
|
const pinService = mock<PinServiceAbstraction>();
|
|
const vaultTimeoutSettingsService = mock<VaultTimeoutSettingsService>();
|
|
const kdfConfigService = mock<KdfConfigService>();
|
|
const biometricsService = mock<BiometricsService>();
|
|
|
|
const mockUserId = Utils.newGuid() as UserId;
|
|
let accountService: FakeAccountService;
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
accountService = mockAccountServiceWith(mockUserId);
|
|
|
|
sut = new UserVerificationService(
|
|
keyService,
|
|
accountService,
|
|
masterPasswordService,
|
|
i18nService,
|
|
userVerificationApiService,
|
|
userDecryptionOptionsService,
|
|
pinService,
|
|
kdfConfigService,
|
|
biometricsService,
|
|
);
|
|
});
|
|
|
|
describe("getAvailableVerificationOptions", () => {
|
|
describe("client verification type", () => {
|
|
it("correctly returns master password availability", async () => {
|
|
setMasterPasswordAvailability(true);
|
|
setPinAvailability("DISABLED");
|
|
disableBiometricsAvailability();
|
|
|
|
const result = await sut.getAvailableVerificationOptions("client");
|
|
|
|
expect(result).toEqual({
|
|
client: {
|
|
masterPassword: true,
|
|
pin: false,
|
|
biometrics: false,
|
|
},
|
|
server: {
|
|
masterPassword: false,
|
|
otp: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
[true, "PERSISTENT"],
|
|
[true, "EPHEMERAL"],
|
|
[false, "DISABLED"],
|
|
])(
|
|
"returns %s for PIN availability when pin lock type is %s",
|
|
async (expectedPin: boolean, pinLockType: PinLockType) => {
|
|
setMasterPasswordAvailability(false);
|
|
setPinAvailability(pinLockType);
|
|
disableBiometricsAvailability();
|
|
|
|
const result = await sut.getAvailableVerificationOptions("client");
|
|
|
|
expect(result).toEqual({
|
|
client: {
|
|
masterPassword: false,
|
|
pin: expectedPin,
|
|
biometrics: false,
|
|
},
|
|
server: {
|
|
masterPassword: false,
|
|
otp: false,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
|
|
test.each([
|
|
[true, BiometricsStatus.Available],
|
|
[false, BiometricsStatus.DesktopDisconnected],
|
|
[false, BiometricsStatus.HardwareUnavailable],
|
|
])(
|
|
"returns %s for biometrics availability when isBiometricLockSet is %s, hasUserKeyStored is %s, and supportsSecureStorage is %s",
|
|
async (expectedReturn: boolean, biometricsStatus: BiometricsStatus) => {
|
|
setMasterPasswordAvailability(false);
|
|
setPinAvailability("DISABLED");
|
|
biometricsService.getBiometricsStatus.mockResolvedValue(biometricsStatus);
|
|
|
|
const result = await sut.getAvailableVerificationOptions("client");
|
|
|
|
expect(result).toEqual({
|
|
client: {
|
|
masterPassword: false,
|
|
pin: false,
|
|
biometrics: expectedReturn,
|
|
},
|
|
server: {
|
|
masterPassword: false,
|
|
otp: false,
|
|
},
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("server verification type", () => {
|
|
it("correctly returns master password availability", async () => {
|
|
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
|
|
|
|
const result = await sut.getAvailableVerificationOptions("server");
|
|
|
|
expect(result).toEqual({
|
|
client: {
|
|
masterPassword: false,
|
|
pin: false,
|
|
biometrics: false,
|
|
},
|
|
server: {
|
|
masterPassword: true,
|
|
otp: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("correctly returns OTP availability", async () => {
|
|
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
|
|
|
|
const result = await sut.getAvailableVerificationOptions("server");
|
|
|
|
expect(result).toEqual({
|
|
client: {
|
|
masterPassword: false,
|
|
pin: false,
|
|
biometrics: false,
|
|
},
|
|
server: {
|
|
masterPassword: false,
|
|
otp: true,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("buildRequest", () => {
|
|
beforeEach(() => {
|
|
accountService = mockAccountServiceWith(mockUserId);
|
|
i18nService.t
|
|
.calledWith("verificationCodeRequired")
|
|
.mockReturnValue("Verification code is required");
|
|
i18nService.t
|
|
.calledWith("masterPasswordRequired")
|
|
.mockReturnValue("Master Password is required");
|
|
});
|
|
|
|
describe("OTP verification", () => {
|
|
it("should build request with OTP secret", async () => {
|
|
const verification = {
|
|
type: VerificationType.OTP,
|
|
secret: "123456",
|
|
} as any;
|
|
|
|
const result = await sut.buildRequest(verification);
|
|
|
|
expect(result.otp).toBe("123456");
|
|
});
|
|
|
|
it("should throw if OTP secret is empty", async () => {
|
|
const verification = {
|
|
type: VerificationType.OTP,
|
|
secret: "",
|
|
} as any;
|
|
|
|
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
|
"Verification code is required",
|
|
);
|
|
});
|
|
|
|
it("should throw if OTP secret is null", async () => {
|
|
const verification = {
|
|
type: VerificationType.OTP,
|
|
secret: null,
|
|
} as any;
|
|
|
|
await expect(sut.buildRequest(verification)).rejects.toThrow(
|
|
"Verification code is required",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("Master password verification", () => {
|
|
beforeEach(() => {
|
|
kdfConfigService.getKdfConfig.mockResolvedValue("kdfConfig" as unknown as KdfConfig);
|
|
masterPasswordService.saltForUser$.mockReturnValue(of("salt" as any));
|
|
masterPasswordService.makeMasterPasswordAuthenticationData.mockResolvedValue({
|
|
masterPasswordAuthenticationHash: "hash",
|
|
} as any);
|
|
});
|
|
|
|
it("should build request with master password secret", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password123",
|
|
} as any;
|
|
|
|
const result = await sut.buildRequest(verification);
|
|
|
|
expect(result.masterPasswordHash).toBe("hash");
|
|
});
|
|
|
|
it("should use default SecretVerificationRequest if no custom class provided", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password123",
|
|
} as any;
|
|
|
|
const result = await sut.buildRequest(verification);
|
|
|
|
expect(result).toHaveProperty("masterPasswordHash");
|
|
});
|
|
|
|
it("should get KDF config for the active user", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password123",
|
|
} as any;
|
|
|
|
await sut.buildRequest(verification);
|
|
|
|
expect(kdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
|
|
});
|
|
|
|
it("should get salt for the active user", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password123",
|
|
} as any;
|
|
|
|
await sut.buildRequest(verification);
|
|
|
|
expect(masterPasswordService.saltForUser$).toHaveBeenCalledWith(mockUserId);
|
|
});
|
|
|
|
it("should call makeMasterPasswordAuthenticationData with correct parameters", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password123",
|
|
} as any;
|
|
|
|
await sut.buildRequest(verification);
|
|
|
|
expect(masterPasswordService.makeMasterPasswordAuthenticationData).toHaveBeenCalledWith(
|
|
"password123",
|
|
"kdfConfig",
|
|
"salt",
|
|
);
|
|
});
|
|
|
|
it("should throw if master password secret is empty", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: "",
|
|
} as any;
|
|
|
|
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
|
});
|
|
|
|
it("should throw if master password secret is null", async () => {
|
|
const verification = {
|
|
type: VerificationType.MasterPassword,
|
|
secret: null,
|
|
} as any;
|
|
|
|
await expect(sut.buildRequest(verification)).rejects.toThrow("Master Password is required");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("verifyUserByMasterPassword", () => {
|
|
beforeAll(() => {
|
|
i18nService.t.calledWith("invalidMasterPassword").mockReturnValue("Invalid master password");
|
|
|
|
kdfConfigService.getKdfConfig.mockResolvedValue("kdfConfig" as unknown as KdfConfig);
|
|
masterPasswordService.masterKey$.mockReturnValue(of("masterKey" as unknown as MasterKey));
|
|
keyService.hashMasterKey
|
|
.calledWith("password", "masterKey" as unknown as MasterKey, HashPurpose.LocalAuthorization)
|
|
.mockResolvedValue("localHash");
|
|
});
|
|
|
|
describe("client-side verification", () => {
|
|
beforeEach(() => {
|
|
setMasterPasswordAvailability(true);
|
|
});
|
|
|
|
it("returns if verification is successful", async () => {
|
|
keyService.compareKeyHash.mockResolvedValueOnce(true);
|
|
|
|
const result = await sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
);
|
|
|
|
expect(keyService.compareKeyHash).toHaveBeenCalled();
|
|
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
|
"localHash",
|
|
mockUserId,
|
|
);
|
|
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith("masterKey", mockUserId);
|
|
expect(result).toEqual({
|
|
policyOptions: null,
|
|
masterKey: "masterKey",
|
|
email: "email",
|
|
});
|
|
});
|
|
|
|
it("throws if verification fails", async () => {
|
|
keyService.compareKeyHash.mockResolvedValueOnce(false);
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
),
|
|
).rejects.toThrow("Invalid master password");
|
|
|
|
expect(keyService.compareKeyHash).toHaveBeenCalled();
|
|
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
|
|
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
|
|
});
|
|
});
|
|
|
|
describe("server-side verification", () => {
|
|
beforeEach(() => {
|
|
setMasterPasswordAvailability(false);
|
|
});
|
|
|
|
it("returns if verification is successful", async () => {
|
|
keyService.hashMasterKey
|
|
.calledWith(
|
|
"password",
|
|
"masterKey" as unknown as MasterKey,
|
|
HashPurpose.ServerAuthorization,
|
|
)
|
|
.mockResolvedValueOnce("serverHash");
|
|
userVerificationApiService.postAccountVerifyPassword.mockResolvedValueOnce(
|
|
"MasterPasswordPolicyOptions" as unknown as MasterPasswordPolicyResponse,
|
|
);
|
|
|
|
const result = await sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
);
|
|
|
|
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
|
|
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(
|
|
"localHash",
|
|
mockUserId,
|
|
);
|
|
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith("masterKey", mockUserId);
|
|
expect(result).toEqual({
|
|
policyOptions: "MasterPasswordPolicyOptions",
|
|
masterKey: "masterKey",
|
|
email: "email",
|
|
});
|
|
});
|
|
|
|
it("throws if verification fails", async () => {
|
|
keyService.hashMasterKey
|
|
.calledWith(
|
|
"password",
|
|
"masterKey" as unknown as MasterKey,
|
|
HashPurpose.ServerAuthorization,
|
|
)
|
|
.mockResolvedValueOnce("serverHash");
|
|
userVerificationApiService.postAccountVerifyPassword.mockRejectedValueOnce(new Error());
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
),
|
|
).rejects.toThrow("Invalid master password");
|
|
|
|
expect(keyService.compareKeyHash).not.toHaveBeenCalled();
|
|
expect(masterPasswordService.setMasterKeyHash).not.toHaveBeenCalledWith();
|
|
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalledWith();
|
|
});
|
|
});
|
|
|
|
describe("error handling", () => {
|
|
it("throws if any of the parameters are nullish", async () => {
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: null,
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
),
|
|
).rejects.toThrow(
|
|
"Master Password is required. Cannot verify user without a master password.",
|
|
);
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
null,
|
|
"email",
|
|
),
|
|
).rejects.toThrow("User ID is required. Cannot verify user by master password.");
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
null,
|
|
),
|
|
).rejects.toThrow("Email is required. Cannot verify user by master password.");
|
|
});
|
|
|
|
it("throws if kdf config is not available", async () => {
|
|
kdfConfigService.getKdfConfig.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
),
|
|
).rejects.toThrow("KDF config is required. Cannot verify user by master password.");
|
|
});
|
|
|
|
it("throws if master key cannot be created", async () => {
|
|
kdfConfigService.getKdfConfig.mockResolvedValueOnce("kdfConfig" as unknown as KdfConfig);
|
|
masterPasswordService.masterKey$.mockReturnValueOnce(of(null));
|
|
keyService.makeMasterKey.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
sut.verifyUserByMasterPassword(
|
|
{
|
|
type: VerificationType.MasterPassword,
|
|
secret: "password",
|
|
} as MasterPasswordVerification,
|
|
mockUserId,
|
|
"email",
|
|
),
|
|
).rejects.toThrow("Master key could not be created to verify the master password.");
|
|
});
|
|
});
|
|
});
|
|
|
|
// Helpers
|
|
function setMasterPasswordAvailability(hasMasterPassword: boolean) {
|
|
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(hasMasterPassword));
|
|
masterPasswordService.masterKeyHash$.mockReturnValue(
|
|
of(hasMasterPassword ? "masterKeyHash" : null),
|
|
);
|
|
}
|
|
|
|
function setPinAvailability(type: PinLockType) {
|
|
pinService.getPinLockType.mockResolvedValue(type);
|
|
|
|
if (type === "EPHEMERAL" || type === "PERSISTENT") {
|
|
pinService.isPinDecryptionAvailable.mockResolvedValue(true);
|
|
} else if (type === "DISABLED") {
|
|
pinService.isPinDecryptionAvailable.mockResolvedValue(false);
|
|
}
|
|
}
|
|
|
|
function disableBiometricsAvailability() {
|
|
vaultTimeoutSettingsService.isBiometricLockSet.mockResolvedValue(false);
|
|
}
|
|
});
|