1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Merge remote-tracking branch 'origin/main' into playwright

This commit is contained in:
Matt Gibson
2026-01-26 12:57:05 -08:00
1790 changed files with 150488 additions and 32025 deletions

View File

@@ -6,19 +6,26 @@ import { ReplaySubject, combineLatest, map, Observable } from "rxjs";
import { Account, AccountInfo, AccountService } from "../src/auth/abstractions/account.service";
import { UserId } from "../src/types/guid";
/**
* Creates a mock AccountInfo object with sensible defaults that can be overridden.
* Use this when you need just an AccountInfo object in tests.
*/
export function mockAccountInfoWith(info: Partial<AccountInfo> = {}): AccountInfo {
return {
name: "name",
email: "email",
emailVerified: true,
creationDate: new Date("2024-01-01T00:00:00.000Z"),
...info,
};
}
export function mockAccountServiceWith(
userId: UserId,
info: Partial<AccountInfo> = {},
activity: Record<UserId, Date> = {},
): FakeAccountService {
const fullInfo: AccountInfo = {
...info,
...{
name: "name",
email: "email",
emailVerified: true,
},
};
const fullInfo = mockAccountInfoWith(info);
const fullActivity = { [userId]: new Date(), ...activity };
@@ -37,6 +44,8 @@ export class FakeAccountService implements AccountService {
accountActivitySubject = new ReplaySubject<Record<UserId, Date>>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
accountVerifyDevicesSubject = new ReplaySubject<boolean>(1);
// eslint-disable-next-line rxjs/no-exposed-subjects -- test class
showHeaderSubject = new ReplaySubject<boolean>(1);
private _activeUserId: UserId;
get activeUserId() {
return this._activeUserId;
@@ -55,6 +64,7 @@ export class FakeAccountService implements AccountService {
}),
);
}
showHeader$ = this.showHeaderSubject.asObservable();
get nextUpAccount$(): Observable<Account> {
return combineLatest([this.accounts$, this.activeAccount$, this.sortedUserIds$]).pipe(
map(([accounts, activeAccount, sortedUserIds]) => {
@@ -101,6 +111,10 @@ export class FakeAccountService implements AccountService {
await this.mock.setAccountEmailVerified(userId, emailVerified);
}
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.mock.setAccountCreationDate(userId, creationDate);
}
async switchAccount(userId: UserId): Promise<void> {
const next =
userId == null ? null : { id: userId, ...this.accountsSubject["_buffer"]?.[0]?.[userId] };
@@ -114,10 +128,15 @@ export class FakeAccountService implements AccountService {
this.accountsSubject.next(updated);
await this.mock.clean(userId);
}
async setShowHeader(value: boolean): Promise<void> {
this.showHeaderSubject.next(value);
}
}
const loggedOutInfo: AccountInfo = {
name: undefined,
email: "",
emailVerified: false,
creationDate: undefined,
};

View File

@@ -2,7 +2,10 @@
// @ts-strict-ignore
import { mock, MockProxy } from "jest-mock-extended";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { KeyService } from "@bitwarden/key-management";
import { EncryptionType } from "../src/platform/enums";
import { Utils } from "../src/platform/misc/utils";
@@ -29,6 +32,7 @@ export function BuildTestObject<T, K extends keyof T = keyof T>(
export function mockEnc(s: string): MockProxy<EncString> {
const mocked = mock<EncString>();
mocked.decryptedValue = s;
mocked.decrypt.mockResolvedValue(s);
return mocked;
@@ -77,4 +81,14 @@ export const mockFromSdk = (stub: any) => {
return `${stub}_fromSdk`;
};
export const mockContainerService = () => {
const keyService = mock<KeyService>();
const encryptService = mock<EncryptService>();
encryptService.decryptString.mockImplementation(async (encStr, _key) => {
return encStr.decryptedValue;
});
(window as any).bitwardenContainerService = new ContainerService(keyService, encryptService);
return (window as any).bitwardenContainerService;
};
export { trackEmissions, awaitAsync } from "@bitwarden/core-test-utils";

View File

@@ -1,12 +1,11 @@
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CreateCollectionRequest, UpdateCollectionRequest } from "@bitwarden/admin-console/common";
import {
CollectionAccessDetailsResponse,
CollectionDetailsResponse,
CollectionResponse,
CreateCollectionRequest,
UpdateCollectionRequest,
} from "@bitwarden/admin-console/common";
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationConnectionType } from "../admin-console/enums";
import { OrganizationSponsorshipCreateRequest } from "../admin-console/models/request/organization/organization-sponsorship-create.request";
@@ -50,6 +49,7 @@ import { UpdateProfileRequest } from "../auth/models/request/update-profile.requ
import { ApiKeyResponse } from "../auth/models/response/api-key.response";
import { AuthRequestResponse } from "../auth/models/response/auth-request.response";
import { IdentityDeviceVerificationResponse } from "../auth/models/response/identity-device-verification.response";
import { IdentitySsoRequiredResponse } from "../auth/models/response/identity-sso-required.response";
import { IdentityTokenResponse } from "../auth/models/response/identity-token.response";
import { IdentityTwoFactorResponse } from "../auth/models/response/identity-two-factor.response";
import { KeyConnectorUserKeyResponse } from "../auth/models/response/key-connector-user-key.response";
@@ -140,7 +140,10 @@ export abstract class ApiService {
| UserApiTokenRequest
| WebAuthnLoginTokenRequest,
): Promise<
IdentityTokenResponse | IdentityTwoFactorResponse | IdentityDeviceVerificationResponse
| IdentityTokenResponse
| IdentityTwoFactorResponse
| IdentityDeviceVerificationResponse
| IdentitySsoRequiredResponse
>;
abstract refreshIdentityToken(userId?: UserId): Promise<any>;
@@ -442,6 +445,13 @@ export abstract class ApiService {
abstract postBitPayInvoice(request: BitPayInvoiceRequest): Promise<string>;
abstract postSetupPayment(): Promise<string>;
/**
* Retrieves the bearer access token for the user.
* If the access token is expired or within 5 minutes of expiration, attempts to refresh the token
* and persists the refresh token to state before returning it.
* @param userId The user for whom we're retrieving the access token
* @returns The access token, or an Error if no access token exists.
*/
abstract getActiveBearerToken(userId: UserId): Promise<string>;
abstract fetch(request: Request): Promise<Response>;
abstract nativeFetch(request: Request): Promise<Response>;

View File

@@ -41,6 +41,18 @@ export function canAccessBillingTab(org: Organization): boolean {
return org.isOwner;
}
/**
* Access Intelligence is only available to:
* - Enterprise organizations
* - Users in those organizations with report access
*
* @param org The organization to verify access
* @returns If true can access the Access Intelligence feature
*/
export function canAccessAccessIntelligence(org: Organization): boolean {
return org.canUseAccessIntelligence && org.canAccessReports;
}
export function canAccessOrgAdmin(org: Organization): boolean {
// Admin console can only be accessed by Owners for disabled organizations
if (!org.enabled && !org.isOwner) {
@@ -63,8 +75,8 @@ export function canAccessEmergencyAccess(
) {
return combineLatest([
configService.getFeatureFlag$(FeatureFlag.AutoConfirm),
policyService.policiesByType$(PolicyType.AutoConfirm, userId),
]).pipe(map(([enabled, policies]) => !enabled || !policies.some((p) => p.enabled)));
policyService.policyAppliesToUser$(PolicyType.AutoConfirm, userId),
]).pipe(map(([enabled, policyAppliesToUser]) => !(enabled && policyAppliesToUser)));
}
/**

View File

@@ -101,4 +101,9 @@ export abstract class InternalPolicyService extends PolicyService {
* Replace a policy in the local sync data. This does not update any policies on the server.
*/
abstract replace: (policies: { [id: string]: PolicyData }, userId: UserId) => Promise<void>;
/**
* Wrapper around upsert that uses account service to sync policies for the logged in user. This comes from
* the server push notification to update local policies.
*/
abstract syncPolicy: (payload: PolicyData) => Promise<void>;
}

View File

@@ -0,0 +1,30 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { View } from "@bitwarden/common/models/view/view";
interface SelectionResponseLike {
id: string;
readOnly: boolean;
hidePasswords: boolean;
manage: boolean;
}
export class CollectionAccessSelectionView extends View {
readonly id: string;
readonly readOnly: boolean;
readonly hidePasswords: boolean;
readonly manage: boolean;
constructor(response?: SelectionResponseLike) {
super();
if (!response) {
return;
}
this.id = response.id;
this.readOnly = response.readOnly;
this.hidePasswords = response.hidePasswords;
this.manage = response.manage;
}
}

View File

@@ -0,0 +1,180 @@
import { CollectionAccessSelectionView } from "@bitwarden/common/admin-console/models/collections";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionAccessDetailsResponse, CollectionResponse } from "./collection.response";
import { CollectionView } from "./collection.view";
// TODO: this is used to represent the pseudo "Unassigned" collection as well as
// the user's personal vault (as a pseudo organization). This should be separated out into different values.
export const Unassigned = "unassigned";
export type Unassigned = typeof Unassigned;
export class CollectionAdminView extends CollectionView {
groups: CollectionAccessSelectionView[] = [];
users: CollectionAccessSelectionView[] = [];
/**
* Flag indicating the collection has no active user or group assigned to it with CanManage permissions
* In this case, the collection can be managed by admins/owners or custom users with appropriate permissions
*/
unmanaged: boolean = false;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean = false;
/**
* Returns true if the user can edit a collection (including user and group access) from the Admin Console.
*/
override canEdit(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
org?.canEditAnyCollection ||
(this.unmanaged && org?.canEditUnmanagedCollections) ||
super.canEdit(org)
);
}
/**
* Returns true if the user can delete a collection from the Admin Console.
*/
override canDelete(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return org?.canDeleteAnyCollection || super.canDelete(org);
}
/**
* Whether the user can modify user access to this collection
*/
canEditUserAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageUsers && org.allowAdminAccessToAllCollectionItems) || this.canEdit(org)
);
}
/**
* Whether the user can modify group access to this collection
*/
canEditGroupAccess(org: Organization): boolean {
if (this.isDefaultCollection) {
return false;
}
return (
(org.permissions.manageGroups && org.allowAdminAccessToAllCollectionItems) ||
this.canEdit(org)
);
}
/**
* Returns true if the user can view collection info and access in a read-only state from the Admin Console
*/
override canViewCollectionInfo(org: Organization | undefined): boolean {
if (this.isUnassignedCollection || this.isDefaultCollection) {
return false;
}
const isAdmin = org?.isAdmin ?? false;
const permissions = org?.permissions.editAnyCollection ?? false;
return this.manage || isAdmin || permissions;
}
/**
* True if this collection represents the pseudo "Unassigned" collection
* This is different from the "unmanaged" flag, which indicates that no users or groups have access to the collection
*/
get isUnassignedCollection() {
return this.id === Unassigned;
}
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
override canEditName(org: Organization): boolean {
return (this.canEdit(org) && !this.defaultUserCollectionEmail) || super.canEditName(org);
}
static async fromCollectionAccessDetails(
collection: CollectionAccessDetailsResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionAdminView> {
const view = new CollectionAdminView({ ...collection });
try {
view.name = await encryptService.decryptString(new EncString(view.name), orgKey);
} catch (e) {
view.name = "[error: cannot decrypt]";
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionAccessDetails] Error decrypting collection name",
e,
);
}
view.assigned = collection.assigned;
view.readOnly = collection.readOnly;
view.hidePasswords = collection.hidePasswords;
view.manage = collection.manage;
view.unmanaged = collection.unmanaged;
view.type = collection.type;
view.externalId = collection.externalId;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
view.groups = collection.groups
? collection.groups.map((g) => new CollectionAccessSelectionView(g))
: [];
view.users = collection.users
? collection.users.map((g) => new CollectionAccessSelectionView(g))
: [];
return view;
}
static async fromCollectionResponse(
collection: CollectionResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionAdminView> {
let collectionName: string;
try {
collectionName = await encryptService.decryptString(new EncString(collection.name), orgKey);
} catch (e) {
// Note: This should be updated by the owning team with appropriate, domain specific behavior
// eslint-disable-next-line no-console
console.error(
"[CollectionAdminView/fromCollectionResponse] Failed to decrypt the collection name",
e,
);
throw e;
}
const collectionAdminView = new CollectionAdminView({
id: collection.id,
name: collectionName,
organizationId: collection.organizationId,
});
collectionAdminView.externalId = collection.externalId;
return collectionAdminView;
}
}

View File

@@ -0,0 +1,39 @@
import { Jsonify } from "type-fest";
import {
CollectionDetailsResponse,
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
export class CollectionData {
id: CollectionId;
organizationId: OrganizationId;
name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined;
readOnly: boolean = false;
manage: boolean = false;
hidePasswords: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(response: CollectionDetailsResponse) {
this.id = response.id;
this.organizationId = response.organizationId;
this.name = response.name;
this.externalId = response.externalId;
this.readOnly = response.readOnly;
this.manage = response.manage;
this.hidePasswords = response.hidePasswords;
this.type = response.type;
this.defaultUserCollectionEmail = response.defaultUserCollectionEmail;
}
static fromJSON(obj: Jsonify<CollectionData | null>): CollectionData | null {
if (obj == null) {
return null;
}
return Object.assign(new CollectionData(new CollectionDetailsResponse({})), obj);
}
}

View File

@@ -0,0 +1,70 @@
import {
CollectionType,
CollectionTypes,
} from "@bitwarden/common/admin-console/models/collections";
import { SelectionReadOnlyResponse } from "@bitwarden/common/admin-console/models/response/selection-read-only.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
export class CollectionResponse extends BaseResponse {
id: CollectionId;
organizationId: OrganizationId;
name: string;
defaultUserCollectionEmail: string | undefined;
externalId: string | undefined;
type: CollectionType = CollectionTypes.SharedCollection;
constructor(response: any) {
super(response);
this.id = this.getResponseProperty("Id");
this.organizationId = this.getResponseProperty("OrganizationId");
this.name = this.getResponseProperty("Name");
this.externalId = this.getResponseProperty("ExternalId");
this.defaultUserCollectionEmail = this.getResponseProperty("DefaultUserCollectionEmail");
this.type = this.getResponseProperty("Type") ?? CollectionTypes.SharedCollection;
}
}
export class CollectionDetailsResponse extends CollectionResponse {
readOnly: boolean;
manage: boolean;
hidePasswords: boolean;
/**
* Flag indicating the user has been explicitly assigned to this Collection
*/
assigned: boolean;
constructor(response: any) {
super(response);
this.readOnly = this.getResponseProperty("ReadOnly") || false;
this.manage = this.getResponseProperty("Manage") || false;
this.hidePasswords = this.getResponseProperty("HidePasswords") || false;
// Temporary until the API is updated to return this property in AC-2084
// For now, we can assume that if the object is 'collectionDetails' then the user is assigned
this.assigned = this.getResponseProperty("object") == "collectionDetails";
}
}
export class CollectionAccessDetailsResponse extends CollectionDetailsResponse {
groups: SelectionReadOnlyResponse[] = [];
users: SelectionReadOnlyResponse[] = [];
unmanaged: boolean;
constructor(response: any) {
super(response);
this.assigned = this.getResponseProperty("Assigned") || false;
this.unmanaged = this.getResponseProperty("Unmanaged") || false;
const groups = this.getResponseProperty("Groups");
if (groups != null) {
this.groups = groups.map((g: any) => new SelectionReadOnlyResponse(g));
}
const users = this.getResponseProperty("Users");
if (users != null) {
this.users = users.map((g: any) => new SelectionReadOnlyResponse(g));
}
}
}

View File

@@ -0,0 +1,84 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import Domain from "@bitwarden/common/platform/models/domain/domain-base";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { CollectionData } from "./collection.data";
export const CollectionTypes = {
SharedCollection: 0,
DefaultUserCollection: 1,
} as const;
export type CollectionType = (typeof CollectionTypes)[keyof typeof CollectionTypes];
export class Collection extends Domain {
id: CollectionId;
organizationId: OrganizationId;
name: EncString;
externalId: string | undefined;
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
constructor(c: { id: CollectionId; name: EncString; organizationId: OrganizationId }) {
super();
this.id = c.id;
this.name = c.name;
this.organizationId = c.organizationId;
}
static fromCollectionData(obj: CollectionData): Collection {
if (obj == null || obj.name == null || obj.organizationId == null) {
throw new Error("CollectionData must contain name and organizationId.");
}
const collection = new Collection({
...obj,
name: new EncString(obj.name),
});
collection.externalId = obj.externalId;
collection.readOnly = obj.readOnly;
collection.hidePasswords = obj.hidePasswords;
collection.manage = obj.manage;
collection.type = obj.type;
collection.defaultUserCollectionEmail = obj.defaultUserCollectionEmail;
return collection;
}
static async fromCollectionView(
view: CollectionView,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<Collection> {
const collection = new Collection({
name: await encryptService.encryptString(view.name, orgKey),
id: view.id,
organizationId: view.organizationId,
});
collection.externalId = view.externalId;
collection.readOnly = view.readOnly;
collection.hidePasswords = view.hidePasswords;
collection.manage = view.manage;
collection.type = view.type;
return collection;
}
decrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<CollectionView> {
return CollectionView.fromCollection(this, encryptService, orgKey);
}
// @TODO: This would be better off in Collection.Utils. Move this there when
// refactoring to a shared lib.
static isCollectionId(id: any): id is CollectionId {
return typeof id === "string" && id != null;
}
}

View File

@@ -0,0 +1,177 @@
import { Jsonify } from "type-fest";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { View } from "@bitwarden/common/models/view/view";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { OrgKey } from "@bitwarden/common/types/key";
import { ITreeNodeObject } from "@bitwarden/common/vault/models/domain/tree-node";
import { Collection, CollectionType, CollectionTypes } from "./collection";
import { CollectionAccessDetailsResponse } from "./collection.response";
export const NestingDelimiter = "/";
export class CollectionView implements View, ITreeNodeObject {
id: CollectionId;
organizationId: OrganizationId;
externalId: string | undefined;
// readOnly applies to the items within a collection
readOnly: boolean = false;
hidePasswords: boolean = false;
manage: boolean = false;
assigned: boolean = false;
type: CollectionType = CollectionTypes.SharedCollection;
defaultUserCollectionEmail: string | undefined;
private _name: string;
constructor(c: { id: CollectionId; organizationId: OrganizationId; name: string }) {
this.id = c.id;
this.organizationId = c.organizationId;
this._name = c.name;
}
set name(name: string) {
this._name = name;
}
get name(): string {
return this.defaultUserCollectionEmail ?? this._name;
}
canEditItems(org: Organization): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
return org?.canEditAllCiphers || this.manage || (this.assigned && !this.readOnly);
}
/**
* Returns true if the user can edit a collection (including user and group access) from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canEdit}.
*/
canEdit(org: Organization | undefined): boolean {
if (this.isDefaultCollection) {
return false;
}
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
return this.manage;
}
/**
* Returns true if the user can delete a collection from the individual vault.
* Does not include admin permissions - see {@link CollectionAdminView.canDelete}.
*/
canDelete(org: Organization | undefined): boolean {
if (org != null && org.id !== this.organizationId) {
throw new Error(
"Id of the organization provided does not match the org id of the collection.",
);
}
const canDeleteManagedCollections = !org?.limitCollectionDeletion || org.isAdmin;
// Only use individual permissions, not admin permissions
return canDeleteManagedCollections && this.manage && !this.isDefaultCollection;
}
/**
* Returns true if the user can view collection info and access in a read-only state from the individual vault
*/
canViewCollectionInfo(org: Organization | undefined): boolean {
return false;
}
/**
* Returns true if the collection name can be edited. Editing the collection name is restricted for collections
* that were DefaultUserCollections but where the relevant user has been offboarded.
* When this occurs, the offboarded user's email is treated as the collection name, and cannot be edited.
* This is important for security so that the server cannot ask the client to encrypt arbitrary data.
* WARNING! This is an IMPORTANT restriction that MUST be maintained for security purposes.
* Do not edit or remove this unless you understand why.
*/
canEditName(org: Organization): boolean {
return this.canEdit(org) && !this.defaultUserCollectionEmail;
}
get isDefaultCollection() {
return this.type == CollectionTypes.DefaultUserCollection;
}
// FIXME: we should not use a CollectionView object for the vault filter header because it is not a real
// CollectionView and this violates ts-strict rules.
static vaultFilterHead(): CollectionView {
return new CollectionView({
id: "" as CollectionId,
organizationId: "" as OrganizationId,
name: "",
});
}
static async fromCollection(
collection: Collection,
encryptService: EncryptService,
key: OrgKey,
): Promise<CollectionView> {
const view = new CollectionView({ ...collection, name: "" });
try {
view.name = await encryptService.decryptString(collection.name, key);
} catch (e) {
view.name = "[error: cannot decrypt]";
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
}
view.assigned = true;
view.externalId = collection.externalId;
view.readOnly = collection.readOnly;
view.hidePasswords = collection.hidePasswords;
view.manage = collection.manage;
view.type = collection.type;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view;
}
static async fromCollectionAccessDetails(
collection: CollectionAccessDetailsResponse,
encryptService: EncryptService,
orgKey: OrgKey,
): Promise<CollectionView> {
const view = new CollectionView({ ...collection });
try {
view.name = await encryptService.decryptString(new EncString(collection.name), orgKey);
} catch (e) {
// Note: This should be replaced by the owning team with appropriate, domain-specific behavior.
// eslint-disable-next-line no-console
console.error("[CollectionView] Error decrypting collection name", e);
throw e;
}
view.externalId = collection.externalId;
view.type = collection.type;
view.assigned = collection.assigned;
view.defaultUserCollectionEmail = collection.defaultUserCollectionEmail;
return view;
}
static fromJSON(obj: Jsonify<CollectionView>) {
return Object.assign(new CollectionView({ ...obj }), obj);
}
encrypt(orgKey: OrgKey, encryptService: EncryptService): Promise<Collection> {
return Collection.fromCollectionView(this, encryptService, orgKey);
}
}

View File

@@ -0,0 +1,6 @@
export * from "./collection-access-selection.view";
export * from "./collection-admin.view";
export * from "./collection.view";
export * from "./collection.response";
export * from "./collection";
export * from "./collection.data";

View File

@@ -64,6 +64,8 @@ describe("ORGANIZATIONS state", () => {
isAdminInitiated: false,
ssoEnabled: false,
ssoMemberDecryptionType: undefined,
useDisableSMAdsForUsers: false,
usePhishingBlocker: false,
},
};
const result = sut.deserializer(JSON.parse(JSON.stringify(expectedResult)));

View File

@@ -64,9 +64,11 @@ export class OrganizationData {
userIsManagedByOrganization: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
useDisableSMAdsForUsers: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
usePhishingBlocker: boolean;
constructor(
response?: ProfileOrganizationResponse,
@@ -132,9 +134,11 @@ export class OrganizationData {
this.userIsManagedByOrganization = response.userIsManagedByOrganization;
this.useAccessIntelligence = response.useAccessIntelligence;
this.useAdminSponsoredFamilies = response.useAdminSponsoredFamilies;
this.useDisableSMAdsForUsers = response.useDisableSMAdsForUsers ?? false;
this.isAdminInitiated = response.isAdminInitiated;
this.ssoEnabled = response.ssoEnabled;
this.ssoMemberDecryptionType = response.ssoMemberDecryptionType;
this.usePhishingBlocker = response.usePhishingBlocker;
this.isMember = options.isMember;
this.isProviderUser = options.isProviderUser;

View File

@@ -11,6 +11,7 @@ export class PolicyData {
type: PolicyType;
data: Record<string, string | number | boolean>;
enabled: boolean;
revisionDate: string;
constructor(response?: PolicyResponse) {
if (response == null) {
@@ -22,6 +23,7 @@ export class PolicyData {
this.type = response.type;
this.data = response.data;
this.enabled = response.enabled;
this.revisionDate = response.revisionDate;
}
static fromPolicy(policy: Policy): PolicyData {

View File

@@ -95,9 +95,11 @@ export class Organization {
userIsManagedByOrganization: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
useDisableSMAdsForUsers: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
usePhishingBlocker: boolean;
constructor(obj?: OrganizationData) {
if (obj == null) {
@@ -159,9 +161,11 @@ export class Organization {
this.userIsManagedByOrganization = obj.userIsManagedByOrganization;
this.useAccessIntelligence = obj.useAccessIntelligence;
this.useAdminSponsoredFamilies = obj.useAdminSponsoredFamilies;
this.useDisableSMAdsForUsers = obj.useDisableSMAdsForUsers ?? false;
this.isAdminInitiated = obj.isAdminInitiated;
this.ssoEnabled = obj.ssoEnabled;
this.ssoMemberDecryptionType = obj.ssoMemberDecryptionType;
this.usePhishingBlocker = obj.usePhishingBlocker;
}
get canAccess() {
@@ -379,6 +383,13 @@ export class Organization {
return this.familySponsorshipAvailable || this.familySponsorshipFriendlyName !== null;
}
/**
* Do not call this function to perform business logic, use the function in @link AutomaticUserConfirmationService instead.
**/
get canManageAutoConfirm() {
return this.isMember && this.canManageUsers && this.useAutomaticUserConfirmation;
}
static fromJSON(json: Jsonify<Organization>) {
if (json == null) {
return null;
@@ -400,4 +411,8 @@ export class Organization {
this.permissions.accessEventLogs)
);
}
get canUseAccessIntelligence() {
return this.productTierType === ProductTierType.Enterprise;
}
}

View File

@@ -19,6 +19,8 @@ export class Policy extends Domain {
*/
enabled: boolean;
revisionDate: Date;
constructor(obj?: PolicyData) {
super();
if (obj == null) {
@@ -30,6 +32,7 @@ export class Policy extends Domain {
this.type = obj.type;
this.data = obj.data;
this.enabled = obj.enabled;
this.revisionDate = new Date(obj.revisionDate);
}
static fromResponse(response: PolicyResponse): Policy {

View File

@@ -1,5 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
export class ProviderUserConfirmRequest {
key: string;
protected key: string;
constructor(key: string) {
this.key = key;
}
}

View File

@@ -1,8 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { CollectionResponse } from "@bitwarden/admin-console/common";
import { CollectionResponse } from "@bitwarden/common/admin-console/models/collections";
import { BaseResponse } from "../../../models/response/base.response";
import { CipherResponse } from "../../../vault/models/response/cipher.response";

View File

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

View File

@@ -9,6 +9,7 @@ export class PolicyResponse extends BaseResponse {
data: any;
enabled: boolean;
canToggleState: boolean;
revisionDate: string;
constructor(response: any) {
super(response);
@@ -18,5 +19,6 @@ export class PolicyResponse extends BaseResponse {
this.data = this.getResponseProperty("Data");
this.enabled = this.getResponseProperty("Enabled");
this.canToggleState = this.getResponseProperty("CanToggleState") ?? true;
this.revisionDate = this.getResponseProperty("RevisionDate");
}
}

View File

@@ -59,9 +59,11 @@ export class ProfileOrganizationResponse extends BaseResponse {
userIsManagedByOrganization: boolean;
useAccessIntelligence: boolean;
useAdminSponsoredFamilies: boolean;
useDisableSMAdsForUsers: boolean;
isAdminInitiated: boolean;
ssoEnabled: boolean;
ssoMemberDecryptionType?: MemberDecryptionType;
usePhishingBlocker: boolean;
constructor(response: any) {
super(response);
@@ -132,8 +134,10 @@ export class ProfileOrganizationResponse extends BaseResponse {
// Map from backend API property (UseRiskInsights) to domain model property (useAccessIntelligence)
this.useAccessIntelligence = this.getResponseProperty("UseRiskInsights");
this.useAdminSponsoredFamilies = this.getResponseProperty("UseAdminSponsoredFamilies");
this.useDisableSMAdsForUsers = this.getResponseProperty("UseDisableSMAdsForUsers") ?? false;
this.isAdminInitiated = this.getResponseProperty("IsAdminInitiated");
this.ssoEnabled = this.getResponseProperty("SsoEnabled") ?? false;
this.ssoMemberDecryptionType = this.getResponseProperty("SsoMemberDecryptionType");
this.usePhishingBlocker = this.getResponseProperty("UsePhishingBlocker") ?? false;
}
}

View File

@@ -27,9 +27,7 @@ function buildKeyDefinition<T>(key: string): UserKeyDefinition<T> {
export const AUTO_CONFIRM_FINGERPRINTS = buildKeyDefinition<boolean>("autoConfirmFingerPrints");
export class DefaultOrganizationManagementPreferencesService
implements OrganizationManagementPreferencesService
{
export class DefaultOrganizationManagementPreferencesService implements OrganizationManagementPreferencesService {
constructor(private stateProvider: StateProvider) {}
autoConfirmFingerPrints = this.buildOrganizationManagementPreference(

View File

@@ -1,6 +1,8 @@
import { mock, MockProxy } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { newGuid } from "@bitwarden/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { FakeSingleUserState } from "../../../../spec/fake-state";
import {
@@ -22,15 +24,15 @@ import { DefaultPolicyService, getFirstPolicy } from "./default-policy.service";
import { POLICIES } from "./policy-state";
describe("PolicyService", () => {
const userId = "userId" as UserId;
const userId = newGuid() as UserId;
let stateProvider: FakeStateProvider;
let organizationService: MockProxy<OrganizationService>;
let singleUserState: FakeSingleUserState<Record<PolicyId, PolicyData>>;
const accountService = mockAccountServiceWith(userId);
let policyService: DefaultPolicyService;
beforeEach(() => {
const accountService = mockAccountServiceWith(userId);
stateProvider = new FakeStateProvider(accountService);
organizationService = mock<OrganizationService>();
singleUserState = stateProvider.singleUser.getFake(userId, POLICIES);
@@ -59,7 +61,7 @@ describe("PolicyService", () => {
organizationService.organizations$.calledWith(userId).mockReturnValue(organizations$);
policyService = new DefaultPolicyService(stateProvider, organizationService);
policyService = new DefaultPolicyService(stateProvider, organizationService, accountService);
});
it("upsert", async () => {
@@ -81,12 +83,15 @@ describe("PolicyService", () => {
type: PolicyType.MaximumVaultTimeout,
enabled: true,
data: { minutes: 14 },
revisionDate: expect.any(Date),
},
{
id: "99",
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -111,6 +116,8 @@ describe("PolicyService", () => {
organizationId: "test-organization",
type: PolicyType.DisableSend,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -240,6 +247,8 @@ describe("PolicyService", () => {
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
});
});
@@ -329,24 +338,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -369,24 +386,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: false,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -409,24 +434,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org5",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org2",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -449,24 +482,32 @@ describe("PolicyService", () => {
organizationId: "org4",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy2",
organizationId: "org1",
type: PolicyType.ActivateAutofill,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy3",
organizationId: "org3",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
{
id: "policy4",
organizationId: "org1",
type: PolicyType.DisablePersonalVaultExport,
enabled: true,
data: undefined,
revisionDate: expect.any(Date),
},
]);
});
@@ -635,7 +676,7 @@ describe("PolicyService", () => {
beforeEach(() => {
stateProvider = new FakeStateProvider(mockAccountServiceWith(userId));
organizationService = mock<OrganizationService>();
policyService = new DefaultPolicyService(stateProvider, organizationService);
policyService = new DefaultPolicyService(stateProvider, organizationService, accountService);
});
it("returns undefined when there are no policies", () => {
@@ -786,6 +827,7 @@ describe("PolicyService", () => {
policyData.type = type;
policyData.enabled = enabled;
policyData.data = data;
policyData.revisionDate = new Date().toISOString();
return policyData;
}

View File

@@ -1,4 +1,7 @@
import { combineLatest, map, Observable, of } from "rxjs";
import { combineLatest, firstValueFrom, map, Observable, of, switchMap } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { StateProvider } from "../../../platform/state";
import { UserId } from "../../../types/guid";
@@ -25,6 +28,7 @@ export class DefaultPolicyService implements PolicyService {
constructor(
private stateProvider: StateProvider,
private organizationService: OrganizationService,
private accountService: AccountService,
) {}
private policyState(userId: UserId) {
@@ -326,4 +330,13 @@ export class DefaultPolicyService implements PolicyService {
target.enforceOnLogin = Boolean(target.enforceOnLogin || source.enforceOnLogin);
}
}
async syncPolicy(policyData: PolicyData) {
await firstValueFrom(
this.accountService.activeAccount$.pipe(
getUserId,
switchMap((userId) => this.upsert(policyData, userId)),
),
);
}
}

View File

@@ -0,0 +1,120 @@
import { CollectionView } from "@bitwarden/common/admin-console/models/collections";
import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { newGuid } from "@bitwarden/guid";
import { getNestedCollectionTree, getFlatCollectionTree } from "./collection-utils";
describe("CollectionUtils Service", () => {
describe("getNestedCollectionTree", () => {
it("should return collections properly sorted if provided out of order", () => {
// Arrange
const collections: CollectionView[] = [];
const parentCollection = new CollectionView({
name: "Parent",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
const childCollection = new CollectionView({
name: "Parent/Child",
organizationId: "orgId" as OrganizationId,
id: newGuid() as CollectionId,
});
collections.push(childCollection);
collections.push(parentCollection);
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result[0].node.name).toBe("Parent");
expect(result[0].children[0].node.name).toBe("Child");
});
it("should return an empty array if no collections are provided", () => {
// Arrange
const collections: CollectionView[] = [];
// Act
const result = getNestedCollectionTree(collections);
// Assert
expect(result).toEqual([]);
});
});
describe("getFlatCollectionTree", () => {
it("should flatten a tree node with no children", () => {
// Arrange
const collection = new CollectionView({
name: "Test Collection",
id: "test-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const treeNodes: TreeNode<CollectionView>[] = [
new TreeNode<CollectionView>(collection, {} as TreeNode<CollectionView>),
];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(1);
expect(result[0]).toBe(collection);
});
it("should flatten a tree node with children", () => {
// Arrange
const parentCollection = new CollectionView({
name: "Parent",
id: "parent-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child1Collection = new CollectionView({
name: "Child 1",
id: "child1-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const child2Collection = new CollectionView({
name: "Child 2",
id: "child2-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const grandchildCollection = new CollectionView({
name: "Grandchild",
id: "grandchild-id" as CollectionId,
organizationId: "orgId" as OrganizationId,
});
const parentNode = new TreeNode<CollectionView>(
parentCollection,
{} as TreeNode<CollectionView>,
);
const child1Node = new TreeNode<CollectionView>(child1Collection, parentNode);
const child2Node = new TreeNode<CollectionView>(child2Collection, parentNode);
const grandchildNode = new TreeNode<CollectionView>(grandchildCollection, child1Node);
parentNode.children = [child1Node, child2Node];
child1Node.children = [grandchildNode];
const treeNodes: TreeNode<CollectionView>[] = [parentNode];
// Act
const result = getFlatCollectionTree(treeNodes);
// Assert
expect(result.length).toBe(4);
expect(result[0]).toBe(parentCollection);
expect(result).toContain(child1Collection);
expect(result).toContain(child2Collection);
expect(result).toContain(grandchildCollection);
});
});
});

View File

@@ -0,0 +1,87 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import {
CollectionView,
NestingDelimiter,
CollectionAdminView,
} from "@bitwarden/common/admin-console/models/collections";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
export function getNestedCollectionTree(
collections: CollectionAdminView[],
): TreeNode<CollectionAdminView>[];
export function getNestedCollectionTree(collections: CollectionView[]): TreeNode<CollectionView>[];
export function getNestedCollectionTree(
collections: (CollectionView | CollectionAdminView)[],
): TreeNode<CollectionView | CollectionAdminView>[] {
if (!collections) {
return [];
}
// Collections need to be cloned because ServiceUtils.nestedTraverse actively
// modifies the names of collections.
// These changes risk affecting collections store in StateService.
const clonedCollections: CollectionView[] | CollectionAdminView[] = collections
.sort((a, b) => a.name.localeCompare(b.name))
.map(cloneCollection);
const all: TreeNode<CollectionView | CollectionAdminView>[] = [];
const groupedByOrg = new Map<OrganizationId, (CollectionView | CollectionAdminView)[]>();
clonedCollections.map((c) => {
const key = c.organizationId;
(groupedByOrg.get(key) ?? groupedByOrg.set(key, []).get(key)!).push(c);
});
for (const group of groupedByOrg.values()) {
const nodes: TreeNode<CollectionView | CollectionAdminView>[] = [];
for (const c of group) {
const parts = c.name ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : [];
ServiceUtils.nestedTraverse(nodes, 0, parts, c, undefined, NestingDelimiter);
}
all.push(...nodes);
}
return all;
}
export function cloneCollection(collection: CollectionView): CollectionView;
export function cloneCollection(collection: CollectionAdminView): CollectionAdminView;
export function cloneCollection(
collection: CollectionView | CollectionAdminView,
): CollectionView | CollectionAdminView {
let cloned;
if (collection instanceof CollectionAdminView) {
cloned = Object.assign(
new CollectionAdminView({ ...collection, name: collection.name }),
collection,
);
} else {
cloned = Object.assign(
new CollectionView({ ...collection, name: collection.name }),
collection,
);
}
return cloned;
}
export function getFlatCollectionTree(
nodes: TreeNode<CollectionAdminView>[],
): CollectionAdminView[];
export function getFlatCollectionTree(nodes: TreeNode<CollectionView>[]): CollectionView[];
export function getFlatCollectionTree(
nodes: TreeNode<CollectionView | CollectionAdminView>[],
): (CollectionView | CollectionAdminView)[] {
if (!nodes || nodes.length === 0) {
return [];
}
return nodes.flatMap((node) => {
if (!node.children || node.children.length === 0) {
return [node.node];
}
const children = getFlatCollectionTree(node.children);
return [node.node, ...children];
});
}

View File

@@ -0,0 +1 @@
export * from "./collection-utils";

View File

@@ -3,35 +3,24 @@ import { Observable } from "rxjs";
import { UserId } from "../../types/guid";
/**
* Holds information about an account for use in the AccountService
* if more information is added, be sure to update the equality method.
* Holds state that represents a user's account with Bitwarden.
* Any additions here should be added to the equality check in the AccountService
* to ensure that emissions are done on every change.
*
* @property email - User's email address.
* @property emailVerified - Whether the email has been verified.
* @property name - User's display name (optional).
* @property creationDate - Date when the account was created.
* Will be undefined immediately after login until the first sync completes.
*/
export type AccountInfo = {
email: string;
emailVerified: boolean;
name: string | undefined;
creationDate: Date | undefined;
};
export type Account = { id: UserId } & AccountInfo;
export function accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof AccountInfo>;
for (const key of keys) {
if (a[key] !== b[key]) {
return false;
}
}
return true;
}
export abstract class AccountService {
abstract accounts$: Observable<Record<UserId, AccountInfo>>;
@@ -47,6 +36,8 @@ export abstract class AccountService {
abstract sortedUserIds$: Observable<UserId[]>;
/** Next account that is not the current active account */
abstract nextUpAccount$: Observable<Account>;
/** Observable to display the header */
abstract showHeader$: Observable<boolean>;
/**
* Updates the `accounts$` observable with the new account data.
*
@@ -73,6 +64,12 @@ export abstract class AccountService {
* @param emailVerified
*/
abstract setAccountEmailVerified(userId: UserId, emailVerified: boolean): Promise<void>;
/**
* updates the `accounts$` observable with the creation date for the account.
* @param userId
* @param creationDate
*/
abstract setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void>;
/**
* updates the `accounts$` observable with the new VerifyNewDeviceLogin property for the account.
* @param userId
@@ -100,6 +97,11 @@ export abstract class AccountService {
* @param lastActivity
*/
abstract setAccountActivity(userId: UserId, lastActivity: Date): Promise<void>;
/**
* Show the account switcher.
* @param value
*/
abstract setShowHeader(visible: boolean): Promise<void>;
}
export abstract class InternalAccountService extends AccountService {

View File

@@ -1,7 +1,6 @@
# Auth Request Answering Service
This feature is to allow for the taking of auth requests that are received via websockets by the background service to
be acted on when the user loads up a client. Currently only implemented with the browser client.
This feature is to allow for the taking of auth requests that are received via websockets to be acted on when the user loads up a client.
See diagram for the high level picture of how this is wired up.

View File

@@ -1,30 +1,50 @@
import { Observable } from "rxjs";
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
export abstract class AuthRequestAnsweringServiceAbstraction {
export abstract class AuthRequestAnsweringService {
/**
* Tries to either display the dialog for the user or will preserve its data and show it at a
* later time. Even in the event the dialog is shown immediately, this will write to global state
* so that even if someone closes a window or a popup and comes back, it could be processed later.
* Only way to clear out the global state is to respond to the auth request.
* - Implemented on Extension and Desktop.
*
* Currently, this is only implemented for browser extension.
*
* @param userId The UserId that the auth request is for.
* @param authRequestUserId The UserId that the auth request is for.
* @param authRequestId The id of the auth request that is to be processed.
*/
abstract receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void>;
abstract receivedPendingAuthRequest?(
authRequestUserId: UserId,
authRequestId: string,
): Promise<void>;
/**
* When a system notification is clicked, this function is used to process that event.
* Confirms whether or not the user meets the conditions required to show them an
* approval dialog immediately.
*
* @param authRequestUserId the UserId that the auth request is for.
* @returns boolean stating whether or not the user meets conditions
*/
abstract activeUserMeetsConditionsToShowApprovalDialog(
authRequestUserId: UserId,
): Promise<boolean>;
/**
* Sets up listeners for scenarios where the user unlocks and we want to process
* any pending auth requests in state.
*
* @param destroy$ The destroy$ observable from the caller
*/
abstract setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void;
/**
* When a system notification is clicked, this method is used to process that event.
* - Implemented on Extension only.
* - Desktop does not implement this method because click handling is already setup in
* electron-main-messaging.service.ts.
*
* @param event The event passed in. Check initNotificationSubscriptions in main.background.ts.
*/
abstract handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void>;
/**
* Process notifications that have been received but didn't meet the conditions to display the
* approval dialog.
*/
abstract processPendingAuthRequests(): Promise<void>;
}

View File

@@ -1,25 +1,27 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { UserId } from "../../../types/guid";
import { TwoFactorProviderType } from "../../enums/two-factor-provider-type";
export class AuthResult {
userId: UserId;
// TODO: PM-3287 - Remove this after 3 releases of backwards compatibility. - Target release 2023.12 for removal
/**
* @deprecated
* Replace with using UserDecryptionOptions to determine if the user does
* not have a master password and is not using Key Connector.
* */
resetMasterPassword = false;
twoFactorProviders: Partial<Record<TwoFactorProviderType, Record<string, string>>> = null;
ssoEmail2FaSessionToken?: string;
email: string;
requiresEncryptionKeyMigration: boolean;
requiresDeviceVerification: boolean;
ssoOrganizationIdentifier?: string | null;
// The master-password used in the authentication process
masterPassword: string | null;
get requiresTwoFactor() {
return this.twoFactorProviders != null;
}
// This is not as extensible as an object-based approach. In the future we may need to adjust to an object based approach.
get requiresSso() {
return !Utils.isNullOrWhitespace(this.ssoOrganizationIdentifier);
}
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
export class IdentitySsoRequiredResponse extends BaseResponse {
ssoOrganizationIdentifier: string | null;
constructor(response: any) {
super(response);
this.ssoOrganizationIdentifier = this.getResponseProperty("SsoOrganizationIdentifier");
}
}

View File

@@ -116,4 +116,36 @@ describe("IdentityTokenResponse", () => {
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.userDecryptionOptions).toBeDefined();
});
it("should create response with accountKeys not present", () => {
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: null as unknown,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeNull();
});
it("should create response with accountKeys present", () => {
const accountKeysData = {
publicKeyEncryptionKeyPair: {
publicKey: "testPublicKey",
wrappedPrivateKey: "testPrivateKey",
},
};
const response = {
access_token: accessToken,
token_type: tokenType,
AccountKeys: accountKeysData,
};
const identityTokenResponse = new IdentityTokenResponse(response);
expect(identityTokenResponse.accountKeysResponseModel).toBeDefined();
expect(
identityTokenResponse.accountKeysResponseModel?.publicKeyEncryptionKeyPair,
).toBeDefined();
});
});

View File

@@ -5,6 +5,7 @@
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { EncString } from "../../../key-management/crypto/models/enc-string";
import { PrivateKeysResponseModel } from "../../../key-management/keys/response/private-keys.response";
import { BaseResponse } from "../../../models/response/base.response";
import { MasterPasswordPolicyResponse } from "./master-password-policy.response";
@@ -18,15 +19,26 @@ export class IdentityTokenResponse extends BaseResponse {
tokenType: string;
// Decryption Information
resetMasterPassword: boolean;
privateKey: string; // userKeyEncryptedPrivateKey
key?: EncString; // masterKeyEncryptedUserKey
/**
* privateKey is actually userKeyEncryptedPrivateKey
* @deprecated Use {@link accountKeysResponseModel} instead
*/
privateKey: string;
// TODO: https://bitwarden.atlassian.net/browse/PM-30124 - Rename to just accountKeys
accountKeysResponseModel: PrivateKeysResponseModel | null = null;
/**
* key is actually masterKeyEncryptedUserKey
* @deprecated Use {@link userDecryptionOptions.masterPasswordUnlock.masterKeyWrappedUserKey} instead
*/
key?: EncString;
twoFactorToken: string;
kdfConfig: KdfConfig;
forcePasswordReset: boolean;
masterPasswordPolicy: MasterPasswordPolicyResponse;
apiUseKeyConnector: boolean;
keyConnectorUrl: string;
userDecryptionOptions?: UserDecryptionOptionsResponse;
@@ -53,8 +65,12 @@ export class IdentityTokenResponse extends BaseResponse {
this.refreshToken = refreshToken;
}
this.resetMasterPassword = this.getResponseProperty("ResetMasterPassword");
this.privateKey = this.getResponseProperty("PrivateKey");
if (this.getResponseProperty("AccountKeys") != null) {
this.accountKeysResponseModel = new PrivateKeysResponseModel(
this.getResponseProperty("AccountKeys"),
);
}
const key = this.getResponseProperty("Key");
if (key) {
this.key = new EncString(key);
@@ -70,7 +86,7 @@ export class IdentityTokenResponse extends BaseResponse {
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
this.forcePasswordReset = this.getResponseProperty("ForcePasswordReset");
this.apiUseKeyConnector = this.getResponseProperty("ApiUseKeyConnector");
this.keyConnectorUrl = this.getResponseProperty("KeyConnectorUrl");
this.masterPasswordPolicy = new MasterPasswordPolicyResponse(
this.getResponseProperty("MasterPasswordPolicy"),
);

View File

@@ -27,6 +27,10 @@ export class UserDecryptionOptionsResponse extends BaseResponse {
masterPasswordUnlock?: MasterPasswordUnlockResponse;
trustedDeviceOption?: TrustedDeviceUserDecryptionOptionResponse;
keyConnectorOption?: KeyConnectorUserDecryptionOptionResponse;
/**
* The IdTokenresponse only returns a single WebAuthn PRF option.
* To support immediate unlock after logging in with the same PRF passkey.
*/
webAuthnPrfOption?: WebAuthnPrfDecryptionOptionResponse;
constructor(response: IUserDecryptionOptionsServerResponse) {

View File

@@ -6,19 +6,30 @@ import { BaseResponse } from "../../../../models/response/base.response";
export interface IWebAuthnPrfDecryptionOptionServerResponse {
EncryptedPrivateKey: string;
EncryptedUserKey: string;
CredentialId: string;
Transports: string[];
}
export class WebAuthnPrfDecryptionOptionResponse extends BaseResponse {
encryptedPrivateKey: EncString;
encryptedUserKey: EncString;
credentialId: string;
transports: string[];
constructor(response: IWebAuthnPrfDecryptionOptionServerResponse) {
super(response);
if (response.EncryptedPrivateKey) {
this.encryptedPrivateKey = new EncString(this.getResponseProperty("EncryptedPrivateKey"));
const encPrivateKey = this.getResponseProperty("EncryptedPrivateKey");
if (encPrivateKey) {
this.encryptedPrivateKey = new EncString(encPrivateKey);
}
if (response.EncryptedUserKey) {
this.encryptedUserKey = new EncString(this.getResponseProperty("EncryptedUserKey"));
const encUserKey = this.getResponseProperty("EncryptedUserKey");
if (encUserKey) {
this.encryptedUserKey = new EncString(encUserKey);
}
this.credentialId = this.getResponseProperty("CredentialId");
this.transports = this.getResponseProperty("Transports") || [];
}
}

View File

@@ -217,7 +217,7 @@ export class DefaultSendTokenService implements SendTokenServiceAbstraction {
}
// Destructure to omit the specific sendId and get new reference for the rest of the dict for an immutable update
const { [sendId]: _, ...rest } = sendAccessTokenDict;
return rest;

View File

@@ -6,6 +6,7 @@
import { MockProxy, mock } from "jest-mock-extended";
import { firstValueFrom } from "rxjs";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { FakeGlobalState } from "../../../spec/fake-state";
import {
FakeGlobalStateProvider,
@@ -16,7 +17,7 @@ import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
import { Utils } from "../../platform/misc/utils";
import { UserId } from "../../types/guid";
import { AccountInfo, accountInfoEqual } from "../abstractions/account.service";
import { AccountInfo } from "../abstractions/account.service";
import {
ACCOUNT_ACCOUNTS,
@@ -26,46 +27,6 @@ import {
AccountServiceImplementation,
} from "./account.service";
describe("accountInfoEqual", () => {
const accountInfo: AccountInfo = { name: "name", email: "email", emailVerified: true };
it("compares nulls", () => {
expect(accountInfoEqual(null, null)).toBe(true);
expect(accountInfoEqual(null, accountInfo)).toBe(false);
expect(accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares all keys, not just those defined in AccountInfo", () => {
const different = { ...accountInfo, extra: "extra" };
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect(accountInfoEqual(accountInfo, same)).toBe(true);
expect(accountInfoEqual(accountInfo, different)).toBe(false);
});
});
describe("accountService", () => {
let messagingService: MockProxy<MessagingService>;
let logService: MockProxy<LogService>;
@@ -76,7 +37,10 @@ describe("accountService", () => {
let activeAccountIdState: FakeGlobalState<UserId>;
let accountActivityState: FakeGlobalState<Record<UserId, Date>>;
const userId = Utils.newGuid() as UserId;
const userInfo = { email: "email", name: "name", emailVerified: true };
const userInfo = mockAccountInfoWith({
email: "email",
name: "name",
});
beforeEach(() => {
messagingService = mock();
@@ -100,6 +64,60 @@ describe("accountService", () => {
jest.resetAllMocks();
});
describe("accountInfoEqual", () => {
const accountInfo = mockAccountInfoWith();
it("compares nulls", () => {
expect((sut as any).accountInfoEqual(null, null)).toBe(true);
expect((sut as any).accountInfoEqual(null, accountInfo)).toBe(false);
expect((sut as any).accountInfoEqual(accountInfo, null)).toBe(false);
});
it("compares name", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, name: "name2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares email", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, email: "email2" };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares emailVerified", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, emailVerified: false };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares creationDate", () => {
const same = { ...accountInfo };
const different = { ...accountInfo, creationDate: new Date("2024-12-31T00:00:00.000Z") };
expect((sut as any).accountInfoEqual(accountInfo, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountInfo, different)).toBe(false);
});
it("compares undefined creationDate", () => {
const accountWithoutCreationDate = mockAccountInfoWith({ creationDate: undefined });
const same = { ...accountWithoutCreationDate };
const different = {
...accountWithoutCreationDate,
creationDate: new Date("2024-01-01T00:00:00.000Z"),
};
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, same)).toBe(true);
expect((sut as any).accountInfoEqual(accountWithoutCreationDate, different)).toBe(false);
});
});
describe("activeAccount$", () => {
it("should emit null if no account is active", () => {
const emissions = trackEmissions(sut.activeAccount$);
@@ -253,6 +271,79 @@ describe("accountService", () => {
});
});
describe("setCreationDate", () => {
const initialState = { [userId]: userInfo };
beforeEach(() => {
accountsState.stateSubject.next(initialState);
});
it("should update the account with a new creation date", async () => {
const newCreationDate = new Date("2024-12-31T00:00:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: newCreationDate },
});
});
it("should not update if the creation date is the same", async () => {
await sut.setAccountCreationDate(userId, userInfo.creationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should not update if the creation date has the same timestamp but different Date object", async () => {
const sameTimestamp = new Date(userInfo.creationDate.getTime());
await sut.setAccountCreationDate(userId, sameTimestamp);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual(initialState);
});
it("should update if the creation date has a different timestamp", async () => {
const differentDate = new Date(userInfo.creationDate.getTime() + 1000);
await sut.setAccountCreationDate(userId, differentDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...userInfo, creationDate: differentDate },
});
});
it("should update from undefined to a defined creation date", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
const newCreationDate = new Date("2024-06-15T12:30:00.000Z");
await sut.setAccountCreationDate(userId, newCreationDate);
const currentState = await firstValueFrom(accountsState.state$);
expect(currentState).toEqual({
[userId]: { ...accountWithoutCreationDate, creationDate: newCreationDate },
});
});
it("should not update when both creation dates are undefined", async () => {
const accountWithoutCreationDate = mockAccountInfoWith({
...userInfo,
creationDate: undefined,
});
accountsState.stateSubject.next({ [userId]: accountWithoutCreationDate });
// Attempt to set to undefined (shouldn't trigger update)
const currentStateBefore = await firstValueFrom(accountsState.state$);
// We can't directly call setAccountCreationDate with undefined, but we can verify
// the behavior through setAccountInfo which accountInfoEqual uses internally
expect(currentStateBefore[userId].creationDate).toBeUndefined();
});
});
describe("setAccountVerifyNewDeviceLogin", () => {
const initialState = true;
beforeEach(() => {
@@ -294,6 +385,7 @@ describe("accountService", () => {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
},
});
});
@@ -429,6 +521,16 @@ describe("accountService", () => {
},
);
});
describe("setShowHeader", () => {
it("should update _showHeader$ when setShowHeader is called", async () => {
expect(sut["_showHeader$"].value).toBe(true);
await sut.setShowHeader(false);
expect(sut["_showHeader$"].value).toBe(false);
});
});
});
});

View File

@@ -6,6 +6,7 @@ import {
distinctUntilChanged,
shareReplay,
combineLatest,
BehaviorSubject,
Observable,
switchMap,
filter,
@@ -17,7 +18,6 @@ import {
Account,
AccountInfo,
InternalAccountService,
accountInfoEqual,
} from "../../auth/abstractions/account.service";
import { LogService } from "../../platform/abstractions/log.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -36,7 +36,10 @@ export const ACCOUNT_ACCOUNTS = KeyDefinition.record<AccountInfo, UserId>(
ACCOUNT_DISK,
"accounts",
{
deserializer: (accountInfo) => accountInfo,
deserializer: (accountInfo) => ({
...accountInfo,
creationDate: accountInfo.creationDate ? new Date(accountInfo.creationDate) : undefined,
}),
},
);
@@ -61,6 +64,7 @@ const LOGGED_OUT_INFO: AccountInfo = {
email: "",
emailVerified: false,
name: undefined,
creationDate: undefined,
};
/**
@@ -84,6 +88,7 @@ export const getOptionalUserId = map<Account | null, UserId | null>(
export class AccountServiceImplementation implements InternalAccountService {
private accountsState: GlobalState<Record<UserId, AccountInfo>>;
private activeAccountIdState: GlobalState<UserId | undefined>;
private _showHeader$ = new BehaviorSubject<boolean>(true);
accounts$: Observable<Record<UserId, AccountInfo>>;
activeAccount$: Observable<Account | null>;
@@ -91,6 +96,7 @@ export class AccountServiceImplementation implements InternalAccountService {
accountVerifyNewDeviceLogin$: Observable<boolean>;
sortedUserIds$: Observable<UserId[]>;
nextUpAccount$: Observable<Account>;
showHeader$ = this._showHeader$.asObservable();
constructor(
private messagingService: MessagingService,
@@ -107,7 +113,7 @@ export class AccountServiceImplementation implements InternalAccountService {
this.activeAccount$ = this.activeAccountIdState.state$.pipe(
combineLatestWith(this.accounts$),
map(([id, accounts]) => (id ? ({ id, ...(accounts[id] as AccountInfo) } as Account) : null)),
distinctUntilChanged((a, b) => a?.id === b?.id && accountInfoEqual(a, b)),
distinctUntilChanged((a, b) => a?.id === b?.id && this.accountInfoEqual(a, b)),
shareReplay({ bufferSize: 1, refCount: false }),
);
this.accountActivity$ = this.globalStateProvider
@@ -164,6 +170,10 @@ export class AccountServiceImplementation implements InternalAccountService {
await this.setAccountInfo(userId, { emailVerified });
}
async setAccountCreationDate(userId: UserId, creationDate: Date): Promise<void> {
await this.setAccountInfo(userId, { creationDate });
}
async clean(userId: UserId) {
await this.setAccountInfo(userId, LOGGED_OUT_INFO);
await this.removeAccountActivity(userId);
@@ -262,6 +272,27 @@ export class AccountServiceImplementation implements InternalAccountService {
}
}
async setShowHeader(visible: boolean): Promise<void> {
this._showHeader$.next(visible);
}
private accountInfoEqual(a: AccountInfo, b: AccountInfo) {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
return (
a.email === b.email &&
a.emailVerified === b.emailVerified &&
a.name === b.name &&
a.creationDate?.getTime() === b.creationDate?.getTime()
);
}
private async setAccountInfo(userId: UserId, update: Partial<AccountInfo>): Promise<void> {
function newAccountInfo(oldAccountInfo: AccountInfo): AccountInfo {
return { ...oldAccountInfo, ...update };
@@ -279,7 +310,7 @@ export class AccountServiceImplementation implements InternalAccountService {
throw new Error("Account does not exist");
}
return !accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
return !this.accountInfoEqual(accounts[userId], newAccountInfo(accounts[userId]));
},
},
);

View File

@@ -1,139 +0,0 @@
import { mock, MockProxy } from "jest-mock-extended";
import { of } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "./auth-request-answering.service";
import { PendingAuthRequestsStateService } from "./pending-auth-requests.state";
describe("AuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let actionService: MockProxy<ActionsService>;
let authService: MockProxy<AuthService>;
let i18nService: MockProxy<I18nService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let platformUtilsService: MockProxy<PlatformUtilsService>;
let systemNotificationsService: MockProxy<SystemNotificationsService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
beforeEach(() => {
accountService = mock<AccountService>();
actionService = mock<ActionsService>();
authService = mock<AuthService>();
i18nService = mock<I18nService>();
masterPasswordService = { forceSetPasswordReason$: jest.fn() };
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
platformUtilsService = mock<PlatformUtilsService>();
systemNotificationsService = mock<SystemNotificationsService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of({
id: userId,
email: "user@example.com",
emailVerified: true,
name: "User",
});
accountService.accounts$ = of({
[userId]: { email: "user@example.com", emailVerified: true, name: "User" },
});
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
of(ForceSetPasswordReason.None),
);
platformUtilsService.isPopupOpen.mockResolvedValue(false);
i18nService.t.mockImplementation(
(key: string, p1?: any) => `${key}${p1 != null ? ":" + p1 : ""}`,
);
systemNotificationsService.create.mockResolvedValue("notif-id");
sut = new AuthRequestAnsweringService(
accountService,
actionService,
authService,
i18nService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
platformUtilsService,
systemNotificationsService,
);
});
describe("handleAuthRequestNotificationClicked", () => {
it("clears notification and opens popup when notification body is clicked", async () => {
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.NotificationButton,
};
await sut.handleAuthRequestNotificationClicked(event);
expect(systemNotificationsService.clear).toHaveBeenCalledWith({ id: "123" });
expect(actionService.openPopup).toHaveBeenCalledTimes(1);
});
it("does nothing when an optional button is clicked", async () => {
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.FirstOptionalButton,
};
await sut.handleAuthRequestNotificationClicked(event);
expect(systemNotificationsService.clear).not.toHaveBeenCalled();
expect(actionService.openPopup).not.toHaveBeenCalled();
});
});
describe("receivedPendingAuthRequest", () => {
const authRequestId = "req-abc";
it("creates a system notification when popup is not open", async () => {
platformUtilsService.isPopupOpen.mockResolvedValue(false);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
await sut.receivedPendingAuthRequest(userId, authRequestId);
expect(i18nService.t).toHaveBeenCalledWith("accountAccessRequested");
expect(i18nService.t).toHaveBeenCalledWith("confirmAccessAttempt", "user@example.com");
expect(systemNotificationsService.create).toHaveBeenCalledWith({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`,
title: "accountAccessRequested",
body: "confirmAccessAttempt:user@example.com",
buttons: [],
});
});
it("does not create a notification when popup is open, user is active, unlocked, and no force set password", async () => {
platformUtilsService.isPopupOpen.mockResolvedValue(true);
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
(masterPasswordService.forceSetPasswordReason$ as jest.Mock).mockReturnValue(
of(ForceSetPasswordReason.None),
);
await sut.receivedPendingAuthRequest(userId, authRequestId);
expect(systemNotificationsService.create).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,111 +0,0 @@
import { firstValueFrom } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthServerNotificationTags } from "@bitwarden/common/auth/enums/auth-server-notification-tags";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { ActionsService } from "@bitwarden/common/platform/actions";
import {
ButtonLocation,
SystemNotificationEvent,
SystemNotificationsService,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
export class AuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor(
private readonly accountService: AccountService,
private readonly actionService: ActionsService,
private readonly authService: AuthService,
private readonly i18nService: I18nService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
private readonly messagingService: MessagingService,
private readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
private readonly platformUtilsService: PlatformUtilsService,
private readonly systemNotificationsService: SystemNotificationsService,
) {}
async receivedPendingAuthRequest(userId: UserId, authRequestId: string): Promise<void> {
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
const activeUserId: UserId | null = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(userId),
);
const popupOpen = await this.platformUtilsService.isPopupOpen();
// Always persist the pending marker for this user to global state.
await this.pendingAuthRequestsState.add(userId);
// These are the conditions we are looking for to know if the extension is in a state to show
// the approval dialog.
const userIsAvailableToReceiveAuthRequest =
popupOpen &&
authStatus === AuthenticationStatus.Unlocked &&
activeUserId === userId &&
forceSetPasswordReason === ForceSetPasswordReason.None;
if (!userIsAvailableToReceiveAuthRequest) {
// Get the user's email to include in the system notification
const accounts = await firstValueFrom(this.accountService.accounts$);
const emailForUser = accounts[userId].email;
await this.systemNotificationsService.create({
id: `${AuthServerNotificationTags.AuthRequest}_${authRequestId}`, // the underscore is an important delimiter.
title: this.i18nService.t("accountAccessRequested"),
body: this.i18nService.t("confirmAccessAttempt", emailForUser),
buttons: [],
});
return;
}
// Popup is open and conditions are met; open dialog immediately for this request
this.messagingService.send("openLoginApproval");
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
if (event.buttonIdentifier === ButtonLocation.NotificationButton) {
await this.systemNotificationsService.clear({
id: `${event.id}`,
});
await this.actionService.openPopup();
}
}
async processPendingAuthRequests(): Promise<void> {
// Prune any stale pending requests (older than 15 minutes)
// This comes from GlobalSettings.cs
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
const fifteenMinutesMs = 15 * 60 * 1000;
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
if (pendingAuthRequestsInState.length > 0) {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
(e) => e.userId === activeUserId,
);
if (pendingAuthRequestsForActiveUser) {
this.messagingService.send("openLoginApproval");
}
}
}
}

View File

@@ -0,0 +1,444 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, of, Subject } from "rxjs";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import {
ButtonLocation,
SystemNotificationEvent,
} from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { mockAccountInfoWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { DefaultAuthRequestAnsweringService } from "./default-auth-request-answering.service";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
describe("DefaultAuthRequestAnsweringService", () => {
let accountService: MockProxy<AccountService>;
let authService: MockProxy<AuthService>;
let masterPasswordService: any; // MasterPasswordServiceAbstraction has many members; we only use forceSetPasswordReason$
let messagingService: MockProxy<MessagingService>;
let pendingAuthRequestsState: MockProxy<PendingAuthRequestsStateService>;
let sut: AuthRequestAnsweringService;
const userId = "9f4c3452-6a45-48af-a7d0-74d3e8b65e4c" as UserId;
const userAccountInfo = mockAccountInfoWith({
name: "User",
email: "user@example.com",
});
const userAccount: Account = {
id: userId,
...userAccountInfo,
};
const otherUserId = "554c3112-9a75-23af-ab80-8dk3e9bl5i8e" as UserId;
const otherUserAccountInfo = mockAccountInfoWith({
name: "Other",
email: "other@example.com",
});
const otherUserAccount: Account = {
id: otherUserId,
...otherUserAccountInfo,
};
beforeEach(() => {
accountService = mock<AccountService>();
authService = mock<AuthService>();
masterPasswordService = {
forceSetPasswordReason$: jest.fn().mockReturnValue(of(ForceSetPasswordReason.None)),
};
messagingService = mock<MessagingService>();
pendingAuthRequestsState = mock<PendingAuthRequestsStateService>();
// Common defaults
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
accountService.activeAccount$ = of(userAccount);
accountService.accounts$ = of({
[userId]: userAccountInfo,
[otherUserId]: otherUserAccountInfo,
});
sut = new DefaultAuthRequestAnsweringService(
accountService,
authService,
masterPasswordService,
messagingService,
pendingAuthRequestsState,
);
});
describe("activeUserMeetsConditionsToShowApprovalDialog()", () => {
it("should return false if there is no active user", async () => {
// Arrange
accountService.activeAccount$ = of(null);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is not the intended recipient of the auth request", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(otherUserId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is not unlocked", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Locked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return false if the active user is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(false);
});
it("should return true if the active user is the intended recipient of the auth request, unlocked, and not required to set/change their master password", async () => {
// Arrange
authService.activeAccountStatus$ = of(AuthenticationStatus.Unlocked);
// Act
const result = await sut.activeUserMeetsConditionsToShowApprovalDialog(userId);
// Assert
expect(result).toBe(true);
});
});
describe("setupUnlockListenersForProcessingAuthRequests()", () => {
let destroy$: Subject<void>;
let activeAccount$: BehaviorSubject<Account>;
let activeAccountStatus$: BehaviorSubject<AuthenticationStatus>;
let authStatusForSubjects: Map<UserId, BehaviorSubject<AuthenticationStatus>>;
let pendingRequestMarkers: PendingAuthUserMarker[];
beforeEach(() => {
destroy$ = new Subject<void>();
activeAccount$ = new BehaviorSubject(userAccount);
activeAccountStatus$ = new BehaviorSubject(AuthenticationStatus.Locked);
authStatusForSubjects = new Map();
pendingRequestMarkers = [];
accountService.activeAccount$ = activeAccount$;
authService.activeAccountStatus$ = activeAccountStatus$;
authService.authStatusFor$.mockImplementation((id: UserId) => {
if (!authStatusForSubjects.has(id)) {
authStatusForSubjects.set(id, new BehaviorSubject(AuthenticationStatus.Locked));
}
return authStatusForSubjects.get(id)!;
});
pendingAuthRequestsState.getAll$.mockReturnValue(of([]));
});
afterEach(() => {
destroy$.next();
destroy$.complete();
});
describe("active account switching", () => {
it("should process pending auth requests when switching to an unlocked user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Simulate account switching to an Unlocked account
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0)); // Allows observable chain to complete before assertion
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when switching to a locked user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Locked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when switching to a logged out user", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.LoggedOut));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when active account becomes null", async () => {
// Arrange
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(null);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should handle multiple user switches correctly", async () => {
// Arrange
authStatusForSubjects.set(userId, new BehaviorSubject(AuthenticationStatus.Locked));
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Switch to unlocked user (should trigger)
activeAccount$.next(otherUserAccount);
await new Promise((resolve) => setTimeout(resolve, 0));
// Switch to locked user (should NOT trigger)
activeAccount$.next(userAccount);
await new Promise((resolve) => setTimeout(resolve, 0));
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(1);
});
it("should NOT process pending auth requests when switching to an Unlocked user who is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccount$.next(otherUserAccount);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
describe("authentication status transitions", () => {
it("should process pending auth requests when active account transitions to Unlocked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should process pending auth requests when transitioning from LoggedOut to Unlocked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when transitioning from Unlocked to Locked", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
await new Promise((resolve) => setTimeout(resolve, 0));
// Clear any calls from the initial trigger (from null -> Unlocked)
messagingService.send.mockClear();
activeAccountStatus$.next(AuthenticationStatus.Locked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when transitioning from Locked to LoggedOut", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.LoggedOut);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should NOT process pending auth requests when staying in Unlocked status", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
await new Promise((resolve) => setTimeout(resolve, 0));
// Clear any calls from the initial trigger (from null -> Unlocked)
messagingService.send.mockClear();
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
it("should handle multiple status transitions correctly", async () => {
// Arrange
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Transition to Unlocked (should trigger)
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Transition to Locked (should NOT trigger)
activeAccountStatus$.next(AuthenticationStatus.Locked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Transition back to Unlocked (should trigger again)
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
await new Promise((resolve) => setTimeout(resolve, 0));
// Assert
expect(messagingService.send).toHaveBeenCalledTimes(2);
expect(messagingService.send).toHaveBeenCalledWith("openLoginApproval");
});
it("should NOT process pending auth requests when active account transitions to Unlocked but is required to set/change their master password", async () => {
// Arrange
masterPasswordService.forceSetPasswordReason$.mockReturnValue(
of(ForceSetPasswordReason.WeakMasterPassword),
);
activeAccountStatus$.next(AuthenticationStatus.Locked);
pendingRequestMarkers = [{ userId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
describe("subscription cleanup", () => {
it("should stop processing when destroy$ emits", async () => {
// Arrange
authStatusForSubjects.set(otherUserId, new BehaviorSubject(AuthenticationStatus.Unlocked));
pendingRequestMarkers = [{ userId: otherUserId, receivedAtMs: Date.now() }];
pendingAuthRequestsState.getAll$.mockReturnValue(of(pendingRequestMarkers));
// Act
sut.setupUnlockListenersForProcessingAuthRequests(destroy$);
// Emit destroy signal
destroy$.next();
// Try to trigger processing after cleanup
activeAccount$.next(otherUserAccount);
activeAccountStatus$.next(AuthenticationStatus.Unlocked);
// Assert
await new Promise((resolve) => setTimeout(resolve, 0));
expect(messagingService.send).not.toHaveBeenCalled();
});
});
});
describe("handleAuthRequestNotificationClicked()", () => {
it("should throw an error", async () => {
// Arrange
const event: SystemNotificationEvent = {
id: "123",
buttonIdentifier: ButtonLocation.NotificationButton,
};
// Act
const promise = sut.handleAuthRequestNotificationClicked(event);
// Assert
await expect(promise).rejects.toThrow(
"handleAuthRequestNotificationClicked() not implemented for this client",
);
});
});
});

View File

@@ -0,0 +1,140 @@
import {
distinctUntilChanged,
filter,
firstValueFrom,
map,
Observable,
pairwise,
startWith,
switchMap,
take,
takeUntil,
tap,
} from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { ForceSetPasswordReason } from "@bitwarden/common/auth/models/domain/force-set-password-reason";
import { getOptionalUserId, getUserId } from "@bitwarden/common/auth/services/account.service";
import { MasterPasswordServiceAbstraction } from "@bitwarden/common/key-management/master-password/abstractions/master-password.service.abstraction";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import {
PendingAuthRequestsStateService,
PendingAuthUserMarker,
} from "./pending-auth-requests.state";
export class DefaultAuthRequestAnsweringService implements AuthRequestAnsweringService {
constructor(
protected readonly accountService: AccountService,
protected readonly authService: AuthService,
protected readonly masterPasswordService: MasterPasswordServiceAbstraction,
protected readonly messagingService: MessagingService,
protected readonly pendingAuthRequestsState: PendingAuthRequestsStateService,
) {}
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
// If the active user is not the intended recipient of the auth request, return false
const activeUserId: UserId | null = await firstValueFrom(
this.accountService.activeAccount$.pipe(getOptionalUserId),
);
if (activeUserId !== authRequestUserId) {
return false;
}
// If the active user is not unlocked, return false
const authStatus = await firstValueFrom(this.authService.activeAccountStatus$);
if (authStatus !== AuthenticationStatus.Unlocked) {
return false;
}
// If the active user is required to set/change their master password, return false
// Note that by this point we know that the authRequestUserId is the active UserId (see check above)
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(authRequestUserId),
);
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
return false;
}
// User meets conditions: they are the intended recipient, unlocked, and not required to set/change their master password
return true;
}
setupUnlockListenersForProcessingAuthRequests(destroy$: Observable<void>): void {
// When account switching to a user who is Unlocked, process any pending auth requests.
this.accountService.activeAccount$
.pipe(
map((a) => a?.id), // Extract active userId
distinctUntilChanged(), // Only when userId actually changes
filter((userId) => userId != null), // Require a valid userId
switchMap((userId) => this.authService.authStatusFor$(userId).pipe(take(1))), // Get current auth status once for new user
filter((status) => status === AuthenticationStatus.Unlocked), // Only when the new user is Unlocked
tap(() => {
void this.processPendingAuthRequests();
}),
takeUntil(destroy$),
)
.subscribe();
// When the active account transitions TO Unlocked, process any pending auth requests.
this.authService.activeAccountStatus$
.pipe(
startWith(null as unknown as AuthenticationStatus), // Seed previous value to handle initial emission
pairwise(), // Compare previous and current statuses
filter(
([prev, curr]) =>
prev !== AuthenticationStatus.Unlocked && curr === AuthenticationStatus.Unlocked, // Fire on transitions into Unlocked (incl. initial)
),
takeUntil(destroy$),
)
.subscribe(() => {
void this.processPendingAuthRequests();
});
}
/**
* Process notifications that have been received but didn't meet the conditions to display the
* approval dialog.
*/
private async processPendingAuthRequests(): Promise<void> {
const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
// Only continue if the active user is not required to set/change their master password
const forceSetPasswordReason = await firstValueFrom(
this.masterPasswordService.forceSetPasswordReason$(activeUserId),
);
if (forceSetPasswordReason !== ForceSetPasswordReason.None) {
return;
}
// Prune any stale pending requests (older than 15 minutes)
// This comes from GlobalSettings.cs
// public TimeSpan UserRequestExpiration { get; set; } = TimeSpan.FromMinutes(15);
const fifteenMinutesMs = 15 * 60 * 1000;
await this.pendingAuthRequestsState.pruneOlderThan(fifteenMinutesMs);
const pendingAuthRequestsInState: PendingAuthUserMarker[] =
(await firstValueFrom(this.pendingAuthRequestsState.getAll$())) ?? [];
if (pendingAuthRequestsInState.length > 0) {
const pendingAuthRequestsForActiveUser = pendingAuthRequestsInState.some(
(e) => e.userId === activeUserId,
);
if (pendingAuthRequestsForActiveUser) {
this.messagingService.send("openLoginApproval");
}
}
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
}
}

View File

@@ -1,14 +1,22 @@
import { SystemNotificationEvent } from "@bitwarden/common/platform/system-notifications/system-notifications.service";
import { UserId } from "@bitwarden/user-core";
import { AuthRequestAnsweringServiceAbstraction } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
import { AuthRequestAnsweringService } from "../../abstractions/auth-request-answering/auth-request-answering.service.abstraction";
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringServiceAbstraction {
constructor() {}
export class NoopAuthRequestAnsweringService implements AuthRequestAnsweringService {
async activeUserMeetsConditionsToShowApprovalDialog(authRequestUserId: UserId): Promise<boolean> {
throw new Error(
"activeUserMeetsConditionsToShowApprovalDialog() not implemented for this client",
);
}
async receivedPendingAuthRequest(userId: UserId, notificationId: string): Promise<void> {}
setupUnlockListenersForProcessingAuthRequests(): void {
throw new Error(
"setupUnlockListenersForProcessingAuthRequests() not implemented for this client",
);
}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {}
async processPendingAuthRequests(): Promise<void> {}
async handleAuthRequestNotificationClicked(event: SystemNotificationEvent): Promise<void> {
throw new Error("handleAuthRequestNotificationClicked() not implemented for this client");
}
}

View File

@@ -10,6 +10,7 @@ import {
makeStaticByteArray,
mockAccountServiceWith,
trackEmissions,
mockAccountInfoWith,
} from "../../../spec";
import { ApiService } from "../../abstractions/api.service";
import { MessagingService } from "../../platform/abstractions/messaging.service";
@@ -58,9 +59,10 @@ describe("AuthService", () => {
const accountInfo = {
status: AuthenticationStatus.Unlocked,
id: userId,
email: "email",
emailVerified: false,
name: "name",
...mockAccountInfoWith({
email: "email",
name: "name",
}),
};
beforeEach(() => {
@@ -112,9 +114,10 @@ describe("AuthService", () => {
const accountInfo2 = {
status: AuthenticationStatus.Unlocked,
id: Utils.newGuid() as UserId,
email: "email2",
emailVerified: false,
name: "name2",
...mockAccountInfoWith({
email: "email2",
name: "name2",
}),
};
const emissions = trackEmissions(sut.activeAccountStatus$);
@@ -131,11 +134,13 @@ describe("AuthService", () => {
it("requests auth status for all known users", async () => {
const userId2 = Utils.newGuid() as UserId;
await accountService.addAccount(userId2, {
email: "email2",
emailVerified: false,
name: "name2",
});
await accountService.addAccount(
userId2,
mockAccountInfoWith({
email: "email2",
name: "name2",
}),
);
const mockFn = jest.fn().mockReturnValue(of(AuthenticationStatus.Locked));
sut.authStatusFor$ = mockFn;

View File

@@ -8,12 +8,13 @@ import { OrganizationUserApiService } from "@bitwarden/admin-console/common";
// eslint-disable-next-line no-restricted-imports
import { KeyService } from "@bitwarden/key-management";
import { mockAccountInfoWith } from "../../../spec/fake-account-service";
import { OrganizationApiServiceAbstraction } from "../../admin-console/abstractions/organization/organization-api.service.abstraction";
import { OrganizationAutoEnrollStatusResponse } from "../../admin-console/models/response/organization-auto-enroll-status.response";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { I18nService } from "../../platform/abstractions/i18n.service";
import { UserId } from "../../types/guid";
import { Account, AccountInfo, AccountService } from "../abstractions/account.service";
import { Account, AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceImplementation } from "./password-reset-enrollment.service.implementation";
@@ -96,11 +97,10 @@ describe("PasswordResetEnrollmentServiceImplementation", () => {
const encryptedKey = { encryptedString: "encryptedString" };
organizationApiService.getKeys.mockResolvedValue(orgKeyResponse as any);
const user1AccountInfo: AccountInfo = {
const user1AccountInfo = mockAccountInfoWith({
name: "Test User 1",
email: "test1@email.com",
emailVerified: true,
};
});
activeAccountSubject.next(Object.assign(user1AccountInfo, { id: "userId" as UserId }));
keyService.userKey$.mockReturnValue(of({ key: "key" } as any));

View File

@@ -22,9 +22,7 @@ import { UserKey } from "../../types/key";
import { AccountService } from "../abstractions/account.service";
import { PasswordResetEnrollmentServiceAbstraction } from "../abstractions/password-reset-enrollment.service.abstraction";
export class PasswordResetEnrollmentServiceImplementation
implements PasswordResetEnrollmentServiceAbstraction
{
export class PasswordResetEnrollmentServiceImplementation implements PasswordResetEnrollmentServiceAbstraction {
constructor(
protected organizationApiService: OrganizationApiServiceAbstraction,
protected accountService: AccountService,

View File

@@ -445,13 +445,15 @@ export class TokenService implements TokenServiceAbstraction {
// we can't determine storage location w/out vaultTimeoutAction and vaultTimeout
// but we can simply clear all locations to avoid the need to require those parameters.
// When secure storage is supported, clear the encryption key from secure storage.
// When not supported (e.g., portable builds), tokens are stored on disk and this step is skipped.
if (this.platformSupportsSecureStorage) {
// Always clear the access token key when clearing the access token
// The next set of the access token will create a new access token key
// Always clear the access token key when clearing the access token.
// The next set of the access token will create a new access token key.
await this.clearAccessTokenKey(userId);
}
// Platform doesn't support secure storage, so use state provider implementation
// Clear tokens from disk storage (all platforms)
await this.singleUserStateProvider.get(userId, ACCESS_TOKEN_DISK).update((_) => null, {
shouldUpdate: (previousValue) => previousValue !== null,
});
@@ -478,6 +480,9 @@ export class TokenService implements TokenServiceAbstraction {
return null;
}
// When platformSupportsSecureStorage=true, tokens on disk are encrypted and require
// decryption keys from secure storage. When false (e.g., portable builds), tokens are
// stored on disk.
if (this.platformSupportsSecureStorage) {
let accessTokenKey: AccessTokenKey;
try {
@@ -1118,6 +1123,9 @@ export class TokenService implements TokenServiceAbstraction {
) {
return TokenStorageLocation.Memory;
} else {
// Secure storage (e.g., OS credential manager) is preferred when available.
// Desktop portable builds set platformSupportsSecureStorage=false to store tokens
// on disk for portability across machines.
if (useSecureStorage && this.platformSupportsSecureStorage) {
return TokenStorageLocation.SecureStorage;
}

View File

@@ -0,0 +1,102 @@
import { CartResponse } from "@bitwarden/common/billing/models/response/cart.response";
import { StorageResponse } from "@bitwarden/common/billing/models/response/storage.response";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart } from "@bitwarden/pricing";
import {
BitwardenSubscription,
Storage,
SubscriptionStatus,
SubscriptionStatuses,
} from "@bitwarden/subscription";
export class BitwardenSubscriptionResponse extends BaseResponse {
status: SubscriptionStatus;
cart: Cart;
storage: Storage;
cancelAt?: Date;
canceled?: Date;
nextCharge?: Date;
suspension?: Date;
gracePeriod?: number;
constructor(response: any) {
super(response);
const status = this.getResponseProperty("Status");
if (
status !== SubscriptionStatuses.Incomplete &&
status !== SubscriptionStatuses.IncompleteExpired &&
status !== SubscriptionStatuses.Trialing &&
status !== SubscriptionStatuses.Active &&
status !== SubscriptionStatuses.PastDue &&
status !== SubscriptionStatuses.Canceled &&
status !== SubscriptionStatuses.Unpaid
) {
throw new Error(`Failed to parse invalid subscription status: ${status}`);
}
this.status = status;
this.cart = new CartResponse(this.getResponseProperty("Cart"));
this.storage = new StorageResponse(this.getResponseProperty("Storage"));
const suspension = this.getResponseProperty("Suspension");
if (suspension) {
this.suspension = new Date(suspension);
}
const gracePeriod = this.getResponseProperty("GracePeriod");
if (gracePeriod) {
this.gracePeriod = gracePeriod;
}
const nextCharge = this.getResponseProperty("NextCharge");
if (nextCharge) {
this.nextCharge = new Date(nextCharge);
}
const cancelAt = this.getResponseProperty("CancelAt");
if (cancelAt) {
this.cancelAt = new Date(cancelAt);
}
const canceled = this.getResponseProperty("Canceled");
if (canceled) {
this.canceled = new Date(canceled);
}
}
toDomain = (): BitwardenSubscription => {
switch (this.status) {
case SubscriptionStatuses.Incomplete:
case SubscriptionStatuses.IncompleteExpired:
case SubscriptionStatuses.PastDue:
case SubscriptionStatuses.Unpaid: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
suspension: this.suspension!,
gracePeriod: this.gracePeriod!,
};
}
case SubscriptionStatuses.Trialing:
case SubscriptionStatuses.Active: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
nextCharge: this.nextCharge!,
cancelAt: this.cancelAt,
};
}
case SubscriptionStatuses.Canceled: {
return {
cart: this.cart,
storage: this.storage,
status: this.status,
canceled: this.canceled!,
};
}
}
};
}

View File

@@ -0,0 +1,97 @@
import {
SubscriptionCadence,
SubscriptionCadenceIds,
} from "@bitwarden/common/billing/types/subscription-pricing-tier";
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Cart, CartItem, Discount } from "@bitwarden/pricing";
import { DiscountResponse } from "./discount.response";
export class CartItemResponse extends BaseResponse implements CartItem {
translationKey: string;
quantity: number;
cost: number;
discount?: Discount;
constructor(response: any) {
super(response);
this.translationKey = this.getResponseProperty("TranslationKey");
this.quantity = this.getResponseProperty("Quantity");
this.cost = this.getResponseProperty("Cost");
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = discount;
}
}
}
class PasswordManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalStorage?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalStorage = this.getResponseProperty("AdditionalStorage");
if (additionalStorage) {
this.additionalStorage = new CartItemResponse(additionalStorage);
}
}
}
class SecretsManagerCartItemResponse extends BaseResponse {
seats: CartItem;
additionalServiceAccounts?: CartItem;
constructor(response: any) {
super(response);
this.seats = new CartItemResponse(this.getResponseProperty("Seats"));
const additionalServiceAccounts = this.getResponseProperty("AdditionalServiceAccounts");
if (additionalServiceAccounts) {
this.additionalServiceAccounts = new CartItemResponse(additionalServiceAccounts);
}
}
}
export class CartResponse extends BaseResponse implements Cart {
passwordManager: {
seats: CartItem;
additionalStorage?: CartItem;
};
secretsManager?: {
seats: CartItem;
additionalServiceAccounts?: CartItem;
};
cadence: SubscriptionCadence;
discount?: Discount;
estimatedTax: number;
constructor(response: any) {
super(response);
this.passwordManager = new PasswordManagerCartItemResponse(
this.getResponseProperty("PasswordManager"),
);
const secretsManager = this.getResponseProperty("SecretsManager");
if (secretsManager) {
this.secretsManager = new SecretsManagerCartItemResponse(secretsManager);
}
const cadence = this.getResponseProperty("Cadence");
if (cadence !== SubscriptionCadenceIds.Annually && cadence !== SubscriptionCadenceIds.Monthly) {
throw new Error(`Failed to parse invalid cadence: ${cadence}`);
}
this.cadence = cadence;
const discount = this.getResponseProperty("Discount");
if (discount) {
this.discount = new DiscountResponse(discount);
}
this.estimatedTax = this.getResponseProperty("EstimatedTax");
}
}

View File

@@ -0,0 +1,18 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Discount, DiscountType, DiscountTypes } from "@bitwarden/pricing";
export class DiscountResponse extends BaseResponse implements Discount {
type: DiscountType;
value: number;
constructor(response: any) {
super(response);
const type = this.getResponseProperty("Type");
if (type !== DiscountTypes.AmountOff && type !== DiscountTypes.PercentOff) {
throw new Error(`Failed to parse invalid discount type: ${type}`);
}
this.type = type;
this.value = this.getResponseProperty("Value");
}
}

View File

@@ -4,10 +4,12 @@ export class PremiumPlanResponse extends BaseResponse {
seat: {
stripePriceId: string;
price: number;
provided: number;
};
storage: {
stripePriceId: string;
price: number;
provided: number;
};
constructor(response: any) {
@@ -30,6 +32,7 @@ export class PremiumPlanResponse extends BaseResponse {
class PurchasableResponse extends BaseResponse {
stripePriceId: string;
price: number;
provided: number;
constructor(response: any) {
super(response);
@@ -43,5 +46,9 @@ class PurchasableResponse extends BaseResponse {
if (typeof this.price !== "number" || isNaN(this.price)) {
throw new Error("PurchasableResponse: Missing or invalid 'Price' property");
}
this.provided = this.getResponseProperty("Provided");
if (typeof this.provided !== "number" || isNaN(this.provided)) {
throw new Error("PurchasableResponse: Missing or invalid 'Provided' property");
}
}
}

View File

@@ -0,0 +1,16 @@
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
import { Storage } from "@bitwarden/subscription";
export class StorageResponse extends BaseResponse implements Storage {
available: number;
used: number;
readableUsed: string;
constructor(response: any) {
super(response);
this.available = this.getResponseProperty("Available");
this.used = this.getResponseProperty("Used");
this.readableUsed = this.getResponseProperty("ReadableUsed");
}
}

View File

@@ -4,9 +4,7 @@ import { PlatformUtilsService } from "../../../platform/abstractions/platform-ut
import { OrganizationSponsorshipApiServiceAbstraction } from "../../abstractions/organizations/organization-sponsorship-api.service.abstraction";
import { OrganizationSponsorshipInvitesResponse } from "../../models/response/organization-sponsorship-invites.response";
export class OrganizationSponsorshipApiService
implements OrganizationSponsorshipApiServiceAbstraction
{
export class OrganizationSponsorshipApiService implements OrganizationSponsorshipApiServiceAbstraction {
constructor(
private apiService: ApiService,
private platformUtilsService: PlatformUtilsService,

View File

@@ -6,6 +6,10 @@ import { PlanType, ProductTierType } from "@bitwarden/common/billing/enums";
import { PlanResponse } from "@bitwarden/common/billing/models/response/plan.response";
import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/premium-plan.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import {
EnvironmentService,
Region,
} from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -23,6 +27,7 @@ describe("DefaultSubscriptionPricingService", () => {
let configService: MockProxy<ConfigService>;
let i18nService: MockProxy<I18nService>;
let logService: MockProxy<LogService>;
let environmentService: MockProxy<EnvironmentService>;
const mockFamiliesPlan = {
type: PlanType.FamiliesAnnually2025,
@@ -55,6 +60,7 @@ describe("DefaultSubscriptionPricingService", () => {
basePrice: 36,
seatPrice: 0,
additionalStoragePricePerGb: 4,
providedStorageGB: 1,
allowSeatAutoscale: false,
maxSeats: 6,
maxCollections: null,
@@ -94,6 +100,7 @@ describe("DefaultSubscriptionPricingService", () => {
basePrice: 0,
seatPrice: 36,
additionalStoragePricePerGb: 4,
providedStorageGB: 1,
allowSeatAutoscale: true,
maxSeats: null,
maxCollections: null,
@@ -248,7 +255,7 @@ describe("DefaultSubscriptionPricingService", () => {
return "Custom";
// Plan descriptions
case "planDescPremium":
case "advancedOnlineSecurity":
return "Premium plan description";
case "planDescFamiliesV2":
return "Families plan description";
@@ -326,19 +333,32 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
const setupEnvironmentService = (
envService: MockProxy<EnvironmentService>,
region: Region = Region.US,
) => {
envService.environment$ = of({
getRegion: () => region,
isCloud: () => region !== Region.SelfHosted,
} as any);
};
beforeEach(() => {
billingApiService = mock<BillingApiServiceAbstraction>();
configService = mock<ConfigService>();
environmentService = mock<EnvironmentService>();
billingApiService.getPlans.mockResolvedValue(mockPlansResponse);
billingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
configService.getFeatureFlag$.mockReturnValue(of(false)); // Default to false (use hardcoded value)
setupEnvironmentService(environmentService);
service = new DefaultSubscriptionPricingService(
billingApiService,
configService,
i18nService,
logService,
environmentService,
);
});
@@ -359,6 +379,7 @@ describe("DefaultSubscriptionPricingService", () => {
type: "standalone",
annualPrice: 10,
annualPricePerAdditionalStorageGB: 4,
providedStorageGB: 1,
features: [
{ key: "builtInAuthenticator", value: "Built-in authenticator" },
{ key: "secureFileStorage", value: "Secure file storage" },
@@ -383,6 +404,7 @@ describe("DefaultSubscriptionPricingService", () => {
annualPrice: mockFamiliesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: mockFamiliesPlan.PasswordManager.baseStorageGb,
features: [
{ key: "premiumAccounts", value: "6 premium accounts" },
{ key: "familiesUnlimitedSharing", value: "Unlimited sharing for families" },
@@ -393,7 +415,7 @@ describe("DefaultSubscriptionPricingService", () => {
});
expect(i18nService.t).toHaveBeenCalledWith("premium");
expect(i18nService.t).toHaveBeenCalledWith("planDescPremium");
expect(i18nService.t).toHaveBeenCalledWith("advancedOnlineSecurity");
expect(i18nService.t).toHaveBeenCalledWith("planNameFamilies");
expect(i18nService.t).toHaveBeenCalledWith("planDescFamiliesV2");
expect(i18nService.t).toHaveBeenCalledWith("builtInAuthenticator");
@@ -415,11 +437,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -428,6 +452,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -456,6 +481,7 @@ describe("DefaultSubscriptionPricingService", () => {
expect(premiumTier.passwordManager.annualPrice).toEqual(10);
expect(premiumTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(4);
expect(premiumTier.passwordManager.providedStorageGB).toEqual(1);
expect(familiesTier.passwordManager.annualPrice).toEqual(
mockFamiliesPlan.PasswordManager.basePrice,
@@ -463,6 +489,9 @@ describe("DefaultSubscriptionPricingService", () => {
expect(familiesTier.passwordManager.annualPricePerAdditionalStorageGB).toEqual(
mockFamiliesPlan.PasswordManager.additionalStoragePricePerGb,
);
expect(familiesTier.passwordManager.providedStorageGB).toEqual(
mockFamiliesPlan.PasswordManager.baseStorageGb,
);
done();
});
@@ -487,6 +516,7 @@ describe("DefaultSubscriptionPricingService", () => {
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
features: [
{ key: "secureItemSharing", value: "Secure item sharing" },
{ key: "eventLogMonitoring", value: "Event log monitoring" },
@@ -522,6 +552,7 @@ describe("DefaultSubscriptionPricingService", () => {
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
features: [
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
{ key: "passwordLessSso", value: "Passwordless SSO" },
@@ -595,11 +626,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -608,6 +641,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getBusinessSubscriptionPricingTiers$().subscribe({
@@ -648,6 +682,9 @@ describe("DefaultSubscriptionPricingService", () => {
expect(teamsSecretsManager.annualPricePerAdditionalServiceAccount).toEqual(
mockTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
);
expect(teamsPasswordManager.providedStorageGB).toEqual(
mockTeamsPlan.PasswordManager.baseStorageGb,
);
const enterprisePasswordManager = enterpriseTier.passwordManager as any;
const enterpriseSecretsManager = enterpriseTier.secretsManager as any;
@@ -657,6 +694,9 @@ describe("DefaultSubscriptionPricingService", () => {
expect(enterprisePasswordManager.annualPricePerAdditionalStorageGB).toEqual(
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
);
expect(enterprisePasswordManager.providedStorageGB).toEqual(
mockEnterprisePlan.PasswordManager.baseStorageGb,
);
expect(enterpriseSecretsManager.annualPricePerUser).toEqual(
mockEnterprisePlan.SecretsManager.seatPrice,
);
@@ -729,6 +769,7 @@ describe("DefaultSubscriptionPricingService", () => {
annualPricePerUser: mockTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
mockTeamsPlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: mockTeamsPlan.PasswordManager.baseStorageGb,
features: [
{ key: "secureItemSharing", value: "Secure item sharing" },
{ key: "eventLogMonitoring", value: "Event log monitoring" },
@@ -764,6 +805,7 @@ describe("DefaultSubscriptionPricingService", () => {
annualPricePerUser: mockEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
mockEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
providedStorageGB: mockEnterprisePlan.PasswordManager.baseStorageGb,
features: [
{ key: "enterpriseSecurityPolicies", value: "Enterprise security policies" },
{ key: "passwordLessSso", value: "Passwordless SSO" },
@@ -830,11 +872,13 @@ describe("DefaultSubscriptionPricingService", () => {
const errorConfigService = mock<ConfigService>();
const errorI18nService = mock<I18nService>();
const errorLogService = mock<LogService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("API error");
errorBillingApiService.getPlans.mockRejectedValue(testError);
errorBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
errorConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(errorEnvironmentService);
errorI18nService.t.mockImplementation((key: string) => key);
@@ -843,6 +887,7 @@ describe("DefaultSubscriptionPricingService", () => {
errorConfigService,
errorI18nService,
errorLogService,
errorEnvironmentService,
);
errorService.getDeveloperSubscriptionPricingTiers$().subscribe({
@@ -865,17 +910,20 @@ describe("DefaultSubscriptionPricingService", () => {
it("should handle getPremiumPlan() error when getPlans() succeeds", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const errorEnvironmentService = mock<EnvironmentService>();
const testError = new Error("Premium plan API error");
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockRejectedValue(testError);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag to use premium plan API
setupEnvironmentService(errorEnvironmentService);
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
errorEnvironmentService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
@@ -896,88 +944,6 @@ describe("DefaultSubscriptionPricingService", () => {
},
});
});
it("should handle malformed premium plan API response", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response missing the Seat property
const malformedResponse = {
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
it("should handle malformed premium plan with invalid price types", (done) => {
const errorBillingApiService = mock<BillingApiServiceAbstraction>();
const errorConfigService = mock<ConfigService>();
const testError = new TypeError("Cannot read properties of undefined (reading 'price')");
// Malformed response with price as string instead of number
const malformedResponse = {
Seat: {
StripePriceId: "price_seat",
Price: "10", // Should be a number
},
Storage: {
StripePriceId: "price_storage",
Price: 4,
},
};
errorBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
errorBillingApiService.getPremiumPlan.mockResolvedValue(malformedResponse as any);
errorConfigService.getFeatureFlag$.mockReturnValue(of(true)); // Enable feature flag
const errorService = new DefaultSubscriptionPricingService(
errorBillingApiService,
errorConfigService,
i18nService,
logService,
);
errorService.getPersonalSubscriptionPricingTiers$().subscribe({
next: () => {
fail("Observable should error, not return a value");
},
error: (error: unknown) => {
expect(logService.error).toHaveBeenCalledWith(
"Failed to load personal subscription pricing tiers",
testError,
);
expect(error).toEqual(testError);
done();
},
});
});
});
describe("Observable behavior and caching", () => {
@@ -997,10 +963,12 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to avoid conflicts with beforeEach setup
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue(mockPremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(newEnvironmentService);
const getPremiumPlanSpy = jest.spyOn(newBillingApiService, "getPremiumPlan");
@@ -1010,6 +978,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe to the premium pricing tier multiple times
@@ -1024,6 +993,7 @@ describe("DefaultSubscriptionPricingService", () => {
// Create a new mock to test from scratch
const newBillingApiService = mock<BillingApiServiceAbstraction>();
const newConfigService = mock<ConfigService>();
const newEnvironmentService = mock<EnvironmentService>();
newBillingApiService.getPlans.mockResolvedValue(mockPlansResponse);
newBillingApiService.getPremiumPlan.mockResolvedValue({
@@ -1031,6 +1001,7 @@ describe("DefaultSubscriptionPricingService", () => {
storage: { price: 999 },
} as PremiumPlanResponse);
newConfigService.getFeatureFlag$.mockReturnValue(of(false));
setupEnvironmentService(newEnvironmentService);
// Create a new service instance with the feature flag disabled
const newService = new DefaultSubscriptionPricingService(
@@ -1038,6 +1009,7 @@ describe("DefaultSubscriptionPricingService", () => {
newConfigService,
i18nService,
logService,
newEnvironmentService,
);
// Subscribe with feature flag disabled
@@ -1053,4 +1025,66 @@ describe("DefaultSubscriptionPricingService", () => {
});
});
});
describe("Self-hosted environment behavior", () => {
it("should not call API for self-hosted environment", () => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
const getPlansSpy = jest.spyOn(selfHostedBillingApiService, "getPlans");
const getPremiumPlanSpy = jest.spyOn(selfHostedBillingApiService, "getPremiumPlan");
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
// Trigger subscriptions by calling the methods
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe();
selfHostedService.getBusinessSubscriptionPricingTiers$().subscribe();
selfHostedService.getDeveloperSubscriptionPricingTiers$().subscribe();
// API should not be called for self-hosted environments
expect(getPlansSpy).not.toHaveBeenCalled();
expect(getPremiumPlanSpy).not.toHaveBeenCalled();
});
it("should return valid tier structure with undefined prices for self-hosted", (done) => {
const selfHostedBillingApiService = mock<BillingApiServiceAbstraction>();
const selfHostedConfigService = mock<ConfigService>();
const selfHostedEnvironmentService = mock<EnvironmentService>();
selfHostedConfigService.getFeatureFlag$.mockReturnValue(of(true));
setupEnvironmentService(selfHostedEnvironmentService, Region.SelfHosted);
const selfHostedService = new DefaultSubscriptionPricingService(
selfHostedBillingApiService,
selfHostedConfigService,
i18nService,
logService,
selfHostedEnvironmentService,
);
selfHostedService.getPersonalSubscriptionPricingTiers$().subscribe((tiers) => {
expect(tiers).toHaveLength(2); // Premium and Families
const premiumTier = tiers.find((t) => t.id === PersonalSubscriptionPricingTierIds.Premium);
expect(premiumTier).toBeDefined();
expect(premiumTier?.passwordManager.annualPrice).toBeUndefined();
expect(premiumTier?.passwordManager.annualPricePerAdditionalStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.providedStorageGB).toBeUndefined();
expect(premiumTier?.passwordManager.features).toBeDefined();
expect(premiumTier?.passwordManager.features.length).toBeGreaterThan(0);
done();
});
});
});
});

View File

@@ -19,6 +19,7 @@ import { PremiumPlanResponse } from "@bitwarden/common/billing/models/response/p
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { LogService } from "@bitwarden/logging";
@@ -40,17 +41,20 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
*/
private static readonly FALLBACK_PREMIUM_SEAT_PRICE = 10;
private static readonly FALLBACK_PREMIUM_STORAGE_PRICE = 4;
private static readonly FALLBACK_PREMIUM_PROVIDED_STORAGE_GB = 1;
constructor(
private billingApiService: BillingApiServiceAbstraction,
private configService: ConfigService,
private i18nService: I18nService,
private logService: LogService,
private environmentService: EnvironmentService,
) {}
/**
* Gets personal subscription pricing tiers (Premium and Families).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of personal subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -65,6 +69,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets business subscription pricing tiers (Teams, Enterprise, and Custom).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers.
* @throws Error if any errors occur during api request.
*/
@@ -79,6 +84,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
/**
* Gets developer subscription pricing tiers (Free, Teams, and Enterprise).
* Throws any errors that occur during api request so callers must handle errors.
* Pricing information will be undefined if current environment is self-hosted.
* @returns An observable of an array of business subscription pricing tiers for developers.
* @throws Error if any errors occur during api request.
*/
@@ -90,19 +96,32 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
}),
);
private plansResponse$: Observable<ListResponse<PlanResponse>> = from(
this.billingApiService.getPlans(),
).pipe(shareReplay({ bufferSize: 1, refCount: false }));
private organizationPlansResponse$: Observable<ListResponse<PlanResponse>> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ data: [] } as unknown as ListResponse<PlanResponse>)
: from(this.billingApiService.getPlans()),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> = from(
this.billingApiService.getPremiumPlan(),
).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premiumPlanResponse$: Observable<PremiumPlanResponse> =
this.environmentService.environment$.pipe(
take(1),
switchMap((environment) =>
!environment.isCloud()
? of({ seat: undefined, storage: undefined } as unknown as PremiumPlanResponse)
: from(this.billingApiService.getPremiumPlan()).pipe(
catchError((error: unknown) => {
this.logService.error("Failed to fetch premium plan from API", error);
return throwError(() => error); // Re-throw to propagate to higher-level error handler
}),
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
private premium$: Observable<PersonalSubscriptionPricingTier> = this.configService
.getFeatureFlag$(FeatureFlag.PM26793_FetchPremiumPriceFromPricingService)
@@ -112,24 +131,27 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
fetchPremiumFromPricingService
? this.premiumPlanResponse$.pipe(
map((premiumPlan) => ({
seat: premiumPlan.seat.price,
storage: premiumPlan.storage.price,
seat: premiumPlan.seat?.price,
storage: premiumPlan.storage?.price,
provided: premiumPlan.storage?.provided,
})),
)
: of({
seat: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_SEAT_PRICE,
storage: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_STORAGE_PRICE,
provided: DefaultSubscriptionPricingService.FALLBACK_PREMIUM_PROVIDED_STORAGE_GB,
}),
),
map((premiumPrices) => ({
id: PersonalSubscriptionPricingTierIds.Premium,
name: this.i18nService.t("premium"),
description: this.i18nService.t("planDescPremium"),
description: this.i18nService.t("advancedOnlineSecurity"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "standalone",
annualPrice: premiumPrices.seat,
annualPricePerAdditionalStorageGB: premiumPrices.storage,
providedStorageGB: premiumPrices.provided,
features: [
this.featureTranslations.builtInAuthenticator(),
this.featureTranslations.secureFileStorage(),
@@ -141,40 +163,42 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
})),
);
private families$: Observable<PersonalSubscriptionPricingTier> = this.plansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
)!;
private families$: Observable<PersonalSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.PM26462_Milestone_3)),
map(([plans, milestone3FeatureEnabled]) => {
const familiesPlan = plans.data.find(
(plan) =>
plan.type ===
(milestone3FeatureEnabled ? PlanType.FamiliesAnnually : PlanType.FamiliesAnnually2025),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan.PasswordManager.baseSeats,
annualPrice: familiesPlan.PasswordManager.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
return {
id: PersonalSubscriptionPricingTierIds.Families,
name: this.i18nService.t("planNameFamilies"),
description: this.i18nService.t("planDescFamiliesV2"),
availableCadences: [SubscriptionCadenceIds.Annually],
passwordManager: {
type: "packaged",
users: familiesPlan?.PasswordManager?.baseSeats,
annualPrice: familiesPlan?.PasswordManager?.basePrice,
annualPricePerAdditionalStorageGB:
familiesPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: familiesPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.premiumAccounts(),
this.featureTranslations.familiesUnlimitedSharing(),
this.featureTranslations.familiesUnlimitedCollections(),
this.featureTranslations.familiesSharedStorage(),
],
},
};
}),
);
private free$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
private free$: Observable<BusinessSubscriptionPricingTier> = this.organizationPlansResponse$.pipe(
map((plans): BusinessSubscriptionPricingTier => {
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free)!;
const freePlan = plans.data.find((plan) => plan.type === PlanType.Free);
return {
id: BusinessSubscriptionPricingTierIds.Free,
@@ -184,8 +208,10 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
passwordManager: {
type: "free",
features: [
this.featureTranslations.limitedUsersV2(freePlan.PasswordManager.maxSeats),
this.featureTranslations.limitedCollectionsV2(freePlan.PasswordManager.maxCollections),
this.featureTranslations.limitedUsersV2(freePlan?.PasswordManager?.maxSeats),
this.featureTranslations.limitedCollectionsV2(
freePlan?.PasswordManager?.maxCollections,
),
this.featureTranslations.alwaysFree(),
],
},
@@ -193,108 +219,113 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
type: "free",
features: [
this.featureTranslations.twoSecretsIncluded(),
this.featureTranslations.projectsIncludedV2(freePlan.SecretsManager.maxProjects),
this.featureTranslations.projectsIncludedV2(freePlan?.SecretsManager?.maxProjects),
],
},
};
}),
);
private teams$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually)!;
private teams$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualTeamsPlan = plans.data.find((plan) => plan.type === PlanType.TeamsAnnually);
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
)!;
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.PasswordManager.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan.PasswordManager.additionalStoragePricePerGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan.SecretsManager.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan.SecretsManager.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan.SecretsManager.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> = this.plansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
return {
id: BusinessSubscriptionPricingTierIds.Teams,
name: this.i18nService.t("planNameTeams"),
description: this.i18nService.t("teamsPlanUpgradeMessage"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualTeamsPlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualTeamsPlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.secureItemSharing(),
this.featureTranslations.eventLogMonitoring(),
this.featureTranslations.directoryIntegration(),
this.featureTranslations.scimSupport(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualTeamsPlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualTeamsPlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedSecretsAndProjects(),
this.featureTranslations.includedMachineAccountsV2(
annualTeamsPlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
),
);
);
private enterprise$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map((plans) => {
const annualEnterprisePlan = plans.data.find(
(plan) => plan.type === PlanType.EnterpriseAnnually,
);
return {
id: BusinessSubscriptionPricingTierIds.Enterprise,
name: this.i18nService.t("planNameEnterprise"),
description: this.i18nService.t("planDescEnterpriseV2"),
availableCadences: [SubscriptionCadenceIds.Annually, SubscriptionCadenceIds.Monthly],
passwordManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.PasswordManager?.seatPrice,
annualPricePerAdditionalStorageGB:
annualEnterprisePlan?.PasswordManager?.additionalStoragePricePerGb,
providedStorageGB: annualEnterprisePlan?.PasswordManager?.baseStorageGb,
features: [
this.featureTranslations.enterpriseSecurityPolicies(),
this.featureTranslations.passwordLessSso(),
this.featureTranslations.accountRecovery(),
this.featureTranslations.selfHostOption(),
this.featureTranslations.complimentaryFamiliesPlan(),
],
},
secretsManager: {
type: "scalable",
annualPricePerUser: annualEnterprisePlan?.SecretsManager?.seatPrice,
annualPricePerAdditionalServiceAccount:
annualEnterprisePlan?.SecretsManager?.additionalPricePerServiceAccount,
features: [
this.featureTranslations.unlimitedUsers(),
this.featureTranslations.includedMachineAccountsV2(
annualEnterprisePlan?.SecretsManager?.baseServiceAccount,
),
],
},
};
}),
);
private custom$: Observable<BusinessSubscriptionPricingTier> =
this.organizationPlansResponse$.pipe(
map(
(): BusinessSubscriptionPricingTier => ({
id: BusinessSubscriptionPricingTierIds.Custom,
name: this.i18nService.t("planNameCustom"),
description: this.i18nService.t("planDescCustom"),
availableCadences: [],
passwordManager: {
type: "custom",
features: [
this.featureTranslations.strengthenCybersecurity(),
this.featureTranslations.boostProductivity(),
this.featureTranslations.seamlessIntegration(),
],
},
}),
),
);
private featureTranslations = {
builtInAuthenticator: () => ({
@@ -333,11 +364,11 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "familiesSharedStorage",
value: this.i18nService.t("familiesSharedStorage"),
}),
limitedUsersV2: (users: number) => ({
limitedUsersV2: (users?: number) => ({
key: "limitedUsersV2",
value: this.i18nService.t("limitedUsersV2", users),
}),
limitedCollectionsV2: (collections: number) => ({
limitedCollectionsV2: (collections?: number) => ({
key: "limitedCollectionsV2",
value: this.i18nService.t("limitedCollectionsV2", collections),
}),
@@ -349,7 +380,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "twoSecretsIncluded",
value: this.i18nService.t("twoSecretsIncluded"),
}),
projectsIncludedV2: (projects: number) => ({
projectsIncludedV2: (projects?: number) => ({
key: "projectsIncludedV2",
value: this.i18nService.t("projectsIncludedV2", projects),
}),
@@ -373,7 +404,7 @@ export class DefaultSubscriptionPricingService implements SubscriptionPricingSer
key: "unlimitedSecretsAndProjects",
value: this.i18nService.t("unlimitedSecretsAndProjects"),
}),
includedMachineAccountsV2: (included: number) => ({
includedMachineAccountsV2: (included?: number) => ({
key: "includedMachineAccountsV2",
value: this.i18nService.t("includedMachineAccountsV2", included),
}),

View File

@@ -27,20 +27,26 @@ type HasFeatures = {
};
type HasAdditionalStorage = {
annualPricePerAdditionalStorageGB: number;
annualPricePerAdditionalStorageGB?: number;
};
type HasProvidedStorage = {
providedStorageGB?: number;
};
type StandalonePasswordManager = HasFeatures &
HasAdditionalStorage & {
HasAdditionalStorage &
HasProvidedStorage & {
type: "standalone";
annualPrice: number;
annualPrice?: number;
};
type PackagedPasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "packaged";
users: number;
annualPrice: number;
users?: number;
annualPrice?: number;
};
type FreePasswordManager = HasFeatures & {
@@ -52,9 +58,10 @@ type CustomPasswordManager = HasFeatures & {
};
type ScalablePasswordManager = HasFeatures &
HasProvidedStorage &
HasAdditionalStorage & {
type: "scalable";
annualPricePerUser: number;
annualPricePerUser?: number;
};
type FreeSecretsManager = HasFeatures & {
@@ -63,8 +70,8 @@ type FreeSecretsManager = HasFeatures & {
type ScalableSecretsManager = HasFeatures & {
type: "scalable";
annualPricePerUser: number;
annualPricePerAdditionalServiceAccount: number;
annualPricePerUser?: number;
annualPricePerAdditionalServiceAccount?: number;
};
export type PersonalSubscriptionPricingTier = {

View File

@@ -0,0 +1,37 @@
import { Observable } from "rxjs";
import { UserId } from "@bitwarden/user-core";
/**
* Abstraction for phishing detection settings
*/
export abstract class PhishingDetectionSettingsServiceAbstraction {
/**
* An observable for whether phishing detection is available for the active user account.
*
* Access is granted only when the PhishingDetection feature flag is enabled and
* at least one of the following is true for the active account:
* - the user has a personal premium subscription
* - the user is a member of a Family org (ProductTierType.Families)
* - the user is a member of an Enterprise org with `usePhishingBlocker` enabled
*
* Note: Non-specified organization types (e.g., Team orgs) do not grant access.
*/
abstract readonly available$: Observable<boolean>;
/**
* An observable for whether phishing detection is on for the active user account
*
* This is true when {@link available$} is true and when {@link enabled$} is true
*/
abstract readonly on$: Observable<boolean>;
/**
* An observable for whether phishing detection is enabled
*/
abstract readonly enabled$: Observable<boolean>;
/**
* Sets whether phishing detection is enabled
*
* @param enabled True to enable, false to disable
*/
abstract setEnabled: (userId: UserId, enabled: boolean) => Promise<void>;
}

View File

@@ -35,5 +35,26 @@ describe("HibpApiService", () => {
expect(result).toHaveLength(1);
expect(result[0]).toBeInstanceOf(BreachAccountResponse);
});
it("should return empty array when no breaches found (REST semantics)", async () => {
// Server now returns 200 OK with empty array [] instead of 404
const mockResponse: any[] = [];
const username = "safe@example.com";
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getHibpBreach(username);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/hibp/breach?username=" + encodeURIComponent(username),
null,
true,
true,
);
expect(result).toEqual([]);
expect(result).toBeInstanceOf(Array);
expect(result).toHaveLength(0);
});
});
});

View File

@@ -0,0 +1,212 @@
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject, firstValueFrom, Subject } from "rxjs";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { Account, AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LogService } from "@bitwarden/logging";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { UserId } from "../../../types/guid";
import { PhishingDetectionSettingsService } from "./phishing-detection-settings.service";
describe("PhishingDetectionSettingsService", () => {
// Mock services
let mockAccountService: MockProxy<AccountService>;
let mockBillingService: MockProxy<BillingAccountProfileStateService>;
let mockConfigService: MockProxy<ConfigService>;
let mockOrganizationService: MockProxy<OrganizationService>;
let mockPlatformService: MockProxy<PlatformUtilsService>;
// RxJS Subjects we control in the tests
let activeAccountSubject: BehaviorSubject<Account | null>;
let featureFlagSubject: BehaviorSubject<boolean>;
let premiumStatusSubject: BehaviorSubject<boolean>;
let organizationsSubject: BehaviorSubject<Organization[]>;
let service: PhishingDetectionSettingsService;
let stateProvider: FakeStateProvider;
// Constant mock data
const familyOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Families,
usePhishingBlocker: true,
});
const teamOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Teams,
usePhishingBlocker: true,
});
const enterpriseOrg = mock<Organization>({
canAccess: true,
isMember: true,
usersGetPremium: true,
productTierType: ProductTierType.Enterprise,
usePhishingBlocker: true,
});
const mockLogService = mock<LogService>();
const mockUserId = "mock-user-id" as UserId;
const account = mock<Account>({ id: mockUserId });
const accountService: FakeAccountService = mockAccountServiceWith(mockUserId);
beforeEach(() => {
// Initialize subjects
activeAccountSubject = new BehaviorSubject<Account | null>(null);
featureFlagSubject = new BehaviorSubject<boolean>(false);
premiumStatusSubject = new BehaviorSubject<boolean>(false);
organizationsSubject = new BehaviorSubject<Organization[]>([]);
// Default implementations for required functions
mockAccountService = mock<AccountService>();
mockAccountService.activeAccount$ = activeAccountSubject.asObservable();
mockBillingService = mock<BillingAccountProfileStateService>();
mockBillingService.hasPremiumPersonally$.mockReturnValue(premiumStatusSubject.asObservable());
mockConfigService = mock<ConfigService>();
mockConfigService.getFeatureFlag$.mockReturnValue(featureFlagSubject.asObservable());
mockOrganizationService = mock<OrganizationService>();
mockOrganizationService.organizations$.mockReturnValue(organizationsSubject.asObservable());
mockPlatformService = mock<PlatformUtilsService>();
stateProvider = new FakeStateProvider(accountService);
service = new PhishingDetectionSettingsService(
mockAccountService,
mockBillingService,
mockConfigService,
mockLogService,
mockOrganizationService,
mockPlatformService,
stateProvider,
);
});
// Helper to easily get the result of the observable we are testing
const getAccess = () => firstValueFrom(service.available$);
describe("enabled$", () => {
it("should default to true if an account is logged in", async () => {
activeAccountSubject.next(account);
const result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
it("should return the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
const resultDisabled = await firstValueFrom(service.enabled$);
expect(resultDisabled).toBe(false);
await service.setEnabled(mockUserId, true);
const resultEnabled = await firstValueFrom(service.enabled$);
expect(resultEnabled).toBe(true);
});
});
describe("setEnabled", () => {
it("should update the stored value", async () => {
activeAccountSubject.next(account);
await service.setEnabled(mockUserId, false);
let result = await firstValueFrom(service.enabled$);
expect(result).toBe(false);
await service.setEnabled(mockUserId, true);
result = await firstValueFrom(service.enabled$);
expect(result).toBe(true);
});
});
it("returns false immediately when the feature flag is disabled, regardless of other conditions", async () => {
activeAccountSubject.next(account);
premiumStatusSubject.next(true);
organizationsSubject.next([familyOrg]);
featureFlagSubject.next(false);
await expect(getAccess()).resolves.toBe(false);
});
it("returns false if there is no active account present yet", async () => {
activeAccountSubject.next(null); // No active account
featureFlagSubject.next(true); // Flag is on
await expect(getAccess()).resolves.toBe(false);
});
it("returns true when feature flag is enabled and user has premium personally", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
premiumStatusSubject.next(true);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in a Family Organization", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false); // User has no personal premium
organizationsSubject.next([familyOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns true when feature flag is enabled and user is in an Enterprise org with phishing blocker enabled", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([enterpriseOrg]);
await expect(getAccess()).resolves.toBe(true);
});
it("returns false when user has no access through personal premium or organizations", async () => {
activeAccountSubject.next(account);
featureFlagSubject.next(true);
premiumStatusSubject.next(false);
organizationsSubject.next([teamOrg]); // Team org does not give access
await expect(getAccess()).resolves.toBe(false);
});
it("shares/caches the available$ result between multiple subscribers", async () => {
// Use a plain Subject for this test so we control when the premium observable emits
// and avoid the BehaviorSubject's initial emission which can race with subscriptions.
// Provide the Subject directly as the mock return value for the billing service
const oneTimePremium = new Subject<boolean>();
mockBillingService.hasPremiumPersonally$.mockReturnValueOnce(oneTimePremium.asObservable());
activeAccountSubject.next(account);
featureFlagSubject.next(true);
organizationsSubject.next([]);
const p1 = firstValueFrom(service.available$);
const p2 = firstValueFrom(service.available$);
// Trigger the pipeline
oneTimePremium.next(true);
const [first, second] = await Promise.all([p1, p2]);
expect(first).toBe(true);
expect(second).toBe(true);
// The billing function should have been called at most once due to caching
expect(mockBillingService.hasPremiumPersonally$).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,150 @@
import { combineLatest, Observable, of, switchMap } from "rxjs";
import { catchError, distinctUntilChanged, map, shareReplay, tap } from "rxjs/operators";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions";
import { ProductTierType } from "@bitwarden/common/billing/enums";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { LogService } from "@bitwarden/logging";
import { UserId } from "@bitwarden/user-core";
import { PHISHING_DETECTION_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { PhishingDetectionSettingsServiceAbstraction } from "../abstractions/phishing-detection-settings.service.abstraction";
const ENABLE_PHISHING_DETECTION = new UserKeyDefinition(
PHISHING_DETECTION_DISK,
"enablePhishingDetection",
{
deserializer: (value: boolean) => value ?? true, // Default: enabled
clearOn: [],
},
);
export class PhishingDetectionSettingsService implements PhishingDetectionSettingsServiceAbstraction {
readonly available$: Observable<boolean>;
readonly enabled$: Observable<boolean>;
readonly on$: Observable<boolean>;
constructor(
private accountService: AccountService,
private billingService: BillingAccountProfileStateService,
private configService: ConfigService,
private logService: LogService,
private organizationService: OrganizationService,
private platformService: PlatformUtilsService,
private stateProvider: StateProvider,
) {
this.logService.debug(`[PhishingDetectionSettingsService] Initializing service...`);
this.available$ = this.buildAvailablePipeline$().pipe(
distinctUntilChanged(),
tap((available) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection available: ${available}`,
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.enabled$ = this.buildEnabledPipeline$().pipe(
distinctUntilChanged(),
tap((enabled) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection enabled: ${{ enabled }}`,
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
this.on$ = combineLatest([this.available$, this.enabled$]).pipe(
map(([available, enabled]) => available && enabled),
distinctUntilChanged(),
tap((on) =>
this.logService.debug(
`[PhishingDetectionSettingsService] Phishing detection is on: ${{ on }}`,
),
),
shareReplay({ bufferSize: 1, refCount: false }),
);
}
async setEnabled(userId: UserId, enabled: boolean): Promise<void> {
this.logService.debug(
`[PhishingDetectionSettingsService] Setting phishing detection enabled: ${{ enabled, userId }}`,
);
await this.stateProvider.getUser(userId, ENABLE_PHISHING_DETECTION).update(() => enabled);
}
/**
* Builds the observable pipeline to determine if phishing detection is available to the user
*
* @returns An observable pipeline that determines if phishing detection is available
*/
private buildAvailablePipeline$(): Observable<boolean> {
// Phishing detection is unavailable on Safari due to platform limitations.
if (this.platformService.isSafari()) {
this.logService.warning(
`[PhishingDetectionSettingsService] Phishing detection is unavailable on Safari due to platform limitations`,
);
return of(false);
}
return combineLatest([
this.accountService.activeAccount$,
this.configService.getFeatureFlag$(FeatureFlag.PhishingDetection),
]).pipe(
switchMap(([account, featureEnabled]) => {
if (!account || !featureEnabled) {
return of(false);
}
return combineLatest([
this.billingService.hasPremiumPersonally$(account.id).pipe(catchError(() => of(false))),
this.organizationService.organizations$(account.id).pipe(catchError(() => of([]))),
]).pipe(
map(([hasPremium, organizations]) => hasPremium || this.orgGrantsAccess(organizations)),
catchError(() => of(false)),
);
}),
);
}
/**
* Builds the observable pipeline to determine if phishing detection is enabled by the user
*
* @returns True if phishing detection is enabled for the active user
*/
private buildEnabledPipeline$(): Observable<boolean> {
return this.accountService.activeAccount$.pipe(
switchMap((account) => {
if (!account) {
return of(false);
}
this.logService.debug(
`[PhishingDetectionSettingsService] Refreshing phishing detection enabled state`,
);
return this.stateProvider.getUserState$(ENABLE_PHISHING_DETECTION, account.id);
}),
map((enabled) => enabled ?? true),
);
}
/**
* Determines if any of the user's organizations grant access to phishing detection
*
* @param organizations The organizations the user is a member of
* @returns True if any organization grants access to phishing detection
*/
private orgGrantsAccess(organizations: Organization[]): boolean {
return organizations.some((org) => {
if (!org.canAccess || !org.isMember || !org.usersGetPremium) {
return false;
}
return (
org.productTierType === ProductTierType.Families ||
(org.productTierType === ProductTierType.Enterprise && org.usePhishingBlocker)
);
});
}
}

View File

@@ -79,6 +79,8 @@ export enum EventType {
Organization_CollectionManagement_LimitItemDeletionDisabled = 1615,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsEnabled = 1616,
Organization_CollectionManagement_AllowAdminAccessToAllCollectionItemsDisabled = 1617,
Organization_ItemOrganization_Accepted = 1618,
Organization_ItemOrganization_Declined = 1619,
Policy_Updated = 1700,

View File

@@ -11,9 +11,10 @@ import { ServerConfig } from "../platform/abstractions/config/server-config";
// eslint-disable-next-line @bitwarden/platform/no-enums
export enum FeatureFlag {
/* Admin Console Team */
CreateDefaultLocation = "pm-19467-create-default-location",
AutoConfirm = "pm-19934-auto-confirm-organization-users",
BlockClaimedDomainAccountCreation = "pm-28297-block-uninvited-claimed-domain-registration",
IncreaseBulkReinviteLimitForCloud = "pm-28251-increase-bulk-reinvite-limit-for-cloud",
MembersComponentRefactor = "pm-29503-refactor-members-inheritance",
/* Auth */
PM23801_PrefetchPasswordPrelogin = "pm-23801-prefetch-password-prelogin",
@@ -21,59 +22,68 @@ export enum FeatureFlag {
/* Autofill */
MacOsNativeCredentialSync = "macos-native-credential-sync",
WindowsDesktopAutotype = "windows-desktop-autotype",
WindowsDesktopAutotypeGA = "windows-desktop-autotype-ga",
SSHAgentV2 = "ssh-agent-v2",
/* Billing */
TrialPaymentOptional = "PM-8163-trial-payment",
PM21821_ProviderPortalTakeover = "pm-21821-provider-portal-takeover",
PM22415_TaxIDWarnings = "pm-22415-tax-id-warnings",
PM24032_NewNavigationPremiumUpgradeButton = "pm-24032-new-navigation-premium-upgrade-button",
PM25379_UseNewOrganizationMetadataStructure = "pm-25379-use-new-organization-metadata-structure",
PM24996_ImplementUpgradeFromFreeDialog = "pm-24996-implement-upgrade-from-free-dialog",
PM24033PremiumUpgradeNewDesign = "pm-24033-updat-premium-subscription-page",
PM26793_FetchPremiumPriceFromPricingService = "pm-26793-fetch-premium-price-from-pricing-service",
PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog = "pm-23713-premium-badge-opens-new-premium-upgrade-dialog",
PM26462_Milestone_3 = "pm-26462-milestone-3",
PM23341_Milestone_2 = "pm-23341-milestone-2",
PM29594_UpdateIndividualSubscriptionPage = "pm-29594-update-individual-subscription-page",
PM29593_PremiumToOrganizationUpgrade = "pm-29593-premium-to-organization-upgrade",
/* Key Management */
PrivateKeyRegeneration = "pm-12241-private-key-regeneration",
EnrollAeadOnKeyRotation = "enroll-aead-on-key-rotation",
ForceUpdateKDFSettings = "pm-18021-force-update-kdf-settings",
PM25174_DisableType0Decryption = "pm-25174-disable-type-0-decryption",
LinuxBiometricsV2 = "pm-26340-linux-biometrics-v2",
UnlockWithMasterPasswordUnlockData = "pm-23246-unlock-with-master-password-unlock-data",
NoLogoutOnKdfChange = "pm-23995-no-logout-on-kdf-change",
PasskeyUnlock = "pm-2035-passkey-unlock",
DataRecoveryTool = "pm-28813-data-recovery-tool",
ConsolidatedSessionTimeoutComponent = "pm-26056-consolidated-session-timeout-component",
PM27279_V2RegistrationTdeJit = "pm-27279-v2-registration-tde-jit",
EnableAccountEncryptionV2KeyConnectorRegistration = "enable-account-encryption-v2-key-connector-registration",
EnableAccountEncryptionV2JitPasswordRegistration = "enable-account-encryption-v2-jit-password-registration",
/* Tools */
DesktopSendUIRefresh = "desktop-send-ui-refresh",
UseSdkPasswordGenerators = "pm-19976-use-sdk-password-generators",
ChromiumImporterWithABE = "pm-25855-chromium-importer-abe",
SendUIRefresh = "pm-28175-send-ui-refresh",
SendEmailOTP = "pm-19051-send-email-verification",
/* DIRT */
EventManagementForDataDogAndCrowdStrike = "event-management-for-datadog-and-crowdstrike",
EventManagementForHuntress = "event-management-for-huntress",
PhishingDetection = "phishing-detection",
PM22887_RiskInsightsActivityTab = "pm-22887-risk-insights-activity-tab",
/* Vault */
PM19941MigrateCipherDomainToSdk = "pm-19941-migrate-cipher-domain-to-sdk",
PM22134SdkCipherListView = "pm-22134-sdk-cipher-list-view",
PM22136_SdkCipherEncryption = "pm-22136-sdk-cipher-encryption",
CipherKeyEncryption = "cipher-key-encryption",
AutofillConfirmation = "pm-25083-autofill-confirm-from-search",
RiskInsightsForPremium = "pm-23904-risk-insights-for-premium",
VaultLoadingSkeletons = "pm-25081-vault-skeleton-loaders",
BrowserPremiumSpotlight = "pm-23384-browser-premium-spotlight",
MigrateMyVaultToMyItems = "pm-20558-migrate-myvault-to-myitems",
PM27632_SdkCipherCrudOperations = "pm-27632-cipher-crud-operations-to-sdk",
/* Platform */
IpcChannelFramework = "ipc-channel-framework",
InactiveUserServerNotification = "pm-25130-receive-push-notifications-for-inactive-users",
PushNotificationsWhenLocked = "pm-19388-push-notifications-when-locked",
/* Innovation */
PM19148_InnovationArchive = "pm-19148-innovation-archive",
/* Desktop */
DesktopUiMigrationMilestone1 = "desktop-ui-migration-milestone-1",
DesktopUiMigrationMilestone2 = "desktop-ui-migration-milestone-2",
/* UIF */
RouterFocusManagement = "router-focus-management",
/* Secrets Manager */
SM1719_RemoveSecretsManagerAds = "sm-1719-remove-secrets-manager-ads",
}
export type AllowedFeatureFlagTypes = boolean | number | string;
@@ -91,69 +101,79 @@ const FALSE = false as boolean;
*/
export const DefaultFeatureFlagValue = {
/* Admin Console Team */
[FeatureFlag.CreateDefaultLocation]: FALSE,
[FeatureFlag.AutoConfirm]: FALSE,
[FeatureFlag.BlockClaimedDomainAccountCreation]: FALSE,
[FeatureFlag.IncreaseBulkReinviteLimitForCloud]: FALSE,
[FeatureFlag.MembersComponentRefactor]: FALSE,
/* Autofill */
[FeatureFlag.MacOsNativeCredentialSync]: FALSE,
[FeatureFlag.WindowsDesktopAutotype]: FALSE,
[FeatureFlag.WindowsDesktopAutotypeGA]: FALSE,
[FeatureFlag.SSHAgentV2]: FALSE,
/* Tools */
[FeatureFlag.DesktopSendUIRefresh]: FALSE,
[FeatureFlag.UseSdkPasswordGenerators]: FALSE,
[FeatureFlag.ChromiumImporterWithABE]: FALSE,
[FeatureFlag.SendUIRefresh]: FALSE,
[FeatureFlag.SendEmailOTP]: FALSE,
/* DIRT */
[FeatureFlag.EventManagementForDataDogAndCrowdStrike]: FALSE,
[FeatureFlag.EventManagementForHuntress]: FALSE,
[FeatureFlag.PhishingDetection]: FALSE,
[FeatureFlag.PM22887_RiskInsightsActivityTab]: FALSE,
/* Vault */
[FeatureFlag.CipherKeyEncryption]: FALSE,
[FeatureFlag.PM19941MigrateCipherDomainToSdk]: FALSE,
[FeatureFlag.PM22134SdkCipherListView]: FALSE,
[FeatureFlag.PM22136_SdkCipherEncryption]: FALSE,
[FeatureFlag.AutofillConfirmation]: FALSE,
[FeatureFlag.RiskInsightsForPremium]: FALSE,
[FeatureFlag.VaultLoadingSkeletons]: FALSE,
[FeatureFlag.BrowserPremiumSpotlight]: FALSE,
[FeatureFlag.PM27632_SdkCipherCrudOperations]: FALSE,
[FeatureFlag.MigrateMyVaultToMyItems]: FALSE,
/* Auth */
[FeatureFlag.PM23801_PrefetchPasswordPrelogin]: FALSE,
/* Billing */
[FeatureFlag.TrialPaymentOptional]: FALSE,
[FeatureFlag.PM21821_ProviderPortalTakeover]: FALSE,
[FeatureFlag.PM22415_TaxIDWarnings]: FALSE,
[FeatureFlag.PM24032_NewNavigationPremiumUpgradeButton]: FALSE,
[FeatureFlag.PM25379_UseNewOrganizationMetadataStructure]: FALSE,
[FeatureFlag.PM24996_ImplementUpgradeFromFreeDialog]: FALSE,
[FeatureFlag.PM24033PremiumUpgradeNewDesign]: FALSE,
[FeatureFlag.PM26793_FetchPremiumPriceFromPricingService]: FALSE,
[FeatureFlag.PM23713_PremiumBadgeOpensNewPremiumUpgradeDialog]: FALSE,
[FeatureFlag.PM26462_Milestone_3]: FALSE,
[FeatureFlag.PM23341_Milestone_2]: FALSE,
[FeatureFlag.PM29594_UpdateIndividualSubscriptionPage]: FALSE,
[FeatureFlag.PM29593_PremiumToOrganizationUpgrade]: FALSE,
/* Key Management */
[FeatureFlag.PrivateKeyRegeneration]: FALSE,
[FeatureFlag.EnrollAeadOnKeyRotation]: FALSE,
[FeatureFlag.ForceUpdateKDFSettings]: FALSE,
[FeatureFlag.PM25174_DisableType0Decryption]: FALSE,
[FeatureFlag.LinuxBiometricsV2]: FALSE,
[FeatureFlag.UnlockWithMasterPasswordUnlockData]: FALSE,
[FeatureFlag.NoLogoutOnKdfChange]: FALSE,
[FeatureFlag.PasskeyUnlock]: FALSE,
[FeatureFlag.DataRecoveryTool]: FALSE,
[FeatureFlag.ConsolidatedSessionTimeoutComponent]: FALSE,
[FeatureFlag.PM27279_V2RegistrationTdeJit]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration]: FALSE,
[FeatureFlag.EnableAccountEncryptionV2JitPasswordRegistration]: FALSE,
/* Platform */
[FeatureFlag.IpcChannelFramework]: FALSE,
[FeatureFlag.InactiveUserServerNotification]: FALSE,
[FeatureFlag.PushNotificationsWhenLocked]: FALSE,
/* Innovation */
[FeatureFlag.PM19148_InnovationArchive]: FALSE,
/* Desktop */
[FeatureFlag.DesktopUiMigrationMilestone1]: FALSE,
[FeatureFlag.DesktopUiMigrationMilestone2]: FALSE,
/* UIF */
[FeatureFlag.RouterFocusManagement]: FALSE,
/* Secrets Manager */
[FeatureFlag.SM1719_RemoveSecretsManagerAds]: FALSE,
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;

View File

@@ -33,4 +33,6 @@ export enum NotificationType {
OrganizationBankAccountVerified = 23,
ProviderBankAccountVerified = 24,
SyncPolicy = 25,
}

View File

@@ -0,0 +1,22 @@
import { Observable } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { UserId } from "@bitwarden/user-core";
export abstract class AccountCryptographicStateService {
/**
* Emits the provided user's account cryptographic state or null if there is no account cryptographic state present for the user.
*/
abstract accountCryptographicState$(
userId: UserId,
): Observable<WrappedAccountCryptographicState | null>;
/**
* Sets the account cryptographic state.
* This is not yet validated, and is only validated upon SDK initialization.
*/
abstract setAccountCryptographicState(
accountCryptographicState: WrappedAccountCryptographicState,
userId: UserId,
): Promise<void>;
}

View File

@@ -0,0 +1,133 @@
import { firstValueFrom } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { FakeStateProvider } from "@bitwarden/state-test-utils";
import { UserId } from "@bitwarden/user-core";
import { FakeAccountService, mockAccountServiceWith } from "../../../spec";
import {
ACCOUNT_CRYPTOGRAPHIC_STATE,
DefaultAccountCryptographicStateService,
} from "./default-account-cryptographic-state.service";
describe("DefaultAccountCryptographicStateService", () => {
let service: DefaultAccountCryptographicStateService;
let stateProvider: FakeStateProvider;
let accountService: FakeAccountService;
const mockUserId = "user-id" as UserId;
beforeEach(() => {
accountService = mockAccountServiceWith(mockUserId);
stateProvider = new FakeStateProvider(accountService);
service = new DefaultAccountCryptographicStateService(stateProvider);
});
describe("accountCryptographicState$", () => {
it("returns null when no state is set", async () => {
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toBeNull();
});
it("returns the account cryptographic state when set (V1)", async () => {
const mockState: WrappedAccountCryptographicState = {
V1: {
private_key: "test-wrapped-state" as any,
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("returns the account cryptographic state when set (V2)", async () => {
const mockState: WrappedAccountCryptographicState = {
V2: {
private_key: "test-wrapped-private-key" as any,
signing_key: "test-wrapped-signing-key" as any,
signed_public_key: "test-signed-public-key" as any,
security_state: "test-security-state",
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("emits updated state when state changes", async () => {
const mockState1: any = {
V1: {
private_key: "test-state-1" as any,
},
};
const mockState2: any = {
V1: {
private_key: "test-state-2" as any,
},
};
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState1, mockUserId);
const observable = service.accountCryptographicState$(mockUserId);
const results: (WrappedAccountCryptographicState | null)[] = [];
const subscription = observable.subscribe((state) => results.push(state));
await stateProvider.setUserState(ACCOUNT_CRYPTOGRAPHIC_STATE, mockState2, mockUserId);
subscription.unsubscribe();
expect(results).toHaveLength(2);
expect(results[0]).toEqual(mockState1);
expect(results[1]).toEqual(mockState2);
});
});
describe("setAccountCryptographicState", () => {
it("sets the account cryptographic state", async () => {
const mockState: WrappedAccountCryptographicState = {
V1: {
private_key: "test-wrapped-state" as any,
},
};
await service.setAccountCryptographicState(mockState, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState);
});
it("overwrites existing state", async () => {
const mockState1: WrappedAccountCryptographicState = {
V1: {
private_key: "test-state-1" as any,
},
};
const mockState2: WrappedAccountCryptographicState = {
V1: {
private_key: "test-state-2" as any,
},
};
await service.setAccountCryptographicState(mockState1, mockUserId);
await service.setAccountCryptographicState(mockState2, mockUserId);
const result = await firstValueFrom(service.accountCryptographicState$(mockUserId));
expect(result).toEqual(mockState2);
});
});
describe("ACCOUNT_CRYPTOGRAPHIC_STATE key definition", () => {
it("deserializer returns object as-is", () => {
const mockState: any = {
V1: {
private_key: "test" as any,
},
};
const result = ACCOUNT_CRYPTOGRAPHIC_STATE.deserializer(mockState);
expect(result).toBe(mockState);
});
});
});

View File

@@ -0,0 +1,35 @@
import { Observable } from "rxjs";
import { WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { CRYPTO_DISK, StateProvider, UserKeyDefinition } from "@bitwarden/state";
import { UserId } from "@bitwarden/user-core";
import { AccountCryptographicStateService } from "./account-cryptographic-state.service";
export const ACCOUNT_CRYPTOGRAPHIC_STATE = new UserKeyDefinition<WrappedAccountCryptographicState>(
CRYPTO_DISK,
"accountCryptographicState",
{
deserializer: (obj) => obj as WrappedAccountCryptographicState,
clearOn: ["logout"],
},
);
export class DefaultAccountCryptographicStateService implements AccountCryptographicStateService {
constructor(protected stateProvider: StateProvider) {}
accountCryptographicState$(userId: UserId): Observable<WrappedAccountCryptographicState | null> {
return this.stateProvider.getUserState$(ACCOUNT_CRYPTOGRAPHIC_STATE, userId);
}
async setAccountCryptographicState(
accountCryptographicState: WrappedAccountCryptographicState,
userId: UserId,
): Promise<void> {
await this.stateProvider.setUserState(
ACCOUNT_CRYPTOGRAPHIC_STATE,
accountCryptographicState,
userId,
);
}
}

View File

@@ -91,7 +91,7 @@ export abstract class CryptoFunctionService {
abstract rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
/**
* @deprecated HAZMAT WARNING: DO NOT USE THIS FOR NEW CODE. Implement low-level crypto operations
@@ -100,10 +100,10 @@ export abstract class CryptoFunctionService {
abstract rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
algorithm: "sha1",
): Promise<Uint8Array>;
abstract rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array>;
abstract rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]>;
abstract rsaGenerateKeyPair(length: 2048): Promise<[Uint8Array, Uint8Array]>;
/**
* Generates a key of the given length suitable for use in AES encryption
*/

View File

@@ -1,16 +1,8 @@
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { EncString } from "../models/enc-string";
export abstract class EncryptService {
/**
* A temporary init method to make the encrypt service listen to feature-flag changes.
* This will be removed once the feature flag has been rolled out.
*/
abstract init(configService: ConfigService): void;
/**
* Encrypts a string to an EncString
* @param plainValue - The value to encrypt

View File

@@ -30,7 +30,7 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
): Promise<{ salt: string; material: CsprngArray; derivedKey: SymmetricCryptoKey }> {
if (salt == null) {
const bytes = await this.cryptoFunctionService.randomBytes(32);
salt = Utils.fromBufferToUtf8(bytes);
salt = Utils.fromBufferToUtf8(bytes.buffer as ArrayBuffer);
}
const material = await this.cryptoFunctionService.aesGenerateKey(bitLength);
const key = await this.cryptoFunctionService.hkdf(material, salt, purpose, 64, "sha256");
@@ -91,4 +91,12 @@ export class DefaultKeyGenerationService implements KeyGenerationService {
return new SymmetricCryptoKey(newKey);
}
async deriveVaultExportKey(
password: string,
salt: string,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey> {
return await this.stretchKey(await this.deriveKeyFromPassword(password, salt, kdfConfig));
}
}

View File

@@ -87,4 +87,19 @@ export abstract class KeyGenerationService {
* @returns 64 byte derived key.
*/
abstract stretchKey(key: SymmetricCryptoKey): Promise<SymmetricCryptoKey>;
/**
* Derives a 64 byte key for encrypting and decrypting vault exports.
*
* @deprecated Do not use this for new use-cases.
* @param password Password to derive the key from.
* @param salt Salt for the key derivation function.
* @param kdfConfig Configuration for the key derivation function.
* @returns 64 byte derived key.
*/
abstract deriveVaultExportKey(
password: string,
salt: string,
kdfConfig: KdfConfig,
): Promise<SymmetricCryptoKey>;
}

View File

@@ -1,9 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { CryptoFunctionService } from "@bitwarden/common/key-management/crypto/abstractions/crypto-function.service";
import { EncString } from "@bitwarden/common/key-management/crypto/models/enc-string";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { LogService } from "@bitwarden/common/platform/abstractions/log.service";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { EncryptionType } from "@bitwarden/common/platform/enums";
@@ -15,28 +13,12 @@ import { PureCrypto } from "@bitwarden/sdk-internal";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
private disableType0Decryption = false;
constructor(
protected cryptoFunctionService: CryptoFunctionService,
protected logService: LogService,
protected logMacFailures: boolean,
) {}
init(configService: ConfigService): void {
configService.serverConfig$.subscribe((newConfig) => {
if (newConfig != null) {
this.setDisableType0Decryption(
newConfig.featureStates[FeatureFlag.PM25174_DisableType0Decryption] === true,
);
}
});
}
setDisableType0Decryption(disable: boolean): void {
this.disableType0Decryption = disable;
}
async encryptString(plainValue: string, key: SymmetricCryptoKey): Promise<EncString> {
if (plainValue == null) {
this.logService.warning(
@@ -60,7 +42,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptString(encString: EncString, key: SymmetricCryptoKey): Promise<string> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -68,7 +50,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptBytes(encString: EncString, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encString.encryptionType === EncryptionType.AesCbc256_B64) {
if (encString.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -76,7 +58,7 @@ export class EncryptServiceImplementation implements EncryptService {
}
async decryptFileData(encBuffer: EncArrayBuffer, key: SymmetricCryptoKey): Promise<Uint8Array> {
if (this.disableType0Decryption && encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
if (encBuffer.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
await SdkLoadService.Ready;
@@ -148,10 +130,7 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedDecapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -171,10 +150,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64
) {
if (wrappedEncapsulationKey.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -194,10 +170,7 @@ export class EncryptServiceImplementation implements EncryptService {
if (wrappingKey == null) {
throw new Error("No wrappingKey provided for unwrapping.");
}
if (
this.disableType0Decryption &&
keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64
) {
if (keyToBeUnwrapped.encryptionType === EncryptionType.AesCbc256_B64) {
throw new Error("Decryption of AesCbc256_B64 encrypted data is disabled.");
}
@@ -252,15 +225,9 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No data provided for decryption.");
}
let algorithm: "sha1" | "sha256";
switch (data.encryptionType) {
case EncryptionType.Rsa2048_OaepSha1_B64:
case EncryptionType.Rsa2048_OaepSha1_HmacSha256_B64:
algorithm = "sha1";
break;
case EncryptionType.Rsa2048_OaepSha256_B64:
case EncryptionType.Rsa2048_OaepSha256_HmacSha256_B64:
algorithm = "sha256";
break;
default:
throw new Error("Invalid encryption type.");
@@ -270,6 +237,6 @@ export class EncryptServiceImplementation implements EncryptService {
throw new Error("[Encrypt service] rsaDecrypt: No private key provided for decryption.");
}
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, algorithm);
return this.cryptoFunctionService.rsaDecrypt(data.dataBytes, privateKey, "sha1");
}
}

View File

@@ -163,7 +163,7 @@ describe("EncryptService", () => {
describe("decryptString", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_string");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_string");
const result = await encryptService.decryptString(encString, key);
expect(result).toEqual("decrypted_string");
expect(PureCrypto.symmetric_decrypt_string).toHaveBeenCalledWith(
@@ -172,8 +172,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_string");
await expect(encryptService.decryptString(encString, key)).rejects.toThrow(
@@ -185,7 +184,7 @@ describe("EncryptService", () => {
describe("decryptBytes", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("encrypted_bytes");
const encString = new EncString(EncryptionType.AesCbc256_HmacSha256_B64, "encrypted_bytes");
const result = await encryptService.decryptBytes(encString, key);
expect(result).toEqual(new Uint8Array(3));
expect(PureCrypto.symmetric_decrypt_bytes).toHaveBeenCalledWith(
@@ -194,8 +193,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "encrypted_bytes");
await expect(encryptService.decryptBytes(encString, key)).rejects.toThrow(
@@ -216,8 +214,7 @@ describe("EncryptService", () => {
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encBuffer = EncArrayBuffer.fromParts(
EncryptionType.AesCbc256_B64,
@@ -234,7 +231,10 @@ describe("EncryptService", () => {
describe("unwrapDecapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_decapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_decapsulation_key",
);
const result = await encryptService.unwrapDecapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(4));
expect(PureCrypto.unwrap_decapsulation_key).toHaveBeenCalledWith(
@@ -242,8 +242,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_decapsulation_key");
await expect(encryptService.unwrapDecapsulationKey(encString, key)).rejects.toThrow(
@@ -267,7 +266,10 @@ describe("EncryptService", () => {
describe("unwrapEncapsulationKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_encapsulation_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_encapsulation_key",
);
const result = await encryptService.unwrapEncapsulationKey(encString, key);
expect(result).toEqual(new Uint8Array(5));
expect(PureCrypto.unwrap_encapsulation_key).toHaveBeenCalledWith(
@@ -275,8 +277,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_encapsulation_key");
await expect(encryptService.unwrapEncapsulationKey(encString, key)).rejects.toThrow(
@@ -300,7 +301,10 @@ describe("EncryptService", () => {
describe("unwrapSymmetricKey", () => {
it("is a proxy to PureCrypto", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString("wrapped_symmetric_key");
const encString = new EncString(
EncryptionType.AesCbc256_HmacSha256_B64,
"wrapped_symmetric_key",
);
const result = await encryptService.unwrapSymmetricKey(encString, key);
expect(result).toEqual(new SymmetricCryptoKey(new Uint8Array(64)));
expect(PureCrypto.unwrap_symmetric_key).toHaveBeenCalledWith(
@@ -308,8 +312,7 @@ describe("EncryptService", () => {
key.toEncoded(),
);
});
it("throws if disableType0Decryption is enabled and type is AesCbc256_B64", async () => {
encryptService.setDisableType0Decryption(true);
it("throws if type is AesCbc256_B64", async () => {
const key = new SymmetricCryptoKey(makeStaticByteArray(64));
const encString = new EncString(EncryptionType.AesCbc256_B64, "wrapped_symmetric_key");
await expect(encryptService.unwrapSymmetricKey(encString, key)).rejects.toThrow(

View File

@@ -1,12 +1,20 @@
import { mock } from "jest-mock-extended";
import { PlatformUtilsService } from "../../../platform/abstractions/platform-utils.service";
import { SdkLoadService } from "../../../platform/abstractions/sdk/sdk-load.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";
class TestSdkLoadService extends SdkLoadService {
protected override load(): Promise<void> {
// Simulate successful WASM load
return Promise.resolve();
}
}
const RsaPublicKey =
"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAl0Vawl/toXzkEvB82FEtqHP" +
"4xlU2ab/v0crqIfXfIoWF/XXdHGIdrZeilnRXPPJT1B9dTsasttEZNnua/0Rek/cjNDHtzT52irfoZYS7X6HNIfOi54Q+egP" +
@@ -40,6 +48,10 @@ const Sha512Mac =
"5ea7817a0b7c5d4d9b00364ccd214669131fc17fe4aca";
describe("WebCrypto Function Service", () => {
beforeAll(async () => {
await new TestSdkLoadService().loadAndInit();
});
describe("pbkdf2", () => {
const regular256Key = "pj9prw/OHPleXI6bRdmlaD+saJS4awrMiQsQiDjeu2I=";
const utf8256Key = "yqvoFXgMRmHR3QPYr5pyR4uVuoHkltv9aHUP63p8n7I=";
@@ -287,7 +299,6 @@ describe("WebCrypto Function Service", () => {
});
describe("rsaGenerateKeyPair", () => {
testRsaGenerateKeyPair(1024);
testRsaGenerateKeyPair(2048);
// Generating 4096 bit keys can be slow. Commenting it out to save CI.
@@ -483,7 +494,7 @@ function testHmac(algorithm: "sha1" | "sha256" | "sha512", mac: string) {
});
}
function testRsaGenerateKeyPair(length: 1024 | 2048 | 4096) {
function testRsaGenerateKeyPair(length: 2048) {
it(
"should successfully generate a " + length + " bit key pair",
async () => {

View File

@@ -1,5 +1,8 @@
import * as forge from "node-forge";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { PureCrypto } from "@bitwarden/sdk-internal";
import { EncryptionType } from "../../../platform/enums";
import { Utils } from "../../../platform/misc/utils";
import {
@@ -260,57 +263,31 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
async rsaEncrypt(
data: Uint8Array,
publicKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("spki", publicKey, rsaParams, false, ["encrypt"]);
const buffer = await this.subtle.encrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_encrypt_data(data, publicKey);
}
async rsaDecrypt(
data: Uint8Array,
privateKey: Uint8Array,
algorithm: "sha1" | "sha256",
_algorithm: "sha1",
): Promise<Uint8Array> {
// Note: Edge browser requires that we specify name and hash for both key import and decrypt.
// We cannot use the proper types here.
const rsaParams = {
name: "RSA-OAEP",
hash: { name: this.toWebCryptoAlgorithm(algorithm) },
};
const impKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, false, ["decrypt"]);
const buffer = await this.subtle.decrypt(rsaParams, impKey, data);
return new Uint8Array(buffer);
await SdkLoadService.Ready;
return PureCrypto.rsa_decrypt_data(data, privateKey);
}
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<Uint8Array> {
const rsaParams = {
name: "RSA-OAEP",
// Have to specify some algorithm
hash: { name: this.toWebCryptoAlgorithm("sha1") },
};
const impPrivateKey = await this.subtle.importKey("pkcs8", privateKey, rsaParams, true, [
"decrypt",
]);
const jwkPrivateKey = await this.subtle.exportKey("jwk", impPrivateKey);
const jwkPublicKeyParams = {
kty: "RSA",
e: jwkPrivateKey.e,
n: jwkPrivateKey.n,
alg: "RSA-OAEP",
ext: true,
};
const impPublicKey = await this.subtle.importKey("jwk", jwkPublicKeyParams, rsaParams, true, [
"encrypt",
]);
const buffer = await this.subtle.exportKey("spki", impPublicKey);
return new Uint8Array(buffer) as UnsignedPublicKey;
async rsaExtractPublicKey(privateKey: Uint8Array): Promise<UnsignedPublicKey> {
await SdkLoadService.Ready;
return PureCrypto.rsa_extract_public_key(privateKey) as UnsignedPublicKey;
}
async rsaGenerateKeyPair(_length: 2048): Promise<[UnsignedPublicKey, Uint8Array]> {
await SdkLoadService.Ready;
const privateKey = PureCrypto.rsa_generate_keypair();
const publicKey = await this.rsaExtractPublicKey(privateKey);
return [publicKey, privateKey];
}
async aesGenerateKey(bitLength = 128 | 192 | 256 | 512): Promise<CsprngArray> {
@@ -330,20 +307,6 @@ export class WebCryptoFunctionService implements CryptoFunctionService {
return new Uint8Array(rawKey) as CsprngArray;
}
async rsaGenerateKeyPair(length: 1024 | 2048 | 4096): Promise<[Uint8Array, Uint8Array]> {
const rsaParams = {
name: "RSA-OAEP",
modulusLength: length,
publicExponent: new Uint8Array([0x01, 0x00, 0x01]), // 65537
// Have to specify some algorithm
hash: { name: this.toWebCryptoAlgorithm("sha1") },
};
const keyPair = await this.subtle.generateKey(rsaParams, true, ["encrypt", "decrypt"]);
const publicKey = await this.subtle.exportKey("spki", keyPair.publicKey);
const privateKey = await this.subtle.exportKey("pkcs8", keyPair.privateKey);
return [new Uint8Array(publicKey), new Uint8Array(privateKey)];
}
randomBytes(length: number): Promise<CsprngArray> {
const arr = new Uint8Array(length);
this.crypto.getRandomValues(arr);

View File

@@ -39,6 +39,7 @@ export abstract class DeviceTrustServiceAbstraction {
/** Retrieves the device key if it exists from state or secure storage if supported for the active user. */
abstract getDeviceKey(userId: UserId): Promise<DeviceKey | null>;
abstract setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void>;
abstract decryptUserKeyWithDeviceKey(
userId: UserId,
encryptedDevicePrivateKey: EncString,

View File

@@ -356,7 +356,7 @@ export class DeviceTrustService implements DeviceTrustServiceAbstraction {
}
}
private async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
async setDeviceKey(userId: UserId, deviceKey: DeviceKey | null): Promise<void> {
if (!userId) {
throw new Error("UserId is required. Cannot set device key.");
}

View File

@@ -0,0 +1,194 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { DefaultEncryptedMigrator } from "./default-encrypted-migrator";
import { EncryptedMigration } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
jest.mock("./migrations/minimum-kdf-migration");
describe("EncryptedMigrator", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const configService = mock<ConfigService>();
const masterPasswordService = mock<MasterPasswordServiceAbstraction>();
const syncService = mock<SyncService>();
let sut: DefaultEncryptedMigrator;
const mockMigration = mock<MinimumKdfMigration>();
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword123";
beforeEach(() => {
jest.clearAllMocks();
// Mock the MinimumKdfMigration constructor to return our mock
(MinimumKdfMigration as jest.MockedClass<typeof MinimumKdfMigration>).mockImplementation(
() => mockMigration,
);
sut = new DefaultEncryptedMigrator(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
configService,
masterPasswordService,
syncService,
);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("runMigrations", () => {
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, null)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, null)).rejects.toThrow("userId");
});
it("should not run migration when needsMigration returns 'noMigrationNeeded'", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run migration when needsMigration returns 'needsMigration'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should run migration when needsMigration returns 'needsMigrationWithMasterPassword'", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
});
it("should throw error when migration needs master password but null is provided", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
await sut.runMigrations(mockUserId, null);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).not.toHaveBeenCalled();
});
it("should run multiple migrations", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockMigration.runMigrations).toHaveBeenCalledWith(mockUserId, mockMasterPassword);
expect(mockSecondMigration.runMigrations).toHaveBeenCalledWith(
mockUserId,
mockMasterPassword,
);
});
});
describe("needsMigrations", () => {
it("should return 'noMigrationNeeded' when no migrations are needed", async () => {
mockMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when at least one migration needs to run", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigrationWithMasterPassword' when at least one migration needs master password", async () => {
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should prioritize 'needsMigrationWithMasterPassword' over 'needsMigration'", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("needsMigration");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigrationWithMasterPassword");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should return 'needsMigration' when some migrations need running but none need master password", async () => {
const mockSecondMigration = mock<EncryptedMigration>();
mockSecondMigration.needsMigration.mockResolvedValue("noMigrationNeeded");
(sut as any).migrations.push({
name: "Test Second Migration",
migration: mockSecondMigration,
});
mockMigration.needsMigration.mockResolvedValue("needsMigration");
const result = await sut.needsMigrations(mockUserId);
expect(result).toBe("needsMigration");
expect(mockMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
expect(mockSecondMigration.needsMigration).toHaveBeenCalledWith(mockUserId);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigrations(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigrations(undefined as any)).rejects.toThrow("userId");
});
});
});

View File

@@ -0,0 +1,113 @@
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../auth/utils";
import { ConfigService } from "../../platform/abstractions/config/config.service";
import { SyncService } from "../../platform/sync";
import { UserId } from "../../types/guid";
import { ChangeKdfService } from "../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigrator } from "./encrypted-migrator.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./migrations/encrypted-migration";
import { MinimumKdfMigration } from "./migrations/minimum-kdf-migration";
export class DefaultEncryptedMigrator implements EncryptedMigrator {
private migrations: { name: string; migration: EncryptedMigration }[] = [];
private isRunningMigration = false;
constructor(
readonly kdfConfigService: KdfConfigService,
readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
readonly configService: ConfigService,
readonly masterPasswordService: MasterPasswordServiceAbstraction,
readonly syncService: SyncService,
) {
// Register migrations here
this.migrations.push({
name: "Minimum PBKDF2 Iteration Count Migration",
migration: new MinimumKdfMigration(
kdfConfigService,
changeKdfService,
logService,
configService,
masterPasswordService,
),
});
}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
// Ensure that the requirements for running all migrations are met
const needsMigration = await this.needsMigrations(userId);
if (needsMigration === "noMigrationNeeded") {
return;
} else if (needsMigration === "needsMigrationWithMasterPassword" && masterPassword == null) {
// If a migration needs a password, but none is provided, the migrations are skipped. If a manual caller
// during a login / unlock flow calls without a master password in a login / unlock strategy that has no
// password, such as biometric unlock, the migrations are skipped.
//
// The fallback to this, the encrypted migrations scheduler, will first check if a migration needs a password
// and then prompt the user. If the user enters their password, runMigrations is called again with the password.
return;
}
try {
// No concurrent migrations allowed, so acquire a service-wide lock
if (this.isRunningMigration) {
return;
}
this.isRunningMigration = true;
// Run all migrations sequentially in the order they were registered
this.logService.mark("[Encrypted Migrator] Start");
this.logService.info(`[Encrypted Migrator] Starting migrations for user: ${userId}`);
let ranMigration = false;
for (const { name, migration } of this.migrations) {
if ((await migration.needsMigration(userId)) !== "noMigrationNeeded") {
this.logService.info(`[Encrypted Migrator] Running migration: ${name}`);
const start = performance.now();
await migration.runMigrations(userId, masterPassword);
this.logService.measure(start, "[Encrypted Migrator]", name, "ExecutionTime");
ranMigration = true;
}
}
this.logService.mark("[Encrypted Migrator] Finish");
this.logService.info(`[Encrypted Migrator] Completed migrations for user: ${userId}`);
if (ranMigration) {
await this.syncService.fullSync(true);
}
} catch (error) {
this.logService.error(
`[Encrypted Migrator] Error running migrations for user: ${userId}`,
error,
);
throw error; // Re-throw the error to be handled by the caller
} finally {
this.isRunningMigration = false;
}
}
async needsMigrations(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
const migrationRequirements = await Promise.all(
this.migrations.map(async ({ migration }) => migration.needsMigration(userId)),
);
if (migrationRequirements.includes("needsMigrationWithMasterPassword")) {
return "needsMigrationWithMasterPassword";
} else if (migrationRequirements.includes("needsMigration")) {
return "needsMigration";
} else {
return "noMigrationNeeded";
}
}
isRunningMigrations(): boolean {
return this.isRunningMigration;
}
}

View File

@@ -0,0 +1,32 @@
import { UserId } from "../../types/guid";
import { MigrationRequirement } from "./migrations/encrypted-migration";
export abstract class EncryptedMigrator {
/**
* Runs migrations on a decrypted user, with the cryptographic state initialized.
* This only runs the migrations that are needed for the user.
* This needs to be run after the decrypted user key has been set to state.
*
* If the master password is required but not provided, the migrations will not run, and the function will return early.
* If migrations are already running, the migrations will not run again, and the function will return early.
*
* @param userId The ID of the user to run migrations for.
* @param masterPassword The user's current master password.
* @throws If the user does not exist
* @throws If the user is locked or logged out
* @throws If a migration fails
*/
abstract runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Checks if the user needs to run any migrations.
* This is used to determine if the user should be prompted to run migrations.
* @param userId The ID of the user to check migrations for.
*/
abstract needsMigrations(userId: UserId): Promise<MigrationRequirement>;
/**
* Indicates whether migrations are currently running.
*/
abstract isRunningMigrations(): boolean;
}

View File

@@ -0,0 +1,36 @@
import { UserId } from "../../../types/guid";
/**
* @internal
* IMPORTANT: Please read this when implementing new migrations.
*
* An encrypted migration defines an online migration that mutates the persistent state of the user on the server, or locally.
* It should only be run once per user (or for local migrations, once per device). Migrations get scheduled automatically,
* during actions such as login and unlock, or during sync.
*
* Migrations can require the master-password, which is provided by the user if required.
* Migrations are run as soon as possible non-lazily, and MAY block unlock / login, if they have to run.
*
* Most importantly, implementing a migration should be done such that concurrent migrations may fail, but must never
* leave the user in a broken state. Locally, these are scheduled with an application-global lock. However, no such guarantees
* are made for the server, and other devices may run the migration concurrently.
*
* When adding a migration, it *MUST* be feature-flagged for the initial roll-out.
*/
export interface EncryptedMigration {
/**
* Runs the migration.
* @throws If the migration fails, such as when no network is available.
* @throws If the requirements for migration are not met (e.g. the user is locked)
*/
runMigrations(userId: UserId, masterPassword: string | null): Promise<void>;
/**
* Returns whether the migration needs to be run for the user, and if it does, whether the master password is required.
*/
needsMigration(userId: UserId): Promise<MigrationRequirement>;
}
export type MigrationRequirement =
| "needsMigration"
| "needsMigrationWithMasterPassword"
| "noMigrationNeeded";

View File

@@ -0,0 +1,184 @@
import { mock } from "jest-mock-extended";
// eslint-disable-next-line no-restricted-imports
import {
Argon2KdfConfig,
KdfConfigService,
KdfType,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { UserId } from "../../../types/guid";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { MinimumKdfMigration } from "./minimum-kdf-migration";
describe("MinimumKdfMigration", () => {
const mockKdfConfigService = mock<KdfConfigService>();
const mockChangeKdfService = mock<ChangeKdfService>();
const mockLogService = mock<LogService>();
const mockConfigService = mock<ConfigService>();
const mockMasterPasswordService = mock<MasterPasswordServiceAbstraction>();
let sut: MinimumKdfMigration;
const mockUserId = "00000000-0000-0000-0000-000000000000" as UserId;
const mockMasterPassword = "masterPassword";
beforeEach(() => {
jest.clearAllMocks();
sut = new MinimumKdfMigration(
mockKdfConfigService,
mockChangeKdfService,
mockLogService,
mockConfigService,
mockMasterPasswordService,
);
});
describe("needsMigration", () => {
it("should return 'noMigrationNeeded' when user does not have a master password`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when user uses argon2id`", async () => {
mockMasterPasswordService.userHasMasterPassword.mockResolvedValue(true);
mockKdfConfigService.getKdfConfig.mockResolvedValue(new Argon2KdfConfig(3, 64, 4));
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations are already above minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min + 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when PBKDF2 iterations equal minimum", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
});
it("should return 'noMigrationNeeded' when feature flag is disabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(false);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("noMigrationNeeded");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should return 'needsMigrationWithMasterPassword' when PBKDF2 iterations are below minimum and feature flag is enabled", async () => {
const mockKdfConfig = {
kdfType: KdfType.PBKDF2_SHA256,
iterations: PBKDF2KdfConfig.ITERATIONS.min - 1000,
};
mockKdfConfigService.getKdfConfig.mockResolvedValue(mockKdfConfig as any);
mockConfigService.getFeatureFlag.mockResolvedValue(true);
const result = await sut.needsMigration(mockUserId);
expect(result).toBe("needsMigrationWithMasterPassword");
expect(mockKdfConfigService.getKdfConfig).toHaveBeenCalledWith(mockUserId);
expect(mockConfigService.getFeatureFlag).toHaveBeenCalledWith(
FeatureFlag.ForceUpdateKDFSettings,
);
});
it("should throw error when userId is null", async () => {
await expect(sut.needsMigration(null as any)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.needsMigration(undefined as any)).rejects.toThrow("userId");
});
});
describe("runMigrations", () => {
it("should update KDF parameters with minimum PBKDF2 iterations", async () => {
await sut.runMigrations(mockUserId, mockMasterPassword);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
// Verify the PBKDF2KdfConfig has the correct iteration count
const kdfConfigArg = (mockChangeKdfService.updateUserKdfParams as jest.Mock).mock.calls[0][1];
expect(kdfConfigArg.iterations).toBe(PBKDF2KdfConfig.ITERATIONS.defaultValue);
});
it("should throw error when userId is null", async () => {
await expect(sut.runMigrations(null as any, mockMasterPassword)).rejects.toThrow("userId");
});
it("should throw error when userId is undefined", async () => {
await expect(sut.runMigrations(undefined as any, mockMasterPassword)).rejects.toThrow(
"userId",
);
});
it("should throw error when masterPassword is null", async () => {
await expect(sut.runMigrations(mockUserId, null as any)).rejects.toThrow("masterPassword");
});
it("should throw error when masterPassword is undefined", async () => {
await expect(sut.runMigrations(mockUserId, undefined as any)).rejects.toThrow(
"masterPassword",
);
});
it("should handle errors from changeKdfService", async () => {
const mockError = new Error("KDF update failed");
mockChangeKdfService.updateUserKdfParams.mockRejectedValue(mockError);
await expect(sut.runMigrations(mockUserId, mockMasterPassword)).rejects.toThrow(
"KDF update failed",
);
expect(mockLogService.info).toHaveBeenCalledWith(
`[MinimumKdfMigration] Updating user ${mockUserId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.min}`,
);
expect(mockChangeKdfService.updateUserKdfParams).toHaveBeenCalledWith(
mockMasterPassword,
expect.any(PBKDF2KdfConfig),
mockUserId,
);
});
});
});

View File

@@ -0,0 +1,68 @@
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfigService, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { assertNonNullish } from "../../../auth/utils";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { ChangeKdfService } from "../../kdf/change-kdf.service.abstraction";
import { MasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { EncryptedMigration, MigrationRequirement } from "./encrypted-migration";
/**
* @internal
* This migrator ensures the user's account has a minimum PBKDF2 iteration count.
* It will update the entire account, logging out old clients if necessary.
*/
export class MinimumKdfMigration implements EncryptedMigration {
constructor(
private readonly kdfConfigService: KdfConfigService,
private readonly changeKdfService: ChangeKdfService,
private readonly logService: LogService,
private readonly configService: ConfigService,
private readonly masterPasswordService: MasterPasswordServiceAbstraction,
) {}
async runMigrations(userId: UserId, masterPassword: string | null): Promise<void> {
assertNonNullish(userId, "userId");
assertNonNullish(masterPassword, "masterPassword");
this.logService.info(
`[MinimumKdfMigration] Updating user ${userId} to minimum PBKDF2 iteration count ${PBKDF2KdfConfig.ITERATIONS.defaultValue}`,
);
await this.changeKdfService.updateUserKdfParams(
masterPassword!,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
userId,
);
await this.kdfConfigService.setKdfConfig(
userId,
new PBKDF2KdfConfig(PBKDF2KdfConfig.ITERATIONS.defaultValue),
);
}
async needsMigration(userId: UserId): Promise<MigrationRequirement> {
assertNonNullish(userId, "userId");
if (!(await this.masterPasswordService.userHasMasterPassword(userId))) {
return "noMigrationNeeded";
}
// Only PBKDF2 users below the minimum iteration count need migration
const kdfConfig = await this.kdfConfigService.getKdfConfig(userId);
if (
kdfConfig.kdfType !== KdfType.PBKDF2_SHA256 ||
kdfConfig.iterations >= PBKDF2KdfConfig.ITERATIONS.min
) {
return "noMigrationNeeded";
}
if (!(await this.configService.getFeatureFlag(FeatureFlag.ForceUpdateKDFSettings))) {
return "noMigrationNeeded";
}
return "needsMigrationWithMasterPassword";
}
}

View File

@@ -2,13 +2,14 @@ import { mock } from "jest-mock-extended";
import { of } from "rxjs";
// eslint-disable-next-line no-restricted-imports
import { PBKDF2KdfConfig } from "@bitwarden/key-management";
import { KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { makeEncString } from "../../../spec";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { UserId } from "../../types/guid";
import { EncString } from "../crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
MasterPasswordAuthenticationHash,
@@ -17,11 +18,13 @@ import {
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { DefaultChangeKdfService } from "./change-kdf-service";
import { DefaultChangeKdfService } from "./change-kdf.service";
describe("ChangeKdfService", () => {
const changeKdfApiService = mock<ChangeKdfApiService>();
const sdkService = mock<SdkService>();
const keyService = mock<KeyService>();
const masterPasswordService = mock<InternalMasterPasswordServiceAbstraction>();
let sut: DefaultChangeKdfService;
@@ -48,7 +51,12 @@ describe("ChangeKdfService", () => {
beforeEach(() => {
sdkService.userClient$ = jest.fn((userId: UserId) => of(mockSdk)) as any;
sut = new DefaultChangeKdfService(changeKdfApiService, sdkService);
sut = new DefaultChangeKdfService(
changeKdfApiService,
sdkService,
keyService,
masterPasswordService,
);
});
afterEach(() => {
@@ -163,6 +171,20 @@ describe("ChangeKdfService", () => {
expect(changeKdfApiService.updateUserKdfParams).toHaveBeenCalledWith(expectedRequest);
});
it("should set master key and hash after KDF update", async () => {
const masterPassword = "masterPassword";
const mockMasterKey = {} as any;
const mockHash = "localHash";
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.hashMasterKey.mockResolvedValue(mockHash);
await sut.updateUserKdfParams(masterPassword, mockNewKdfConfig, mockUserId);
expect(masterPasswordService.setMasterKey).toHaveBeenCalledWith(mockMasterKey, mockUserId);
expect(masterPasswordService.setMasterKeyHash).toHaveBeenCalledWith(mockHash, mockUserId);
});
it("should properly dispose of SDK resources", async () => {
const masterPassword = "masterPassword";
jest.spyOn(mockNewKdfConfig, "toSdkConfig").mockReturnValue({} as any);

View File

@@ -1,12 +1,14 @@
import { firstValueFrom, map } from "rxjs";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { UserId } from "@bitwarden/common/types/guid";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
import { KdfConfig, KeyService } from "@bitwarden/key-management";
import { KdfRequest } from "../../models/request/kdf.request";
import { SdkService } from "../../platform/abstractions/sdk/sdk.service";
import { InternalMasterPasswordServiceAbstraction } from "../master-password/abstractions/master-password.service.abstraction";
import {
fromSdkAuthenticationData,
MasterPasswordAuthenticationData,
@@ -14,12 +16,14 @@ import {
} from "../master-password/types/master-password.types";
import { ChangeKdfApiService } from "./change-kdf-api.service.abstraction";
import { ChangeKdfService } from "./change-kdf-service.abstraction";
import { ChangeKdfService } from "./change-kdf.service.abstraction";
export class DefaultChangeKdfService implements ChangeKdfService {
constructor(
private changeKdfApiService: ChangeKdfApiService,
private sdkService: SdkService,
private keyService: KeyService,
private masterPasswordService: InternalMasterPasswordServiceAbstraction,
) {}
async updateUserKdfParams(masterPassword: string, kdf: KdfConfig, userId: UserId): Promise<void> {
@@ -56,5 +60,19 @@ export class DefaultChangeKdfService implements ChangeKdfService {
const request = new KdfRequest(authenticationData, unlockData);
request.authenticateWith(oldAuthenticationData);
await this.changeKdfApiService.updateUserKdfParams(request);
// Update the locally stored master key and hash, so that UV, etc. still works
const masterKey = await this.keyService.makeMasterKey(
masterPassword,
unlockData.salt,
unlockData.kdf,
);
const localMasterKeyHash = await this.keyService.hashMasterKey(
masterPassword,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.masterPasswordService.setMasterKeyHash(localMasterKeyHash, userId);
await this.masterPasswordService.setMasterKey(masterKey, userId);
}
}

View File

@@ -0,0 +1,7 @@
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
export abstract class KeyConnectorApiService {
abstract getConfirmationDetails(
orgSsoIdentifier: string,
): Promise<KeyConnectorConfirmationDetailsResponse>;
}

View File

@@ -1,3 +1,4 @@
export interface KeyConnectorDomainConfirmation {
keyConnectorUrl: string;
organizationSsoIdentifier: string;
}

View File

@@ -5,5 +5,6 @@ import { KdfConfig } from "@bitwarden/key-management";
export interface NewSsoUserKeyConnectorConversion {
kdfConfig: KdfConfig;
keyConnectorUrl: string;
// SSO organization identifier, not UUID
organizationId: string;
}

View File

@@ -0,0 +1,10 @@
import { BaseResponse } from "../../../../models/response/base.response";
export class KeyConnectorConfirmationDetailsResponse extends BaseResponse {
organizationName: string;
constructor(response: any) {
super(response);
this.organizationName = this.getResponseProperty("OrganizationName");
}
}

View File

@@ -0,0 +1,54 @@
import { mock, MockProxy } from "jest-mock-extended";
import { ApiService } from "../../../abstractions/api.service";
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
import { DefaultKeyConnectorApiService } from "./default-key-connector-api.service";
describe("DefaultKeyConnectorApiService", () => {
let apiService: MockProxy<ApiService>;
let sut: DefaultKeyConnectorApiService;
beforeEach(() => {
apiService = mock<ApiService>();
sut = new DefaultKeyConnectorApiService(apiService);
});
describe("getConfirmationDetails", () => {
it("encodes orgSsoIdentifier in URL", async () => {
const orgSsoIdentifier = "test org/with special@chars";
const expectedEncodedIdentifier = encodeURIComponent(orgSsoIdentifier);
const mockResponse = {};
apiService.send.mockResolvedValue(mockResponse);
await sut.getConfirmationDetails(orgSsoIdentifier);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
`/accounts/key-connector/confirmation-details/${expectedEncodedIdentifier}`,
null,
true,
true,
);
});
it("returns expected response", async () => {
const orgSsoIdentifier = "test-org";
const expectedOrgName = "example";
const mockResponse = { OrganizationName: expectedOrgName };
apiService.send.mockResolvedValue(mockResponse);
const result = await sut.getConfirmationDetails(orgSsoIdentifier);
expect(result).toBeInstanceOf(KeyConnectorConfirmationDetailsResponse);
expect(result.organizationName).toBe(expectedOrgName);
expect(apiService.send).toHaveBeenCalledWith(
"GET",
"/accounts/key-connector/confirmation-details/test-org",
null,
true,
true,
);
});
});
});

View File

@@ -0,0 +1,20 @@
import { ApiService } from "../../../abstractions/api.service";
import { KeyConnectorApiService } from "../abstractions/key-connector-api.service";
import { KeyConnectorConfirmationDetailsResponse } from "../models/response/key-connector-confirmation-details.response";
export class DefaultKeyConnectorApiService implements KeyConnectorApiService {
constructor(private apiService: ApiService) {}
async getConfirmationDetails(
orgSsoIdentifier: string,
): Promise<KeyConnectorConfirmationDetailsResponse> {
const r = await this.apiService.send(
"GET",
"/accounts/key-connector/confirmation-details/" + encodeURIComponent(orgSsoIdentifier),
null,
true,
true,
);
return new KeyConnectorConfirmationDetailsResponse(r);
}
}

View File

@@ -7,7 +7,8 @@ import { SetKeyConnectorKeyRequest } from "@bitwarden/common/key-management/key-
import { KeysRequest } from "@bitwarden/common/models/request/keys.request";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, PBKDF2KdfConfig, KeyService, KdfType } from "@bitwarden/key-management";
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import { BitwardenClient } from "@bitwarden/sdk-internal";
import { FakeAccountService, FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { ApiService } from "../../../abstractions/api.service";
@@ -16,21 +17,26 @@ import { Organization } from "../../../admin-console/models/domain/organization"
import { ProfileOrganizationResponse } from "../../../admin-console/models/response/profile-organization.response";
import { KeyConnectorUserKeyResponse } from "../../../auth/models/response/key-connector-user-key.response";
import { TokenService } from "../../../auth/services/token.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { Rc } from "../../../platform/misc/reference-counting/rc";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { OrganizationId, UserId } from "../../../types/guid";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { FakeMasterPasswordService } from "../../master-password/services/fake-master-password.service";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
import { NewSsoUserKeyConnectorConversion } from "../models/new-sso-user-key-connector-conversion";
import {
USES_KEY_CONNECTOR,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
KeyConnectorService,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
USES_KEY_CONNECTOR,
} from "./key-connector.service";
describe("KeyConnectorService", () => {
@@ -43,6 +49,10 @@ describe("KeyConnectorService", () => {
const organizationService = mock<OrganizationService>();
const keyGenerationService = mock<KeyGenerationService>();
const logoutCallback = jest.fn();
const configService = mock<ConfigService>();
const registerSdkService = mock<RegisterSdkService>();
const securityStateService = mock<SecurityStateService>();
const accountCryptographicStateService = mock<AccountCryptographicStateService>();
let stateProvider: FakeStateProvider;
@@ -50,6 +60,7 @@ describe("KeyConnectorService", () => {
let masterPasswordService: FakeMasterPasswordService;
const mockUserId = Utils.newGuid() as UserId;
const mockSsoOrgIdentifier = "test-sso-org-id";
const mockOrgId = Utils.newGuid() as OrganizationId;
const mockMasterKeyResponse: KeyConnectorUserKeyResponse = new KeyConnectorUserKeyResponse({
@@ -61,7 +72,7 @@ describe("KeyConnectorService", () => {
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: new PBKDF2KdfConfig(600_000),
keyConnectorUrl,
organizationId: mockOrgId,
organizationId: mockSsoOrgIdentifier,
};
beforeEach(() => {
@@ -82,6 +93,10 @@ describe("KeyConnectorService", () => {
keyGenerationService,
logoutCallback,
stateProvider,
configService,
registerSdkService,
securityStateService,
accountCryptographicStateService,
);
});
@@ -419,44 +434,52 @@ describe("KeyConnectorService", () => {
});
describe("convertNewSsoUserToKeyConnector", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
describe("V2", () => {
const mockKeyConnectorKey = Utils.fromBufferToB64(new Uint8Array(64));
const mockUserKeyString = Utils.fromBufferToB64(new Uint8Array(64));
const mockPrivateKey = "mockPrivateKey789";
const mockKeyConnectorKeyWrappedUserKey = "2.mockWrappedUserKey";
const mockSigningKey = "mockSigningKey";
const mockSignedPublicKey = "mockSignedPublicKey";
const mockSecurityState = "mockSecurityState";
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
let mockSdkRef: any;
let mockSdk: any;
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
});
beforeEach(() => {
configService.getFeatureFlag$.mockReturnValue(of(true));
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockOrgId,
mockSdkRef = {
value: {
auth: jest.fn().mockReturnValue({
registration: jest.fn().mockReturnValue({
post_keys_for_key_connector_registration: jest.fn().mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
}),
}),
}),
},
[Symbol.dispose]: jest.fn(),
};
mockSdk = {
take: jest.fn().mockReturnValue(mockSdkRef),
};
registerSdkService.registerClient$.mockReturnValue(of(mockSdk));
});
it("should set up a new SSO user with key connector using V2", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
@@ -465,11 +488,253 @@ describe("KeyConnectorService", () => {
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(registerSdkService.registerClient$).toHaveBeenCalledWith(mockUserId);
expect(mockSdk.take).toHaveBeenCalled();
expect(mockSdkRef.value.auth).toHaveBeenCalled();
const mockRegistration = mockSdkRef.value
.auth()
.registration().post_keys_for_key_connector_registration;
expect(mockRegistration).toHaveBeenCalledWith(
keyConnectorUrl,
mockSsoOrgIdentifier,
mockUserId,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(keyService.setUserKey).toHaveBeenCalledWith(
expect.any(SymmetricCryptoKey),
mockUserId,
);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
expect.any(EncString),
mockUserId,
);
expect(accountCryptographicStateService.setAccountCryptographicState).toHaveBeenCalledWith(
{
V2: {
private_key: mockPrivateKey,
signing_key: mockSigningKey,
signed_public_key: mockSignedPublicKey,
security_state: mockSecurityState,
},
},
mockUserId,
);
expect(keyService.setPrivateKey).toHaveBeenCalledWith(mockPrivateKey, mockUserId);
expect(keyService.setUserSigningKey).toHaveBeenCalledWith(mockSigningKey, mockUserId);
expect(securityStateService.setAccountSecurityState).toHaveBeenCalledWith(
mockSecurityState,
mockUserId,
);
expect(keyService.setSignedPublicKey).toHaveBeenCalledWith(mockSignedPublicKey, mockUserId);
expect(await firstValueFrom(conversionState.state$)).toBeNull();
});
it("should throw error when SDK is not available", async () => {
registerSdkService.registerClient$.mockReturnValue(
of(null as unknown as Rc<BitwardenClient>),
);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("SDK not available");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when account cryptographic state is not V2", async () => {
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockResolvedValue({
key_connector_key: mockKeyConnectorKey,
user_key: mockUserKeyString,
key_connector_key_wrapped_user_key: mockKeyConnectorKeyWrappedUserKey,
account_cryptographic_state: {
V1: {
private_key: mockPrivateKey,
},
},
});
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Unexpected account cryptographic state version");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
it("should throw error when post_keys_for_key_connector_registration fails", async () => {
const sdkError = new Error("Key Connector registration failed");
mockSdkRef.value
.auth()
.registration()
.post_keys_for_key_connector_registration.mockRejectedValue(sdkError);
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow("Key Connector registration failed");
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(masterPasswordService.mock.setMasterKey).not.toHaveBeenCalled();
expect(keyService.setUserKey).not.toHaveBeenCalled();
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).not.toHaveBeenCalled();
expect(
accountCryptographicStateService.setAccountCryptographicState,
).not.toHaveBeenCalled();
expect(keyService.setPrivateKey).not.toHaveBeenCalled();
expect(keyService.setUserSigningKey).not.toHaveBeenCalled();
expect(securityStateService.setAccountSecurityState).not.toHaveBeenCalled();
expect(keyService.setSignedPublicKey).not.toHaveBeenCalled();
});
});
describe("V1", () => {
const passwordKey = new SymmetricCryptoKey(new Uint8Array(64));
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const mockEmail = "test@example.com";
const mockMasterKey = getMockMasterKey();
const mockKeyPair = ["mockPubKey", new EncString("mockEncryptedPrivKey")] as [
string,
EncString,
];
let mockMakeUserKeyResult: [UserKey, EncString];
beforeEach(() => {
const mockUserKey = new SymmetricCryptoKey(new Uint8Array(64)) as UserKey;
const encString = new EncString("mockEncryptedString");
mockMakeUserKeyResult = [mockUserKey, encString] as [UserKey, EncString];
keyGenerationService.createKey.mockResolvedValue(passwordKey);
keyService.makeMasterKey.mockResolvedValue(mockMasterKey);
keyService.makeUserKey.mockResolvedValue(mockMakeUserKeyResult);
keyService.makeKeyPair.mockResolvedValue(mockKeyPair);
tokenService.getEmail.mockResolvedValue(mockEmail);
configService.getFeatureFlag$.mockReturnValue(of(false));
});
it.each([
[KdfType.PBKDF2_SHA256, 700_000, undefined, undefined],
[KdfType.Argon2id, 11, 65, 5],
])(
"sets up a new SSO user with key connector",
async (kdfType, kdfIterations, kdfMemory, kdfParallelism) => {
const expectedKdfConfig =
kdfType == KdfType.PBKDF2_SHA256
? new PBKDF2KdfConfig(kdfIterations)
: new Argon2KdfConfig(kdfIterations, kdfMemory, kdfParallelism);
const conversion: NewSsoUserKeyConnectorConversion = {
kdfConfig: expectedKdfConfig,
keyConnectorUrl: keyConnectorUrl,
organizationId: mockSsoOrgIdentifier,
};
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockSsoOrgIdentifier,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector error"));
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
expectedKdfConfig,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
@@ -488,76 +753,29 @@ describe("KeyConnectorService", () => {
Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey),
),
);
expect(apiService.postSetKeyConnectorKey).toHaveBeenCalledWith(
new SetKeyConnectorKeyRequest(
mockMakeUserKeyResult[1].encryptedString!,
expectedKdfConfig,
mockOrgId,
new KeysRequest(mockKeyPair[0], mockKeyPair[1].encryptedString!),
),
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
// Verify that conversion data is cleared from conversionState
expect(await firstValueFrom(conversionState.state$)).toBeNull();
},
);
await expect(
keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId),
).rejects.toThrow(new Error("Key Connector conversion not found"));
it("handles api error", async () => {
apiService.postUserKeyToKeyConnector.mockRejectedValue(new Error("API error"));
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(conversion);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector error"),
);
expect(keyGenerationService.createKey).toHaveBeenCalledWith(512);
expect(keyService.makeMasterKey).toHaveBeenCalledWith(
passwordKey.keyB64,
mockEmail,
new PBKDF2KdfConfig(600_000),
);
expect(masterPasswordService.mock.setMasterKey).toHaveBeenCalledWith(
mockMasterKey,
mockUserId,
);
expect(keyService.makeUserKey).toHaveBeenCalledWith(mockMasterKey);
expect(keyService.setUserKey).toHaveBeenCalledWith(mockUserKey, mockUserId);
expect(masterPasswordService.mock.setMasterKeyEncryptedUserKey).toHaveBeenCalledWith(
mockMakeUserKeyResult[1],
mockUserId,
);
expect(keyService.makeKeyPair).toHaveBeenCalledWith(mockMakeUserKeyResult[0]);
expect(apiService.postUserKeyToKeyConnector).toHaveBeenCalledWith(
keyConnectorUrl,
new KeyConnectorUserKeyRequest(Utils.fromBufferToB64(mockMasterKey.inner().encryptionKey)),
);
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
expect(await firstValueFrom(conversionState.state$)).toEqual(conversion);
expect(logoutCallback).toHaveBeenCalledWith("keyConnectorError");
});
it("should throw error when conversion data is null", async () => {
const conversionState = stateProvider.singleUser.getFake(
mockUserId,
NEW_SSO_USER_KEY_CONNECTOR_CONVERSION,
);
conversionState.nextState(null);
await expect(keyConnectorService.convertNewSsoUserToKeyConnector(mockUserId)).rejects.toThrow(
new Error("Key Connector conversion not found"),
);
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
// Verify that no key generation or API calls were made
expect(keyGenerationService.createKey).not.toHaveBeenCalled();
expect(keyService.makeMasterKey).not.toHaveBeenCalled();
expect(apiService.postUserKeyToKeyConnector).not.toHaveBeenCalled();
expect(apiService.postSetKeyConnectorKey).not.toHaveBeenCalled();
});
});
});
@@ -603,7 +821,10 @@ describe("KeyConnectorService", () => {
const data$ = keyConnectorService.requiresDomainConfirmation$(mockUserId);
const data = await firstValueFrom(data$);
expect(data).toEqual({ keyConnectorUrl: conversion.keyConnectorUrl });
expect(data).toEqual({
keyConnectorUrl: conversion.keyConnectorUrl,
organizationSsoIdentifier: conversion.organizationId,
});
});
it("should return observable of null value when no data is set", async () => {

View File

@@ -9,22 +9,36 @@ import { AccountService } from "@bitwarden/common/auth/abstractions/account.serv
import { NewSsoUserKeyConnectorConversion } from "@bitwarden/common/key-management/key-connector/models/new-sso-user-key-connector-conversion";
// This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop.
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfType, KeyService, PBKDF2KdfConfig } from "@bitwarden/key-management";
import {
Argon2KdfConfig,
KdfConfig,
KdfType,
KeyService,
PBKDF2KdfConfig,
} from "@bitwarden/key-management";
import { LogService } from "@bitwarden/logging";
import { ApiService } from "../../../abstractions/api.service";
import { OrganizationService } from "../../../admin-console/abstractions/organization/organization.service.abstraction";
import { OrganizationUserType } from "../../../admin-console/enums";
import { Organization } from "../../../admin-console/models/domain/organization";
import { TokenService } from "../../../auth/abstractions/token.service";
import { FeatureFlag } from "../../../enums/feature-flag.enum";
import { KeysRequest } from "../../../models/request/keys.request";
import { LogService } from "../../../platform/abstractions/log.service";
import { ConfigService } from "../../../platform/abstractions/config/config.service";
import { RegisterSdkService } from "../../../platform/abstractions/sdk/register-sdk.service";
import { asUuid } from "../../../platform/abstractions/sdk/sdk.service";
import { Utils } from "../../../platform/misc/utils";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { KEY_CONNECTOR_DISK, StateProvider, UserKeyDefinition } from "../../../platform/state";
import { UserId } from "../../../types/guid";
import { MasterKey } from "../../../types/key";
import { MasterKey, UserKey } from "../../../types/key";
import { AccountCryptographicStateService } from "../../account-cryptography/account-cryptographic-state.service";
import { KeyGenerationService } from "../../crypto";
import { EncString } from "../../crypto/models/enc-string";
import { InternalMasterPasswordServiceAbstraction } from "../../master-password/abstractions/master-password.service.abstraction";
import { SecurityStateService } from "../../security-state/abstractions/security-state.service";
import { SignedPublicKey, SignedSecurityState, WrappedSigningKey } from "../../types";
import { KeyConnectorService as KeyConnectorServiceAbstraction } from "../abstractions/key-connector.service";
import { KeyConnectorDomainConfirmation } from "../models/key-connector-domain-confirmation";
import { KeyConnectorUserKeyRequest } from "../models/key-connector-user-key.request";
@@ -75,6 +89,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
private keyGenerationService: KeyGenerationService,
private logoutCallback: (logoutReason: LogoutReason, userId?: string) => Promise<void>,
private stateProvider: StateProvider,
private configService: ConfigService,
private registerSdkService: RegisterSdkService,
private securityStateService: SecurityStateService,
private accountCryptographicStateService: AccountCryptographicStateService,
) {
this.convertAccountRequired$ = accountService.activeAccount$.pipe(
filter((account) => account != null),
@@ -152,8 +170,106 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
throw new Error("Key Connector conversion not found");
}
const { kdfConfig, keyConnectorUrl, organizationId } = conversion;
const { kdfConfig, keyConnectorUrl, organizationId: ssoOrganizationIdentifier } = conversion;
if (
await firstValueFrom(
this.configService.getFeatureFlag$(
FeatureFlag.EnableAccountEncryptionV2KeyConnectorRegistration,
),
)
) {
await this.convertNewSsoUserToKeyConnectorV2(
userId,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
} else {
await this.convertNewSsoUserToKeyConnectorV1(
userId,
kdfConfig,
keyConnectorUrl,
ssoOrganizationIdentifier,
);
}
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async convertNewSsoUserToKeyConnectorV2(
userId: UserId,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const result = await firstValueFrom(
this.registerSdkService.registerClient$(userId).pipe(
map((sdk) => {
if (!sdk) {
throw new Error("SDK not available");
}
using ref = sdk.take();
return ref.value
.auth()
.registration()
.post_keys_for_key_connector_registration(
keyConnectorUrl,
ssoOrganizationIdentifier,
asUuid(userId),
);
}),
),
);
if (!("V2" in result.account_cryptographic_state)) {
const version = Object.keys(result.account_cryptographic_state);
throw new Error(`Unexpected account cryptographic state version ${version}`);
}
await this.masterPasswordService.setMasterKey(
SymmetricCryptoKey.fromString(result.key_connector_key) as MasterKey,
userId,
);
await this.keyService.setUserKey(
SymmetricCryptoKey.fromString(result.user_key) as UserKey,
userId,
);
await this.masterPasswordService.setMasterKeyEncryptedUserKey(
new EncString(result.key_connector_key_wrapped_user_key),
userId,
);
await this.accountCryptographicStateService.setAccountCryptographicState(
result.account_cryptographic_state,
userId,
);
// Legacy states
await this.keyService.setPrivateKey(result.account_cryptographic_state.V2.private_key, userId);
await this.keyService.setUserSigningKey(
result.account_cryptographic_state.V2.signing_key as WrappedSigningKey,
userId,
);
await this.securityStateService.setAccountSecurityState(
result.account_cryptographic_state.V2.security_state as SignedSecurityState,
userId,
);
if (result.account_cryptographic_state.V2.signed_public_key != null) {
await this.keyService.setSignedPublicKey(
result.account_cryptographic_state.V2.signed_public_key as SignedPublicKey,
userId,
);
}
}
async convertNewSsoUserToKeyConnectorV1(
userId: UserId,
kdfConfig: KdfConfig,
keyConnectorUrl: string,
ssoOrganizationIdentifier: string,
) {
const password = await this.keyGenerationService.createKey(512);
const masterKey = await this.keyService.makeMasterKey(
@@ -182,14 +298,10 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
const setPasswordRequest = new SetKeyConnectorKeyRequest(
userKey[1].encryptedString,
kdfConfig,
organizationId,
ssoOrganizationIdentifier,
keys,
);
await this.apiService.postSetKeyConnectorKey(setPasswordRequest);
await this.stateProvider
.getUser(userId, NEW_SSO_USER_KEY_CONNECTOR_CONVERSION)
.update(() => null);
}
async setNewSsoUserKeyConnectorConversionData(
@@ -202,9 +314,16 @@ export class KeyConnectorService implements KeyConnectorServiceAbstraction {
}
requiresDomainConfirmation$(userId: UserId): Observable<KeyConnectorDomainConfirmation | null> {
return this.stateProvider
.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId)
.pipe(map((data) => (data != null ? { keyConnectorUrl: data.keyConnectorUrl } : null)));
return this.stateProvider.getUserState$(NEW_SSO_USER_KEY_CONNECTOR_CONVERSION, userId).pipe(
map((data) =>
data != null
? {
keyConnectorUrl: data.keyConnectorUrl,
organizationSsoIdentifier: data.organizationId,
}
: null,
),
);
}
private handleKeyConnectorError(e: any) {

View File

@@ -1,3 +1,5 @@
import { SignedPublicKey, WrappedAccountCryptographicState } from "@bitwarden/sdk-internal";
import { SecurityStateResponse } from "../../security-state/response/security-state.response";
import { PublicKeyEncryptionKeyPairResponse } from "./public-key-encryption-key-pair.response";
@@ -52,4 +54,31 @@ export class PrivateKeysResponseModel {
);
}
}
toWrappedAccountCryptographicState(): WrappedAccountCryptographicState {
if (this.signatureKeyPair === null && this.securityState === null) {
// V1 user
return {
V1: {
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
},
};
} else if (this.signatureKeyPair !== null && this.securityState !== null) {
// V2 user
return {
V2: {
private_key: this.publicKeyEncryptionKeyPair.wrappedPrivateKey,
signing_key: this.signatureKeyPair.wrappedSigningKey,
signed_public_key: this.publicKeyEncryptionKeyPair.signedPublicKey as SignedPublicKey,
security_state: this.securityState.securityState as string,
},
};
} else {
throw new Error("Both signatureKeyPair and securityState must be present or absent together");
}
}
isV2Encryption(): boolean {
return this.signatureKeyPair !== null && this.securityState !== null;
}
}

View File

@@ -106,6 +106,30 @@ export abstract class MasterPasswordServiceAbstraction {
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
) => Promise<UserKey>;
/**
* Returns whether the user has a master password set.
* @param userId The user ID.
* @throws If the user ID is missing.
*/
abstract userHasMasterPassword(userId: UserId): Promise<boolean>;
/**
* Derives a master key from the provided password and master password unlock data,
* then sets it to state for the specified user. This is a temporary backwards compatibility function
* to support existing code that relies on direct master key access.
* Note: This will be removed in https://bitwarden.atlassian.net/browse/PM-30676
*
* @param password The master password.
* @param masterPasswordUnlockData The master password unlock data containing the KDF settings and salt.
* @param userId The user ID.
* @throws If the password, master password unlock data, or user ID is missing.
*/
abstract setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void>;
}
export abstract class InternalMasterPasswordServiceAbstraction extends MasterPasswordServiceAbstraction {

View File

@@ -33,6 +33,10 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
this.masterKeyHashSubject.next(initialMasterKeyHash);
}
userHasMasterPassword(userId: UserId): Promise<boolean> {
return this.mock.userHasMasterPassword(userId);
}
emailToSalt(email: string): MasterPasswordSalt {
return this.mock.emailToSalt(email);
}
@@ -123,4 +127,12 @@ export class FakeMasterPasswordService implements InternalMasterPasswordServiceA
masterPasswordUnlockData$(userId: UserId): Observable<MasterPasswordUnlockData | null> {
return this.mock.masterPasswordUnlockData$(userId);
}
setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
return this.mock.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
}
}

View File

@@ -3,6 +3,7 @@ import { firstValueFrom } from "rxjs";
import { Jsonify } from "type-fest";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// eslint-disable-next-line no-restricted-imports
import { Argon2KdfConfig, KdfConfig, KdfType, PBKDF2KdfConfig } from "@bitwarden/key-management";
@@ -415,6 +416,125 @@ describe("MasterPasswordService", () => {
);
});
describe("setLegacyMasterKeyFromUnlockData", () => {
const password = "test-password";
it("derives master key from password and sets it in state", async () => {
const masterKey = makeSymmetricCryptoKey(32, 5) as MasterKey;
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
);
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
expect(state).toEqual(masterKey);
});
it("works with argon2 kdf config", async () => {
const masterKey = makeSymmetricCryptoKey(32, 6) as MasterKey;
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(new Uint8Array(32));
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfArgon2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(keyGenerationService.deriveKeyFromPassword).toHaveBeenCalledWith(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
);
const state = await firstValueFrom(stateProvider.getUser(userId, MASTER_KEY).state$);
expect(state).toEqual(masterKey);
});
it("computes and sets master key hash in state", async () => {
const masterKey = makeSymmetricCryptoKey(32, 7) as MasterKey;
const expectedHashBytes = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
const expectedHashB64 = "AQIDBAUGBwg=";
keyGenerationService.deriveKeyFromPassword.mockResolvedValue(masterKey);
cryptoFunctionService.pbkdf2.mockResolvedValue(expectedHashBytes);
jest.spyOn(Utils, "fromBufferToB64").mockReturnValue(expectedHashB64);
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await sut.setLegacyMasterKeyFromUnlockData(password, masterPasswordUnlockData, userId);
expect(cryptoFunctionService.pbkdf2).toHaveBeenCalledWith(
masterKey.inner().encryptionKey,
password,
"sha256",
HashPurpose.LocalAuthorization,
);
const hashState = await firstValueFrom(sut.masterKeyHash$(userId));
expect(hashState).toEqual(expectedHashB64);
});
it("throws if password is null", async () => {
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await expect(
sut.setLegacyMasterKeyFromUnlockData(
null as unknown as string,
masterPasswordUnlockData,
userId,
),
).rejects.toThrow("password is null or undefined.");
});
it("throws if masterPasswordUnlockData is null", async () => {
await expect(
sut.setLegacyMasterKeyFromUnlockData(
password,
null as unknown as MasterPasswordUnlockData,
userId,
),
).rejects.toThrow("masterPasswordUnlockData is null or undefined.");
});
it("throws if userId is null", async () => {
const masterPasswordUnlockData = new MasterPasswordUnlockData(
salt,
kdfPBKDF2,
makeEncString().toSdk() as MasterKeyWrappedUserKey,
);
await expect(
sut.setLegacyMasterKeyFromUnlockData(
password,
masterPasswordUnlockData,
null as unknown as UserId,
),
).rejects.toThrow("userId is null or undefined.");
});
});
describe("MASTER_PASSWORD_UNLOCK_KEY", () => {
it("has the correct configuration", () => {
expect(MASTER_PASSWORD_UNLOCK_KEY.stateDefinition).toBeDefined();

View File

@@ -5,6 +5,7 @@ import { firstValueFrom, map, Observable } from "rxjs";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { assertNonNullish } from "@bitwarden/common/auth/utils";
import { SdkLoadService } from "@bitwarden/common/platform/abstractions/sdk/sdk-load.service";
import { HashPurpose } from "@bitwarden/common/platform/enums";
import { Utils } from "@bitwarden/common/platform/misc/utils";
// eslint-disable-next-line no-restricted-imports
import { KdfConfig } from "@bitwarden/key-management";
@@ -25,6 +26,7 @@ import { MasterKey, UserKey } from "../../../types/key";
import { KeyGenerationService } from "../../crypto";
import { CryptoFunctionService } from "../../crypto/abstractions/crypto-function.service";
import { EncryptedString, EncString } from "../../crypto/models/enc-string";
import { USES_KEY_CONNECTOR } from "../../key-connector/services/key-connector.service";
import { InternalMasterPasswordServiceAbstraction } from "../abstractions/master-password.service.abstraction";
import {
MasterKeyWrappedUserKey,
@@ -85,6 +87,19 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
private accountService: AccountService,
) {}
async userHasMasterPassword(userId: UserId): Promise<boolean> {
assertNonNullish(userId, "userId");
// A user has a master-password if they have a master-key encrypted user key *but* are not a key connector user
// Note: We can't use the key connector service as an abstraction here because it causes a run-time dependency injection cycle between KC service and MP service.
const usesKeyConnector = await firstValueFrom(
this.stateProvider.getUser(userId, USES_KEY_CONNECTOR).state$,
);
const usesMasterKey = await firstValueFrom(
this.stateProvider.getUser(userId, MASTER_KEY_ENCRYPTED_USER_KEY).state$,
);
return usesMasterKey && !usesKeyConnector;
}
saltForUser$(userId: UserId): Observable<MasterPasswordSalt> {
assertNonNullish(userId, "userId");
return this.accountService.accounts$.pipe(
@@ -307,6 +322,7 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
masterPasswordUnlockData.kdf.toSdkConfig(),
),
);
return userKey as UserKey;
}
@@ -327,4 +343,51 @@ export class MasterPasswordService implements InternalMasterPasswordServiceAbstr
return this.stateProvider.getUser(userId, MASTER_PASSWORD_UNLOCK_KEY).state$;
}
async setLegacyMasterKeyFromUnlockData(
password: string,
masterPasswordUnlockData: MasterPasswordUnlockData,
userId: UserId,
): Promise<void> {
assertNonNullish(password, "password");
assertNonNullish(masterPasswordUnlockData, "masterPasswordUnlockData");
assertNonNullish(userId, "userId");
const masterKey = (await this.keyGenerationService.deriveKeyFromPassword(
password,
masterPasswordUnlockData.salt,
masterPasswordUnlockData.kdf,
)) as MasterKey;
const localKeyHash = await this.hashMasterKey(
password,
masterKey,
HashPurpose.LocalAuthorization,
);
await this.setMasterKey(masterKey, userId);
await this.setMasterKeyHash(localKeyHash, userId);
}
// Copied from KeyService to avoid circular dependency. This will be dropped together with `setLegacyMatserKeyFromUnlockData`.
private async hashMasterKey(
password: string,
key: MasterKey,
hashPurpose: HashPurpose,
): Promise<string> {
if (password == null) {
throw new Error("password is required.");
}
if (key == null) {
throw new Error("key is required.");
}
const iterations = hashPurpose === HashPurpose.LocalAuthorization ? 2 : 1;
const hash = await this.cryptoFunctionService.pbkdf2(
key.inner().encryptionKey,
password,
"sha256",
iterations,
);
return Utils.fromBufferToB64(hash);
}
}

Some files were not shown because too many files have changed in this diff Show More