1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-18 02:19:18 +00:00

Merge branch 'main' into auth/pm-9115/implement-view-data-persistence-in-2FA-flows

This commit is contained in:
Alec Rippberger
2025-04-01 18:42:48 -05:00
committed by GitHub
108 changed files with 1609 additions and 522 deletions

View File

@@ -35,7 +35,7 @@
{{ "open" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'unpaid'">
<i class="bwi bwi-exclamation-circle tw-text-muted" aria-hidden="true"></i>
<i class="bwi bwi-error tw-text-muted" aria-hidden="true"></i>
{{ "unpaid" | i18n | titlecase }}
</span>
<span *ngIf="expandedInvoiceStatus === 'paid'">

View File

@@ -4,48 +4,48 @@ import { ErrorHandler, LOCALE_ID, NgModule } from "@angular/core";
import { Subject } from "rxjs";
import {
OrganizationUserApiService,
DefaultOrganizationUserApiService,
CollectionService,
DefaultCollectionService,
DefaultOrganizationUserApiService,
OrganizationUserApiService,
} from "@bitwarden/admin-console/common";
import {
SetPasswordJitService,
DefaultSetPasswordJitService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
DefaultRegistrationFinishService,
AnonLayoutWrapperDataService,
DefaultAnonLayoutWrapperDataService,
LoginComponentService,
DefaultLoginApprovalComponentService,
DefaultLoginComponentService,
LoginDecryptionOptionsService,
DefaultLoginDecryptionOptionsService,
TwoFactorAuthComponentService,
DefaultRegistrationFinishService,
DefaultSetPasswordJitService,
DefaultTwoFactorAuthComponentService,
DefaultTwoFactorAuthEmailComponentService,
TwoFactorAuthEmailComponentService,
DefaultTwoFactorAuthWebAuthnComponentService,
LoginComponentService,
LoginDecryptionOptionsService,
RegistrationFinishService as RegistrationFinishServiceAbstraction,
SetPasswordJitService,
TwoFactorAuthComponentService,
TwoFactorAuthEmailComponentService,
TwoFactorAuthWebAuthnComponentService,
DefaultLoginApprovalComponentService,
} from "@bitwarden/auth/angular";
import {
AuthRequestServiceAbstraction,
AuthRequestService,
PinServiceAbstraction,
PinService,
LoginStrategyServiceAbstraction,
LoginStrategyService,
LoginEmailServiceAbstraction,
LoginEmailService,
InternalUserDecryptionOptionsServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
LogoutReason,
AuthRequestApiService,
AuthRequestService,
AuthRequestServiceAbstraction,
DefaultAuthRequestApiService,
DefaultLoginSuccessHandlerService,
LoginSuccessHandlerService,
InternalUserDecryptionOptionsServiceAbstraction,
LoginApprovalComponentServiceAbstraction,
LoginEmailService,
LoginEmailServiceAbstraction,
LoginStrategyService,
LoginStrategyServiceAbstraction,
LoginSuccessHandlerService,
LogoutReason,
PinService,
PinServiceAbstraction,
UserDecryptionOptionsService,
UserDecryptionOptionsServiceAbstraction,
} from "@bitwarden/auth/common";
import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service";
import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service";
@@ -118,16 +118,16 @@ import { WebAuthnLoginApiService } from "@bitwarden/common/auth/services/webauth
import { WebAuthnLoginPrfKeyService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login-prf-key.service";
import { WebAuthnLoginService } from "@bitwarden/common/auth/services/webauthn-login/webauthn-login.service";
import {
AutofillSettingsServiceAbstraction,
AutofillSettingsService,
AutofillSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/autofill-settings.service";
import {
BadgeSettingsServiceAbstraction,
BadgeSettingsService,
BadgeSettingsServiceAbstraction,
} from "@bitwarden/common/autofill/services/badge-settings.service";
import {
DomainSettingsService,
DefaultDomainSettingsService,
DomainSettingsService,
} from "@bitwarden/common/autofill/services/domain-settings.service";
import {
BillingApiServiceAbstraction,
@@ -200,8 +200,8 @@ import {
WebPushNotificationsApiService,
} from "@bitwarden/common/platform/notifications/internal";
import {
TaskSchedulerService,
DefaultTaskSchedulerService,
TaskSchedulerService,
} from "@bitwarden/common/platform/scheduling";
import { AppIdService } from "@bitwarden/common/platform/services/app-id.service";
import { ConfigApiService } from "@bitwarden/common/platform/services/config/config-api.service";
@@ -222,10 +222,10 @@ import { ValidationService } from "@bitwarden/common/platform/services/validatio
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import {
ActiveUserStateProvider,
DerivedStateProvider,
GlobalStateProvider,
SingleUserStateProvider,
StateProvider,
DerivedStateProvider,
} from "@bitwarden/common/platform/state";
/* eslint-disable import/no-restricted-paths -- We need the implementations to inject, but generally these should not be accessed */
import { DefaultActiveUserStateProvider } from "@bitwarden/common/platform/state/implementations/default-active-user-state.provider";
@@ -280,6 +280,7 @@ import { FolderApiService } from "@bitwarden/common/vault/services/folder/folder
import { FolderService } from "@bitwarden/common/vault/services/folder/folder.service";
import { TotpService } from "@bitwarden/common/vault/services/totp.service";
import { VaultSettingsService } from "@bitwarden/common/vault/services/vault-settings/vault-settings.service";
import { DefaultTaskService, TaskService } from "@bitwarden/common/vault/tasks";
import { ToastService } from "@bitwarden/components";
import {
GeneratorHistoryService,
@@ -292,34 +293,32 @@ import {
UsernameGenerationServiceAbstraction,
} from "@bitwarden/generator-legacy";
import {
KeyService,
DefaultKeyService,
BiometricsService,
BiometricStateService,
DefaultBiometricStateService,
BiometricsService,
DefaultKdfConfigService,
KdfConfigService,
UserAsymmetricKeysRegenerationService,
DefaultUserAsymmetricKeysRegenerationService,
UserAsymmetricKeysRegenerationApiService,
DefaultKeyService,
DefaultUserAsymmetricKeysRegenerationApiService,
DefaultUserAsymmetricKeysRegenerationService,
KdfConfigService,
KeyService,
UserAsymmetricKeysRegenerationApiService,
UserAsymmetricKeysRegenerationService,
} from "@bitwarden/key-management";
import { SafeInjectionToken } from "@bitwarden/ui-common";
import {
DefaultTaskService,
DefaultEndUserNotificationService,
EndUserNotificationService,
NewDeviceVerificationNoticeService,
PasswordRepromptService,
TaskService,
} from "@bitwarden/vault";
import {
VaultExportService,
VaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
IndividualVaultExportService,
IndividualVaultExportServiceAbstraction,
OrganizationVaultExportService,
OrganizationVaultExportServiceAbstraction,
VaultExportService,
VaultExportServiceAbstraction,
} from "@bitwarden/vault-export-core";
import { DeviceTrustToastService as DeviceTrustToastServiceAbstraction } from "../auth/services/device-trust-toast.service.abstraction";
@@ -334,24 +333,24 @@ import { AbstractThemingService } from "../platform/services/theming/theming.ser
import { safeProvider, SafeProvider } from "../platform/utils/safe-provider";
import {
CLIENT_TYPE,
DEFAULT_VAULT_TIMEOUT,
ENV_ADDITIONAL_REGIONS,
INTRAPROCESS_MESSAGING_SUBJECT,
LOCALES_DIRECTORY,
LOCKED_CALLBACK,
LOGOUT_CALLBACK,
LOG_MAC_FAILURES,
LOGOUT_CALLBACK,
MEMORY_STORAGE,
OBSERVABLE_DISK_STORAGE,
OBSERVABLE_MEMORY_STORAGE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
SECURE_STORAGE,
STATE_FACTORY,
SUPPORTS_SECURE_STORAGE,
SYSTEM_LANGUAGE,
SYSTEM_THEME_OBSERVABLE,
WINDOW,
DEFAULT_VAULT_TIMEOUT,
INTRAPROCESS_MESSAGING_SUBJECT,
CLIENT_TYPE,
REFRESH_ACCESS_TOKEN_ERROR_CALLBACK,
ENV_ADDITIONAL_REGIONS,
} from "./injection-tokens";
import { ModalService } from "./modal.service";

View File

@@ -0,0 +1,38 @@
import { mock } from "jest-mock-extended";
import { ServerConfig } from "../platform/abstractions/config/server-config";
import { getFeatureFlagValue, FeatureFlag, DefaultFeatureFlagValue } from "./feature-flag.enum";
describe("getFeatureFlagValue", () => {
const testFlag = Object.values(FeatureFlag)[0];
const testFlagDefaultValue = DefaultFeatureFlagValue[testFlag];
it("returns default flag value when serverConfig is null", () => {
const result = getFeatureFlagValue(null, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns default flag value when serverConfig.featureStates is undefined", () => {
const serverConfig = {} as ServerConfig;
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns default flag value when the feature flag is not in serverConfig.featureStates", () => {
const serverConfig = mock<ServerConfig>();
serverConfig.featureStates = {};
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(testFlagDefaultValue);
});
it("returns the flag value from serverConfig.featureStates when the feature flag exists", () => {
const expectedValue = true;
const serverConfig = mock<ServerConfig>();
serverConfig.featureStates = { [testFlag]: expectedValue };
const result = getFeatureFlagValue(serverConfig, testFlag);
expect(result).toBe(expectedValue);
});
});

View File

@@ -1,3 +1,5 @@
import { ServerConfig } from "../platform/abstractions/config/server-config";
/**
* Feature flags.
*
@@ -125,3 +127,14 @@ export const DefaultFeatureFlagValue = {
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
export type FeatureFlagValueType<Flag extends FeatureFlag> = DefaultFeatureFlagValueType[Flag];
export function getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
}

View File

@@ -1,10 +1,13 @@
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export abstract class BulkEncryptService {
abstract decryptItems<T extends InitializerMetadata>(
items: Decryptable<T>[],
key: SymmetricCryptoKey,
): Promise<T[]>;
abstract onServerConfigChange(newConfig: ServerConfig): void;
}

View File

@@ -1,9 +1,10 @@
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { Encrypted } from "@bitwarden/common/platform/interfaces/encrypted";
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { EncArrayBuffer } from "@bitwarden/common/platform/models/domain/enc-array-buffer";
import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { Encrypted } from "../../../platform/interfaces/encrypted";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { EncArrayBuffer } from "../../../platform/models/domain/enc-array-buffer";
import { EncString } from "../../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export abstract class EncryptService {
abstract encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString>;
@@ -54,4 +55,6 @@ export abstract class EncryptService {
value: string | Uint8Array,
algorithm: "sha1" | "sha256" | "sha512",
): Promise<string>;
abstract onServerConfigChange(newConfig: ServerConfig): void;
}

View File

@@ -0,0 +1,170 @@
import { mock, MockProxy } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { InitializerMetadata } from "../../../platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { buildSetConfigMessage } from "../types/worker-command.type";
import { BulkEncryptServiceImplementation } from "./bulk-encrypt.service.implementation";
describe("BulkEncryptServiceImplementation", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<LogService>();
let sut: BulkEncryptServiceImplementation;
beforeEach(() => {
sut = new BulkEncryptServiceImplementation(cryptoFunctionService, logService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const serverConfig = mock<ServerConfig>();
const mockWorker = mock<Worker>();
let globalWindow: any;
beforeEach(() => {
globalWindow = global.window;
// Mock creating a worker.
global.Worker = jest.fn().mockImplementation(() => mockWorker);
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
global.URL.revokeObjectURL = jest.fn();
global.URL.canParse = jest.fn().mockReturnValue(true);
// Mock the workers returned response.
const mockMessageEvent = {
id: "mock-guid",
data: ["decrypted1", "decrypted2"],
};
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
});
afterEach(() => {
global.window = globalWindow;
});
it("throws error if key is null", async () => {
const nullKey = null as unknown as SymmetricCryptoKey;
await expect(sut.decryptItems([], nullKey)).rejects.toThrow("No encryption key provided.");
});
it("returns an empty array when items is null", async () => {
const result = await sut.decryptItems(null as any, key);
expect(result).toEqual([]);
});
it("returns an empty array when items is empty", async () => {
const result = await sut.decryptItems([], key);
expect(result).toEqual([]);
});
it("decrypts items sequentially when window is undefined", async () => {
// Make global window undefined.
delete (global as any).window;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
const result = await sut.decryptItems(mockItems, key);
expect(logService.info).toHaveBeenCalledWith(
"Window not available in BulkEncryptService, decrypting sequentially",
);
expect(result).toEqual(["item1", "item2"]);
expect(mockItems[0].decrypt).toHaveBeenCalledWith(key);
expect(mockItems[1].decrypt).toHaveBeenCalledWith(key);
});
it("uses workers for decryption when window is available", async () => {
const mockDecryptedItems = ["decrypted1", "decrypted2"];
jest
.spyOn<any, any>(sut, "getDecryptedItemsFromWorkers")
.mockResolvedValue(mockDecryptedItems);
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
const result = await sut.decryptItems(mockItems, key);
expect(sut["getDecryptedItemsFromWorkers"]).toHaveBeenCalledWith(mockItems, key);
expect(result).toEqual(mockDecryptedItems);
});
it("creates new worker when none exist", async () => {
(sut as any).currentServerConfig = undefined;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
(sut as any).currentServerConfig = serverConfig;
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("does not create worker if one exists", async () => {
(sut as any).currentServerConfig = serverConfig;
(sut as any).workers = [mockWorker];
const mockItems = [createMockDecryptable("item1"), createMockDecryptable("item2")];
await sut.decryptItems(mockItems, key);
expect(global.Worker).not.toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config", () => {
const newConfig = mock<ServerConfig>();
sut.onServerConfigChange(newConfig);
expect((sut as any).currentServerConfig).toBe(newConfig);
});
it("does send a SetConfigMessage to workers when there is a worker", () => {
const newConfig = mock<ServerConfig>();
const mockWorker = mock<Worker>();
(sut as any).workers = [mockWorker];
sut.onServerConfigChange(newConfig);
expect(mockWorker.postMessage).toHaveBeenCalledWith(buildSetConfigMessage({ newConfig }));
});
});
});
function createMockDecryptable<T extends InitializerMetadata>(
returnValue: any,
): MockProxy<Decryptable<T>> {
const mockDecryptable = mock<Decryptable<T>>();
mockDecryptable.decrypt.mockResolvedValue(returnValue);
return mockDecryptable;
}

View File

@@ -12,6 +12,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
const workerTTL = 60000; // 1 minute
const maxWorkers = 8;
@@ -20,6 +23,7 @@ const minNumberOfItemsForMultithreading = 400;
export class BulkEncryptServiceImplementation implements BulkEncryptService {
private workers: Worker[] = [];
private timeout: any;
private currentServerConfig: ServerConfig | undefined = undefined;
private clear$ = new Subject<void>();
@@ -57,6 +61,11 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
return decryptedItems;
}
onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
this.updateWorkerServerConfigs(newConfig);
}
/**
* Sends items to a set of web workers to decrypt them. This utilizes multiple workers to decrypt items
* faster without interrupting other operations (e.g. updating UI).
@@ -93,6 +102,9 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
),
);
}
if (this.currentServerConfig != undefined) {
this.updateWorkerServerConfigs(this.currentServerConfig);
}
}
const itemsPerWorker = Math.floor(items.length / this.workers.length);
@@ -108,17 +120,18 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
itemsForWorker.push(...items.slice(end));
}
const request = {
id: Utils.newGuid(),
const id = Utils.newGuid();
const request = buildDecryptMessage({
id,
items: itemsForWorker,
key: key,
};
});
worker.postMessage(JSON.stringify(request));
worker.postMessage(request);
results.push(
firstValueFrom(
fromEvent(worker, "message").pipe(
filter((response: MessageEvent) => response.data?.id === request.id),
filter((response: MessageEvent) => response.data?.id === id),
map((response) => JSON.parse(response.data.items)),
map((items) =>
items.map((jsonItem: Jsonify<T>) => {
@@ -143,6 +156,13 @@ export class BulkEncryptServiceImplementation implements BulkEncryptService {
return decryptedItems;
}
private updateWorkerServerConfigs(newConfig: ServerConfig) {
this.workers.forEach((worker) => {
const request = buildSetConfigMessage({ newConfig });
worker.postMessage(request);
});
}
private clear() {
this.clear$.next();
for (const worker of this.workers) {

View File

@@ -15,6 +15,7 @@ import { EncString } from "@bitwarden/common/platform/models/domain/enc-string";
import { EncryptedObject } from "@bitwarden/common/platform/models/domain/encrypted-object";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { EncryptService } from "../abstractions/encrypt.service";
export class EncryptServiceImplementation implements EncryptService {
@@ -24,6 +25,11 @@ export class EncryptServiceImplementation implements EncryptService {
protected logMacFailures: boolean,
) {}
// Handle updating private properties to turn on/off feature flags.
onServerConfigChange(newConfig: ServerConfig): void {
return;
}
async encrypt(plainValue: string | Uint8Array, key: SymmetricCryptoKey): Promise<EncString> {
if (key == null) {
throw new Error("No encryption key provided.");

View File

@@ -2,12 +2,19 @@
// @ts-strict-ignore
import { Jsonify } from "type-fest";
import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service";
import { ContainerService } from "@bitwarden/common/platform/services/container.service";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { WebCryptoFunctionService } from "@bitwarden/common/platform/services/web-crypto-function.service";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { ConsoleLogService } from "../../../platform/services/console-log.service";
import { ContainerService } from "../../../platform/services/container.service";
import { getClassInitializer } from "../../../platform/services/cryptography/get-class-initializer";
import { WebCryptoFunctionService } from "../../../platform/services/web-crypto-function.service";
import {
DECRYPT_COMMAND,
SET_CONFIG_COMMAND,
ParsedDecryptCommandData,
} from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
@@ -15,13 +22,14 @@ const workerApi: Worker = self as any;
let inited = false;
let encryptService: EncryptServiceImplementation;
let logService: LogService;
/**
* Bootstrap the worker environment with services required for decryption
*/
export function init() {
const cryptoFunctionService = new WebCryptoFunctionService(self);
const logService = new ConsoleLogService(false);
logService = new ConsoleLogService(false);
encryptService = new EncryptServiceImplementation(cryptoFunctionService, logService, true);
const bitwardenContainerService = new ContainerService(null, encryptService);
@@ -39,11 +47,22 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
}
const request: {
id: string;
items: Jsonify<Decryptable<any>>[];
key: Jsonify<SymmetricCryptoKey>;
command: string;
} = JSON.parse(event.data);
switch (request.command) {
case DECRYPT_COMMAND:
return await handleDecrypt(request as unknown as ParsedDecryptCommandData);
case SET_CONFIG_COMMAND: {
const newConfig = (request as unknown as { newConfig: Jsonify<ServerConfig> }).newConfig;
return await handleSetConfig(newConfig);
}
default:
logService.error(`[EncryptWorker] unknown worker command`, request.command, request);
}
});
async function handleDecrypt(request: ParsedDecryptCommandData) {
const key = SymmetricCryptoKey.fromJSON(request.key);
const items = request.items.map((jsonItem) => {
const initializer = getClassInitializer<Decryptable<any>>(jsonItem.initializerKey);
@@ -55,4 +74,8 @@ workerApi.addEventListener("message", async (event: { data: string }) => {
id: request.id,
items: JSON.stringify(result),
});
});
}
async function handleSetConfig(newConfig: Jsonify<ServerConfig>) {
encryptService.onServerConfigChange(ServerConfig.fromJSON(newConfig));
}

View File

@@ -0,0 +1,97 @@
import { mock } from "jest-mock-extended";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { BulkEncryptService } from "../abstractions/bulk-encrypt.service";
import { EncryptService } from "../abstractions/encrypt.service";
import { FallbackBulkEncryptService } from "./fallback-bulk-encrypt.service";
describe("FallbackBulkEncryptService", () => {
const encryptService = mock<EncryptService>();
const featureFlagEncryptService = mock<BulkEncryptService>();
const serverConfig = mock<ServerConfig>();
let sut: FallbackBulkEncryptService;
beforeEach(() => {
sut = new FallbackBulkEncryptService(encryptService);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const mockItems = [{ id: "guid", name: "encryptedValue" }] as any[];
const mockDecryptedItems = [{ id: "guid", name: "decryptedValue" }] as any[];
it("calls decryptItems on featureFlagEncryptService when it is set", async () => {
featureFlagEncryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
const result = await sut.decryptItems(mockItems, key);
expect(featureFlagEncryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
expect(encryptService.decryptItems).not.toHaveBeenCalled();
expect(result).toEqual(mockDecryptedItems);
});
it("calls decryptItems on encryptService when featureFlagEncryptService is not set", async () => {
encryptService.decryptItems.mockResolvedValue(mockDecryptedItems);
const result = await sut.decryptItems(mockItems, key);
expect(encryptService.decryptItems).toHaveBeenCalledWith(mockItems, key);
expect(result).toEqual(mockDecryptedItems);
});
});
describe("setFeatureFlagEncryptService", () => {
it("sets the featureFlagEncryptService property", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
it("does not call onServerConfigChange when currentServerConfig is undefined", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect(featureFlagEncryptService.onServerConfigChange).not.toHaveBeenCalled();
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
it("calls onServerConfigChange with currentServerConfig when it is defined", async () => {
sut.onServerConfigChange(serverConfig);
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
expect((sut as any).featureFlagEncryptService).toBe(featureFlagEncryptService);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config", async () => {
sut.onServerConfigChange(serverConfig);
expect((sut as any).currentServerConfig).toBe(serverConfig);
});
it("calls onServerConfigChange on featureFlagEncryptService when it is set", async () => {
await sut.setFeatureFlagEncryptService(featureFlagEncryptService);
sut.onServerConfigChange(serverConfig);
expect(featureFlagEncryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
expect(encryptService.onServerConfigChange).not.toHaveBeenCalled();
});
it("calls onServerConfigChange on encryptService when featureFlagEncryptService is not set", () => {
sut.onServerConfigChange(serverConfig);
expect(encryptService.onServerConfigChange).toHaveBeenCalledWith(serverConfig);
});
});
});

View File

@@ -5,6 +5,7 @@ import { Decryptable } from "@bitwarden/common/platform/interfaces/decryptable.i
import { InitializerMetadata } from "@bitwarden/common/platform/interfaces/initializer-metadata.interface";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { EncryptService } from "../abstractions/encrypt.service";
/**
@@ -12,6 +13,7 @@ import { EncryptService } from "../abstractions/encrypt.service";
*/
export class FallbackBulkEncryptService implements BulkEncryptService {
private featureFlagEncryptService: BulkEncryptService;
private currentServerConfig: ServerConfig | undefined = undefined;
constructor(protected encryptService: EncryptService) {}
@@ -31,6 +33,14 @@ export class FallbackBulkEncryptService implements BulkEncryptService {
}
async setFeatureFlagEncryptService(featureFlagEncryptService: BulkEncryptService) {
if (this.currentServerConfig !== undefined) {
featureFlagEncryptService.onServerConfigChange(this.currentServerConfig);
}
this.featureFlagEncryptService = featureFlagEncryptService;
}
onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
(this.featureFlagEncryptService ?? this.encryptService).onServerConfigChange(newConfig);
}
}

View File

@@ -0,0 +1,123 @@
import { mock } from "jest-mock-extended";
import * as rxjs from "rxjs";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { CryptoFunctionService } from "../../../platform/abstractions/crypto-function.service";
import { LogService } from "../../../platform/abstractions/log.service";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import { buildSetConfigMessage } from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
import { MultithreadEncryptServiceImplementation } from "./multithread-encrypt.service.implementation";
describe("MultithreadEncryptServiceImplementation", () => {
const cryptoFunctionService = mock<CryptoFunctionService>();
const logService = mock<LogService>();
const serverConfig = mock<ServerConfig>();
let sut: MultithreadEncryptServiceImplementation;
beforeEach(() => {
sut = new MultithreadEncryptServiceImplementation(cryptoFunctionService, logService, true);
});
afterEach(() => {
jest.resetAllMocks();
});
describe("decryptItems", () => {
const key = mock<SymmetricCryptoKey>();
const mockWorker = mock<Worker>();
beforeEach(() => {
// Mock creating a worker.
global.Worker = jest.fn().mockImplementation(() => mockWorker);
global.URL = jest.fn().mockImplementation(() => "url") as unknown as typeof URL;
global.URL.createObjectURL = jest.fn().mockReturnValue("blob:url");
global.URL.revokeObjectURL = jest.fn();
global.URL.canParse = jest.fn().mockReturnValue(true);
// Mock the workers returned response.
const mockMessageEvent = {
id: "mock-guid",
data: ["decrypted1", "decrypted2"],
};
const mockMessageEvent$ = rxjs.from([mockMessageEvent]);
jest.spyOn(rxjs, "fromEvent").mockReturnValue(mockMessageEvent$);
});
it("returns empty array if items is null", async () => {
const items = null as unknown as Decryptable<any>[];
const result = await sut.decryptItems(items, key);
expect(result).toEqual([]);
});
it("returns empty array if items is empty", async () => {
const result = await sut.decryptItems([], key);
expect(result).toEqual([]);
});
it("creates worker if none exists", async () => {
// Make sure currentServerConfig is undefined so a SetConfigMessage is not sent.
(sut as any).currentServerConfig = undefined;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("sends a SetConfigMessage to the new worker when there is a current server config", async () => {
// Populate currentServerConfig so a SetConfigMessage is sent.
(sut as any).currentServerConfig = serverConfig;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(2);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
it("does not create worker if one exists", async () => {
(sut as any).currentServerConfig = serverConfig;
(sut as any).worker = mockWorker;
await sut.decryptItems([mock<Decryptable<any>>(), mock<Decryptable<any>>()], key);
expect(global.Worker).not.toHaveBeenCalled();
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).not.toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
describe("onServerConfigChange", () => {
it("updates internal currentServerConfig to new config and calls super", () => {
const superSpy = jest.spyOn(EncryptServiceImplementation.prototype, "onServerConfigChange");
sut.onServerConfigChange(serverConfig);
expect(superSpy).toHaveBeenCalledWith(serverConfig);
expect((sut as any).currentServerConfig).toBe(serverConfig);
});
it("sends config update to worker if worker exists", () => {
const mockWorker = mock<Worker>();
(sut as any).worker = mockWorker;
sut.onServerConfigChange(serverConfig);
expect(mockWorker.postMessage).toHaveBeenCalledTimes(1);
expect(mockWorker.postMessage).toHaveBeenCalledWith(
buildSetConfigMessage({ newConfig: serverConfig }),
);
});
});
});

View File

@@ -9,6 +9,9 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
import { SymmetricCryptoKey } from "@bitwarden/common/platform/models/domain/symmetric-crypto-key";
import { getClassInitializer } from "@bitwarden/common/platform/services/cryptography/get-class-initializer";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { buildDecryptMessage, buildSetConfigMessage } from "../types/worker-command.type";
import { EncryptServiceImplementation } from "./encrypt.service.implementation";
// TTL (time to live) is not strictly required but avoids tying up memory resources if inactive
@@ -20,6 +23,7 @@ const workerTTL = 3 * 60000; // 3 minutes
export class MultithreadEncryptServiceImplementation extends EncryptServiceImplementation {
private worker: Worker;
private timeout: any;
private currentServerConfig: ServerConfig | undefined = undefined;
private clear$ = new Subject<void>();
@@ -37,27 +41,33 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
this.logService.info("Starting decryption using multithreading");
this.worker ??= new Worker(
new URL(
/* webpackChunkName: 'encrypt-worker' */
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
import.meta.url,
),
);
if (this.worker == null) {
this.worker = new Worker(
new URL(
/* webpackChunkName: 'encrypt-worker' */
"@bitwarden/common/key-management/crypto/services/encrypt.worker.ts",
import.meta.url,
),
);
if (this.currentServerConfig !== undefined) {
this.updateWorkerServerConfig(this.currentServerConfig);
}
}
this.restartTimeout();
const request = {
id: Utils.newGuid(),
const id = Utils.newGuid();
const request = buildDecryptMessage({
id,
items: items,
key: key,
};
});
this.worker.postMessage(JSON.stringify(request));
this.worker.postMessage(request);
return await firstValueFrom(
fromEvent(this.worker, "message").pipe(
filter((response: MessageEvent) => response.data?.id === request.id),
filter((response: MessageEvent) => response.data?.id === id),
map((response) => JSON.parse(response.data.items)),
map((items) =>
items.map((jsonItem: Jsonify<T>) => {
@@ -71,6 +81,19 @@ export class MultithreadEncryptServiceImplementation extends EncryptServiceImple
);
}
override onServerConfigChange(newConfig: ServerConfig): void {
this.currentServerConfig = newConfig;
super.onServerConfigChange(newConfig);
this.updateWorkerServerConfig(newConfig);
}
private updateWorkerServerConfig(newConfig: ServerConfig) {
if (this.worker != null) {
const request = buildSetConfigMessage({ newConfig });
this.worker.postMessage(request);
}
}
private clear() {
this.clear$.next();
this.worker?.terminate();

View File

@@ -0,0 +1,67 @@
import { mock } from "jest-mock-extended";
import { makeStaticByteArray } from "../../../../spec";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
import {
DECRYPT_COMMAND,
DecryptCommandData,
SET_CONFIG_COMMAND,
buildDecryptMessage,
buildSetConfigMessage,
} from "./worker-command.type";
describe("Worker command types", () => {
describe("buildDecryptMessage", () => {
it("builds a message with the correct command", () => {
const commandData = createDecryptCommandData();
const result = buildDecryptMessage(commandData);
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
});
it("includes the provided data in the message", () => {
const mockItems = [{ encrypted: "test-encrypted" } as unknown as Decryptable<any>];
const commandData = createDecryptCommandData(mockItems);
const result = buildDecryptMessage(commandData);
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(DECRYPT_COMMAND);
expect(parsedResult.id).toBe("test-id");
expect(parsedResult.items).toEqual(mockItems);
expect(SymmetricCryptoKey.fromJSON(parsedResult.key)).toEqual(commandData.key);
});
});
describe("buildSetConfigMessage", () => {
it("builds a message with the correct command", () => {
const result = buildSetConfigMessage({ newConfig: mock<ServerConfig>() });
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
});
it("includes the provided data in the message", () => {
const serverConfig = { version: "test-version" } as unknown as ServerConfig;
const result = buildSetConfigMessage({ newConfig: serverConfig });
const parsedResult = JSON.parse(result);
expect(parsedResult.command).toBe(SET_CONFIG_COMMAND);
expect(ServerConfig.fromJSON(parsedResult.newConfig).version).toEqual(serverConfig.version);
});
});
});
function createDecryptCommandData(items?: Decryptable<any>[]): DecryptCommandData {
return {
id: "test-id",
items: items ?? [],
key: new SymmetricCryptoKey(makeStaticByteArray(64)),
};
}

View File

@@ -0,0 +1,36 @@
import { Jsonify } from "type-fest";
import { ServerConfig } from "../../../platform/abstractions/config/server-config";
import { Decryptable } from "../../../platform/interfaces/decryptable.interface";
import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key";
export const DECRYPT_COMMAND = "decrypt";
export const SET_CONFIG_COMMAND = "updateConfig";
export type DecryptCommandData = {
id: string;
items: Decryptable<any>[];
key: SymmetricCryptoKey;
};
export type ParsedDecryptCommandData = {
id: string;
items: Jsonify<Decryptable<any>>[];
key: Jsonify<SymmetricCryptoKey>;
};
type SetConfigCommandData = { newConfig: ServerConfig };
export function buildDecryptMessage(data: DecryptCommandData): string {
return JSON.stringify({
command: DECRYPT_COMMAND,
...data,
});
}
export function buildSetConfigMessage(data: SetConfigCommandData): string {
return JSON.stringify({
command: SET_CONFIG_COMMAND,
...data,
});
}

View File

@@ -17,11 +17,7 @@ import { SemVer } from "semver";
import { AuthService } from "../../../auth/abstractions/auth.service";
import { AuthenticationStatus } from "../../../auth/enums/authentication-status";
import {
DefaultFeatureFlagValue,
FeatureFlag,
FeatureFlagValueType,
} from "../../../enums/feature-flag.enum";
import { FeatureFlag, getFeatureFlagValue } from "../../../enums/feature-flag.enum";
import { UserId } from "../../../types/guid";
import { ConfigApiServiceAbstraction } from "../../abstractions/config/config-api.service.abstraction";
import { ConfigService } from "../../abstractions/config/config.service";
@@ -123,26 +119,13 @@ export class DefaultConfigService implements ConfigService {
}
getFeatureFlag$<Flag extends FeatureFlag>(key: Flag) {
return this.serverConfig$.pipe(
map((serverConfig) => this.getFeatureFlagValue(serverConfig, key)),
);
}
private getFeatureFlagValue<Flag extends FeatureFlag>(
serverConfig: ServerConfig | null,
flag: Flag,
) {
if (serverConfig?.featureStates == null || serverConfig.featureStates[flag] == null) {
return DefaultFeatureFlagValue[flag];
}
return serverConfig.featureStates[flag] as FeatureFlagValueType<Flag>;
return this.serverConfig$.pipe(map((serverConfig) => getFeatureFlagValue(serverConfig, key)));
}
userCachedFeatureFlag$<Flag extends FeatureFlag>(key: Flag, userId: UserId) {
return this.stateProvider
.getUser(userId, USER_SERVER_CONFIG)
.state$.pipe(map((config) => this.getFeatureFlagValue(config, key)));
.state$.pipe(map((config) => getFeatureFlagValue(config, key)));
}
async getFeatureFlag<Flag extends FeatureFlag>(key: Flag) {

View File

@@ -206,3 +206,4 @@ export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk");
export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk");
export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk");
export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk");
export const VAULT_NUDGES_DISK = new StateDefinition("vaultNudges", "disk");

View File

@@ -31,7 +31,7 @@ export abstract class CipherService implements UserKeyRotationDataProvider<Ciphe
*
* An empty array indicates that all ciphers were successfully decrypted.
*/
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[]>;
abstract failedToDecryptCiphers$(userId: UserId): Observable<CipherView[] | null>;
abstract clearCache(userId: UserId): Promise<void>;
abstract encrypt(
model: CipherView,

View File

@@ -1,7 +1,8 @@
import { Observable } from "rxjs";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { SecurityTask } from "@bitwarden/vault";
import { SecurityTask } from "../models";
export abstract class TaskService {
/**

View File

@@ -1,19 +1,18 @@
import { TestBed } from "@angular/core/testing";
import { BehaviorSubject, firstValueFrom } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { DefaultTaskService, SecurityTaskStatus } from "@bitwarden/vault";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec";
import { SecurityTaskData } from "../models/security-task.data";
import { SecurityTaskResponse } from "../models/security-task.response";
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
import { SecurityTaskStatus } from "../enums";
import { SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
import { DefaultTaskService } from "./default-task.service";
describe("Default task service", () => {
let fakeStateProvider: FakeStateProvider;
@@ -21,7 +20,7 @@ describe("Default task service", () => {
const mockGetAllOrgs$ = jest.fn();
const mockGetFeatureFlag$ = jest.fn();
let testBed: TestBed;
let service: DefaultTaskService;
beforeEach(async () => {
mockApiSend.mockClear();
@@ -29,34 +28,12 @@ describe("Default task service", () => {
mockGetFeatureFlag$.mockClear();
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
testBed = TestBed.configureTestingModule({
imports: [],
providers: [
DefaultTaskService,
{
provide: ConfigService,
useValue: {
getFeatureFlag$: mockGetFeatureFlag$,
},
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: ApiService,
useValue: {
send: mockApiSend,
},
},
{
provide: OrganizationService,
useValue: {
organizations$: mockGetAllOrgs$,
},
},
],
});
service = new DefaultTaskService(
fakeStateProvider,
{ send: mockApiSend } as unknown as ApiService,
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
);
});
describe("tasksEnabled$", () => {
@@ -73,7 +50,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -93,7 +70,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -110,7 +87,7 @@ describe("Default task service", () => {
] as Organization[]),
);
const { tasksEnabled$ } = testBed.inject(DefaultTaskService);
const { tasksEnabled$ } = service;
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
@@ -130,7 +107,7 @@ describe("Default task service", () => {
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any);
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
@@ -145,7 +122,7 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const result = await firstValueFrom(tasks$("user-id" as UserId));
@@ -154,7 +131,7 @@ describe("Default task service", () => {
});
it("should share the same observable for the same user", async () => {
const { tasks$ } = testBed.inject(DefaultTaskService);
const { tasks$ } = service;
const first = tasks$("user-id" as UserId);
const second = tasks$("user-id" as UserId);
@@ -176,7 +153,7 @@ describe("Default task service", () => {
},
] as SecurityTaskData[]);
const { pendingTasks$ } = testBed.inject(DefaultTaskService);
const { pendingTasks$ } = service;
const result = await firstValueFrom(pendingTasks$("user-id" as UserId));
@@ -195,8 +172,6 @@ describe("Default task service", () => {
] as SecurityTaskResponse[],
});
const service = testBed.inject(DefaultTaskService);
await service.refreshTasks("user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
@@ -217,8 +192,6 @@ describe("Default task service", () => {
null as any,
);
const service = testBed.inject(DefaultTaskService);
await service.refreshTasks("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([
@@ -237,8 +210,6 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const service = testBed.inject(DefaultTaskService);
await service.clear("user-id" as UserId);
expect(mock.nextMock).toHaveBeenCalledWith([]);
@@ -247,8 +218,6 @@ describe("Default task service", () => {
describe("markAsComplete()", () => {
it("should send an API request to mark the task as complete", async () => {
const service = testBed.inject(DefaultTaskService);
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith(
@@ -278,8 +247,6 @@ describe("Default task service", () => {
} as SecurityTaskData,
]);
const service = testBed.inject(DefaultTaskService);
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);

View File

@@ -1,4 +1,3 @@
import { Injectable } from "@angular/core";
import { combineLatest, map, switchMap } from "rxjs";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
@@ -8,14 +7,13 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { StateProvider } from "@bitwarden/common/platform/state";
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
import { SecurityTask, SecurityTaskStatus, TaskService } from "@bitwarden/vault";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { SecurityTaskData } from "../models/security-task.data";
import { SecurityTaskResponse } from "../models/security-task.response";
import { TaskService } from "../abstractions/task.service";
import { SecurityTaskStatus } from "../enums";
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
import { SECURITY_TASKS } from "../state/security-task.state";
@Injectable()
export class DefaultTaskService implements TaskService {
constructor(
private stateProvider: StateProvider,

View File

@@ -48,11 +48,12 @@ export class MenuTriggerForDirective implements OnDestroy {
overlayX: "start",
overlayY: "top",
},
// Fallback position: show above the trigger
{
originX: "end",
originY: "bottom",
overlayX: "end",
overlayY: "top",
originX: "start",
originY: "top",
overlayX: "start",
overlayY: "bottom",
},
])
.withLockedPosition(true)

View File

@@ -14,11 +14,8 @@ or an options menu icon.
| Icon | bwi-name | Usage |
| -------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <i class="bwi bwi-ban"></i> | bwi-ban | option or feature not available. Example: send maximum access count was reached |
| <i class="bwi bwi-check"></i> | bwi-check | confirmation action (Example: "confirm member"), successful confirmation (toast or callout), or shows currently selected option in a menu. Use with success color variable if applicable. |
| <i class="bwi bwi-error"></i> | bwi-error | error; used in form field error states and error toasts, banners, and callouts. Do not use as a close or clear icon. Use with danger color variable. |
| <i class="bwi bwi-expired"></i> | bwi-expired | - |
| <i class="bwi bwi-exclamation-circle"></i> | bwi-exclamation-circle | deprecated error icon; use bwi-error |
| <i class="bwi bwi-exclamation-triangle"></i> | bwi-exclamation-triangle | warning; used in warning callouts, banners, and toasts. Use with warning color variable. |
| <i class="bwi bwi-info-circle"></i> | bwi-info-circle | information; used in info callouts, banners, and toasts. Use with info color variable. |
| <i class="bwi bwi-question-circle"></i> | bwi-question-circle | link to help documentation or hover tooltip |
@@ -26,34 +23,31 @@ or an options menu icon.
## Bitwarden Objects
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | --------------------------------------------------- |
| <i class="bwi bwi-authenticator"></i> | bwi-authenticator | authenticator app |
| <i class="bwi bwi-business"></i> | bwi-business | organization or vault for Free, Teams or Enterprise |
| <i class="bwi bwi-collection"></i> | bwi-collection | collection |
| <i class="bwi bwi-credit-card"></i> | bwi-credit-card | card item type |
| <i class="bwi bwi-family"></i> | bwi-family | family vault or organization |
| <i class="bwi bwi-folder"></i> | bwi-folder | folder |
| <i class="bwi bwi-globe"></i> | bwi-globe | login item type |
| <i class="bwi bwi-id-card"></i> | bwi-id-card | identity item type |
| <i class="bwi bwi-send"></i> | bwi-send | send action or feature |
| <i class="bwi bwi-send-f"></i> | bwi-send-f | - |
| <i class="bwi bwi-sticky-note"></i> | bwi-sticky-note | secure note item type |
| <i class="bwi bwi-users"></i> | bwi-users | user group |
| <i class="bwi bwi-vault"></i> | bwi-vault | general vault |
| <i class="bwi bwi-vault-f"></i> | bwi-vault-f | general vault |
| Icon | bwi-name | Usage |
| ----------------------------------- | --------------- | --------------------------------------------------- |
| <i class="bwi bwi-business"></i> | bwi-business | organization or vault for Free, Teams or Enterprise |
| <i class="bwi bwi-collection"></i> | bwi-collection | collection |
| <i class="bwi bwi-credit-card"></i> | bwi-credit-card | card item type |
| <i class="bwi bwi-family"></i> | bwi-family | family vault or organization |
| <i class="bwi bwi-folder"></i> | bwi-folder | folder |
| <i class="bwi bwi-globe"></i> | bwi-globe | login item type |
| <i class="bwi bwi-id-card"></i> | bwi-id-card | identity item type |
| <i class="bwi bwi-send"></i> | bwi-send | send action or feature |
| <i class="bwi bwi-send-f"></i> | bwi-send-f | - |
| <i class="bwi bwi-sticky-note"></i> | bwi-sticky-note | secure note item type |
| <i class="bwi bwi-users"></i> | bwi-users | user group |
| <i class="bwi bwi-vault"></i> | bwi-vault | general vault |
| <i class="bwi bwi-vault-f"></i> | bwi-vault-f | general vault |
## Actions
| Icon | bwi-name | Usage |
| -------------------------------------- | ------------------ | -------------------------------------------- |
| <i class="bwi bwi-check-circle"></i> | bwi-check-circle | check if password has been exposed |
| <i class="bwi bwi-check-square"></i> | bwi-check-square | select all action |
| <i class="bwi bwi-clone"></i> | bwi-clone | copy to clipboard action |
| <i class="bwi bwi-close"></i> | bwi-close | close action |
| <i class="bwi bwi-cog"></i> | bwi-cog | settings |
| <i class="bwi bwi-cog-f"></i> | bwi-cog-f | settings |
| <i class="bwi bwi-cogs"></i> | bwi-cogs | deprecated; do not use in app. |
| <i class="bwi bwi-download"></i> | bwi-download | download or export |
| <i class="bwi bwi-envelope"></i> | bwi-envelope | action related to emailing a user |
| <i class="bwi bwi-external-link"></i> | bwi-external-link | open in new window or popout |
@@ -66,141 +60,81 @@ or an options menu icon.
| <i class="bwi bwi-lock-encrypted"></i> | bwi-lock-encrypted | - |
| <i class="bwi bwi-lock-f"></i> | bwi-lock-f | - |
| <i class="bwi bwi-minus-circle"></i> | bwi-minus-circle | remove action |
| <i class="bwi bwi-minus-square"></i> | bwi-minus-square | unselect all action |
| <i class="bwi bwi-paste"></i> | bwi-paste | paste from clipboard action |
| <i class="bwi bwi-pencil-square"></i> | bwi-pencil-square | edit action |
| <i class="bwi bwi-popout"></i> | bwi-popout | popout action |
| <i class="bwi bwi-play"></i> | bwi-play | start or play action |
| <i class="bwi bwi-plus"></i> | bwi-plus | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-f"></i> | bwi-plus-f | new or add option in contained buttons/links |
| <i class="bwi bwi-plus-circle"></i> | bwi-plus-circle | new or add option in text buttons/links |
| <i class="bwi bwi-plus-square"></i> | bwi-plus-square | - |
| <i class="bwi bwi-refresh"></i> | bwi-refresh | "re"-action; such as refresh or regenerate |
| <i class="bwi bwi-refresh-tab"></i> | bwi-refresh-tab | - |
| <i class="bwi bwi-save"></i> | bwi-save | alternate download action |
| <i class="bwi bwi-save-changes"></i> | bwi-save-changes | save changes action |
| <i class="bwi bwi-search"></i> | bwi-search | search action |
| <i class="bwi bwi-share"></i> | bwi-share | - |
| <i class="bwi bwi-share-arrow"></i> | bwi-share-arrow | - |
| <i class="bwi bwi-share-square"></i> | bwi-share-square | avoid using; use external-link instead |
| <i class="bwi bwi-sign-in"></i> | bwi-sign-in | sign-in action |
| <i class="bwi bwi-sign-out"></i> | bwi-sign-out | sign-out action |
| <i class="bwi bwi-star"></i> | bwi-star | favorite action |
| <i class="bwi bwi-star-f"></i> | bwi-star-f | favorited / unfavorite action |
| <i class="bwi bwi-stop"></i> | bwi-stop | stop action |
| <i class="bwi bwi-trash"></i> | bwi-trash | delete action or trash area |
| <i class="bwi bwi-undo"></i> | bwi-undo | restore action |
| <i class="bwi bwi-unlock"></i> | bwi-unlock | unlocked |
## Directional and Menu Indicators
| Icon | bwi-name | Usage |
| ------------------------------------------ | ---------------------- | ------------------------------------------------------- |
| <i class="bwi bwi-angle-down"></i> | bwi-angle-down | closed dropdown or open expandable section |
| <i class="bwi bwi-angle-left"></i> | bwi-angle-left | - |
| <i class="bwi bwi-angle-right"></i> | bwi-angle-right | closed expandable section |
| <i class="bwi bwi-angle-up"></i> | bwi-angle-up | open dropdown |
| <i class="bwi bwi-arrow-circle-down"></i> | bwi-arrow-circle-down | - |
| <i class="bwi bwi-arrow-circle-left"></i> | bwi-arrow-circle-left | - |
| <i class="bwi bwi-arrow-circle-right"></i> | bwi-arrow-circle-right | - |
| <i class="bwi bwi-arrow-circle-up"></i> | bwi-arrow-circle-up | - |
| <i class="bwi bwi-back"></i> | bwi-back | back arrow |
| <i class="bwi bwi-caret-down"></i> | bwi-caret-down | table sort order |
| <i class="bwi bwi-caret-right"></i> | bwi-caret-right | - |
| <i class="bwi bwi-caret-up"></i> | bwi-caret-up | table sort order |
| <i class="bwi bwi-dbl-angle-left"></i> | bwi-dbl-angle-left | - |
| <i class="bwi bwi-dbl-angle-right"></i> | bwi-dbl-angle-right | - |
| <i class="bwi bwi-down-solid"></i> | bwi-down-solid | table sort order |
| <i class="bwi bwi-ellipsis-h"></i> | bwi-ellipsis-h | more options menu horizontal; used in mobile list items |
| <i class="bwi bwi-ellipsis-v"></i> | bwi-ellipsis-v | more options menu vertical; used primarily in tables |
| <i class="bwi bwi-filter"></i> | bwi-filter | Product switcher |
| <i class="bwi bwi-hamburger"></i> | bwi-hamburger | navigation indicator |
| <i class="bwi bwi-list"></i> | bwi-list | toggle list/grid view |
| <i class="bwi bwi-list-alt"></i> | bwi-list-alt | view item action in extension |
| <i class="bwi bwi-long-arrow-right"></i> | bwi-long-arrow-right | - |
| <i class="bwi bwi-numbered-list"></i> | bwi-numbered-list | toggle numbered list view |
| <i class="bwi bwi-up-down-btn"></i> | bwi-up-down-btn | table sort order |
| <i class="bwi bwi-up-solid"></i> | bwi-up-solid | table sort order |
| Icon | bwi-name | Usage |
| ------------------------------------- | ----------------- | ------------------------------------------------------- |
| <i class="bwi bwi-angle-down"></i> | bwi-angle-down | closed dropdown or open expandable section |
| <i class="bwi bwi-angle-left"></i> | bwi-angle-left | - |
| <i class="bwi bwi-angle-right"></i> | bwi-angle-right | closed expandable section |
| <i class="bwi bwi-angle-up"></i> | bwi-angle-up | open dropdown |
| <i class="bwi bwi-back"></i> | bwi-back | back arrow |
| <i class="bwi bwi-down-solid"></i> | bwi-down-solid | table sort order |
| <i class="bwi bwi-ellipsis-h"></i> | bwi-ellipsis-h | more options menu horizontal; used in mobile list items |
| <i class="bwi bwi-ellipsis-v"></i> | bwi-ellipsis-v | more options menu vertical; used primarily in tables |
| <i class="bwi bwi-filter"></i> | bwi-filter | Product switcher |
| <i class="bwi bwi-hamburger"></i> | bwi-hamburger | navigation indicator |
| <i class="bwi bwi-list"></i> | bwi-list | toggle list/grid view |
| <i class="bwi bwi-list-alt"></i> | bwi-list-alt | view item action in extension |
| <i class="bwi bwi-numbered-list"></i> | bwi-numbered-list | toggle numbered list view |
| <i class="bwi bwi-up-down-btn"></i> | bwi-up-down-btn | table sort order |
| <i class="bwi bwi-up-solid"></i> | bwi-up-solid | table sort order |
## Misc Objects
| Icon | bwi-name | Usage |
| ----------------------------------------- | --------------------- | ---------------------------------------------- |
| <i class="bwi bwi-bank"></i> | bwi-bank | - |
| <i class="bwi bwi-billing"></i> | bwi-billing | billing options |
| <i class="bwi bwi-bitcoin"></i> | bwi-bitcoin | crypto |
| <i class="bwi bwi-bolt"></i> | bwi-bolt | deprecated "danger" icon |
| <i class="bwi bwi-bookmark"></i> | bwi-bookmark | bookmark or save related actions |
| <i class="bwi bwi-browser"></i> | bwi-browser | web browser |
| <i class="bwi bwi-browser-alt"></i> | bwi-browser-alt | web browser |
| <i class="bwi bwi-bug"></i> | bwi-bug | test or debug action |
| <i class="bwi bwi-camera"></i> | bwi-camera | actions related to camera use |
| <i class="bwi bwi-chain-broken"></i> | bwi-chain-broken | unlink action |
| <i class="bwi bwi-chat"></i> | bwi-chat | - |
| <i class="bwi bwi-cli"></i> | bwi-cli | cli client or code |
| <i class="bwi bwi-clock"></i> | bwi-clock | use for time based actions or views |
| <i class="bwi bwi-community"></i> | bwi-community | - |
| <i class="bwi bwi-cut"></i> | bwi-cut | cut or omit actions |
| <i class="bwi bwi-dashboard"></i> | bwi-dashboard | statuses or dashboard views |
| <i class="bwi bwi-desktop"></i> | bwi-desktop | desktop client |
| <i class="bwi bwi-desktop-alt"></i> | bwi-desktop-alt | desktop client |
| <i class="bwi bwi-dollar"></i> | bwi-dollar | account credit |
| <i class="bwi bwi-file"></i> | bwi-file | file related objects or actions |
| <i class="bwi bwi-file-pdf"></i> | bwi-file-pdf | PDF related object or actions |
| <i class="bwi bwi-file-text"></i> | bwi-file-text | text related objects or actions |
| <i class="bwi bwi-fingerprint"></i> | bwi-fingerprint | - |
| <i class="bwi bwi-bw-folder-open-f1"></i> | bwi-bw-folder-open-f1 | - |
| <i class="bwi bwi-folder-closed-f"></i> | bwi-folder-closed-f | - |
| <i class="bwi bwi-folder-open"></i> | bwi-folder-open | - |
| <i class="bwi bwi-frown"></i> | bwi-frown | - |
| <i class="bwi bwi-hashtag"></i> | bwi-hashtag | link to specific id |
| <i class="bwi bwi-icon-1"></i> | bwi-icon-1 | - |
| <i class="bwi bwi-icon-2"></i> | bwi-icon-2 | - |
| <i class="bwi bwi-icon-3"></i> | bwi-icon-3 | - |
| <i class="bwi bwi-icon-4"></i> | bwi-icon-4 | - |
| <i class="bwi bwi-icon-5"></i> | bwi-icon-5 | - |
| <i class="bwi bwi-icon-6"></i> | bwi-icon-6 | - |
| <i class="bwi bwi-icon-7"></i> | bwi-icon-7 | - |
| <i class="bwi bwi-icon-8"></i> | bwi-icon-8 | - |
| <i class="bwi bwi-icon-9"></i> | bwi-icon-9 | - |
| <i class="bwi bwi-insurance"></i> | bwi-insurance | - |
| <i class="bwi bwi-key"></i> | bwi-key | key or password related objects or actions |
| <i class="bwi bwi-learning"></i> | bwi-learning | learning center |
| <i class="bwi bwi-lightbulb"></i> | bwi-lightbulb | - |
| <i class="bwi bwi-link"></i> | bwi-link | link action |
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-mobile-alt"></i> | bwi-mobile-alt | mobile client |
| <i class="bwi bwi-money"></i> | bwi-money | - |
| <i class="bwi bwi-msp"></i> | bwi-msp | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-passkey"></i> | bwi-passkey | passkey |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-providers"></i> | bwi-providers | - |
| <i class="bwi bwi-puzzle"></i> | bwi-puzzle | - |
| <i class="bwi bwi-rocket"></i> | bwi-rocket | - |
| <i class="bwi bwi-rss"></i> | bwi-rss | - |
| <i class="bwi bwi-search-book"></i> | bwi-search-book | - |
| <i class="bwi bwi-server"></i> | bwi-server | - |
| <i class="bwi bwi-shield"></i> | bwi-shield | - |
| <i class="bwi bwi-sitemap"></i> | bwi-sitemap | - |
| <i class="bwi bwi-sliders"></i> | bwi-sliders | reporting or filtering |
| <i class="bwi bwi-software-license"></i> | bwi-software-license | - |
| <i class="bwi bwi-square"></i> | bwi-square | - |
| <i class="bwi bwi-tag"></i> | bwi-tag | labels |
| <i class="bwi bwi-thumb-tack"></i> | bwi-thumb-tack | - |
| <i class="bwi bwi-thumbs-up"></i> | bwi-thumbs-up | - |
| <i class="bwi bwi-totp-codes"></i> | bwi-totp-codes | - |
| <i class="bwi bwi-totp-codes-alt"></i> | bwi-totp-codes-alt | - |
| <i class="bwi bwi-totp-codes-alt2"></i> | bwi-totp-codes-alt2 | - |
| <i class="bwi bwi-universal-access"></i> | bwi-universal-access | use for accessibility related actions |
| <i class="bwi bwi-user"></i> | bwi-user | relates to current user or organization member |
| <i class="bwi bwi-user-circle"></i> | bwi-user-circle | - |
| <i class="bwi bwi-user-f"></i> | bwi-user-f | - |
| <i class="bwi bwi-user-monitor"></i> | bwi-user-monitor | - |
| <i class="bwi bwi-wand"></i> | bwi-wand | - |
| <i class="bwi bwi-wireless"></i> | bwi-wireless | - |
| <i class="bwi bwi-wrench"></i> | bwi-wrench | tools or additional configuration options |
| Icon | bwi-name | Usage |
| ---------------------------------------- | -------------------- | ---------------------------------------------- |
| <i class="bwi bwi-billing"></i> | bwi-billing | billing options |
| <i class="bwi bwi-bitcoin"></i> | bwi-bitcoin | crypto |
| <i class="bwi bwi-browser"></i> | bwi-browser | web browser |
| <i class="bwi bwi-browser-alt"></i> | bwi-browser-alt | web browser |
| <i class="bwi bwi-bug"></i> | bwi-bug | test or debug action |
| <i class="bwi bwi-camera"></i> | bwi-camera | actions related to camera use |
| <i class="bwi bwi-cli"></i> | bwi-cli | cli client or code |
| <i class="bwi bwi-clock"></i> | bwi-clock | use for time based actions or views |
| <i class="bwi bwi-community"></i> | bwi-community | - |
| <i class="bwi bwi-desktop"></i> | bwi-desktop | desktop client |
| <i class="bwi bwi-dollar"></i> | bwi-dollar | account credit |
| <i class="bwi bwi-file"></i> | bwi-file | file related objects or actions |
| <i class="bwi bwi-file-text"></i> | bwi-file-text | text related objects or actions |
| <i class="bwi bwi-hashtag"></i> | bwi-hashtag | link to specific id |
| <i class="bwi bwi-key"></i> | bwi-key | key or password related objects or actions |
| <i class="bwi bwi-link"></i> | bwi-link | link action |
| <i class="bwi bwi-mobile"></i> | bwi-mobile | mobile client |
| <i class="bwi bwi-msp"></i> | bwi-msp | - |
| <i class="bwi bwi-paperclip"></i> | bwi-paperclip | attachments |
| <i class="bwi bwi-passkey"></i> | bwi-passkey | passkey |
| <i class="bwi bwi-pencil"></i> | bwi-pencil | editing |
| <i class="bwi bwi-provider"></i> | bwi-provider | relates to provider or provider portal |
| <i class="bwi bwi-puzzle"></i> | bwi-puzzle | - |
| <i class="bwi bwi-shield"></i> | bwi-shield | - |
| <i class="bwi bwi-sliders"></i> | bwi-sliders | reporting or filtering |
| <i class="bwi bwi-tag"></i> | bwi-tag | labels |
| <i class="bwi bwi-totp-codes"></i> | bwi-totp-codes | - |
| <i class="bwi bwi-totp-codes-alt"></i> | bwi-totp-codes-alt | - |
| <i class="bwi bwi-totp-codes-alt2"></i> | bwi-totp-codes-alt2 | - |
| <i class="bwi bwi-universal-access"></i> | bwi-universal-access | use for accessibility related actions |
| <i class="bwi bwi-user"></i> | bwi-user | relates to current user or organization member |
| <i class="bwi bwi-user-monitor"></i> | bwi-user-monitor | - |
| <i class="bwi bwi-wireless"></i> | bwi-wireless | - |
| <i class="bwi bwi-wrench"></i> | bwi-wrench | tools or additional configuration options |
## Platforms and Logos

View File

@@ -736,6 +736,52 @@ describe("keyService", () => {
});
});
describe("getOrDeriveMasterKey", () => {
it("returns the master key if it is already available", async () => {
const getMasterKey = jest
.spyOn(masterPasswordService, "masterKey$")
.mockReturnValue(of("masterKey" as any));
const result = await keyService.getOrDeriveMasterKey("password", mockUserId);
expect(getMasterKey).toHaveBeenCalledWith(mockUserId);
expect(result).toEqual("masterKey");
});
it("derives the master key if it is not available", async () => {
const getMasterKey = jest
.spyOn(masterPasswordService, "masterKey$")
.mockReturnValue(of(null as any));
const deriveKeyFromPassword = jest
.spyOn(keyGenerationService, "deriveKeyFromPassword")
.mockResolvedValue("mockMasterKey" as any);
kdfConfigService.getKdfConfig$.mockReturnValue(of("mockKdfConfig" as any));
const result = await keyService.getOrDeriveMasterKey("password", mockUserId);
expect(getMasterKey).toHaveBeenCalledWith(mockUserId);
expect(deriveKeyFromPassword).toHaveBeenCalledWith("password", "email", "mockKdfConfig");
expect(result).toEqual("mockMasterKey");
});
it("throws an error if no user is found", async () => {
accountService.activeAccountSubject.next(null);
await expect(keyService.getOrDeriveMasterKey("password")).rejects.toThrow("No user found");
});
it("throws an error if no kdf config is found", async () => {
jest.spyOn(masterPasswordService, "masterKey$").mockReturnValue(of(null as any));
kdfConfigService.getKdfConfig$.mockReturnValue(of(null));
await expect(keyService.getOrDeriveMasterKey("password", mockUserId)).rejects.toThrow(
"No kdf found for user",
);
});
});
describe("compareKeyHash", () => {
type TestCase = {
masterKey: MasterKey;

View File

@@ -287,10 +287,15 @@ export class DefaultKeyService implements KeyServiceAbstraction {
),
);
const masterKey = await firstValueFrom(this.masterPasswordService.masterKey$(resolvedUserId));
return (
masterKey ||
(await this.makeMasterKey(password, email, await this.kdfConfigService.getKdfConfig()))
);
if (masterKey != null) {
return masterKey;
}
const kdf = await firstValueFrom(this.kdfConfigService.getKdfConfig$(resolvedUserId));
if (kdf == null) {
throw new Error("No kdf found for user");
}
return await this.makeMasterKey(password, email, kdf);
}
/**

View File

@@ -10,6 +10,7 @@
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
cdkDrag
[cdkDragDisabled]="uriControls.length <= 1"
[formControlName]="i"
(remove)="removeUri(i)"
(onKeydown)="onUriItemKeydown($event, i)"

View File

@@ -20,10 +20,10 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FolderView } from "@bitwarden/common/vault/models/view/folder.view";
import { SecurityTaskType, TaskService } from "@bitwarden/common/vault/tasks";
import { AnchorLinkDirective, CalloutModule, SearchModule } from "@bitwarden/components";
import { ChangeLoginPasswordService } from "../abstractions/change-login-password.service";
import { TaskService, SecurityTaskType } from "../tasks";
import { AdditionalOptionsComponent } from "./additional-options/additional-options.component";
import { AttachmentsV2ViewComponent } from "./attachments/attachments-v2-view.component";

View File

@@ -6,8 +6,6 @@ export { OrgIconDirective } from "./components/org-icon.directive";
export { CanDeleteCipherDirective } from "./components/can-delete-cipher.directive";
export { DarkImageSourceDirective } from "./components/dark-image-source.directive";
export * from "./utils/observable-utilities";
export * from "./cipher-view";
export * from "./cipher-form";
export {
@@ -25,8 +23,8 @@ export * from "./components/add-edit-folder-dialog/add-edit-folder-dialog.compon
export * from "./components/carousel";
export * as VaultIcons from "./icons";
export * from "./tasks";
export * from "./notifications";
export * from "./services/vault-nudges.service";
export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service";
export { SshImportPromptService } from "./services/ssh-import-prompt.service";

View File

@@ -7,8 +7,11 @@ import { ListResponse } from "@bitwarden/common/models/response/list.response";
import { NotificationsService } from "@bitwarden/common/platform/notifications";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import {
filterOutNullish,
perUserCache$,
} from "@bitwarden/common/vault/utils/observable-utilities";
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
import { EndUserNotificationService } from "../abstractions/end-user-notification.service";
import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models";
import { NOTIFICATIONS } from "../state/end-user-notification.state";

View File

@@ -0,0 +1,30 @@
import { inject, Injectable } from "@angular/core";
import { map, Observable, of, switchMap } from "rxjs";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
import { DefaultSingleNudgeService } from "../default-single-nudge.service";
import { VaultNudgeType } from "../vault-nudges.service";
/**
* Custom Nudge Service to use for the Onboarding Nudges in the Vault
*/
@Injectable({
providedIn: "root",
})
export class HasItemsNudgeService extends DefaultSingleNudgeService {
cipherService = inject(CipherService);
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
return this.isDismissed$(nudgeType, userId).pipe(
switchMap((dismissed) =>
dismissed
? of(false)
: this.cipherService
.cipherViews$(userId)
.pipe(map((ciphers) => ciphers == null || ciphers.length === 0)),
),
);
}
}

View File

@@ -0,0 +1 @@
export * from "./has-items-nudge.service";

View File

@@ -0,0 +1,52 @@
import { inject, Injectable } from "@angular/core";
import { map, Observable } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { VAULT_NUDGE_DISMISSED_DISK_KEY, VaultNudgeType } from "./vault-nudges.service";
/**
* Base interface for handling a nudge's status
*/
export interface SingleNudgeService {
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean>;
setNudgeStatus(nudgeType: VaultNudgeType, dismissed: boolean, userId: UserId): Promise<void>;
}
/**
* Default implementation for nudges. Set and Show Nudge dismissed state
*/
@Injectable({
providedIn: "root",
})
export class DefaultSingleNudgeService implements SingleNudgeService {
stateProvider = inject(StateProvider);
protected isDismissed$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
return this.stateProvider
.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY)
.state$.pipe(map((nudges) => nudges?.includes(nudgeType) ?? false));
}
shouldShowNudge$(nudgeType: VaultNudgeType, userId: UserId): Observable<boolean> {
return this.isDismissed$(nudgeType, userId).pipe(map((dismissed) => !dismissed));
}
async setNudgeStatus(
nudgeType: VaultNudgeType,
dismissed: boolean,
userId: UserId,
): Promise<void> {
await this.stateProvider.getUser(userId, VAULT_NUDGE_DISMISSED_DISK_KEY).update((nudges) => {
nudges ??= [];
if (dismissed) {
nudges.push(nudgeType);
} else {
nudges = nudges.filter((n) => n !== nudgeType);
}
return nudges;
});
}
}

View File

@@ -0,0 +1,102 @@
import { TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { firstValueFrom, of } from "rxjs";
import { StateProvider } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { FakeStateProvider, mockAccountServiceWith } from "../../../common/spec";
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
import { DefaultSingleNudgeService } from "./default-single-nudge.service";
import { VaultNudgesService, VaultNudgeType } from "./vault-nudges.service";
describe("Vault Nudges Service", () => {
let fakeStateProvider: FakeStateProvider;
let testBed: TestBed;
beforeEach(async () => {
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
testBed = TestBed.configureTestingModule({
imports: [],
providers: [
{
provide: VaultNudgesService,
},
{
provide: DefaultSingleNudgeService,
},
{
provide: StateProvider,
useValue: fakeStateProvider,
},
{
provide: HasItemsNudgeService,
useValue: mock<HasItemsNudgeService>(),
},
],
});
});
describe("DefaultSingleNudgeService", () => {
it("should return shouldShowNudge === false when IntroCaourselDismissal dismissed is true", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.IntroCarouselDismissal,
true,
"user-id" as UserId,
);
const result = await firstValueFrom(
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
);
expect(result).toBe(false);
});
it("should return shouldShowNudge === true when IntroCaourselDismissal dismissed is false", async () => {
const service = testBed.inject(DefaultSingleNudgeService);
await service.setNudgeStatus(
VaultNudgeType.IntroCarouselDismissal,
false,
"user-id" as UserId,
);
const result = await firstValueFrom(
service.shouldShowNudge$(VaultNudgeType.IntroCarouselDismissal, "user-id" as UserId),
);
expect(result).toBe(true);
});
});
describe("VaultNudgesService", () => {
it("should return true, the proper value from the custom nudge service shouldShowNudge$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { shouldShowNudge$: () => of(true) },
});
const service = testBed.inject(VaultNudgesService);
const result = await firstValueFrom(
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(true);
});
it("should return false, the proper value for the custom nudge service shouldShowNudge$", async () => {
TestBed.overrideProvider(HasItemsNudgeService, {
useValue: { shouldShowNudge$: () => of(false) },
});
const service = testBed.inject(VaultNudgesService);
const result = await firstValueFrom(
service.showNudge$(VaultNudgeType.HasVaultItems, "user-id" as UserId),
);
expect(result).toBe(false);
});
});
});

View File

@@ -0,0 +1,70 @@
import { inject, Injectable } from "@angular/core";
import { UserKeyDefinition, VAULT_NUDGES_DISK } from "@bitwarden/common/platform/state";
import { UserId } from "@bitwarden/common/types/guid";
import { HasItemsNudgeService } from "./custom-nudges-services/has-items-nudge.service";
import { DefaultSingleNudgeService, SingleNudgeService } from "./default-single-nudge.service";
/**
* Enum to list the various nudge types, to be used by components/badges to show/hide the nudge
*/
export enum VaultNudgeType {
/** Nudge to show when user has no items in their vault
* Add future nudges here
*/
HasVaultItems = "has-vault-items",
IntroCarouselDismissal = "intro-carousel-dismissal",
}
export const VAULT_NUDGE_DISMISSED_DISK_KEY = new UserKeyDefinition<VaultNudgeType[]>(
VAULT_NUDGES_DISK,
"vaultNudgeDismissed",
{
deserializer: (nudgeDismissed) => nudgeDismissed,
clearOn: [], // Do not clear dismissals
},
);
@Injectable({
providedIn: "root",
})
export class VaultNudgesService {
/**
* Custom nudge services to use for specific nudge types
* Each nudge type can have its own service to determine when to show the nudge
* @private
*/
private customNudgeServices: any = {
[VaultNudgeType.HasVaultItems]: inject(HasItemsNudgeService),
};
/**
* Default nudge service to use when no custom service is available
* Simply stores the dismissed state in the user's state
* @private
*/
private defaultNudgeService = inject(DefaultSingleNudgeService);
private getNudgeService(nudge: VaultNudgeType): SingleNudgeService {
return this.customNudgeServices[nudge] ?? this.defaultNudgeService;
}
/**
* Check if a nudge should be shown to the user
* @param nudge
* @param userId
*/
showNudge$(nudge: VaultNudgeType, userId: UserId) {
return this.getNudgeService(nudge).shouldShowNudge$(nudge, userId);
}
/**
* Dismiss a nudge for the user so that it is not shown again
* @param nudge
* @param userId
*/
dismissNudge(nudge: VaultNudgeType, userId: UserId) {
return this.getNudgeService(nudge).setNudgeStatus(nudge, true, userId);
}
}