1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-16 16:59:30 +00:00

Merge branch 'main' into km/auto-kdf

This commit is contained in:
Bernd Schoolmann
2025-12-01 14:08:37 +01:00
1027 changed files with 43220 additions and 14561 deletions

View File

@@ -20,4 +20,5 @@ export enum PolicyType {
UriMatchDefaults = 16, // Sets the default URI matching strategy for all users within an organization
AutotypeDefaultSetting = 17, // Sets the default autotype setting for desktop app
AutoConfirm = 18, // Enables the auto confirmation feature for admins to enable in their client
BlockClaimedDomainAccountCreation = 19, // Prevents users from creating personal accounts using email addresses from verified domains
}

View File

@@ -58,7 +58,7 @@ describe("ORGANIZATIONS state", () => {
allowAdminAccessToAllCollectionItems: false,
familySponsorshipLastSyncDate: new Date(),
userIsManagedByOrganization: false,
useRiskInsights: false,
useAccessIntelligence: false,
useOrganizationDomains: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,

View File

@@ -62,7 +62,7 @@ export class OrganizationData {
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
@@ -130,7 +130,7 @@ export class OrganizationData {
this.limitItemDeletion = response.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useRiskInsights = response.useRiskInsights;
this.useAccessIntelligence = response.useAccessIntelligence;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.isAdminInitiated = response.isAdminInitiated;
this.ssoEnabled = response.ssoEnabled;

View File

@@ -32,6 +32,7 @@ describe("Organization", () => {
useSecretsManager: true,
usePasswordManager: true,
useActivateAutofillPolicy: false,
useAutomaticUserConfirmation: false,
selfHost: false,
usersGetPremium: false,
seats: 10,
@@ -79,7 +80,7 @@ describe("Organization", () => {
limitItemDeletion: false,
allowAdminAccessToAllCollectionItems: true,
userIsManagedByOrganization: false,
useRiskInsights: false,
useAccessIntelligence: false,
useAdminSponsoredFamilies: false,
isAdminInitiated: false,
ssoEnabled: false,
@@ -179,4 +180,118 @@ describe("Organization", () => {
expect(organization.canManageDeviceApprovals).toBe(true);
});
});
describe("canEnableAutoConfirmPolicy", () => {
it("should return false when user cannot manage users or policies", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = false;
data.permissions.managePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage users but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has manageUsers permission but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user can manage policies but useAutomaticUserConfirmation is false", () => {
data.type = OrganizationUserType.Admin;
data.usePolicies = true;
data.useAutomaticUserConfirmation = false;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return false when user has managePolicies permission but usePolicies is false", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = false;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
it("should return true when admin has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Admin;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when owner has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has manageUsers permission and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has managePolicies permission, usePolicies is true, and useAutomaticUserConfirmation is enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return true when user has both manageUsers and managePolicies permissions with useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.User;
data.permissions.manageUsers = true;
data.permissions.managePolicies = true;
data.usePolicies = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(true);
});
it("should return false when provider user has useAutomaticUserConfirmation enabled", () => {
data.type = OrganizationUserType.Owner;
data.isProviderUser = true;
data.useAutomaticUserConfirmation = true;
const organization = new Organization(data);
expect(organization.canEnableAutoConfirmPolicy).toBe(false);
});
});
});

View File

@@ -93,7 +93,7 @@ export class Organization {
* matches one of the verified domains of that organization, and the user is a member of it.
*/
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
@@ -157,7 +157,7 @@ export class Organization {
this.limitItemDeletion = obj.limitItemDeletion;
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useRiskInsights = obj.useRiskInsights;
this.useAccessIntelligence = obj.useAccessIntelligence;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
this.isAdminInitiated = obj.isAdminInitiated;
this.ssoEnabled = obj.ssoEnabled;
@@ -310,6 +310,14 @@ export class Organization {
return this.isAdmin || this.permissions.manageResetPassword;
}
get canEnableAutoConfirmPolicy() {
return (
(this.canManageUsers || this.canManagePolicies) &&
this.useAutomaticUserConfirmation &&
!this.isProviderUser
);
}
get canManageDeviceApprovals() {
return (
(this.isAdmin || this.permissions.manageResetPassword) &&

View File

@@ -1,10 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { OrganizationKeysRequest } from "./organization-keys.request";
export class OrganizationUpdateRequest {
name: string;
businessName: string;
billingEmail: string;
keys: OrganizationKeysRequest;
export interface OrganizationUpdateRequest {
name?: string;
billingEmail?: string;
keys?: OrganizationKeysRequest;
}

View File

@@ -1,7 +1,4 @@
import { PolicyType } from "../../enums";
export type PolicyRequest = {
type: PolicyType;
enabled: boolean;
data: any;
};

View File

@@ -38,7 +38,7 @@ export class OrganizationResponse extends BaseResponse {
limitCollectionDeletion: boolean;
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
useRiskInsights: boolean;
useAccessIntelligence: boolean;
constructor(response: any) {
super(response);
@@ -80,6 +80,7 @@ export class OrganizationResponse extends BaseResponse {
this.allowAdminAccessToAllCollectionItems = this.getResponseProperty(
"AllowAdminAccessToAllCollectionItems",
);
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
}
}

View File

@@ -57,7 +57,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
limitItemDeletion: boolean;
allowAdminAccessToAllCollectionItems: boolean;
userIsManagedByOrganization: boolean;
useRiskInsights: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
@@ -129,7 +129,8 @@ export class ProfileOrganizationResponse extends BaseResponse {
"AllowAdminAccessToAllCollectionItems",
);
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;

View File

@@ -285,6 +285,8 @@ export class DefaultPolicyService implements PolicyService {
case PolicyType.RemoveUnlockWithPin:
// Remove Unlock with PIN policy
return false;
case PolicyType.AutoConfirm:
return false;
case PolicyType.OrganizationDataOwnership:
// organization data ownership policy applies to everyone except admins and owners
return organization.isAdmin;

View File

@@ -1,60 +0,0 @@
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export interface TwoFactorProviderDetails {
type: TwoFactorProviderType;
name: string;
description: string;
priority: number;
sort: number;
premium: boolean;
}
export abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
}

View File

@@ -48,6 +48,9 @@ export abstract class UserVerificationService {
* @param userId The user id to check. If not provided, the current user is used
* @returns True if the user has a master password
* @deprecated Use UserDecryptionOptionsService.hasMasterPassword$ instead
* @remark To facilitate deprecation, many call sites were removed as part of PM-26413.
* Those remaining are blocked by currently-disallowed imports of auth/common.
* PM-27009 has been filed to track completion of this deprecation.
*/
abstract hasMasterPassword(userId?: string): Promise<boolean>;
/**

View File

@@ -11,7 +11,7 @@ export abstract class WebAuthnLoginPrfKeyServiceAbstraction {
/**
* Create a symmetric key from the PRF-output by stretching it.
* This should be used as `ExternalKey` with `RotateableKeySet`.
* This should be used as `UpstreamKey` with `RotateableKeySet`.
*/
abstract createSymmetricKeyFromPrf(prf: ArrayBuffer): Promise<PrfKey>;
}

View File

@@ -1,10 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// FIXME: remove `src` and fix import
// eslint-disable-next-line no-restricted-imports
import { RotateableKeySet } from "../../../../../auth/src/common/models";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
export class WebauthnRotateCredentialRequest {
id: string;

View File

@@ -2,12 +2,9 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
// 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 { RotateableKeySet } from "@bitwarden/auth/common";
import { DeviceType } from "../../../enums";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { RotateableKeySet } from "../../../key-management/keys/models/rotateable-key-set";
import { BaseResponse } from "../../../models/response/base.response";
export class ProtectedDeviceResponse extends BaseResponse {

View File

@@ -13,7 +13,7 @@ export abstract class SendTokenService {
/**
* Attempts to retrieve a {@link SendAccessToken} for the given sendId.
* If the access token is found in session storage and is not expired, then it returns the token.
* If the access token is expired, then it returns a {@link TryGetSendAccessTokenError} expired error.
* If the access token found in session storage is expired, then it returns a {@link TryGetSendAccessTokenError} expired error and clears the token from storage so that a subsequent call can attempt to retrieve a new token.
* If an access token is not found in storage, then it attempts to retrieve it from the server (will succeed for sends that don't require any credentials to view).
* If the access token is successfully retrieved from the server, then it stores the token in session storage and returns it.
* If an access token cannot be granted b/c the send requires credentials, then it returns a {@link TryGetSendAccessTokenError} indicating which credentials are required.

View File

@@ -1,212 +0,0 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../platform/abstractions/platform-utils.service";
import { Utils } from "../../platform/misc/utils";
import { GlobalStateProvider, KeyDefinition, TWO_FACTOR_MEMORY } from "../../platform/state";
import {
TwoFactorProviderDetails,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
import { TwoFactorProviderType } from "../enums/two-factor-provider-type";
import { IdentityTwoFactorResponse } from "../models/response/identity-two-factor.response";
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null as string,
description: null as string,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null as string,
description: null as string,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null as string,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null as string,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null as string,
description: null as string,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null as string,
description: null as string,
priority: 4,
sort: 3,
premium: false,
},
};
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export class TwoFactorService implements TwoFactorServiceAbstraction {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
}

View File

@@ -3,10 +3,7 @@ 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 {
UserDecryptionOptions,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
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 {
@@ -146,11 +143,7 @@ describe("UserVerificationService", () => {
describe("server verification type", () => {
it("correctly returns master password availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: true,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(true));
const result = await sut.getAvailableVerificationOptions("server");
@@ -168,11 +161,7 @@ describe("UserVerificationService", () => {
});
it("correctly returns OTP availability", async () => {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: false,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(false));
const result = await sut.getAvailableVerificationOptions("server");
@@ -191,6 +180,140 @@ describe("UserVerificationService", () => {
});
});
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");
@@ -228,7 +351,6 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: null,
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -288,7 +410,6 @@ describe("UserVerificationService", () => {
expect(result).toEqual({
policyOptions: "MasterPasswordPolicyOptions",
masterKey: "masterKey",
kdfConfig: "kdfConfig",
email: "email",
});
});
@@ -394,11 +515,7 @@ describe("UserVerificationService", () => {
// Helpers
function setMasterPasswordAvailability(hasMasterPassword: boolean) {
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
of({
hasMasterPassword: hasMasterPassword,
} as UserDecryptionOptions),
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(of(hasMasterPassword));
masterPasswordService.masterKeyHash$.mockReturnValue(
of(hasMasterPassword ? "masterKeyHash" : null),
);

View File

@@ -37,6 +37,7 @@ import {
VerificationWithSecret,
verificationHasSecret,
} from "../../types/verification";
import { getUserId } from "../account.service";
/**
* Used for general-purpose user verification throughout the app.
@@ -101,7 +102,6 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
async buildRequest<T extends SecretVerificationRequest>(
verification: ServerSideVerification,
requestClass?: new () => T,
alreadyHashed?: boolean,
) {
this.validateSecretInput(verification);
@@ -111,20 +111,17 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
if (verification.type === VerificationType.OTP) {
request.otp = verification.secret;
} else {
const [userId, email] = await firstValueFrom(
this.accountService.activeAccount$.pipe(map((a) => [a?.id, a?.email])),
);
let masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
if (!masterKey && !alreadyHashed) {
masterKey = await this.keyService.makeMasterKey(
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const kdf = await this.kdfConfigService.getKdfConfig(userId as UserId);
const salt = await firstValueFrom(this.masterPasswordService.saltForUser$(userId as UserId));
const authenticationData =
await this.masterPasswordService.makeMasterPasswordAuthenticationData(
verification.secret,
email,
await this.kdfConfigService.getKdfConfig(userId),
kdf,
salt,
);
}
request.masterPasswordHash = alreadyHashed
? verification.secret
: await this.keyService.hashMasterKey(verification.secret, masterKey);
request.authenticateWith(authenticationData);
}
return request;
@@ -239,7 +236,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
);
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
return { policyOptions, masterKey, kdfConfig, email };
return { policyOptions, masterKey, email };
}
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {
@@ -261,16 +258,19 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
}
async hasMasterPassword(userId?: string): Promise<boolean> {
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
const resolvedUserId = userId ?? (await firstValueFrom(this.accountService.activeAccount$))?.id;
if (decryptionOptions?.hasMasterPassword != undefined) {
return decryptionOptions.hasMasterPassword;
}
if (!resolvedUserId) {
return false;
}
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
// Ideally, this method would accept a UserId over string. To avoid scope creep in PM-26413, we are
// doing the cast here. Future work should be done to make this type-safe, and should be considered
// as part of PM-27009.
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId as UserId),
);
}
async hasMasterPasswordAndMasterKeyHash(userId?: string): Promise<boolean> {

View File

@@ -0,0 +1,2 @@
export * from "./two-factor-api.service";
export * from "./two-factor.service";

View File

@@ -0,0 +1,497 @@
import { ListResponse } from "../../../models/response/list.response";
import { KeyDefinition, TWO_FACTOR_MEMORY } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
ChallengeResponse,
TwoFactorWebAuthnResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
/**
* Metadata and display information for a two-factor authentication provider.
* Used by UI components to render provider selection and configuration screens.
*/
export interface TwoFactorProviderDetails {
/** The unique identifier for this provider type. */
type: TwoFactorProviderType;
/**
* Display name for the provider, localized via {@link TwoFactorService.init}.
* Examples: "Authenticator App", "Email", "YubiKey".
*/
name: string | null;
/**
* User-facing description explaining what this provider is and how it works.
* Localized via {@link TwoFactorService.init}.
*/
description: string | null;
/**
* Selection priority during login when multiple providers are available.
* Higher values are preferred. Used to determine the default provider.
* Range: 0 (lowest) to 10 (highest).
*/
priority: number;
/**
* Display order in provider lists within settings UI.
* Lower values appear first (1 = first position).
*/
sort: number;
/**
* Whether this provider requires an active premium subscription.
* Premium providers: Duo (personal), YubiKey.
* Organization providers (e.g., OrganizationDuo) do not require personal premium.
*/
premium: boolean;
}
/**
* Registry of all supported two-factor authentication providers with their metadata.
* Strings (name, description) are initialized as null and populated with localized
* translations when {@link TwoFactorService.init} is called during application startup.
*
* @remarks
* This constant is mutated during initialization. Components should not access it before
* the service's init() method has been called.
*
* @example
* ```typescript
* // During app init
* twoFactorService.init();
*
* // In components
* const authenticator = TwoFactorProviders[TwoFactorProviderType.Authenticator];
* console.log(authenticator.name); // "Authenticator App" (localized)
* ```
*/
export const TwoFactorProviders: Partial<Record<TwoFactorProviderType, TwoFactorProviderDetails>> =
{
[TwoFactorProviderType.Authenticator]: {
type: TwoFactorProviderType.Authenticator,
name: null,
description: null,
priority: 1,
sort: 2,
premium: false,
},
[TwoFactorProviderType.Yubikey]: {
type: TwoFactorProviderType.Yubikey,
name: null,
description: null,
priority: 3,
sort: 4,
premium: true,
},
[TwoFactorProviderType.Duo]: {
type: TwoFactorProviderType.Duo,
name: "Duo",
description: null,
priority: 2,
sort: 5,
premium: true,
},
[TwoFactorProviderType.OrganizationDuo]: {
type: TwoFactorProviderType.OrganizationDuo,
name: "Duo (Organization)",
description: null,
priority: 10,
sort: 6,
premium: false,
},
[TwoFactorProviderType.Email]: {
type: TwoFactorProviderType.Email,
name: null,
description: null,
priority: 0,
sort: 1,
premium: false,
},
[TwoFactorProviderType.WebAuthn]: {
type: TwoFactorProviderType.WebAuthn,
name: null,
description: null,
priority: 4,
sort: 3,
premium: false,
},
};
// Memory storage as only required during authentication process
export const PROVIDERS = KeyDefinition.record<Record<string, string>, TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"providers",
{
deserializer: (obj) => obj,
},
);
// Memory storage as only required during authentication process
export const SELECTED_PROVIDER = new KeyDefinition<TwoFactorProviderType>(
TWO_FACTOR_MEMORY,
"selected",
{
deserializer: (obj) => obj,
},
);
export abstract class TwoFactorService {
/**
* Initializes the client-side's TwoFactorProviders const with translations.
*/
abstract init(): void;
/**
* Gets a list of two-factor providers from state that are supported on the current client.
* E.g., WebAuthn and Duo are not available on all clients.
* @returns A list of supported two-factor providers or an empty list if none are stored in state.
*/
abstract getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]>;
/**
* Gets the previously selected two-factor provider or the default two factor provider based on priority.
* @param webAuthnSupported - Whether or not WebAuthn is supported by the client. Prevents WebAuthn from being the default provider if false.
*/
abstract getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType>;
/**
* Sets the selected two-factor provider in state.
* @param type - The type of two-factor provider to set as the selected provider.
*/
abstract setSelectedProvider(type: TwoFactorProviderType): Promise<void>;
/**
* Clears the selected two-factor provider from state.
*/
abstract clearSelectedProvider(): Promise<void>;
/**
* Sets the list of available two-factor providers in state.
* @param response - the response from Identity for when 2FA is required. Includes the list of available 2FA providers.
*/
abstract setProviders(response: IdentityTwoFactorResponse): Promise<void>;
/**
* Clears the list of available two-factor providers from state.
*/
abstract clearProviders(): Promise<void>;
/**
* Gets the list of two-factor providers from state.
* Note: no filtering is done here, so this will return all providers, including potentially
* unsupported ones for the current client.
* @returns A list of two-factor providers or null if none are stored in state.
*/
abstract getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null>;
/**
* Gets the enabled two-factor providers for the current user from the API.
* Used for settings management.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the enabled two-factor providers for an organization from the API.
* Requires organization administrator permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @returns A promise that resolves to a list response containing enabled two-factor provider configurations.
*/
abstract getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>>;
/**
* Gets the authenticator (TOTP) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the authenticator configuration including the secret key.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Gets the email two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse>;
/**
* Gets the Duo two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse>;
/**
* Gets the Duo two-factor configuration for an organization from the API.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Gets the YubiKey OTP two-factor configuration for the current user from the API.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorYubiKey(
request: SecretVerificationRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Gets the WebAuthn (FIDO2) two-factor configuration for the current user from the API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to authentication.
* @returns A promise that resolves to the WebAuthn configuration including registered credentials.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthn(
request: SecretVerificationRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Gets a WebAuthn challenge for registering a new WebAuthn credential from the API.
* This must be called before putTwoFactorWebAuthn to obtain the cryptographic challenge
* required for credential creation. The challenge is used by the browser's WebAuthn API.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link SecretVerificationRequest} to prove authentication.
* @returns A promise that resolves to the credential creation options containing the challenge.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorWebAuthnChallenge(
request: SecretVerificationRequest,
): Promise<ChallengeResponse>;
/**
* Gets the recovery code configuration for the current user from the API.
* The recovery code should be stored securely by the user.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param verification The verification information to prove authentication.
* @returns A promise that resolves to the recovery code configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract getTwoFactorRecover(
request: SecretVerificationRequest,
): Promise<TwoFactorRecoverResponse>;
/**
* Enables or updates the authenticator (TOTP) two-factor provider.
* Validates the provided token against the shared secret before enabling.
* The token must be generated by an authenticator app using the secret key.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated authenticator configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse>;
/**
* Disables the authenticator (TOTP) two-factor provider for the current user.
* Requires user verification token to confirm the operation.
* Used for settings management.
*
* @param request The {@link DisableTwoFactorAuthenticatorRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Enables or updates the email two-factor provider for the current user.
* Validates the email verification token sent via postTwoFactorEmailSetup before enabling.
* The token must match the code sent to the specified email address.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves to the updated email two-factor configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse>;
/**
* Enables or updates the Duo two-factor provider for the current user.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the Duo two-factor provider for an organization.
* Validates the Duo configuration (client ID, client secret, and host) before enabling.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link UpdateTwoFactorDuoRequest} to prove authentication.
* @returns A promise that resolves to the updated organization Duo configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse>;
/**
* Enables or updates the YubiKey OTP two-factor provider for the current user.
* Validates each provided YubiKey by testing an OTP from the device.
* Supports up to 5 YubiKey devices. Empty key slots are allowed.
* Requires user verification and an active premium subscription.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorYubikeyOtpRequest} to prove authentication.
* @returns A promise that resolves to the updated YubiKey configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse>;
/**
* Registers a new WebAuthn (FIDO2) credential for two-factor authentication for the current user.
* Must be called after getTwoFactorWebAuthnChallenge to complete the registration flow.
* The device response contains the signed challenge from the authenticator device.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration with the new credential.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Removes a specific WebAuthn (FIDO2) credential from the user's account.
* The credential will no longer be usable for two-factor authentication.
* Other registered WebAuthn credentials remain active.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link UpdateTwoFactorWebAuthnDeleteRequest} to prove authentication.
* @returns A promise that resolves to the updated WebAuthn configuration.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse>;
/**
* Disables a specific two-factor provider for the current user.
* The provider will no longer be required or usable for authentication.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorDisable(
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Disables a specific two-factor provider for an organization.
* The provider will no longer be available for organization members.
* Requires user verification and organization policy management permissions.
* Used for settings management.
*
* @param organizationId The ID of the organization.
* @param request The {@link TwoFactorProviderRequest} to prove authentication.
* @returns A promise that resolves to the updated provider status.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse>;
/**
* Initiates email two-factor setup by sending a verification code to the specified email address.
* This is the first step in enabling email two-factor authentication.
* The verification code must be provided to putTwoFactorEmail to complete setup.
* Only used during initial configuration, not during login flows.
* Requires user verification via master password or OTP.
* Used for settings management.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the verification email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any>;
/**
* Sends a two-factor authentication code via email during the login flow.
* Supports multiple authentication contexts including standard login, SSO, and passwordless flows.
* This is used to deliver codes during authentication, not during initial setup.
* May be called without authentication for login scenarios.
* Used during authentication flows.
*
* @param request The {@link TwoFactorEmailRequest} to prove authentication.
* @returns A promise that resolves when the authentication email has been sent.
* @remarks Use {@link UserVerificationService.buildRequest} to create the request object.
*/
abstract postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any>;
}

View File

@@ -1,2 +1,2 @@
export { TwoFactorApiService } from "./two-factor-api.service";
export { DefaultTwoFactorApiService } from "./default-two-factor-api.service";
export * from "./abstractions";
export * from "./services";

View File

@@ -22,7 +22,7 @@ import { TwoFactorYubiKeyResponse } from "@bitwarden/common/auth/models/response
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { TwoFactorApiService } from "./two-factor-api.service";
import { TwoFactorApiService } from "../abstractions/two-factor-api.service";
export class DefaultTwoFactorApiService implements TwoFactorApiService {
constructor(private apiService: ApiService) {}

View File

@@ -0,0 +1,279 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map } from "rxjs";
import { TwoFactorApiService } from "..";
import { ListResponse } from "../../../models/response/list.response";
import { I18nService } from "../../../platform/abstractions/i18n.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { Utils } from "../../../platform/misc/utils";
import { GlobalStateProvider } from "../../../platform/state";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
import { DisableTwoFactorAuthenticatorRequest } from "../../models/request/disable-two-factor-authenticator.request";
import { SecretVerificationRequest } from "../../models/request/secret-verification.request";
import { TwoFactorEmailRequest } from "../../models/request/two-factor-email.request";
import { TwoFactorProviderRequest } from "../../models/request/two-factor-provider.request";
import { UpdateTwoFactorAuthenticatorRequest } from "../../models/request/update-two-factor-authenticator.request";
import { UpdateTwoFactorDuoRequest } from "../../models/request/update-two-factor-duo.request";
import { UpdateTwoFactorEmailRequest } from "../../models/request/update-two-factor-email.request";
import { UpdateTwoFactorWebAuthnDeleteRequest } from "../../models/request/update-two-factor-web-authn-delete.request";
import { UpdateTwoFactorWebAuthnRequest } from "../../models/request/update-two-factor-web-authn.request";
import { UpdateTwoFactorYubikeyOtpRequest } from "../../models/request/update-two-factor-yubikey-otp.request";
import { IdentityTwoFactorResponse } from "../../models/response/identity-two-factor.response";
import { TwoFactorAuthenticatorResponse } from "../../models/response/two-factor-authenticator.response";
import { TwoFactorDuoResponse } from "../../models/response/two-factor-duo.response";
import { TwoFactorEmailResponse } from "../../models/response/two-factor-email.response";
import { TwoFactorProviderResponse } from "../../models/response/two-factor-provider.response";
import { TwoFactorRecoverResponse } from "../../models/response/two-factor-recover.response";
import {
TwoFactorWebAuthnResponse,
ChallengeResponse,
} from "../../models/response/two-factor-web-authn.response";
import { TwoFactorYubiKeyResponse } from "../../models/response/two-factor-yubi-key.response";
import {
PROVIDERS,
SELECTED_PROVIDER,
TwoFactorProviderDetails,
TwoFactorProviders,
TwoFactorService as TwoFactorServiceAbstraction,
} from "../abstractions/two-factor.service";
export class DefaultTwoFactorService implements TwoFactorServiceAbstraction {
private providersState = this.globalStateProvider.get(PROVIDERS);
private selectedState = this.globalStateProvider.get(SELECTED_PROVIDER);
readonly providers$ = this.providersState.state$.pipe(
map((providers) => Utils.recordToMap(providers)),
);
readonly selected$ = this.selectedState.state$;
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private globalStateProvider: GlobalStateProvider,
private twoFactorApiService: TwoFactorApiService,
) {}
init() {
TwoFactorProviders[TwoFactorProviderType.Email].name = this.i18nService.t("emailTitle");
TwoFactorProviders[TwoFactorProviderType.Email].description = this.i18nService.t("emailDescV2");
TwoFactorProviders[TwoFactorProviderType.Authenticator].name =
this.i18nService.t("authenticatorAppTitle");
TwoFactorProviders[TwoFactorProviderType.Authenticator].description =
this.i18nService.t("authenticatorAppDescV2");
TwoFactorProviders[TwoFactorProviderType.Duo].description = this.i18nService.t("duoDescV2");
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].name =
"Duo (" + this.i18nService.t("organization") + ")";
TwoFactorProviders[TwoFactorProviderType.OrganizationDuo].description =
this.i18nService.t("duoOrganizationDesc");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].name = this.i18nService.t("webAuthnTitle");
TwoFactorProviders[TwoFactorProviderType.WebAuthn].description =
this.i18nService.t("webAuthnDesc");
TwoFactorProviders[TwoFactorProviderType.Yubikey].name = this.i18nService.t("yubiKeyTitleV2");
TwoFactorProviders[TwoFactorProviderType.Yubikey].description =
this.i18nService.t("yubiKeyDesc");
}
async getSupportedProviders(win: Window): Promise<TwoFactorProviderDetails[]> {
const data = await firstValueFrom(this.providers$);
const providers: any[] = [];
if (data == null) {
return providers;
}
if (
data.has(TwoFactorProviderType.OrganizationDuo) &&
this.platformUtilsService.supportsDuo()
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.OrganizationDuo]);
}
if (data.has(TwoFactorProviderType.Authenticator)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Authenticator]);
}
if (data.has(TwoFactorProviderType.Yubikey)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Yubikey]);
}
if (data.has(TwoFactorProviderType.Duo) && this.platformUtilsService.supportsDuo()) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Duo]);
}
if (
data.has(TwoFactorProviderType.WebAuthn) &&
this.platformUtilsService.supportsWebAuthn(win)
) {
providers.push(TwoFactorProviders[TwoFactorProviderType.WebAuthn]);
}
if (data.has(TwoFactorProviderType.Email)) {
providers.push(TwoFactorProviders[TwoFactorProviderType.Email]);
}
return providers;
}
async getDefaultProvider(webAuthnSupported: boolean): Promise<TwoFactorProviderType> {
const data = await firstValueFrom(this.providers$);
const selected = await firstValueFrom(this.selected$);
if (data == null) {
return null;
}
if (selected != null && data.has(selected)) {
return selected;
}
let providerType: TwoFactorProviderType = null;
let providerPriority = -1;
data.forEach((_value, type) => {
const provider = (TwoFactorProviders as any)[type];
if (provider != null && provider.priority > providerPriority) {
if (type === TwoFactorProviderType.WebAuthn && !webAuthnSupported) {
return;
}
providerType = type;
providerPriority = provider.priority;
}
});
return providerType;
}
async setSelectedProvider(type: TwoFactorProviderType): Promise<void> {
await this.selectedState.update(() => type);
}
async clearSelectedProvider(): Promise<void> {
await this.selectedState.update(() => null);
}
async setProviders(response: IdentityTwoFactorResponse): Promise<void> {
await this.providersState.update(() => response.twoFactorProviders2);
}
async clearProviders(): Promise<void> {
await this.providersState.update(() => null);
}
getProviders(): Promise<Map<TwoFactorProviderType, { [key: string]: string }> | null> {
return firstValueFrom(this.providers$);
}
getEnabledTwoFactorProviders(): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorProviders();
}
getTwoFactorOrganizationProviders(
organizationId: string,
): Promise<ListResponse<TwoFactorProviderResponse>> {
return this.twoFactorApiService.getTwoFactorOrganizationProviders(organizationId);
}
getTwoFactorAuthenticator(
request: SecretVerificationRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.getTwoFactorAuthenticator(request);
}
getTwoFactorEmail(request: SecretVerificationRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.getTwoFactorEmail(request);
}
getTwoFactorDuo(request: SecretVerificationRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorDuo(request);
}
getTwoFactorOrganizationDuo(
organizationId: string,
request: SecretVerificationRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.getTwoFactorOrganizationDuo(organizationId, request);
}
getTwoFactorYubiKey(request: SecretVerificationRequest): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.getTwoFactorYubiKey(request);
}
getTwoFactorWebAuthn(request: SecretVerificationRequest): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthn(request);
}
getTwoFactorWebAuthnChallenge(request: SecretVerificationRequest): Promise<ChallengeResponse> {
return this.twoFactorApiService.getTwoFactorWebAuthnChallenge(request);
}
getTwoFactorRecover(request: SecretVerificationRequest): Promise<TwoFactorRecoverResponse> {
return this.twoFactorApiService.getTwoFactorRecover(request);
}
putTwoFactorAuthenticator(
request: UpdateTwoFactorAuthenticatorRequest,
): Promise<TwoFactorAuthenticatorResponse> {
return this.twoFactorApiService.putTwoFactorAuthenticator(request);
}
deleteTwoFactorAuthenticator(
request: DisableTwoFactorAuthenticatorRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.deleteTwoFactorAuthenticator(request);
}
putTwoFactorEmail(request: UpdateTwoFactorEmailRequest): Promise<TwoFactorEmailResponse> {
return this.twoFactorApiService.putTwoFactorEmail(request);
}
putTwoFactorDuo(request: UpdateTwoFactorDuoRequest): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorDuo(request);
}
putTwoFactorOrganizationDuo(
organizationId: string,
request: UpdateTwoFactorDuoRequest,
): Promise<TwoFactorDuoResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDuo(organizationId, request);
}
putTwoFactorYubiKey(
request: UpdateTwoFactorYubikeyOtpRequest,
): Promise<TwoFactorYubiKeyResponse> {
return this.twoFactorApiService.putTwoFactorYubiKey(request);
}
putTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.putTwoFactorWebAuthn(request);
}
deleteTwoFactorWebAuthn(
request: UpdateTwoFactorWebAuthnDeleteRequest,
): Promise<TwoFactorWebAuthnResponse> {
return this.twoFactorApiService.deleteTwoFactorWebAuthn(request);
}
putTwoFactorDisable(request: TwoFactorProviderRequest): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorDisable(request);
}
putTwoFactorOrganizationDisable(
organizationId: string,
request: TwoFactorProviderRequest,
): Promise<TwoFactorProviderResponse> {
return this.twoFactorApiService.putTwoFactorOrganizationDisable(organizationId, request);
}
postTwoFactorEmailSetup(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmailSetup(request);
}
postTwoFactorEmail(request: TwoFactorEmailRequest): Promise<any> {
return this.twoFactorApiService.postTwoFactorEmail(request);
}
}

View File

@@ -0,0 +1,2 @@
export * from "./default-two-factor-api.service";
export * from "./default-two-factor.service";

View File

@@ -1,13 +1,13 @@
// 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 { KdfConfig } from "@bitwarden/key-management";
import { MasterKey } from "../../types/key";
import { VerificationType } from "../enums/verification-type";
import { MasterPasswordPolicyResponse } from "../models/response/master-password-policy.response";
export type OtpVerification = { type: VerificationType.OTP; secret: string };
export type MasterPasswordVerification = { type: VerificationType.MasterPassword; secret: string };
export type MasterPasswordVerification = {
type: VerificationType.MasterPassword;
/** Secret here means the master password, *NOT* a hash of it */
secret: string;
};
export type PinVerification = { type: VerificationType.PIN; secret: string };
export type BiometricsVerification = { type: VerificationType.Biometrics };
@@ -25,8 +25,8 @@ export function verificationHasSecret(
export type ServerSideVerification = OtpVerification | MasterPasswordVerification;
export type MasterPasswordVerificationResponse = {
/** @deprecated */
masterKey: MasterKey;
kdfConfig: KdfConfig;
email: string;
policyOptions: MasterPasswordPolicyResponse | null;
};

View File

@@ -25,6 +25,10 @@ export abstract class BillingApiServiceAbstraction {
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse>;
abstract getPlans(): Promise<ListResponse<PlanResponse>>;
abstract getPremiumPlan(): Promise<PremiumPlanResponse>;

View File

@@ -8,7 +8,7 @@ export enum PlanType {
EnterpriseMonthly2019 = 4,
EnterpriseAnnually2019 = 5,
Custom = 6,
FamiliesAnnually = 7,
FamiliesAnnually2025 = 7,
TeamsMonthly2020 = 8,
TeamsAnnually2020 = 9,
EnterpriseMonthly2020 = 10,
@@ -23,4 +23,5 @@ export enum PlanType {
EnterpriseMonthly = 19,
EnterpriseAnnually = 20,
TeamsStarter = 21,
FamiliesAnnually = 22,
}

View File

@@ -40,6 +40,7 @@ export class BillingCustomerDiscount extends BaseResponse {
id: string;
active: boolean;
percentOff?: number;
amountOff?: number;
appliesTo: string[];
constructor(response: any) {
@@ -47,6 +48,7 @@ export class BillingCustomerDiscount extends BaseResponse {
this.id = this.getResponseProperty("Id");
this.active = this.getResponseProperty("Active");
this.percentOff = this.getResponseProperty("PercentOff");
this.appliesTo = this.getResponseProperty("AppliesTo");
this.amountOff = this.getResponseProperty("AmountOff");
this.appliesTo = this.getResponseProperty("AppliesTo") || [];
}
}

View File

@@ -2,12 +2,15 @@
// @ts-strict-ignore
import { BaseResponse } from "../../../models/response/base.response";
import { BillingCustomerDiscount } from "./organization-subscription.response";
export class SubscriptionResponse extends BaseResponse {
storageName: string;
storageGb: number;
maxStorageGb: number;
subscription: BillingSubscriptionResponse;
upcomingInvoice: BillingSubscriptionUpcomingInvoiceResponse;
customerDiscount: BillingCustomerDiscount;
license: any;
expiration: string;
@@ -20,11 +23,14 @@ export class SubscriptionResponse extends BaseResponse {
this.expiration = this.getResponseProperty("Expiration");
const subscription = this.getResponseProperty("Subscription");
const upcomingInvoice = this.getResponseProperty("UpcomingInvoice");
const customerDiscount = this.getResponseProperty("CustomerDiscount");
this.subscription = subscription == null ? null : new BillingSubscriptionResponse(subscription);
this.upcomingInvoice =
upcomingInvoice == null
? null
: new BillingSubscriptionUpcomingInvoiceResponse(upcomingInvoice);
this.customerDiscount =
customerDiscount == null ? null : new BillingCustomerDiscount(customerDiscount);
}
}

View File

@@ -62,6 +62,20 @@ export class BillingApiService implements BillingApiServiceAbstraction {
return new OrganizationBillingMetadataResponse(r);
}
async getOrganizationBillingMetadataVNextSelfHost(
organizationId: OrganizationId,
): Promise<OrganizationBillingMetadataResponse> {
const r = await this.apiService.send(
"GET",
"/organizations/" + organizationId + "/billing/vnext/self-host/metadata",
null,
true,
true,
);
return new OrganizationBillingMetadataResponse(r);
}
async getPlans(): Promise<ListResponse<PlanResponse>> {
const r = await this.apiService.send("GET", "/plans", null, true, true);
return new ListResponse(r, PlanResponse);

View File

@@ -135,6 +135,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
case PlanType.Free:
case PlanType.FamiliesAnnually:
case PlanType.FamiliesAnnually2019:
case PlanType.FamiliesAnnually2025:
case PlanType.TeamsStarter2023:
case PlanType.TeamsStarter:
return true;

View File

@@ -4,6 +4,7 @@ import { BehaviorSubject, firstValueFrom } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { OrganizationBillingMetadataResponse } from "@bitwarden/common/billing/models/response/organization-billing-metadata.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { newGuid } from "@bitwarden/guid";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
@@ -15,6 +16,7 @@ describe("DefaultOrganizationMetadataService", () => {
let service: DefaultOrganizationMetadataService;
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
let configService: jest.Mocked<ConfigService>;
let platformUtilsService: jest.Mocked<PlatformUtilsService>;
let featureFlagSubject: BehaviorSubject<boolean>;
const mockOrganizationId = newGuid() as OrganizationId;
@@ -33,11 +35,17 @@ describe("DefaultOrganizationMetadataService", () => {
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
platformUtilsService = mock<PlatformUtilsService>();
featureFlagSubject = new BehaviorSubject<boolean>(false);
configService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
platformUtilsService.isSelfHost.mockReturnValue(false);
service = new DefaultOrganizationMetadataService(billingApiService, configService);
service = new DefaultOrganizationMetadataService(
billingApiService,
configService,
platformUtilsService,
);
});
afterEach(() => {
@@ -142,6 +150,24 @@ describe("DefaultOrganizationMetadataService", () => {
expect(result3).toEqual(mockResponse1);
expect(result4).toEqual(mockResponse2);
});
it("calls getOrganizationBillingMetadataVNextSelfHost when feature flag is on and isSelfHost is true", async () => {
platformUtilsService.isSelfHost.mockReturnValue(true);
const mockResponse = createMockMetadataResponse(true, 25);
billingApiService.getOrganizationBillingMetadataVNextSelfHost.mockResolvedValue(
mockResponse,
);
const result = await firstValueFrom(service.getOrganizationMetadata$(mockOrganizationId));
expect(platformUtilsService.isSelfHost).toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadataVNextSelfHost).toHaveBeenCalledWith(
mockOrganizationId,
);
expect(billingApiService.getOrganizationBillingMetadataVNext).not.toHaveBeenCalled();
expect(billingApiService.getOrganizationBillingMetadata).not.toHaveBeenCalled();
expect(result).toEqual(mockResponse);
});
});
describe("shareReplay behavior", () => {

View File

@@ -1,6 +1,7 @@
import { BehaviorSubject, combineLatest, from, Observable, shareReplay, switchMap } from "rxjs";
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
@@ -17,6 +18,7 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private platformUtilsService: PlatformUtilsService,
) {}
private refreshMetadataTrigger = new BehaviorSubject<void>(undefined);
@@ -67,7 +69,9 @@ export class DefaultOrganizationMetadataService implements OrganizationMetadataS
featureFlagEnabled: boolean,
): Promise<OrganizationBillingMetadataResponse> {
return featureFlagEnabled
? await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
? this.platformUtilsService.isSelfHost()
? await this.billingApiService.getOrganizationBillingMetadataVNextSelfHost(organizationId)
: await this.billingApiService.getOrganizationBillingMetadataVNext(organizationId)
: await this.billingApiService.getOrganizationBillingMetadata(organizationId);
}
}

View File

@@ -25,7 +25,7 @@ describe("DefaultSubscriptionPricingService", () => {
let logService: MockProxy<LogService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually,
type: PlanType.FamiliesAnnually2025,
productTier: ProductTierType.Families,
name: "Families (Annually)",
isAnnual: true,

View File

@@ -1,5 +1,6 @@
import {
combineLatest,
combineLatestWith,
from,
map,
Observable,
@@ -141,8 +142,13 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const familiesPlan = plans.data.find((plan) => plan.type === PlanType.FamiliesAnnually)!;
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
)!;
return {
id: PersonalSubscriptionPricingTierIds.Families,

View File

@@ -13,9 +13,10 @@ export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
/* Auth */
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
@@ -31,6 +32,8 @@ export enum FeatureFlag {
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
PM23341_Milestone_2 = "pm-23341-milestone-2",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
@@ -41,6 +44,7 @@ export enum FeatureFlag {
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
@@ -58,6 +62,8 @@ export enum FeatureFlag {
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
@@ -85,6 +91,7 @@ export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
@@ -106,9 +113,11 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
/* Auth */
[FeatureFlag.PM22110_DisableAlternateLoginMethods]: FALSE,
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
@@ -120,6 +129,8 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
@@ -130,6 +141,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,

View File

@@ -1,6 +1,4 @@
import { AuthService } from "../../auth/abstractions/auth.service";
export abstract class ProcessReloadServiceAbstraction {
abstract startProcessReload(authService: AuthService): Promise<void>;
abstract startProcessReload(): Promise<void>;
abstract cancelProcessReload(): void;
}

View File

@@ -1,14 +1,15 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { firstValueFrom, map, Observable, Subject } from "rxjs";
import { firstValueFrom, map, Observable, Subject, switchMap } 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 { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
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 { KeyService } from "@bitwarden/key-management";
import { AccountService } from "../../../auth/abstractions/account.service";
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices-api.service.abstraction";
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
@@ -33,6 +34,7 @@ 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 { RotateableKeySet } from "../../keys/models/rotateable-key-set";
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
/** Uses disk storage so that the device key can persist after log out and tab removal. */
@@ -86,10 +88,18 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
private userDecryptionOptionsService: UserDecryptionOptionsServiceAbstraction,
private logService: LogService,
private configService: ConfigService,
private accountService: AccountService,
) {
this.supportsDeviceTrust$ = this.userDecryptionOptionsService.userDecryptionOptions$.pipe(
map((options) => {
return options?.trustedDeviceOption != null;
this.supportsDeviceTrust$ = this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (account == null) {
return [false];
}
return this.userDecryptionOptionsService.userDecryptionOptionsById$(account.id).pipe(
map((options) => {
return options?.trustedDeviceOption != null;
}),
);
}),
);
}
@@ -145,7 +155,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
// Attempt to get user key
const userKey: UserKey = await this.keyService.getUserKey(userId);
const userKey = await firstValueFrom(this.keyService.userKey$(userId));
// If user key is not found, throw error
if (!userKey) {
@@ -240,7 +250,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
const request = new OtherDeviceKeysUpdateRequest();
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
request.encryptedUserKey = newRotateableKeySet.encapsulatedDownstreamKey.encryptedString;
request.deviceId = device.id;
return request;
})

View File

@@ -366,7 +366,6 @@ describe("deviceTrustService", () => {
let makeDeviceKeySpy: jest.SpyInstance;
let rsaGenerateKeyPairSpy: jest.SpyInstance;
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
@@ -402,6 +401,8 @@ describe("deviceTrustService", () => {
"mockDeviceKeyEncryptedDevicePrivateKey",
);
keyService.userKey$.mockReturnValue(of(mockUserKey));
// TypeScript will allow calling private methods if the object is of type 'any'
makeDeviceKeySpy = jest
.spyOn(deviceTrustService as any, "makeDeviceKey")
@@ -411,10 +412,6 @@ describe("deviceTrustService", () => {
.spyOn(cryptoFunctionService, "rsaGenerateKeyPair")
.mockResolvedValue(mockDeviceRsaKeyPair);
cryptoSvcGetUserKeySpy = jest
.spyOn(keyService, "getUserKey")
.mockResolvedValue(mockUserKey);
cryptoSvcRsaEncryptSpy = jest
.spyOn(encryptService, "encapsulateKeyUnsigned")
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
@@ -448,7 +445,7 @@ describe("deviceTrustService", () => {
expect(makeDeviceKeySpy).toHaveBeenCalledTimes(1);
expect(rsaGenerateKeyPairSpy).toHaveBeenCalledTimes(1);
expect(cryptoSvcGetUserKeySpy).toHaveBeenCalledTimes(1);
expect(keyService.userKey$).toHaveBeenCalledTimes(1);
expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1);
@@ -473,18 +470,13 @@ describe("deviceTrustService", () => {
});
it("throws specific error if user key is not found", async () => {
// setup the spy to return null
cryptoSvcGetUserKeySpy.mockResolvedValue(null);
keyService.userKey$.mockReturnValueOnce(of(null));
// check if the expected error is thrown
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
);
// reset the spy
cryptoSvcGetUserKeySpy.mockReset();
// setup the spy to return undefined
cryptoSvcGetUserKeySpy.mockResolvedValue(undefined);
keyService.userKey$.mockReturnValueOnce(of(undefined));
// check if the expected error is thrown
await expect(deviceTrustService.trustDevice(mockUserId)).rejects.toThrow(
"User symmetric key not found",
@@ -502,11 +494,6 @@ describe("deviceTrustService", () => {
spy: () => rsaGenerateKeyPairSpy,
errorText: "rsaGenerateKeyPair error",
},
{
method: "getUserKey",
spy: () => cryptoSvcGetUserKeySpy,
errorText: "getUserKey error",
},
{
method: "rsaEncrypt",
spy: () => cryptoSvcRsaEncryptSpy,
@@ -927,7 +914,7 @@ describe("deviceTrustService", () => {
platformUtilsService.supportsSecureStorage.mockReturnValue(supportsSecureStorage);
decryptionOptions.next({} as any);
userDecryptionOptionsService.userDecryptionOptions$ = decryptionOptions;
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(decryptionOptions);
return new DeviceTrustService(
keyGenerationService,
@@ -943,6 +930,7 @@ describe("deviceTrustService", () => {
userDecryptionOptionsService,
logService,
configService,
accountService,
);
}
});

View File

@@ -0,0 +1,34 @@
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { PrfKey } from "../../../types/key";
import { EncString } from "../../crypto/models/enc-string";
declare const tag: unique symbol;
/**
* A set of keys where a symmetric `DownstreamKey` is protected by an encrypted public/private key-pair.
* The `DownstreamKey` is used to encrypt/decrypt data, while the public/private key-pair is
* used to rotate the `DownstreamKey`.
*
* The `PrivateKey` is protected by an `UpstreamKey`, such as a `DeviceKey`, or `PrfKey`,
* and the `PublicKey` is protected by the `DownstreamKey`. This setup allows:
*
* - Access to `DownstreamKey` by knowing the `UpstreamKey`
* - Rotation to a new `DownstreamKey` by knowing the current `DownstreamKey`,
* without needing access to the `UpstreamKey`
*/
export class RotateableKeySet<UpstreamKey extends SymmetricCryptoKey = SymmetricCryptoKey> {
private readonly [tag]!: UpstreamKey;
constructor(
/** `DownstreamKey` protected by publicKey */
readonly encapsulatedDownstreamKey: EncString,
/** DownstreamKey encrypted PublicKey */
readonly encryptedPublicKey: EncString,
/** UpstreamKey encrypted PrivateKey */
readonly encryptedPrivateKey?: EncString,
) {}
}
export type PrfKeySet = RotateableKeySet<PrfKey>;

View File

@@ -0,0 +1,30 @@
import { SymmetricCryptoKey } from "../../../../platform/models/domain/symmetric-crypto-key";
import { RotateableKeySet } from "../../models/rotateable-key-set";
export abstract class RotateableKeySetService {
/**
* Create a new rotatable key set for the provided downstreamKey, using the provided upstream key.
* For more information on rotatable key sets, see {@link RotateableKeySet}
* @param upstreamKey The `UpstreamKey` used to encrypt {@link RotateableKeySet.encryptedPrivateKey}
* @param downstreamKey The symmetric key to be contained within the `RotateableKeySet`.
* @returns RotateableKeySet containing the provided symmetric downstreamKey.
*/
abstract createKeySet<UpstreamKey extends SymmetricCryptoKey>(
upstreamKey: UpstreamKey,
downstreamKey: SymmetricCryptoKey,
): Promise<RotateableKeySet<UpstreamKey>>;
/**
* Rotates the provided `RotateableKeySet` with the new key.
*
* @param keySet The current `RotateableKeySet` to be rotated.
* @param oldDownstreamKey The current downstreamKey used to decrypt the `PublicKey`.
* @param newDownstreamKey The new downstreamKey to encrypt the `PublicKey`.
* @returns The updated `RotateableKeySet` that contains the new downstreamKey.
*/
abstract rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
keySet: RotateableKeySet<UpstreamKey>,
oldDownstreamKey: SymmetricCryptoKey,
newDownstreamKey: SymmetricCryptoKey,
): Promise<RotateableKeySet<UpstreamKey>>;
}

View File

@@ -0,0 +1,185 @@
import { mock, MockProxy } from "jest-mock-extended";
// 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 { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import { EncString } from "../../crypto/models/enc-string";
import { RotateableKeySet } from "../models/rotateable-key-set";
import { DefaultRotateableKeySetService } from "./default-rotateable-key-set.service";
describe("DefaultRotateableKeySetService", () => {
let keyService!: MockProxy<KeyService>;
let encryptService!: MockProxy<EncryptService>;
let service!: DefaultRotateableKeySetService;
beforeEach(() => {
keyService = mock<KeyService>();
encryptService = mock<EncryptService>();
service = new DefaultRotateableKeySetService(keyService, encryptService);
});
describe("createKeySet", () => {
test.each([null, undefined])(
"throws error when downstreamKey parameter is %s",
async (downstreamKey) => {
const externalKey = createSymmetricKey();
await expect(service.createKeySet(externalKey, downstreamKey as any)).rejects.toThrow(
"failed to create key set: downstreamKey is required",
);
},
);
test.each([null, undefined])(
"throws error when upstreamKey parameter is %s",
async (upstreamKey) => {
const userKey = createSymmetricKey();
await expect(service.createKeySet(upstreamKey as any, userKey)).rejects.toThrow(
"failed to create key set: upstreamKey is required",
);
},
);
it("should create a new key set", async () => {
const externalKey = createSymmetricKey();
const userKey = createSymmetricKey();
const encryptedUserKey = new EncString("encryptedUserKey");
const encryptedPublicKey = new EncString("encryptedPublicKey");
const encryptedPrivateKey = new EncString("encryptedPrivateKey");
keyService.makeKeyPair.mockResolvedValue(["publicKey", encryptedPrivateKey]);
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedUserKey);
encryptService.wrapEncapsulationKey.mockResolvedValue(encryptedPublicKey);
const result = await service.createKeySet(externalKey, userKey);
expect(result).toEqual(
new RotateableKeySet(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey),
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(externalKey);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
userKey,
Utils.fromB64ToArray("publicKey"),
);
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(
Utils.fromB64ToArray("publicKey"),
userKey,
);
});
});
describe("rotateKeySet", () => {
const keySet = new RotateableKeySet(
new EncString("encUserKey"),
new EncString("encPublicKey"),
new EncString("encPrivateKey"),
);
const dataValidationTests = [
{
keySet: null as any as RotateableKeySet,
oldDownstreamKey: createSymmetricKey(),
newDownstreamKey: createSymmetricKey(),
expectedError: "failed to rotate key set: keySet is required",
},
{
keySet: undefined as any as RotateableKeySet,
oldDownstreamKey: createSymmetricKey(),
newDownstreamKey: createSymmetricKey(),
expectedError: "failed to rotate key set: keySet is required",
},
{
keySet: keySet,
oldDownstreamKey: null,
newDownstreamKey: createSymmetricKey(),
expectedError: "failed to rotate key set: oldDownstreamKey is required",
},
{
keySet: keySet,
oldDownstreamKey: undefined,
newDownstreamKey: createSymmetricKey(),
expectedError: "failed to rotate key set: oldDownstreamKey is required",
},
{
keySet: keySet,
oldDownstreamKey: createSymmetricKey(),
newDownstreamKey: null,
expectedError: "failed to rotate key set: newDownstreamKey is required",
},
{
keySet: keySet,
oldDownstreamKey: createSymmetricKey(),
newDownstreamKey: undefined,
expectedError: "failed to rotate key set: newDownstreamKey is required",
},
];
test.each(dataValidationTests)(
"should throw error when required parameter is missing",
async ({ keySet, oldDownstreamKey, newDownstreamKey, expectedError }) => {
await expect(
service.rotateKeySet(keySet, oldDownstreamKey as any, newDownstreamKey as any),
).rejects.toThrow(expectedError);
},
);
it("throws an error if the public key cannot be decrypted", async () => {
const oldDownstreamKey = createSymmetricKey();
const newDownstreamKey = createSymmetricKey();
encryptService.unwrapEncapsulationKey.mockResolvedValue(null as any);
await expect(
service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey),
).rejects.toThrow("failed to rotate key set: could not decrypt public key");
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
keySet.encryptedPublicKey,
oldDownstreamKey,
);
expect(encryptService.wrapEncapsulationKey).not.toHaveBeenCalled();
expect(encryptService.encapsulateKeyUnsigned).not.toHaveBeenCalled();
});
it("rotates the key set", async () => {
const oldDownstreamKey = createSymmetricKey();
const newDownstreamKey = new SymmetricCryptoKey(new Uint8Array(64));
const publicKey = Utils.fromB64ToArray("decryptedPublicKey");
const newEncryptedPublicKey = new EncString("newEncPublicKey");
const newEncryptedRotateableKey = new EncString("newEncUserKey");
encryptService.unwrapEncapsulationKey.mockResolvedValue(publicKey);
encryptService.wrapEncapsulationKey.mockResolvedValue(newEncryptedPublicKey);
encryptService.encapsulateKeyUnsigned.mockResolvedValue(newEncryptedRotateableKey);
const result = await service.rotateKeySet(keySet, oldDownstreamKey, newDownstreamKey);
expect(result).toEqual(
new RotateableKeySet(
newEncryptedRotateableKey,
newEncryptedPublicKey,
keySet.encryptedPrivateKey,
),
);
expect(encryptService.unwrapEncapsulationKey).toHaveBeenCalledWith(
keySet.encryptedPublicKey,
oldDownstreamKey,
);
expect(encryptService.wrapEncapsulationKey).toHaveBeenCalledWith(publicKey, newDownstreamKey);
expect(encryptService.encapsulateKeyUnsigned).toHaveBeenCalledWith(
newDownstreamKey,
publicKey,
);
});
});
});
function createSymmetricKey() {
const key = Utils.fromB64ToArray(
"1h-TuPwSbX5qoX0aVgjmda_Lfq85qAcKssBlXZnPIsQC3HNDGIecunYqXhJnp55QpdXRh-egJiLH3a0wqlVQsQ",
);
return new SymmetricCryptoKey(key);
}

View File

@@ -0,0 +1,83 @@
// 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 { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
import { RotateableKeySet } from "../models/rotateable-key-set";
import { RotateableKeySetService } from "./abstractions/rotateable-key-set.service";
export class DefaultRotateableKeySetService implements RotateableKeySetService {
constructor(
private keyService: KeyService,
private encryptService: EncryptService,
) {}
async createKeySet<UpstreamKey extends SymmetricCryptoKey>(
upstreamKey: UpstreamKey,
downstreamKey: SymmetricCryptoKey,
): Promise<RotateableKeySet<UpstreamKey>> {
if (!upstreamKey) {
throw new Error("failed to create key set: upstreamKey is required");
}
if (!downstreamKey) {
throw new Error("failed to create key set: downstreamKey is required");
}
const [publicKey, encryptedPrivateKey] = await this.keyService.makeKeyPair(upstreamKey);
const rawPublicKey = Utils.fromB64ToArray(publicKey);
const encryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
downstreamKey,
rawPublicKey,
);
const encryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
rawPublicKey,
downstreamKey,
);
return new RotateableKeySet(encryptedRotateableKey, encryptedPublicKey, encryptedPrivateKey);
}
async rotateKeySet<UpstreamKey extends SymmetricCryptoKey>(
keySet: RotateableKeySet<UpstreamKey>,
oldDownstreamKey: SymmetricCryptoKey,
newDownstreamKey: SymmetricCryptoKey,
): Promise<RotateableKeySet<UpstreamKey>> {
// validate parameters
if (!keySet) {
throw new Error("failed to rotate key set: keySet is required");
}
if (!oldDownstreamKey) {
throw new Error("failed to rotate key set: oldDownstreamKey is required");
}
if (!newDownstreamKey) {
throw new Error("failed to rotate key set: newDownstreamKey is required");
}
const publicKey = await this.encryptService.unwrapEncapsulationKey(
keySet.encryptedPublicKey,
oldDownstreamKey,
);
if (publicKey == null) {
throw new Error("failed to rotate key set: could not decrypt public key");
}
const newEncryptedPublicKey = await this.encryptService.wrapEncapsulationKey(
publicKey,
newDownstreamKey,
);
const newEncryptedRotateableKey = await this.encryptService.encapsulateKeyUnsigned(
newDownstreamKey,
publicKey,
);
const newRotateableKeySet = new RotateableKeySet<UpstreamKey>(
newEncryptedRotateableKey,
newEncryptedPublicKey,
keySet.encryptedPrivateKey,
);
return newRotateableKeySet;
}
}

View File

@@ -7,7 +7,21 @@ export abstract class MasterPasswordUnlockService {
* Unlocks the user's account using the master password.
* @param masterPassword The master password provided by the user.
* @param userId The ID of the active user.
* @throws If the master password provided is null/undefined/empty.
* @throws If the userId provided is null/undefined.
* @throws if the masterPasswordUnlockData for the user is not found.
* @throws If unwrapping the user key fails.
* @returns the user's decrypted userKey.
*/
abstract unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey>;
/**
* For the given master password and user ID, verifies whether the user can decrypt their user key stored in state.
* @param masterPassword The master password provided by the user.
* @param userId The ID of the active user.
* @throws If the master password provided is null/undefined/empty.
* @throws If the userId provided is null/undefined.
* @returns true if the userKey can be decrypted, false otherwise.
*/
abstract proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean>;
}

View File

@@ -4,6 +4,8 @@ import { of } from "rxjs";
import { newGuid } from "@bitwarden/guid";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { CryptoError } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
import { HashPurpose } from "../../../platform/enums";
@@ -23,6 +25,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
let masterPasswordService: MockProxy<InternalMasterPasswordServiceAbstraction>;
let keyService: MockProxy<KeyService>;
let logService: MockProxy<LogService>;
const mockMasterPassword = "testExample";
const mockUserId = newGuid() as UserId;
@@ -41,8 +44,9 @@ describe("DefaultMasterPasswordUnlockService", () => {
beforeEach(() => {
masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
keyService = mock<KeyService>();
logService = mock<LogService>();
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService);
sut = new DefaultMasterPasswordUnlockService(masterPasswordService, keyService, logService);
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(
of(mockMasterPasswordUnlockData),
@@ -73,7 +77,7 @@ describe("DefaultMasterPasswordUnlockService", () => {
);
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided master password is %s",
"throws when the provided userID is %s",
async (userId) => {
await expect(sut.unlockWithMasterPassword(mockMasterPassword, userId)).rejects.toThrow(
"User ID is required",
@@ -151,4 +155,90 @@ describe("DefaultMasterPasswordUnlockService", () => {
expect(masterPasswordService.setMasterKey).not.toHaveBeenCalled();
});
});
describe("proofOfDecryption", () => {
test.each([null as unknown as string, undefined as unknown as string, ""])(
"throws when the provided master password is %s",
async (masterPassword) => {
await expect(sut.proofOfDecryption(masterPassword, mockUserId)).rejects.toThrow(
"Master password is required",
);
expect(masterPasswordService.masterPasswordUnlockData$).not.toHaveBeenCalled();
expect(
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
).not.toHaveBeenCalled();
},
);
test.each([null as unknown as UserId, undefined as unknown as UserId])(
"throws when the provided userID is %s",
async (userId) => {
await expect(sut.proofOfDecryption(mockMasterPassword, userId)).rejects.toThrow(
"User ID is required",
);
},
);
it("returns false when the user doesn't have masterPasswordUnlockData", async () => {
masterPasswordService.masterPasswordUnlockData$.mockReturnValue(of(null));
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData,
).not.toHaveBeenCalled();
expect(logService.warning).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${mockUserId} returning false.`,
);
});
it("returns true when the master password is correct", async () => {
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(true);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
});
it("returns false when the master password is incorrect", async () => {
const error = new Error("Incorrect password") as CryptoError;
error.name = "CryptoError";
error.variant = "InvalidKey";
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
expect(logService.debug).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${mockUserId} returning false: ${error}`,
);
});
it("returns false when a generic error occurs", async () => {
const error = new Error("Generic error");
masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData.mockRejectedValue(error);
const result = await sut.proofOfDecryption(mockMasterPassword, mockUserId);
expect(result).toBe(false);
expect(masterPasswordService.masterPasswordUnlockData$).toHaveBeenCalledWith(mockUserId);
expect(masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData).toHaveBeenCalledWith(
mockMasterPassword,
mockMasterPasswordUnlockData,
);
expect(logService.error).toHaveBeenCalledWith(
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${mockUserId} returning false: ${error}`,
);
});
});
});

View File

@@ -2,6 +2,8 @@ import { firstValueFrom } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { isCryptoError } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
import { HashPurpose } from "../../../platform/enums";
@@ -14,6 +16,7 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
constructor(
private readonly masterPasswordService: InternalMasterPasswordServiceAbstraction,
private readonly keyService: KeyService,
private readonly logService: LogService,
) {}
async unlockWithMasterPassword(masterPassword: string, userId: UserId): Promise<UserKey> {
@@ -37,6 +40,43 @@ export class DefaultMasterPasswordUnlockService implements MasterPasswordUnlockS
return userKey;
}
async proofOfDecryption(masterPassword: string, userId: UserId): Promise<boolean> {
this.validateInput(masterPassword, userId);
try {
const masterPasswordUnlockData = await firstValueFrom(
this.masterPasswordService.masterPasswordUnlockData$(userId),
);
if (masterPasswordUnlockData == null) {
this.logService.warning(
`[DefaultMasterPasswordUnlockService] No master password unlock data found for user ${userId} returning false.`,
);
return false;
}
const userKey = await this.masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData(
masterPassword,
masterPasswordUnlockData,
);
return userKey != null;
} catch (error) {
// masterPasswordService.unwrapUserKeyFromMasterPasswordUnlockData is expected to throw if the password is incorrect.
// Currently this throws CryptoError:InvalidKey if decrypting the user key fails at all.
if (isCryptoError(error) && error.variant === "InvalidKey") {
this.logService.debug(
`[DefaultMasterPasswordUnlockService] Error during proof of decryption for user ${userId} returning false: ${error}`,
);
} else {
this.logService.error(
`[DefaultMasterPasswordUnlockService] Unexpected error during proof of decryption for user ${userId} returning false: ${error}`,
);
}
return false;
}
}
private validateInput(masterPassword: string, userId: UserId): void {
if (masterPassword == null || masterPassword === "") {
throw new Error("Master password is required");

View File

@@ -25,7 +25,10 @@ export type MasterPasswordSalt = Opaque<string, "MasterPasswordSalt">;
export type MasterKeyWrappedUserKey = Opaque<EncString, "MasterKeyWrappedUserKey">;
/**
* The data required to unlock with the master password.
* Encapsulates the data needed to unlock a vault using a master password.
* It contains the masterKeyWrappedUserKey along with the KDF settings and salt used to derive the master key.
* It is currently backwards compatible to master-key based unlock, but this will not be the case in the future.
* Features relating to master-password-based unlock should use this abstraction.
*/
export class MasterPasswordUnlockData {
constructor(
@@ -66,7 +69,9 @@ export class MasterPasswordUnlockData {
}
/**
* The data required to authenticate with the master password.
* Encapsulates the data required to authenticate using a master password.
* It contains the masterPasswordAuthenticationHash, along with the KDF settings and salt used to derive it.
* The encapsulated abstraction prevents authentication issues resulting from unsynchronized state.
*/
export type MasterPasswordAuthenticationData = {
salt: MasterPasswordSalt;

View File

@@ -30,16 +30,17 @@ export class DefaultProcessReloadService implements ProcessReloadServiceAbstract
private biometricStateService: BiometricStateService,
private accountService: AccountService,
private logService: LogService,
private authService: AuthService,
) {}
async startProcessReload(authService: AuthService): Promise<void> {
async startProcessReload(): Promise<void> {
const accounts = await firstValueFrom(this.accountService.accounts$);
if (accounts != null) {
const keys = Object.keys(accounts);
if (keys.length > 0) {
for (const userId of keys) {
let status = await firstValueFrom(authService.authStatusFor$(userId as UserId));
status = await authService.getAuthStatus(userId);
let status = await firstValueFrom(this.authService.authStatusFor$(userId as UserId));
status = await this.authService.getAuthStatus(userId);
if (status === AuthenticationStatus.Unlocked) {
this.logService.info(
"[Process Reload Service] User unlocked, preventing process reload",

View File

@@ -1,4 +1,3 @@
export abstract class VaultTimeoutService {
abstract checkVaultTimeout(): Promise<void>;
abstract lock(userId?: string): Promise<void>;
}

View File

@@ -8,3 +8,4 @@ export {
VaultTimeoutOption,
VaultTimeoutStringType,
} from "./types/vault-timeout.type";
export { MaximumVaultTimeoutPolicyData } from "./types/maximum-vault-timeout-policy.type";

View File

@@ -53,9 +53,11 @@ describe("VaultTimeoutSettingsService", () => {
policyService = mock<PolicyService>();
userDecryptionOptionsSubject = new BehaviorSubject(null);
userDecryptionOptionsService.userDecryptionOptions$ = userDecryptionOptionsSubject;
userDecryptionOptionsService.hasMasterPassword$ = userDecryptionOptionsSubject.pipe(
map((options) => options?.hasMasterPassword ?? false),
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
);
userDecryptionOptionsService.hasMasterPasswordById$.mockReturnValue(
userDecryptionOptionsSubject.pipe(map((options) => options?.hasMasterPassword ?? false)),
);
userDecryptionOptionsService.userDecryptionOptionsById$.mockReturnValue(
userDecryptionOptionsSubject,
@@ -127,6 +129,23 @@ describe("VaultTimeoutSettingsService", () => {
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
it("should return only LogOut when userId is not provided and there is no active account", async () => {
// Set up accountService to return null for activeAccount
accountService.activeAccount$ = of(null);
pinStateService.isPinSet.mockResolvedValue(false);
biometricStateService.biometricUnlockEnabled$ = of(false);
// Call availableVaultTimeoutActions$ which internally calls userHasMasterPassword without a userId
const result = await firstValueFrom(
vaultTimeoutSettingsService.availableVaultTimeoutActions$(),
);
// Since there's no active account, userHasMasterPassword returns false,
// meaning no master password is available, so Lock should not be available
expect(result).toEqual([VaultTimeoutAction.LogOut]);
expect(result).not.toContain(VaultTimeoutAction.Lock);
});
});
describe("canLock", () => {

View File

@@ -290,14 +290,19 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
}
private async userHasMasterPassword(userId: string): Promise<boolean> {
let resolvedUserId: UserId;
if (userId) {
const decryptionOptions = await firstValueFrom(
this.userDecryptionOptionsService.userDecryptionOptionsById$(userId),
);
return !!decryptionOptions?.hasMasterPassword;
resolvedUserId = userId as UserId;
} else {
return await firstValueFrom(this.userDecryptionOptionsService.hasMasterPassword$);
const activeAccount = await firstValueFrom(this.accountService.activeAccount$);
if (!activeAccount) {
return false; // No account, can't have master password
}
resolvedUserId = activeAccount.id;
}
return await firstValueFrom(
this.userDecryptionOptionsService.hasMasterPasswordById$(resolvedUserId),
);
}
}

View File

@@ -5,31 +5,17 @@ import { BehaviorSubject, from, 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 { CollectionService } from "@bitwarden/admin-console/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 { LogoutService } 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 } from "@bitwarden/key-management";
import { StateService } from "@bitwarden/state";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec";
import { AccountInfo } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { Utils } from "../../../platform/misc/utils";
import { TaskSchedulerService } from "../../../platform/scheduling";
import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
import { VaultTimeout, VaultTimeoutStringType } from "../types/vault-timeout.type";
@@ -38,23 +24,13 @@ import { VaultTimeoutService } from "./vault-timeout.service";
describe("VaultTimeoutService", () => {
let accountService: FakeAccountService;
let masterPasswordService: FakeMasterPasswordService;
let cipherService: MockProxy<CipherService>;
let folderService: MockProxy<FolderService>;
let collectionService: MockProxy<CollectionService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let messagingService: MockProxy<MessagingService>;
let searchService: MockProxy<SearchService>;
let stateService: MockProxy<StateService>;
let tokenService: MockProxy<TokenService>;
let authService: MockProxy<AuthService>;
let vaultTimeoutSettingsService: MockProxy<VaultTimeoutSettingsService>;
let stateEventRunnerService: MockProxy<StateEventRunnerService>;
let taskSchedulerService: MockProxy<TaskSchedulerService>;
let logService: MockProxy<LogService>;
let biometricsService: MockProxy<BiometricsService>;
let lockService: MockProxy<LockService>;
let logoutService: MockProxy<LogoutService>;
let lockedCallback: jest.Mock<Promise<void>, [userId: string]>;
let vaultTimeoutActionSubject: BehaviorSubject<VaultTimeoutAction>;
let availableVaultTimeoutActionsSubject: BehaviorSubject<VaultTimeoutAction[]>;
@@ -65,25 +41,14 @@ describe("VaultTimeoutService", () => {
beforeEach(() => {
accountService = mockAccountServiceWith(userId);
masterPasswordService = new FakeMasterPasswordService();
cipherService = mock();
folderService = mock();
collectionService = mock();
platformUtilsService = mock();
messagingService = mock();
searchService = mock();
stateService = mock();
tokenService = mock();
authService = mock();
vaultTimeoutSettingsService = mock();
stateEventRunnerService = mock();
taskSchedulerService = mock<TaskSchedulerService>();
lockService = mock<LockService>();
logService = mock<LogService>();
biometricsService = mock<BiometricsService>();
logoutService = mock<LogoutService>();
lockedCallback = jest.fn();
vaultTimeoutActionSubject = new BehaviorSubject(VaultTimeoutAction.Lock);
vaultTimeoutSettingsService.getVaultTimeoutActionByUserId$.mockReturnValue(
@@ -94,22 +59,12 @@ describe("VaultTimeoutService", () => {
vaultTimeoutService = new VaultTimeoutService(
accountService,
masterPasswordService,
cipherService,
folderService,
collectionService,
platformUtilsService,
messagingService,
searchService,
stateService,
tokenService,
authService,
vaultTimeoutSettingsService,
stateEventRunnerService,
taskSchedulerService,
logService,
biometricsService,
lockedCallback,
lockService,
logoutService,
);
});
@@ -145,9 +100,6 @@ describe("VaultTimeoutService", () => {
authService.getAuthStatus.mockImplementation((userId) => {
return Promise.resolve(accounts[userId]?.authStatus);
});
tokenService.hasAccessToken$.mockImplementation((userId) => {
return of(accounts[userId]?.isAuthenticated ?? false);
});
vaultTimeoutSettingsService.getVaultTimeoutByUserId$.mockImplementation((userId) => {
return new BehaviorSubject<VaultTimeout>(accounts[userId]?.vaultTimeout);
@@ -203,13 +155,7 @@ describe("VaultTimeoutService", () => {
};
const expectUserToHaveLocked = (userId: string) => {
// This does NOT assert all the things that the lock process does
expect(tokenService.hasAccessToken$).toHaveBeenCalledWith(userId);
expect(vaultTimeoutSettingsService.availableVaultTimeoutActions$).toHaveBeenCalledWith(userId);
expect(stateService.setUserKeyAutoUnlock).toHaveBeenCalledWith(null, { userId: userId });
expect(masterPasswordService.mock.clearMasterKey).toHaveBeenCalledWith(userId);
expect(cipherService.clearCache).toHaveBeenCalledWith(userId);
expect(lockedCallback).toHaveBeenCalledWith(userId);
expect(lockService.lock).toHaveBeenCalledWith(userId);
};
const expectUserToHaveLoggedOut = (userId: string) => {
@@ -217,7 +163,7 @@ describe("VaultTimeoutService", () => {
};
const expectNoAction = (userId: string) => {
expect(lockedCallback).not.toHaveBeenCalledWith(userId);
expect(lockService.lock).not.toHaveBeenCalledWith(userId);
expect(logoutService.logout).not.toHaveBeenCalledWith(userId, "vaultTimeout");
};
@@ -347,12 +293,8 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
expectUserToHaveLocked("2");
// Active users should have additional steps ran
expect(searchService.clearIndex).toHaveBeenCalled();
expect(folderService.clearDecryptedFolderState).toHaveBeenCalled();
expectUserToHaveLoggedOut("3"); // They have chosen logout as their action and it's available, log them out
expectUserToHaveLoggedOut("4"); // They may have had lock as their chosen action but it's not available to them so logout
expectUserToHaveLocked("4"); // They don't have lock available. But this is handled in lock service so we do not check for logout here
});
it("should lock an account if they haven't been active passed their vault timeout even if a view is open when they are not the active user.", async () => {
@@ -392,70 +334,4 @@ describe("VaultTimeoutService", () => {
expectNoAction("1");
});
});
describe("lock", () => {
const setupLock = () => {
setupAccounts(
{
user1: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
user2: {
authStatus: AuthenticationStatus.Unlocked,
isAuthenticated: true,
},
},
{
userId: "user1",
},
);
};
it("should call state event runner with currently active user if no user passed into lock", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", "user1");
});
it("should call locked callback with the locking user if no userID is passed in.", async () => {
setupLock();
await vaultTimeoutService.lock();
expect(lockedCallback).toHaveBeenCalledWith("user1");
});
it("should call state event runner with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(stateEventRunnerService.handleEvent).toHaveBeenCalledWith("lock", user2);
});
it("should call messaging service locked message with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(messagingService.send).toHaveBeenCalledWith("locked", { userId: user2 });
});
it("should call locked callback with user passed into lock", async () => {
setupLock();
const user2 = "user2" as UserId;
await vaultTimeoutService.lock(user2);
expect(lockedCallback).toHaveBeenCalledWith(user2);
});
});
});

View File

@@ -1,32 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { combineLatest, concatMap, filter, firstValueFrom, map, timeout } from "rxjs";
import { combineLatest, concatMap, firstValueFrom } 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 { CollectionService } from "@bitwarden/admin-console/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 { LogoutService } 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 } from "@bitwarden/key-management";
import { LockService, LogoutService } from "@bitwarden/auth/common";
import { AccountService } from "../../../auth/abstractions/account.service";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { TokenService } from "../../../auth/abstractions/token.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import { LogService } from "../../../platform/abstractions/log.service";
import { MessagingService } from "../../../platform/abstractions/messaging.service";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { StateService } from "../../../platform/abstractions/state.service";
import { TaskSchedulerService, ScheduledTaskNames } from "../../../platform/scheduling";
import { StateEventRunnerService } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { CipherService } from "../../../vault/abstractions/cipher.service";
import { FolderService } from "../../../vault/abstractions/folder/folder.service.abstraction";
import { SearchService } from "../../../vault/abstractions/search.service";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { VaultTimeoutSettingsService } from "../abstractions/vault-timeout-settings.service";
import { VaultTimeoutService as VaultTimeoutServiceAbstraction } from "../abstractions/vault-timeout.service";
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
@@ -36,22 +22,12 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
constructor(
private accountService: AccountService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
private cipherService: CipherService,
private folderService: FolderService,
private collectionService: CollectionService,
protected platformUtilsService: PlatformUtilsService,
private messagingService: MessagingService,
private searchService: SearchService,
private stateService: StateService,
private tokenService: TokenService,
private authService: AuthService,
private vaultTimeoutSettingsService: VaultTimeoutSettingsService,
private stateEventRunnerService: StateEventRunnerService,
private taskSchedulerService: TaskSchedulerService,
protected logService: LogService,
private biometricService: BiometricsService,
private lockedCallback: (userId: UserId) => Promise<void> = null,
private lockService: LockService,
private logoutService: LogoutService,
) {
this.taskSchedulerService.registerTaskHandler(
@@ -104,64 +80,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
}
async lock(userId?: UserId): Promise<void> {
await this.biometricService.setShouldAutopromptNow(false);
const lockingUserId =
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
const authed = await firstValueFrom(this.tokenService.hasAccessToken$(lockingUserId));
if (!authed) {
return;
}
const availableActions = await firstValueFrom(
this.vaultTimeoutSettingsService.availableVaultTimeoutActions$(userId),
);
const supportsLock = availableActions.includes(VaultTimeoutAction.Lock);
if (!supportsLock) {
await this.logoutService.logout(userId, "vaultTimeout");
}
// HACK: Start listening for the transition of the locking user from something to the locked state.
// This is very much a hack to ensure that the authentication status to retrievable right after
// it does its work. Particularly the `lockedCallback` and `"locked"` message. Instead
// lockedCallback should be deprecated and people should subscribe and react to `authStatusFor$` themselves.
const lockPromise = firstValueFrom(
this.authService.authStatusFor$(lockingUserId).pipe(
filter((authStatus) => authStatus === AuthenticationStatus.Locked),
timeout({
first: 5_000,
with: () => {
throw new Error("The lock process did not complete in a reasonable amount of time.");
},
}),
),
);
await this.searchService.clearIndex(lockingUserId);
await this.folderService.clearDecryptedFolderState(lockingUserId);
await this.masterPasswordService.clearMasterKey(lockingUserId);
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
await this.cipherService.clearCache(lockingUserId);
await this.stateEventRunnerService.handleEvent("lock", lockingUserId);
// HACK: Sit here and wait for the the auth status to transition to `Locked`
// to ensure the message and lockedCallback will get the correct status
// if/when they call it.
await lockPromise;
this.messagingService.send("locked", { userId: lockingUserId });
if (this.lockedCallback != null) {
await this.lockedCallback(lockingUserId);
}
}
private async shouldLock(
userId: string,
lastActive: Date,
@@ -206,6 +124,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
);
timeoutAction === VaultTimeoutAction.LogOut
? await this.logoutService.logout(userId, "vaultTimeout")
: await this.lock(userId);
: await this.lockService.lock(userId);
}
}

View File

@@ -0,0 +1,6 @@
import { VaultTimeoutAction } from "../enums/vault-timeout-action.enum";
export interface MaximumVaultTimeoutPolicyData {
minutes: number;
action?: VaultTimeoutAction;
}

View File

@@ -3,6 +3,8 @@ import { SecureNoteExport } from "@bitwarden/common/models/export/secure-note.ex
import { CipherType } from "@bitwarden/common/vault/enums";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { SshKeyExport } from "./ssh-key.export";
describe("Cipher Export", () => {
describe("toView", () => {
it.each([[null], [undefined]])(
@@ -41,4 +43,36 @@ describe("Cipher Export", () => {
expect(resultView.deletedDate).toEqual(request.deletedDate);
});
});
describe("SshKeyExport.toView", () => {
const validSshKey = {
privateKey: "PRIVATE_KEY",
publicKey: "PUBLIC_KEY",
keyFingerprint: "FINGERPRINT",
};
it.each([null, undefined, "", " "])("should throw when privateKey is %p", (value) => {
const sshKey = { ...validSshKey, privateKey: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key private key is required.");
});
it.each([null, undefined, "", " "])("should throw when publicKey is %p", (value) => {
const sshKey = { ...validSshKey, publicKey: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key public key is required.");
});
it.each([null, undefined, "", " "])("should throw when keyFingerprint is %p", (value) => {
const sshKey = { ...validSshKey, keyFingerprint: value } as any;
expect(() => SshKeyExport.toView(sshKey)).toThrow("SSH key fingerprint is required.");
});
it("should succeed with valid inputs", () => {
const sshKey = { ...validSshKey };
const result = SshKeyExport.toView(sshKey);
expect(result).toBeDefined();
expect(result?.privateKey).toBe(validSshKey.privateKey);
expect(result?.publicKey).toBe(validSshKey.publicKey);
expect(result?.keyFingerprint).toBe(validSshKey.keyFingerprint);
});
});
});

View File

@@ -16,7 +16,22 @@ export class SshKeyExport {
return req;
}
static toView(req: SshKeyExport, view = new SshKeyView()) {
static toView(req?: SshKeyExport, view = new SshKeyView()): SshKeyView | undefined {
if (req == null) {
return undefined;
}
// Validate required fields
if (!req.privateKey || req.privateKey.trim() === "") {
throw new Error("SSH key private key is required.");
}
if (!req.publicKey || req.publicKey.trim() === "") {
throw new Error("SSH key public key is required.");
}
if (!req.keyFingerprint || req.keyFingerprint.trim() === "") {
throw new Error("SSH key fingerprint is required.");
}
view.privateKey = req.privateKey;
view.publicKey = req.publicKey;
view.keyFingerprint = req.keyFingerprint;

View File

@@ -5,6 +5,6 @@ import { TranslationService } from "./translation.service";
export abstract class I18nService extends TranslationService {
abstract userSetLocale$: Observable<string | undefined>;
abstract locale$: Observable<string>;
abstract setLocale(locale: string): Promise<void>;
abstract setLocale(locale: string | null): Promise<void>;
abstract init(): Promise<void>;
}

View File

@@ -1,14 +1,14 @@
import type { BitwardenClient } from "@bitwarden/sdk-internal";
import type { PasswordManagerClient } from "@bitwarden/sdk-internal";
/**
* Factory for creating SDK clients.
*/
export abstract class SdkClientFactory {
/**
* Creates a new BitwardenClient. Assumes the SDK is already loaded.
* @param args Bitwarden client constructor parameters
* Creates a new Password Manager client. Assumes the SDK is already loaded.
* @param args Password Manager client constructor parameters
*/
abstract createSdkClient(
...args: ConstructorParameters<typeof BitwardenClient>
): Promise<BitwardenClient>;
...args: ConstructorParameters<typeof PasswordManagerClient>
): Promise<PasswordManagerClient>;
}

View File

@@ -1,6 +1,6 @@
import { Observable } from "rxjs";
import { BitwardenClient, Uuid } from "@bitwarden/sdk-internal";
import { PasswordManagerClient, Uuid } from "@bitwarden/sdk-internal";
import { UserId } from "../../../types/guid";
import { Rc } from "../../misc/reference-counting/rc";
@@ -46,7 +46,7 @@ export abstract class SdkService {
* Retrieve a client initialized without a user.
* This client can only be used for operations that don't require a user context.
*/
abstract client$: Observable<BitwardenClient>;
abstract client$: Observable<PasswordManagerClient>;
/**
* Retrieve a client initialized for a specific user.
@@ -64,7 +64,7 @@ export abstract class SdkService {
*
* @param userId The user id for which to retrieve the client
*/
abstract userClient$(userId: UserId): Observable<Rc<BitwardenClient>>;
abstract userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>>;
/**
* This method is used during/after an authentication procedure to set a new client for a specific user.
@@ -75,5 +75,5 @@ export abstract class SdkService {
* @param userId The user id for which to set the client
* @param client The client to set for the user. If undefined, the client will be unset.
*/
abstract setClient(userId: UserId, client: BitwardenClient | undefined): void;
abstract setClient(userId: UserId, client: PasswordManagerClient | undefined): void;
}

View File

@@ -1,2 +1,3 @@
export * from "./ipc-message";
export * from "./ipc.service";
export * from "./ipc-session-repository";

View File

@@ -5,7 +5,8 @@ export interface IpcMessage {
message: SerializedOutgoingMessage;
}
export interface SerializedOutgoingMessage extends Omit<OutgoingMessage, "free" | "payload"> {
export interface SerializedOutgoingMessage
extends Omit<OutgoingMessage, typeof Symbol.dispose | "free" | "payload"> {
payload: number[];
}

View File

@@ -0,0 +1,49 @@
import { FakeActiveUserAccessor, FakeStateProvider } from "../../../spec";
import { UserId } from "../../types/guid";
import { IpcSessionRepository } from "./ipc-session-repository";
describe("IpcSessionRepository", () => {
const userId = "user-id" as UserId;
let stateProvider!: FakeStateProvider;
let repository!: IpcSessionRepository;
beforeEach(() => {
stateProvider = new FakeStateProvider(new FakeActiveUserAccessor(userId));
repository = new IpcSessionRepository(stateProvider);
});
it("returns undefined when empty", async () => {
const result = await repository.get("BrowserBackground");
expect(result).toBeUndefined();
});
it("saves and retrieves a session", async () => {
const session = { some: "data" };
await repository.save("BrowserBackground", session);
const result = await repository.get("BrowserBackground");
expect(result).toEqual(session);
});
it("saves and retrieves a web session", async () => {
const session = { some: "data" };
await repository.save({ Web: { id: 9001 } }, session);
const result = await repository.get({ Web: { id: 9001 } });
expect(result).toEqual(session);
});
it("removes a session", async () => {
const session = { some: "data" };
await repository.save("BrowserBackground", session);
await repository.remove("BrowserBackground");
const result = await repository.get("BrowserBackground");
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,51 @@
import { firstValueFrom, map } from "rxjs";
import { Endpoint, IpcSessionRepository as SdkIpcSessionRepository } from "@bitwarden/sdk-internal";
import { GlobalState, IPC_MEMORY, KeyDefinition, StateProvider } from "../state";
const IPC_SESSIONS = KeyDefinition.record<object, string>(IPC_MEMORY, "ipcSessions", {
deserializer: (value: object) => value,
});
/**
* Implementation of SDK-defined repository interface/trait. Do not use directly.
* All error handling is done by the caller (the SDK).
* For more information see IPC docs.
*
* Interface uses `any` type as defined by the SDK until we get a concrete session type.
*/
export class IpcSessionRepository implements SdkIpcSessionRepository {
private states: GlobalState<Record<string, any>>;
constructor(private stateProvider: StateProvider) {
this.states = this.stateProvider.getGlobal(IPC_SESSIONS);
}
get(endpoint: Endpoint): Promise<any | undefined> {
return firstValueFrom(this.states.state$.pipe(map((s) => s?.[endpointToString(endpoint)])));
}
async save(endpoint: Endpoint, session: any): Promise<void> {
await this.states.update((s) => ({
...s,
[endpointToString(endpoint)]: session,
}));
}
async remove(endpoint: Endpoint): Promise<void> {
await this.states.update((s) => {
const newState = { ...s };
delete newState[endpointToString(endpoint)];
return newState;
});
}
}
function endpointToString(endpoint: Endpoint): string {
if (typeof endpoint === "object" && "Web" in endpoint) {
return `Web(${endpoint.Web.id})`;
}
return endpoint;
}

View File

@@ -689,6 +689,32 @@ describe("Utils Service", () => {
});
});
describe("invalidUrlPatterns", () => {
it("should return false if no invalid patterns are found", () => {
const urlString = "https://www.example.com/api/my/account/status";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(false);
});
it("should return true if an invalid pattern is found", () => {
const urlString = "https://www.example.com/api/%2e%2e/secret";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(true);
});
it("should return true if an invalid pattern is found in a param", () => {
const urlString = "https://www.example.com/api/history?someToken=../secret";
const actual = Utils.invalidUrlPatterns(urlString);
expect(actual).toBe(true);
});
});
describe("getUrl", () => {
it("assumes a http protocol if no protocol is specified", () => {
const urlString = "www.exampleapp.com.au:4000";

View File

@@ -612,6 +612,55 @@ export class Utils {
return path.normalize(decodeURIComponent(denormalizedPath)).replace(/^(\.\.(\/|\\|$))+/, "");
}
/**
* Validates an url checking against invalid patterns
* @param url
* @returns true if invalid patterns found, false if safe
*/
static invalidUrlPatterns(url: string): boolean {
const invalidUrlPatterns = ["..", "%2e", "\\", "%5c"];
const decodedUrl = decodeURIComponent(url.toLocaleLowerCase());
// Check URL for invalidUrl patterns across entire URL
if (invalidUrlPatterns.some((p) => decodedUrl.includes(p))) {
return true;
}
// Check for additional invalid patterns inside URL params
if (decodedUrl.includes("?")) {
const hasInvalidParams = this.validateQueryParameters(decodedUrl);
if (hasInvalidParams) {
return true;
}
}
return false;
}
/**
* Validates query parameters for additional invalid patterns
* @param url - The URL containing query parameters
* @returns true if invalid patterns found, false if safe
*/
private static validateQueryParameters(url: string): boolean {
try {
let queryString: string;
if (url.includes("?")) {
queryString = url.split("?")[1];
} else {
return false;
}
const paramInvalidPatterns = ["/", "%2f", "#", "%23"];
return paramInvalidPatterns.some((p) => queryString.includes(p));
} catch (error) {
throw new Error(`Error validating query parameters: ${error}`);
}
}
private static isMobile(win: Window) {
let mobile = false;
((a) => {

View File

@@ -29,7 +29,7 @@ export class I18nService extends TranslationService implements I18nServiceAbstra
this.locale$ = this.userSetLocale$.pipe(map((locale) => locale ?? this.translationLocale));
}
async setLocale(locale: string): Promise<void> {
async setLocale(locale: string | null): Promise<void> {
await this.translationLocaleState.update(() => locale);
}

View File

@@ -7,13 +7,13 @@ import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
*/
export class DefaultSdkClientFactory implements SdkClientFactory {
/**
* Initializes a Bitwarden client. Assumes the SDK is already loaded.
* @param args Bitwarden client constructor parameters
* @returns A BitwardenClient
* Initializes a Password Manager client. Assumes the SDK is already loaded.
* @param args Password Manager client constructor parameters
* @returns A PasswordManagerClient
*/
async createSdkClient(
...args: ConstructorParameters<typeof sdk.BitwardenClient>
): Promise<sdk.BitwardenClient> {
return Promise.resolve(new sdk.BitwardenClient(...args));
...args: ConstructorParameters<typeof sdk.PasswordManagerClient>
): Promise<sdk.PasswordManagerClient> {
return Promise.resolve(new sdk.PasswordManagerClient(...args));
}
}

View File

@@ -5,7 +5,7 @@ import { SecurityStateService } from "@bitwarden/common/key-management/security-
// 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 { KdfConfigService, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { PasswordManagerClient } from "@bitwarden/sdk-internal";
import {
ObservableTracker,
@@ -109,7 +109,7 @@ describe("DefaultSdkService", () => {
});
describe("given no client override has been set for the user", () => {
let mockClient!: MockProxy<BitwardenClient>;
let mockClient!: MockProxy<PasswordManagerClient>;
beforeEach(() => {
mockClient = createMockClient();
@@ -123,8 +123,8 @@ describe("DefaultSdkService", () => {
});
it("does not create an SDK client when called the second time with same userId", async () => {
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
// Use subjects to ensure the subscription is kept alive
service.userClient$(userId).subscribe(subject_1);
@@ -139,8 +139,8 @@ describe("DefaultSdkService", () => {
});
it("destroys the internal SDK client when all subscriptions are closed", async () => {
const subject_1 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<BitwardenClient> | undefined>(undefined);
const subject_1 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subject_2 = new BehaviorSubject<Rc<PasswordManagerClient> | undefined>(undefined);
const subscription_1 = service.userClient$(userId).subscribe(subject_1);
const subscription_2 = service.userClient$(userId).subscribe(subject_2);
await new Promise(process.nextTick);
@@ -170,7 +170,7 @@ describe("DefaultSdkService", () => {
describe("given overrides are used", () => {
it("does not create a new client and emits the override client when a client override has already been set ", async () => {
const mockClient = mock<BitwardenClient>();
const mockClient = mock<PasswordManagerClient>();
service.setClient(userId, mockClient);
const userClientTracker = new ObservableTracker(service.userClient$(userId), false);
await userClientTracker.pauseUntilReceived(1);
@@ -242,13 +242,14 @@ describe("DefaultSdkService", () => {
});
});
function createMockClient(): MockProxy<BitwardenClient> {
const client = mock<BitwardenClient>();
function createMockClient(): MockProxy<PasswordManagerClient> {
const client = mock<PasswordManagerClient>();
client.crypto.mockReturnValue(mock());
client.platform.mockReturnValue({
state: jest.fn().mockReturnValue(mock()),
load_flags: jest.fn().mockReturnValue(mock()),
free: mock(),
[Symbol.dispose]: jest.fn(),
});
return client;
}

View File

@@ -20,7 +20,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co
// eslint-disable-next-line no-restricted-imports
import { KeyService, KdfConfigService, KdfConfig, KdfType } from "@bitwarden/key-management";
import {
BitwardenClient,
PasswordManagerClient,
ClientSettings,
DeviceType as SdkDeviceType,
TokenProvider,
@@ -70,9 +70,9 @@ class JsTokenProvider implements TokenProvider {
export class DefaultSdkService implements SdkService {
private sdkClientOverrides = new BehaviorSubject<{
[userId: UserId]: Rc<BitwardenClient> | typeof UnsetClient;
[userId: UserId]: Rc<PasswordManagerClient> | typeof UnsetClient;
}>({});
private sdkClientCache = new Map<UserId, Observable<Rc<BitwardenClient>>>();
private sdkClientCache = new Map<UserId, Observable<Rc<PasswordManagerClient>>>();
client$ = this.environmentService.environment$.pipe(
concatMap(async (env) => {
@@ -107,14 +107,14 @@ export class DefaultSdkService implements SdkService {
private userAgent: string | null = null,
) {}
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
return this.sdkClientOverrides.pipe(
takeWhile((clients) => clients[userId] !== UnsetClient, false),
map((clients) => {
if (clients[userId] === UnsetClient) {
throw new Error("Encountered UnsetClient even though it should have been filtered out");
}
return clients[userId] as Rc<BitwardenClient>;
return clients[userId] as Rc<PasswordManagerClient>;
}),
distinctUntilChanged(),
switchMap((clientOverride) => {
@@ -129,7 +129,7 @@ export class DefaultSdkService implements SdkService {
);
}
setClient(userId: UserId, client: BitwardenClient | undefined) {
setClient(userId: UserId, client: PasswordManagerClient | undefined) {
const previousValue = this.sdkClientOverrides.value[userId];
this.sdkClientOverrides.next({
@@ -149,7 +149,7 @@ export class DefaultSdkService implements SdkService {
* @param userId The user id for which to create the client
* @returns An observable that emits the client for the user
*/
private internalClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
private internalClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
const cached = this.sdkClientCache.get(userId);
if (cached !== undefined) {
return cached;
@@ -187,7 +187,7 @@ export class DefaultSdkService implements SdkService {
switchMap(
([env, account, kdfParams, privateKey, userKey, signingKey, orgKeys, securityState]) => {
// Create our own observable to be able to implement clean-up logic
return new Observable<Rc<BitwardenClient>>((subscriber) => {
return new Observable<Rc<PasswordManagerClient>>((subscriber) => {
const createAndInitializeClient = async () => {
if (env == null || kdfParams == null || privateKey == null || userKey == null) {
return undefined;
@@ -214,7 +214,7 @@ export class DefaultSdkService implements SdkService {
return client;
};
let client: Rc<BitwardenClient> | undefined;
let client: Rc<PasswordManagerClient> | undefined;
createAndInitializeClient()
.then((c) => {
client = c === undefined ? undefined : new Rc(c);
@@ -239,7 +239,7 @@ export class DefaultSdkService implements SdkService {
private async initializeClient(
userId: UserId,
client: BitwardenClient,
client: PasswordManagerClient,
account: AccountInfo,
kdfParams: KdfConfig,
privateKey: EncryptedString,
@@ -281,7 +281,7 @@ export class DefaultSdkService implements SdkService {
await this.loadFeatureFlags(client);
}
private async loadFeatureFlags(client: BitwardenClient) {
private async loadFeatureFlags(client: PasswordManagerClient) {
const serverConfig = await firstValueFrom(this.configService.serverConfig$);
const featureFlagMap = new Map(

View File

@@ -1,4 +1,4 @@
import type { BitwardenClient } from "@bitwarden/sdk-internal";
import type { PasswordManagerClient } from "@bitwarden/sdk-internal";
import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
@@ -9,8 +9,8 @@ import { SdkClientFactory } from "../../abstractions/sdk/sdk-client-factory";
*/
export class NoopSdkClientFactory implements SdkClientFactory {
createSdkClient(
...args: ConstructorParameters<typeof BitwardenClient>
): Promise<BitwardenClient> {
...args: ConstructorParameters<typeof PasswordManagerClient>
): Promise<PasswordManagerClient> {
return Promise.reject(new Error("SDK not available"));
}
}

View File

@@ -7,7 +7,7 @@ import {
throwIfEmpty,
} from "rxjs";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { PasswordManagerClient } from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { SdkService, UserNotLoggedInError } from "../abstractions/sdk/sdk.service";
@@ -17,18 +17,18 @@ import { DeepMockProxy, mockDeep } from "./mock-deep";
export class MockSdkService implements SdkService {
private userClients$ = new BehaviorSubject<{
[userId: UserId]: Rc<BitwardenClient> | undefined;
[userId: UserId]: Rc<PasswordManagerClient> | undefined;
}>({});
private _client$ = new BehaviorSubject(mockDeep<BitwardenClient>());
private _client$ = new BehaviorSubject(mockDeep<PasswordManagerClient>());
client$ = this._client$.asObservable();
version$ = new BehaviorSubject("0.0.1-test").asObservable();
userClient$(userId: UserId): Observable<Rc<BitwardenClient>> {
userClient$(userId: UserId): Observable<Rc<PasswordManagerClient>> {
return this.userClients$.pipe(
takeWhile((clients) => clients[userId] !== undefined, false),
map((clients) => clients[userId] as Rc<BitwardenClient>),
map((clients) => clients[userId] as Rc<PasswordManagerClient>),
distinctUntilChanged(),
throwIfEmpty(() => new UserNotLoggedInError(userId)),
);
@@ -42,7 +42,7 @@ export class MockSdkService implements SdkService {
* Returns the non-user scoped client mock.
* This is what is returned by the `client$` observable.
*/
get client(): DeepMockProxy<BitwardenClient> {
get client(): DeepMockProxy<PasswordManagerClient> {
return this._client$.value;
}
@@ -55,7 +55,7 @@ export class MockSdkService implements SdkService {
* @returns A user-scoped mock for the user.
*/
userLogin: (userId: UserId) => {
const client = mockDeep<BitwardenClient>();
const client = mockDeep<PasswordManagerClient>();
this.userClients$.next({
...this.userClients$.getValue(),
[userId]: new Rc(client),

View File

@@ -1588,8 +1588,16 @@ export class ApiService implements ApiServiceAbstraction {
);
apiUrl = Utils.isNullOrWhitespace(apiUrl) ? env.getApiUrl() : apiUrl;
// Prevent directory traversal from malicious paths
const pathParts = path.split("?");
// Check for path traversal patterns from any URL.
const fullUrlPath = apiUrl + pathParts[0] + (pathParts.length > 1 ? `?${pathParts[1]}` : "");
const isInvalidUrl = Utils.invalidUrlPatterns(fullUrlPath);
if (isInvalidUrl) {
throw new Error("The request URL contains dangerous patterns.");
}
// Prevent directory traversal from malicious paths
const requestUrl =
apiUrl + Utils.normalizePath(pathParts[0]) + (pathParts.length > 1 ? `?${pathParts[1]}` : "");

View File

@@ -0,0 +1,88 @@
import type { CipherRiskResult, CipherId } from "@bitwarden/sdk-internal";
import { isPasswordAtRisk } from "./cipher-risk.service";
describe("isPasswordAtRisk", () => {
const mockId = "00000000-0000-0000-0000-000000000000" as unknown as CipherId;
const createRisk = (overrides: Partial<CipherRiskResult> = {}): CipherRiskResult => ({
id: mockId,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
...overrides,
});
describe("exposed password risk", () => {
it.each([
{ value: 5, expected: true, desc: "found with value > 0" },
{ value: 0, expected: false, desc: "found but value is 0" },
])("should return $expected when password is $desc", ({ value, expected }) => {
const risk = createRisk({ exposed_result: { type: "Found", value } });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
it("should return false when password is not checked", () => {
expect(isPasswordAtRisk(createRisk())).toBe(false);
});
});
describe("password reuse risk", () => {
it.each([
{ count: 2, expected: true, desc: "reused (reuse_count > 1)" },
{ count: 1, expected: false, desc: "not reused" },
{ count: undefined, expected: false, desc: "undefined" },
])("should return $expected when reuse_count is $desc", ({ count, expected }) => {
const risk = createRisk({ reuse_count: count });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
describe("password strength risk", () => {
it.each([
{ strength: 0, expected: true },
{ strength: 1, expected: true },
{ strength: 2, expected: true },
{ strength: 3, expected: false },
{ strength: 4, expected: false },
])("should return $expected when password strength is $strength", ({ strength, expected }) => {
const risk = createRisk({ password_strength: strength });
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
describe("multiple risk factors", () => {
it.each<{ desc: string; overrides: Partial<CipherRiskResult>; expected: boolean }>([
{
desc: "exposed and reused",
overrides: {
exposed_result: { type: "Found" as const, value: 3 },
reuse_count: 2,
},
expected: true,
},
{
desc: "reused and weak strength",
overrides: { password_strength: 2, reuse_count: 2 },
expected: true,
},
{
desc: "all three risk factors",
overrides: {
password_strength: 1,
exposed_result: { type: "Found" as const, value: 10 },
reuse_count: 3,
},
expected: true,
},
{
desc: "no risk factors",
overrides: { reuse_count: undefined },
expected: false,
},
])("should return $expected when $desc present", ({ overrides, expected }) => {
const risk = createRisk(overrides);
expect(isPasswordAtRisk(risk)).toBe(expected);
});
});
});

View File

@@ -1,12 +1,10 @@
import type {
CipherRiskResult,
CipherRiskOptions,
ExposedPasswordResult,
PasswordReuseMap,
CipherId,
} from "@bitwarden/sdk-internal";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherView } from "../models/view/cipher.view";
export abstract class CipherRiskService {
@@ -51,5 +49,21 @@ export abstract class CipherRiskService {
abstract buildPasswordReuseMap(ciphers: CipherView[], userId: UserId): Promise<PasswordReuseMap>;
}
// Re-export SDK types for convenience
export type { CipherRiskResult, CipherRiskOptions, ExposedPasswordResult, PasswordReuseMap };
/**
* Evaluates if a password represented by a CipherRiskResult is considered at risk.
*
* A password is considered at risk if any of the following conditions are true:
* - The password has been exposed in data breaches
* - The password is reused across multiple ciphers
* - The password has weak strength (password_strength < 3)
*
* @param risk - The CipherRiskResult to evaluate
* @returns true if the password is at risk, false otherwise
*/
export function isPasswordAtRisk(risk: CipherRiskResult): boolean {
return (
(risk.exposed_result.type === "Found" && risk.exposed_result.value > 0) ||
(risk.reuse_count ?? 1) > 1 ||
risk.password_strength < 3
);
}

View File

@@ -6,6 +6,9 @@ import { CipherView } from "../models/view/cipher.view";
import { CipherViewLike } from "../utils/cipher-view-like-utils";
export abstract class SearchService {
abstract isCipherSearching$: Observable<boolean>;
abstract isSendSearching$: Observable<boolean>;
abstract indexedEntityId$(userId: UserId): Observable<IndexedEntityId | null>;
abstract clearIndex(userId: UserId): Promise<void>;

View File

@@ -1088,6 +1088,7 @@ describe("Cipher DTO", () => {
card: undefined,
secureNote: undefined,
sshKey: undefined,
data: undefined,
favorite: false,
reprompt: SdkCipherRepromptType.None,
organizationUseTotp: true,

View File

@@ -414,13 +414,17 @@ export class Cipher extends Domain implements Decryptable<CipherView> {
creationDate: this.creationDate.toISOString(),
deletedDate: this.deletedDate?.toISOString(),
archivedDate: this.archivedDate?.toISOString(),
reprompt: this.reprompt,
reprompt:
this.reprompt === CipherRepromptType.Password
? CipherRepromptType.Password
: CipherRepromptType.None,
// Initialize all cipher-type-specific properties as undefined
login: undefined,
identity: undefined,
card: undefined,
secureNote: undefined,
sshKey: undefined,
data: undefined,
};
switch (this.type) {

View File

@@ -113,6 +113,12 @@ export class CipherView implements View, InitializerMetadata {
return this.passwordHistory && this.passwordHistory.length > 0;
}
get hasLoginPassword(): boolean {
return (
this.type === CipherType.Login && this.login?.password != null && this.login.password !== ""
);
}
get hasAttachments(): boolean {
return !!this.attachments && this.attachments.length > 0;
}

View File

@@ -94,16 +94,16 @@ export class IdentityView extends ItemView implements SdkIdentityView {
this.lastName != null
) {
let name = "";
if (this.title != null) {
if (!Utils.isNullOrWhitespace(this.title)) {
name += this.title + " ";
}
if (this.firstName != null) {
if (!Utils.isNullOrWhitespace(this.firstName)) {
name += this.firstName + " ";
}
if (this.middleName != null) {
if (!Utils.isNullOrWhitespace(this.middleName)) {
name += this.middleName + " ";
}
if (this.lastName != null) {
if (!Utils.isNullOrWhitespace(this.lastName)) {
name += this.lastName;
}
return name.trim();
@@ -130,14 +130,20 @@ export class IdentityView extends ItemView implements SdkIdentityView {
}
get fullAddressPart2(): string | undefined {
if (this.city == null && this.state == null && this.postalCode == null) {
const hasCity = !Utils.isNullOrWhitespace(this.city);
const hasState = !Utils.isNullOrWhitespace(this.state);
const hasPostalCode = !Utils.isNullOrWhitespace(this.postalCode);
if (!hasCity && !hasState && !hasPostalCode) {
return undefined;
}
const city = this.city || "-";
const city = hasCity ? this.city : "-";
const state = this.state;
const postalCode = this.postalCode || "-";
const postalCode = hasPostalCode ? this.postalCode : "-";
let addressPart2 = city;
if (!Utils.isNullOrWhitespace(state)) {
if (hasState) {
addressPart2 += ", " + state;
}
addressPart2 += ", " + postalCode;

View File

@@ -1,11 +1,11 @@
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { BehaviorSubject, Observable } from "rxjs";
import type { CipherRiskOptions, CipherId, CipherRiskResult } from "@bitwarden/sdk-internal";
import type { CipherRiskOptions, CipherRiskResult } from "@bitwarden/sdk-internal";
import { asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { MockSdkService } from "../../platform/spec/mock-sdk.service";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherService } from "../abstractions/cipher.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
@@ -19,9 +19,9 @@ describe("DefaultCipherRiskService", () => {
let mockCipherService: jest.Mocked<CipherService>;
const mockUserId = "test-user-id" as UserId;
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2";
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3";
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4";
const mockCipherId1 = "cbea34a8-bde4-46ad-9d19-b05001228ab2" as CipherId;
const mockCipherId2 = "cbea34a8-bde4-46ad-9d19-b05001228ab3" as CipherId;
const mockCipherId3 = "cbea34a8-bde4-46ad-9d19-b05001228ab4" as CipherId;
beforeEach(() => {
sdkService = new MockSdkService();
@@ -534,5 +534,56 @@ describe("DefaultCipherRiskService", () => {
// Verify password_reuse_map was called twice (fresh computation each time)
expect(mockCipherRiskClient.password_reuse_map).toHaveBeenCalledTimes(2);
});
it("should wait for a decrypted vault before computing risk", async () => {
const mockClient = sdkService.simulate.userLogin(mockUserId);
const mockCipherRiskClient = mockClient.vault.mockDeep().cipher_risk.mockDeep();
const cipher = new CipherView();
cipher.id = mockCipherId1;
cipher.type = CipherType.Login;
cipher.login = new LoginView();
cipher.login.password = "password1";
// Simulate the observable emitting null (undecrypted vault) first, then the decrypted ciphers
const cipherViewsSubject = new BehaviorSubject<CipherView[] | null>(null);
mockCipherService.cipherViews$.mockReturnValue(
cipherViewsSubject as Observable<CipherView[]>,
);
mockCipherRiskClient.password_reuse_map.mockReturnValue({});
mockCipherRiskClient.compute_risk.mockResolvedValue([
{
id: mockCipherId1 as any,
password_strength: 4,
exposed_result: { type: "NotChecked" },
reuse_count: 1,
},
]);
// Initiate the async call but don't await yet
const computePromise = cipherRiskService.computeCipherRiskForUser(
asUuid<CipherId>(mockCipherId1),
mockUserId,
true,
);
// Simulate a tick to allow the service to process the null emission
await new Promise((resolve) => setTimeout(resolve, 0));
// Now emit the actual decrypted ciphers
cipherViewsSubject.next([cipher]);
const result = await computePromise;
expect(mockCipherRiskClient.compute_risk).toHaveBeenCalledWith(
[expect.objectContaining({ password: "password1" })],
{
passwordMap: expect.any(Object),
checkExposed: true,
},
);
expect(result).toEqual(expect.objectContaining({ id: expect.anything() }));
});
});
});

View File

@@ -1,16 +1,17 @@
import { firstValueFrom, switchMap } from "rxjs";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import {
CipherLoginDetails,
CipherRiskOptions,
PasswordReuseMap,
CipherId,
CipherRiskResult,
CipherId as SdkCipherId,
} from "@bitwarden/sdk-internal";
import { SdkService, asUuid } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { UserId, CipherId } from "../../types/guid";
import { CipherRiskService as CipherRiskServiceAbstraction } from "../abstractions/cipher-risk.service";
import { CipherType } from "../enums/cipher-type";
import { CipherView } from "../models/view/cipher.view";
@@ -52,7 +53,9 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
checkExposed: boolean = true,
): Promise<CipherRiskResult> {
// Get all ciphers for the user
const allCiphers = await firstValueFrom(this.cipherService.cipherViews$(userId));
const allCiphers = await firstValueFrom(
this.cipherService.cipherViews$(userId).pipe(filterOutNullish()),
);
// Find the specific cipher
const targetCipher = allCiphers?.find((c) => asUuid<CipherId>(c.id) === cipherId);
@@ -106,7 +109,7 @@ export class DefaultCipherRiskService implements CipherRiskServiceAbstraction {
.map(
(cipher) =>
({
id: asUuid<CipherId>(cipher.id),
id: asUuid<SdkCipherId>(cipher.id),
password: cipher.login.password!,
username: cipher.login.username,
}) satisfies CipherLoginDetails,

View File

@@ -1,7 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import * as lunr from "lunr";
import { Observable, firstValueFrom, map } from "rxjs";
import { BehaviorSubject, Observable, firstValueFrom, map } from "rxjs";
import { Jsonify } from "type-fest";
import { perUserCache$ } from "@bitwarden/common/vault/utils/observable-utilities";
@@ -81,6 +81,12 @@ export class SearchService implements SearchServiceAbstraction {
private readonly defaultSearchableMinLength: number = 2;
private searchableMinLength: number = this.defaultSearchableMinLength;
private _isCipherSearching$ = new BehaviorSubject<boolean>(false);
isCipherSearching$: Observable<boolean> = this._isCipherSearching$.asObservable();
private _isSendSearching$ = new BehaviorSubject<boolean>(false);
isSendSearching$: Observable<boolean> = this._isSendSearching$.asObservable();
constructor(
private logService: LogService,
private i18nService: I18nService,
@@ -223,6 +229,7 @@ export class SearchService implements SearchServiceAbstraction {
filter: ((cipher: C) => boolean) | ((cipher: C) => boolean)[] = null,
ciphers: C[],
): Promise<C[]> {
this._isCipherSearching$.next(true);
const results: C[] = [];
const searchStartTime = performance.now();
if (query != null) {
@@ -243,6 +250,7 @@ export class SearchService implements SearchServiceAbstraction {
}
if (!(await this.isSearchable(userId, query))) {
this._isCipherSearching$.next(false);
return ciphers;
}
@@ -258,6 +266,7 @@ export class SearchService implements SearchServiceAbstraction {
// Fall back to basic search if index is not available
const basicResults = this.searchCiphersBasic(ciphers, query);
this.logService.measure(searchStartTime, "Vault", "SearchService", "basic search complete");
this._isCipherSearching$.next(false);
return basicResults;
}
@@ -293,6 +302,7 @@ export class SearchService implements SearchServiceAbstraction {
});
}
this.logService.measure(searchStartTime, "Vault", "SearchService", "search complete");
this._isCipherSearching$.next(false);
return results;
}
@@ -335,8 +345,10 @@ export class SearchService implements SearchServiceAbstraction {
}
searchSends(sends: SendView[], query: string) {
this._isSendSearching$.next(true);
query = SearchService.normalizeSearchQuery(query.trim().toLocaleLowerCase());
if (query === null) {
this._isSendSearching$.next(false);
return sends;
}
const sendsMatched: SendView[] = [];
@@ -359,6 +371,7 @@ export class SearchService implements SearchServiceAbstraction {
lowPriorityMatched.push(s);
}
});
this._isSendSearching$.next(false);
return sendsMatched.concat(lowPriorityMatched);
}

View File

@@ -51,10 +51,10 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
useAccessIntelligence: false,
},
{
useRiskInsights: true,
useAccessIntelligence: true,
},
] as Organization[]),
);
@@ -70,10 +70,10 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
useAccessIntelligence: false,
},
{
useRiskInsights: false,
useAccessIntelligence: false,
},
] as Organization[]),
);
@@ -91,7 +91,7 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: true,
useAccessIntelligence: true,
},
] as Organization[]),
);
@@ -101,7 +101,7 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
useAccessIntelligence: false,
},
] as Organization[]),
);
@@ -163,7 +163,7 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: true,
useAccessIntelligence: true,
},
] as Organization[]),
);
@@ -173,7 +173,7 @@ describe("Default task service", () => {
mockGetAllOrgs$.mockReturnValue(
new BehaviorSubject([
{
useRiskInsights: false,
useAccessIntelligence: false,
},
] as Organization[]),
);

View File

@@ -48,7 +48,7 @@ export class DefaultTaskService implements TaskService {
tasksEnabled$ = perUserCache$((userId) => {
return this.organizationService.organizations$(userId).pipe(
map((orgs) => orgs.some((o) => o.useRiskInsights)),
map((orgs) => orgs.some((o) => o.useAccessIntelligence)),
distinctUntilChanged(),
);
});

View File

@@ -0,0 +1,109 @@
import { BehaviorSubject } from "rxjs";
import { skeletonLoadingDelay } from "./skeleton-loading.operator";
describe("skeletonLoadingDelay", () => {
beforeEach(() => {
jest.useFakeTimers();
});
afterEach(() => {
jest.clearAllTimers();
jest.useRealTimers();
});
it("returns false immediately when starting with false", () => {
const source$ = new BehaviorSubject<boolean>(false);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([false]);
});
it("waits 1 second before returning true when starting with true", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
expect(results).toEqual([]);
jest.advanceTimersByTime(999);
expect(results).toEqual([]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true]);
});
it("cancels if source becomes false before show delay completes", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
source$.next(false);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([false]);
});
it("delays hiding if minimum display time has not elapsed", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1000);
expect(results).toEqual([true, false]);
});
it("handles rapid true->false->true transitions", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay()).subscribe((value) => results.push(value));
jest.advanceTimersByTime(500);
expect(results).toEqual([]);
source$.next(false);
expect(results).toEqual([false]);
source$.next(true);
jest.advanceTimersByTime(999);
expect(results).toEqual([false]);
jest.advanceTimersByTime(1);
expect(results).toEqual([false, true]);
});
it("allows for custom timings", () => {
const source$ = new BehaviorSubject<boolean>(true);
const results: boolean[] = [];
source$.pipe(skeletonLoadingDelay(1000, 2000)).subscribe((value) => results.push(value));
jest.advanceTimersByTime(1000);
expect(results).toEqual([true]);
source$.next(false);
jest.advanceTimersByTime(1999);
expect(results).toEqual([true]);
jest.advanceTimersByTime(1);
expect(results).toEqual([true, false]);
});
});

View File

@@ -0,0 +1,59 @@
import { defer, Observable, of, timer } from "rxjs";
import { map, switchMap, tap } from "rxjs/operators";
/**
* RxJS operator that adds skeleton loading delay behavior.
*
* - Waits 1 second before showing (prevents flashing for quick loads)
* - Ensures skeleton stays visible for at least 1 second once shown regardless of the source observable emissions
* - After the minimum display time, if the source is still true, continues to emit true until the source becomes false
* - False can only be emitted either:
* - Immediately when the source emits false before the skeleton is shown
* - After the minimum display time has passed once the skeleton is shown
*/
export function skeletonLoadingDelay(
showDelay = 1000,
minDisplayTime = 1000,
): (source: Observable<boolean>) => Observable<boolean> {
return (source: Observable<boolean>) => {
return defer(() => {
let skeletonShownAt: number | null = null;
return source.pipe(
switchMap((shouldShow): Observable<boolean> => {
if (shouldShow) {
if (skeletonShownAt !== null) {
return of(true); // Already shown, continue showing
}
// Wait for delay, then mark the skeleton as shown and emit true
return timer(showDelay).pipe(
tap(() => {
skeletonShownAt = Date.now();
}),
map(() => true),
);
} else {
if (skeletonShownAt === null) {
// Skeleton not shown yet, can emit false immediately
return of(false);
}
// Skeleton shown, ensure minimum display time has passed
const elapsedTime = Date.now() - skeletonShownAt;
const remainingTime = Math.max(0, minDisplayTime - elapsedTime);
// Wait for remaining time to ensure minimum display time
return timer(remainingTime).pipe(
tap(() => {
// Reset the shown timestamp
skeletonShownAt = null;
}),
map(() => false),
);
}
}),
);
});
};
}