1
0
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:
Anders Åberg
2025-04-22 17:10:28 +02:00
committed by GitHub
1230 changed files with 35787 additions and 32141 deletions

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
export enum ProviderType {
Msp = 0,
Reseller = 1,
MultiOrganizationEnterprise = 2,
BusinessUnit = 2,
}

View File

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

View File

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

View File

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

View File

@@ -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() {

View File

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

View File

@@ -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() {

View File

@@ -6,4 +6,5 @@ export class OrganizationSponsorshipCreateRequest {
sponsoredEmail: string;
planSponsorshipType: PlanSponsorshipType;
friendlyName: string;
notes?: string;
}

View File

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

View File

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

View File

@@ -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 () => {

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
export class UntrustDevicesRequestModel {
constructor(public devices: string[]) {}
}

View File

@@ -13,5 +13,5 @@ export class DeviceKeysUpdateRequest {
}
export class OtherDeviceKeysUpdateRequest extends DeviceKeysUpdateRequest {
id: string;
deviceId: string;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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> {

View File

@@ -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";

View File

@@ -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";

View File

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

View File

@@ -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(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -24,4 +24,8 @@ export enum NotificationType {
SyncOrganizations = 17,
SyncOrganizationStatusChanged = 18,
SyncOrganizationCollectionSettingChanged = 19,
Notification = 20,
NotificationStatus = 21,
PendingSecurityTasks = 22,
}

View File

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

View File

@@ -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(

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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";

View File

@@ -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({

View File

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

View File

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

View File

@@ -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[]>;
}

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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[])),
);

View File

@@ -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[]> {

View File

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

View File

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

View File

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

View File

@@ -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 {}

View File

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

View File

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

View 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";
}

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

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

View 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;
}
}

View File

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

View File

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

View File

@@ -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++;

View File

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

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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:

View File

@@ -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 {

View File

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

View File

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

View File

@@ -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.

View File

@@ -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.
*

View File

@@ -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) {

View File

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

View File

@@ -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";

View File

@@ -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";

View File

@@ -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