mirror of
https://github.com/bitwarden/browser
synced 2025-12-16 00:03:56 +00:00
Merge branch 'main' into feature/passkey-provider
This commit is contained in:
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
@@ -7,19 +5,23 @@ import { Policy } from "../../models/domain/policy";
|
||||
import { PolicyRequest } from "../../models/request/policy.request";
|
||||
import { PolicyResponse } from "../../models/response/policy.response";
|
||||
|
||||
export class PolicyApiServiceAbstraction {
|
||||
getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
|
||||
getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
|
||||
export abstract class PolicyApiServiceAbstraction {
|
||||
abstract getPolicy: (organizationId: string, type: PolicyType) => Promise<PolicyResponse>;
|
||||
abstract getPolicies: (organizationId: string) => Promise<ListResponse<PolicyResponse>>;
|
||||
|
||||
getPoliciesByToken: (
|
||||
abstract getPoliciesByToken: (
|
||||
organizationId: string,
|
||||
token: string,
|
||||
email: string,
|
||||
organizationUserId: string,
|
||||
) => Promise<Policy[] | undefined>;
|
||||
|
||||
getMasterPasswordPolicyOptsForOrgUser: (
|
||||
abstract getMasterPasswordPolicyOptsForOrgUser: (
|
||||
orgId: string,
|
||||
) => Promise<MasterPasswordPolicyOptions | null>;
|
||||
putPolicy: (organizationId: string, type: PolicyType, request: PolicyRequest) => Promise<any>;
|
||||
abstract putPolicy: (
|
||||
organizationId: string,
|
||||
type: PolicyType,
|
||||
request: PolicyRequest,
|
||||
) => Promise<any>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -11,43 +9,27 @@ import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-p
|
||||
|
||||
export abstract class PolicyService {
|
||||
/**
|
||||
* All policies for the active user from sync data.
|
||||
* All policies for the provided user from sync data.
|
||||
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
|
||||
* Consider using {@link get$} or {@link getAll$} instead, which will only return policies that should be enforced against the user.
|
||||
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
policies$: Observable<Policy[]>;
|
||||
abstract policies$: (userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns the first {@link Policy} found that applies to the active user.
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
* @see {@link getAll$} if you need all policies of a given type
|
||||
* @param userId the {@link UserId} to search against
|
||||
*/
|
||||
get$: (policyType: PolicyType) => Observable<Policy>;
|
||||
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user (or the active user if not specified).
|
||||
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
*/
|
||||
getAll$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* All {@link Policy} objects for the specified user (from sync data).
|
||||
* May include policies that are disabled or otherwise do not apply to the user.
|
||||
* Consider using {@link getAll$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
getAll: (policyType: PolicyType) => Promise<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns true if a policy of the specified type applies to the active user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link getAll$} to get the
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
|
||||
* {@link Policy} objects and then filter by Policy.data.
|
||||
*/
|
||||
policyAppliesToActiveUser$: (policyType: PolicyType) => Observable<boolean>;
|
||||
|
||||
policyAppliesToUser: (policyType: PolicyType) => Promise<boolean>;
|
||||
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
|
||||
|
||||
// Policy specific interfaces
|
||||
|
||||
@@ -56,28 +38,31 @@ export abstract class PolicyService {
|
||||
* @returns a set of options which represent the minimum Master Password settings that the user must
|
||||
* comply with in order to comply with **all** Master Password policies.
|
||||
*/
|
||||
masterPasswordPolicyOptions$: (policies?: Policy[]) => Observable<MasterPasswordPolicyOptions>;
|
||||
abstract masterPasswordPolicyOptions$: (
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
) => Observable<MasterPasswordPolicyOptions | undefined>;
|
||||
|
||||
/**
|
||||
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
|
||||
*/
|
||||
evaluateMasterPassword: (
|
||||
abstract evaluateMasterPassword: (
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* @returns Reset Password policy options for the specified organization and a boolean indicating whether the policy
|
||||
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
|
||||
* is enabled
|
||||
*/
|
||||
getResetPasswordPolicyOptions: (
|
||||
abstract getResetPasswordPolicyOptions: (
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
) => [ResetPasswordPolicyOptions, boolean];
|
||||
}
|
||||
|
||||
export abstract class InternalPolicyService extends PolicyService {
|
||||
upsert: (policy: PolicyData) => Promise<void>;
|
||||
replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
export abstract class vNextPolicyService {
|
||||
/**
|
||||
* All policies for the provided user from sync data.
|
||||
* May include policies that are disabled or otherwise do not apply to the user. Be careful using this!
|
||||
* Consider {@link policiesByType$} instead, which will only return policies that should be enforced against the user.
|
||||
*/
|
||||
abstract policies$: (userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns all {@link Policy} objects of a given type that apply to the specified user.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* @param policyType the {@link PolicyType} to search for
|
||||
* @param userId the {@link UserId} to search against
|
||||
*/
|
||||
abstract policiesByType$: (policyType: PolicyType, userId: UserId) => Observable<Policy[]>;
|
||||
|
||||
/**
|
||||
* @returns true if a policy of the specified type applies to the specified user, otherwise false.
|
||||
* A policy "applies" if it is enabled and the user is not exempt (e.g. because they are an Owner).
|
||||
* This does not take into account the policy's configuration - if that is important, use {@link policiesByType$} to get the
|
||||
* {@link Policy} objects and then filter by Policy.data.
|
||||
*/
|
||||
abstract policyAppliesToUser$: (policyType: PolicyType, userId: UserId) => Observable<boolean>;
|
||||
|
||||
// Policy specific interfaces
|
||||
|
||||
/**
|
||||
* Combines all Master Password policies that apply to the user.
|
||||
* @returns a set of options which represent the minimum Master Password settings that the user must
|
||||
* comply with in order to comply with **all** Master Password policies.
|
||||
*/
|
||||
abstract masterPasswordPolicyOptions$: (
|
||||
userId: UserId,
|
||||
policies?: Policy[],
|
||||
) => Observable<MasterPasswordPolicyOptions | undefined>;
|
||||
|
||||
/**
|
||||
* Evaluates whether a proposed Master Password complies with all Master Password policies that apply to the user.
|
||||
*/
|
||||
abstract evaluateMasterPassword: (
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions?: MasterPasswordPolicyOptions,
|
||||
) => boolean;
|
||||
|
||||
/**
|
||||
* @returns {@link ResetPasswordPolicyOptions} for the specified organization and a boolean indicating whether the policy
|
||||
* is enabled
|
||||
*/
|
||||
abstract getResetPasswordPolicyOptions: (
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
) => [ResetPasswordPolicyOptions, boolean];
|
||||
}
|
||||
|
||||
export abstract class vNextInternalPolicyService extends vNextPolicyService {
|
||||
abstract upsert: (policy: PolicyData, userId: UserId) => Promise<void>;
|
||||
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export enum ProviderType {
|
||||
Msp = 0,
|
||||
Reseller = 1,
|
||||
MultiOrganizationEnterprise = 2,
|
||||
BusinessUnit = 2,
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ describe("ORGANIZATIONS state", () => {
|
||||
familySponsorshipLastSyncDate: new Date(),
|
||||
userIsManagedByOrganization: false,
|
||||
useRiskInsights: false,
|
||||
useAdminSponsoredFamilies: false,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -60,6 +60,7 @@ export class OrganizationData {
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(
|
||||
response?: ProfileOrganizationResponse,
|
||||
@@ -122,6 +123,7 @@ export class OrganizationData {
|
||||
this.allowAdminAccessToAllCollectionItems = response.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
|
||||
this.useRiskInsights = response.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
|
||||
|
||||
this.isMember = options.isMember;
|
||||
this.isProviderUser = options.isProviderUser;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserStatusType,
|
||||
ProviderUserType,
|
||||
} from "../../enums";
|
||||
import { ProfileProviderResponse } from "../response/profile-provider.response";
|
||||
|
||||
export class ProviderData {
|
||||
@@ -10,6 +15,7 @@ export class ProviderData {
|
||||
userId: string;
|
||||
useEvents: boolean;
|
||||
providerStatus: ProviderStatusType;
|
||||
providerType: ProviderType;
|
||||
|
||||
constructor(response: ProfileProviderResponse) {
|
||||
this.id = response.id;
|
||||
@@ -20,5 +26,6 @@ export class ProviderData {
|
||||
this.userId = response.userId;
|
||||
this.useEvents = response.useEvents;
|
||||
this.providerStatus = response.providerStatus;
|
||||
this.providerType = response.providerType;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,10 @@ export class EncryptedOrganizationKey implements BaseEncryptedOrganizationKey {
|
||||
constructor(private key: string) {}
|
||||
|
||||
async decrypt(encryptService: EncryptService, privateKey: UserPrivateKey) {
|
||||
const decValue = await encryptService.rsaDecrypt(this.encryptedOrganizationKey, privateKey);
|
||||
return new SymmetricCryptoKey(decValue) as OrgKey;
|
||||
return (await encryptService.decapsulateKeyUnsigned(
|
||||
this.encryptedOrganizationKey,
|
||||
privateKey,
|
||||
)) as OrgKey;
|
||||
}
|
||||
|
||||
get encryptedOrganizationKey() {
|
||||
|
||||
@@ -90,6 +90,7 @@ export class Organization {
|
||||
*/
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(obj?: OrganizationData) {
|
||||
if (obj == null) {
|
||||
@@ -148,6 +149,7 @@ export class Organization {
|
||||
this.allowAdminAccessToAllCollectionItems = obj.allowAdminAccessToAllCollectionItems;
|
||||
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
|
||||
this.useRiskInsights = obj.useRiskInsights;
|
||||
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
@@ -331,8 +333,7 @@ export class Organization {
|
||||
get hasBillableProvider() {
|
||||
return (
|
||||
this.hasProvider &&
|
||||
(this.providerType === ProviderType.Msp ||
|
||||
this.providerType === ProviderType.MultiOrganizationEnterprise)
|
||||
(this.providerType === ProviderType.Msp || this.providerType === ProviderType.BusinessUnit)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserStatusType,
|
||||
ProviderUserType,
|
||||
} from "../../enums";
|
||||
import { ProviderData } from "../data/provider.data";
|
||||
|
||||
export class Provider {
|
||||
@@ -12,6 +17,7 @@ export class Provider {
|
||||
userId: string;
|
||||
useEvents: boolean;
|
||||
providerStatus: ProviderStatusType;
|
||||
providerType: ProviderType;
|
||||
|
||||
constructor(obj?: ProviderData) {
|
||||
if (obj == null) {
|
||||
@@ -26,6 +32,7 @@ export class Provider {
|
||||
this.userId = obj.userId;
|
||||
this.useEvents = obj.useEvents;
|
||||
this.providerStatus = obj.providerStatus;
|
||||
this.providerType = obj.providerType;
|
||||
}
|
||||
|
||||
get canAccess() {
|
||||
|
||||
@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
|
||||
sponsoredEmail: string;
|
||||
planSponsorshipType: PlanSponsorshipType;
|
||||
friendlyName: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
allowAdminAccessToAllCollectionItems: boolean;
|
||||
userIsManagedByOrganization: boolean;
|
||||
useRiskInsights: boolean;
|
||||
useAdminSponsoredFamilies: boolean;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -121,5 +122,6 @@ export class ProfileOrganizationResponse extends BaseResponse {
|
||||
);
|
||||
this.userIsManagedByOrganization = this.getResponseProperty("UserIsManagedByOrganization");
|
||||
this.useRiskInsights = this.getResponseProperty("UseRiskInsights");
|
||||
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../../enums";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserStatusType,
|
||||
ProviderUserType,
|
||||
} from "../../enums";
|
||||
import { PermissionsApi } from "../api/permissions.api";
|
||||
|
||||
export class ProfileProviderResponse extends BaseResponse {
|
||||
@@ -13,6 +18,7 @@ export class ProfileProviderResponse extends BaseResponse {
|
||||
userId: string;
|
||||
useEvents: boolean;
|
||||
providerStatus: ProviderStatusType;
|
||||
providerType: ProviderType;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
@@ -26,5 +32,6 @@ export class ProfileProviderResponse extends BaseResponse {
|
||||
this.userId = this.getResponseProperty("UserId");
|
||||
this.useEvents = this.getResponseProperty("UseEvents");
|
||||
this.providerStatus = this.getResponseProperty("ProviderStatus");
|
||||
this.providerType = this.getResponseProperty("ProviderType");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,11 +15,11 @@ import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domai
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
import { DefaultvNextPolicyService, getFirstPolicy } from "./default-vnext-policy.service";
|
||||
import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
@@ -27,7 +27,7 @@ describe("PolicyService", () => {
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: DefaultvNextPolicyService;
|
||||
let policyService: DefaultPolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
@@ -59,7 +59,7 @@ describe("PolicyService", () => {
|
||||
|
||||
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
|
||||
|
||||
policyService = new DefaultvNextPolicyService(stateProvider, organizationService);
|
||||
policyService = new DefaultPolicyService(stateProvider, organizationService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
@@ -3,7 +3,7 @@ import { combineLatest, map, Observable, of } from "rxjs";
|
||||
import { StateProvider } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { vNextPolicyService } from "../../abstractions/policy/vnext-policy.service";
|
||||
import { PolicyService } from "../../abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType, PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
@@ -11,7 +11,7 @@ import { Organization } from "../../models/domain/organization";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
import { POLICIES } from "./vnext-policy-state";
|
||||
import { POLICIES } from "./policy-state";
|
||||
|
||||
export function policyRecordToArray(policiesMap: { [id: string]: PolicyData }) {
|
||||
return Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
@@ -21,7 +21,7 @@ export const getFirstPolicy = map<Policy[], Policy | undefined>((policies) => {
|
||||
return policies.at(0) ?? undefined;
|
||||
});
|
||||
|
||||
export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
export class DefaultPolicyService implements PolicyService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
@@ -89,7 +89,7 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
const policies$ = policies ? of(policies) : this.policies$(userId);
|
||||
return policies$.pipe(
|
||||
map((obsPolicies) => {
|
||||
const enforcedOptions: MasterPasswordPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
let enforcedOptions: MasterPasswordPolicyOptions | undefined = undefined;
|
||||
const filteredPolicies =
|
||||
obsPolicies.filter((p) => p.type === PolicyType.MasterPassword) ?? [];
|
||||
|
||||
@@ -102,6 +102,10 @@ export class DefaultvNextPolicyService implements vNextPolicyService {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enforcedOptions) {
|
||||
enforcedOptions = new MasterPasswordPolicyOptions();
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minComplexity != null &&
|
||||
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
|
||||
@@ -1,6 +1,8 @@
|
||||
import { firstValueFrom } from "rxjs";
|
||||
import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "../../../abstractions/api.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../../auth/services/account.service";
|
||||
import { HttpStatusCode } from "../../../enums";
|
||||
import { ErrorResponse } from "../../../models/response/error.response";
|
||||
import { ListResponse } from "../../../models/response/list.response";
|
||||
@@ -18,6 +20,7 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
constructor(
|
||||
private policyService: InternalPolicyService,
|
||||
private apiService: ApiService,
|
||||
private accountService: AccountService,
|
||||
) {}
|
||||
|
||||
async getPolicy(organizationId: string, type: PolicyType): Promise<PolicyResponse> {
|
||||
@@ -93,8 +96,14 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
return null;
|
||||
}
|
||||
|
||||
return await firstValueFrom(
|
||||
this.policyService.masterPasswordPolicyOptions$([masterPasswordPolicy]),
|
||||
return firstValueFrom(
|
||||
this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.masterPasswordPolicyOptions$(userId, [masterPasswordPolicy]),
|
||||
),
|
||||
map((policy) => policy ?? null),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// If policy not found, return null
|
||||
@@ -114,8 +123,9 @@ export class PolicyApiService implements PolicyApiServiceAbstraction {
|
||||
true,
|
||||
true,
|
||||
);
|
||||
const userId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
|
||||
const response = new PolicyResponse(r);
|
||||
const data = new PolicyData(response);
|
||||
await this.policyService.upsert(data);
|
||||
await this.policyService.upsert(data, userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../../spec/fake-state";
|
||||
import {
|
||||
OrganizationUserStatusType,
|
||||
OrganizationUserType,
|
||||
PolicyType,
|
||||
} from "../../../admin-console/enums";
|
||||
import { PermissionsApi } from "../../../admin-console/models/api/permissions.api";
|
||||
import { OrganizationData } from "../../../admin-console/models/data/organization.data";
|
||||
import { PolicyData } from "../../../admin-console/models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../../admin-console/models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../../admin-console/models/domain/organization";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../../admin-console/models/domain/reset-password-policy-options";
|
||||
import { POLICIES, PolicyService } from "../../../admin-console/services/policy/policy.service";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
|
||||
describe("PolicyService", () => {
|
||||
const userId = "userId" as UserId;
|
||||
let stateProvider: FakeStateProvider;
|
||||
let organizationService: MockProxy<OrganizationService>;
|
||||
let activeUserState: FakeActiveUserState<Record<PolicyId, PolicyData>>;
|
||||
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
|
||||
|
||||
let policyService: PolicyService;
|
||||
|
||||
beforeEach(() => {
|
||||
const accountService = mockAccountServiceWith(userId);
|
||||
stateProvider = new FakeStateProvider(accountService);
|
||||
organizationService = mock<OrganizationService>();
|
||||
|
||||
activeUserState = stateProvider.activeUser.getFake(POLICIES);
|
||||
singleUserState = stateProvider.singleUser.getFake(activeUserState.userId, POLICIES);
|
||||
|
||||
const organizations$ = of([
|
||||
// User
|
||||
organization("org1", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Owner
|
||||
organization(
|
||||
"org2",
|
||||
true,
|
||||
true,
|
||||
OrganizationUserStatusType.Confirmed,
|
||||
false,
|
||||
OrganizationUserType.Owner,
|
||||
),
|
||||
// Does not use policies
|
||||
organization("org3", true, false, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org4", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Another User
|
||||
organization("org5", true, true, OrganizationUserStatusType.Confirmed, false),
|
||||
// Can manage policies
|
||||
organization("org6", true, true, OrganizationUserStatusType.Confirmed, true),
|
||||
]);
|
||||
|
||||
organizationService.organizations$.mockReturnValue(organizations$);
|
||||
|
||||
policyService = new PolicyService(stateProvider, organizationService);
|
||||
});
|
||||
|
||||
it("upsert", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.upsert(policyData("99", "test-organization", PolicyType.DisableSend, true));
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$)).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.MaximumVaultTimeout,
|
||||
enabled: true,
|
||||
data: { minutes: 14 },
|
||||
},
|
||||
{
|
||||
id: "99",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replace", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("1", "test-organization", PolicyType.MaximumVaultTimeout, true, { minutes: 14 }),
|
||||
]),
|
||||
);
|
||||
|
||||
await policyService.replace(
|
||||
{
|
||||
"2": policyData("2", "test-organization", PolicyType.DisableSend, true),
|
||||
},
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(await firstValueFrom(policyService.policies$)).toEqual([
|
||||
{
|
||||
id: "2",
|
||||
organizationId: "test-organization",
|
||||
type: PolicyType.DisableSend,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe("masterPasswordPolicyOptions", () => {
|
||||
it("returns default policy options", async () => {
|
||||
const data: any = {
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireUpper: true,
|
||||
};
|
||||
const model = [
|
||||
new Policy(policyData("1", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 5,
|
||||
minLength: 20,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: true,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null", async () => {
|
||||
const data: any = {};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(
|
||||
policyData("4", "test-organization-3", PolicyType.MaximumVaultTimeout, true, data),
|
||||
),
|
||||
];
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual(null);
|
||||
});
|
||||
|
||||
it("returns specified policy options", async () => {
|
||||
const data: any = {
|
||||
minLength: 14,
|
||||
};
|
||||
const model = [
|
||||
new Policy(
|
||||
policyData("3", "test-organization-3", PolicyType.DisablePersonalVaultExport, true, data),
|
||||
),
|
||||
new Policy(policyData("4", "test-organization-3", PolicyType.MasterPassword, true, data)),
|
||||
];
|
||||
|
||||
const result = await firstValueFrom(policyService.masterPasswordPolicyOptions$(model));
|
||||
|
||||
expect(result).toEqual({
|
||||
minComplexity: 0,
|
||||
minLength: 14,
|
||||
requireLower: false,
|
||||
requireNumbers: false,
|
||||
requireSpecial: false,
|
||||
requireUpper: false,
|
||||
enforceOnLogin: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateMasterPassword", () => {
|
||||
it("false", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
enforcedPolicyOptions.minLength = 14;
|
||||
const result = policyService.evaluateMasterPassword(10, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(false);
|
||||
});
|
||||
|
||||
it("true", async () => {
|
||||
const enforcedPolicyOptions = new MasterPasswordPolicyOptions();
|
||||
const result = policyService.evaluateMasterPassword(0, "password", enforcedPolicyOptions);
|
||||
|
||||
expect(result).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getResetPasswordPolicyOptions", () => {
|
||||
it("default", async () => {
|
||||
const result = policyService.getResetPasswordPolicyOptions([], "");
|
||||
|
||||
expect(result).toEqual([new ResetPasswordPolicyOptions(), false]);
|
||||
});
|
||||
|
||||
it("returns autoEnrollEnabled true", async () => {
|
||||
const data: any = {
|
||||
autoEnrollEnabled: true,
|
||||
};
|
||||
const policies = [
|
||||
new Policy(policyData("5", "test-organization-3", PolicyType.ResetPassword, true, data)),
|
||||
];
|
||||
const result = policyService.getResetPasswordPolicyOptions(policies, "test-organization-3");
|
||||
|
||||
expect(result).toEqual([{ autoEnrollEnabled: true }, true]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("get$", () => {
|
||||
it("returns the specified PolicyType", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy3", "org1", PolicyType.RemoveUnlockWithPin, true),
|
||||
]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.ActivateAutofill)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy1",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.ActivateAutofill,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.DisablePersonalVaultExport)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy2",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
});
|
||||
await expect(
|
||||
firstValueFrom(policyService.get$(PolicyType.RemoveUnlockWithPin)),
|
||||
).resolves.toMatchObject({
|
||||
id: "policy3",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.RemoveUnlockWithPin,
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org1", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.get$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, false),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.get$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["owners", "org2"],
|
||||
["administrators", "org6"],
|
||||
])("returns the password generator policy for %s", async (_, organization) => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org1", PolicyType.ActivateAutofill, false),
|
||||
policyData("policy2", organization, PolicyType.PasswordGenerator, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.get$(PolicyType.PasswordGenerator));
|
||||
|
||||
expect(result).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org3", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy2", "org2", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(policyService.get$(PolicyType.ActivateAutofill));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getAll$", () => {
|
||||
it("returns the specified PolicyTypes", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return disabled policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return policies that do not apply to the user because the user's role is exempt", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy3",
|
||||
organizationId: "org5",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not return policies for organizations that do not use policies", async () => {
|
||||
singleUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.getAll$(PolicyType.DisablePersonalVaultExport, activeUserState.userId),
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: "policy1",
|
||||
organizationId: "org4",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
id: "policy4",
|
||||
organizationId: "org1",
|
||||
type: PolicyType.DisablePersonalVaultExport,
|
||||
enabled: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("policyAppliesToActiveUser$", () => {
|
||||
it("returns true when the policyType applies to the user", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy1", "org4", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, true),
|
||||
policyData("policy4", "org1", PolicyType.DisablePersonalVaultExport, true),
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when policyType is disabled", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org5", PolicyType.DisablePersonalVaultExport, false), // disabled
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the policyType does not apply to the user because the user's role is exempt", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy4", "org2", PolicyType.DisablePersonalVaultExport, true), // owner
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for organizations that do not use policies", async () => {
|
||||
activeUserState.nextState(
|
||||
arrayToRecord([
|
||||
policyData("policy2", "org1", PolicyType.ActivateAutofill, true),
|
||||
policyData("policy3", "org3", PolicyType.DisablePersonalVaultExport, true), // does not use policies
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await firstValueFrom(
|
||||
policyService.policyAppliesToActiveUser$(PolicyType.DisablePersonalVaultExport),
|
||||
);
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
function policyData(
|
||||
id: string,
|
||||
organizationId: string,
|
||||
type: PolicyType,
|
||||
enabled: boolean,
|
||||
data?: any,
|
||||
) {
|
||||
const policyData = new PolicyData({} as any);
|
||||
policyData.id = id as PolicyId;
|
||||
policyData.organizationId = organizationId;
|
||||
policyData.type = type;
|
||||
policyData.enabled = enabled;
|
||||
policyData.data = data;
|
||||
|
||||
return policyData;
|
||||
}
|
||||
|
||||
function organizationData(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
const organizationData = new OrganizationData({} as any, {} as any);
|
||||
organizationData.id = id;
|
||||
organizationData.enabled = enabled;
|
||||
organizationData.usePolicies = usePolicies;
|
||||
organizationData.status = status;
|
||||
organizationData.permissions = new PermissionsApi({ managePolicies: managePolicies } as any);
|
||||
organizationData.type = type;
|
||||
return organizationData;
|
||||
}
|
||||
|
||||
function organization(
|
||||
id: string,
|
||||
enabled: boolean,
|
||||
usePolicies: boolean,
|
||||
status: OrganizationUserStatusType,
|
||||
managePolicies: boolean,
|
||||
type: OrganizationUserType = OrganizationUserType.User,
|
||||
) {
|
||||
return new Organization(
|
||||
organizationData(id, enabled, usePolicies, status, managePolicies, type),
|
||||
);
|
||||
}
|
||||
|
||||
function arrayToRecord(input: PolicyData[]): Record<PolicyId, PolicyData> {
|
||||
return Object.fromEntries(input.map((i) => [i.id, i]));
|
||||
}
|
||||
});
|
||||
@@ -1,257 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { UserKeyDefinition, POLICIES_DISK, StateProvider } from "../../../platform/state";
|
||||
import { PolicyId, UserId } from "../../../types/guid";
|
||||
import { OrganizationService } from "../../abstractions/organization/organization.service.abstraction";
|
||||
import { InternalPolicyService as InternalPolicyServiceAbstraction } from "../../abstractions/policy/policy.service.abstraction";
|
||||
import { OrganizationUserStatusType, PolicyType } from "../../enums";
|
||||
import { PolicyData } from "../../models/data/policy.data";
|
||||
import { MasterPasswordPolicyOptions } from "../../models/domain/master-password-policy-options";
|
||||
import { Organization } from "../../models/domain/organization";
|
||||
import { Policy } from "../../models/domain/policy";
|
||||
import { ResetPasswordPolicyOptions } from "../../models/domain/reset-password-policy-options";
|
||||
|
||||
const policyRecordToArray = (policiesMap: { [id: string]: PolicyData }) =>
|
||||
Object.values(policiesMap || {}).map((f) => new Policy(f));
|
||||
|
||||
export const POLICIES = UserKeyDefinition.record<PolicyData, PolicyId>(POLICIES_DISK, "policies", {
|
||||
deserializer: (policyData) => policyData,
|
||||
clearOn: ["logout"],
|
||||
});
|
||||
|
||||
export class PolicyService implements InternalPolicyServiceAbstraction {
|
||||
private activeUserPolicyState = this.stateProvider.getActive(POLICIES);
|
||||
private activeUserPolicies$ = this.activeUserPolicyState.state$.pipe(
|
||||
map((policyData) => policyRecordToArray(policyData)),
|
||||
);
|
||||
|
||||
policies$ = this.activeUserPolicies$;
|
||||
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private organizationService: OrganizationService,
|
||||
) {}
|
||||
|
||||
get$(policyType: PolicyType): Observable<Policy> {
|
||||
const filteredPolicies$ = this.activeUserPolicies$.pipe(
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
const organizations$ = this.stateProvider.activeUserId$.pipe(
|
||||
switchMap((userId) => this.organizationService.organizations$(userId)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, organizations$]).pipe(
|
||||
map(
|
||||
([policies, organizations]) =>
|
||||
this.enforcedPolicyFilter(policies, organizations)?.at(0) ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
getAll$(policyType: PolicyType, userId: UserId) {
|
||||
const filteredPolicies$ = this.stateProvider.getUserState$(POLICIES, userId).pipe(
|
||||
map((policyData) => policyRecordToArray(policyData)),
|
||||
map((policies) => policies.filter((p) => p.type === policyType)),
|
||||
);
|
||||
|
||||
return combineLatest([filteredPolicies$, this.organizationService.organizations$(userId)]).pipe(
|
||||
map(([policies, organizations]) => this.enforcedPolicyFilter(policies, organizations)),
|
||||
);
|
||||
}
|
||||
|
||||
async getAll(policyType: PolicyType) {
|
||||
return await firstValueFrom(
|
||||
this.policies$.pipe(map((policies) => policies.filter((p) => p.type === policyType))),
|
||||
);
|
||||
}
|
||||
|
||||
policyAppliesToActiveUser$(policyType: PolicyType) {
|
||||
return this.get$(policyType).pipe(map((policy) => policy != null));
|
||||
}
|
||||
|
||||
async policyAppliesToUser(policyType: PolicyType) {
|
||||
return await firstValueFrom(this.policyAppliesToActiveUser$(policyType));
|
||||
}
|
||||
|
||||
private enforcedPolicyFilter(policies: Policy[], organizations: Organization[]) {
|
||||
const orgDict = Object.fromEntries(organizations.map((o) => [o.id, o]));
|
||||
return policies.filter((policy) => {
|
||||
const organization = orgDict[policy.organizationId];
|
||||
|
||||
// This shouldn't happen, i.e. the user should only have policies for orgs they are a member of
|
||||
// But if it does, err on the side of enforcing the policy
|
||||
if (organization == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
policy.enabled &&
|
||||
organization.status >= OrganizationUserStatusType.Accepted &&
|
||||
organization.usePolicies &&
|
||||
!this.isExemptFromPolicy(policy.type, organization)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
masterPasswordPolicyOptions$(policies?: Policy[]): Observable<MasterPasswordPolicyOptions> {
|
||||
const observable = policies ? of(policies) : this.policies$;
|
||||
return observable.pipe(
|
||||
map((obsPolicies) => {
|
||||
let enforcedOptions: MasterPasswordPolicyOptions = null;
|
||||
const filteredPolicies = obsPolicies.filter((p) => p.type === PolicyType.MasterPassword);
|
||||
|
||||
if (filteredPolicies == null || filteredPolicies.length === 0) {
|
||||
return enforcedOptions;
|
||||
}
|
||||
|
||||
filteredPolicies.forEach((currentPolicy) => {
|
||||
if (!currentPolicy.enabled || currentPolicy.data == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (enforcedOptions == null) {
|
||||
enforcedOptions = new MasterPasswordPolicyOptions();
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minComplexity != null &&
|
||||
currentPolicy.data.minComplexity > enforcedOptions.minComplexity
|
||||
) {
|
||||
enforcedOptions.minComplexity = currentPolicy.data.minComplexity;
|
||||
}
|
||||
|
||||
if (
|
||||
currentPolicy.data.minLength != null &&
|
||||
currentPolicy.data.minLength > enforcedOptions.minLength
|
||||
) {
|
||||
enforcedOptions.minLength = currentPolicy.data.minLength;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireUpper) {
|
||||
enforcedOptions.requireUpper = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireLower) {
|
||||
enforcedOptions.requireLower = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireNumbers) {
|
||||
enforcedOptions.requireNumbers = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.requireSpecial) {
|
||||
enforcedOptions.requireSpecial = true;
|
||||
}
|
||||
|
||||
if (currentPolicy.data.enforceOnLogin) {
|
||||
enforcedOptions.enforceOnLogin = true;
|
||||
}
|
||||
});
|
||||
|
||||
return enforcedOptions;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
evaluateMasterPassword(
|
||||
passwordStrength: number,
|
||||
newPassword: string,
|
||||
enforcedPolicyOptions: MasterPasswordPolicyOptions,
|
||||
): boolean {
|
||||
if (enforcedPolicyOptions == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minComplexity > 0 &&
|
||||
enforcedPolicyOptions.minComplexity > passwordStrength
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
enforcedPolicyOptions.minLength > 0 &&
|
||||
enforcedPolicyOptions.minLength > newPassword.length
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireUpper && newPassword.toLocaleLowerCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireLower && newPassword.toLocaleUpperCase() === newPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (enforcedPolicyOptions.requireNumbers && !/[0-9]/.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line
|
||||
if (enforcedPolicyOptions.requireSpecial && !/[!@#$%\^&*]/g.test(newPassword)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getResetPasswordPolicyOptions(
|
||||
policies: Policy[],
|
||||
orgId: string,
|
||||
): [ResetPasswordPolicyOptions, boolean] {
|
||||
const resetPasswordPolicyOptions = new ResetPasswordPolicyOptions();
|
||||
|
||||
if (policies == null || orgId == null) {
|
||||
return [resetPasswordPolicyOptions, false];
|
||||
}
|
||||
|
||||
const policy = policies.find(
|
||||
(p) => p.organizationId === orgId && p.type === PolicyType.ResetPassword && p.enabled,
|
||||
);
|
||||
resetPasswordPolicyOptions.autoEnrollEnabled = policy?.data?.autoEnrollEnabled ?? false;
|
||||
|
||||
return [resetPasswordPolicyOptions, policy?.enabled ?? false];
|
||||
}
|
||||
|
||||
async upsert(policy: PolicyData): Promise<void> {
|
||||
await this.activeUserPolicyState.update((policies) => {
|
||||
policies ??= {};
|
||||
policies[policy.id] = policy;
|
||||
return policies;
|
||||
});
|
||||
}
|
||||
|
||||
async replace(policies: { [id: string]: PolicyData }, userId: UserId): Promise<void> {
|
||||
await this.stateProvider.setUserState(POLICIES, policies, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an orgUser is exempt from a specific policy because of their role
|
||||
* Generally orgUsers who can manage policies are exempt from them, but some policies are stricter
|
||||
*/
|
||||
private isExemptFromPolicy(policyType: PolicyType, organization: Organization) {
|
||||
switch (policyType) {
|
||||
case PolicyType.MaximumVaultTimeout:
|
||||
// Max Vault Timeout applies to everyone except owners
|
||||
return organization.isOwner;
|
||||
case PolicyType.PasswordGenerator:
|
||||
// password generation policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.PersonalOwnership:
|
||||
// individual vault policy applies to everyone except admins and owners
|
||||
return organization.isAdmin;
|
||||
case PolicyType.FreeFamiliesSponsorshipPolicy:
|
||||
// free Bitwarden families policy applies to everyone
|
||||
return false;
|
||||
case PolicyType.RemoveUnlockWithPin:
|
||||
// free Remove Unlock with PIN policy applies to everyone
|
||||
return false;
|
||||
default:
|
||||
return organization.canManagePolicies;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,12 @@ import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from ".
|
||||
import { FakeActiveUserState, FakeSingleUserState } from "../../../spec/fake-state";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { UserId } from "../../types/guid";
|
||||
import { ProviderStatusType, ProviderUserStatusType, ProviderUserType } from "../enums";
|
||||
import {
|
||||
ProviderStatusType,
|
||||
ProviderType,
|
||||
ProviderUserStatusType,
|
||||
ProviderUserType,
|
||||
} from "../enums";
|
||||
import { ProviderData } from "../models/data/provider.data";
|
||||
import { Provider } from "../models/domain/provider";
|
||||
|
||||
@@ -67,6 +72,7 @@ describe("PROVIDERS key definition", () => {
|
||||
userId: "string",
|
||||
useEvents: true,
|
||||
providerStatus: ProviderStatusType.Pending,
|
||||
providerType: ProviderType.Msp,
|
||||
},
|
||||
};
|
||||
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// @ts-strict-ignore
|
||||
import { ListResponse } from "../../models/response/list.response";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
@@ -25,10 +24,7 @@ export abstract class DevicesApiServiceAbstraction {
|
||||
deviceIdentifier: string,
|
||||
) => Promise<void>;
|
||||
|
||||
getDeviceKeys: (
|
||||
deviceIdentifier: string,
|
||||
secretVerificationRequest: SecretVerificationRequest,
|
||||
) => Promise<ProtectedDeviceResponse>;
|
||||
getDeviceKeys: (deviceIdentifier: string) => Promise<ProtectedDeviceResponse>;
|
||||
|
||||
/**
|
||||
* Notifies the server that the device has a device key, but didn't receive any associated decryption keys.
|
||||
@@ -42,4 +38,10 @@ export abstract class DevicesApiServiceAbstraction {
|
||||
* @param deviceId - The device ID
|
||||
*/
|
||||
deactivateDevice: (deviceId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Removes trust from a list of devices
|
||||
* @param deviceIds - The device IDs to be untrusted
|
||||
*/
|
||||
untrustDevices: (deviceIds: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@ export class DeviceResponse extends BaseResponse {
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
isTrusted: boolean;
|
||||
encryptedUserKey: string | null;
|
||||
encryptedPublicKey: string | null;
|
||||
|
||||
devicePendingAuthRequest: DevicePendingAuthRequest | null;
|
||||
|
||||
constructor(response: any) {
|
||||
@@ -27,6 +30,8 @@ export class DeviceResponse extends BaseResponse {
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
this.isTrusted = this.getResponseProperty("IsTrusted");
|
||||
this.encryptedUserKey = this.getResponseProperty("EncryptedUserKey");
|
||||
this.encryptedPublicKey = this.getResponseProperty("EncryptedPublicKey");
|
||||
this.devicePendingAuthRequest = this.getResponseProperty("DevicePendingAuthRequest");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export class UntrustDevicesRequestModel {
|
||||
constructor(public devices: string[]) {}
|
||||
}
|
||||
@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
|
||||
}
|
||||
|
||||
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { RotateableKeySet } from "@bitwarden/auth/common";
|
||||
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { BaseResponse } from "../../../models/response/base.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
@@ -38,4 +40,12 @@ export class ProtectedDeviceResponse extends BaseResponse {
|
||||
* This enabled a user to rotate the keys for all of their devices.
|
||||
*/
|
||||
encryptedPublicKey: EncString;
|
||||
|
||||
getRotateableKeyset(): RotateableKeySet {
|
||||
return new RotateableKeySet(this.encryptedUserKey, this.encryptedPublicKey);
|
||||
}
|
||||
|
||||
isTrusted(): boolean {
|
||||
return this.encryptedUserKey != null && this.encryptedPublicKey != null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { AuthRequest } from "@bitwarden/common/auth/models/request/auth.request";
|
||||
import { AuthRequestResponse } from "@bitwarden/common/auth/models/response/auth-request.response";
|
||||
import { View } from "@bitwarden/common/models/view/view";
|
||||
|
||||
export class LoginViaAuthRequestView implements View {
|
||||
authRequest: AuthRequest | undefined = undefined;
|
||||
authRequestResponse: AuthRequestResponse | undefined = undefined;
|
||||
fingerprintPhrase: string | undefined = undefined;
|
||||
id: string | undefined = undefined;
|
||||
accessCode: string | undefined = undefined;
|
||||
privateKey: string | undefined = undefined;
|
||||
publicKey: string | undefined = undefined;
|
||||
|
||||
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView {
|
||||
static fromJSON(obj: Partial<Jsonify<LoginViaAuthRequestView>>): LoginViaAuthRequestView | null {
|
||||
if (obj == null) {
|
||||
return null;
|
||||
}
|
||||
return Object.assign(new LoginViaAuthRequestView(), obj);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,24 @@ describe("DevicesApiServiceImplementation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("untrustDevices", () => {
|
||||
it("calls api with correct parameters", async () => {
|
||||
const deviceIds = ["device1", "device2"];
|
||||
apiService.send.mockResolvedValue(true);
|
||||
|
||||
await devicesApiService.untrustDevices(deviceIds);
|
||||
expect(apiService.send).toHaveBeenCalledWith(
|
||||
"POST",
|
||||
"/devices/untrust",
|
||||
{
|
||||
devices: deviceIds,
|
||||
},
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("propagates api errors", async () => {
|
||||
const error = new Error("API Error");
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ListResponse } from "../../models/response/list.response";
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { DeviceResponse } from "../abstractions/devices/responses/device.response";
|
||||
import { DevicesApiServiceAbstraction } from "../abstractions/devices-api.service.abstraction";
|
||||
import { SecretVerificationRequest } from "../models/request/secret-verification.request";
|
||||
import { UntrustDevicesRequestModel } from "../models/request/untrust-devices.request";
|
||||
import { UpdateDevicesTrustRequest } from "../models/request/update-devices-trust.request";
|
||||
import { ProtectedDeviceResponse } from "../models/response/protected-device.response";
|
||||
|
||||
@@ -90,14 +90,11 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
|
||||
);
|
||||
}
|
||||
|
||||
async getDeviceKeys(
|
||||
deviceIdentifier: string,
|
||||
secretVerificationRequest: SecretVerificationRequest,
|
||||
): Promise<ProtectedDeviceResponse> {
|
||||
async getDeviceKeys(deviceIdentifier: string): Promise<ProtectedDeviceResponse> {
|
||||
const result = await this.apiService.send(
|
||||
"POST",
|
||||
`/devices/${deviceIdentifier}/retrieve-keys`,
|
||||
secretVerificationRequest,
|
||||
null,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
@@ -121,4 +118,14 @@ export class DevicesApiServiceImplementation implements DevicesApiServiceAbstrac
|
||||
async deactivateDevice(deviceId: string): Promise<void> {
|
||||
await this.apiService.send("POST", `/devices/${deviceId}/deactivate`, null, true, false);
|
||||
}
|
||||
|
||||
async untrustDevices(deviceIds: string[]): Promise<void> {
|
||||
await this.apiService.send(
|
||||
"POST",
|
||||
"/devices/untrust",
|
||||
new UntrustDevicesRequestModel(deviceIds),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
|
||||
|
||||
keyService.getUserKey.mockResolvedValue({ key: "key" } as any);
|
||||
encryptService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedKey as any);
|
||||
|
||||
await service.enroll("orgId");
|
||||
|
||||
@@ -122,7 +122,7 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
|
||||
};
|
||||
const encryptedKey = { encryptedString: "encryptedString" };
|
||||
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
|
||||
encryptService.rsaEncrypt.mockResolvedValue(encryptedKey as any);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue(encryptedKey as any);
|
||||
|
||||
await service.enroll("orgId", "userId", { key: "key" } as any);
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ export class PasswordResetEnrollmentServiceImplementation
|
||||
userId ?? (await firstValueFrom(this.accountService.activeAccount$.pipe(map((a) => a?.id))));
|
||||
userKey = userKey ?? (await this.keyService.getUserKey(userId));
|
||||
// RSA Encrypt user's userKey.key with organization public key
|
||||
const encryptedKey = await this.encryptService.rsaEncrypt(userKey.key, orgPublicKey);
|
||||
const encryptedKey = await this.encryptService.encapsulateKeyUnsigned(userKey, orgPublicKey);
|
||||
|
||||
const resetRequest = new OrganizationUserResetPasswordEnrollmentRequest();
|
||||
resetRequest.resetPasswordKey = encryptedKey.encryptedString;
|
||||
|
||||
@@ -224,6 +224,8 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: null,
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -282,6 +284,8 @@ describe("UserVerificationService", () => {
|
||||
expect(result).toEqual({
|
||||
policyOptions: "MasterPasswordPolicyOptions",
|
||||
masterKey: "masterKey",
|
||||
kdfConfig: "kdfConfig",
|
||||
email: "email",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -237,7 +237,7 @@ export class UserVerificationService implements UserVerificationServiceAbstracti
|
||||
);
|
||||
await this.masterPasswordService.setMasterKeyHash(localKeyHash, userId);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
return { policyOptions, masterKey };
|
||||
return { policyOptions, masterKey, kdfConfig, email };
|
||||
}
|
||||
|
||||
private async verifyUserByPIN(verification: PinVerification, userId: UserId): Promise<boolean> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
|
||||
|
||||
import { WebAuthnLoginPrfKeyService } from "./webauthn-login-prf-key.service";
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { CryptoFunctionService } from "../../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { PrfKey } from "../../../types/key";
|
||||
import { WebAuthnLoginPrfKeyServiceAbstraction } from "../../abstractions/webauthn/webauthn-login-prf-key.service.abstraction";
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
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";
|
||||
@@ -22,5 +24,7 @@ export type ServerSideVerification = OtpVerification | MasterPasswordVerificatio
|
||||
|
||||
export type MasterPasswordVerificationResponse = {
|
||||
masterKey: MasterKey;
|
||||
kdfConfig: KdfConfig;
|
||||
email: string;
|
||||
policyOptions: MasterPasswordPolicyResponse | null;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { map, Observable } from "rxjs";
|
||||
import { map, Observable, switchMap } from "rxjs";
|
||||
|
||||
import { PolicyService } from "../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../admin-console/enums";
|
||||
import { AccountService } from "../../auth/abstractions/account.service";
|
||||
import { getUserId } from "../../auth/services/account.service";
|
||||
import {
|
||||
AUTOFILL_SETTINGS_DISK,
|
||||
AUTOFILL_SETTINGS_DISK_LOCAL,
|
||||
@@ -152,6 +154,7 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private policyService: PolicyService,
|
||||
private accountService: AccountService,
|
||||
) {
|
||||
this.autofillOnPageLoadState = this.stateProvider.getActive(AUTOFILL_ON_PAGE_LOAD);
|
||||
this.autofillOnPageLoad$ = this.autofillOnPageLoadState.state$.pipe(map((x) => x ?? false));
|
||||
@@ -169,8 +172,11 @@ export class AutofillSettingsService implements AutofillSettingsServiceAbstracti
|
||||
this.autofillOnPageLoadCalloutIsDismissed$ =
|
||||
this.autofillOnPageLoadCalloutIsDismissedState.state$.pipe(map((x) => x ?? false));
|
||||
|
||||
this.activateAutofillOnPageLoadFromPolicy$ = this.policyService.policyAppliesToActiveUser$(
|
||||
PolicyType.ActivateAutofill,
|
||||
this.activateAutofillOnPageLoadFromPolicy$ = this.accountService.activeAccount$.pipe(
|
||||
getUserId,
|
||||
switchMap((userId) =>
|
||||
this.policyService.policyAppliesToUser$(PolicyType.ActivateAutofill, userId),
|
||||
),
|
||||
);
|
||||
|
||||
this.autofillOnPageLoadPolicyToastHasDisplayedState = this.stateProvider.getActive(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
|
||||
import { OrganizationResponse } from "../../admin-console/models/response/organization.response";
|
||||
import { InitiationPath } from "../../models/request/reference-event.request";
|
||||
@@ -59,4 +62,10 @@ export abstract class OrganizationBillingServiceAbstraction {
|
||||
organizationId: string,
|
||||
subscription: SubscriptionInformation,
|
||||
) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Determines if breadcrumbing policies is enabled for the organizations meeting certain criteria.
|
||||
* @param organization
|
||||
*/
|
||||
abstract isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean>;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import {
|
||||
BillingInvoiceResponse,
|
||||
BillingTransactionResponse,
|
||||
} from "../../models/response/billing.response";
|
||||
|
||||
export class OrganizationBillingApiServiceAbstraction {
|
||||
getBillingInvoices: (
|
||||
export abstract class OrganizationBillingApiServiceAbstraction {
|
||||
abstract getBillingInvoices: (
|
||||
id: string,
|
||||
status?: string,
|
||||
startAfter?: string,
|
||||
) => Promise<BillingInvoiceResponse[]>;
|
||||
|
||||
getBillingTransactions: (
|
||||
abstract getBillingTransactions: (
|
||||
id: string,
|
||||
startAfter?: string,
|
||||
) => Promise<BillingTransactionResponse[]>;
|
||||
|
||||
abstract setupBusinessUnit: (
|
||||
id: string,
|
||||
request: {
|
||||
userId: string;
|
||||
token: string;
|
||||
providerKey: string;
|
||||
organizationKey: string;
|
||||
},
|
||||
) => Promise<string>;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { PlanType } from "../../enums";
|
||||
import { PlanSponsorshipType, PlanType } from "../../enums";
|
||||
|
||||
export class PreviewOrganizationInvoiceRequest {
|
||||
organizationId?: string;
|
||||
@@ -21,6 +21,7 @@ export class PreviewOrganizationInvoiceRequest {
|
||||
|
||||
class PasswordManager {
|
||||
plan: PlanType;
|
||||
sponsoredPlan?: PlanSponsorshipType;
|
||||
seats: number;
|
||||
additionalStorage: number;
|
||||
|
||||
|
||||
@@ -68,15 +68,18 @@ export class DefaultBillingAccountProfileStateService implements BillingAccountP
|
||||
this.hasPremiumFromAnyOrganization$(userId),
|
||||
]).pipe(
|
||||
concatMap(async ([hasPremiumPersonally, hasPremiumFromOrg]) => {
|
||||
const isCloud = !this.platformUtilsService.isSelfHost();
|
||||
|
||||
let billing = null;
|
||||
if (isCloud) {
|
||||
billing = await this.apiService.getUserBillingHistory();
|
||||
if (hasPremiumPersonally === true || !hasPremiumFromOrg === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const cloudAndBillingHistory = isCloud && !billing?.hasNoHistory;
|
||||
return hasPremiumPersonally || !hasPremiumFromOrg || cloudAndBillingHistory;
|
||||
const isCloud = !this.platformUtilsService.isSelfHost();
|
||||
|
||||
if (isCloud) {
|
||||
const billing = await this.apiService.getUserBillingHistory();
|
||||
return !billing?.hasNoHistory;
|
||||
}
|
||||
|
||||
return false;
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { firstValueFrom, of } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationApiServiceAbstraction as OrganizationApiService } from "@bitwarden/common/admin-console/abstractions/organization/organization-api.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstractions";
|
||||
import { ProductTierType } from "@bitwarden/common/billing/enums";
|
||||
import { OrganizationBillingService } from "@bitwarden/common/billing/services/organization-billing.service";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { SyncService } from "@bitwarden/common/platform/sync";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
describe("BillingAccountProfileStateService", () => {
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let billingApiService: jest.Mocked<BillingApiServiceAbstraction>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
let organizationApiService: jest.Mocked<OrganizationApiService>;
|
||||
let syncService: jest.Mocked<SyncService>;
|
||||
let configService: jest.Mocked<ConfigService>;
|
||||
|
||||
let sut: OrganizationBillingService;
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
billingApiService = mock<BillingApiServiceAbstraction>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
i18nService = mock<I18nService>();
|
||||
organizationApiService = mock<OrganizationApiService>();
|
||||
syncService = mock<SyncService>();
|
||||
configService = mock<ConfigService>();
|
||||
|
||||
sut = new OrganizationBillingService(
|
||||
apiService,
|
||||
billingApiService,
|
||||
keyService,
|
||||
encryptService,
|
||||
i18nService,
|
||||
organizationApiService,
|
||||
syncService,
|
||||
configService,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
return jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("isBreadcrumbingPoliciesEnabled", () => {
|
||||
it("returns false when feature flag is disabled", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when organization belongs to a provider", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: true,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when cannot edit subscription", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: false,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["Teams", ProductTierType.Teams],
|
||||
["TeamsStarter", ProductTierType.TeamsStarter],
|
||||
])("returns true when all conditions are met with %s tier", async (_, productTierType) => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: productTierType,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(true);
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledWith(
|
||||
FeatureFlag.PM12276_BreadcrumbEventLogs,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns false when product tier is not supported", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(true));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Enterprise,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("handles all conditions false correctly", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: true,
|
||||
canEditSubscription: false,
|
||||
productTierType: ProductTierType.Free,
|
||||
} as Organization;
|
||||
|
||||
const actual = await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(actual).toBe(false);
|
||||
});
|
||||
|
||||
it("verifies feature flag is only called once", async () => {
|
||||
configService.getFeatureFlag$.mockReturnValue(of(false));
|
||||
const org = {
|
||||
isProviderUser: false,
|
||||
canEditSubscription: true,
|
||||
productTierType: ProductTierType.Teams,
|
||||
} as Organization;
|
||||
|
||||
await firstValueFrom(sut.isBreadcrumbingPoliciesEnabled$(org));
|
||||
expect(configService.getFeatureFlag$).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,10 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { Observable, of, switchMap } from "rxjs";
|
||||
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { ApiService } from "../../abstractions/api.service";
|
||||
@@ -20,7 +25,7 @@ import {
|
||||
PlanInformation,
|
||||
SubscriptionInformation,
|
||||
} from "../abstractions";
|
||||
import { PlanType } from "../enums";
|
||||
import { PlanType, ProductTierType } from "../enums";
|
||||
import { OrganizationNoPaymentMethodCreateRequest } from "../models/request/organization-no-payment-method-create-request";
|
||||
import { PaymentSourceResponse } from "../models/response/payment-source.response";
|
||||
|
||||
@@ -40,6 +45,7 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
private i18nService: I18nService,
|
||||
private organizationApiService: OrganizationApiService,
|
||||
private syncService: SyncService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
async getPaymentSource(organizationId: string): Promise<PaymentSourceResponse> {
|
||||
@@ -220,4 +226,29 @@ export class OrganizationBillingService implements OrganizationBillingServiceAbs
|
||||
this.setPaymentInformation(request, subscription.payment);
|
||||
await this.billingApiService.restartSubscription(organizationId, request);
|
||||
}
|
||||
|
||||
isBreadcrumbingPoliciesEnabled$(organization: Organization): Observable<boolean> {
|
||||
if (organization === null || organization === undefined) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
return this.configService.getFeatureFlag$(FeatureFlag.PM12276_BreadcrumbEventLogs).pipe(
|
||||
switchMap((featureFlagEnabled) => {
|
||||
if (!featureFlagEnabled) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
if (organization.isProviderUser || !organization.canEditSubscription) {
|
||||
return of(false);
|
||||
}
|
||||
|
||||
const supportedProducts = [ProductTierType.Teams, ProductTierType.TeamsStarter];
|
||||
const isSupportedProduct = supportedProducts.some(
|
||||
(product) => product === organization.productTierType,
|
||||
);
|
||||
|
||||
return of(isSupportedProduct);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,24 @@ export class OrganizationBillingApiService implements OrganizationBillingApiServ
|
||||
);
|
||||
return r?.map((i: any) => new BillingTransactionResponse(i)) || [];
|
||||
}
|
||||
|
||||
async setupBusinessUnit(
|
||||
id: string,
|
||||
request: {
|
||||
userId: string;
|
||||
token: string;
|
||||
providerKey: string;
|
||||
organizationKey: string;
|
||||
},
|
||||
): Promise<string> {
|
||||
const response = await this.apiService.send(
|
||||
"POST",
|
||||
`/organizations/${id}/billing/setup-business-unit`,
|
||||
request,
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
return response as string;
|
||||
}
|
||||
}
|
||||
|
||||
38
libs/common/src/enums/feature-flag.enum.spec.ts
Normal file
38
libs/common/src/enums/feature-flag.enum.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
|
||||
import { getFeatureFlagValue, FeatureFlag, DefaultFeatureFlagValue } from "./feature-flag.enum";
|
||||
|
||||
describe("getFeatureFlagValue", () => {
|
||||
const testFlag = Object.values(FeatureFlag)[0];
|
||||
const testFlagDefaultValue = DefaultFeatureFlagValue[testFlag];
|
||||
|
||||
it("returns default flag value when serverConfig is null", () => {
|
||||
const result = getFeatureFlagValue(null, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns default flag value when serverConfig.featureStates is undefined", () => {
|
||||
const serverConfig = {} as ServerConfig;
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns default flag value when the feature flag is not in serverConfig.featureStates", () => {
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
serverConfig.featureStates = {};
|
||||
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(testFlagDefaultValue);
|
||||
});
|
||||
|
||||
it("returns the flag value from serverConfig.featureStates when the feature flag exists", () => {
|
||||
const expectedValue = true;
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
serverConfig.featureStates = { [testFlag]: expectedValue };
|
||||
|
||||
const result = getFeatureFlagValue(serverConfig, testFlag);
|
||||
expect(result).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,22 @@
|
||||
import { ServerConfig } from "../platform/abstractions/config/server-config";
|
||||
|
||||
/**
|
||||
* Feature flags.
|
||||
*
|
||||
* Flags MUST be short lived and SHALL be removed once enabled.
|
||||
*
|
||||
* Flags should be grouped by team to have visibility of ownership and cleanup.
|
||||
*/
|
||||
export enum FeatureFlag {
|
||||
/* Admin Console Team */
|
||||
AccountDeprovisioning = "pm-10308-account-deprovisioning",
|
||||
VerifiedSsoDomainEndpoint = "pm-12337-refactor-sso-details-endpoint",
|
||||
LimitItemDeletion = "pm-15493-restrict-item-deletion-to-can-manage-permission",
|
||||
SsoExternalIdVisibility = "pm-18630-sso-external-id-visibility",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
|
||||
/* Autofill */
|
||||
BlockBrowserInjectionsByDomain = "block-browser-injections-by-domain",
|
||||
@@ -15,18 +24,28 @@ export enum FeatureFlag {
|
||||
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
IdpAutoSubmitLogin = "idp-auto-submit-login",
|
||||
InlineMenuFieldQualification = "inline-menu-field-qualification",
|
||||
InlineMenuPositioningImprovements = "inline-menu-positioning-improvements",
|
||||
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
|
||||
NotificationRefresh = "notification-refresh",
|
||||
UseTreeWalkerApiForPageDetailsCollection = "use-tree-walker-api-for-page-details-collection",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
|
||||
/* Billing */
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
|
||||
PM17772_AdminInitiatedSponsorships = "pm-17772-admin-initiated-sponsorships",
|
||||
|
||||
/* Key Management */
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
UserKeyRotationV2 = "userkey-rotation-v2",
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
PM17987_BlockType0 = "pm-17987-block-type-0",
|
||||
|
||||
/* Tools */
|
||||
ItemShare = "item-share",
|
||||
CriticalApps = "pm-14466-risk-insights-critical-application",
|
||||
EnableRiskInsightsNotifications = "enable-risk-insights-notifications",
|
||||
DesktopSendUIRefresh = "desktop-send-ui-refresh",
|
||||
ExportAttachments = "export-attachments",
|
||||
|
||||
/* Vault */
|
||||
PM8851_BrowserOnboardingNudge = "pm-8851-browser-onboarding-nudge",
|
||||
@@ -35,21 +54,11 @@ export enum FeatureFlag {
|
||||
NewDeviceVerificationPermanentDismiss = "new-device-permanent-dismiss",
|
||||
VaultBulkManagementAction = "vault-bulk-management-action",
|
||||
SecurityTasks = "security-tasks",
|
||||
|
||||
/* Auth */
|
||||
PM9112_DeviceApprovalPersistence = "pm-9112-device-approval-persistence",
|
||||
|
||||
PM4154_BulkEncryptionService = "PM-4154-bulk-encryption-service",
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
CipherKeyEncryption = "cipher-key-encryption",
|
||||
TrialPaymentOptional = "PM-8163-trial-payment",
|
||||
MacOsNativeCredentialSync = "macos-native-credential-sync",
|
||||
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
|
||||
ResellerManagedOrgAlert = "PM-15814-alert-owners-of-reseller-managed-orgs",
|
||||
AccountDeprovisioningBanner = "pm-17120-account-deprovisioning-admin-console-banner",
|
||||
PM15179_AddExistingOrgsFromProviderPortal = "pm-15179-add-existing-orgs-from-provider-portal",
|
||||
PM12276_BreadcrumbEventLogs = "pm-12276-breadcrumbing-for-business-features",
|
||||
PM18794_ProviderPaymentMethod = "pm-18794-provider-payment-method",
|
||||
EndUserNotifications = "pm-10609-end-user-notifications",
|
||||
|
||||
/* Platform */
|
||||
IpcChannelFramework = "ipc-channel-framework",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -62,12 +71,16 @@ const FALSE = false as boolean;
|
||||
*
|
||||
* DO NOT enable previously disabled flags, REMOVE them instead.
|
||||
* We support true as a value as we prefer flags to "enable" not "disable".
|
||||
*
|
||||
* Flags should be grouped by team to have visibility of ownership and cleanup.
|
||||
*/
|
||||
export const DefaultFeatureFlagValue = {
|
||||
/* Admin Console Team */
|
||||
[FeatureFlag.AccountDeprovisioning]: FALSE,
|
||||
[FeatureFlag.VerifiedSsoDomainEndpoint]: FALSE,
|
||||
[FeatureFlag.LimitItemDeletion]: FALSE,
|
||||
[FeatureFlag.SsoExternalIdVisibility]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
|
||||
/* Autofill */
|
||||
[FeatureFlag.BlockBrowserInjectionsByDomain]: FALSE,
|
||||
@@ -75,18 +88,15 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.IdpAutoSubmitLogin]: FALSE,
|
||||
[FeatureFlag.InlineMenuFieldQualification]: FALSE,
|
||||
[FeatureFlag.InlineMenuPositioningImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationBarAddLoginImprovements]: FALSE,
|
||||
[FeatureFlag.NotificationRefresh]: FALSE,
|
||||
[FeatureFlag.UseTreeWalkerApiForPageDetailsCollection]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
|
||||
/* Tools */
|
||||
[FeatureFlag.ItemShare]: FALSE,
|
||||
[FeatureFlag.CriticalApps]: FALSE,
|
||||
[FeatureFlag.EnableRiskInsightsNotifications]: FALSE,
|
||||
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
|
||||
[FeatureFlag.ExportAttachments]: FALSE,
|
||||
|
||||
/* Vault */
|
||||
[FeatureFlag.PM8851_BrowserOnboardingNudge]: FALSE,
|
||||
@@ -95,23 +105,40 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.NewDeviceVerificationPermanentDismiss]: FALSE,
|
||||
[FeatureFlag.VaultBulkManagementAction]: FALSE,
|
||||
[FeatureFlag.SecurityTasks]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
[FeatureFlag.EndUserNotifications]: FALSE,
|
||||
|
||||
/* Auth */
|
||||
[FeatureFlag.PM9112_DeviceApprovalPersistence]: FALSE,
|
||||
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.CipherKeyEncryption]: FALSE,
|
||||
/* Billing */
|
||||
[FeatureFlag.TrialPaymentOptional]: FALSE,
|
||||
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.ResellerManagedOrgAlert]: FALSE,
|
||||
[FeatureFlag.AccountDeprovisioningBanner]: FALSE,
|
||||
[FeatureFlag.PM15179_AddExistingOrgsFromProviderPortal]: FALSE,
|
||||
[FeatureFlag.PM12276_BreadcrumbEventLogs]: FALSE,
|
||||
[FeatureFlag.PM18794_ProviderPaymentMethod]: FALSE,
|
||||
[FeatureFlag.PM17772_AdminInitiatedSponsorships]: FALSE,
|
||||
|
||||
/* Key Management */
|
||||
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
|
||||
[FeatureFlag.UserKeyRotationV2]: FALSE,
|
||||
[FeatureFlag.PM4154_BulkEncryptionService]: FALSE,
|
||||
[FeatureFlag.PM17987_BlockType0]: FALSE,
|
||||
|
||||
/* Platform */
|
||||
[FeatureFlag.IpcChannelFramework]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
export type FeatureFlagValueType<Flag extends FeatureFlag> = DefaultFeatureFlagValueType[Flag];
|
||||
|
||||
export function getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
}
|
||||
|
||||
@@ -24,4 +24,8 @@ export enum NotificationType {
|
||||
SyncOrganizations = 17,
|
||||
SyncOrganizationStatusChanged = 18,
|
||||
SyncOrganizationCollectionSettingChanged = 19,
|
||||
Notification = 20,
|
||||
NotificationStatus = 21,
|
||||
|
||||
PendingSecurityTasks = 22,
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class BulkEncryptService {
|
||||
abstract decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]>;
|
||||
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { CbcDecryptParameters, EcbDecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
CbcDecryptParameters,
|
||||
EcbDecryptParameters,
|
||||
} from "../../../platform/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
|
||||
export abstract class CryptoFunctionService {
|
||||
abstract pbkdf2(
|
||||
@@ -1,13 +1,56 @@
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { Encrypted } from "../../../platform/interfaces/encrypted";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export abstract class EncryptService {
|
||||
/**
|
||||
* Encrypts a string or Uint8Array to an EncString
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
|
||||
/**
|
||||
* Encrypts a value to a Uint8Array
|
||||
* @param plainValue - The value to encrypt
|
||||
* @param key - The key to encrypt the value with
|
||||
*/
|
||||
abstract encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer>;
|
||||
|
||||
/**
|
||||
* Wraps a decapsulation key (Private key) with a symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param decapsulationKeyPcks8 - The private key in PKCS8 format
|
||||
* @param wrappingKey - The symmetric key to wrap the private key with
|
||||
*/
|
||||
abstract wrapDecapsulationKey(
|
||||
decapsulationKeyPcks8: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
/**
|
||||
* Wraps an encapsulation key (Public key) with a symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param encapsulationKeySpki - The public key in SPKI format
|
||||
* @param wrappingKey - The symmetric key to wrap the public key with
|
||||
*/
|
||||
abstract wrapEncapsulationKey(
|
||||
encapsulationKeySpki: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
/**
|
||||
* Wraps a symmetric key with another symmetric key
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_wrap}
|
||||
* @param keyToBeWrapped - The symmetric key to wrap
|
||||
* @param wrappingKey - The symmetric key to wrap the encapsulated key with
|
||||
*/
|
||||
abstract wrapSymmetricKey(
|
||||
keyToBeWrapped: SymmetricCryptoKey,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString>;
|
||||
|
||||
/**
|
||||
* Decrypts an EncString to a string
|
||||
* @param encString - The EncString to decrypt
|
||||
@@ -34,7 +77,40 @@ export abstract class EncryptService {
|
||||
key: SymmetricCryptoKey,
|
||||
decryptTrace?: string,
|
||||
): Promise<Uint8Array | null>;
|
||||
|
||||
/**
|
||||
* Encapsulates a symmetric key with an asymmetric public key
|
||||
* Note: This does not establish sender authenticity
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
|
||||
* @param sharedKey - The symmetric key that is to be shared
|
||||
* @param encapsulationKey - The encapsulation key (public key) of the receiver that the key is shared with
|
||||
*/
|
||||
abstract encapsulateKeyUnsigned(
|
||||
sharedKey: SymmetricCryptoKey,
|
||||
encapsulationKey: Uint8Array,
|
||||
): Promise<EncString>;
|
||||
/**
|
||||
* Decapsulates a shared symmetric key with an asymmetric private key
|
||||
* Note: This does not establish sender authenticity
|
||||
* @see {@link https://en.wikipedia.org/wiki/Key_encapsulation_mechanism}
|
||||
* @param encryptedSharedKey - The encrypted shared symmetric key
|
||||
* @param decapsulationKey - The key to decapsulate with (private key)
|
||||
*/
|
||||
abstract decapsulateKeyUnsigned(
|
||||
encryptedSharedKey: EncString,
|
||||
decapsulationKey: Uint8Array,
|
||||
): Promise<SymmetricCryptoKey>;
|
||||
/**
|
||||
* @deprecated Use @see {@link encapsulateKeyUnsigned} instead
|
||||
* @param data - The data to encrypt
|
||||
* @param publicKey - The public key to encrypt with
|
||||
*/
|
||||
abstract rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString>;
|
||||
/**
|
||||
* @deprecated Use @see {@link decapsulateKeyUnsigned} instead
|
||||
* @param data - The ciphertext to decrypt
|
||||
* @param privateKey - The privateKey to decrypt with
|
||||
*/
|
||||
abstract rsaDecrypt(data: EncString, privateKey: Uint8Array): Promise<Uint8Array>;
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptService, remove once the feature is tested and the featureflag PM-4154-multi-worker-encryption-service is removed
|
||||
@@ -54,4 +130,6 @@ export abstract class EncryptService {
|
||||
value: string | Uint8Array,
|
||||
algorithm: "sha1" | "sha256" | "sha512",
|
||||
): Promise<string>;
|
||||
|
||||
abstract onServerConfigChange(newConfig: ServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation";
|
||||
|
||||
describe("BulkEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
|
||||
let sut: BulkEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
let globalWindow: any;
|
||||
|
||||
beforeEach(() => {
|
||||
globalWindow = global.window;
|
||||
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.window = globalWindow;
|
||||
});
|
||||
|
||||
it("throws error if key is null", async () => {
|
||||
const nullKey = null as unknown as SymmetricCryptoKey;
|
||||
await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided.");
|
||||
});
|
||||
|
||||
it("returns an empty array when items is null", async () => {
|
||||
const result = await sut.decryptItems(null as any, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns an empty array when items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("decrypts items sequentially when window is undefined", async () => {
|
||||
// Make global window undefined.
|
||||
delete (global as any).window;
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(logService.info).toHaveBeenCalledWith(
|
||||
"Window not available in BulkEncryptService, decrypting sequentially",
|
||||
);
|
||||
expect(result).toEqual(["item1", "item2"]);
|
||||
expect(mockItems[0].decrypt).toHaveBeenCalledWith(key);
|
||||
expect(mockItems[1].decrypt).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it("uses workers for decryption when window is available", async () => {
|
||||
const mockDecryptedItems = ["decrypted1", "decrypted2"];
|
||||
jest
|
||||
.spyOn<any, any>(sut, "getDecryptedItemsFromWorkers")
|
||||
.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("creates new worker when none exist", async () => {
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).workers = [mockWorker];
|
||||
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
|
||||
|
||||
await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(newConfig);
|
||||
});
|
||||
|
||||
it("does send a SetConfigMessage to workers when there is a worker", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).workers = [mockWorker];
|
||||
|
||||
sut.onServerConfigChange(newConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig }));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createMockDecryptable<T extends InitializerMetadata>(
|
||||
returnValue: any,
|
||||
): MockProxy<Decryptable<T>> {
|
||||
const mockDecryptable = mock<Decryptable<T>>();
|
||||
mockDecryptable.decrypt.mockResolvedValue(returnValue);
|
||||
return mockDecryptable;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { firstValueFrom, fromEvent, filter, map, takeUntil, defaultIfEmpty, Subj
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { BulkEncryptService } from "@bitwarden/common/key-management/crypto/abstractions/bulk-encrypt.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
@@ -12,6 +12,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
const workerTTL = 60000; // 1 minute
|
||||
const maxWorkers = 8;
|
||||
@@ -20,6 +23,7 @@ const minNumberOfItemsForMultithreading = 400;
|
||||
export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
private workers: Worker[] = [];
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
@@ -57,6 +61,11 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
this.updateWorkerServerConfigs(newConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
|
||||
* faster without interrupting other operations (e.g. updating UI).
|
||||
@@ -93,6 +102,9 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
),
|
||||
);
|
||||
}
|
||||
if (this.currentServerConfig != undefined) {
|
||||
this.updateWorkerServerConfigs(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
const itemsPerWorker = Math.floor(items.length / this.workers.length);
|
||||
@@ -108,17 +120,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
itemsForWorker.push(...items.slice(end));
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: itemsForWorker,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
worker.postMessage(JSON.stringify(request));
|
||||
worker.postMessage(request);
|
||||
results.push(
|
||||
firstValueFrom(
|
||||
fromEvent(worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
@@ -143,6 +156,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
|
||||
return decryptedItems;
|
||||
}
|
||||
|
||||
private updateWorkerServerConfigs(newConfig: ServerConfig) {
|
||||
this.workers.forEach((worker) => {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
worker.postMessage(request);
|
||||
});
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
for (const worker of this.workers) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import {
|
||||
EncryptionType,
|
||||
@@ -13,38 +13,126 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
Aes256CbcKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
getFeatureFlagValue,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
export class EncryptServiceImplementation implements EncryptService {
|
||||
private blockType0: boolean = DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0];
|
||||
|
||||
constructor(
|
||||
protected cryptoFunctionService: CryptoFunctionService,
|
||||
protected logService: LogService,
|
||||
protected logMacFailures: boolean,
|
||||
) {}
|
||||
|
||||
// Handle updating private properties to turn on/off feature flags.
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.blockType0 = getFeatureFlagValue(newConfig, FeatureFlag.PM17987_BlockType0);
|
||||
}
|
||||
|
||||
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
if (typeof plainValue === "string") {
|
||||
return this.encryptUint8Array(Utils.fromUtf8ToArray(plainValue), key);
|
||||
} else {
|
||||
return this.encryptUint8Array(plainValue, key);
|
||||
}
|
||||
}
|
||||
|
||||
async wrapDecapsulationKey(
|
||||
decapsulationKeyPkcs8: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (decapsulationKeyPkcs8 == null) {
|
||||
throw new Error("No decapsulation key provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(decapsulationKeyPkcs8, wrappingKey);
|
||||
}
|
||||
|
||||
async wrapEncapsulationKey(
|
||||
encapsulationKeySpki: Uint8Array,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (encapsulationKeySpki == null) {
|
||||
throw new Error("No encapsulation key provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(encapsulationKeySpki, wrappingKey);
|
||||
}
|
||||
|
||||
async wrapSymmetricKey(
|
||||
keyToBeWrapped: SymmetricCryptoKey,
|
||||
wrappingKey: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (keyToBeWrapped == null) {
|
||||
throw new Error("No keyToBeWrapped provided for wrapping.");
|
||||
}
|
||||
|
||||
if (wrappingKey == null) {
|
||||
throw new Error("No wrappingKey provided for wrapping.");
|
||||
}
|
||||
|
||||
return await this.encryptUint8Array(keyToBeWrapped.key, wrappingKey);
|
||||
}
|
||||
|
||||
private async encryptUint8Array(
|
||||
plainValue: Uint8Array,
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<EncString> {
|
||||
if (key == null) {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
if (plainValue == null) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
let plainBuf: Uint8Array;
|
||||
if (typeof plainValue === "string") {
|
||||
plainBuf = Utils.fromUtf8ToArray(plainValue);
|
||||
} else {
|
||||
plainBuf = plainValue;
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encObj = await this.aesEncrypt(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
const mac = Utils.fromBufferToB64(encObj.mac);
|
||||
return new EncString(innerKey.type, data, iv, mac);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encObj = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
return new EncString(innerKey.type, data, iv);
|
||||
}
|
||||
|
||||
const encObj = await this.aesEncrypt(plainBuf, key);
|
||||
const iv = Utils.fromBufferToB64(encObj.iv);
|
||||
const data = Utils.fromBufferToB64(encObj.data);
|
||||
const mac = encObj.mac != null ? Utils.fromBufferToB64(encObj.mac) : null;
|
||||
return new EncString(encObj.key.encType, data, iv, mac);
|
||||
}
|
||||
|
||||
async encryptToBytes(plainValue: Uint8Array, key: SymmetricCryptoKey): Promise<EncArrayBuffer> {
|
||||
@@ -52,21 +140,32 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No encryption key provided.");
|
||||
}
|
||||
|
||||
const encValue = await this.aesEncrypt(plainValue, key);
|
||||
let macLen = 0;
|
||||
if (encValue.mac != null) {
|
||||
macLen = encValue.mac.byteLength;
|
||||
if (this.blockType0) {
|
||||
if (key.inner().type === EncryptionType.AesCbc256_B64 || key.key.byteLength < 64) {
|
||||
throw new Error("Type 0 encryption is not supported.");
|
||||
}
|
||||
}
|
||||
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + macLen + encValue.data.byteLength);
|
||||
encBytes.set([encValue.key.encType]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
if (encValue.mac != null) {
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encValue = await this.aesEncrypt(plainValue, innerKey);
|
||||
const macLen = encValue.mac.length;
|
||||
const encBytes = new Uint8Array(
|
||||
1 + encValue.iv.byteLength + macLen + encValue.data.byteLength,
|
||||
);
|
||||
encBytes.set([innerKey.type]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
encBytes.set(new Uint8Array(encValue.mac), 1 + encValue.iv.byteLength);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const encValue = await this.aesEncryptLegacy(plainValue, innerKey);
|
||||
const encBytes = new Uint8Array(1 + encValue.iv.byteLength + encValue.data.byteLength);
|
||||
encBytes.set([innerKey.type]);
|
||||
encBytes.set(new Uint8Array(encValue.iv), 1);
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
}
|
||||
|
||||
encBytes.set(new Uint8Array(encValue.data), 1 + encValue.iv.byteLength + macLen);
|
||||
return new EncArrayBuffer(encBytes);
|
||||
}
|
||||
|
||||
async decryptToUtf8(
|
||||
@@ -78,36 +177,25 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("No key provided for decryption.");
|
||||
}
|
||||
|
||||
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
|
||||
if (key.macKey != null && encString?.mac == null) {
|
||||
this.logService.error(
|
||||
"[Encrypt service] Key has mac key but payload is missing mac bytes. Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
"Payload type " +
|
||||
encryptionTypeName(encString.encryptionType),
|
||||
"Decrypt context: " + decryptContext,
|
||||
const innerKey = key.inner();
|
||||
if (encString.encryptionType !== innerKey.type) {
|
||||
this.logDecryptError(
|
||||
"Key encryption type does not match payload encryption type",
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.encType !== encString.encryptionType) {
|
||||
this.logService.error(
|
||||
"[Encrypt service] Key encryption type does not match payload encryption type. Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
"Payload type " +
|
||||
encryptionTypeName(encString.encryptionType),
|
||||
"Decrypt context: " + decryptContext,
|
||||
if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
encString.mac,
|
||||
key,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
encString.mac,
|
||||
key,
|
||||
);
|
||||
if (fastParams.macKey != null && fastParams.mac != null) {
|
||||
const computedMac = await this.cryptoFunctionService.hmacFast(
|
||||
fastParams.macData,
|
||||
fastParams.macKey,
|
||||
@@ -116,18 +204,31 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac);
|
||||
if (!macsEqual) {
|
||||
this.logMacFailed(
|
||||
"[Encrypt service] decryptToUtf8 MAC comparison failed. Key or payload has changed. Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
"Payload type " +
|
||||
encryptionTypeName(encString.encryptionType) +
|
||||
" Decrypt context: " +
|
||||
decryptContext,
|
||||
"decryptToUtf8 MAC comparison failed. Key or payload has changed.",
|
||||
innerKey.type,
|
||||
encString.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return await this.cryptoFunctionService.aesDecryptFast({
|
||||
mode: "cbc",
|
||||
parameters: fastParams,
|
||||
});
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(
|
||||
encString.data,
|
||||
encString.iv,
|
||||
undefined,
|
||||
key,
|
||||
);
|
||||
return await this.cryptoFunctionService.aesDecryptFast({
|
||||
mode: "cbc",
|
||||
parameters: fastParams,
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported encryption type`);
|
||||
}
|
||||
|
||||
return await this.cryptoFunctionService.aesDecryptFast({ mode: "cbc", parameters: fastParams });
|
||||
}
|
||||
|
||||
async decryptToBytes(
|
||||
@@ -143,72 +244,143 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
throw new Error("Nothing provided for decryption.");
|
||||
}
|
||||
|
||||
// DO NOT REMOVE OR MOVE. This prevents downgrade to mac-less CBC, which would compromise integrity and confidentiality.
|
||||
if (key.macKey != null && encThing.macBytes == null) {
|
||||
this.logService.error(
|
||||
"[Encrypt service] Key has mac key but payload is missing mac bytes. Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
" Payload type " +
|
||||
encryptionTypeName(encThing.encryptionType) +
|
||||
" Decrypt context: " +
|
||||
decryptContext,
|
||||
const inner = key.inner();
|
||||
if (encThing.encryptionType !== inner.type) {
|
||||
this.logDecryptError(
|
||||
"Encryption key type mismatch",
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.encType !== encThing.encryptionType) {
|
||||
this.logService.error(
|
||||
"[Encrypt service] Key encryption type does not match payload encryption type. Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
" Payload type " +
|
||||
encryptionTypeName(encThing.encryptionType) +
|
||||
" Decrypt context: " +
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (inner.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
if (encThing.macBytes == null) {
|
||||
this.logDecryptError("Mac missing", inner.type, encThing.encryptionType, decryptContext);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (key.macKey != null && encThing.macBytes != null) {
|
||||
const macData = new Uint8Array(encThing.ivBytes.byteLength + encThing.dataBytes.byteLength);
|
||||
macData.set(new Uint8Array(encThing.ivBytes), 0);
|
||||
macData.set(new Uint8Array(encThing.dataBytes), encThing.ivBytes.byteLength);
|
||||
const computedMac = await this.cryptoFunctionService.hmac(macData, key.macKey, "sha256");
|
||||
if (computedMac === null) {
|
||||
this.logMacFailed(
|
||||
"[Encrypt service#decryptToBytes] Failed to compute MAC." +
|
||||
" Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
" Payload type " +
|
||||
encryptionTypeName(encThing.encryptionType) +
|
||||
" Decrypt context: " +
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const computedMac = await this.cryptoFunctionService.hmac(
|
||||
macData,
|
||||
inner.authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
const macsMatch = await this.cryptoFunctionService.compare(encThing.macBytes, computedMac);
|
||||
if (!macsMatch) {
|
||||
this.logMacFailed(
|
||||
"[Encrypt service#decryptToBytes]: MAC comparison failed. Key or payload has changed." +
|
||||
" Key type " +
|
||||
encryptionTypeName(key.encType) +
|
||||
" Payload type " +
|
||||
encryptionTypeName(encThing.encryptionType) +
|
||||
" Decrypt context: " +
|
||||
decryptContext,
|
||||
"MAC comparison failed. Key or payload has changed.",
|
||||
inner.type,
|
||||
encThing.encryptionType,
|
||||
decryptContext,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
} else if (inner.type === EncryptionType.AesCbc256_B64) {
|
||||
return await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
inner.encryptionKey,
|
||||
"cbc",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async encapsulateKeyUnsigned(
|
||||
sharedKey: SymmetricCryptoKey,
|
||||
encapsulationKey: Uint8Array,
|
||||
): Promise<EncString> {
|
||||
if (sharedKey == null) {
|
||||
throw new Error("No sharedKey provided for encapsulation");
|
||||
}
|
||||
return await this.rsaEncrypt(sharedKey.toEncoded(), encapsulationKey);
|
||||
}
|
||||
|
||||
async decapsulateKeyUnsigned(
|
||||
encryptedSharedKey: EncString,
|
||||
decapsulationKey: Uint8Array,
|
||||
): Promise<SymmetricCryptoKey> {
|
||||
const keyBytes = await this.rsaDecrypt(encryptedSharedKey, decapsulationKey);
|
||||
return new SymmetricCryptoKey(keyBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptService (PM-4154)
|
||||
*/
|
||||
async decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const result = await this.cryptoFunctionService.aesDecrypt(
|
||||
encThing.dataBytes,
|
||||
encThing.ivBytes,
|
||||
key.encKey,
|
||||
"cbc",
|
||||
);
|
||||
// don't use promise.all because this task is not io bound
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
results.push(await items[i].decrypt(key));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
return result ?? null;
|
||||
async hash(value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512"): Promise<string> {
|
||||
const hashArray = await this.cryptoFunctionService.hash(value, algorithm);
|
||||
return Utils.fromBufferToB64(hashArray);
|
||||
}
|
||||
|
||||
private async aesEncrypt(data: Uint8Array, key: Aes256CbcHmacKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey);
|
||||
|
||||
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength);
|
||||
macData.set(new Uint8Array(obj.iv), 0);
|
||||
macData.set(new Uint8Array(obj.data), obj.iv.byteLength);
|
||||
obj.mac = await this.cryptoFunctionService.hmac(macData, key.authenticationKey, "sha256");
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Removed once AesCbc256_B64 support is removed
|
||||
*/
|
||||
private async aesEncryptLegacy(data: Uint8Array, key: Aes256CbcKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, key.encryptionKey);
|
||||
return obj;
|
||||
}
|
||||
|
||||
private logDecryptError(
|
||||
msg: string,
|
||||
keyEncType: EncryptionType,
|
||||
dataEncType: EncryptionType,
|
||||
decryptContext: string,
|
||||
) {
|
||||
this.logService.error(
|
||||
`[Encrypt service] ${msg} Key type ${encryptionTypeName(keyEncType)} Payload type ${encryptionTypeName(dataEncType)} Decrypt context: ${decryptContext}`,
|
||||
);
|
||||
}
|
||||
|
||||
private logMacFailed(
|
||||
msg: string,
|
||||
keyEncType: EncryptionType,
|
||||
dataEncType: EncryptionType,
|
||||
decryptContext: string,
|
||||
) {
|
||||
if (this.logMacFailures) {
|
||||
this.logDecryptError(msg, keyEncType, dataEncType, decryptContext);
|
||||
}
|
||||
}
|
||||
|
||||
async rsaEncrypt(data: Uint8Array, publicKey: Uint8Array): Promise<EncString> {
|
||||
@@ -248,50 +420,4 @@ export class EncryptServiceImplementation implements EncryptService {
|
||||
|
||||
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Replaced by BulkEncryptService (PM-4154)
|
||||
*/
|
||||
async decryptItems<T extends InitializerMetadata>(
|
||||
items: Decryptable<T>[],
|
||||
key: SymmetricCryptoKey,
|
||||
): Promise<T[]> {
|
||||
if (items == null || items.length < 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// don't use promise.all because this task is not io bound
|
||||
const results = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
results.push(await items[i].decrypt(key));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async hash(value: string | Uint8Array, algorithm: "sha1" | "sha256" | "sha512"): Promise<string> {
|
||||
const hashArray = await this.cryptoFunctionService.hash(value, algorithm);
|
||||
return Utils.fromBufferToB64(hashArray);
|
||||
}
|
||||
|
||||
private async aesEncrypt(data: Uint8Array, key: SymmetricCryptoKey): Promise<EncryptedObject> {
|
||||
const obj = new EncryptedObject();
|
||||
obj.key = key;
|
||||
obj.iv = await this.cryptoFunctionService.randomBytes(16);
|
||||
obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey);
|
||||
|
||||
if (obj.key.macKey != null) {
|
||||
const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength);
|
||||
macData.set(new Uint8Array(obj.iv), 0);
|
||||
macData.set(new Uint8Array(obj.data), obj.iv.byteLength);
|
||||
obj.mac = await this.cryptoFunctionService.hmac(macData, obj.key.macKey, "sha256");
|
||||
}
|
||||
|
||||
return obj;
|
||||
}
|
||||
|
||||
private logMacFailed(msg: string) {
|
||||
if (this.logMacFailures) {
|
||||
this.logService.error(msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
import { mockReset, mock } from "jest-mock-extended";
|
||||
|
||||
import { CryptoFunctionService } from "@bitwarden/common/platform/abstractions/crypto-function.service";
|
||||
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
|
||||
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
|
||||
import { EncryptionType } from "@bitwarden/common/platform/enums";
|
||||
import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
|
||||
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import {
|
||||
Aes256CbcHmacKey,
|
||||
SymmetricCryptoKey,
|
||||
} from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "@bitwarden/common/types/csprng";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { DefaultFeatureFlagValue, FeatureFlag } from "../../../enums/feature-flag.enum";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
@@ -26,17 +31,151 @@ describe("EncryptService", () => {
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
describe("wrapSymmetricKey", () => {
|
||||
it("roundtrip encrypts and decrypts a symmetric key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapSymmetricKey(key, wrappingKey);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if key toBeWrapped is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapSymmetricKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No keyToBeWrapped provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapSymmetricKey(key, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapDecapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts a decapsulation key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapDecapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if decapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapDecapsulationKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No decapsulation key provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const decapsulationKey = makeStaticByteArray(64);
|
||||
await expect(encryptService.wrapDecapsulationKey(decapsulationKey, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapEncapsulationKey", () => {
|
||||
it("roundtrip encrypts and decrypts an encapsulationKey key", async () => {
|
||||
cryptoFunctionService.aesEncrypt.mockResolvedValue(makeStaticByteArray(64, 0));
|
||||
cryptoFunctionService.randomBytes.mockResolvedValue(makeStaticByteArray(16) as CsprngArray);
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(32));
|
||||
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const encString = await encryptService.wrapEncapsulationKey(
|
||||
makeStaticByteArray(64),
|
||||
wrappingKey,
|
||||
);
|
||||
expect(encString.encryptionType).toEqual(EncryptionType.AesCbc256_HmacSha256_B64);
|
||||
expect(encString.data).toEqual(Utils.fromBufferToB64(makeStaticByteArray(64, 0)));
|
||||
});
|
||||
it("fails if encapsulation key is null", async () => {
|
||||
const wrappingKey = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
await expect(encryptService.wrapEncapsulationKey(null, wrappingKey)).rejects.toThrow(
|
||||
"No encapsulation key provided for wrapping.",
|
||||
);
|
||||
});
|
||||
it("fails if wrapping key is null", async () => {
|
||||
const encapsulationKey = makeStaticByteArray(64);
|
||||
await expect(encryptService.wrapEncapsulationKey(encapsulationKey, null)).rejects.toThrow(
|
||||
"No wrappingKey provided for wrapping.",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
const newConfig = mock<ServerConfig>();
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
it("updates internal flag with default value when not present in config", () => {
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(
|
||||
DefaultFeatureFlagValue[FeatureFlag.PM17987_BlockType0],
|
||||
);
|
||||
});
|
||||
|
||||
test.each([true, false])("updates internal flag with value in config", (expectedValue) => {
|
||||
newConfig.featureStates = { [FeatureFlag.PM17987_BlockType0]: expectedValue };
|
||||
|
||||
encryptService.onServerConfigChange(newConfig);
|
||||
|
||||
expect((encryptService as any).blockType0).toBe(expectedValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encrypt", () => {
|
||||
it("throws if no key is provided", () => {
|
||||
return expect(encryptService.encrypt(null, null)).rejects.toThrow(
|
||||
"No encryption key provided.",
|
||||
);
|
||||
});
|
||||
it("returns null if no data is provided", async () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
|
||||
it("throws if type 0 key is provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(encryptService.encrypt(null!, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(null!, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
const plainValue = "data";
|
||||
await expect(encryptService.encrypt(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
await expect(encryptService.encrypt(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null if no data is provided with valid key", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const actual = await encryptService.encrypt(null, key);
|
||||
expect(actual).toBeNull();
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const plainValue = "data";
|
||||
@@ -53,6 +192,7 @@ describe("EncryptService", () => {
|
||||
expect(Utils.fromB64ToArray(result.data).length).toEqual(4);
|
||||
expect(Utils.fromB64ToArray(result.iv).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("creates an EncString for Aes256Cbc_HmacSha256_B64", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const plainValue = "data";
|
||||
@@ -90,6 +230,25 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if type 0 key provided with flag turned on", async () => {
|
||||
(encryptService as any).blockType0 = true;
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const mock32Key = mock<SymmetricCryptoKey>();
|
||||
mock32Key.key = makeStaticByteArray(32);
|
||||
mock32Key.inner.mockReturnValue({
|
||||
type: 0,
|
||||
encryptionKey: mock32Key.key,
|
||||
});
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
|
||||
await expect(encryptService.encryptToBytes(plainValue, mock32Key)).rejects.toThrow(
|
||||
"Type 0 encryption is not supported.",
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts data with provided Aes256Cbc key and returns correct encbuffer", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const iv = makeStaticByteArray(16, 80);
|
||||
@@ -150,7 +309,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256Cbc", async () => {
|
||||
it("decrypts data with provided key for Aes256CbcHmac", async () => {
|
||||
const decryptedBytes = makeStaticByteArray(10, 200);
|
||||
|
||||
cryptoFunctionService.hmac.mockResolvedValue(makeStaticByteArray(1));
|
||||
@@ -162,7 +321,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.encKey),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
@@ -183,7 +342,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.aesDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(encBuffer.dataBytes),
|
||||
expect.toEqualBuffer(encBuffer.ivBytes),
|
||||
expect.toEqualBuffer(key.encKey),
|
||||
expect.toEqualBuffer(key.inner().encryptionKey),
|
||||
"cbc",
|
||||
);
|
||||
|
||||
@@ -201,7 +360,7 @@ describe("EncryptService", () => {
|
||||
|
||||
expect(cryptoFunctionService.hmac).toBeCalledWith(
|
||||
expect.toEqualBuffer(expectedMacData),
|
||||
key.macKey,
|
||||
(key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
"sha256",
|
||||
);
|
||||
|
||||
@@ -257,7 +416,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256Cbc_HmacSha256", async () => {
|
||||
it("decrypts data with provided key for AesCbc256_HmacSha256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({
|
||||
@@ -277,10 +436,14 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("decrypts data with provided key for Aes256Cbc", async () => {
|
||||
it("decrypts data with provided key for AesCbc256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({} as any);
|
||||
cryptoFunctionService.aesDecryptFastParameters.mockReturnValue({
|
||||
macData: makeStaticByteArray(32, 0),
|
||||
macKey: makeStaticByteArray(32, 0),
|
||||
mac: makeStaticByteArray(32, 0),
|
||||
} as any);
|
||||
cryptoFunctionService.hmacFast.mockResolvedValue(makeStaticByteArray(32, 0));
|
||||
cryptoFunctionService.compareFast.mockResolvedValue(true);
|
||||
cryptoFunctionService.aesDecryptFast.mockResolvedValue("data");
|
||||
@@ -290,7 +453,7 @@ describe("EncryptService", () => {
|
||||
expect(cryptoFunctionService.compareFast).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is Aes256Cbc_HmacSha256 but EncString is Aes256Cbc", async () => {
|
||||
it("returns null if key is AesCbc256_HMAC but encstring is AesCbc256", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
|
||||
@@ -299,7 +462,7 @@ describe("EncryptService", () => {
|
||||
expect(logService.error).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null if key is Aes256Cbc but encstring is AesCbc256_HmacSha256", async () => {
|
||||
it("returns null if key is AesCbc256 but encstring is AesCbc256_HMAC", async () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "data", "iv", "mac");
|
||||
|
||||
@@ -332,10 +495,7 @@ describe("EncryptService", () => {
|
||||
);
|
||||
});
|
||||
it("returns null if key is mac key but encstring has no mac", async () => {
|
||||
const key = new SymmetricCryptoKey(
|
||||
makeStaticByteArray(64, 0),
|
||||
EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
);
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64, 0));
|
||||
const encString = new EncString(EncryptionType.AesCbc256_B64, "data");
|
||||
|
||||
const actual = await encryptService.decryptToUtf8(encString, key);
|
||||
@@ -345,7 +505,8 @@ describe("EncryptService", () => {
|
||||
});
|
||||
|
||||
describe("rsa", () => {
|
||||
const data = makeStaticByteArray(10, 100);
|
||||
const data = makeStaticByteArray(64, 100);
|
||||
const testKey = new SymmetricCryptoKey(data);
|
||||
const encryptedData = makeStaticByteArray(10, 150);
|
||||
const publicKey = makeStaticByteArray(10, 200);
|
||||
const privateKey = makeStaticByteArray(10, 250);
|
||||
@@ -355,22 +516,26 @@ describe("EncryptService", () => {
|
||||
return new EncString(EncryptionType.Rsa2048_OaepSha1_B64, Utils.fromBufferToB64(data));
|
||||
}
|
||||
|
||||
describe("rsaEncrypt", () => {
|
||||
describe("encapsulateKeyUnsigned", () => {
|
||||
it("throws if no data is provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(null, publicKey)).rejects.toThrow("No data");
|
||||
return expect(encryptService.encapsulateKeyUnsigned(null, publicKey)).rejects.toThrow(
|
||||
"No sharedKey provided for encapsulation",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no public key is provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(data, null)).rejects.toThrow("No public key");
|
||||
return expect(encryptService.encapsulateKeyUnsigned(testKey, null)).rejects.toThrow(
|
||||
"No public key",
|
||||
);
|
||||
});
|
||||
|
||||
it("encrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaEncrypt.mockResolvedValue(encryptedData);
|
||||
|
||||
const actual = await encryptService.rsaEncrypt(data, publicKey);
|
||||
const actual = await encryptService.encapsulateKeyUnsigned(testKey, publicKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaEncrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(data),
|
||||
expect.toEqualBuffer(testKey.key),
|
||||
expect.toEqualBuffer(publicKey),
|
||||
"sha1",
|
||||
);
|
||||
@@ -378,15 +543,25 @@ describe("EncryptService", () => {
|
||||
expect(actual).toEqual(encString);
|
||||
expect(actual.dataBytes).toEqualBuffer(encryptedData);
|
||||
});
|
||||
|
||||
it("throws if no data was provided", () => {
|
||||
return expect(encryptService.rsaEncrypt(null, new Uint8Array(32))).rejects.toThrow(
|
||||
"No data provided for encryption",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rsaDecrypt", () => {
|
||||
describe("decapsulateKeyUnsigned", () => {
|
||||
it("throws if no data is provided", () => {
|
||||
return expect(encryptService.rsaDecrypt(null, privateKey)).rejects.toThrow("No data");
|
||||
return expect(encryptService.decapsulateKeyUnsigned(null, privateKey)).rejects.toThrow(
|
||||
"No data",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws if no private key is provided", () => {
|
||||
return expect(encryptService.rsaDecrypt(encString, null)).rejects.toThrow("No private key");
|
||||
return expect(encryptService.decapsulateKeyUnsigned(encString, null)).rejects.toThrow(
|
||||
"No private key",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([EncryptionType.AesCbc256_B64, EncryptionType.AesCbc256_HmacSha256_B64])(
|
||||
@@ -394,16 +569,16 @@ describe("EncryptService", () => {
|
||||
async (encType) => {
|
||||
encString.encryptionType = encType;
|
||||
|
||||
await expect(encryptService.rsaDecrypt(encString, privateKey)).rejects.toThrow(
|
||||
"Invalid encryption type",
|
||||
);
|
||||
await expect(
|
||||
encryptService.decapsulateKeyUnsigned(encString, privateKey),
|
||||
).rejects.toThrow("Invalid encryption type");
|
||||
},
|
||||
);
|
||||
|
||||
it("decrypts data with provided key", async () => {
|
||||
cryptoFunctionService.rsaDecrypt.mockResolvedValue(data);
|
||||
|
||||
const actual = await encryptService.rsaDecrypt(makeEncString(data), privateKey);
|
||||
const actual = await encryptService.decapsulateKeyUnsigned(makeEncString(data), privateKey);
|
||||
|
||||
expect(cryptoFunctionService.rsaDecrypt).toBeCalledWith(
|
||||
expect.toEqualBuffer(data),
|
||||
@@ -411,7 +586,7 @@ describe("EncryptService", () => {
|
||||
"sha1",
|
||||
);
|
||||
|
||||
expect(actual).toEqualBuffer(data);
|
||||
expect(actual.key).toEqualBuffer(data);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,26 +2,34 @@
|
||||
// @ts-strict-ignore
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
|
||||
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { ConsoleLogService } from "../../../platform/services/console-log.service";
|
||||
import { ContainerService } from "../../../platform/services/container.service";
|
||||
import { getClassInitializer } from "../../../platform/services/cryptography/get-class-initializer";
|
||||
import {
|
||||
DECRYPT_COMMAND,
|
||||
SET_CONFIG_COMMAND,
|
||||
ParsedDecryptCommandData,
|
||||
} from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { WebCryptoFunctionService } from "./web-crypto-function.service";
|
||||
|
||||
const workerApi: Worker = self as any;
|
||||
|
||||
let inited = false;
|
||||
let encryptService: EncryptServiceImplementation;
|
||||
let logService: LogService;
|
||||
|
||||
/**
|
||||
* Bootstrap the worker environment with services required for decryption
|
||||
*/
|
||||
export function init() {
|
||||
const cryptoFunctionService = new WebCryptoFunctionService(self);
|
||||
const logService = new ConsoleLogService(false);
|
||||
logService = new ConsoleLogService(false);
|
||||
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
|
||||
const bitwardenContainerService = new ContainerService(null, encryptService);
|
||||
@@ -39,11 +47,22 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
}
|
||||
|
||||
const request: {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
command: string;
|
||||
} = JSON.parse(event.data);
|
||||
|
||||
switch (request.command) {
|
||||
case DECRYPT_COMMAND:
|
||||
return await handleDecrypt(request as unknown as ParsedDecryptCommandData);
|
||||
case SET_CONFIG_COMMAND: {
|
||||
const newConfig = (request as unknown as { newConfig: Jsonify<ServerConfig> }).newConfig;
|
||||
return await handleSetConfig(newConfig);
|
||||
}
|
||||
default:
|
||||
logService.error(`[EncryptWorker] unknown worker command`, request.command, request);
|
||||
}
|
||||
});
|
||||
|
||||
async function handleDecrypt(request: ParsedDecryptCommandData) {
|
||||
const key = SymmetricCryptoKey.fromJSON(request.key);
|
||||
const items = request.items.map((jsonItem) => {
|
||||
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
|
||||
@@ -55,4 +74,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
|
||||
id: request.id,
|
||||
items: JSON.stringify(result),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSetConfig(newConfig: Jsonify<ServerConfig>) {
|
||||
encryptService.onServerConfigChange(ServerConfig.fromJSON(newConfig));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { BulkEncryptService } from "../abstractions/bulk-encrypt.service";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service";
|
||||
|
||||
describe("FallbackBulkEncryptService", () => {
|
||||
const encryptService = mock<EncryptService>();
|
||||
const featureFlagEncryptService = mock<BulkEncryptService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: FallbackBulkEncryptService;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new FallbackBulkEncryptService(encryptService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[];
|
||||
const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[];
|
||||
|
||||
it("calls decryptItems on featureFlagEncryptService when it is set", async () => {
|
||||
featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(encryptService.decryptItems).not.toHaveBeenCalled();
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
|
||||
it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => {
|
||||
encryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
|
||||
|
||||
const result = await sut.decryptItems(mockItems, key);
|
||||
|
||||
expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
|
||||
expect(result).toEqual(mockDecryptedItems);
|
||||
});
|
||||
});
|
||||
|
||||
describe("setFeatureFlagEncryptService", () => {
|
||||
it("sets the featureFlagEncryptService property", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("does not call onServerConfigChange when currentServerConfig is undefined", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange with currentServerConfig when it is defined", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config", async () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => {
|
||||
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
expect(encryptService.onServerConfigChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => {
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.i
|
||||
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { EncryptService } from "../abstractions/encrypt.service";
|
||||
|
||||
/**
|
||||
@@ -12,6 +13,7 @@ import { EncryptService } from "../abstractions/encrypt.service";
|
||||
*/
|
||||
export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
private featureFlagEncryptService: BulkEncryptService;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
constructor(protected encryptService: EncryptService) {}
|
||||
|
||||
@@ -31,6 +33,14 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
|
||||
}
|
||||
|
||||
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
featureFlagEncryptService.onServerConfigChange(this.currentServerConfig);
|
||||
}
|
||||
this.featureFlagEncryptService = featureFlagEncryptService;
|
||||
}
|
||||
|
||||
onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import * as rxjs from "rxjs";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation";
|
||||
|
||||
describe("MultithreadEncryptServiceImplementation", () => {
|
||||
const cryptoFunctionService = mock<CryptoFunctionService>();
|
||||
const logService = mock<LogService>();
|
||||
const serverConfig = mock<ServerConfig>();
|
||||
|
||||
let sut: MultithreadEncryptServiceImplementation;
|
||||
|
||||
beforeEach(() => {
|
||||
sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("decryptItems", () => {
|
||||
const key = mock<SymmetricCryptoKey>();
|
||||
const mockWorker = mock<Worker>();
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock creating a worker.
|
||||
global.Worker = jest.fn().mockImplementation(() => mockWorker);
|
||||
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
|
||||
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
|
||||
global.URL.revokeObjectURL = jest.fn();
|
||||
global.URL.canParse = jest.fn().mockReturnValue(true);
|
||||
|
||||
// Mock the workers returned response.
|
||||
const mockMessageEvent = {
|
||||
id: "mock-guid",
|
||||
data: ["decrypted1", "decrypted2"],
|
||||
};
|
||||
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
|
||||
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
|
||||
});
|
||||
|
||||
it("returns empty array if items is null", async () => {
|
||||
const items = null as unknown as Decryptable<any>[];
|
||||
const result = await sut.decryptItems(items, key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array if items is empty", async () => {
|
||||
const result = await sut.decryptItems([], key);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates worker if none exists", async () => {
|
||||
// Make sure currentServerConfig is undefined so a SetConfigMessage is not sent.
|
||||
(sut as any).currentServerConfig = undefined;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
|
||||
// Populate currentServerConfig so a SetConfigMessage is sent.
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not create worker if one exists", async () => {
|
||||
(sut as any).currentServerConfig = serverConfig;
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
|
||||
|
||||
expect(global.Worker).not.toHaveBeenCalled();
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("onServerConfigChange", () => {
|
||||
it("updates internal currentServerConfig to new config and calls super", () => {
|
||||
const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange");
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(superSpy).toHaveBeenCalledWith(serverConfig);
|
||||
expect((sut as any).currentServerConfig).toBe(serverConfig);
|
||||
});
|
||||
|
||||
it("sends config update to worker if worker exists", () => {
|
||||
const mockWorker = mock<Worker>();
|
||||
(sut as any).worker = mockWorker;
|
||||
|
||||
sut.onServerConfigChange(serverConfig);
|
||||
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
|
||||
expect(mockWorker.postMessage).toHaveBeenCalledWith(
|
||||
buildSetConfigMessage({ newConfig: serverConfig }),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
|
||||
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
|
||||
|
||||
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
|
||||
|
||||
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
|
||||
@@ -20,6 +23,7 @@ const workerTTL = 3 * 60000; // 3 minutes
|
||||
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
|
||||
private worker: Worker;
|
||||
private timeout: any;
|
||||
private currentServerConfig: ServerConfig | undefined = undefined;
|
||||
|
||||
private clear$ = new Subject<void>();
|
||||
|
||||
@@ -37,27 +41,33 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
|
||||
this.logService.info("Starting decryption using multithreading");
|
||||
|
||||
this.worker ??= new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
if (this.worker == null) {
|
||||
this.worker = new Worker(
|
||||
new URL(
|
||||
/* webpackChunkName: 'encrypt-worker' */
|
||||
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
|
||||
import.meta.url,
|
||||
),
|
||||
);
|
||||
if (this.currentServerConfig !== undefined) {
|
||||
this.updateWorkerServerConfig(this.currentServerConfig);
|
||||
}
|
||||
}
|
||||
|
||||
this.restartTimeout();
|
||||
|
||||
const request = {
|
||||
id: Utils.newGuid(),
|
||||
const id = Utils.newGuid();
|
||||
const request = buildDecryptMessage({
|
||||
id,
|
||||
items: items,
|
||||
key: key,
|
||||
};
|
||||
});
|
||||
|
||||
this.worker.postMessage(JSON.stringify(request));
|
||||
this.worker.postMessage(request);
|
||||
|
||||
return await firstValueFrom(
|
||||
fromEvent(this.worker, "message").pipe(
|
||||
filter((response: MessageEvent) => response.data?.id === request.id),
|
||||
filter((response: MessageEvent) => response.data?.id === id),
|
||||
map((response) => JSON.parse(response.data.items)),
|
||||
map((items) =>
|
||||
items.map((jsonItem: Jsonify<T>) => {
|
||||
@@ -71,6 +81,19 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
|
||||
);
|
||||
}
|
||||
|
||||
override onServerConfigChange(newConfig: ServerConfig): void {
|
||||
this.currentServerConfig = newConfig;
|
||||
super.onServerConfigChange(newConfig);
|
||||
this.updateWorkerServerConfig(newConfig);
|
||||
}
|
||||
|
||||
private updateWorkerServerConfig(newConfig: ServerConfig) {
|
||||
if (this.worker != null) {
|
||||
const request = buildSetConfigMessage({ newConfig });
|
||||
this.worker.postMessage(request);
|
||||
}
|
||||
}
|
||||
|
||||
private clear() {
|
||||
this.clear$.next();
|
||||
this.worker?.terminate();
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { PlatformUtilsService } from "../abstractions/platform-utils.service";
|
||||
import { EcbDecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EcbDecryptParameters } from "../../../platform/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import { WebCryptoFunctionService } from "./web-crypto-function.service";
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import * as argon2 from "argon2-browser";
|
||||
import * as forge from "node-forge";
|
||||
|
||||
import { Utils } from "../../platform/misc/utils";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { EncryptionType } from "../../../platform/enums";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import {
|
||||
CbcDecryptParameters,
|
||||
EcbDecryptParameters,
|
||||
} from "../../../platform/models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { CbcDecryptParameters, EcbDecryptParameters } from "../models/domain/decrypt-parameters";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
private crypto: Crypto;
|
||||
@@ -244,37 +248,26 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
|
||||
mac: string | null,
|
||||
key: SymmetricCryptoKey,
|
||||
): CbcDecryptParameters<string> {
|
||||
const p = {} as CbcDecryptParameters<string>;
|
||||
if (key.meta != null) {
|
||||
p.encKey = key.meta.encKeyByteString;
|
||||
p.macKey = key.meta.macKeyByteString;
|
||||
const innerKey = key.inner();
|
||||
if (innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
return {
|
||||
iv: forge.util.decode64(iv),
|
||||
data: forge.util.decode64(data),
|
||||
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
|
||||
} as CbcDecryptParameters<string>;
|
||||
} else if (innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const macData = forge.util.decode64(iv) + forge.util.decode64(data);
|
||||
return {
|
||||
iv: forge.util.decode64(iv),
|
||||
data: forge.util.decode64(data),
|
||||
encKey: forge.util.createBuffer(innerKey.encryptionKey).getBytes(),
|
||||
macKey: forge.util.createBuffer(innerKey.authenticationKey).getBytes(),
|
||||
mac: forge.util.decode64(mac!),
|
||||
macData,
|
||||
} as CbcDecryptParameters<string>;
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type.");
|
||||
}
|
||||
|
||||
if (p.encKey == null) {
|
||||
p.encKey = forge.util.decode64(key.encKeyB64);
|
||||
}
|
||||
p.data = forge.util.decode64(data);
|
||||
p.iv = forge.util.decode64(iv);
|
||||
p.macData = p.iv + p.data;
|
||||
if (p.macKey == null && key.macKeyB64 != null) {
|
||||
p.macKey = forge.util.decode64(key.macKeyB64);
|
||||
}
|
||||
if (mac != null) {
|
||||
p.mac = forge.util.decode64(mac);
|
||||
}
|
||||
|
||||
// cache byte string keys for later
|
||||
if (key.meta == null) {
|
||||
key.meta = {};
|
||||
}
|
||||
if (key.meta.encKeyByteString == null) {
|
||||
key.meta.encKeyByteString = p.encKey;
|
||||
}
|
||||
if (p.macKey != null && key.meta.macKeyByteString == null) {
|
||||
key.meta.macKeyByteString = p.macKey;
|
||||
}
|
||||
|
||||
return p;
|
||||
}
|
||||
|
||||
aesDecryptFast({
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
import {
|
||||
DECRYPT_COMMAND,
|
||||
DecryptCommandData,
|
||||
SET_CONFIG_COMMAND,
|
||||
buildDecryptMessage,
|
||||
buildSetConfigMessage,
|
||||
} from "./worker-command.type";
|
||||
|
||||
describe("Worker command types", () => {
|
||||
describe("buildDecryptMessage", () => {
|
||||
it("builds a message with the correct command", () => {
|
||||
const commandData = createDecryptCommandData();
|
||||
|
||||
const result = buildDecryptMessage(commandData);
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
|
||||
});
|
||||
|
||||
it("includes the provided data in the message", () => {
|
||||
const mockItems = [{ encrypted: "test-encrypted" } as unknown as Decryptable<any>];
|
||||
const commandData = createDecryptCommandData(mockItems);
|
||||
|
||||
const result = buildDecryptMessage(commandData);
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
|
||||
expect(parsedResult.id).toBe("test-id");
|
||||
expect(parsedResult.items).toEqual(mockItems);
|
||||
expect(SymmetricCryptoKey.fromJSON(parsedResult.key)).toEqual(commandData.key);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSetConfigMessage", () => {
|
||||
it("builds a message with the correct command", () => {
|
||||
const result = buildSetConfigMessage({ newConfig: mock<ServerConfig>() });
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
|
||||
});
|
||||
|
||||
it("includes the provided data in the message", () => {
|
||||
const serverConfig = { version: "test-version" } as unknown as ServerConfig;
|
||||
|
||||
const result = buildSetConfigMessage({ newConfig: serverConfig });
|
||||
|
||||
const parsedResult = JSON.parse(result);
|
||||
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
|
||||
expect(ServerConfig.fromJSON(parsedResult.newConfig).version).toEqual(serverConfig.version);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createDecryptCommandData(items?: Decryptable<any>[]): DecryptCommandData {
|
||||
return {
|
||||
id: "test-id",
|
||||
items: items ?? [],
|
||||
key: new SymmetricCryptoKey(makeStaticByteArray(64)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
|
||||
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export const DECRYPT_COMMAND = "decrypt";
|
||||
export const SET_CONFIG_COMMAND = "updateConfig";
|
||||
|
||||
export type DecryptCommandData = {
|
||||
id: string;
|
||||
items: Decryptable<any>[];
|
||||
key: SymmetricCryptoKey;
|
||||
};
|
||||
|
||||
export type ParsedDecryptCommandData = {
|
||||
id: string;
|
||||
items: Jsonify<Decryptable<any>>[];
|
||||
key: Jsonify<SymmetricCryptoKey>;
|
||||
};
|
||||
|
||||
type SetConfigCommandData = { newConfig: ServerConfig };
|
||||
|
||||
export function buildDecryptMessage(data: DecryptCommandData): string {
|
||||
return JSON.stringify({
|
||||
command: DECRYPT_COMMAND,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSetConfigMessage(data: SetConfigCommandData): string {
|
||||
return JSON.stringify({
|
||||
command: SET_CONFIG_COMMAND,
|
||||
...data,
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { DeviceKeysUpdateRequest } from "@bitwarden/common/auth/models/request/update-devices-trust.request";
|
||||
|
||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||
import { EncString } from "../../../platform/models/domain/enc-string";
|
||||
import { UserId } from "../../../types/guid";
|
||||
@@ -55,4 +57,9 @@ export abstract class DeviceTrustServiceAbstraction {
|
||||
* Note: For debugging purposes only.
|
||||
*/
|
||||
recordDeviceTrustLoss: () => Promise<void>;
|
||||
getRotatedData: (
|
||||
oldUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
) => Promise<DeviceKeysUpdateRequest[]>;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// @ts-strict-ignore
|
||||
import { firstValueFrom, map, Observable, Subject } from "rxjs";
|
||||
|
||||
import { UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { RotateableKeySet, UserDecryptionOptionsServiceAbstraction } from "@bitwarden/auth/common";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { DeviceResponse } from "../../../auth/abstractions/devices/responses/device.response";
|
||||
@@ -10,11 +10,11 @@ import { DevicesApiServiceAbstraction } from "../../../auth/abstractions/devices
|
||||
import { SecretVerificationRequest } from "../../../auth/models/request/secret-verification.request";
|
||||
import {
|
||||
DeviceKeysUpdateRequest,
|
||||
OtherDeviceKeysUpdateRequest,
|
||||
UpdateDevicesTrustRequest,
|
||||
} from "../../../auth/models/request/update-devices-trust.request";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
@@ -27,6 +27,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
|
||||
import { DEVICE_TRUST_DISK_LOCAL, StateProvider, UserKeyDefinition } from "../../../platform/state";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { UserKey, DeviceKey } from "../../../types/key";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
import { DeviceTrustServiceAbstraction } from "../abstractions/device-trust.service.abstraction";
|
||||
|
||||
@@ -160,13 +161,13 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
deviceKeyEncryptedDevicePrivateKey,
|
||||
] = await Promise.all([
|
||||
// Encrypt user key with the DevicePublicKey
|
||||
this.encryptService.rsaEncrypt(userKey.key, devicePublicKey),
|
||||
this.encryptService.encapsulateKeyUnsigned(userKey, devicePublicKey),
|
||||
|
||||
// Encrypt devicePublicKey with user key
|
||||
this.encryptService.encrypt(devicePublicKey, userKey),
|
||||
this.encryptService.wrapEncapsulationKey(devicePublicKey, userKey),
|
||||
|
||||
// Encrypt devicePrivateKey with deviceKey
|
||||
this.encryptService.encrypt(devicePrivateKey, deviceKey),
|
||||
this.encryptService.wrapDecapsulationKey(devicePrivateKey, deviceKey),
|
||||
]);
|
||||
|
||||
// Send encrypted keys to server
|
||||
@@ -187,6 +188,64 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
return deviceResponse;
|
||||
}
|
||||
|
||||
async getRotatedData(
|
||||
oldUserKey: UserKey,
|
||||
newUserKey: UserKey,
|
||||
userId: UserId,
|
||||
): Promise<DeviceKeysUpdateRequest[]> {
|
||||
if (!userId) {
|
||||
throw new Error("UserId is required. Cannot get rotated data.");
|
||||
}
|
||||
if (!oldUserKey) {
|
||||
throw new Error("Old user key is required. Cannot get rotated data.");
|
||||
}
|
||||
if (!newUserKey) {
|
||||
throw new Error("New user key is required. Cannot get rotated data.");
|
||||
}
|
||||
|
||||
const devices = await this.devicesApiService.getDevices();
|
||||
const devicesToUntrust: string[] = [];
|
||||
const rotatedData = await Promise.all(
|
||||
devices.data
|
||||
.filter((device) => device.isTrusted)
|
||||
.map(async (device) => {
|
||||
const publicKey = await this.encryptService.decryptToBytes(
|
||||
new EncString(device.encryptedPublicKey),
|
||||
oldUserKey,
|
||||
);
|
||||
|
||||
if (!publicKey) {
|
||||
// Device was trusted but encryption is broken. This should be untrusted
|
||||
devicesToUntrust.push(device.id);
|
||||
return null;
|
||||
}
|
||||
|
||||
const newEncryptedPublicKey = await this.encryptService.encrypt(publicKey, newUserKey);
|
||||
const newEncryptedUserKey = await this.encryptService.rsaEncrypt(
|
||||
newUserKey.key,
|
||||
publicKey,
|
||||
);
|
||||
|
||||
const newRotateableKeySet = new RotateableKeySet(
|
||||
newEncryptedUserKey,
|
||||
newEncryptedPublicKey,
|
||||
);
|
||||
|
||||
const request = new OtherDeviceKeysUpdateRequest();
|
||||
request.encryptedPublicKey = newRotateableKeySet.encryptedPublicKey.encryptedString;
|
||||
request.encryptedUserKey = newRotateableKeySet.encryptedUserKey.encryptedString;
|
||||
request.deviceId = device.id;
|
||||
return request;
|
||||
})
|
||||
.filter((otherDeviceKeysUpdateRequest) => otherDeviceKeysUpdateRequest != null),
|
||||
);
|
||||
if (rotatedData.length > 0) {
|
||||
this.logService.info("[Device trust rotation] Distrusting devices that failed to decrypt.");
|
||||
await this.devicesApiService.untrustDevices(devicesToUntrust);
|
||||
}
|
||||
return rotatedData;
|
||||
}
|
||||
|
||||
async rotateDevicesTrust(
|
||||
userId: UserId,
|
||||
newUserKey: UserKey,
|
||||
@@ -216,10 +275,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
secretVerificationRequest.masterPasswordHash = masterPasswordHash;
|
||||
|
||||
// Get the keys that are used in rotating a devices keys from the server
|
||||
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(
|
||||
deviceIdentifier,
|
||||
secretVerificationRequest,
|
||||
);
|
||||
const currentDeviceKeys = await this.devicesApiService.getDeviceKeys(deviceIdentifier);
|
||||
|
||||
// Decrypt the existing device public key with the old user key
|
||||
const decryptedDevicePublicKey = await this.encryptService.decryptToBytes(
|
||||
@@ -228,13 +284,13 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
);
|
||||
|
||||
// Encrypt the brand new user key with the now-decrypted public key for the device
|
||||
const encryptedNewUserKey = await this.encryptService.rsaEncrypt(
|
||||
newUserKey.key,
|
||||
const encryptedNewUserKey = await this.encryptService.encapsulateKeyUnsigned(
|
||||
newUserKey,
|
||||
decryptedDevicePublicKey,
|
||||
);
|
||||
|
||||
// Re-encrypt the device public key with the new user key
|
||||
const encryptedDevicePublicKey = await this.encryptService.encrypt(
|
||||
const encryptedDevicePublicKey = await this.encryptService.wrapEncapsulationKey(
|
||||
decryptedDevicePublicKey,
|
||||
newUserKey,
|
||||
);
|
||||
@@ -344,12 +400,12 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
|
||||
);
|
||||
|
||||
// Attempt to decrypt encryptedUserDataKey with devicePrivateKey
|
||||
const userKey = await this.encryptService.rsaDecrypt(
|
||||
const userKey = await this.encryptService.decapsulateKeyUnsigned(
|
||||
new EncString(encryptedUserKey.encryptedString),
|
||||
devicePrivateKey,
|
||||
);
|
||||
|
||||
return new SymmetricCryptoKey(userKey) as UserKey;
|
||||
return userKey as UserKey;
|
||||
// FIXME: Remove when updating file. Eslint update
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
} catch (e) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
UserDecryptionOptionsServiceAbstraction,
|
||||
UserDecryptionOptions,
|
||||
} from "@bitwarden/auth/common";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { FakeAccountService, mockAccountServiceWith } from "../../../../spec/fake-account-service";
|
||||
@@ -19,7 +20,6 @@ import { ProtectedDeviceResponse } from "../../../auth/models/response/protected
|
||||
import { DeviceType } from "../../../enums";
|
||||
import { AppIdService } from "../../../platform/abstractions/app-id.service";
|
||||
import { ConfigService } from "../../../platform/abstractions/config/config.service";
|
||||
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
|
||||
import { I18nService } from "../../../platform/abstractions/i18n.service";
|
||||
import { KeyGenerationService } from "../../../platform/abstractions/key-generation.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
@@ -34,6 +34,7 @@ import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-cr
|
||||
import { CsprngArray } from "../../../types/csprng";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { DeviceKey, UserKey } from "../../../types/key";
|
||||
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
|
||||
import { EncryptService } from "../../crypto/abstractions/encrypt.service";
|
||||
|
||||
import {
|
||||
@@ -345,8 +346,6 @@ describe("deviceTrustService", () => {
|
||||
|
||||
const deviceRsaKeyLength = 2048;
|
||||
let mockDeviceRsaKeyPair: [Uint8Array, Uint8Array];
|
||||
let mockDevicePrivateKey: Uint8Array;
|
||||
let mockDevicePublicKey: Uint8Array;
|
||||
let mockDevicePublicKeyEncryptedUserKey: EncString;
|
||||
let mockUserKeyEncryptedDevicePublicKey: EncString;
|
||||
let mockDeviceKeyEncryptedDevicePrivateKey: EncString;
|
||||
@@ -365,7 +364,8 @@ describe("deviceTrustService", () => {
|
||||
let rsaGenerateKeyPairSpy: jest.SpyInstance;
|
||||
let cryptoSvcGetUserKeySpy: jest.SpyInstance;
|
||||
let cryptoSvcRsaEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceEncryptSpy: jest.SpyInstance;
|
||||
let encryptServiceWrapDecapsulationKeySpy: jest.SpyInstance;
|
||||
let encryptServiceWrapEncapsulationKeySpy: jest.SpyInstance;
|
||||
let appIdServiceGetAppIdSpy: jest.SpyInstance;
|
||||
let devicesApiServiceUpdateTrustedDeviceKeysSpy: jest.SpyInstance;
|
||||
|
||||
@@ -383,9 +383,6 @@ describe("deviceTrustService", () => {
|
||||
new Uint8Array(deviceRsaKeyLength),
|
||||
];
|
||||
|
||||
mockDevicePublicKey = mockDeviceRsaKeyPair[0];
|
||||
mockDevicePrivateKey = mockDeviceRsaKeyPair[1];
|
||||
|
||||
mockDevicePublicKeyEncryptedUserKey = new EncString(
|
||||
EncryptionType.Rsa2048_OaepSha1_B64,
|
||||
"mockDevicePublicKeyEncryptedUserKey",
|
||||
@@ -415,16 +412,20 @@ describe("deviceTrustService", () => {
|
||||
.mockResolvedValue(mockUserKey);
|
||||
|
||||
cryptoSvcRsaEncryptSpy = jest
|
||||
.spyOn(encryptService, "rsaEncrypt")
|
||||
.spyOn(encryptService, "encapsulateKeyUnsigned")
|
||||
.mockResolvedValue(mockDevicePublicKeyEncryptedUserKey);
|
||||
|
||||
encryptServiceEncryptSpy = jest
|
||||
.spyOn(encryptService, "encrypt")
|
||||
encryptServiceWrapEncapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "wrapEncapsulationKey")
|
||||
.mockImplementation((plainValue, key) => {
|
||||
if (plainValue === mockDevicePublicKey && key === mockUserKey) {
|
||||
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
|
||||
return Promise.resolve(mockUserKeyEncryptedDevicePublicKey);
|
||||
}
|
||||
if (plainValue === mockDevicePrivateKey && key === mockDeviceKey) {
|
||||
});
|
||||
encryptServiceWrapDecapsulationKeySpy = jest
|
||||
.spyOn(encryptService, "wrapDecapsulationKey")
|
||||
.mockImplementation((plainValue, key) => {
|
||||
if (plainValue instanceof Uint8Array && key instanceof SymmetricCryptoKey) {
|
||||
return Promise.resolve(mockDeviceKeyEncryptedDevicePrivateKey);
|
||||
}
|
||||
});
|
||||
@@ -448,10 +449,11 @@ describe("deviceTrustService", () => {
|
||||
expect(cryptoSvcRsaEncryptSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// RsaEncrypt must be called w/ a user key array buffer of 64 bytes
|
||||
const userKeyKey: Uint8Array = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
|
||||
expect(userKeyKey.byteLength).toBe(64);
|
||||
const userKey = cryptoSvcRsaEncryptSpy.mock.calls[0][0];
|
||||
expect(userKey.key.byteLength).toBe(64);
|
||||
|
||||
expect(encryptServiceEncryptSpy).toHaveBeenCalledTimes(2);
|
||||
expect(encryptServiceWrapDecapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
expect(encryptServiceWrapEncapsulationKeySpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
expect(appIdServiceGetAppIdSpy).toHaveBeenCalledTimes(1);
|
||||
expect(devicesApiServiceUpdateTrustedDeviceKeysSpy).toHaveBeenCalledTimes(1);
|
||||
@@ -507,9 +509,14 @@ describe("deviceTrustService", () => {
|
||||
errorText: "rsaEncrypt error",
|
||||
},
|
||||
{
|
||||
method: "encryptService.encrypt",
|
||||
spy: () => encryptServiceEncryptSpy,
|
||||
errorText: "encryptService.encrypt error",
|
||||
method: "encryptService.wrapEncapsulationKey",
|
||||
spy: () => encryptServiceWrapEncapsulationKeySpy,
|
||||
errorText: "encryptService.wrapEncapsulationKey error",
|
||||
},
|
||||
{
|
||||
method: "encryptService.wrapDecapsulationKey",
|
||||
spy: () => encryptServiceWrapDecapsulationKeySpy,
|
||||
errorText: "encryptService.wrapDecapsulationKey error",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -609,7 +616,7 @@ describe("deviceTrustService", () => {
|
||||
mockUserId,
|
||||
mockEncryptedDevicePrivateKey,
|
||||
mockEncryptedUserKey,
|
||||
mockDeviceKey,
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
@@ -620,8 +627,8 @@ describe("deviceTrustService", () => {
|
||||
.spyOn(encryptService, "decryptToBytes")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
const rsaDecryptSpy = jest
|
||||
.spyOn(encryptService, "rsaDecrypt")
|
||||
.mockResolvedValue(new Uint8Array(userKeyBytesLength));
|
||||
.spyOn(encryptService, "decapsulateKeyUnsigned")
|
||||
.mockResolvedValue(new SymmetricCryptoKey(new Uint8Array(userKeyBytesLength)));
|
||||
|
||||
const result = await deviceTrustService.decryptUserKeyWithDeviceKey(
|
||||
mockUserId,
|
||||
@@ -655,6 +662,132 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRotatedData", () => {
|
||||
let fakeNewUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
let fakeOldUserKey: UserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
|
||||
const userId: UserId = Utils.newGuid() as UserId;
|
||||
|
||||
it("throws an error when a null user id is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(fakeOldUserKey, fakeNewUserKey, null),
|
||||
).rejects.toThrow("UserId is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("throws an error when a null old user key is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(null, fakeNewUserKey, userId),
|
||||
).rejects.toThrow("Old user key is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("throws an error when a null new user key is passed in", async () => {
|
||||
await expect(
|
||||
deviceTrustService.getRotatedData(fakeOldUserKey, null, userId),
|
||||
).rejects.toThrow("New user key is required. Cannot get rotated data.");
|
||||
});
|
||||
|
||||
it("untrusts devices that failed to decrypt", async () => {
|
||||
const deviceResponse = {
|
||||
id: "id",
|
||||
userId: "",
|
||||
name: "",
|
||||
identifier: "",
|
||||
type: DeviceType.Android,
|
||||
creationDate: "",
|
||||
revisionDate: "",
|
||||
isTrusted: true,
|
||||
};
|
||||
devicesApiService.getDevices.mockResolvedValue(
|
||||
new ListResponse(
|
||||
{
|
||||
data: [deviceResponse],
|
||||
},
|
||||
DeviceResponse,
|
||||
),
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(null);
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
|
||||
const protectedDeviceResponse = new ProtectedDeviceResponse({
|
||||
id: "id",
|
||||
creationDate: "",
|
||||
identifier: "test_device_identifier",
|
||||
name: "Firefox",
|
||||
type: DeviceType.FirefoxBrowser,
|
||||
encryptedPublicKey: "",
|
||||
encryptedUserKey: "",
|
||||
});
|
||||
devicesApiService.getDeviceKeys.mockResolvedValue(protectedDeviceResponse);
|
||||
|
||||
const fakeOldUserKeyData = new Uint8Array(64);
|
||||
fakeOldUserKeyData.fill(5, 0, 1);
|
||||
fakeOldUserKey = new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey;
|
||||
const fakeNewUserKeyData = new Uint8Array(64);
|
||||
fakeNewUserKeyData.fill(1, 0, 1);
|
||||
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
|
||||
|
||||
await deviceTrustService.getRotatedData(fakeOldUserKey, fakeNewUserKey, userId);
|
||||
|
||||
expect(devicesApiService.untrustDevices).toHaveBeenCalledWith(["id"]);
|
||||
});
|
||||
|
||||
it("returns the expected data when all required parameters are provided", async () => {
|
||||
const deviceResponse = {
|
||||
id: "",
|
||||
userId: "",
|
||||
name: "",
|
||||
identifier: "",
|
||||
type: DeviceType.Android,
|
||||
creationDate: "",
|
||||
revisionDate: "",
|
||||
isTrusted: true,
|
||||
};
|
||||
devicesApiService.getDevices.mockResolvedValue(
|
||||
new ListResponse(
|
||||
{
|
||||
data: [deviceResponse],
|
||||
},
|
||||
DeviceResponse,
|
||||
),
|
||||
);
|
||||
encryptService.decryptToBytes.mockResolvedValue(new Uint8Array(64));
|
||||
encryptService.encrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
encryptService.rsaEncrypt.mockResolvedValue(new EncString("test_encrypted_data"));
|
||||
|
||||
const protectedDeviceResponse = new ProtectedDeviceResponse({
|
||||
id: "",
|
||||
creationDate: "",
|
||||
identifier: "test_device_identifier",
|
||||
name: "Firefox",
|
||||
type: DeviceType.FirefoxBrowser,
|
||||
encryptedPublicKey: "",
|
||||
encryptedUserKey: "",
|
||||
});
|
||||
devicesApiService.getDeviceKeys.mockResolvedValue(protectedDeviceResponse);
|
||||
const fakeOldUserKeyData = new Uint8Array(64);
|
||||
fakeOldUserKeyData.fill(5, 0, 1);
|
||||
fakeOldUserKey = new SymmetricCryptoKey(fakeOldUserKeyData) as UserKey;
|
||||
|
||||
const fakeNewUserKeyData = new Uint8Array(64);
|
||||
fakeNewUserKeyData.fill(1, 0, 1);
|
||||
fakeNewUserKey = new SymmetricCryptoKey(fakeNewUserKeyData) as UserKey;
|
||||
|
||||
const result = await deviceTrustService.getRotatedData(
|
||||
fakeOldUserKey,
|
||||
fakeNewUserKey,
|
||||
userId,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
deviceId: "",
|
||||
encryptedUserKey: "test_encrypted_data",
|
||||
encryptedPublicKey: "test_encrypted_data",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rotateDevicesTrust", () => {
|
||||
let fakeNewUserKey: UserKey = null;
|
||||
|
||||
@@ -708,11 +841,8 @@ describe("deviceTrustService", () => {
|
||||
|
||||
appIdService.getAppId.mockResolvedValue("test_device_identifier");
|
||||
|
||||
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier, secretRequest) => {
|
||||
if (
|
||||
deviceIdentifier !== "test_device_identifier" ||
|
||||
secretRequest.masterPasswordHash !== "my_password_hash"
|
||||
) {
|
||||
devicesApiService.getDeviceKeys.mockImplementation((deviceIdentifier) => {
|
||||
if (deviceIdentifier !== "test_device_identifier") {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
@@ -739,16 +869,16 @@ describe("deviceTrustService", () => {
|
||||
});
|
||||
|
||||
// Mock the encryption of the new user key with the decrypted public key
|
||||
encryptService.rsaEncrypt.mockImplementationOnce((data, publicKey) => {
|
||||
expect(data.byteLength).toBe(64); // New key should also be 64 bytes
|
||||
expect(new Uint8Array(data)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
|
||||
encryptService.encapsulateKeyUnsigned.mockImplementationOnce((data, publicKey) => {
|
||||
expect(data.key.byteLength).toBe(64); // New key should also be 64 bytes
|
||||
expect(new Uint8Array(data.key)[0]).toBe(FakeNewUserKeyMarker); // New key should have the first byte be '1';
|
||||
|
||||
expect(new Uint8Array(publicKey)[0]).toBe(FakeDecryptedPublicKeyMarker);
|
||||
return Promise.resolve(new EncString("4.ZW5jcnlwdGVkdXNlcg=="));
|
||||
});
|
||||
|
||||
// Mock the reencryption of the device public key with the new user key
|
||||
encryptService.encrypt.mockImplementationOnce((plainValue, key) => {
|
||||
encryptService.wrapEncapsulationKey.mockImplementationOnce((plainValue, key) => {
|
||||
expect(plainValue).toBeInstanceOf(Uint8Array);
|
||||
expect(new Uint8Array(plainValue as Uint8Array)[0]).toBe(FakeDecryptedPublicKeyMarker);
|
||||
|
||||
|
||||
@@ -252,7 +252,9 @@ describe("KeyConnectorService", () => {
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
masterPasswordService.masterKeySubject.next(masterKey);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
|
||||
jest.spyOn(keyConnectorService, "getManagingOrganization").mockResolvedValue(organization);
|
||||
jest.spyOn(apiService, "postUserKeyToKeyConnector").mockResolvedValue();
|
||||
@@ -273,7 +275,9 @@ describe("KeyConnectorService", () => {
|
||||
// Arrange
|
||||
const organization = organizationData(true, true, "https://key-connector-url.com", 2, false);
|
||||
const masterKey = getMockMasterKey();
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
const error = new Error("Failed to post user key to key connector");
|
||||
organizationService.organizations$.mockReturnValue(of([organization]));
|
||||
|
||||
|
||||
@@ -95,7 +95,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
userId ??= (await firstValueFrom(this.accountService.activeAccount$))?.id;
|
||||
const organization = await this.getManagingOrganization(userId);
|
||||
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(userId));
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
|
||||
try {
|
||||
await this.apiService.postUserKeyToKeyConnector(
|
||||
@@ -157,7 +159,9 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
|
||||
await this.tokenService.getEmail(),
|
||||
kdfConfig,
|
||||
);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(masterKey.encKeyB64);
|
||||
const keyConnectorRequest = new KeyConnectorUserKeyRequest(
|
||||
Utils.fromBufferToB64(masterKey.inner().encryptionKey),
|
||||
);
|
||||
await this.masterPasswordService.setMasterKey(masterKey, userId);
|
||||
|
||||
const userKey = await this.keyService.makeUserKey(masterKey);
|
||||
|
||||
@@ -163,19 +163,6 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
|
||||
throw new Error("No master key found.");
|
||||
}
|
||||
|
||||
// Try one more way to get the user key if it still wasn't found.
|
||||
if (userKey == null) {
|
||||
const deprecatedKey = await this.stateService.getEncryptedCryptoSymmetricKey({
|
||||
userId: userId,
|
||||
});
|
||||
|
||||
if (deprecatedKey == null) {
|
||||
throw new Error("No encrypted user key found.");
|
||||
}
|
||||
|
||||
userKey = new EncString(deprecatedKey);
|
||||
}
|
||||
|
||||
let decUserKey: Uint8Array;
|
||||
|
||||
if (userKey.encryptionType === EncryptionType.AesCbc256_B64) {
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"returns $expected when policy is $policy, and user preference is $userPreference",
|
||||
async ({ policy, userPreference, expected }) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
@@ -213,7 +213,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
userDecryptionOptionsSubject.next(
|
||||
new UserDecryptionOptions({ hasMasterPassword: false }),
|
||||
);
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { action: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
@@ -257,7 +257,7 @@ describe("VaultTimeoutSettingsService", () => {
|
||||
"when policy is %s, and vault timeout is %s, returns %s",
|
||||
async (policy, vaultTimeout, expected) => {
|
||||
userDecryptionOptionsSubject.next(new UserDecryptionOptions({ hasMasterPassword: true }));
|
||||
policyService.getAll$.mockReturnValue(
|
||||
policyService.policiesByType$.mockReturnValue(
|
||||
of(policy === null ? [] : ([{ data: { minutes: policy } }] as unknown as Policy[])),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
distinctUntilChanged,
|
||||
firstValueFrom,
|
||||
from,
|
||||
map,
|
||||
shareReplay,
|
||||
switchMap,
|
||||
tap,
|
||||
@@ -24,6 +23,7 @@ import { BiometricStateService, KeyService } from "@bitwarden/key-management";
|
||||
import { PolicyService } from "../../../admin-console/abstractions/policy/policy.service.abstraction";
|
||||
import { PolicyType } from "../../../admin-console/enums";
|
||||
import { Policy } from "../../../admin-console/models/domain/policy";
|
||||
import { getFirstPolicy } from "../../../admin-console/services/policy/default-policy.service";
|
||||
import { AccountService } from "../../../auth/abstractions/account.service";
|
||||
import { TokenService } from "../../../auth/abstractions/token.service";
|
||||
import { LogService } from "../../../platform/abstractions/log.service";
|
||||
@@ -266,8 +266,8 @@ export class VaultTimeoutSettingsService implements VaultTimeoutSettingsServiceA
|
||||
}
|
||||
|
||||
return this.policyService
|
||||
.getAll$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(map((policies) => policies[0] ?? null));
|
||||
.policiesByType$(PolicyType.MaximumVaultTimeout, userId)
|
||||
.pipe(getFirstPolicy);
|
||||
}
|
||||
|
||||
private async getAvailableVaultTimeoutActions(userId?: string): Promise<VaultTimeoutAction[]> {
|
||||
|
||||
@@ -147,7 +147,6 @@ export class VaultTimeoutService implements VaultTimeoutServiceAbstraction {
|
||||
await this.masterPasswordService.clearMasterKey(lockingUserId);
|
||||
|
||||
await this.stateService.setUserKeyAutoUnlock(null, { userId: lockingUserId });
|
||||
await this.stateService.setCryptoMasterKeyAuto(null, { userId: lockingUserId });
|
||||
|
||||
await this.cipherService.clearCache(lockingUserId);
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { import_ssh_key } from "@bitwarden/sdk-internal";
|
||||
|
||||
import { EncString } from "../../platform/models/domain/enc-string";
|
||||
import { SshKey as SshKeyDomain } from "../../vault/models/domain/ssh-key";
|
||||
@@ -18,18 +17,16 @@ export class SshKeyExport {
|
||||
}
|
||||
|
||||
static toView(req: SshKeyExport, view = new SshKeyView()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
view.privateKey = parsedKey.privateKey;
|
||||
view.publicKey = parsedKey.publicKey;
|
||||
view.keyFingerprint = parsedKey.fingerprint;
|
||||
view.privateKey = req.privateKey;
|
||||
view.publicKey = req.publicKey;
|
||||
view.keyFingerprint = req.keyFingerprint;
|
||||
return view;
|
||||
}
|
||||
|
||||
static toDomain(req: SshKeyExport, domain = new SshKeyDomain()) {
|
||||
const parsedKey = import_ssh_key(req.privateKey);
|
||||
domain.privateKey = new EncString(parsedKey.privateKey);
|
||||
domain.publicKey = new EncString(parsedKey.publicKey);
|
||||
domain.keyFingerprint = new EncString(parsedKey.fingerprint);
|
||||
domain.privateKey = new EncString(req.privateKey);
|
||||
domain.publicKey = new EncString(req.publicKey);
|
||||
domain.keyFingerprint = new EncString(req.keyFingerprint);
|
||||
return domain;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { NotificationViewResponse as EndUserNotificationResponse } from "@bitwarden/common/vault/notifications/models";
|
||||
|
||||
import { NotificationType } from "../../enums";
|
||||
|
||||
import { BaseResponse } from "./base.response";
|
||||
@@ -57,6 +59,10 @@ export class NotificationResponse extends BaseResponse {
|
||||
case NotificationType.SyncOrganizationCollectionSettingChanged:
|
||||
this.payload = new OrganizationCollectionSettingChangedPushNotification(payload);
|
||||
break;
|
||||
case NotificationType.Notification:
|
||||
case NotificationType.NotificationStatus:
|
||||
this.payload = new EndUserNotificationResponse(payload);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
// See https://contributing.bitwarden.com/architecture/clients/data-model/#view for proper use.
|
||||
// View models represent the decrypted state of a corresponding Domain model.
|
||||
// They typically match the Domain model but contains a decrypted string for any EncString fields.
|
||||
// Don't use this to represent arbitrary component view data as that isn't what it is for.
|
||||
export class View {}
|
||||
|
||||
@@ -50,14 +50,6 @@ export abstract class StateService<T extends Account = Account> {
|
||||
value: boolean,
|
||||
options?: StorageOptions,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* @deprecated For migration purposes only, use getUserKeyMasterKey instead
|
||||
*/
|
||||
getEncryptedCryptoSymmetricKey: (options?: StorageOptions) => Promise<string>;
|
||||
/**
|
||||
* @deprecated For migration purposes only, use setUserKeyAuto instead
|
||||
*/
|
||||
setCryptoMasterKeyAuto: (value: string | null, options?: StorageOptions) => Promise<void>;
|
||||
getDuckDuckGoSharedKey: (options?: StorageOptions) => Promise<string>;
|
||||
setDuckDuckGoSharedKey: (value: string, options?: StorageOptions) => Promise<void>;
|
||||
|
||||
|
||||
2
libs/common/src/platform/ipc/index.ts
Normal file
2
libs/common/src/platform/ipc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./ipc-message";
|
||||
export * from "./ipc.service";
|
||||
10
libs/common/src/platform/ipc/ipc-message.ts
Normal file
10
libs/common/src/platform/ipc/ipc-message.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { OutgoingMessage } from "@bitwarden/sdk-internal";
|
||||
|
||||
export interface IpcMessage {
|
||||
type: "bitwarden-ipc-message";
|
||||
message: OutgoingMessage;
|
||||
}
|
||||
|
||||
export function isIpcMessage(message: any): message is IpcMessage {
|
||||
return message.type === "bitwarden-ipc-message";
|
||||
}
|
||||
51
libs/common/src/platform/ipc/ipc.service.ts
Normal file
51
libs/common/src/platform/ipc/ipc.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Observable, shareReplay } from "rxjs";
|
||||
|
||||
import { IpcClient, IncomingMessage, OutgoingMessage } from "@bitwarden/sdk-internal";
|
||||
|
||||
export abstract class IpcService {
|
||||
private _client?: IpcClient;
|
||||
protected get client(): IpcClient {
|
||||
if (!this._client) {
|
||||
throw new Error("IpcService not initialized");
|
||||
}
|
||||
return this._client;
|
||||
}
|
||||
|
||||
private _messages$?: Observable<IncomingMessage>;
|
||||
protected get messages$(): Observable<IncomingMessage> {
|
||||
if (!this._messages$) {
|
||||
throw new Error("IpcService not initialized");
|
||||
}
|
||||
return this._messages$;
|
||||
}
|
||||
|
||||
abstract init(): Promise<void>;
|
||||
|
||||
protected async initWithClient(client: IpcClient): Promise<void> {
|
||||
this._client = client;
|
||||
this._messages$ = new Observable<IncomingMessage>((subscriber) => {
|
||||
let isSubscribed = true;
|
||||
|
||||
const receiveLoop = async () => {
|
||||
while (isSubscribed) {
|
||||
try {
|
||||
const message = await this.client.receive();
|
||||
subscriber.next(message);
|
||||
} catch (error) {
|
||||
subscriber.error(error);
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
void receiveLoop();
|
||||
|
||||
return () => {
|
||||
isSubscribed = false;
|
||||
};
|
||||
}).pipe(shareReplay({ bufferSize: 0, refCount: true }));
|
||||
}
|
||||
|
||||
async send(message: OutgoingMessage) {
|
||||
await this.client.send(message);
|
||||
}
|
||||
}
|
||||
48
libs/common/src/platform/ipc/message-queue.spec.ts
Normal file
48
libs/common/src/platform/ipc/message-queue.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { MessageQueue } from "./message-queue";
|
||||
|
||||
type Message = symbol;
|
||||
|
||||
describe("MessageQueue", () => {
|
||||
let messageQueue!: MessageQueue<Message>;
|
||||
|
||||
beforeEach(() => {
|
||||
messageQueue = new MessageQueue<Message>();
|
||||
});
|
||||
|
||||
it("waits for a new message when queue is empty", async () => {
|
||||
const message = createMessage();
|
||||
|
||||
// Start a promise to dequeue a message
|
||||
let dequeuedValue: Message | undefined;
|
||||
void messageQueue.dequeue().then((value) => {
|
||||
dequeuedValue = value;
|
||||
});
|
||||
|
||||
// No message is enqueued yet
|
||||
expect(dequeuedValue).toBeUndefined();
|
||||
|
||||
// Enqueue a message
|
||||
await messageQueue.enqueue(message);
|
||||
|
||||
// Expect the message to be dequeued
|
||||
await new Promise(process.nextTick);
|
||||
expect(dequeuedValue).toBe(message);
|
||||
});
|
||||
|
||||
it("returns existing message when queue is not empty", async () => {
|
||||
const message = createMessage();
|
||||
|
||||
// Enqueue a message
|
||||
await messageQueue.enqueue(message);
|
||||
|
||||
// Dequeue the message
|
||||
const dequeuedValue = await messageQueue.dequeue();
|
||||
|
||||
// Expect the message to be dequeued
|
||||
expect(dequeuedValue).toBe(message);
|
||||
});
|
||||
});
|
||||
|
||||
function createMessage(name?: string): symbol {
|
||||
return Symbol(name);
|
||||
}
|
||||
20
libs/common/src/platform/ipc/message-queue.ts
Normal file
20
libs/common/src/platform/ipc/message-queue.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { firstValueFrom, Subject } from "rxjs";
|
||||
|
||||
export class MessageQueue<T> {
|
||||
private queue: T[] = [];
|
||||
private messageAvailable$ = new Subject<void>();
|
||||
|
||||
async enqueue(message: T): Promise<void> {
|
||||
this.queue.push(message);
|
||||
this.messageAvailable$.next();
|
||||
}
|
||||
|
||||
async dequeue(): Promise<T> {
|
||||
if (this.queue.length > 0) {
|
||||
return this.queue.shift() as T;
|
||||
}
|
||||
|
||||
await firstValueFrom(this.messageAvailable$);
|
||||
return this.queue.shift() as T;
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { clearCaches, sequentialize } from "./sequentialize";
|
||||
|
||||
describe("sequentialize decorator", () => {
|
||||
it("should call the function once", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once for each instance of the object", async () => {
|
||||
const foo = new Foo();
|
||||
const foo2 = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.bar(1));
|
||||
promises.push(foo2.bar(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
expect(foo2.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function once with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.baz(1));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(1);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.bar(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function again when already resolved with a key function", async () => {
|
||||
const foo = new Foo();
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(1);
|
||||
await foo.baz(1);
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
|
||||
it("should call the function for each argument", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.bar(1), foo.bar(1), foo.bar(2), foo.bar(2), foo.bar(3), foo.bar(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should call the function for each argument with key function", async () => {
|
||||
const foo = new Foo();
|
||||
await Promise.all([foo.baz(1), foo.baz(1), foo.baz(2), foo.baz(2), foo.baz(3), foo.baz(3)]);
|
||||
expect(foo.calls).toBe(3);
|
||||
});
|
||||
|
||||
it("should return correct result for each call", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(1).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(2).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
foo.bar(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([2, 2, 4, 4, 6, 6]);
|
||||
});
|
||||
|
||||
it("should return correct result for each call with key function", async () => {
|
||||
const foo = new Foo();
|
||||
const allRes: number[] = [];
|
||||
|
||||
await Promise.all([
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(1).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(2).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
foo.baz(3).then((res) => allRes.push(res)),
|
||||
]);
|
||||
expect(foo.calls).toBe(3);
|
||||
expect(allRes.length).toBe(6);
|
||||
allRes.sort();
|
||||
expect(allRes).toEqual([3, 3, 6, 6, 9, 9]);
|
||||
});
|
||||
|
||||
describe("clearCaches", () => {
|
||||
it("should clear all caches", async () => {
|
||||
const foo = new Foo();
|
||||
const promise = Promise.all([foo.bar(1), foo.bar(1)]);
|
||||
clearCaches();
|
||||
await foo.bar(1);
|
||||
await promise;
|
||||
// one call for the first two, one for the third after the cache was cleared
|
||||
expect(foo.calls).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
calls = 0;
|
||||
|
||||
@sequentialize((args) => "bar" + args[0])
|
||||
bar(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 2);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "baz" + args[0])
|
||||
baz(a: number): Promise<number> {
|
||||
this.calls++;
|
||||
return new Promise((res) => {
|
||||
setTimeout(() => {
|
||||
res(a * 3);
|
||||
}, Math.random() * 100);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
const caches = new Map<any, Map<string, Promise<any>>>();
|
||||
|
||||
const getCache = (obj: any) => {
|
||||
let cache = caches.get(obj);
|
||||
if (cache != null) {
|
||||
return cache;
|
||||
}
|
||||
cache = new Map<string, Promise<any>>();
|
||||
caches.set(obj, cache);
|
||||
return cache;
|
||||
};
|
||||
|
||||
export function clearCaches() {
|
||||
caches.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Use as a Decorator on async functions, it will prevent multiple 'active' calls as the same time
|
||||
*
|
||||
* If a promise was returned from a previous call to this function, that hasn't yet resolved it will
|
||||
* be returned, instead of calling the original function again
|
||||
*
|
||||
* Results are not cached, once the promise has returned, the next call will result in a fresh call
|
||||
*
|
||||
* Read more at https://github.com/bitwarden/jslib/pull/7
|
||||
*/
|
||||
export function sequentialize(cacheKey: (args: any[]) => string) {
|
||||
return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => {
|
||||
const originalMethod: () => Promise<any> = descriptor.value;
|
||||
|
||||
return {
|
||||
value: function (...args: any[]) {
|
||||
const cache = getCache(this);
|
||||
const argsCacheKey = cacheKey(args);
|
||||
let response = cache.get(argsCacheKey);
|
||||
if (response != null) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const onFinally = () => {
|
||||
cache.delete(argsCacheKey);
|
||||
if (cache.size === 0) {
|
||||
caches.delete(this);
|
||||
}
|
||||
};
|
||||
response = originalMethod
|
||||
.apply(this, args)
|
||||
.then((val: any) => {
|
||||
onFinally();
|
||||
return val;
|
||||
})
|
||||
.catch((err: any) => {
|
||||
onFinally();
|
||||
throw err;
|
||||
});
|
||||
|
||||
cache.set(argsCacheKey, response);
|
||||
return response;
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { sequentialize } from "./sequentialize";
|
||||
import { throttle } from "./throttle";
|
||||
|
||||
describe("throttle decorator", () => {
|
||||
@@ -51,17 +50,6 @@ describe("throttle decorator", () => {
|
||||
expect(foo.calls).toBe(10);
|
||||
expect(foo2.calls).toBe(10);
|
||||
});
|
||||
|
||||
it("should work together with sequentialize", async () => {
|
||||
const foo = new Foo();
|
||||
const promises = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
promises.push(foo.qux(Math.floor(i / 2) * 2));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
|
||||
expect(foo.calls).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
class Foo {
|
||||
@@ -94,7 +82,6 @@ class Foo {
|
||||
});
|
||||
}
|
||||
|
||||
@sequentialize((args) => "qux" + args[0])
|
||||
@throttle(1, () => "qux")
|
||||
qux(a: number) {
|
||||
this.calls++;
|
||||
|
||||
@@ -706,4 +706,73 @@ describe("Utils Service", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromUtf8ToB64(...)", () => {
|
||||
const originalIsNode = Utils.isNode;
|
||||
|
||||
afterEach(() => {
|
||||
Utils.isNode = originalIsNode;
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle empty string", () => {
|
||||
const str = Utils.fromUtf8ToB64("");
|
||||
expect(str).toBe("");
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert a normal b64 string", () => {
|
||||
const str = Utils.fromUtf8ToB64(asciiHelloWorld);
|
||||
expect(str).toBe(b64HelloWorldString);
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert various special characters", () => {
|
||||
const cases = [
|
||||
{ input: "»", output: "wrs=" },
|
||||
{ input: "¦", output: "wqY=" },
|
||||
{ input: "£", output: "wqM=" },
|
||||
{ input: "é", output: "w6k=" },
|
||||
{ input: "ö", output: "w7Y=" },
|
||||
{ input: "»»", output: "wrvCuw==" },
|
||||
];
|
||||
cases.forEach((c) => {
|
||||
const utfStr = c.input;
|
||||
const str = Utils.fromUtf8ToB64(utfStr);
|
||||
expect(str).toBe(c.output);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("fromB64ToUtf8(...)", () => {
|
||||
const originalIsNode = Utils.isNode;
|
||||
|
||||
afterEach(() => {
|
||||
Utils.isNode = originalIsNode;
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle empty string", () => {
|
||||
const str = Utils.fromB64ToUtf8("");
|
||||
expect(str).toBe("");
|
||||
});
|
||||
|
||||
runInBothEnvironments("should convert a normal b64 string", () => {
|
||||
const str = Utils.fromB64ToUtf8(b64HelloWorldString);
|
||||
expect(str).toBe(asciiHelloWorld);
|
||||
});
|
||||
|
||||
runInBothEnvironments("should handle various special characters", () => {
|
||||
const cases = [
|
||||
{ input: "wrs=", output: "»" },
|
||||
{ input: "wqY=", output: "¦" },
|
||||
{ input: "wqM=", output: "£" },
|
||||
{ input: "w6k=", output: "é" },
|
||||
{ input: "w7Y=", output: "ö" },
|
||||
{ input: "wrvCuw==", output: "»»" },
|
||||
];
|
||||
|
||||
cases.forEach((c) => {
|
||||
const b64Str = c.input;
|
||||
const str = Utils.fromB64ToUtf8(b64Str);
|
||||
expect(str).toBe(c.output);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,7 +233,7 @@ export class Utils {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(utfStr, "utf8").toString("base64");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.btoa(utfStr)));
|
||||
return BufferLib.from(utfStr, "utf8").toString("base64");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +245,7 @@ export class Utils {
|
||||
if (Utils.isNode) {
|
||||
return Buffer.from(b64Str, "base64").toString("utf8");
|
||||
} else {
|
||||
return decodeURIComponent(escape(Utils.global.atob(b64Str)));
|
||||
return BufferLib.from(b64Str, "base64").toString("utf8");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,6 @@ export class EncryptionPair<TEncrypted, TDecrypted> {
|
||||
export class AccountKeys {
|
||||
publicKey?: Uint8Array;
|
||||
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
cryptoMasterKeyAuto?: string;
|
||||
/** @deprecated July 2023, left for migration purposes*/
|
||||
cryptoSymmetricKey?: EncryptionPair<string, SymmetricCryptoKey> = new EncryptionPair<
|
||||
string,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
|
||||
|
||||
export class EncryptedObject {
|
||||
iv: Uint8Array;
|
||||
data: Uint8Array;
|
||||
mac: Uint8Array;
|
||||
key: SymmetricCryptoKey;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { makeStaticByteArray } from "../../../../spec";
|
||||
import { EncryptionType } from "../../enums";
|
||||
import { Utils } from "../../misc/utils";
|
||||
|
||||
import { SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
import { Aes256CbcHmacKey, SymmetricCryptoKey } from "./symmetric-crypto-key";
|
||||
|
||||
describe("SymmetricCryptoKey", () => {
|
||||
it("errors if no key", () => {
|
||||
@@ -18,12 +19,12 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key,
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: EncryptionType.AesCbc256_B64,
|
||||
key: key,
|
||||
keyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
macKey: null,
|
||||
innerKey: {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
encryptionKey: key,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -32,14 +33,14 @@ describe("SymmetricCryptoKey", () => {
|
||||
const cryptoKey = new SymmetricCryptoKey(key);
|
||||
|
||||
expect(cryptoKey).toEqual({
|
||||
encKey: key.slice(0, 32),
|
||||
encKeyB64: "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=",
|
||||
encType: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
key: key,
|
||||
keyB64:
|
||||
"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+Pw==",
|
||||
macKey: key.slice(32, 64),
|
||||
macKeyB64: "ICEiIyQlJicoKSorLC0uLzAxMjM0NTY3ODk6Ozw9Pj8=",
|
||||
innerKey: {
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.slice(0, 32),
|
||||
authenticationKey: key.slice(32),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,7 +49,7 @@ describe("SymmetricCryptoKey", () => {
|
||||
new SymmetricCryptoKey(makeStaticByteArray(30));
|
||||
};
|
||||
|
||||
expect(t).toThrowError("Unable to determine encType.");
|
||||
expect(t).toThrowError(`Unsupported encType/key length 30`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,6 +70,41 @@ describe("SymmetricCryptoKey", () => {
|
||||
expect(actual).toBeInstanceOf(SymmetricCryptoKey);
|
||||
});
|
||||
|
||||
it("inner returns inner key", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
|
||||
const actual = key.inner();
|
||||
|
||||
expect(actual).toEqual({
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.inner().encryptionKey,
|
||||
authenticationKey: (key.inner() as Aes256CbcHmacKey).authenticationKey,
|
||||
});
|
||||
});
|
||||
|
||||
it("toEncoded returns encoded key for AesCbc256_B64", () => {
|
||||
const key = new SymmetricCryptoKey(makeStaticByteArray(32));
|
||||
const actual = key.toEncoded();
|
||||
|
||||
expect(actual).toEqual(key.inner().encryptionKey);
|
||||
});
|
||||
|
||||
it("toEncoded returns encoded key for AesCbc256_HmacSha256_B64", () => {
|
||||
const keyBytes = makeStaticByteArray(64);
|
||||
const key = new SymmetricCryptoKey(keyBytes);
|
||||
const actual = key.toEncoded();
|
||||
|
||||
expect(actual).toEqual(keyBytes);
|
||||
});
|
||||
|
||||
it("toBase64 returns base64 encoded key", () => {
|
||||
const keyBytes = makeStaticByteArray(64);
|
||||
const keyB64 = Utils.fromBufferToB64(keyBytes);
|
||||
const key = new SymmetricCryptoKey(keyBytes);
|
||||
const actual = key.toBase64();
|
||||
|
||||
expect(actual).toEqual(keyB64);
|
||||
});
|
||||
|
||||
describe("fromString", () => {
|
||||
it("null string returns null", () => {
|
||||
const actual = SymmetricCryptoKey.fromString(null);
|
||||
|
||||
@@ -5,50 +5,53 @@ import { Jsonify } from "type-fest";
|
||||
import { Utils } from "../../../platform/misc/utils";
|
||||
import { EncryptionType } from "../../enums";
|
||||
|
||||
export type Aes256CbcHmacKey = {
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
encryptionKey: Uint8Array;
|
||||
authenticationKey: Uint8Array;
|
||||
};
|
||||
|
||||
export type Aes256CbcKey = {
|
||||
type: EncryptionType.AesCbc256_B64;
|
||||
encryptionKey: Uint8Array;
|
||||
};
|
||||
|
||||
/**
|
||||
* A symmetric crypto key represents a symmetric key usable for symmetric encryption and decryption operations.
|
||||
* The specific algorithm used is private to the key, and should only be exposed to encrypt service implementations.
|
||||
* This can be done via `inner()`.
|
||||
*/
|
||||
export class SymmetricCryptoKey {
|
||||
private innerKey: Aes256CbcHmacKey | Aes256CbcKey;
|
||||
|
||||
key: Uint8Array;
|
||||
encKey: Uint8Array;
|
||||
macKey?: Uint8Array;
|
||||
encType: EncryptionType;
|
||||
|
||||
keyB64: string;
|
||||
encKeyB64: string;
|
||||
macKeyB64: string;
|
||||
|
||||
meta: any;
|
||||
|
||||
constructor(key: Uint8Array, encType?: EncryptionType) {
|
||||
/**
|
||||
* @param key The key in one of the permitted serialization formats
|
||||
*/
|
||||
constructor(key: Uint8Array) {
|
||||
if (key == null) {
|
||||
throw new Error("Must provide key");
|
||||
}
|
||||
|
||||
if (encType == null) {
|
||||
if (key.byteLength === 32) {
|
||||
encType = EncryptionType.AesCbc256_B64;
|
||||
} else if (key.byteLength === 64) {
|
||||
encType = EncryptionType.AesCbc256_HmacSha256_B64;
|
||||
} else {
|
||||
throw new Error("Unable to determine encType.");
|
||||
}
|
||||
}
|
||||
|
||||
this.key = key;
|
||||
this.encType = encType;
|
||||
|
||||
if (encType === EncryptionType.AesCbc256_B64 && key.byteLength === 32) {
|
||||
this.encKey = key;
|
||||
this.macKey = null;
|
||||
} else if (encType === EncryptionType.AesCbc256_HmacSha256_B64 && key.byteLength === 64) {
|
||||
this.encKey = key.slice(0, 32);
|
||||
this.macKey = key.slice(32, 64);
|
||||
if (key.byteLength === 32) {
|
||||
this.innerKey = {
|
||||
type: EncryptionType.AesCbc256_B64,
|
||||
encryptionKey: key,
|
||||
};
|
||||
this.key = key;
|
||||
this.keyB64 = this.toBase64();
|
||||
} else if (key.byteLength === 64) {
|
||||
this.innerKey = {
|
||||
type: EncryptionType.AesCbc256_HmacSha256_B64,
|
||||
encryptionKey: key.slice(0, 32),
|
||||
authenticationKey: key.slice(32),
|
||||
};
|
||||
this.key = key;
|
||||
this.keyB64 = this.toBase64();
|
||||
} else {
|
||||
throw new Error("Unsupported encType/key length.");
|
||||
}
|
||||
|
||||
this.keyB64 = Utils.fromBufferToB64(this.key);
|
||||
this.encKeyB64 = Utils.fromBufferToB64(this.encKey);
|
||||
if (this.macKey != null) {
|
||||
this.macKeyB64 = Utils.fromBufferToB64(this.macKey);
|
||||
throw new Error(`Unsupported encType/key length ${key.byteLength}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,6 +60,48 @@ export class SymmetricCryptoKey {
|
||||
return { keyB64: this.keyB64 };
|
||||
}
|
||||
|
||||
/**
|
||||
* It is preferred not to work with the raw key where possible.
|
||||
* Only use this method if absolutely necessary.
|
||||
*
|
||||
* @returns The inner key instance that can be directly used for encryption primitives
|
||||
*/
|
||||
inner(): Aes256CbcHmacKey | Aes256CbcKey {
|
||||
return this.innerKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns The serialized key in base64 format
|
||||
*/
|
||||
toBase64(): string {
|
||||
return Utils.fromBufferToB64(this.toEncoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the key to a format that can be written to state or shared
|
||||
* The currently permitted format is:
|
||||
* - AesCbc256_B64: 32 bytes (the raw key)
|
||||
* - AesCbc256_HmacSha256_B64: 64 bytes (32 bytes encryption key, 32 bytes authentication key, concatenated)
|
||||
*
|
||||
* @returns The serialized key that can be written to state or encrypted and then written to state / shared
|
||||
*/
|
||||
toEncoded(): Uint8Array {
|
||||
if (this.innerKey.type === EncryptionType.AesCbc256_B64) {
|
||||
return this.innerKey.encryptionKey;
|
||||
} else if (this.innerKey.type === EncryptionType.AesCbc256_HmacSha256_B64) {
|
||||
const encodedKey = new Uint8Array(64);
|
||||
encodedKey.set(this.innerKey.encryptionKey, 0);
|
||||
encodedKey.set(this.innerKey.authenticationKey, 32);
|
||||
return encodedKey;
|
||||
} else {
|
||||
throw new Error("Unsupported encryption type.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param s The serialized key in base64 format
|
||||
* @returns A SymmetricCryptoKey instance
|
||||
*/
|
||||
static fromString(s: string): SymmetricCryptoKey {
|
||||
if (s == null) {
|
||||
return null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
map,
|
||||
mergeMap,
|
||||
Observable,
|
||||
share,
|
||||
switchMap,
|
||||
} from "rxjs";
|
||||
|
||||
@@ -66,6 +67,7 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
|
||||
map((notification) => [notification, activeAccountId] as const),
|
||||
);
|
||||
}),
|
||||
share(), // Multiple subscribers should only create a single connection to the server
|
||||
);
|
||||
}
|
||||
|
||||
@@ -151,14 +153,14 @@ export class DefaultNotificationsService implements NotificationsServiceAbstract
|
||||
await this.syncService.syncUpsertCipher(
|
||||
notification.payload as SyncCipherNotification,
|
||||
notification.type === NotificationType.SyncCipherUpdate,
|
||||
payloadUserId,
|
||||
userId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncCipherDelete:
|
||||
case NotificationType.SyncLoginDelete:
|
||||
await this.syncService.syncDeleteCipher(
|
||||
notification.payload as SyncCipherNotification,
|
||||
payloadUserId,
|
||||
userId,
|
||||
);
|
||||
break;
|
||||
case NotificationType.SyncFolderCreate:
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subject, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { LogService } from "../../abstractions/log.service";
|
||||
import { NotificationsService } from "../notifications.service";
|
||||
|
||||
export class NoopNotificationsService implements NotificationsService {
|
||||
notifications$: Observable<readonly [NotificationResponse, UserId]> = new Subject();
|
||||
|
||||
constructor(private logService: LogService) {}
|
||||
|
||||
startListening(): Subscription {
|
||||
|
||||
@@ -23,6 +23,11 @@ export type ReceiveMessage = { type: "ReceiveMessage"; message: NotificationResp
|
||||
|
||||
export type SignalRNotification = Heartbeat | ReceiveMessage;
|
||||
|
||||
export type TimeoutManager = {
|
||||
setTimeout: (handler: TimerHandler, timeout: number) => number;
|
||||
clearTimeout: (timeoutId: number) => void;
|
||||
};
|
||||
|
||||
class SignalRLogger implements ILogger {
|
||||
constructor(private readonly logService: LogService) {}
|
||||
|
||||
@@ -51,11 +56,14 @@ export class SignalRConnectionService {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly logService: LogService,
|
||||
private readonly hubConnectionBuilderFactory: () => HubConnectionBuilder = () =>
|
||||
new HubConnectionBuilder(),
|
||||
private readonly timeoutManager: TimeoutManager = globalThis,
|
||||
) {}
|
||||
|
||||
connect$(userId: UserId, notificationsUrl: string) {
|
||||
return new Observable<SignalRNotification>((subsciber) => {
|
||||
const connection = new HubConnectionBuilder()
|
||||
const connection = this.hubConnectionBuilderFactory()
|
||||
.withUrl(notificationsUrl + "/hub", {
|
||||
accessTokenFactory: () => this.apiService.getActiveBearerToken(),
|
||||
skipNegotiation: true,
|
||||
@@ -76,48 +84,60 @@ export class SignalRConnectionService {
|
||||
let reconnectSubscription: Subscription | null = null;
|
||||
|
||||
// Create schedule reconnect function
|
||||
const scheduleReconnect = (): Subscription => {
|
||||
const scheduleReconnect = () => {
|
||||
if (
|
||||
connection == null ||
|
||||
connection.state !== HubConnectionState.Disconnected ||
|
||||
(reconnectSubscription != null && !reconnectSubscription.closed)
|
||||
) {
|
||||
return Subscription.EMPTY;
|
||||
// Skip scheduling a new reconnect, either the connection isn't disconnected
|
||||
// or an active reconnect is already scheduled.
|
||||
return;
|
||||
}
|
||||
|
||||
const randomTime = this.random();
|
||||
const timeoutHandler = setTimeout(() => {
|
||||
// If we've somehow gotten here while the subscriber is closed,
|
||||
// we do not want to reconnect. So leave.
|
||||
if (subsciber.closed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const randomTime = this.randomReconnectTime();
|
||||
const timeoutHandler = this.timeoutManager.setTimeout(() => {
|
||||
connection
|
||||
.start()
|
||||
.then(() => (reconnectSubscription = null))
|
||||
.then(() => {
|
||||
reconnectSubscription = null;
|
||||
})
|
||||
.catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
}, randomTime);
|
||||
|
||||
return new Subscription(() => clearTimeout(timeoutHandler));
|
||||
reconnectSubscription = new Subscription(() =>
|
||||
this.timeoutManager.clearTimeout(timeoutHandler),
|
||||
);
|
||||
};
|
||||
|
||||
connection.onclose((error) => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
// Start connection
|
||||
connection.start().catch(() => {
|
||||
reconnectSubscription = scheduleReconnect();
|
||||
scheduleReconnect();
|
||||
});
|
||||
|
||||
return () => {
|
||||
// Cancel any possible scheduled reconnects
|
||||
reconnectSubscription?.unsubscribe();
|
||||
connection?.stop().catch((error) => {
|
||||
this.logService.error("Error while stopping SignalR connection", error);
|
||||
// TODO: Does calling stop call `onclose`?
|
||||
reconnectSubscription?.unsubscribe();
|
||||
});
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private random() {
|
||||
private randomReconnectTime() {
|
||||
return (
|
||||
Math.floor(Math.random() * (MAX_RECONNECT_TIME - MIN_RECONNECT_TIME + 1)) + MIN_RECONNECT_TIME
|
||||
);
|
||||
|
||||
@@ -134,7 +134,7 @@ class MyWebPushConnector implements WebPushConnector {
|
||||
|
||||
private async pushManagerSubscribe(key: string) {
|
||||
return await this.serviceWorkerRegistration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
userVisibleOnly: false,
|
||||
applicationServerKey: key,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { Subscription } from "rxjs";
|
||||
import { Observable, Subscription } from "rxjs";
|
||||
|
||||
import { NotificationResponse } from "@bitwarden/common/models/response/notification.response";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- Needed to link to API
|
||||
import type { DefaultNotificationsService } from "./internal";
|
||||
|
||||
/**
|
||||
* A service offering abilities to interact with push notifications from the server.
|
||||
*/
|
||||
export abstract class NotificationsService {
|
||||
/**
|
||||
* @deprecated This method should not be consumed, an observable to listen to server
|
||||
* notifications will be available one day but it is not ready to be consumed generally.
|
||||
* Please add code reacting to notifications in {@link DefaultNotificationsService.processNotification}
|
||||
*/
|
||||
abstract notifications$: Observable<readonly [NotificationResponse, UserId]>;
|
||||
/**
|
||||
* Starts automatic listening and processing of notifications, should only be called once per application,
|
||||
* or you will risk notifications being processed multiple times.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ScheduledTaskName } from "./scheduled-task-name.enum";
|
||||
* in the future but the task that is ran is NOT the remainder of your RXJS pipeline. The
|
||||
* task you want ran must instead be registered in a location reachable on a service worker
|
||||
* startup (on browser). An example of an acceptible location is the constructor of a service
|
||||
* you know is created in `MainBackground`. Uses of this API is other clients _can_ have the
|
||||
* you know is created in `MainBackground`. Uses of this API in other clients _can_ have the
|
||||
* `registerTaskHandler` call in more places, but in order to have it work across clients
|
||||
* it is recommended to register it according to the rules of browser.
|
||||
*
|
||||
|
||||
@@ -17,11 +17,7 @@ import { SemVer } from "semver";
|
||||
|
||||
import { AuthService } from "../../../auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
|
||||
import {
|
||||
DefaultFeatureFlagValue,
|
||||
FeatureFlag,
|
||||
FeatureFlagValueType,
|
||||
} from "../../../enums/feature-flag.enum";
|
||||
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
|
||||
import { UserId } from "../../../types/guid";
|
||||
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
|
||||
import { ConfigService } from "../../abstractions/config/config.service";
|
||||
@@ -123,26 +119,13 @@ export class DefaultConfigService implements ConfigService {
|
||||
}
|
||||
|
||||
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
|
||||
return this.serverConfig$.pipe(
|
||||
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
|
||||
);
|
||||
}
|
||||
|
||||
private getFeatureFlagValue<Flag extends FeatureFlag>(
|
||||
serverConfig: ServerConfig | null,
|
||||
flag: Flag,
|
||||
) {
|
||||
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
|
||||
return DefaultFeatureFlagValue[flag];
|
||||
}
|
||||
|
||||
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
|
||||
return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key)));
|
||||
}
|
||||
|
||||
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
|
||||
return this.stateProvider
|
||||
.getUser(userId, USER_SERVER_CONFIG)
|
||||
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
|
||||
.state$.pipe(map((config) => getFeatureFlagValue(config, key)));
|
||||
}
|
||||
|
||||
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {
|
||||
|
||||
@@ -251,7 +251,8 @@ export class Fido2ClientService<ParentWindowReference>
|
||||
clientDataJSON: Fido2Utils.bufferToString(clientDataJSONBytes),
|
||||
publicKey: Fido2Utils.bufferToString(makeCredentialResult.publicKey),
|
||||
publicKeyAlgorithm: makeCredentialResult.publicKeyAlgorithm,
|
||||
transports: params.rp.id === "google.com" ? ["internal", "usb"] : ["internal"],
|
||||
transports:
|
||||
params.rp.id === "google.com" ? ["internal", "usb", "hybrid"] : ["internal", "hybrid"],
|
||||
extensions: { credProps },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2,8 +2,8 @@ import { mock } from "jest-mock-extended";
|
||||
|
||||
import { PBKDF2KdfConfig, Argon2KdfConfig } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
|
||||
import { KeyGenerationService } from "./key-generation.service";
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// @ts-strict-ignore
|
||||
import { KdfConfig, PBKDF2KdfConfig, Argon2KdfConfig, KdfType } from "@bitwarden/key-management";
|
||||
|
||||
import { CryptoFunctionService } from "../../key-management/crypto/abstractions/crypto-function.service";
|
||||
import { CsprngArray } from "../../types/csprng";
|
||||
import { CryptoFunctionService } from "../abstractions/crypto-function.service";
|
||||
import { KeyGenerationService as KeyGenerationServiceAbstraction } from "../abstractions/key-generation.service";
|
||||
import { Utils } from "../misc/utils";
|
||||
import { SymmetricCryptoKey } from "../models/domain/symmetric-crypto-key";
|
||||
|
||||
@@ -222,45 +222,6 @@ export class StateService<
|
||||
await this.saveSecureStorageKey(partialKeys.userBiometricKey, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use UserKeyAuto instead
|
||||
*/
|
||||
async setCryptoMasterKeyAuto(value: string | null, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(
|
||||
this.reconcileOptions(options, { keySuffix: "auto" }),
|
||||
await this.defaultSecureStorageOptions(),
|
||||
);
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
await this.saveSecureStorageKey(partialKeys.autoKey, value, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated I don't see where this is even used
|
||||
*/
|
||||
async getCryptoMasterKeyB64(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return null;
|
||||
}
|
||||
return await this.secureStorageService.get<string>(
|
||||
`${options?.userId}${partialKeys.masterKey}`,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated I don't see where this is even used
|
||||
*/
|
||||
async setCryptoMasterKeyB64(value: string, options?: StorageOptions): Promise<void> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
return;
|
||||
}
|
||||
await this.saveSecureStorageKey(partialKeys.masterKey, value, options);
|
||||
}
|
||||
|
||||
async getDuckDuckGoSharedKey(options?: StorageOptions): Promise<string> {
|
||||
options = this.reconcileOptions(options, await this.defaultSecureStorageOptions());
|
||||
if (options?.userId == null) {
|
||||
@@ -619,8 +580,6 @@ export class StateService<
|
||||
|
||||
await this.setUserKeyAutoUnlock(null, { userId: userId });
|
||||
await this.setUserKeyBiometric(null, { userId: userId });
|
||||
await this.setCryptoMasterKeyAuto(null, { userId: userId });
|
||||
await this.setCryptoMasterKeyB64(null, { userId: userId });
|
||||
}
|
||||
|
||||
protected async removeAccountFromMemory(userId: string = null): Promise<void> {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user