mirror of
https://github.com/bitwarden/browser
synced 2026-02-14 23:45:37 +00:00
Merge branch 'main' into auth/pm-26209/bugfix-desktop-error-on-auth-request-approval
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("Organization", () => {
|
||||
useSecretsManager: true,
|
||||
usePasswordManager: true,
|
||||
useActivateAutofillPolicy: false,
|
||||
useAutomaticUserConfirmation: false,
|
||||
selfHost: false,
|
||||
usersGetPremium: false,
|
||||
seats: 10,
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) &&
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
CreateDefaultLocation = "pm-19467-create-default-location",
|
||||
AutoConfirm = "pm-19934-auto-confirm-organization-users",
|
||||
BlockClaimedDomainAccountCreation = "block-claimed-domain-account-creation",
|
||||
|
||||
/* Auth */
|
||||
PM22110_DisableAlternateLoginMethods = "pm-22110-disable-alternate-login-methods",
|
||||
@@ -91,6 +92,7 @@ export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.CreateDefaultLocation]: FALSE,
|
||||
[FeatureFlag.AutoConfirm]: FALSE,
|
||||
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./ipc-message";
|
||||
export * from "./ipc.service";
|
||||
export * from "./ipc-session-repository";
|
||||
|
||||
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal file
49
libs/common/src/platform/ipc/ipc-session-repository.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal file
51
libs/common/src/platform/ipc/ipc-session-repository.ts
Normal 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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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]}` : "");
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal file
109
libs/common/src/vault/utils/skeleton-loading.operator.spec.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal file
59
libs/common/src/vault/utils/skeleton-loading.operator.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user