From 06acdefa914e3c6c84403aea2fd815e2e51e7c48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Gon=C3=A7alves?= Date: Tue, 16 Apr 2024 17:37:03 +0100 Subject: [PATCH] [PM-5273] Migrate state in CipherService (#8314) * PM-5273 Initial migration work for localData * PM-5273 Encrypted and Decrypted ciphers migration to state provider * pm-5273 Update references * pm5273 Ensure prototype on cipher * PM-5273 Add CipherId * PM-5273 Remove migrated methods and updated references * pm-5273 Fix versions * PM-5273 Added missing options * Conflict resolution * Revert "Conflict resolution" This reverts commit 0c0c2039edd4ee442456b6c5a4863a92b9a50e16. * PM-5273 Fix PR comments * Pm-5273 Fix comments * PM-5273 Changed decryptedCiphers to use ActiveUserState * PM-5273 Fix tests * PM-5273 Fix pr comments --- .../notification.background.spec.ts | 2 +- .../background/notification.background.ts | 4 +- .../background/overlay.background.spec.ts | 6 +- .../autofill/background/overlay.background.ts | 2 +- .../browser/src/background/main.background.ts | 2 +- apps/browser/src/popup/app.component.ts | 4 +- .../popup/generator/generator.component.ts | 9 +- .../cipher-service.factory.ts | 2 + .../folder-service.factory.ts | 10 +- .../components/vault/add-edit.component.ts | 2 +- apps/cli/src/bw.ts | 2 +- .../src/app/tools/generator.component.spec.ts | 5 + apps/web/src/app/core/state/state.service.ts | 14 -- .../src/services/jslib-services.module.ts | 4 +- .../vault/components/add-edit.component.ts | 6 +- .../platform/abstractions/state.service.ts | 18 -- .../src/platform/models/domain/account.ts | 16 +- .../src/platform/services/state.service.ts | 138 ----------- .../src/platform/state/state-definitions.ts | 5 + libs/common/src/state-migrations/migrate.ts | 7 +- ...e-cipher-service-to-state-provider.spec.ts | 170 +++++++++++++ ...7-move-cipher-service-to-state-provider.ts | 79 ++++++ .../src/vault/abstractions/cipher.service.ts | 8 + .../src/vault/models/data/cipher.data.ts | 6 + .../src/vault/services/cipher.service.spec.ts | 9 + .../src/vault/services/cipher.service.ts | 227 ++++++++++++------ .../services/folder/folder.service.spec.ts | 11 +- .../vault/services/folder/folder.service.ts | 10 +- .../vault/services/key-state/ciphers.state.ts | 52 ++++ 29 files changed, 525 insertions(+), 305 deletions(-) create mode 100644 libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts create mode 100644 libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts create mode 100644 libs/common/src/vault/services/key-state/ciphers.state.ts diff --git a/apps/browser/src/autofill/background/notification.background.spec.ts b/apps/browser/src/autofill/background/notification.background.spec.ts index 93750ece07..45f095aee9 100644 --- a/apps/browser/src/autofill/background/notification.background.spec.ts +++ b/apps/browser/src/autofill/background/notification.background.spec.ts @@ -720,7 +720,7 @@ describe("NotificationBackground", () => { ); tabSendMessageSpy = jest.spyOn(BrowserApi, "tabSendMessage").mockImplementation(); editItemSpy = jest.spyOn(notificationBackground as any, "editItem"); - setAddEditCipherInfoSpy = jest.spyOn(stateService, "setAddEditCipherInfo"); + setAddEditCipherInfoSpy = jest.spyOn(cipherService, "setAddEditCipherInfo"); openAddEditVaultItemPopoutSpy = jest.spyOn( notificationBackground as any, "openAddEditVaultItemPopout", diff --git a/apps/browser/src/autofill/background/notification.background.ts b/apps/browser/src/autofill/background/notification.background.ts index 74e6147505..9b65e4db0b 100644 --- a/apps/browser/src/autofill/background/notification.background.ts +++ b/apps/browser/src/autofill/background/notification.background.ts @@ -600,14 +600,14 @@ export default class NotificationBackground { } /** - * Sets the add/edit cipher info in the state service + * Sets the add/edit cipher info in the cipher service * and opens the add/edit vault item popout. * * @param cipherView - The cipher to edit * @param senderTab - The tab that the message was sent from */ private async editItem(cipherView: CipherView, senderTab: chrome.tabs.Tab) { - await this.stateService.setAddEditCipherInfo({ + await this.cipherService.setAddEditCipherInfo({ cipher: cipherView, collectionIds: cipherView.collectionIds, }); diff --git a/apps/browser/src/autofill/background/overlay.background.spec.ts b/apps/browser/src/autofill/background/overlay.background.spec.ts index 2599c1825e..e65397a62b 100644 --- a/apps/browser/src/autofill/background/overlay.background.spec.ts +++ b/apps/browser/src/autofill/background/overlay.background.spec.ts @@ -592,7 +592,7 @@ describe("OverlayBackground", () => { beforeEach(() => { sender = mock({ tab: { id: 1 } }); jest - .spyOn(overlayBackground["stateService"], "setAddEditCipherInfo") + .spyOn(overlayBackground["cipherService"], "setAddEditCipherInfo") .mockImplementation(); jest.spyOn(overlayBackground as any, "openAddEditVaultItemPopout").mockImplementation(); }); @@ -600,7 +600,7 @@ describe("OverlayBackground", () => { it("will not open the add edit popout window if the message does not have a login cipher provided", () => { sendExtensionRuntimeMessage({ command: "autofillOverlayAddNewVaultItem" }, sender); - expect(overlayBackground["stateService"].setAddEditCipherInfo).not.toHaveBeenCalled(); + expect(overlayBackground["cipherService"].setAddEditCipherInfo).not.toHaveBeenCalled(); expect(overlayBackground["openAddEditVaultItemPopout"]).not.toHaveBeenCalled(); }); @@ -621,7 +621,7 @@ describe("OverlayBackground", () => { ); await flushPromises(); - expect(overlayBackground["stateService"].setAddEditCipherInfo).toHaveBeenCalled(); + expect(overlayBackground["cipherService"].setAddEditCipherInfo).toHaveBeenCalled(); expect(BrowserApi.sendMessage).toHaveBeenCalledWith( "inlineAutofillMenuRefreshAddEditCipher", ); diff --git a/apps/browser/src/autofill/background/overlay.background.ts b/apps/browser/src/autofill/background/overlay.background.ts index 50fb80ef1b..551263525e 100644 --- a/apps/browser/src/autofill/background/overlay.background.ts +++ b/apps/browser/src/autofill/background/overlay.background.ts @@ -636,7 +636,7 @@ class OverlayBackground implements OverlayBackgroundInterface { cipherView.type = CipherType.Login; cipherView.login = loginView; - await this.stateService.setAddEditCipherInfo({ + await this.cipherService.setAddEditCipherInfo({ cipher: cipherView, collectionIds: cipherView.collectionIds, }); diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 105e7e2a38..642510b4de 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -663,12 +663,12 @@ export default class MainBackground { this.encryptService, this.cipherFileUploadService, this.configService, + this.stateProvider, ); this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService, this.stateProvider, ); this.folderApiService = new FolderApiService(this.folderService, this.apiService); diff --git a/apps/browser/src/popup/app.component.ts b/apps/browser/src/popup/app.component.ts index b0fdaec4fc..c224e652f6 100644 --- a/apps/browser/src/popup/app.component.ts +++ b/apps/browser/src/popup/app.component.ts @@ -7,6 +7,7 @@ import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service"; import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status"; import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { DialogService, SimpleDialogOptions } from "@bitwarden/components"; import { BrowserApi } from "../platform/browser/browser-api"; @@ -42,6 +43,7 @@ export class AppComponent implements OnInit, OnDestroy { private stateService: BrowserStateService, private browserSendStateService: BrowserSendStateService, private vaultBrowserStateService: VaultBrowserStateService, + private cipherService: CipherService, private changeDetectorRef: ChangeDetectorRef, private ngZone: NgZone, private platformUtilsService: ForegroundPlatformUtilsService, @@ -161,7 +163,7 @@ export class AppComponent implements OnInit, OnDestroy { await this.clearComponentStates(); } if (url.startsWith("/tabs/")) { - await this.stateService.setAddEditCipherInfo(null); + await this.cipherService.setAddEditCipherInfo(null); } (window as any).previousPopupUrl = url; diff --git a/apps/browser/src/tools/popup/generator/generator.component.ts b/apps/browser/src/tools/popup/generator/generator.component.ts index c683b6f4a6..0c11c28f27 100644 --- a/apps/browser/src/tools/popup/generator/generator.component.ts +++ b/apps/browser/src/tools/popup/generator/generator.component.ts @@ -1,6 +1,7 @@ import { Location } from "@angular/common"; import { Component } from "@angular/core"; import { ActivatedRoute } from "@angular/router"; +import { firstValueFrom } from "rxjs"; import { GeneratorComponent as BaseGeneratorComponent } from "@bitwarden/angular/tools/generator/components/generator.component"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -9,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher-info"; @@ -19,6 +21,7 @@ import { AddEditCipherInfo } from "@bitwarden/common/vault/types/add-edit-cipher export class GeneratorComponent extends BaseGeneratorComponent { private addEditCipherInfo: AddEditCipherInfo; private cipherState: CipherView; + private cipherService: CipherService; constructor( passwordGenerationService: PasswordGenerationServiceAbstraction, @@ -26,6 +29,7 @@ export class GeneratorComponent extends BaseGeneratorComponent { platformUtilsService: PlatformUtilsService, i18nService: I18nService, stateService: StateService, + cipherService: CipherService, route: ActivatedRoute, logService: LogService, private location: Location, @@ -40,10 +44,11 @@ export class GeneratorComponent extends BaseGeneratorComponent { route, window, ); + this.cipherService = cipherService; } async ngOnInit() { - this.addEditCipherInfo = await this.stateService.getAddEditCipherInfo(); + this.addEditCipherInfo = await firstValueFrom(this.cipherService.addEditCipherInfo$); if (this.addEditCipherInfo != null) { this.cipherState = this.addEditCipherInfo.cipher; } @@ -64,7 +69,7 @@ export class GeneratorComponent extends BaseGeneratorComponent { this.addEditCipherInfo.cipher = this.cipherState; // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.stateService.setAddEditCipherInfo(this.addEditCipherInfo); + this.cipherService.setAddEditCipherInfo(this.addEditCipherInfo); this.close(); } diff --git a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts index 8ffeca72bc..57366ea8c0 100644 --- a/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/cipher-service.factory.ts @@ -42,6 +42,7 @@ import { i18nServiceFactory, I18nServiceInitOptions, } from "../../../platform/background/service-factories/i18n-service.factory"; +import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory"; import { stateServiceFactory, StateServiceInitOptions, @@ -81,6 +82,7 @@ export function cipherServiceFactory( await encryptServiceFactory(cache, opts), await cipherFileUploadServiceFactory(cache, opts), await configServiceFactory(cache, opts), + await stateProviderFactory(cache, opts), ), ); } diff --git a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts index 72847a0536..0593dc904c 100644 --- a/apps/browser/src/vault/background/service_factories/folder-service.factory.ts +++ b/apps/browser/src/vault/background/service_factories/folder-service.factory.ts @@ -14,11 +14,10 @@ import { i18nServiceFactory, I18nServiceInitOptions, } from "../../../platform/background/service-factories/i18n-service.factory"; -import { stateProviderFactory } from "../../../platform/background/service-factories/state-provider.factory"; import { - stateServiceFactory as stateServiceFactory, - StateServiceInitOptions, -} from "../../../platform/background/service-factories/state-service.factory"; + stateProviderFactory, + StateProviderInitOptions, +} from "../../../platform/background/service-factories/state-provider.factory"; import { cipherServiceFactory, CipherServiceInitOptions } from "./cipher-service.factory"; @@ -28,7 +27,7 @@ export type FolderServiceInitOptions = FolderServiceFactoryOptions & CryptoServiceInitOptions & CipherServiceInitOptions & I18nServiceInitOptions & - StateServiceInitOptions; + StateProviderInitOptions; export function folderServiceFactory( cache: { folderService?: AbstractFolderService } & CachedServices, @@ -43,7 +42,6 @@ export function folderServiceFactory( await cryptoServiceFactory(cache, opts), await i18nServiceFactory(cache, opts), await cipherServiceFactory(cache, opts), - await stateServiceFactory(cache, opts), await stateProviderFactory(cache, opts), ), ); diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index b27a986231..a566b054c0 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -304,7 +304,7 @@ export class AddEditComponent extends BaseAddEditComponent { } private saveCipherState() { - return this.stateService.setAddEditCipherInfo({ + return this.cipherService.setAddEditCipherInfo({ cipher: this.cipher, collectionIds: this.collections == null diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index 4228eba965..f02d7da49c 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -544,13 +544,13 @@ export class Main { this.encryptService, this.cipherFileUploadService, this.configService, + this.stateProvider, ); this.folderService = new FolderService( this.cryptoService, this.i18nService, this.cipherService, - this.stateService, this.stateProvider, ); diff --git a/apps/desktop/src/app/tools/generator.component.spec.ts b/apps/desktop/src/app/tools/generator.component.spec.ts index 53f919a596..51b5bf93a2 100644 --- a/apps/desktop/src/app/tools/generator.component.spec.ts +++ b/apps/desktop/src/app/tools/generator.component.spec.ts @@ -10,6 +10,7 @@ import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/pl import { StateService } from "@bitwarden/common/platform/abstractions/state.service"; import { PasswordGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/password"; import { UsernameGenerationServiceAbstraction } from "@bitwarden/common/tools/generator/username"; +import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { GeneratorComponent } from "./generator.component"; @@ -54,6 +55,10 @@ describe("GeneratorComponent", () => { provide: LogService, useValue: mock(), }, + { + provide: CipherService, + useValue: mock(), + }, ], schemas: [NO_ERRORS_SCHEMA], }).compileComponents(); diff --git a/apps/web/src/app/core/state/state.service.ts b/apps/web/src/app/core/state/state.service.ts index 54e456d34c..1ae62d8591 100644 --- a/apps/web/src/app/core/state/state.service.ts +++ b/apps/web/src/app/core/state/state.service.ts @@ -18,7 +18,6 @@ import { StateFactory } from "@bitwarden/common/platform/factories/state-factory import { StorageOptions } from "@bitwarden/common/platform/models/domain/storage-options"; import { MigrationRunner } from "@bitwarden/common/platform/services/migration-runner"; import { StateService as BaseStateService } from "@bitwarden/common/platform/services/state.service"; -import { CipherData } from "@bitwarden/common/vault/models/data/cipher.data"; import { Account } from "./account"; import { GlobalState } from "./global-state"; @@ -57,19 +56,6 @@ export class StateService extends BaseStateService { await super.addAccount(account); } - async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.getEncryptedCiphers(options); - } - - async setEncryptedCiphers( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ): Promise { - options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); - return await super.setEncryptedCiphers(value, options); - } - override async getLastSync(options?: StorageOptions): Promise { options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getLastSync(options); diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index b311000fb8..dbb94f6753 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -411,6 +411,7 @@ const safeProviders: SafeProvider[] = [ encryptService: EncryptService, fileUploadService: CipherFileUploadServiceAbstraction, configService: ConfigService, + stateProvider: StateProvider, ) => new CipherService( cryptoService, @@ -423,6 +424,7 @@ const safeProviders: SafeProvider[] = [ encryptService, fileUploadService, configService, + stateProvider, ), deps: [ CryptoServiceAbstraction, @@ -435,6 +437,7 @@ const safeProviders: SafeProvider[] = [ EncryptService, CipherFileUploadServiceAbstraction, ConfigService, + StateProvider, ], }), safeProvider({ @@ -444,7 +447,6 @@ const safeProviders: SafeProvider[] = [ CryptoServiceAbstraction, I18nServiceAbstraction, CipherServiceAbstraction, - StateServiceAbstraction, StateProvider, ], }), diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index ab09d14c86..d29c74b42d 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -1,6 +1,6 @@ import { DatePipe } from "@angular/common"; import { Directive, EventEmitter, Input, OnDestroy, OnInit, Output } from "@angular/core"; -import { concatMap, Observable, Subject, takeUntil } from "rxjs"; +import { concatMap, firstValueFrom, Observable, Subject, takeUntil } from "rxjs"; import { AuditService } from "@bitwarden/common/abstractions/audit.service"; import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.service"; @@ -687,7 +687,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } async loadAddEditCipherInfo(): Promise { - const addEditCipherInfo: any = await this.stateService.getAddEditCipherInfo(); + const addEditCipherInfo: any = await firstValueFrom(this.cipherService.addEditCipherInfo$); const loadedSavedInfo = addEditCipherInfo != null; if (loadedSavedInfo) { @@ -700,7 +700,7 @@ export class AddEditComponent implements OnInit, OnDestroy { } } - await this.stateService.setAddEditCipherInfo(null); + await this.cipherService.setAddEditCipherInfo(null); return loadedSavedInfo; } diff --git a/libs/common/src/platform/abstractions/state.service.ts b/libs/common/src/platform/abstractions/state.service.ts index fd76cad6d1..2348c8844a 100644 --- a/libs/common/src/platform/abstractions/state.service.ts +++ b/libs/common/src/platform/abstractions/state.service.ts @@ -6,10 +6,6 @@ import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -import { CipherData } from "../../vault/models/data/cipher.data"; -import { LocalData } from "../../vault/models/data/local.data"; -import { CipherView } from "../../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { KdfType } from "../enums"; import { Account } from "../models/domain/account"; import { EncString } from "../models/domain/enc-string"; @@ -38,8 +34,6 @@ export abstract class StateService { clean: (options?: StorageOptions) => Promise; init: (initOptions?: InitOptions) => Promise; - getAddEditCipherInfo: (options?: StorageOptions) => Promise; - setAddEditCipherInfo: (value: AddEditCipherInfo, options?: StorageOptions) => Promise; /** * Gets the user's auto key */ @@ -104,8 +98,6 @@ export abstract class StateService { * @deprecated For migration purposes only, use setUserKeyBiometric instead */ setCryptoMasterKeyBiometric: (value: BiometricKey, options?: StorageOptions) => Promise; - getDecryptedCiphers: (options?: StorageOptions) => Promise; - setDecryptedCiphers: (value: CipherView[], options?: StorageOptions) => Promise; getDecryptedPasswordGenerationHistory: ( options?: StorageOptions, ) => Promise; @@ -134,11 +126,6 @@ export abstract class StateService { value: boolean, options?: StorageOptions, ) => Promise; - getEncryptedCiphers: (options?: StorageOptions) => Promise<{ [id: string]: CipherData }>; - setEncryptedCiphers: ( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ) => Promise; getEncryptedPasswordGenerationHistory: ( options?: StorageOptions, ) => Promise; @@ -165,11 +152,6 @@ export abstract class StateService { setLastActive: (value: number, options?: StorageOptions) => Promise; getLastSync: (options?: StorageOptions) => Promise; setLastSync: (value: string, options?: StorageOptions) => Promise; - getLocalData: (options?: StorageOptions) => Promise<{ [cipherId: string]: LocalData }>; - setLocalData: ( - value: { [cipherId: string]: LocalData }, - options?: StorageOptions, - ) => Promise; getMinimizeOnCopyToClipboard: (options?: StorageOptions) => Promise; setMinimizeOnCopyToClipboard: (value: boolean, options?: StorageOptions) => Promise; getOrganizationInvitation: (options?: StorageOptions) => Promise; diff --git a/libs/common/src/platform/models/domain/account.ts b/libs/common/src/platform/models/domain/account.ts index 759a903514..ae7780ada4 100644 --- a/libs/common/src/platform/models/domain/account.ts +++ b/libs/common/src/platform/models/domain/account.ts @@ -8,9 +8,6 @@ import { } from "../../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../../tools/generator/username/username-generation-options"; import { DeepJsonify } from "../../../types/deep-jsonify"; -import { CipherData } from "../../../vault/models/data/cipher.data"; -import { CipherView } from "../../../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../../../vault/types/add-edit-cipher-info"; import { KdfType } from "../../enums"; import { Utils } from "../../misc/utils"; @@ -61,28 +58,17 @@ export class DataEncryptionPair { } export class AccountData { - ciphers?: DataEncryptionPair = new DataEncryptionPair< - CipherData, - CipherView - >(); - localData?: any; passwordGenerationHistory?: EncryptionPair< GeneratedPasswordHistory[], GeneratedPasswordHistory[] > = new EncryptionPair(); - addEditCipherInfo?: AddEditCipherInfo; static fromJSON(obj: DeepJsonify): AccountData { if (obj == null) { return null; } - return Object.assign(new AccountData(), obj, { - addEditCipherInfo: { - cipher: CipherView.fromJSON(obj?.addEditCipherInfo?.cipher), - collectionIds: obj?.addEditCipherInfo?.collectionIds, - }, - }); + return Object.assign(new AccountData(), obj); } } diff --git a/libs/common/src/platform/services/state.service.ts b/libs/common/src/platform/services/state.service.ts index 3d512175a8..9edc9ed1e3 100644 --- a/libs/common/src/platform/services/state.service.ts +++ b/libs/common/src/platform/services/state.service.ts @@ -9,10 +9,6 @@ import { GeneratorOptions } from "../../tools/generator/generator-options"; import { GeneratedPasswordHistory, PasswordGeneratorOptions } from "../../tools/generator/password"; import { UsernameGeneratorOptions } from "../../tools/generator/username"; import { UserId } from "../../types/guid"; -import { CipherData } from "../../vault/models/data/cipher.data"; -import { LocalData } from "../../vault/models/data/local.data"; -import { CipherView } from "../../vault/models/view/cipher.view"; -import { AddEditCipherInfo } from "../../vault/types/add-edit-cipher-info"; import { EnvironmentService } from "../abstractions/environment.service"; import { LogService } from "../abstractions/log.service"; import { @@ -221,34 +217,6 @@ export class StateService< return currentUser as UserId; } - async getAddEditCipherInfo(options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - // ensure prototype on cipher - const raw = account?.data?.addEditCipherInfo; - return raw == null - ? null - : { - cipher: - raw?.cipher.toJSON != null - ? raw.cipher - : CipherView.fromJSON(raw?.cipher as Jsonify), - collectionIds: raw?.collectionIds, - }; - } - - async setAddEditCipherInfo(value: AddEditCipherInfo, options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.addEditCipherInfo = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - /** * user key when using the "never" option of vault timeout */ @@ -465,24 +433,6 @@ export class StateService< await this.saveSecureStorageKey(partialKeys.biometricKey, value, options); } - @withPrototypeForArrayMembers(CipherView, CipherView.fromJSON) - async getDecryptedCiphers(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) - )?.data?.ciphers?.decrypted; - } - - async setDecryptedCiphers(value: CipherView[], options?: StorageOptions): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - account.data.ciphers.decrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultInMemoryOptions()), - ); - } - @withPrototypeForArrayMembers(GeneratedPasswordHistory) async getDecryptedPasswordGenerationHistory( options?: StorageOptions, @@ -621,27 +571,6 @@ export class StateService< ); } - @withPrototypeForObjectValues(CipherData) - async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) - )?.data?.ciphers?.encrypted; - } - - async setEncryptedCiphers( - value: { [id: string]: CipherData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - account.data.ciphers.encrypted = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions()), - ); - } - /** * @deprecated Use UserKey instead */ @@ -805,26 +734,6 @@ export class StateService< ); } - async getLocalData(options?: StorageOptions): Promise<{ [cipherId: string]: LocalData }> { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskLocalOptions())) - )?.data?.localData; - } - - async setLocalData( - value: { [cipherId: string]: LocalData }, - options?: StorageOptions, - ): Promise { - const account = await this.getAccount( - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - account.data.localData = value; - await this.saveAccount( - account, - this.reconcileOptions(options, await this.defaultOnDiskLocalOptions()), - ); - } - async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise { return ( (await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions()))) @@ -1510,50 +1419,3 @@ function withPrototypeForArrayMembers( }; }; } - -function withPrototypeForObjectValues( - valuesConstructor: new (...args: any[]) => T, - valuesConverter: (input: any) => T = (i) => i, -): ( - target: any, - propertyKey: string | symbol, - descriptor: PropertyDescriptor, -) => { value: (...args: any[]) => Promise<{ [key: string]: T }> } { - return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { - const originalMethod = descriptor.value; - - return { - value: function (...args: any[]) { - const originalResult: Promise<{ [key: string]: T }> = originalMethod.apply(this, args); - - if (!Utils.isPromise(originalResult)) { - throw new Error( - `Error applying prototype to stored value -- result is not a promise for method ${String( - propertyKey, - )}`, - ); - } - - return originalResult.then((result) => { - if (result == null) { - return null; - } else { - for (const [key, val] of Object.entries(result)) { - result[key] = - val == null || val.constructor.name === valuesConstructor.prototype.constructor.name - ? valuesConverter(val) - : valuesConverter( - Object.create( - valuesConstructor.prototype, - Object.getOwnPropertyDescriptors(val), - ), - ); - } - - return result as { [key: string]: T }; - } - }); - }, - }; - }; -} diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 518e5c51d6..18df252062 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -135,3 +135,8 @@ export const VAULT_SETTINGS_DISK = new StateDefinition("vaultSettings", "disk", }); export const VAULT_BROWSER_MEMORY = new StateDefinition("vaultBrowser", "memory"); export const VAULT_SEARCH_MEMORY = new StateDefinition("vaultSearch", "memory"); +export const CIPHERS_DISK = new StateDefinition("ciphers", "disk", { web: "memory" }); +export const CIPHERS_DISK_LOCAL = new StateDefinition("ciphersLocal", "disk", { + web: "disk-local", +}); +export const CIPHERS_MEMORY = new StateDefinition("ciphersMemory", "memory"); diff --git a/libs/common/src/state-migrations/migrate.ts b/libs/common/src/state-migrations/migrate.ts index 77b949126f..000f85519e 100644 --- a/libs/common/src/state-migrations/migrate.ts +++ b/libs/common/src/state-migrations/migrate.ts @@ -53,6 +53,7 @@ import { DeviceTrustCryptoServiceStateProviderMigrator } from "./migrations/53-m import { SendMigrator } from "./migrations/54-move-encrypted-sends"; import { MoveMasterKeyStateToProviderMigrator } from "./migrations/55-move-master-key-state-to-provider"; import { AuthRequestMigrator } from "./migrations/56-move-auth-requests"; +import { CipherServiceMigrator } from "./migrations/57-move-cipher-service-to-state-provider"; import { RemoveLegacyEtmKeyMigrator } from "./migrations/6-remove-legacy-etm-key"; import { MoveBiometricAutoPromptToAccount } from "./migrations/7-move-biometric-auto-prompt-to-account"; import { MoveStateVersionMigrator } from "./migrations/8-move-state-version"; @@ -60,8 +61,7 @@ import { MoveBrowserSettingsToGlobal } from "./migrations/9-move-browser-setting import { MinVersionMigrator } from "./migrations/min-version"; export const MIN_VERSION = 3; -export const CURRENT_VERSION = 56; - +export const CURRENT_VERSION = 57; export type MinVersion = typeof MIN_VERSION; export function createMigrationBuilder() { @@ -119,7 +119,8 @@ export function createMigrationBuilder() { .with(DeviceTrustCryptoServiceStateProviderMigrator, 52, 53) .with(SendMigrator, 53, 54) .with(MoveMasterKeyStateToProviderMigrator, 54, 55) - .with(AuthRequestMigrator, 55, CURRENT_VERSION); + .with(AuthRequestMigrator, 55, 56) + .with(CipherServiceMigrator, 56, CURRENT_VERSION); } export async function currentVersion( diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts new file mode 100644 index 0000000000..499cff1c89 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.spec.ts @@ -0,0 +1,170 @@ +import { MockProxy, any } from "jest-mock-extended"; + +import { MigrationHelper } from "../migration-helper"; +import { mockMigrationHelper } from "../migration-helper.spec"; + +import { + CIPHERS_DISK, + CIPHERS_DISK_LOCAL, + CipherServiceMigrator, +} from "./57-move-cipher-service-to-state-provider"; + +function exampleJSON() { + return { + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2"], + user1: { + data: { + localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + }, + }, + user2: { + data: { + otherStuff: "otherStuff5", + }, + }, + }; +} + +function rollbackJSON() { + return { + user_user1_ciphersLocal_localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + user_user1_ciphers_ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + global: { + otherStuff: "otherStuff1", + }, + authenticatedAccounts: ["user1", "user2"], + user1: { + data: {}, + }, + user2: { + data: { + localData: { + otherStuff: "otherStuff3", + }, + ciphers: { + otherStuff: "otherStuff4", + }, + otherStuff: "otherStuff5", + }, + }, + }; +} + +describe("CipherServiceMigrator", () => { + let helper: MockProxy; + let sut: CipherServiceMigrator; + + describe("migrate", () => { + beforeEach(() => { + helper = mockMigrationHelper(exampleJSON(), 56); + sut = new CipherServiceMigrator(56, 57); + }); + + it("should remove local data and ciphers from all accounts", async () => { + await sut.migrate(helper); + expect(helper.set).toHaveBeenCalledWith("user1", { + data: {}, + }); + }); + + it("should migrate localData and ciphers to state provider for accounts that have the data", async () => { + await sut.migrate(helper); + + expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK_LOCAL, { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }); + expect(helper.setToUser).toHaveBeenCalledWith("user1", CIPHERS_DISK, { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }); + + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK_LOCAL, any()); + expect(helper.setToUser).not.toHaveBeenCalledWith("user2", CIPHERS_DISK, any()); + }); + }); + + describe("rollback", () => { + beforeEach(() => { + helper = mockMigrationHelper(rollbackJSON(), 57); + sut = new CipherServiceMigrator(56, 57); + }); + + it.each(["user1", "user2"])("should null out new values", async (userId) => { + await sut.rollback(helper); + expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK_LOCAL, null); + expect(helper.setToUser).toHaveBeenCalledWith(userId, CIPHERS_DISK, null); + }); + + it("should add back localData and ciphers to all accounts", async () => { + await sut.rollback(helper); + + expect(helper.set).toHaveBeenCalledWith("user1", { + data: { + localData: { + "6865ba55-7966-4d63-b743-b12000d49631": { + lastUsedDate: 1708950970632, + }, + "f895f099-6739-4cca-9d61-b12200d04bfa": { + lastUsedDate: 1709031916943, + }, + }, + ciphers: { + "cipher-id-10": { + id: "cipher-id-10", + }, + "cipher-id-11": { + id: "cipher-id-11", + }, + }, + }, + }); + }); + + it("should not add data back if data wasn't migrated or acct doesn't exist", async () => { + await sut.rollback(helper); + + expect(helper.set).not.toHaveBeenCalledWith("user2", any()); + }); + }); +}); diff --git a/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts new file mode 100644 index 0000000000..e71d889bb7 --- /dev/null +++ b/libs/common/src/state-migrations/migrations/57-move-cipher-service-to-state-provider.ts @@ -0,0 +1,79 @@ +import { KeyDefinitionLike, MigrationHelper } from "../migration-helper"; +import { Migrator } from "../migrator"; + +type ExpectedAccountType = { + data: { + localData?: unknown; + ciphers?: unknown; + }; +}; + +export const CIPHERS_DISK_LOCAL: KeyDefinitionLike = { + key: "localData", + stateDefinition: { + name: "ciphersLocal", + }, +}; + +export const CIPHERS_DISK: KeyDefinitionLike = { + key: "ciphers", + stateDefinition: { + name: "ciphers", + }, +}; + +export class CipherServiceMigrator extends Migrator<56, 57> { + async migrate(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function migrateAccount(userId: string, account: ExpectedAccountType): Promise { + let updatedAccount = false; + + //Migrate localData + const localData = account?.data?.localData; + if (localData != null) { + await helper.setToUser(userId, CIPHERS_DISK_LOCAL, localData); + delete account.data.localData; + updatedAccount = true; + } + + //Migrate ciphers + const ciphers = account?.data?.ciphers; + if (ciphers != null) { + await helper.setToUser(userId, CIPHERS_DISK, ciphers); + delete account.data.ciphers; + updatedAccount = true; + } + + if (updatedAccount) { + await helper.set(userId, account); + } + } + + await Promise.all([...accounts.map(({ userId, account }) => migrateAccount(userId, account))]); + } + + async rollback(helper: MigrationHelper): Promise { + const accounts = await helper.getAccounts(); + async function rollbackAccount(userId: string, account: ExpectedAccountType): Promise { + //rollback localData + const localData = await helper.getFromUser(userId, CIPHERS_DISK_LOCAL); + + if (account.data && localData != null) { + account.data.localData = localData; + await helper.set(userId, account); + } + await helper.setToUser(userId, CIPHERS_DISK_LOCAL, null); + + //rollback ciphers + const ciphers = await helper.getFromUser(userId, CIPHERS_DISK); + + if (account.data && ciphers != null) { + account.data.ciphers = ciphers; + await helper.set(userId, account); + } + await helper.setToUser(userId, CIPHERS_DISK, null); + } + + await Promise.all([...accounts.map(({ userId, account }) => rollbackAccount(userId, account))]); + } +} diff --git a/libs/common/src/vault/abstractions/cipher.service.ts b/libs/common/src/vault/abstractions/cipher.service.ts index a8a0a25e9b..501fd87665 100644 --- a/libs/common/src/vault/abstractions/cipher.service.ts +++ b/libs/common/src/vault/abstractions/cipher.service.ts @@ -1,3 +1,5 @@ +import { Observable } from "rxjs"; + import { UriMatchStrategySetting } from "../../models/domain/domain-service"; import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key"; import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; @@ -7,8 +9,13 @@ import { Cipher } from "../models/domain/cipher"; import { Field } from "../models/domain/field"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; +import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; export abstract class CipherService { + /** + * An observable monitoring the add/edit cipher info saved to memory. + */ + addEditCipherInfo$: Observable; clearCache: (userId?: string) => Promise; encrypt: ( model: CipherView, @@ -102,4 +109,5 @@ export abstract class CipherService { asAdmin?: boolean, ) => Promise; getKeyForCipherKeyDecryption: (cipher: Cipher) => Promise; + setAddEditCipherInfo: (value: AddEditCipherInfo) => Promise; } diff --git a/libs/common/src/vault/models/data/cipher.data.ts b/libs/common/src/vault/models/data/cipher.data.ts index 1452ffe7ee..f8db7186d6 100644 --- a/libs/common/src/vault/models/data/cipher.data.ts +++ b/libs/common/src/vault/models/data/cipher.data.ts @@ -1,3 +1,5 @@ +import { Jsonify } from "type-fest"; + import { CipherRepromptType } from "../../enums/cipher-reprompt-type"; import { CipherType } from "../../enums/cipher-type"; import { CipherResponse } from "../response/cipher.response"; @@ -84,4 +86,8 @@ export class CipherData { this.passwordHistory = response.passwordHistory.map((ph) => new PasswordHistoryData(ph)); } } + + static fromJSON(obj: Jsonify) { + return Object.assign(new CipherData(), obj); + } } diff --git a/libs/common/src/vault/services/cipher.service.spec.ts b/libs/common/src/vault/services/cipher.service.spec.ts index c374724781..28c4bfc653 100644 --- a/libs/common/src/vault/services/cipher.service.spec.ts +++ b/libs/common/src/vault/services/cipher.service.spec.ts @@ -1,6 +1,8 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { FakeAccountService, mockAccountServiceWith } from "../../../spec/fake-account-service"; +import { FakeStateProvider } from "../../../spec/fake-state-provider"; import { makeStaticByteArray } from "../../../spec/utils"; import { ApiService } from "../../abstractions/api.service"; import { SearchService } from "../../abstractions/search.service"; @@ -12,10 +14,12 @@ import { CryptoService } from "../../platform/abstractions/crypto.service"; import { EncryptService } from "../../platform/abstractions/encrypt.service"; import { I18nService } from "../../platform/abstractions/i18n.service"; import { StateService } from "../../platform/abstractions/state.service"; +import { Utils } from "../../platform/misc/utils"; 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"; import { ContainerService } from "../../platform/services/container.service"; +import { UserId } from "../../types/guid"; import { CipherKey, OrgKey } from "../../types/key"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; @@ -97,6 +101,8 @@ const cipherData: CipherData = { }, ], }; +const mockUserId = Utils.newGuid() as UserId; +let accountService: FakeAccountService; describe("Cipher Service", () => { const cryptoService = mock(); @@ -109,6 +115,8 @@ describe("Cipher Service", () => { const searchService = mock(); const encryptService = mock(); const configService = mock(); + accountService = mockAccountServiceWith(mockUserId); + const stateProvider = new FakeStateProvider(accountService); let cipherService: CipherService; let cipherObj: Cipher; @@ -130,6 +138,7 @@ describe("Cipher Service", () => { encryptService, cipherFileUploadService, configService, + stateProvider, ); cipherObj = new Cipher(cipherData); diff --git a/libs/common/src/vault/services/cipher.service.ts b/libs/common/src/vault/services/cipher.service.ts index dffbf5cbbe..e8544d7f98 100644 --- a/libs/common/src/vault/services/cipher.service.ts +++ b/libs/common/src/vault/services/cipher.service.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from "rxjs"; +import { Observable, firstValueFrom } from "rxjs"; import { SemVer } from "semver"; import { ApiService } from "../../abstractions/api.service"; @@ -21,13 +21,15 @@ import Domain from "../../platform/models/domain/domain-base"; 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"; +import { ActiveUserState, StateProvider } from "../../platform/state"; import { CipherId, CollectionId, OrganizationId } from "../../types/guid"; -import { OrgKey, UserKey } from "../../types/key"; +import { UserKey, OrgKey } from "../../types/key"; import { CipherService as CipherServiceAbstraction } from "../abstractions/cipher.service"; import { CipherFileUploadService } from "../abstractions/file-upload/cipher-file-upload.service"; import { FieldType } from "../enums"; import { CipherType } from "../enums/cipher-type"; import { CipherData } from "../models/data/cipher.data"; +import { LocalData } from "../models/data/local.data"; import { Attachment } from "../models/domain/attachment"; import { Card } from "../models/domain/card"; import { Cipher } from "../models/domain/cipher"; @@ -54,6 +56,14 @@ import { AttachmentView } from "../models/view/attachment.view"; import { CipherView } from "../models/view/cipher.view"; import { FieldView } from "../models/view/field.view"; import { PasswordHistoryView } from "../models/view/password-history.view"; +import { AddEditCipherInfo } from "../types/add-edit-cipher-info"; + +import { + ENCRYPTED_CIPHERS, + LOCAL_DATA_KEY, + ADD_EDIT_CIPHER_INFO_KEY, + DECRYPTED_CIPHERS, +} from "./key-state/ciphers.state"; const CIPHER_KEY_ENC_MIN_SERVER_VER = new SemVer("2024.2.0"); @@ -62,6 +72,16 @@ export class CipherService implements CipherServiceAbstraction { this.sortCiphersByLastUsed, ); + localData$: Observable>; + ciphers$: Observable>; + cipherViews$: Observable>; + addEditCipherInfo$: Observable; + + private localDataState: ActiveUserState>; + private encryptedCiphersState: ActiveUserState>; + private decryptedCiphersState: ActiveUserState>; + private addEditCipherInfoState: ActiveUserState; + constructor( private cryptoService: CryptoService, private domainSettingsService: DomainSettingsService, @@ -73,11 +93,17 @@ export class CipherService implements CipherServiceAbstraction { private encryptService: EncryptService, private cipherFileUploadService: CipherFileUploadService, private configService: ConfigService, - ) {} + private stateProvider: StateProvider, + ) { + this.localDataState = this.stateProvider.getActive(LOCAL_DATA_KEY); + this.encryptedCiphersState = this.stateProvider.getActive(ENCRYPTED_CIPHERS); + this.decryptedCiphersState = this.stateProvider.getActive(DECRYPTED_CIPHERS); + this.addEditCipherInfoState = this.stateProvider.getActive(ADD_EDIT_CIPHER_INFO_KEY); - async getDecryptedCipherCache(): Promise { - const decryptedCiphers = await this.stateService.getDecryptedCiphers(); - return decryptedCiphers; + this.localData$ = this.localDataState.state$; + this.ciphers$ = this.encryptedCiphersState.state$; + this.cipherViews$ = this.decryptedCiphersState.state$; + this.addEditCipherInfo$ = this.addEditCipherInfoState.state$; } async setDecryptedCipherCache(value: CipherView[]) { @@ -85,7 +111,7 @@ export class CipherService implements CipherServiceAbstraction { // if we cache it then we may accidentially return it when it's not right, we'd rather try decryption again. // We still want to set null though, that is the indicator that the cache isn't valid and we should do decryption. if (value == null || value.length !== 0) { - await this.stateService.setDecryptedCiphers(value); + await this.setDecryptedCiphers(value); } if (this.searchService != null) { if (value == null) { @@ -96,6 +122,14 @@ export class CipherService implements CipherServiceAbstraction { } } + private async setDecryptedCiphers(value: CipherView[]) { + const cipherViews: { [id: string]: CipherView } = {}; + value?.forEach((c) => { + cipherViews[c.id] = c; + }); + await this.decryptedCiphersState.update(() => cipherViews); + } + async clearCache(userId?: string): Promise { await this.clearDecryptedCiphersState(userId); } @@ -268,24 +302,27 @@ export class CipherService implements CipherServiceAbstraction { } async get(id: string): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); // eslint-disable-next-line if (ciphers == null || !ciphers.hasOwnProperty(id)) { return null; } - const localData = await this.stateService.getLocalData(); - return new Cipher(ciphers[id], localData ? localData[id] : null); + const localData = await firstValueFrom(this.localData$); + const cipherId = id as CipherId; + + return new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null); } async getAll(): Promise { - const localData = await this.stateService.getLocalData(); - const ciphers = await this.stateService.getEncryptedCiphers(); + const localData = await firstValueFrom(this.localData$); + const ciphers = await firstValueFrom(this.ciphers$); const response: Cipher[] = []; for (const id in ciphers) { // eslint-disable-next-line if (ciphers.hasOwnProperty(id)) { - response.push(new Cipher(ciphers[id], localData ? localData[id] : null)); + const cipherId = id as CipherId; + response.push(new Cipher(ciphers[cipherId], localData ? localData[cipherId] : null)); } } return response; @@ -293,12 +330,23 @@ export class CipherService implements CipherServiceAbstraction { @sequentialize(() => "getAllDecrypted") async getAllDecrypted(): Promise { - if ((await this.getDecryptedCipherCache()) != null) { + let decCiphers = await this.getDecryptedCiphers(); + if (decCiphers != null && decCiphers.length !== 0) { await this.reindexCiphers(); - return await this.getDecryptedCipherCache(); + return await this.getDecryptedCiphers(); } - const ciphers = await this.getAll(); + decCiphers = await this.decryptCiphers(await this.getAll()); + + await this.setDecryptedCipherCache(decCiphers); + return decCiphers; + } + + private async getDecryptedCiphers() { + return Object.values(await firstValueFrom(this.cipherViews$)); + } + + private async decryptCiphers(ciphers: Cipher[]) { const orgKeys = await this.cryptoService.getOrgKeys(); const userKey = await this.cryptoService.getUserKeyWithLegacySupport(); if (Object.keys(orgKeys).length === 0 && userKey == null) { @@ -326,7 +374,6 @@ export class CipherService implements CipherServiceAbstraction { .flat() .sort(this.getLocaleSortingFunction()); - await this.setDecryptedCipherCache(decCiphers); return decCiphers; } @@ -336,7 +383,7 @@ export class CipherService implements CipherServiceAbstraction { this.searchService != null && ((await firstValueFrom(this.searchService.indexedEntityId$)) ?? userId) !== userId; if (reindexRequired) { - await this.searchService.indexCiphers(await this.getDecryptedCipherCache(), userId); + await this.searchService.indexCiphers(await this.getDecryptedCiphers(), userId); } } @@ -448,22 +495,24 @@ export class CipherService implements CipherServiceAbstraction { } async updateLastUsedDate(id: string): Promise { - let ciphersLocalData = await this.stateService.getLocalData(); + let ciphersLocalData = await firstValueFrom(this.localData$); + if (!ciphersLocalData) { ciphersLocalData = {}; } - if (ciphersLocalData[id]) { - ciphersLocalData[id].lastUsedDate = new Date().getTime(); + const cipherId = id as CipherId; + if (ciphersLocalData[cipherId]) { + ciphersLocalData[cipherId].lastUsedDate = new Date().getTime(); } else { - ciphersLocalData[id] = { + ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime(), }; } - await this.stateService.setLocalData(ciphersLocalData); + await this.localDataState.update(() => ciphersLocalData); - const decryptedCipherCache = await this.stateService.getDecryptedCiphers(); + const decryptedCipherCache = await this.getDecryptedCiphers(); if (!decryptedCipherCache) { return; } @@ -471,30 +520,32 @@ export class CipherService implements CipherServiceAbstraction { for (let i = 0; i < decryptedCipherCache.length; i++) { const cached = decryptedCipherCache[i]; if (cached.id === id) { - cached.localData = ciphersLocalData[id]; + cached.localData = ciphersLocalData[id as CipherId]; break; } } - await this.stateService.setDecryptedCiphers(decryptedCipherCache); + await this.setDecryptedCiphers(decryptedCipherCache); } async updateLastLaunchedDate(id: string): Promise { - let ciphersLocalData = await this.stateService.getLocalData(); + let ciphersLocalData = await firstValueFrom(this.localData$); + if (!ciphersLocalData) { ciphersLocalData = {}; } - if (ciphersLocalData[id]) { - ciphersLocalData[id].lastLaunched = new Date().getTime(); + const cipherId = id as CipherId; + if (ciphersLocalData[cipherId]) { + ciphersLocalData[cipherId].lastLaunched = new Date().getTime(); } else { - ciphersLocalData[id] = { + ciphersLocalData[cipherId] = { lastUsedDate: new Date().getTime(), }; } - await this.stateService.setLocalData(ciphersLocalData); + await this.localDataState.update(() => ciphersLocalData); - const decryptedCipherCache = await this.stateService.getDecryptedCiphers(); + const decryptedCipherCache = await this.getDecryptedCiphers(); if (!decryptedCipherCache) { return; } @@ -502,11 +553,11 @@ export class CipherService implements CipherServiceAbstraction { for (let i = 0; i < decryptedCipherCache.length; i++) { const cached = decryptedCipherCache[i]; if (cached.id === id) { - cached.localData = ciphersLocalData[id]; + cached.localData = ciphersLocalData[id as CipherId]; break; } } - await this.stateService.setDecryptedCiphers(decryptedCipherCache); + await this.setDecryptedCiphers(decryptedCipherCache); } async saveNeverDomain(domain: string): Promise { @@ -711,7 +762,7 @@ export class CipherService implements CipherServiceAbstraction { await this.apiService.send("POST", "/ciphers/bulk-collections", request, true, false); // Update the local state - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); for (const id of cipherIds) { const cipher = ciphers[id]; @@ -728,30 +779,29 @@ export class CipherService implements CipherServiceAbstraction { } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async upsert(cipher: CipherData | CipherData[]): Promise { - let ciphers = await this.stateService.getEncryptedCiphers(); - if (ciphers == null) { - ciphers = {}; - } - - if (cipher instanceof CipherData) { - const c = cipher as CipherData; - ciphers[c.id] = c; - } else { - (cipher as CipherData[]).forEach((c) => { - ciphers[c.id] = c; - }); - } - - await this.replace(ciphers); + const ciphers = cipher instanceof CipherData ? [cipher] : cipher; + await this.updateEncryptedCipherState((current) => { + ciphers.forEach((c) => current[c.id as CipherId]); + return current; + }); } async replace(ciphers: { [id: string]: CipherData }): Promise { + await this.updateEncryptedCipherState(() => ciphers); + } + + private async updateEncryptedCipherState( + update: (current: Record) => Record, + ) { await this.clearDecryptedCiphersState(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update((current) => { + const result = update(current ?? {}); + return result; + }); } async clear(userId?: string): Promise { @@ -762,7 +812,7 @@ export class CipherService implements CipherServiceAbstraction { async moveManyWithServer(ids: string[], folderId: string): Promise { await this.apiService.putMoveCiphers(new CipherBulkMoveRequest(ids, folderId)); - let ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { ciphers = {}; } @@ -770,33 +820,34 @@ export class CipherService implements CipherServiceAbstraction { ids.forEach((id) => { // eslint-disable-next-line if (ciphers.hasOwnProperty(id)) { - ciphers[id].folderId = folderId; + ciphers[id as CipherId].folderId = folderId; } }); await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async delete(id: string | string[]): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } if (typeof id === "string") { - if (ciphers[id] == null) { + const cipherId = id as CipherId; + if (ciphers[cipherId] == null) { return; } - delete ciphers[id]; + delete ciphers[cipherId]; } else { - (id as string[]).forEach((i) => { + (id as CipherId[]).forEach((i) => { delete ciphers[i]; }); } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => ciphers); } async deleteWithServer(id: string, asAdmin = false): Promise { @@ -820,21 +871,26 @@ export class CipherService implements CipherServiceAbstraction { } async deleteAttachment(id: string, attachmentId: string): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); - + let ciphers = await firstValueFrom(this.ciphers$); + const cipherId = id as CipherId; // eslint-disable-next-line - if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[id].attachments == null) { + if (ciphers == null || !ciphers.hasOwnProperty(id) || ciphers[cipherId].attachments == null) { return; } - for (let i = 0; i < ciphers[id].attachments.length; i++) { - if (ciphers[id].attachments[i].id === attachmentId) { - ciphers[id].attachments.splice(i, 1); + for (let i = 0; i < ciphers[cipherId].attachments.length; i++) { + if (ciphers[cipherId].attachments[i].id === attachmentId) { + ciphers[cipherId].attachments.splice(i, 1); } } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async deleteAttachmentWithServer(id: string, attachmentId: string): Promise { @@ -917,12 +973,12 @@ export class CipherService implements CipherServiceAbstraction { } async softDelete(id: string | string[]): Promise { - const ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } - const setDeletedDate = (cipherId: string) => { + const setDeletedDate = (cipherId: CipherId) => { if (ciphers[cipherId] == null) { return; } @@ -930,13 +986,18 @@ export class CipherService implements CipherServiceAbstraction { }; if (typeof id === "string") { - setDeletedDate(id); + setDeletedDate(id as CipherId); } else { (id as string[]).forEach(setDeletedDate); } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async softDeleteWithServer(id: string, asAdmin = false): Promise { @@ -963,17 +1024,18 @@ export class CipherService implements CipherServiceAbstraction { async restore( cipher: { id: string; revisionDate: string } | { id: string; revisionDate: string }[], ) { - const ciphers = await this.stateService.getEncryptedCiphers(); + let ciphers = await firstValueFrom(this.ciphers$); if (ciphers == null) { return; } const clearDeletedDate = (c: { id: string; revisionDate: string }) => { - if (ciphers[c.id] == null) { + const cipherId = c.id as CipherId; + if (ciphers[cipherId] == null) { return; } - ciphers[c.id].deletedDate = null; - ciphers[c.id].revisionDate = c.revisionDate; + ciphers[cipherId].deletedDate = null; + ciphers[cipherId].revisionDate = c.revisionDate; }; if (cipher.constructor.name === Array.name) { @@ -983,7 +1045,12 @@ export class CipherService implements CipherServiceAbstraction { } await this.clearCache(); - await this.stateService.setEncryptedCiphers(ciphers); + await this.encryptedCiphersState.update(() => { + if (ciphers == null) { + ciphers = {}; + } + return ciphers; + }); } async restoreWithServer(id: string, asAdmin = false): Promise { @@ -1025,6 +1092,10 @@ export class CipherService implements CipherServiceAbstraction { ); } + async setAddEditCipherInfo(value: AddEditCipherInfo) { + await this.addEditCipherInfoState.update(() => value); + } + // Helpers // In the case of a cipher that is being shared with an organization, we want to decrypt the @@ -1350,11 +1421,11 @@ export class CipherService implements CipherServiceAbstraction { } private async clearEncryptedCiphersState(userId?: string) { - await this.stateService.setEncryptedCiphers(null, { userId: userId }); + await this.encryptedCiphersState.update(() => ({})); } private async clearDecryptedCiphersState(userId?: string) { - await this.stateService.setDecryptedCiphers(null, { userId: userId }); + await this.setDecryptedCiphers(null); this.clearSortedCiphers(); } diff --git a/libs/common/src/vault/services/folder/folder.service.spec.ts b/libs/common/src/vault/services/folder/folder.service.spec.ts index 88595720e2..8c3be9abe8 100644 --- a/libs/common/src/vault/services/folder/folder.service.spec.ts +++ b/libs/common/src/vault/services/folder/folder.service.spec.ts @@ -8,7 +8,6 @@ import { FakeStateProvider } from "../../../../spec/fake-state-provider"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { EncryptService } from "../../../platform/abstractions/encrypt.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { EncString } from "../../../platform/models/domain/enc-string"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; @@ -27,7 +26,6 @@ describe("Folder Service", () => { let encryptService: MockProxy; let i18nService: MockProxy; let cipherService: MockProxy; - let stateService: MockProxy; let stateProvider: FakeStateProvider; const mockUserId = Utils.newGuid() as UserId; @@ -39,7 +37,6 @@ describe("Folder Service", () => { encryptService = mock(); i18nService = mock(); cipherService = mock(); - stateService = mock(); accountService = mockAccountServiceWith(mockUserId); stateProvider = new FakeStateProvider(accountService); @@ -52,13 +49,7 @@ describe("Folder Service", () => { ); encryptService.decryptToUtf8.mockResolvedValue("DEC"); - folderService = new FolderService( - cryptoService, - i18nService, - cipherService, - stateService, - stateProvider, - ); + folderService = new FolderService(cryptoService, i18nService, cipherService, stateProvider); folderState = stateProvider.activeUser.getFake(FOLDER_ENCRYPTED_FOLDERS); diff --git a/libs/common/src/vault/services/folder/folder.service.ts b/libs/common/src/vault/services/folder/folder.service.ts index afe3b01c68..584567aee8 100644 --- a/libs/common/src/vault/services/folder/folder.service.ts +++ b/libs/common/src/vault/services/folder/folder.service.ts @@ -2,17 +2,16 @@ import { Observable, firstValueFrom, map } from "rxjs"; import { CryptoService } from "../../../platform/abstractions/crypto.service"; import { I18nService } from "../../../platform/abstractions/i18n.service"; -import { StateService } from "../../../platform/abstractions/state.service"; import { Utils } from "../../../platform/misc/utils"; import { SymmetricCryptoKey } from "../../../platform/models/domain/symmetric-crypto-key"; import { ActiveUserState, DerivedState, StateProvider } from "../../../platform/state"; import { UserId } from "../../../types/guid"; import { CipherService } from "../../../vault/abstractions/cipher.service"; import { InternalFolderService as InternalFolderServiceAbstraction } from "../../../vault/abstractions/folder/folder.service.abstraction"; -import { CipherData } from "../../../vault/models/data/cipher.data"; import { FolderData } from "../../../vault/models/data/folder.data"; import { Folder } from "../../../vault/models/domain/folder"; import { FolderView } from "../../../vault/models/view/folder.view"; +import { Cipher } from "../../models/domain/cipher"; import { FOLDER_DECRYPTED_FOLDERS, FOLDER_ENCRYPTED_FOLDERS } from "../key-state/folder.state"; export class FolderService implements InternalFolderServiceAbstraction { @@ -26,7 +25,6 @@ export class FolderService implements InternalFolderServiceAbstraction { private cryptoService: CryptoService, private i18nService: I18nService, private cipherService: CipherService, - private stateService: StateService, private stateProvider: StateProvider, ) { this.encryptedFoldersState = this.stateProvider.getActive(FOLDER_ENCRYPTED_FOLDERS); @@ -144,9 +142,9 @@ export class FolderService implements InternalFolderServiceAbstraction { }); // Items in a deleted folder are re-assigned to "No Folder" - const ciphers = await this.stateService.getEncryptedCiphers(); + const ciphers = await this.cipherService.getAll(); if (ciphers != null) { - const updates: CipherData[] = []; + const updates: Cipher[] = []; for (const cId in ciphers) { if (ciphers[cId].folderId === id) { ciphers[cId].folderId = null; @@ -156,7 +154,7 @@ export class FolderService implements InternalFolderServiceAbstraction { if (updates.length > 0) { // FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling. // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.cipherService.upsert(updates); + this.cipherService.upsert(updates.map((c) => c.toCipherData())); } } } diff --git a/libs/common/src/vault/services/key-state/ciphers.state.ts b/libs/common/src/vault/services/key-state/ciphers.state.ts new file mode 100644 index 0000000000..71da4c2333 --- /dev/null +++ b/libs/common/src/vault/services/key-state/ciphers.state.ts @@ -0,0 +1,52 @@ +import { Jsonify } from "type-fest"; + +import { + CIPHERS_DISK, + CIPHERS_DISK_LOCAL, + CIPHERS_MEMORY, + KeyDefinition, +} from "../../../platform/state"; +import { CipherId } from "../../../types/guid"; +import { CipherData } from "../../models/data/cipher.data"; +import { LocalData } from "../../models/data/local.data"; +import { CipherView } from "../../models/view/cipher.view"; +import { AddEditCipherInfo } from "../../types/add-edit-cipher-info"; + +export const ENCRYPTED_CIPHERS = KeyDefinition.record(CIPHERS_DISK, "ciphers", { + deserializer: (obj: Jsonify) => CipherData.fromJSON(obj), +}); + +export const DECRYPTED_CIPHERS = KeyDefinition.record( + CIPHERS_MEMORY, + "decryptedCiphers", + { + deserializer: (cipher: Jsonify) => CipherView.fromJSON(cipher), + }, +); + +export const LOCAL_DATA_KEY = new KeyDefinition>( + CIPHERS_DISK_LOCAL, + "localData", + { + deserializer: (localData) => localData, + }, +); + +export const ADD_EDIT_CIPHER_INFO_KEY = new KeyDefinition( + CIPHERS_MEMORY, + "addEditCipherInfo", + { + deserializer: (addEditCipherInfo: AddEditCipherInfo) => { + if (addEditCipherInfo == null) { + return null; + } + + const cipher = + addEditCipherInfo?.cipher.toJSON != null + ? addEditCipherInfo.cipher + : CipherView.fromJSON(addEditCipherInfo?.cipher as Jsonify); + + return { cipher, collectionIds: addEditCipherInfo.collectionIds }; + }, + }, +);