From 399b8c2b3476e798ad345d8877d18032d1affedd Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Mon, 27 Jun 2022 13:38:12 -0400 Subject: [PATCH] PS-813 Add memory storage to state service (#2892) * Use abstract methods and generics in StorageService * Prepend `Abstract` to abstract classes * Create session browser storage service * Use memory storage service for state memory * Inject memory storage service * Maintain filename extensions to help ide formatting * Preserve state if it's still in memory * Use jslib's memory storage service * linter * Create prototypes on stored objects * standardize package scripts * Add type safety to `withPrototype` decorators * webpack notify manifest version * Fix desktop * linter * Fix script * Improve prototye application * do not change prototype if it already matches desired * fix error with object values prototype application * Handle null state * Apply prototypes to browser-specific state * Add angular language server to recommended extensions * Improve browser state service tests * Start testing state Service * Fix abstract returns * Move test setup files to not be picked up by default glob matchers * Add key generation service * Add low-dependency encrypt service * Back crypto service with encrypt service. We'll want to work items that don't require state over to encrypt service * Add new storage service and tests * Properly init more stored values * Fix reload issues when state service is recovering state from session storage Co-authored-by: Thomas Avery Co-authored-by: Justin Baur * Simplify encrypt service * Do not log mac failures for local-backed session storage * `content` changed to `main` in #2245 * Fix CLI * Remove loggin * PR feedback * Merge remote-tracking branch 'origin/master' into add-memory-storage-to-state-service * Fix desktop * Fix decrypt method signature * Minify if not development * Key is required Co-authored-by: Thomas Avery Co-authored-by: Justin Baur --- .storybook/tsconfig.json | 2 +- apps/browser/jest.config.js | 1 + apps/browser/package.json | 6 +- .../browser/src/background/main.background.ts | 30 +- .../{manifest.json.v3 => manifest.v3.json} | 4 +- .../src/popup/services/popup-utils.service.ts | 4 +- .../src/popup/services/services.module.ts | 13 +- ...ts => abstractChromeStorageApi.service.ts} | 14 +- .../abstractKeyGeneration.service.ts | 5 + .../services/browserLocalStorage.service.ts | 5 + .../services/browserMemoryStorage.service.ts | 5 + .../src/services/keyGeneration.service.ts | 20 + .../localBackedSessionStorage.service.spec.ts | 308 +++++++ .../localBackedSessionStorage.service.ts | 107 +++ .../src/services/state.service.spec.ts | 109 +++ apps/browser/src/services/state.service.ts | 60 +- apps/browser/test.setup.ts | 26 + apps/browser/webpack.config.js | 9 +- apps/cli/jest.config.js | 2 +- apps/cli/spec/{test.ts => test.setup.ts} | 0 apps/cli/src/bw.ts | 9 + .../services/nodeEnvSecureStorage.service.ts | 6 +- .../src/app/services/services.module.ts | 12 +- apps/desktop/src/main.ts | 4 + apps/web/src/app/services/services.module.ts | 16 +- apps/web/src/services/htmlStorage.service.ts | 4 +- apps/web/src/services/state.service.ts | 20 +- clients.code-workspace | 29 +- libs/angular/jest.config.js | 2 +- libs/angular/spec/{test.ts => test.setup.ts} | 0 .../src/services/jslib-services.module.ts | 20 +- libs/common/jest.config.js | 2 +- .../spec/services/state.service.spec.ts | 82 ++ ...vice.ts => stateMigration.service.spec.ts} | 12 +- libs/common/spec/{test.ts => test.setup.ts} | 0 .../abstractions/abstractEncrypt.service.ts | 7 + .../src/abstractions/storage.service.ts | 10 +- libs/common/src/models/domain/account.ts | 2 + .../src/models/domain/symmetricCryptoKey.ts | 18 + libs/common/src/services/appId.service.ts | 4 +- libs/common/src/services/crypto.service.ts | 75 +- libs/common/src/services/encrypt.service.ts | 94 +++ .../src/services/memoryStorage.service.ts | 4 +- libs/common/src/services/state.service.ts | 751 +++++++++++++----- .../src/services/stateMigration.service.ts | 6 +- libs/components/jest.config.js | 2 +- .../spec/{test.ts => test.setup.ts} | 0 .../components/src/{test.ts => test.setup.ts} | 0 libs/electron/jest.config.js | 2 +- libs/electron/spec/{test.ts => test.setup.ts} | 0 .../src/services/electronCrypto.service.ts | 4 +- .../electronRendererSecureStorage.service.ts | 4 +- .../electronRendererStorage.service.ts | 4 +- .../src/services/electronStorage.service.ts | 4 +- libs/node/jest.config.js | 2 +- libs/node/spec/{test.ts => test.setup.ts} | 0 .../node/src/services/lowdbStorage.service.ts | 4 +- 57 files changed, 1575 insertions(+), 370 deletions(-) rename apps/browser/src/{manifest.json.v3 => manifest.v3.json} (98%) rename apps/browser/src/services/{browserStorage.service.ts => abstractChromeStorageApi.service.ts} (73%) create mode 100644 apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts create mode 100644 apps/browser/src/services/browserLocalStorage.service.ts create mode 100644 apps/browser/src/services/browserMemoryStorage.service.ts create mode 100644 apps/browser/src/services/keyGeneration.service.ts create mode 100644 apps/browser/src/services/localBackedSessionStorage.service.spec.ts create mode 100644 apps/browser/src/services/localBackedSessionStorage.service.ts create mode 100644 apps/browser/src/services/state.service.spec.ts create mode 100644 apps/browser/test.setup.ts rename apps/cli/spec/{test.ts => test.setup.ts} (100%) rename libs/angular/spec/{test.ts => test.setup.ts} (100%) create mode 100644 libs/common/spec/services/state.service.spec.ts rename libs/common/spec/services/{stateMigration.service.ts => stateMigration.service.spec.ts} (85%) rename libs/common/spec/{test.ts => test.setup.ts} (100%) create mode 100644 libs/common/src/abstractions/abstractEncrypt.service.ts create mode 100644 libs/common/src/services/encrypt.service.ts rename {apps/web => libs/common}/src/services/memoryStorage.service.ts (78%) rename libs/components/spec/{test.ts => test.setup.ts} (100%) rename libs/components/src/{test.ts => test.setup.ts} (100%) rename libs/electron/spec/{test.ts => test.setup.ts} (100%) rename libs/node/spec/{test.ts => test.setup.ts} (100%) diff --git a/.storybook/tsconfig.json b/.storybook/tsconfig.json index 89c8f20b35..a55b6597e4 100644 --- a/.storybook/tsconfig.json +++ b/.storybook/tsconfig.json @@ -4,7 +4,7 @@ "types": ["node"], "allowSyntheticDefaultImports": true }, - "exclude": ["../src/test.ts", "../src/**/*.spec.ts", "../projects/**/*.spec.ts"], + "exclude": ["../src/test.setup.ts", "../src/**/*.spec.ts", "../projects/**/*.spec.ts"], "include": ["../src/**/*", "../projects/**/*"], "files": ["./typings.d.ts"] } diff --git a/apps/browser/jest.config.js b/apps/browser/jest.config.js index 025db301eb..dca19ba3bc 100644 --- a/apps/browser/jest.config.js +++ b/apps/browser/jest.config.js @@ -6,6 +6,7 @@ module.exports = { collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", + setupFilesAfterEnv: ["/test.setup.ts"], preset: "jest-preset-angular", moduleNameMapper: pathsToModuleNameMapper(compilerOptions?.paths || {}, { prefix: "/", diff --git a/apps/browser/package.json b/apps/browser/package.json index 427632db33..7d2b7d34ec 100644 --- a/apps/browser/package.json +++ b/apps/browser/package.json @@ -3,12 +3,14 @@ "version": "0.0.0", "scripts": { "build": "webpack", + "build:mv3": "cross-env MANIFEST_VERSION=3 webpack", "build:watch": "webpack --watch", - "build:watch:MV3": "cross-env MANIFEST_VERSION=3 webpack --watch", + "build:watch:mv3": "cross-env MANIFEST_VERSION=3 webpack --watch", "build:prod": "cross-env NODE_ENV=production webpack", "build:prod:watch": "cross-env NODE_ENV=production webpack --watch", "dist": "npm run build:prod && gulp dist", - "dist:chromeMV3": "cross-env MANIFEST_VERSION=3 npm run build:prod && gulp dist:chrome", + "dist:chrome": "npm run build:prod && gulp dist:chrome", + "dist:chrome:mv3": "cross-env MANIFEST_VERSION=3 npm run build:prod && gulp dist:chrome", "dist:firefox": "npm run build:prod && gulp dist:firefox", "dist:opera": "npm run build:prod && gulp dist:opera", "dist:safari": "npm run build:prod && gulp dist:safari", diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index 5bd72307ea..725cb1954b 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -24,7 +24,7 @@ import { ProviderService as ProviderServiceAbstraction } from "@bitwarden/common import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abstractions/search.service"; import { SendService as SendServiceAbstraction } from "@bitwarden/common/abstractions/send.service"; import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; -import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/abstractions/sync.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/abstractions/system.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/abstractions/token.service"; @@ -47,12 +47,14 @@ import { CipherService } from "@bitwarden/common/services/cipher.service"; import { CollectionService } from "@bitwarden/common/services/collection.service"; import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; import { ContainerService } from "@bitwarden/common/services/container.service"; +import { EncryptService } from "@bitwarden/common/services/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/services/environment.service"; import { EventService } from "@bitwarden/common/services/event.service"; import { ExportService } from "@bitwarden/common/services/export.service"; import { FileUploadService } from "@bitwarden/common/services/fileUpload.service"; import { FolderService } from "@bitwarden/common/services/folder.service"; import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { NotificationsService } from "@bitwarden/common/services/notifications.service"; import { OrganizationService } from "@bitwarden/common/services/organization.service"; import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service"; @@ -79,11 +81,13 @@ import { AutofillService as AutofillServiceAbstraction } from "../services/abstr import { StateService as StateServiceAbstraction } from "../services/abstractions/state.service"; import AutofillService from "../services/autofill.service"; import { BrowserCryptoService } from "../services/browserCrypto.service"; +import BrowserLocalStorageService from "../services/browserLocalStorage.service"; import BrowserMessagingService from "../services/browserMessaging.service"; import BrowserMessagingPrivateModeBackgroundService from "../services/browserMessagingPrivateModeBackground.service"; import BrowserPlatformUtilsService from "../services/browserPlatformUtils.service"; -import BrowserStorageService from "../services/browserStorage.service"; import I18nService from "../services/i18n.service"; +import { KeyGenerationService } from "../services/keyGeneration.service"; +import { LocalBackedSessionStorageService } from "../services/localBackedSessionStorage.service"; import { StateService } from "../services/state.service"; import { VaultFilterService } from "../services/vaultFilter.service"; import VaultTimeoutService from "../services/vaultTimeout.service"; @@ -100,8 +104,9 @@ import WebRequestBackground from "./webRequest.background"; export default class MainBackground { messagingService: MessagingServiceAbstraction; - storageService: StorageServiceAbstraction; - secureStorageService: StorageServiceAbstraction; + storageService: AbstractStorageService; + secureStorageService: AbstractStorageService; + memoryStorageService: AbstractStorageService; i18nService: I18nServiceAbstraction; platformUtilsService: PlatformUtilsServiceAbstraction; logService: LogServiceAbstraction; @@ -141,6 +146,7 @@ export default class MainBackground { twoFactorService: TwoFactorServiceAbstraction; vaultFilterService: VaultFilterService; usernameGenerationService: UsernameGenerationServiceAbstraction; + encryptService: EncryptService; onUpdatedRan: boolean; onReplacedRan: boolean; @@ -181,9 +187,17 @@ export default class MainBackground { this.messagingService = isPrivateMode ? new BrowserMessagingPrivateModeBackgroundService() : new BrowserMessagingService(); - this.storageService = new BrowserStorageService(); - this.secureStorageService = new BrowserStorageService(); this.logService = new ConsoleLogService(false); + this.cryptoFunctionService = new WebCryptoFunctionService(window); + this.storageService = new BrowserLocalStorageService(); + this.secureStorageService = new BrowserLocalStorageService(); + this.memoryStorageService = + chrome.runtime.getManifest().manifest_version == 3 + ? new LocalBackedSessionStorageService( + new EncryptService(this.cryptoFunctionService, this.logService, false), + new KeyGenerationService(this.cryptoFunctionService) + ) + : new MemoryStorageService(); this.stateMigrationService = new StateMigrationService( this.storageService, this.secureStorageService, @@ -192,6 +206,7 @@ export default class MainBackground { this.stateService = new StateService( this.storageService, this.secureStorageService, + this.memoryStorageService, this.logService, this.stateMigrationService, new StateFactory(GlobalState, Account) @@ -219,9 +234,10 @@ export default class MainBackground { } ); this.i18nService = new I18nService(BrowserApi.getUILanguage(window)); - this.cryptoFunctionService = new WebCryptoFunctionService(window); + this.encryptService = new EncryptService(this.cryptoFunctionService, this.logService, true); this.cryptoService = new BrowserCryptoService( this.cryptoFunctionService, + this.encryptService, this.platformUtilsService, this.logService, this.stateService diff --git a/apps/browser/src/manifest.json.v3 b/apps/browser/src/manifest.v3.json similarity index 98% rename from apps/browser/src/manifest.json.v3 rename to apps/browser/src/manifest.v3.json index 18e5db2217..b0fdc92459 100644 --- a/apps/browser/src/manifest.json.v3 +++ b/apps/browser/src/manifest.v3.json @@ -64,9 +64,7 @@ "unlimitedStorage", "clipboardRead", "clipboardWrite", - "idle", - "webRequest", - "declarativeNetRequest" + "idle" ], "optional_permissions": ["nativeMessaging"], "host_permissions": ["http://*/*", "https://*/*"], diff --git a/apps/browser/src/popup/services/popup-utils.service.ts b/apps/browser/src/popup/services/popup-utils.service.ts index 3b2556b99d..c224f417a3 100644 --- a/apps/browser/src/popup/services/popup-utils.service.ts +++ b/apps/browser/src/popup/services/popup-utils.service.ts @@ -30,12 +30,12 @@ export class PopupUtilsService { return this.privateMode; } - getContentScrollY(win: Window, scrollingContainer = "content"): number { + getContentScrollY(win: Window, scrollingContainer = "main"): number { const content = win.document.getElementsByTagName(scrollingContainer)[0]; return content.scrollTop; } - setContentScrollY(win: Window, scrollY: number, scrollingContainer = "content"): void { + setContentScrollY(win: Window, scrollY: number, scrollingContainer = "main"): void { if (scrollY != null) { const content = win.document.getElementsByTagName(scrollingContainer)[0]; content.scrollTop = scrollY; diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 265776ab23..62b468e19e 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -4,6 +4,7 @@ import { LockGuard as BaseLockGuardService } from "@bitwarden/angular/guards/loc import { UnauthGuard as BaseUnauthGuardService } from "@bitwarden/angular/guards/unauth.guard"; import { JslibServicesModule, + MEMORY_STORAGE, SECURE_STORAGE, } from "@bitwarden/angular/services/jslib-services.module"; import { ApiService } from "@bitwarden/common/abstractions/api.service"; @@ -34,7 +35,7 @@ import { SearchService as SearchServiceAbstraction } from "@bitwarden/common/abs import { SendService } from "@bitwarden/common/abstractions/send.service"; import { SettingsService } from "@bitwarden/common/abstractions/settings.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; -import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SyncService } from "@bitwarden/common/abstractions/sync.service"; import { TokenService } from "@bitwarden/common/abstractions/token.service"; import { TotpService } from "@bitwarden/common/abstractions/totp.service"; @@ -185,8 +186,8 @@ function getBgService(service: keyof MainBackground) { deps: [], }, { - provide: StorageServiceAbstraction, - useFactory: getBgService("storageService"), + provide: AbstractStorageService, + useFactory: getBgService("storageService"), deps: [], }, { provide: AppIdService, useFactory: getBgService("appIdService"), deps: [] }, @@ -249,9 +250,13 @@ function getBgService(service: keyof MainBackground) { }, { provide: SECURE_STORAGE, - useFactory: getBgService("secureStorageService"), + useFactory: getBgService("secureStorageService"), deps: [], }, + { + provide: MEMORY_STORAGE, + useFactory: getBgService("memoryStorageService"), + }, { provide: StateServiceAbstraction, useFactory: getBgService("stateService"), diff --git a/apps/browser/src/services/browserStorage.service.ts b/apps/browser/src/services/abstractChromeStorageApi.service.ts similarity index 73% rename from apps/browser/src/services/browserStorage.service.ts rename to apps/browser/src/services/abstractChromeStorageApi.service.ts index 842363ecdb..e6784570af 100644 --- a/apps/browser/src/services/browserStorage.service.ts +++ b/apps/browser/src/services/abstractChromeStorageApi.service.ts @@ -1,11 +1,7 @@ -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; -export default class BrowserStorageService implements StorageService { - private chromeStorageApi: any; - - constructor() { - this.chromeStorageApi = chrome.storage.local; - } +export default abstract class AbstractChromeStorageService implements AbstractStorageService { + protected abstract chromeStorageApi: any; async get(key: string): Promise { return new Promise((resolve) => { @@ -23,7 +19,7 @@ export default class BrowserStorageService implements StorageService { return (await this.get(key)) != null; } - async save(key: string, obj: any): Promise { + async save(key: string, obj: any): Promise { if (obj == null) { // Fix safari not liking null in set return new Promise((resolve) => { @@ -45,7 +41,7 @@ export default class BrowserStorageService implements StorageService { }); } - async remove(key: string): Promise { + async remove(key: string): Promise { return new Promise((resolve) => { this.chromeStorageApi.remove(key, () => { resolve(); diff --git a/apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts b/apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts new file mode 100644 index 0000000000..ec6c758d96 --- /dev/null +++ b/apps/browser/src/services/abstractions/abstractKeyGeneration.service.ts @@ -0,0 +1,5 @@ +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + +export interface AbstractKeyGenerationService { + makeEphemeralKey(numBytes?: number): Promise; +} diff --git a/apps/browser/src/services/browserLocalStorage.service.ts b/apps/browser/src/services/browserLocalStorage.service.ts new file mode 100644 index 0000000000..9556a6e4e2 --- /dev/null +++ b/apps/browser/src/services/browserLocalStorage.service.ts @@ -0,0 +1,5 @@ +import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; + +export default class BrowserLocalStorageService extends AbstractChromeStorageService { + protected chromeStorageApi: any = chrome.storage.local; +} diff --git a/apps/browser/src/services/browserMemoryStorage.service.ts b/apps/browser/src/services/browserMemoryStorage.service.ts new file mode 100644 index 0000000000..a1195d1a44 --- /dev/null +++ b/apps/browser/src/services/browserMemoryStorage.service.ts @@ -0,0 +1,5 @@ +import AbstractChromeStorageService from "./abstractChromeStorageApi.service"; + +export default class BrowserMemoryStorageService extends AbstractChromeStorageService { + protected chromeStorageApi: any = (chrome.storage as any).session; +} diff --git a/apps/browser/src/services/keyGeneration.service.ts b/apps/browser/src/services/keyGeneration.service.ts new file mode 100644 index 0000000000..f6e1160a14 --- /dev/null +++ b/apps/browser/src/services/keyGeneration.service.ts @@ -0,0 +1,20 @@ +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + +import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; + +export class KeyGenerationService implements AbstractKeyGenerationService { + constructor(private cryptoFunctionService: CryptoFunctionService) {} + + async makeEphemeralKey(numBytes = 16): Promise { + const keyMaterial = await this.cryptoFunctionService.randomBytes(numBytes); + const key = await this.cryptoFunctionService.hkdf( + keyMaterial, + "bitwarden-ephemeral", + "ephemeral", + 64, + "sha256" + ); + return new SymmetricCryptoKey(key); + } +} diff --git a/apps/browser/src/services/localBackedSessionStorage.service.spec.ts b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts new file mode 100644 index 0000000000..de88b6d8b2 --- /dev/null +++ b/apps/browser/src/services/localBackedSessionStorage.service.spec.ts @@ -0,0 +1,308 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { Utils } from "@bitwarden/common/misc/utils"; +import { EncString } from "@bitwarden/common/models/domain/encString"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { EncryptService } from "@bitwarden/common/src/services/encrypt.service"; + +import BrowserLocalStorageService from "./browserLocalStorage.service"; +import BrowserMemoryStorageService from "./browserMemoryStorage.service"; +import { KeyGenerationService } from "./keyGeneration.service"; +import { LocalBackedSessionStorageService } from "./localBackedSessionStorage.service"; + +describe("Browser Session Storage Service", () => { + let encryptService: SubstituteOf; + let keyGenerationService: SubstituteOf; + + let cache: Map; + const testObj = { a: 1, b: 2 }; + + let localStorage: BrowserLocalStorageService; + let sessionStorage: BrowserMemoryStorageService; + + const key = new SymmetricCryptoKey( + Utils.fromUtf8ToArray("00000000000000000000000000000000").buffer + ); + let getSessionKeySpy: jest.SpyInstance; + const mockEnc = (input: string) => Promise.resolve(new EncString("ENCRYPTED" + input)); + + let sut: LocalBackedSessionStorageService; + + beforeEach(() => { + encryptService = Substitute.for(); + keyGenerationService = Substitute.for(); + + sut = new LocalBackedSessionStorageService(encryptService, keyGenerationService); + + cache = sut["cache"]; + localStorage = sut["localStorage"]; + sessionStorage = sut["sessionStorage"]; + getSessionKeySpy = jest.spyOn(sut, "getSessionEncKey"); + getSessionKeySpy.mockResolvedValue(key); + }); + + it("should exist", () => { + expect(sut).toBeInstanceOf(LocalBackedSessionStorageService); + }); + + describe("get", () => { + it("should return from cache", async () => { + cache.set("test", testObj); + const result = await sut.get("test"); + expect(result).toStrictEqual(testObj); + }); + + describe("not in cache", () => { + const session = { test: testObj }; + + beforeEach(() => { + jest.spyOn(sut, "getSessionEncKey").mockResolvedValue(key); + }); + + describe("no session retrieved", () => { + let result: any; + let spy: jest.SpyInstance; + beforeEach(async () => { + spy = jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); + result = await sut.get("test"); + }); + + it("should grab from session if not in cache", async () => { + expect(spy).toHaveBeenCalledWith(key); + }); + + it("should return null if session is null", async () => { + expect(result).toBeNull(); + }); + }); + + describe("session retrieved from storage", () => { + beforeEach(() => { + jest.spyOn(sut, "getLocalSession").mockResolvedValue(session); + }); + + it("should return null if session does not have the key", async () => { + const result = await sut.get("DNE"); + expect(result).toBeNull(); + }); + + it("should return the value retrieved from session", async () => { + const result = await sut.get("test"); + expect(result).toEqual(session.test); + }); + + it("should set retrieved values in cache", async () => { + await sut.get("test"); + expect(cache.has("test")).toBe(true); + expect(cache.get("test")).toEqual(session.test); + }); + }); + }); + }); + + describe("has", () => { + it("should be false if `get` returns null", async () => { + const spy = jest.spyOn(sut, "get"); + spy.mockResolvedValue(null); + expect(await sut.has("test")).toBe(false); + expect(spy).toHaveBeenCalledWith("test"); + }); + + it("should be true if `get` returns non-null", async () => { + const spy = jest.spyOn(sut, "get"); + spy.mockResolvedValue({}); + expect(await sut.has("test")).toBe(true); + expect(spy).toHaveBeenCalledWith("test"); + }); + }); + + describe("remove", () => { + it("should save null", async () => { + const spy = jest.spyOn(sut, "save"); + spy.mockResolvedValue(null); + await sut.remove("test"); + expect(spy).toHaveBeenCalledWith("test", null); + }); + }); + + describe("save", () => { + describe("caching", () => { + beforeEach(() => { + jest.spyOn(localStorage, "get").mockResolvedValue(null); + jest.spyOn(sessionStorage, "get").mockResolvedValue(null); + jest.spyOn(localStorage, "save").mockResolvedValue(); + jest.spyOn(sessionStorage, "save").mockResolvedValue(); + }); + + it("should remove key from cache if value is null", async () => { + cache.set("test", {}); + const deleteSpy = jest.spyOn(cache, "delete"); + expect(cache.has("test")).toBe(true); + await sut.save("test", null); + expect(cache.has("test")).toBe(false); + expect(deleteSpy).toHaveBeenCalledWith("test"); + }); + + it("should set cache if value is non-null", async () => { + expect(cache.has("test")).toBe(false); + const setSpy = jest.spyOn(cache, "set"); + await sut.save("test", testObj); + expect(cache.get("test")).toBe(testObj); + expect(setSpy).toHaveBeenCalledWith("test", testObj); + }); + }); + + describe("local storing", () => { + let setSpy: jest.SpyInstance; + + beforeEach(() => { + setSpy = jest.spyOn(sut, "setLocalSession").mockResolvedValue(); + }); + + it("should store a new session", async () => { + jest.spyOn(sut, "getLocalSession").mockResolvedValue(null); + await sut.save("test", testObj); + + expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); + }); + + it("should update an existing session", async () => { + const existingObj = { test: testObj }; + jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); + await sut.save("test2", testObj); + + expect(setSpy).toHaveBeenCalledWith({ test2: testObj, ...existingObj }, key); + }); + + it("should overwrite an existing item in session", async () => { + const existingObj = { test: {} }; + jest.spyOn(sut, "getLocalSession").mockResolvedValue(existingObj); + await sut.save("test", testObj); + + expect(setSpy).toHaveBeenCalledWith({ test: testObj }, key); + }); + }); + }); + + describe("getSessionKey", () => { + beforeEach(() => { + getSessionKeySpy.mockRestore(); + }); + + it("should return the stored symmetric crypto key", async () => { + jest.spyOn(sessionStorage, "get").mockResolvedValue({ ...key }); + const result = await sut.getSessionEncKey(); + + expect(result).toStrictEqual(key); + }); + + describe("new key creation", () => { + beforeEach(() => { + jest.spyOn(sessionStorage, "get").mockResolvedValue(null); + keyGenerationService.makeEphemeralKey().resolves(key); + jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); + }); + + it("should create a symmetric crypto key", async () => { + const result = await sut.getSessionEncKey(); + + expect(result).toStrictEqual(key); + keyGenerationService.received(1).makeEphemeralKey(); + }); + + it("should store a symmetric crypto key if it makes one", async () => { + const spy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); + await sut.getSessionEncKey(); + + expect(spy).toBeCalledWith(key); + }); + }); + }); + + describe("getLocalSession", () => { + it("should return null if session is null", async () => { + const spy = jest.spyOn(localStorage, "get").mockResolvedValue(null); + const result = await sut.getLocalSession(key); + + expect(result).toBeNull(); + expect(spy).toBeCalledWith("session"); + }); + + describe("non-null sessions", () => { + const session = { test: "test" }; + const encSession = new EncString(JSON.stringify(session)); + const decryptedSession = JSON.stringify(session); + + beforeEach(() => { + jest.spyOn(localStorage, "get").mockResolvedValue(encSession.encryptedString); + }); + + it("should decrypt returned sessions", async () => { + encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); + await sut.getLocalSession(key); + encryptService.received(1).decryptToUtf8(encSession, key); + }); + + it("should parse session", async () => { + encryptService.decryptToUtf8(encSession, key).resolves(decryptedSession); + const result = await sut.getLocalSession(key); + expect(result).toEqual(session); + }); + + it("should remove state if decryption fails", async () => { + encryptService.decryptToUtf8(Arg.any(), Arg.any()).resolves(null); + const setSessionEncKeySpy = jest.spyOn(sut, "setSessionEncKey").mockResolvedValue(); + const removeLocalSessionSpy = jest.spyOn(localStorage, "remove").mockResolvedValue(); + + const result = await sut.getLocalSession(key); + + expect(result).toBeNull(); + expect(setSessionEncKeySpy).toHaveBeenCalledWith(null); + expect(removeLocalSessionSpy).toHaveBeenCalledWith("session"); + }); + }); + }); + + describe("setLocalSession", () => { + const testSession = { test: "a" }; + const testJSON = JSON.stringify(testSession); + + it("should encrypt a stringified session", async () => { + encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); + jest.spyOn(localStorage, "save").mockResolvedValue(); + await sut.setLocalSession(testSession, key); + + encryptService.received(1).encrypt(testJSON, key); + }); + + it("should remove local session if null", async () => { + encryptService.encrypt(Arg.any(), Arg.any()).resolves(null); + const spy = jest.spyOn(localStorage, "remove").mockResolvedValue(); + await sut.setLocalSession(null, key); + + expect(spy).toHaveBeenCalledWith("session"); + }); + + it("should save encrypted string", async () => { + encryptService.encrypt(Arg.any(), Arg.any()).mimicks(mockEnc); + const spy = jest.spyOn(localStorage, "save").mockResolvedValue(); + await sut.setLocalSession(testSession, key); + + expect(spy).toHaveBeenCalledWith("session", (await mockEnc(testJSON)).encryptedString); + }); + }); + + describe("setSessionKey", () => { + it("should remove if null", async () => { + const spy = jest.spyOn(sessionStorage, "remove").mockResolvedValue(); + await sut.setSessionEncKey(null); + expect(spy).toHaveBeenCalledWith("localEncryptionKey"); + }); + + it("should save key when not null", async () => { + const spy = jest.spyOn(sessionStorage, "save").mockResolvedValue(); + await sut.setSessionEncKey(key); + expect(spy).toHaveBeenCalledWith("localEncryptionKey", key); + }); + }); +}); diff --git a/apps/browser/src/services/localBackedSessionStorage.service.ts b/apps/browser/src/services/localBackedSessionStorage.service.ts new file mode 100644 index 0000000000..9b507b7df5 --- /dev/null +++ b/apps/browser/src/services/localBackedSessionStorage.service.ts @@ -0,0 +1,107 @@ +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { EncString } from "@bitwarden/common/models/domain/encString"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + +import { AbstractKeyGenerationService } from "./abstractions/abstractKeyGeneration.service"; +import BrowserLocalStorageService from "./browserLocalStorage.service"; +import BrowserMemoryStorageService from "./browserMemoryStorage.service"; + +const keys = { + encKey: "localEncryptionKey", + sessionKey: "session", +}; + +export class LocalBackedSessionStorageService extends AbstractStorageService { + private cache = new Map(); + private localStorage = new BrowserLocalStorageService(); + private sessionStorage = new BrowserMemoryStorageService(); + + constructor( + private encryptService: AbstractEncryptService, + private keyGenerationService: AbstractKeyGenerationService + ) { + super(); + } + + async get(key: string): Promise { + if (this.cache.has(key)) { + return this.cache.get(key); + } + + const session = await this.getLocalSession(await this.getSessionEncKey()); + if (session == null || !Object.keys(session).includes(key)) { + return null; + } + + this.cache.set(key, session[key]); + return this.cache.get(key); + } + + async has(key: string): Promise { + return (await this.get(key)) != null; + } + + async save(key: string, obj: any): Promise { + if (obj == null) { + this.cache.delete(key); + } else { + this.cache.set(key, obj); + } + + const sessionEncKey = await this.getSessionEncKey(); + const localSession = (await this.getLocalSession(sessionEncKey)) ?? {}; + localSession[key] = obj; + await this.setLocalSession(localSession, sessionEncKey); + } + + async remove(key: string): Promise { + await this.save(key, null); + } + + async getLocalSession(encKey: SymmetricCryptoKey): Promise { + const local = await this.localStorage.get(keys.sessionKey); + + if (local == null) { + return null; + } + + const sessionJson = await this.encryptService.decryptToUtf8(new EncString(local), encKey); + if (sessionJson == null) { + // Error with decryption -- session is lost, delete state and key and start over + await this.setSessionEncKey(null); + await this.localStorage.remove(keys.sessionKey); + return null; + } + return JSON.parse(sessionJson); + } + + async setLocalSession(session: any, key: SymmetricCryptoKey) { + const jsonSession = JSON.stringify(session); + const encSession = await this.encryptService.encrypt(jsonSession, key); + + if (encSession == null) { + return await this.localStorage.remove(keys.sessionKey); + } + await this.localStorage.save(keys.sessionKey, encSession.encryptedString); + } + + async getSessionEncKey(): Promise { + let storedKey = (await this.sessionStorage.get(keys.encKey)) as SymmetricCryptoKey; + if (storedKey == null || Object.keys(storedKey).length == 0) { + storedKey = await this.keyGenerationService.makeEphemeralKey(); + await this.setSessionEncKey(storedKey); + } + return SymmetricCryptoKey.initFromJson( + Object.create(SymmetricCryptoKey.prototype, Object.getOwnPropertyDescriptors(storedKey)) + ); + } + + async setSessionEncKey(input: SymmetricCryptoKey): Promise { + if (input == null) { + await this.sessionStorage.remove(keys.encKey); + } else { + await this.sessionStorage.save(keys.encKey, input); + } + } +} diff --git a/apps/browser/src/services/state.service.spec.ts b/apps/browser/src/services/state.service.spec.ts new file mode 100644 index 0000000000..2fe6a57859 --- /dev/null +++ b/apps/browser/src/services/state.service.spec.ts @@ -0,0 +1,109 @@ +import { Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { SendType } from "@bitwarden/common/enums/sendType"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { GlobalState } from "@bitwarden/common/models/domain/globalState"; +import { State } from "@bitwarden/common/models/domain/state"; +import { SendView } from "@bitwarden/common/models/view/sendView"; +import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; + +import { Account } from "../models/account"; +import { BrowserComponentState } from "../models/browserComponentState"; +import { BrowserGroupingsComponentState } from "../models/browserGroupingsComponentState"; +import { BrowserSendComponentState } from "../models/browserSendComponentState"; + +import { StateService } from "./state.service"; + +describe("Browser State Service", () => { + let secureStorageService: SubstituteOf; + let diskStorageService: SubstituteOf; + let memoryStorageService: SubstituteOf; + let logService: SubstituteOf; + let stateMigrationService: SubstituteOf; + let stateFactory: SubstituteOf>; + let useAccountCache: boolean; + + let state: State; + const userId = "userId"; + + let sut: StateService; + + beforeEach(() => { + secureStorageService = Substitute.for(); + diskStorageService = Substitute.for(); + memoryStorageService = Substitute.for(); + logService = Substitute.for(); + stateMigrationService = Substitute.for(); + stateFactory = Substitute.for(); + useAccountCache = true; + + state = new State(new GlobalState()); + state.accounts[userId] = new Account({ + profile: { userId: userId }, + }); + state.activeUserId = userId; + const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); + memoryStorageService.get("state").mimicks(stateGetter); + + sut = new StateService( + diskStorageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + stateFactory, + useAccountCache + ); + }); + + describe("getBrowserGroupingComponentState", () => { + it("should return a BrowserGroupingsComponentState", async () => { + state.accounts[userId].groupings = new BrowserGroupingsComponentState(); + + const actual = await sut.getBrowserGroupingComponentState(); + expect(actual).toBeInstanceOf(BrowserGroupingsComponentState); + }); + }); + + describe("getBrowserCipherComponentState", () => { + it("should return a BrowserComponentState", async () => { + const componentState = new BrowserComponentState(); + componentState.scrollY = 0; + componentState.searchText = "test"; + state.accounts[userId].ciphers = componentState; + + const actual = await sut.getBrowserCipherComponentState(); + expect(actual).toStrictEqual(componentState); + }); + }); + + describe("getBrowserSendComponentState", () => { + it("should return a BrowserSendComponentState", async () => { + const sendState = new BrowserSendComponentState(); + sendState.sends = [new SendView(), new SendView()]; + sendState.typeCounts = new Map([ + [SendType.File, 3], + [SendType.Text, 5], + ]); + state.accounts[userId].send = sendState; + + const actual = await sut.getBrowserSendComponentState(); + expect(actual).toBeInstanceOf(BrowserSendComponentState); + expect(actual).toMatchObject(sendState); + }); + }); + + describe("getBrowserSendTypeComponentState", () => { + it("should return a BrowserComponentState", async () => { + const componentState = new BrowserComponentState(); + componentState.scrollY = 0; + componentState.searchText = "test"; + state.accounts[userId].sendType = componentState; + + const actual = await sut.getBrowserSendTypeComponentState(); + expect(actual).toStrictEqual(componentState); + }); + }); +}); diff --git a/apps/browser/src/services/state.service.ts b/apps/browser/src/services/state.service.ts index ece59ccabb..0153be848d 100644 --- a/apps/browser/src/services/state.service.ts +++ b/apps/browser/src/services/state.service.ts @@ -1,6 +1,9 @@ import { GlobalState } from "@bitwarden/common/models/domain/globalState"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; -import { StateService as BaseStateService } from "@bitwarden/common/services/state.service"; +import { + StateService as BaseStateService, + withPrototype, +} from "@bitwarden/common/services/state.service"; import { Account } from "../models/account"; import { BrowserComponentState } from "../models/browserComponentState"; @@ -24,15 +27,17 @@ export class StateService // Check that there is an account in memory before considering the user authenticated return ( (await super.getIsAuthenticated(options)) && - (await this.getAccount(this.defaultInMemoryOptions)) != null + (await this.getAccount(await this.defaultInMemoryOptions())) != null ); } + @withPrototype(BrowserGroupingsComponentState) async getBrowserGroupingComponentState( options?: StorageOptions ): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.groupings; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.groupings; } async setBrowserGroupingComponentState( @@ -40,15 +45,20 @@ export class StateService options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.groupings = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototype(BrowserComponentState) async getBrowserCipherComponentState(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.ciphers; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.ciphers; } async setBrowserCipherComponentState( @@ -56,15 +66,20 @@ export class StateService options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.ciphers = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototype(BrowserSendComponentState) async getBrowserSendComponentState(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.send; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.send; } async setBrowserSendComponentState( @@ -72,14 +87,20 @@ export class StateService options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.send = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + + @withPrototype(BrowserComponentState) async getBrowserSendTypeComponentState(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.sendType; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.sendType; } async setBrowserSendTypeComponentState( @@ -87,9 +108,12 @@ export class StateService options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.sendType = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } } diff --git a/apps/browser/test.setup.ts b/apps/browser/test.setup.ts new file mode 100644 index 0000000000..6215bd65f1 --- /dev/null +++ b/apps/browser/test.setup.ts @@ -0,0 +1,26 @@ +// Add chrome storage api +const get = jest.fn(); +const set = jest.fn(); +const has = jest.fn(); +const remove = jest.fn(); +const QUOTA_BYTES = 10; +const getBytesInUse = jest.fn(); +const clear = jest.fn(); +global.chrome = { + storage: { + local: { + set, + get, + remove, + QUOTA_BYTES, + getBytesInUse, + clear, + }, + session: { + set, + get, + has, + remove, + }, + }, +} as any; diff --git a/apps/browser/webpack.config.js b/apps/browser/webpack.config.js index 23ef258de2..1a36c9506f 100644 --- a/apps/browser/webpack.config.js +++ b/apps/browser/webpack.config.js @@ -11,6 +11,9 @@ if (process.env.NODE_ENV == null) { process.env.NODE_ENV = "development"; } const ENV = (process.env.ENV = process.env.NODE_ENV); +const manifestVersion = process.env.MANIFEST_VERSION == 3 ? 3 : 2; + +console.log(`Building Manifest Version ${manifestVersion} app`); const moduleRules = [ { @@ -72,8 +75,8 @@ const plugins = [ }), new CopyWebpackPlugin({ patterns: [ - process.env.MANIFEST_VERSION == 3 - ? { from: "./src/manifest.json.v3", to: "manifest.json" } + manifestVersion == 3 + ? { from: "./src/manifest.v3.json", to: "manifest.json" } : "./src/manifest.json", { from: "./src/_locales", to: "_locales" }, { from: "./src/images", to: "images" }, @@ -123,7 +126,7 @@ const config = { "notification/bar": "./src/notification/bar.js", }, optimization: { - minimize: true, + minimize: ENV !== "development", minimizer: [ new TerserPlugin({ exclude: [/content\/.*/, /notification\/.*/], diff --git a/apps/cli/jest.config.js b/apps/cli/jest.config.js index 48c4b61d3a..45ee9695ba 100644 --- a/apps/cli/jest.config.js +++ b/apps/cli/jest.config.js @@ -5,7 +5,7 @@ const { compilerOptions } = require("./tsconfig"); module.exports = { preset: "ts-jest", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/apps/cli/spec/test.ts b/apps/cli/spec/test.setup.ts similarity index 100% rename from apps/cli/spec/test.ts rename to apps/cli/spec/test.setup.ts diff --git a/apps/cli/src/bw.ts b/apps/cli/src/bw.ts index dd5086d442..217782aac7 100644 --- a/apps/cli/src/bw.ts +++ b/apps/cli/src/bw.ts @@ -17,12 +17,14 @@ import { CipherService } from "@bitwarden/common/services/cipher.service"; import { CollectionService } from "@bitwarden/common/services/collection.service"; import { ContainerService } from "@bitwarden/common/services/container.service"; import { CryptoService } from "@bitwarden/common/services/crypto.service"; +import { EncryptService } from "@bitwarden/common/services/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/services/environment.service"; import { ExportService } from "@bitwarden/common/services/export.service"; import { FileUploadService } from "@bitwarden/common/services/fileUpload.service"; import { FolderService } from "@bitwarden/common/services/folder.service"; import { ImportService } from "@bitwarden/common/services/import.service"; import { KeyConnectorService } from "@bitwarden/common/services/keyConnector.service"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { NoopMessagingService } from "@bitwarden/common/services/noopMessaging.service"; import { OrganizationService } from "@bitwarden/common/services/organization.service"; import { PasswordGenerationService } from "@bitwarden/common/services/passwordGeneration.service"; @@ -61,6 +63,7 @@ export class Main { messagingService: NoopMessagingService; storageService: LowdbStorageService; secureStorageService: NodeEnvSecureStorageService; + memoryStorageService: MemoryStorageService; i18nService: I18nService; platformUtilsService: CliPlatformUtilsService; cryptoService: CryptoService; @@ -82,6 +85,7 @@ export class Main { exportService: ExportService; searchService: SearchService; cryptoFunctionService: NodeCryptoFunctionService; + encryptService: EncryptService; authService: AuthService; policyService: PolicyService; program: Program; @@ -122,6 +126,7 @@ export class Main { (level) => process.env.BITWARDENCLI_DEBUG !== "true" && level <= LogLevelType.Info ); this.cryptoFunctionService = new NodeCryptoFunctionService(); + this.encryptService = new EncryptService(this.cryptoFunctionService, this.logService, true); this.storageService = new LowdbStorageService(this.logService, null, p, false, true); this.secureStorageService = new NodeEnvSecureStorageService( this.storageService, @@ -129,6 +134,8 @@ export class Main { () => this.cryptoService ); + this.memoryStorageService = new MemoryStorageService(); + this.stateMigrationService = new StateMigrationService( this.storageService, this.secureStorageService, @@ -138,6 +145,7 @@ export class Main { this.stateService = new StateService( this.storageService, this.secureStorageService, + this.memoryStorageService, this.logService, this.stateMigrationService, new StateFactory(GlobalState, Account) @@ -145,6 +153,7 @@ export class Main { this.cryptoService = new CryptoService( this.cryptoFunctionService, + this.encryptService, this.platformUtilsService, this.logService, this.stateService diff --git a/apps/cli/src/services/nodeEnvSecureStorage.service.ts b/apps/cli/src/services/nodeEnvSecureStorage.service.ts index 722ade781e..58337d06d7 100644 --- a/apps/cli/src/services/nodeEnvSecureStorage.service.ts +++ b/apps/cli/src/services/nodeEnvSecureStorage.service.ts @@ -1,12 +1,12 @@ import { CryptoService } from "@bitwarden/common/abstractions/crypto.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { Utils } from "@bitwarden/common/misc/utils"; import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; -export class NodeEnvSecureStorageService implements StorageService { +export class NodeEnvSecureStorageService implements AbstractStorageService { constructor( - private storageService: StorageService, + private storageService: AbstractStorageService, private logService: LogService, private cryptoService: () => CryptoService ) {} diff --git a/apps/desktop/src/app/services/services.module.ts b/apps/desktop/src/app/services/services.module.ts index 2079fc64c9..2d7d858473 100644 --- a/apps/desktop/src/app/services/services.module.ts +++ b/apps/desktop/src/app/services/services.module.ts @@ -8,8 +8,10 @@ import { CLIENT_TYPE, LOCALES_DIRECTORY, SYSTEM_LANGUAGE, + MEMORY_STORAGE, } from "@bitwarden/angular/services/jslib-services.module"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; import { BroadcasterService as BroadcasterServiceAbstraction } from "@bitwarden/common/abstractions/broadcaster.service"; import { CryptoService as CryptoServiceAbstraction } from "@bitwarden/common/abstractions/crypto.service"; import { CryptoFunctionService as CryptoFunctionServiceAbstraction } from "@bitwarden/common/abstractions/cryptoFunction.service"; @@ -23,11 +25,12 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@ import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service"; -import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SystemService as SystemServiceAbstraction } from "@bitwarden/common/abstractions/system.service"; import { ClientType } from "@bitwarden/common/enums/clientType"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { SystemService } from "@bitwarden/common/services/system.service"; import { ElectronCryptoService } from "@bitwarden/electron/services/electronCrypto.service"; import { ElectronLogService } from "@bitwarden/electron/services/electronLog.service"; @@ -96,13 +99,15 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); useClass: ElectronRendererMessagingService, deps: [BroadcasterServiceAbstraction], }, - { provide: StorageServiceAbstraction, useClass: ElectronRendererStorageService }, + { provide: AbstractStorageService, useClass: ElectronRendererStorageService }, { provide: SECURE_STORAGE, useClass: ElectronRendererSecureStorageService }, + { provide: MEMORY_STORAGE, useClass: MemoryStorageService }, { provide: CryptoServiceAbstraction, useClass: ElectronCryptoService, deps: [ CryptoFunctionServiceAbstraction, + AbstractEncryptService, PlatformUtilsServiceAbstraction, LogServiceAbstraction, StateServiceAbstraction, @@ -123,8 +128,9 @@ const RELOAD_CALLBACK = new InjectionToken<() => any>("RELOAD_CALLBACK"); provide: StateServiceAbstraction, useClass: StateService, deps: [ - StorageServiceAbstraction, + AbstractStorageService, SECURE_STORAGE, + MEMORY_STORAGE, LogService, StateMigrationServiceAbstraction, STATE_FACTORY, diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 681f104d65..55c7fb688c 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -4,6 +4,7 @@ import { app } from "electron"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { GlobalState } from "@bitwarden/common/models/domain/globalState"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { StateService } from "@bitwarden/common/services/state.service"; import { ElectronLogService } from "@bitwarden/electron/services/electronLog.service"; import { ElectronMainMessagingService } from "@bitwarden/electron/services/electronMainMessaging.service"; @@ -25,6 +26,7 @@ export class Main { logService: ElectronLogService; i18nService: I18nService; storageService: ElectronStorageService; + memoryStorageService: MemoryStorageService; messagingService: ElectronMainMessagingService; stateService: StateService; desktopCredentialStorageListener: DesktopCredentialStorageListener; @@ -74,6 +76,7 @@ export class Main { storageDefaults["global.vaultTimeout"] = -1; storageDefaults["global.vaultTimeoutAction"] = "lock"; this.storageService = new ElectronStorageService(app.getPath("userData"), storageDefaults); + this.memoryStorageService = new MemoryStorageService(); // TODO: this state service will have access to on disk storage, but not in memory storage. // If we could get this to work using the stateService singleton that the rest of the app uses we could save @@ -81,6 +84,7 @@ export class Main { this.stateService = new StateService( this.storageService, null, + this.memoryStorageService, this.logService, null, new StateFactory(GlobalState, Account), diff --git a/apps/web/src/app/services/services.module.ts b/apps/web/src/app/services/services.module.ts index cf4b290e62..cc2e31defb 100644 --- a/apps/web/src/app/services/services.module.ts +++ b/apps/web/src/app/services/services.module.ts @@ -8,6 +8,7 @@ import { STATE_SERVICE_USE_CACHE, LOCALES_DIRECTORY, SYSTEM_LANGUAGE, + MEMORY_STORAGE, } from "@bitwarden/angular/services/jslib-services.module"; import { ModalService as ModalServiceAbstraction } from "@bitwarden/angular/services/modal.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; @@ -23,9 +24,10 @@ import { PasswordRepromptService as PasswordRepromptServiceAbstraction } from "@ import { PlatformUtilsService as PlatformUtilsServiceAbstraction } from "@bitwarden/common/abstractions/platformUtils.service"; import { StateService as BaseStateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service"; -import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { ImportService } from "@bitwarden/common/services/import.service"; +import { MemoryStorageService } from "@bitwarden/common/services/memoryStorage.service"; import { StateService as StateServiceAbstraction } from "../../abstractions/state.service"; import { Account } from "../../models/account"; @@ -33,7 +35,6 @@ import { GlobalState } from "../../models/globalState"; import { BroadcasterMessagingService } from "../../services/broadcasterMessaging.service"; import { HtmlStorageService } from "../../services/htmlStorage.service"; import { I18nService } from "../../services/i18n.service"; -import { MemoryStorageService } from "../../services/memoryStorage.service"; import { PasswordRepromptService } from "../../services/passwordReprompt.service"; import { StateService } from "../../services/state.service"; import { StateMigrationService } from "../../services/stateMigration.service"; @@ -77,13 +78,17 @@ import { RouterService } from "./router.service"; useClass: I18nService, deps: [SYSTEM_LANGUAGE, LOCALES_DIRECTORY], }, - { provide: StorageServiceAbstraction, useClass: HtmlStorageService }, + { provide: AbstractStorageService, useClass: HtmlStorageService }, { provide: SECURE_STORAGE, // TODO: platformUtilsService.isDev has a helper for this, but using that service here results in a circular dependency. // We have a tech debt item in the backlog to break up platformUtilsService, but in the meantime simply checking the environement here is less cumbersome. useClass: process.env.NODE_ENV === "development" ? HtmlStorageService : MemoryStorageService, }, + { + provide: MEMORY_STORAGE, + useClass: MemoryStorageService, + }, { provide: PlatformUtilsServiceAbstraction, useClass: WebPlatformUtilsService, @@ -106,14 +111,15 @@ import { RouterService } from "./router.service"; { provide: StateMigrationServiceAbstraction, useClass: StateMigrationService, - deps: [StorageServiceAbstraction, SECURE_STORAGE, STATE_FACTORY], + deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], }, { provide: StateServiceAbstraction, useClass: StateService, deps: [ - StorageServiceAbstraction, + AbstractStorageService, SECURE_STORAGE, + MEMORY_STORAGE, LogService, StateMigrationServiceAbstraction, STATE_FACTORY, diff --git a/apps/web/src/services/htmlStorage.service.ts b/apps/web/src/services/htmlStorage.service.ts index 2b3614d210..680051aa85 100644 --- a/apps/web/src/services/htmlStorage.service.ts +++ b/apps/web/src/services/htmlStorage.service.ts @@ -1,11 +1,11 @@ import { Injectable } from "@angular/core"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { HtmlStorageLocation } from "@bitwarden/common/enums/htmlStorageLocation"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; @Injectable() -export class HtmlStorageService implements StorageService { +export class HtmlStorageService implements AbstractStorageService { get defaultOptions(): StorageOptions { return { htmlStorageLocation: HtmlStorageLocation.Session }; } diff --git a/apps/web/src/services/state.service.ts b/apps/web/src/services/state.service.ts index ef2a29a46a..a2e6679b83 100644 --- a/apps/web/src/services/state.service.ts +++ b/apps/web/src/services/state.service.ts @@ -37,7 +37,7 @@ export class StateService } async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getEncryptedCiphers(options); } @@ -45,14 +45,14 @@ export class StateService value: { [id: string]: CipherData }, options?: StorageOptions ): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.setEncryptedCiphers(value, options); } async getEncryptedCollections( options?: StorageOptions ): Promise<{ [id: string]: CollectionData }> { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getEncryptedCollections(options); } @@ -60,12 +60,12 @@ export class StateService value: { [id: string]: CollectionData }, options?: StorageOptions ): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.setEncryptedCollections(value, options); } async getEncryptedFolders(options?: StorageOptions): Promise<{ [id: string]: FolderData }> { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getEncryptedFolders(options); } @@ -73,12 +73,12 @@ export class StateService value: { [id: string]: FolderData }, options?: StorageOptions ): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.setEncryptedFolders(value, options); } async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getEncryptedSends(options); } @@ -86,17 +86,17 @@ export class StateService value: { [id: string]: SendData }, options?: StorageOptions ): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.setEncryptedSends(value, options); } override async getLastSync(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.getLastSync(options); } override async setLastSync(value: string, options?: StorageOptions): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); return await super.setLastSync(value, options); } } diff --git a/clients.code-workspace b/clients.code-workspace index 8b29d7444b..419f72fe12 100644 --- a/clients.code-workspace +++ b/clients.code-workspace @@ -36,20 +36,25 @@ "webpack://?:*/*": "${workspaceFolder}/*", "webpack://@bitwarden/cli/*": "${workspaceFolder}/*" } + }, + "jest.disabledWorkspaceFolders": [ + "browser", + "cli", + "desktop", + "libs", + "web vault", + "web vault (bit)", + "desktop" + ], + "jest.jestCommandLine": "npx jest", + "angular.enable-strict-mode-prompt": false }, - "jest.disabledWorkspaceFolders": [ - "root", - "web vault", - "web vault (bit)", - "desktop" - ], - "jest.jestCommandLine": "npx jest" -}, -"extensions": { - "recommendations": [ - "orta.vscode-jest", + "extensions": { + "recommendations": [ + "orta.vscode-jest", "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode" + "esbenp.prettier-vscode", + "Angular.ng-template" ], } } diff --git a/libs/angular/jest.config.js b/libs/angular/jest.config.js index 8972604212..b17fc82691 100644 --- a/libs/angular/jest.config.js +++ b/libs/angular/jest.config.js @@ -7,7 +7,7 @@ module.exports = { displayName: "libs/angular tests", preset: "jest-preset-angular", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/libs/angular/spec/test.ts b/libs/angular/spec/test.setup.ts similarity index 100% rename from libs/angular/spec/test.ts rename to libs/angular/spec/test.setup.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index fba3678ff6..b9b71c445d 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -2,6 +2,7 @@ import { InjectionToken, Injector, LOCALE_ID, NgModule } from "@angular/core"; import { ThemingService } from "@bitwarden/angular/services/theming/theming.service"; import { AbstractThemingService } from "@bitwarden/angular/services/theming/theming.service.abstraction"; +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; import { ApiService as ApiServiceAbstraction } from "@bitwarden/common/abstractions/api.service"; import { AppIdService as AppIdServiceAbstraction } from "@bitwarden/common/abstractions/appId.service"; import { AuditService as AuditServiceAbstraction } from "@bitwarden/common/abstractions/audit.service"; @@ -32,7 +33,7 @@ import { SendService as SendServiceAbstraction } from "@bitwarden/common/abstrac import { SettingsService as SettingsServiceAbstraction } from "@bitwarden/common/abstractions/settings.service"; import { StateService as StateServiceAbstraction } from "@bitwarden/common/abstractions/state.service"; import { StateMigrationService as StateMigrationServiceAbstraction } from "@bitwarden/common/abstractions/stateMigration.service"; -import { StorageService as StorageServiceAbstraction } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { SyncService as SyncServiceAbstraction } from "@bitwarden/common/abstractions/sync.service"; import { TokenService as TokenServiceAbstraction } from "@bitwarden/common/abstractions/token.service"; import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/abstractions/totp.service"; @@ -51,6 +52,7 @@ import { CipherService } from "@bitwarden/common/services/cipher.service"; import { CollectionService } from "@bitwarden/common/services/collection.service"; import { ConsoleLogService } from "@bitwarden/common/services/consoleLog.service"; import { CryptoService } from "@bitwarden/common/services/crypto.service"; +import { EncryptService } from "@bitwarden/common/services/encrypt.service"; import { EnvironmentService } from "@bitwarden/common/services/environment.service"; import { EventService } from "@bitwarden/common/services/event.service"; import { ExportService } from "@bitwarden/common/services/export.service"; @@ -86,7 +88,8 @@ import { PasswordRepromptService } from "./passwordReprompt.service"; import { ValidationService } from "./validation.service"; export const WINDOW = new InjectionToken("WINDOW"); -export const SECURE_STORAGE = new InjectionToken("SECURE_STORAGE"); +export const MEMORY_STORAGE = new InjectionToken("MEMORY_STORAGE"); +export const SECURE_STORAGE = new InjectionToken("SECURE_STORAGE"); export const STATE_FACTORY = new InjectionToken("STATE_FACTORY"); export const STATE_SERVICE_USE_CACHE = new InjectionToken("STATE_SERVICE_USE_CACHE"); export const LOGOUT_CALLBACK = new InjectionToken<(expired: boolean, userId?: string) => void>( @@ -142,7 +145,7 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); { provide: AppIdServiceAbstraction, useClass: AppIdService, - deps: [StorageServiceAbstraction], + deps: [AbstractStorageService], }, { provide: AuditServiceAbstraction, @@ -233,6 +236,7 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); useClass: CryptoService, deps: [ CryptoFunctionServiceAbstraction, + AbstractEncryptService, PlatformUtilsServiceAbstraction, LogService, StateServiceAbstraction, @@ -315,8 +319,9 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); provide: StateServiceAbstraction, useClass: StateService, deps: [ - StorageServiceAbstraction, + AbstractStorageService, SECURE_STORAGE, + MEMORY_STORAGE, LogService, StateMigrationServiceAbstraction, STATE_FACTORY, @@ -326,7 +331,7 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); { provide: StateMigrationServiceAbstraction, useClass: StateMigrationService, - deps: [StorageServiceAbstraction, SECURE_STORAGE, STATE_FACTORY], + deps: [AbstractStorageService, SECURE_STORAGE, STATE_FACTORY], }, { provide: ExportServiceAbstraction, @@ -362,6 +367,11 @@ export const SYSTEM_LANGUAGE = new InjectionToken("SYSTEM_LANGUAGE"); useClass: WebCryptoFunctionService, deps: [WINDOW], }, + { + provide: AbstractEncryptService, + useClass: EncryptService, + deps: [CryptoFunctionServiceAbstraction, LogService, true], // Log mac failures = true + }, { provide: EventServiceAbstraction, useClass: EventService, diff --git a/libs/common/jest.config.js b/libs/common/jest.config.js index 734598be3e..31cf5007ac 100644 --- a/libs/common/jest.config.js +++ b/libs/common/jest.config.js @@ -8,7 +8,7 @@ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/libs/common/spec/services/state.service.spec.ts b/libs/common/spec/services/state.service.spec.ts new file mode 100644 index 0000000000..a6f76ab4fc --- /dev/null +++ b/libs/common/spec/services/state.service.spec.ts @@ -0,0 +1,82 @@ +import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; + +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; +import { StateFactory } from "@bitwarden/common/factories/stateFactory"; +import { Account } from "@bitwarden/common/models/domain/account"; +import { GlobalState } from "@bitwarden/common/models/domain/globalState"; +import { State } from "@bitwarden/common/models/domain/state"; +import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; +import { StateService } from "@bitwarden/common/services/state.service"; +import { StateMigrationService } from "@bitwarden/common/services/stateMigration.service"; + +describe("Browser State Service backed by chrome.storage api", () => { + let secureStorageService: SubstituteOf; + let diskStorageService: SubstituteOf; + let memoryStorageService: SubstituteOf; + let logService: SubstituteOf; + let stateMigrationService: SubstituteOf; + let stateFactory: SubstituteOf>; + let useAccountCache: boolean; + + let state: State; + const userId = "userId"; + + let sut: StateService; + + beforeEach(() => { + secureStorageService = Substitute.for(); + diskStorageService = Substitute.for(); + memoryStorageService = Substitute.for(); + logService = Substitute.for(); + stateMigrationService = Substitute.for(); + stateFactory = Substitute.for(); + useAccountCache = true; + + state = new State(new GlobalState()); + const stateGetter = (key: string) => Promise.resolve(JSON.parse(JSON.stringify(state))); + memoryStorageService.get("state").mimicks(stateGetter); + memoryStorageService + .save("state", Arg.any(), Arg.any()) + .mimicks((key: string, obj: any, options: StorageOptions) => { + return new Promise(() => { + state = obj; + }); + }); + + sut = new StateService( + diskStorageService, + secureStorageService, + memoryStorageService, + logService, + stateMigrationService, + stateFactory, + useAccountCache + ); + }); + + describe("account state getters", () => { + beforeEach(() => { + state.accounts[userId] = createAccount(userId); + state.activeUserId = userId; + }); + + describe("getCryptoMasterKey", () => { + it("should return the stored SymmetricCryptoKey", async () => { + const key = new SymmetricCryptoKey(new Uint8Array(32).buffer); + state.accounts[userId].keys.cryptoMasterKey = key; + + const actual = await sut.getCryptoMasterKey(); + expect(actual).toBeInstanceOf(SymmetricCryptoKey); + expect(actual).toMatchObject(key); + }); + }); + }); + + function createAccount(userId: string): Account { + return new Account({ + profile: { userId: userId }, + }); + } +}); diff --git a/libs/common/spec/services/stateMigration.service.ts b/libs/common/spec/services/stateMigration.service.spec.ts similarity index 85% rename from libs/common/spec/services/stateMigration.service.ts rename to libs/common/spec/services/stateMigration.service.spec.ts index 400c3aaa24..46fb13d70a 100644 --- a/libs/common/spec/services/stateMigration.service.ts +++ b/libs/common/spec/services/stateMigration.service.spec.ts @@ -1,6 +1,6 @@ import { Arg, Substitute, SubstituteOf } from "@fluffy-spoon/substitute"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { StateVersion } from "@bitwarden/common/enums/stateVersion"; import { StateFactory } from "@bitwarden/common/factories/stateFactory"; import { Account } from "@bitwarden/common/models/domain/account"; @@ -10,15 +10,15 @@ import { StateMigrationService } from "@bitwarden/common/services/stateMigration const userId = "USER_ID"; describe("State Migration Service", () => { - let storageService: SubstituteOf; - let secureStorageService: SubstituteOf; + let storageService: SubstituteOf; + let secureStorageService: SubstituteOf; let stateFactory: SubstituteOf; let stateMigrationService: StateMigrationService; beforeEach(() => { - storageService = Substitute.for(); - secureStorageService = Substitute.for(); + storageService = Substitute.for(); + secureStorageService = Substitute.for(); stateFactory = Substitute.for(); stateMigrationService = new StateMigrationService( @@ -28,7 +28,7 @@ describe("State Migration Service", () => { ); }); - describe("StateVersion 3 to 4 migration", async () => { + describe("StateVersion 3 to 4 migration", () => { beforeEach(() => { const globalVersion3: Partial = { stateVersion: StateVersion.Three, diff --git a/libs/common/spec/test.ts b/libs/common/spec/test.setup.ts similarity index 100% rename from libs/common/spec/test.ts rename to libs/common/spec/test.setup.ts diff --git a/libs/common/src/abstractions/abstractEncrypt.service.ts b/libs/common/src/abstractions/abstractEncrypt.service.ts new file mode 100644 index 0000000000..8e1871074b --- /dev/null +++ b/libs/common/src/abstractions/abstractEncrypt.service.ts @@ -0,0 +1,7 @@ +import { EncString } from "@bitwarden/common/models/domain/encString"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + +export abstract class AbstractEncryptService { + abstract encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise; + abstract decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise; +} diff --git a/libs/common/src/abstractions/storage.service.ts b/libs/common/src/abstractions/storage.service.ts index f522d3cf81..31fe14ddcf 100644 --- a/libs/common/src/abstractions/storage.service.ts +++ b/libs/common/src/abstractions/storage.service.ts @@ -1,8 +1,8 @@ import { StorageOptions } from "../models/domain/storageOptions"; -export abstract class StorageService { - get: (key: string, options?: StorageOptions) => Promise; - has: (key: string, options?: StorageOptions) => Promise; - save: (key: string, obj: any, options?: StorageOptions) => Promise; - remove: (key: string, options?: StorageOptions) => Promise; +export abstract class AbstractStorageService { + abstract get(key: string, options?: StorageOptions): Promise; + abstract has(key: string, options?: StorageOptions): Promise; + abstract save(key: string, obj: T, options?: StorageOptions): Promise; + abstract remove(key: string, options?: StorageOptions): Promise; } diff --git a/libs/common/src/models/domain/account.ts b/libs/common/src/models/domain/account.ts index 97c854e94f..7d2077960f 100644 --- a/libs/common/src/models/domain/account.ts +++ b/libs/common/src/models/domain/account.ts @@ -23,6 +23,7 @@ import { SymmetricCryptoKey } from "./symmetricCryptoKey"; export class EncryptionPair { encrypted?: TEncrypted; decrypted?: TDecrypted; + decryptedSerialized?: string; } export class DataEncryptionPair { @@ -76,6 +77,7 @@ export class AccountKeys { privateKey?: EncryptionPair = new EncryptionPair(); legacyEtmKey?: SymmetricCryptoKey; publicKey?: ArrayBuffer; + publicKeySerialized?: string; apiKeyClientSecret?: string; } diff --git a/libs/common/src/models/domain/symmetricCryptoKey.ts b/libs/common/src/models/domain/symmetricCryptoKey.ts index a58dc6fde4..5e0c437ba3 100644 --- a/libs/common/src/models/domain/symmetricCryptoKey.ts +++ b/libs/common/src/models/domain/symmetricCryptoKey.ts @@ -54,4 +54,22 @@ export class SymmetricCryptoKey { this.macKeyB64 = Utils.fromBufferToB64(this.macKey); } } + + static initFromJson(jsonResult: SymmetricCryptoKey): SymmetricCryptoKey { + if (jsonResult == null) { + return jsonResult; + } + + if (jsonResult.keyB64 != null) { + jsonResult.key = Utils.fromB64ToArray(jsonResult.keyB64).buffer; + } + if (jsonResult.encKeyB64 != null) { + jsonResult.encKey = Utils.fromB64ToArray(jsonResult.encKeyB64).buffer; + } + if (jsonResult.macKeyB64 != null) { + jsonResult.macKey = Utils.fromB64ToArray(jsonResult.macKeyB64).buffer; + } + + return jsonResult; + } } diff --git a/libs/common/src/services/appId.service.ts b/libs/common/src/services/appId.service.ts index a6406fdc83..1c108da080 100644 --- a/libs/common/src/services/appId.service.ts +++ b/libs/common/src/services/appId.service.ts @@ -1,10 +1,10 @@ import { AppIdService as AppIdServiceAbstraction } from "../abstractions/appId.service"; -import { StorageService } from "../abstractions/storage.service"; +import { AbstractStorageService } from "../abstractions/storage.service"; import { HtmlStorageLocation } from "../enums/htmlStorageLocation"; import { Utils } from "../misc/utils"; export class AppIdService implements AppIdServiceAbstraction { - constructor(private storageService: StorageService) {} + constructor(private storageService: AbstractStorageService) {} getAppId(): Promise { return this.makeAndGetAppId("appId"); diff --git a/libs/common/src/services/crypto.service.ts b/libs/common/src/services/crypto.service.ts index c49d7763e3..d4f9bd1de1 100644 --- a/libs/common/src/services/crypto.service.ts +++ b/libs/common/src/services/crypto.service.ts @@ -1,5 +1,6 @@ import * as bigInt from "big-integer"; +import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service"; import { CryptoService as CryptoServiceAbstraction } from "../abstractions/crypto.service"; import { CryptoFunctionService } from "../abstractions/cryptoFunction.service"; import { LogService } from "../abstractions/log.service"; @@ -23,6 +24,7 @@ import { ProfileProviderResponse } from "../models/response/profileProviderRespo export class CryptoService implements CryptoServiceAbstraction { constructor( private cryptoFunctionService: CryptoFunctionService, + private encryptService: AbstractEncryptService, protected platformUtilService: PlatformUtilsService, protected logService: LogService, protected stateService: StateService @@ -503,23 +505,15 @@ export class CryptoService implements CryptoServiceAbstraction { return this.buildEncKey(key, encKey.key); } + /** + * @deprecated June 22 2022: This method has been moved to encryptService. + * All callers should use this service to grab the relevant key and use encryptService for encryption instead. + * This method will be removed once all existing code has been refactored to use encryptService. + */ async encrypt(plainValue: string | ArrayBuffer, key?: SymmetricCryptoKey): Promise { - if (plainValue == null) { - return Promise.resolve(null); - } + key = await this.getKeyForEncryption(key); - let plainBuf: ArrayBuffer; - if (typeof plainValue === "string") { - plainBuf = Utils.fromUtf8ToArray(plainValue).buffer; - } else { - plainBuf = plainValue; - } - - const encObj = await this.aesEncrypt(plainBuf, key); - const iv = Utils.fromBufferToB64(encObj.iv); - const data = Utils.fromBufferToB64(encObj.data); - const mac = encObj.mac != null ? Utils.fromBufferToB64(encObj.mac) : null; - return new EncString(encObj.key.encType, data, iv, mac); + return await this.encryptService.encrypt(plainValue, key); } async encryptToBytes(plainValue: ArrayBuffer, key?: SymmetricCryptoKey): Promise { @@ -618,13 +612,9 @@ export class CryptoService implements CryptoServiceAbstraction { } async decryptToUtf8(encString: EncString, key?: SymmetricCryptoKey): Promise { - return await this.aesDecryptToUtf8( - encString.encryptionType, - encString.data, - encString.iv, - encString.mac, - key - ); + key = await this.getKeyForEncryption(key); + key = await this.resolveLegacyKey(encString.encryptionType, key); + return await this.encryptService.decryptToUtf8(encString, key); } async decryptFromBytes(encBuf: ArrayBuffer, key: SymmetricCryptoKey): Promise { @@ -754,6 +744,10 @@ export class CryptoService implements CryptoServiceAbstraction { : await this.stateService.getCryptoMasterKeyBiometric({ userId: userId }); } + /** + * @deprecated June 22 2022: This method has been moved to encryptService. + * All callers should use encryptService instead. This method will be removed once all existing code has been refactored to use encryptService. + */ private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise { const obj = new EncryptedObject(); obj.key = await this.getKeyForEncryption(key); @@ -770,43 +764,6 @@ export class CryptoService implements CryptoServiceAbstraction { return obj; } - private async aesDecryptToUtf8( - encType: EncryptionType, - data: string, - iv: string, - mac: string, - key: SymmetricCryptoKey - ): Promise { - const keyForEnc = await this.getKeyForEncryption(key); - const theKey = await this.resolveLegacyKey(encType, keyForEnc); - - if (theKey.macKey != null && mac == null) { - this.logService.error("mac required."); - return null; - } - - if (theKey.encType !== encType) { - this.logService.error("encType unavailable."); - return null; - } - - const fastParams = this.cryptoFunctionService.aesDecryptFastParameters(data, iv, mac, theKey); - if (fastParams.macKey != null && fastParams.mac != null) { - const computedMac = await this.cryptoFunctionService.hmacFast( - fastParams.macData, - fastParams.macKey, - "sha256" - ); - const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); - if (!macsEqual) { - this.logService.error("mac failed."); - return null; - } - } - - return this.cryptoFunctionService.aesDecryptFast(fastParams); - } - private async aesDecryptToBytes( encType: EncryptionType, data: ArrayBuffer, diff --git a/libs/common/src/services/encrypt.service.ts b/libs/common/src/services/encrypt.service.ts new file mode 100644 index 0000000000..e0187edfd4 --- /dev/null +++ b/libs/common/src/services/encrypt.service.ts @@ -0,0 +1,94 @@ +import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; +import { LogService } from "@bitwarden/common/abstractions/log.service"; +import { Utils } from "@bitwarden/common/misc/utils"; +import { EncString } from "@bitwarden/common/models/domain/encString"; +import { EncryptedObject } from "@bitwarden/common/models/domain/encryptedObject"; +import { SymmetricCryptoKey } from "@bitwarden/common/models/domain/symmetricCryptoKey"; + +import { AbstractEncryptService } from "../abstractions/abstractEncrypt.service"; + +export class EncryptService implements AbstractEncryptService { + constructor( + private cryptoFunctionService: CryptoFunctionService, + private logService: LogService, + private logMacFailures: boolean + ) {} + + async encrypt(plainValue: string | ArrayBuffer, key: SymmetricCryptoKey): Promise { + if (key == null) { + throw new Error("no encryption key provided."); + } + + if (plainValue == null) { + return Promise.resolve(null); + } + + let plainBuf: ArrayBuffer; + if (typeof plainValue === "string") { + plainBuf = Utils.fromUtf8ToArray(plainValue).buffer; + } else { + plainBuf = plainValue; + } + + const encObj = await this.aesEncrypt(plainBuf, key); + const iv = Utils.fromBufferToB64(encObj.iv); + const data = Utils.fromBufferToB64(encObj.data); + const mac = encObj.mac != null ? Utils.fromBufferToB64(encObj.mac) : null; + return new EncString(encObj.key.encType, data, iv, mac); + } + + async decryptToUtf8(encString: EncString, key: SymmetricCryptoKey): Promise { + if (key?.macKey != null && encString?.mac == null) { + this.logService.error("mac required."); + return null; + } + + if (key.encType !== encString.encryptionType) { + this.logService.error("encType unavailable."); + return null; + } + + const fastParams = this.cryptoFunctionService.aesDecryptFastParameters( + encString.data, + encString.iv, + encString.mac, + key + ); + if (fastParams.macKey != null && fastParams.mac != null) { + const computedMac = await this.cryptoFunctionService.hmacFast( + fastParams.macData, + fastParams.macKey, + "sha256" + ); + const macsEqual = await this.cryptoFunctionService.compareFast(fastParams.mac, computedMac); + if (!macsEqual) { + this.logMacFailed("mac failed."); + return null; + } + } + + return this.cryptoFunctionService.aesDecryptFast(fastParams); + } + + private async aesEncrypt(data: ArrayBuffer, key: SymmetricCryptoKey): Promise { + const obj = new EncryptedObject(); + obj.key = key; + obj.iv = await this.cryptoFunctionService.randomBytes(16); + obj.data = await this.cryptoFunctionService.aesEncrypt(data, obj.iv, obj.key.encKey); + + if (obj.key.macKey != null) { + const macData = new Uint8Array(obj.iv.byteLength + obj.data.byteLength); + macData.set(new Uint8Array(obj.iv), 0); + macData.set(new Uint8Array(obj.data), obj.iv.byteLength); + obj.mac = await this.cryptoFunctionService.hmac(macData.buffer, obj.key.macKey, "sha256"); + } + + return obj; + } + + private logMacFailed(msg: string) { + if (this.logMacFailures) { + this.logService.error(msg); + } + } +} diff --git a/apps/web/src/services/memoryStorage.service.ts b/libs/common/src/services/memoryStorage.service.ts similarity index 78% rename from apps/web/src/services/memoryStorage.service.ts rename to libs/common/src/services/memoryStorage.service.ts index 764d40a613..d1616c6029 100644 --- a/apps/web/src/services/memoryStorage.service.ts +++ b/libs/common/src/services/memoryStorage.service.ts @@ -1,6 +1,6 @@ -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; -export class MemoryStorageService implements StorageService { +export class MemoryStorageService implements AbstractStorageService { private store = new Map(); get(key: string): Promise { diff --git a/libs/common/src/services/state.service.ts b/libs/common/src/services/state.service.ts index e0e6b1451c..c2ae787482 100644 --- a/libs/common/src/services/state.service.ts +++ b/libs/common/src/services/state.service.ts @@ -3,13 +3,14 @@ import { BehaviorSubject } from "rxjs"; import { LogService } from "../abstractions/log.service"; import { StateService as StateServiceAbstraction } from "../abstractions/state.service"; import { StateMigrationService } from "../abstractions/stateMigration.service"; -import { StorageService } from "../abstractions/storage.service"; +import { AbstractStorageService } from "../abstractions/storage.service"; import { HtmlStorageLocation } from "../enums/htmlStorageLocation"; import { KdfType } from "../enums/kdfType"; import { StorageLocation } from "../enums/storageLocation"; import { ThemeType } from "../enums/themeType"; import { UriMatchType } from "../enums/uriMatchType"; import { StateFactory } from "../factories/stateFactory"; +import { Utils } from "../misc/utils"; import { CipherData } from "../models/data/cipherData"; import { CollectionData } from "../models/data/collectionData"; import { EventData } from "../models/data/eventData"; @@ -18,7 +19,7 @@ import { OrganizationData } from "../models/data/organizationData"; import { PolicyData } from "../models/data/policyData"; import { ProviderData } from "../models/data/providerData"; import { SendData } from "../models/data/sendData"; -import { Account, AccountData } from "../models/domain/account"; +import { Account, AccountData, AccountSettings } from "../models/domain/account"; import { EncString } from "../models/domain/encString"; import { EnvironmentUrls } from "../models/domain/environmentUrls"; import { GeneratedPasswordHistory } from "../models/domain/generatedPasswordHistory"; @@ -34,6 +35,7 @@ import { FolderView } from "../models/view/folderView"; import { SendView } from "../models/view/sendView"; const keys = { + state: "state", global: "global", authenticatedAccounts: "authenticatedAccounts", activeUserId: "activeUserId", @@ -55,24 +57,20 @@ export class StateService< accounts = new BehaviorSubject<{ [userId: string]: TAccount }>({}); activeAccount = new BehaviorSubject(null); - protected state: State = new State( - this.createGlobals() - ); - private hasBeenInited = false; + private isRecoveredSession = false; - private accountDiskCache: Map; + private accountDiskCache = new Map(); constructor( - protected storageService: StorageService, - protected secureStorageService: StorageService, + protected storageService: AbstractStorageService, + protected secureStorageService: AbstractStorageService, + protected memoryStorageService: AbstractStorageService, protected logService: LogService, protected stateMigrationService: StateMigrationService, protected stateFactory: StateFactory, protected useAccountCache: boolean = true - ) { - this.accountDiskCache = new Map(); - } + ) {} async init(): Promise { if (this.hasBeenInited) { @@ -83,40 +81,61 @@ export class StateService< await this.stateMigrationService.migrate(); } + await this.state().then(async (state) => { + if (state == null) { + await this.setState(new State(this.createGlobals())); + } else { + this.isRecoveredSession = true; + } + }); await this.initAccountState(); this.hasBeenInited = true; } async initAccountState() { - this.state.authenticatedAccounts = - (await this.storageService.get(keys.authenticatedAccounts)) ?? []; - for (const i in this.state.authenticatedAccounts) { - if (i != null) { - await this.syncAccountFromDisk(this.state.authenticatedAccounts[i]); + if (this.isRecoveredSession) { + return; + } + + await this.updateState(async (state) => { + state.authenticatedAccounts = + (await this.storageService.get(keys.authenticatedAccounts)) ?? []; + for (const i in state.authenticatedAccounts) { + if (i != null) { + await this.syncAccountFromDisk(state.authenticatedAccounts[i]); + } } - } - const storedActiveUser = await this.storageService.get(keys.activeUserId); - if (storedActiveUser != null) { - this.state.activeUserId = storedActiveUser; - } - await this.pushAccounts(); - this.activeAccount.next(this.state.activeUserId); + const storedActiveUser = await this.storageService.get(keys.activeUserId); + if (storedActiveUser != null) { + state.activeUserId = storedActiveUser; + } + await this.pushAccounts(); + this.activeAccount.next(state.activeUserId); + + return state; + }); } async syncAccountFromDisk(userId: string) { if (userId == null) { return; } - this.state.accounts[userId] = this.createAccount(); - const diskAccount = await this.getAccountFromDisk({ userId: userId }); - this.state.accounts[userId].profile = diskAccount.profile; + await this.updateState(async (state) => { + state.accounts[userId] = this.createAccount(); + const diskAccount = await this.getAccountFromDisk({ userId: userId }); + state.accounts[userId].profile = diskAccount.profile; + return state; + }); } async addAccount(account: TAccount) { account = await this.setAccountEnvironmentUrls(account); - this.state.authenticatedAccounts.push(account.profile.userId); - await this.storageService.save(keys.authenticatedAccounts, this.state.authenticatedAccounts); - this.state.accounts[account.profile.userId] = account; + await this.updateState(async (state) => { + state.authenticatedAccounts.push(account.profile.userId); + await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts); + state.accounts[account.profile.userId] = account; + return state; + }); await this.scaffoldNewAccountStorage(account); await this.setLastActive(new Date().getTime(), { userId: account.profile.userId }); await this.setActiveUser(account.profile.userId); @@ -125,16 +144,20 @@ export class StateService< async setActiveUser(userId: string): Promise { this.clearDecryptedDataForActiveUser(); - this.state.activeUserId = userId; - await this.storageService.save(keys.activeUserId, userId); - this.activeAccount.next(this.state.activeUserId); + await this.updateState(async (state) => { + state.activeUserId = userId; + await this.storageService.save(keys.activeUserId, userId); + this.activeAccount.next(state.activeUserId); + return state; + }); + await this.pushAccounts(); } async clean(options?: StorageOptions): Promise { - options = this.reconcileOptions(options, this.defaultInMemoryOptions); + options = this.reconcileOptions(options, await this.defaultInMemoryOptions()); await this.deAuthenticateAccount(options.userId); - if (options.userId === this.state.activeUserId) { + if (options.userId === (await this.state())?.activeUserId) { await this.dynamicallySetActiveUser(); } @@ -156,16 +179,20 @@ export class StateService< } async getAddEditCipherInfo(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.addEditCipherInfo; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.addEditCipherInfo; } async setAddEditCipherInfo(value: any, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.addEditCipherInfo = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getAlwaysShowDock(options?: StorageOptions): Promise { @@ -284,17 +311,20 @@ export class StateService< async getBiometricLocked(options?: StorageOptions): Promise { return ( - (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))?.settings - ?.biometricLocked ?? false + (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) + ?.settings?.biometricLocked ?? false ); } async setBiometricLocked(value: boolean, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.settings.biometricLocked = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getBiometricText(options?: StorageOptions): Promise { @@ -453,17 +483,22 @@ export class StateService< ); } + @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getCryptoMasterKey(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.cryptoMasterKey; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.keys?.cryptoMasterKey; } async setCryptoMasterKey(value: SymmetricCryptoKey, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.cryptoMasterKey = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getCryptoMasterKeyAuto(options?: StorageOptions): Promise { @@ -474,7 +509,10 @@ export class StateService< if (options?.userId == null) { return null; } - return await this.secureStorageService.get(`${options.userId}${partialKeys.autoKey}`, options); + return await this.secureStorageService.get( + `${options.userId}${partialKeys.autoKey}`, + options + ); } async setCryptoMasterKeyAuto(value: string, options?: StorageOptions): Promise { @@ -493,7 +531,7 @@ export class StateService< if (options?.userId == null) { return null; } - return await this.secureStorageService.get( + return await this.secureStorageService.get( `${options?.userId}${partialKeys.masterKey}`, options ); @@ -515,7 +553,7 @@ export class StateService< if (options?.userId == null) { return null; } - return await this.secureStorageService.get( + return await this.secureStorageService.get( `${options.userId}${partialKeys.biometricKey}`, options ); @@ -547,47 +585,63 @@ export class StateService< } async getDecodedToken(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.tokens?.decodedToken; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.tokens?.decodedToken; } async setDecodedToken(value: any, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.tokens.decodedToken = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(CipherView) async getDecryptedCiphers(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.ciphers?.decrypted; + 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, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.ciphers.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(CollectionView) async getDecryptedCollections(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.collections?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.collections?.decrypted; } async setDecryptedCollections(value: CollectionView[], options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.collections.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getDecryptedCryptoSymmetricKey(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.cryptoSymmetricKey?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.keys?.cryptoSymmetricKey?.decrypted; } async setDecryptedCryptoSymmetricKey( @@ -595,30 +649,41 @@ export class StateService< options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.cryptoSymmetricKey.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(FolderView) async getDecryptedFolders(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.folders?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.folders?.decrypted; } async setDecryptedFolders(value: FolderView[], options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.folders.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getDecryptedOrganizationKeys( options?: StorageOptions ): Promise> { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.organizationKeys?.decrypted; + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); + return account?.keys?.organizationKeys?.decrypted; } async setDecryptedOrganizationKeys( @@ -626,17 +691,22 @@ export class StateService< options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.organizationKeys.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(GeneratedPasswordHistory) async getDecryptedPasswordGenerationHistory( options?: StorageOptions ): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.passwordGenerationHistory?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.passwordGenerationHistory?.decrypted; } async setDecryptedPasswordGenerationHistory( @@ -644,56 +714,82 @@ export class StateService< options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.passwordGenerationHistory.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototype(EncString) async getDecryptedPinProtected(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.settings?.pinProtected?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.settings?.pinProtected?.decrypted; } async setDecryptedPinProtected(value: EncString, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.settings.pinProtected.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(Policy) async getDecryptedPolicies(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.policies?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.policies?.decrypted; } async setDecryptedPolicies(value: Policy[], options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.policies.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getDecryptedPrivateKey(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.privateKey?.decrypted; + const privateKey = ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.keys?.privateKey; + let result = privateKey?.decrypted; + if (result == null && privateKey?.decryptedSerialized != null) { + result = Utils.fromByteStringToArray(privateKey.decryptedSerialized); + } + return result; } async setDecryptedPrivateKey(value: ArrayBuffer, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.privateKey.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + account.keys.privateKey.decryptedSerialized = + value == null ? null : Utils.fromBufferToByteString(value); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForMap(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getDecryptedProviderKeys( options?: StorageOptions ): Promise> { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.providerKeys?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.keys?.providerKeys?.decrypted; } async setDecryptedProviderKeys( @@ -701,23 +797,31 @@ export class StateService< options?: StorageOptions ): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.providerKeys.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } + @withPrototypeForArrayMembers(SendView) async getDecryptedSends(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.data?.sends?.decrypted; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.data?.sends?.decrypted; } async setDecryptedSends(value: SendView[], options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.data.sends.decrypted = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getDefaultUriMatch(options?: StorageOptions): Promise { @@ -924,16 +1028,20 @@ export class StateService< } async getEmail(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.profile?.email; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.profile?.email; } async setEmail(value: string, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.profile.email = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getEmailVerified(options?: StorageOptions): Promise { @@ -1173,6 +1281,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(CipherData) async getEncryptedCiphers(options?: StorageOptions): Promise<{ [id: string]: CipherData }> { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) @@ -1193,6 +1302,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(CollectionData) async getEncryptedCollections( options?: StorageOptions ): Promise<{ [id: string]: CollectionData }> { @@ -1232,6 +1342,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(FolderData) async getEncryptedFolders(options?: StorageOptions): Promise<{ [id: string]: FolderData }> { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) @@ -1272,6 +1383,7 @@ export class StateService< ); } + @withPrototypeForArrayMembers(GeneratedPasswordHistory) async getEncryptedPasswordGenerationHistory( options?: StorageOptions ): Promise { @@ -1311,6 +1423,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(PolicyData) async getEncryptedPolicies(options?: StorageOptions): Promise<{ [id: string]: PolicyData }> { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1332,9 +1445,10 @@ export class StateService< } async getEncryptedPrivateKey(options?: StorageOptions): Promise { - return ( - await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) - )?.keys?.privateKey?.encrypted; + const account = await this.getAccount( + this.reconcileOptions(options, await this.defaultOnDiskOptions()) + ); + return account?.keys?.privateKey?.encrypted; } async setEncryptedPrivateKey(value: string, options?: StorageOptions): Promise { @@ -1365,6 +1479,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(SendData) async getEncryptedSends(options?: StorageOptions): Promise<{ [id: string]: SendData }> { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskMemoryOptions())) @@ -1419,8 +1534,9 @@ export class StateService< ); } + @withPrototype(EnvironmentUrls) async getEnvironmentUrls(options?: StorageOptions): Promise { - if (this.state.activeUserId == null) { + if ((await this.state())?.activeUserId == null) { return await this.getGlobalEnvironmentUrls(options); } options = this.reconcileOptions(options, await this.defaultOnDiskOptions()); @@ -1457,6 +1573,7 @@ export class StateService< ); } + @withPrototypeForArrayMembers(EventData) async getEventCollection(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1476,32 +1593,38 @@ export class StateService< async getEverBeenUnlocked(options?: StorageOptions): Promise { return ( - (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))?.profile - ?.everBeenUnlocked ?? false + (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) + ?.profile?.everBeenUnlocked ?? false ); } async setEverBeenUnlocked(value: boolean, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.profile.everBeenUnlocked = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getForcePasswordReset(options?: StorageOptions): Promise { return ( - (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions)))?.profile - ?.forcePasswordReset ?? false + (await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions()))) + ?.profile?.forcePasswordReset ?? false ); } async setForcePasswordReset(value: boolean, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.profile.forcePasswordReset = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getInstalledVersion(options?: StorageOptions): Promise { @@ -1622,6 +1745,7 @@ export class StateService< ); } + @withPrototype(SymmetricCryptoKey, SymmetricCryptoKey.initFromJson) async getLegacyEtmKey(options?: StorageOptions): Promise { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1673,16 +1797,20 @@ export class StateService< } async getMainWindowSize(options?: StorageOptions): Promise { - return (await this.getGlobals(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.mainWindowSize; + return ( + await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.mainWindowSize; } async setMainWindowSize(value: number, options?: StorageOptions): Promise { const globals = await this.getGlobals( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); globals.mainWindowSize = value; - await this.saveGlobals(globals, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveGlobals( + globals, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getMinimizeOnCopyToClipboard(options?: StorageOptions): Promise { @@ -1774,16 +1902,20 @@ export class StateService< } async getOrganizationInvitation(options?: StorageOptions): Promise { - return (await this.getGlobals(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.organizationInvitation; + return ( + await this.getGlobals(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.organizationInvitation; } async setOrganizationInvitation(value: any, options?: StorageOptions): Promise { const globals = await this.getGlobals( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); globals.organizationInvitation = value; - await this.saveGlobals(globals, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveGlobals( + globals, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getOrganizations(options?: StorageOptions): Promise<{ [id: string]: OrganizationData }> { @@ -1874,6 +2006,7 @@ export class StateService< ); } + @withPrototypeForObjectValues(ProviderData) async getProviders(options?: StorageOptions): Promise<{ [id: string]: ProviderData }> { return ( await this.getAccount(this.reconcileOptions(options, await this.defaultOnDiskOptions())) @@ -1895,16 +2028,26 @@ export class StateService< } async getPublicKey(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.keys?.publicKey; + const keys = ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.keys; + let result = keys?.publicKey; + if (result == null && keys?.publicKeySerialized != null) { + result = Utils.fromByteStringToArray(keys.publicKeySerialized); + } + return result; } async setPublicKey(value: ArrayBuffer, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.keys.publicKey = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + account.keys.publicKeySerialized = value == null ? null : Utils.fromBufferToByteString(value); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getRefreshToken(options?: StorageOptions): Promise { @@ -1937,16 +2080,20 @@ export class StateService< } async getSecurityStamp(options?: StorageOptions): Promise { - return (await this.getAccount(this.reconcileOptions(options, this.defaultInMemoryOptions))) - ?.tokens?.securityStamp; + return ( + await this.getAccount(this.reconcileOptions(options, await this.defaultInMemoryOptions())) + )?.tokens?.securityStamp; } async setSecurityStamp(value: string, options?: StorageOptions): Promise { const account = await this.getAccount( - this.reconcileOptions(options, this.defaultInMemoryOptions) + this.reconcileOptions(options, await this.defaultInMemoryOptions()) ); account.tokens.securityStamp = value; - await this.saveAccount(account, this.reconcileOptions(options, this.defaultInMemoryOptions)); + await this.saveAccount( + account, + this.reconcileOptions(options, await this.defaultInMemoryOptions()) + ); } async getSettings(options?: StorageOptions): Promise { @@ -2144,7 +2291,7 @@ export class StateService< protected async getGlobals(options: StorageOptions): Promise { let globals: TGlobalState; if (this.useMemory(options.storageLocation)) { - globals = this.getGlobalsFromMemory(); + globals = await this.getGlobalsFromMemory(); } if (this.useDisk && globals == null) { @@ -2160,16 +2307,19 @@ export class StateService< : await this.saveGlobalsToDisk(globals, options); } - protected getGlobalsFromMemory(): TGlobalState { - return this.state.globals; + protected async getGlobalsFromMemory(): Promise { + return (await this.state()).globals; } protected async getGlobalsFromDisk(options: StorageOptions): Promise { return await this.storageService.get(keys.global, options); } - protected saveGlobalsToMemory(globals: TGlobalState): void { - this.state.globals = globals; + protected async saveGlobalsToMemory(globals: TGlobalState): Promise { + await this.updateState(async (state) => { + state.globals = globals; + return state; + }); } protected async saveGlobalsToDisk(globals: TGlobalState, options: StorageOptions): Promise { @@ -2184,7 +2334,7 @@ export class StateService< try { let account: TAccount; if (this.useMemory(options.storageLocation)) { - account = this.getAccountFromMemory(options); + account = await this.getAccountFromMemory(options); } if (this.useDisk(options.storageLocation) && account == null) { @@ -2197,21 +2347,25 @@ export class StateService< } } - protected getAccountFromMemory(options: StorageOptions): TAccount { - if (this.state.accounts == null) { - return null; - } - return this.state.accounts[this.getUserIdFromMemory(options)]; + protected async getAccountFromMemory(options: StorageOptions): Promise { + return await this.state().then(async (state) => { + if (state.accounts == null) { + return null; + } + return state.accounts[await this.getUserIdFromMemory(options)]; + }); } - protected getUserIdFromMemory(options: StorageOptions): string { - return options?.userId != null - ? this.state.accounts[options.userId]?.profile?.userId - : this.state.activeUserId; + protected async getUserIdFromMemory(options: StorageOptions): Promise { + return await this.state().then((state) => { + return options?.userId != null + ? state.accounts[options.userId]?.profile?.userId + : state.activeUserId; + }); } protected async getAccountFromDisk(options: StorageOptions): Promise { - if (options?.userId == null && this.state.activeUserId == null) { + if (options?.userId == null && (await this.state())?.activeUserId == null) { return null; } @@ -2270,7 +2424,12 @@ export class StateService< protected async saveAccountToMemory(account: TAccount): Promise { if (this.getAccountFromMemory({ userId: account.profile.userId }) !== null) { - this.state.accounts[account.profile.userId] = account; + await this.updateState((state) => { + return new Promise((resolve) => { + state.accounts[account.profile.userId] = account; + resolve(state); + }); + }); } await this.pushAccounts(); } @@ -2297,7 +2456,7 @@ export class StateService< if (storedAccount?.settings != null) { account.settings = storedAccount.settings; } else if (await this.storageService.has(keys.tempAccountSettings)) { - account.settings = await this.storageService.get(keys.tempAccountSettings); + account.settings = await this.storageService.get(keys.tempAccountSettings); await this.storageService.remove(keys.tempAccountSettings); } account.settings.environmentUrls = environmentUrls; @@ -2363,12 +2522,14 @@ export class StateService< protected async pushAccounts(): Promise { await this.pruneInMemoryAccounts(); - if (this.state?.accounts == null || Object.keys(this.state.accounts).length < 1) { - this.accounts.next(null); - return; - } + await this.state().then((state) => { + if (state.accounts == null || Object.keys(state.accounts).length < 1) { + this.accounts.next(null); + return; + } - this.accounts.next(this.state.accounts); + this.accounts.next(state.accounts); + }); } protected reconcileOptions( @@ -2389,15 +2550,18 @@ export class StateService< return requestedOptions; } - protected get defaultInMemoryOptions(): StorageOptions { - return { storageLocation: StorageLocation.Memory, userId: this.state.activeUserId }; + protected async defaultInMemoryOptions(): Promise { + return { + storageLocation: StorageLocation.Memory, + userId: (await this.state()).activeUserId, + }; } protected async defaultOnDiskOptions(): Promise { return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Session, - userId: this.state.activeUserId ?? (await this.getActiveUserIdFromStorage()), + userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), useSecureStorage: false, }; } @@ -2406,7 +2570,7 @@ export class StateService< return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Local, - userId: this.state.activeUserId ?? (await this.getActiveUserIdFromStorage()), + userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), useSecureStorage: false, }; } @@ -2415,7 +2579,7 @@ export class StateService< return { storageLocation: StorageLocation.Disk, htmlStorageLocation: HtmlStorageLocation.Memory, - userId: this.state.activeUserId ?? (await this.getUserId()), + userId: (await this.state())?.activeUserId ?? (await this.getUserId()), useSecureStorage: false, }; } @@ -2424,7 +2588,7 @@ export class StateService< return { storageLocation: StorageLocation.Disk, useSecureStorage: true, - userId: this.state.activeUserId ?? (await this.getActiveUserIdFromStorage()), + userId: (await this.state())?.activeUserId ?? (await this.getActiveUserIdFromStorage()), }; } @@ -2432,9 +2596,8 @@ export class StateService< return await this.storageService.get(keys.activeUserId); } - protected async removeAccountFromLocalStorage( - userId: string = this.state.activeUserId - ): Promise { + protected async removeAccountFromLocalStorage(userId: string = null): Promise { + userId = userId ?? (await this.state())?.activeUserId; const storedAccount = await this.getAccount( this.reconcileOptions({ userId: userId }, await this.defaultOnDiskLocalOptions()) ); @@ -2444,9 +2607,8 @@ export class StateService< ); } - protected async removeAccountFromSessionStorage( - userId: string = this.state.activeUserId - ): Promise { + protected async removeAccountFromSessionStorage(userId: string = null): Promise { + userId = userId ?? (await this.state())?.activeUserId; const storedAccount = await this.getAccount( this.reconcileOptions({ userId: userId }, await this.defaultOnDiskOptions()) ); @@ -2456,26 +2618,31 @@ export class StateService< ); } - protected async removeAccountFromSecureStorage( - userId: string = this.state.activeUserId - ): Promise { + protected async removeAccountFromSecureStorage(userId: string = null): Promise { + userId = userId ?? (await this.state())?.activeUserId; await this.setCryptoMasterKeyAuto(null, { userId: userId }); await this.setCryptoMasterKeyBiometric(null, { userId: userId }); await this.setCryptoMasterKeyB64(null, { userId: userId }); } - protected removeAccountFromMemory(userId: string = this.state.activeUserId): void { - delete this.state.accounts[userId]; - if (this.useAccountCache) { - this.accountDiskCache.delete(userId); - } + protected async removeAccountFromMemory(userId: string = null): Promise { + await this.updateState(async (state) => { + userId = userId ?? state.activeUserId; + delete state.accounts[userId]; + + if (this.useAccountCache) { + this.accountDiskCache.delete(userId); + } + + return state; + }); } protected async pruneInMemoryAccounts() { // We preserve settings for logged out accounts, but we don't want to consider them when thinking about active account state - for (const userId in this.state.accounts) { + for (const userId in (await this.state())?.accounts) { if (!(await this.getIsAuthenticated({ userId: userId }))) { - this.removeAccountFromMemory(userId); + await this.removeAccountFromMemory(userId); } } } @@ -2496,12 +2663,16 @@ export class StateService< return (await this.getGlobals(options)).environmentUrls ?? new EnvironmentUrls(); } - protected clearDecryptedDataForActiveUser() { - const userId = this.state.activeUserId; - if (userId == null || this.state?.accounts[userId]?.data == null) { - return; - } - this.state.accounts[userId].data = new AccountData(); + protected async clearDecryptedDataForActiveUser(): Promise { + await this.updateState(async (state) => { + const userId = state?.activeUserId; + if (userId == null || state?.accounts[userId]?.data == null) { + return; + } + state.accounts[userId].data = new AccountData(); + + return state; + }); } protected createAccount(init: Partial = null): TAccount { @@ -2512,13 +2683,16 @@ export class StateService< return this.stateFactory.createGlobal(init); } - protected async deAuthenticateAccount(userId: string) { + protected async deAuthenticateAccount(userId: string): Promise { await this.setAccessToken(null, { userId: userId }); await this.setLastActive(null, { userId: userId }); - this.state.authenticatedAccounts = this.state.authenticatedAccounts.filter( - (activeUserId) => activeUserId !== userId - ); - await this.storageService.save(keys.authenticatedAccounts, this.state.authenticatedAccounts); + await this.updateState(async (state) => { + state.authenticatedAccounts = state.authenticatedAccounts.filter((id) => id !== userId); + + await this.storageService.save(keys.authenticatedAccounts, state.authenticatedAccounts); + + return state; + }); } protected async removeAccountFromDisk(userId: string) { @@ -2528,11 +2702,12 @@ export class StateService< } protected async dynamicallySetActiveUser() { - if (this.state.accounts == null || Object.keys(this.state.accounts).length < 1) { + const accounts = (await this.state())?.accounts; + if (accounts == null || Object.keys(accounts).length < 1) { await this.setActiveUser(null); return; } - for (const userId in this.state.accounts) { + for (const userId in accounts) { if (userId == null) { continue; } @@ -2549,7 +2724,7 @@ export class StateService< const timeout = await this.getVaultTimeout({ userId: options?.userId }); const defaultOptions = timeoutAction === "logOut" && timeout != null - ? this.defaultInMemoryOptions + ? await this.defaultInMemoryOptions() : await this.defaultOnDiskOptions(); return this.reconcileOptions(options, defaultOptions); } @@ -2559,4 +2734,202 @@ export class StateService< ? await this.secureStorageService.remove(`${options.userId}${key}`, options) : await this.secureStorageService.save(`${options.userId}${key}`, value, options); } + + protected state(): Promise> { + return this.memoryStorageService.get>(keys.state); + } + + private async setState(state: State): Promise { + await this.memoryStorageService.save(keys.state, state); + } + + protected async updateState( + stateUpdater: (state: State) => Promise> + ) { + await this.state().then(async (state) => { + const updatedState = await stateUpdater(state); + + await this.setState(updatedState); + }); + } +} + +export function withPrototype( + constructor: new (...args: any[]) => T, + converter: (input: T) => T = (i) => i +): ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor +) => { value: (...args: any[]) => Promise } { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + + return { + value: function (...args: any[]) { + const originalResult: Promise = originalMethod.apply(this, args); + + if (!(originalResult instanceof Promise)) { + throw new Error( + `Error applying prototype to stored value -- result is not a promise for method ${String( + propertyKey + )}` + ); + } + + return originalResult.then((result) => { + return result == null || + result.constructor.name === constructor.prototype.constructor.name + ? converter(result as T) + : converter( + Object.create(constructor.prototype, Object.getOwnPropertyDescriptors(result)) as T + ); + }); + }, + }; + }; +} + +function withPrototypeForArrayMembers( + memberConstructor: new (...args: any[]) => T, + memberConverter: (input: T) => T = (i) => i +): ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor +) => { value: (...args: any[]) => Promise } { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + + return { + value: function (...args: any[]) { + const originalResult: Promise = originalMethod.apply(this, args); + + if (!(originalResult instanceof Promise)) { + 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 if (!(result instanceof Array)) { + throw new Error( + `Attempted to retrieve non array type from state as an array for method ${String( + propertyKey + )}` + ); + } else { + return result.map((r) => { + return r == null || + r.constructor.name === memberConstructor.prototype.constructor.name + ? memberConverter(r) + : memberConverter( + Object.create(memberConstructor.prototype, Object.getOwnPropertyDescriptors(r)) + ); + }); + } + }); + }, + }; + }; +} + +function withPrototypeForObjectValues( + valuesConstructor: new (...args: any[]) => T, + valuesConverter: (input: T) => 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 (!(originalResult instanceof Promise)) { + 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 }; + } + }); + }, + }; + }; +} + +function withPrototypeForMap( + valuesConstructor: new (...args: any[]) => T, + valuesConverter: (input: T) => T = (i) => i +): ( + target: any, + propertyKey: string | symbol, + descriptor: PropertyDescriptor +) => { value: (...args: any[]) => Promise> } { + return (target: any, propertyKey: string | symbol, descriptor: PropertyDescriptor) => { + const originalMethod = descriptor.value; + + return { + value: function (...args: any[]) { + const originalResult: Promise = originalMethod.apply(this, args); + + if (!(originalResult instanceof Promise)) { + 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 if (result instanceof Map) { + return result; + } else { + for (const key in Object.keys(result)) { + result[key] = + result[key] == null || + result[key].constructor.name === valuesConstructor.prototype.constructor.name + ? valuesConverter(result[key]) + : valuesConverter( + Object.create( + valuesConstructor.prototype, + Object.getOwnPropertyDescriptors(result[key]) + ) + ); + } + return new Map(Object.entries(result)); + } + }); + }, + }; + }; } diff --git a/libs/common/src/services/stateMigration.service.ts b/libs/common/src/services/stateMigration.service.ts index 2b299898a3..0fc38aa297 100644 --- a/libs/common/src/services/stateMigration.service.ts +++ b/libs/common/src/services/stateMigration.service.ts @@ -1,4 +1,4 @@ -import { StorageService } from "../abstractions/storage.service"; +import { AbstractStorageService } from "../abstractions/storage.service"; import { HtmlStorageLocation } from "../enums/htmlStorageLocation"; import { KdfType } from "../enums/kdfType"; import { StateVersion } from "../enums/stateVersion"; @@ -132,8 +132,8 @@ export class StateMigrationService< TAccount extends Account = Account > { constructor( - protected storageService: StorageService, - protected secureStorageService: StorageService, + protected storageService: AbstractStorageService, + protected secureStorageService: AbstractStorageService, protected stateFactory: StateFactory ) {} diff --git a/libs/components/jest.config.js b/libs/components/jest.config.js index 8aa314bd35..7cd0efb73a 100644 --- a/libs/components/jest.config.js +++ b/libs/components/jest.config.js @@ -7,7 +7,7 @@ module.exports = { displayName: "libs/components tests", preset: "jest-preset-angular", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/libs/components/spec/test.ts b/libs/components/spec/test.setup.ts similarity index 100% rename from libs/components/spec/test.ts rename to libs/components/spec/test.setup.ts diff --git a/libs/components/src/test.ts b/libs/components/src/test.setup.ts similarity index 100% rename from libs/components/src/test.ts rename to libs/components/src/test.setup.ts diff --git a/libs/electron/jest.config.js b/libs/electron/jest.config.js index ff97e478b7..06f2234ec6 100644 --- a/libs/electron/jest.config.js +++ b/libs/electron/jest.config.js @@ -6,7 +6,7 @@ module.exports = { preset: "ts-jest", testEnvironment: "jsdom", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/libs/electron/spec/test.ts b/libs/electron/spec/test.setup.ts similarity index 100% rename from libs/electron/spec/test.ts rename to libs/electron/spec/test.setup.ts diff --git a/libs/electron/src/services/electronCrypto.service.ts b/libs/electron/src/services/electronCrypto.service.ts index 161de70342..461d809d79 100644 --- a/libs/electron/src/services/electronCrypto.service.ts +++ b/libs/electron/src/services/electronCrypto.service.ts @@ -1,3 +1,4 @@ +import { AbstractEncryptService } from "@bitwarden/common/abstractions/abstractEncrypt.service"; import { CryptoFunctionService } from "@bitwarden/common/abstractions/cryptoFunction.service"; import { LogService } from "@bitwarden/common/abstractions/log.service"; import { PlatformUtilsService } from "@bitwarden/common/abstractions/platformUtils.service"; @@ -9,11 +10,12 @@ import { CryptoService } from "@bitwarden/common/services/crypto.service"; export class ElectronCryptoService extends CryptoService { constructor( cryptoFunctionService: CryptoFunctionService, + encryptService: AbstractEncryptService, platformUtilService: PlatformUtilsService, logService: LogService, stateService: StateService ) { - super(cryptoFunctionService, platformUtilService, logService, stateService); + super(cryptoFunctionService, encryptService, platformUtilService, logService, stateService); } async hasKeyStored(keySuffix: KeySuffixOptions): Promise { diff --git a/libs/electron/src/services/electronRendererSecureStorage.service.ts b/libs/electron/src/services/electronRendererSecureStorage.service.ts index ac329532b8..f80a22e781 100644 --- a/libs/electron/src/services/electronRendererSecureStorage.service.ts +++ b/libs/electron/src/services/electronRendererSecureStorage.service.ts @@ -1,9 +1,9 @@ import { ipcRenderer } from "electron"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { StorageOptions } from "@bitwarden/common/models/domain/storageOptions"; -export class ElectronRendererSecureStorageService implements StorageService { +export class ElectronRendererSecureStorageService implements AbstractStorageService { async get(key: string, options?: StorageOptions): Promise { const val = ipcRenderer.sendSync("keytar", { action: "getPassword", diff --git a/libs/electron/src/services/electronRendererStorage.service.ts b/libs/electron/src/services/electronRendererStorage.service.ts index 574eadee12..601dcbb22f 100644 --- a/libs/electron/src/services/electronRendererStorage.service.ts +++ b/libs/electron/src/services/electronRendererStorage.service.ts @@ -1,8 +1,8 @@ import { ipcRenderer } from "electron"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; -export class ElectronRendererStorageService implements StorageService { +export class ElectronRendererStorageService implements AbstractStorageService { get(key: string): Promise { return ipcRenderer.invoke("storageService", { action: "get", diff --git a/libs/electron/src/services/electronStorage.service.ts b/libs/electron/src/services/electronStorage.service.ts index 0b19bb0755..e2ff9a92d0 100644 --- a/libs/electron/src/services/electronStorage.service.ts +++ b/libs/electron/src/services/electronStorage.service.ts @@ -2,13 +2,13 @@ import * as fs from "fs"; import { ipcMain } from "electron"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { NodeUtils } from "@bitwarden/common/misc/nodeUtils"; // eslint-disable-next-line const Store = require("electron-store"); -export class ElectronStorageService implements StorageService { +export class ElectronStorageService implements AbstractStorageService { private store: any; constructor(dir: string, defaults = {}) { diff --git a/libs/node/jest.config.js b/libs/node/jest.config.js index 8fe21f8af5..056444b830 100644 --- a/libs/node/jest.config.js +++ b/libs/node/jest.config.js @@ -5,7 +5,7 @@ const { compilerOptions } = require("../shared/tsconfig.libs"); module.exports = { preset: "ts-jest", testMatch: ["**/+(*.)+(spec).+(ts)"], - setupFilesAfterEnv: ["/spec/test.ts"], + setupFilesAfterEnv: ["/spec/test.setup.ts"], collectCoverage: true, coverageReporters: ["html", "lcov"], coverageDirectory: "coverage", diff --git a/libs/node/spec/test.ts b/libs/node/spec/test.setup.ts similarity index 100% rename from libs/node/spec/test.ts rename to libs/node/spec/test.setup.ts diff --git a/libs/node/src/services/lowdbStorage.service.ts b/libs/node/src/services/lowdbStorage.service.ts index e7db9ec692..102168f909 100644 --- a/libs/node/src/services/lowdbStorage.service.ts +++ b/libs/node/src/services/lowdbStorage.service.ts @@ -5,12 +5,12 @@ import * as lowdb from "lowdb"; import * as FileSync from "lowdb/adapters/FileSync"; import { LogService } from "@bitwarden/common/abstractions/log.service"; -import { StorageService } from "@bitwarden/common/abstractions/storage.service"; +import { AbstractStorageService } from "@bitwarden/common/abstractions/storage.service"; import { NodeUtils } from "@bitwarden/common/misc/nodeUtils"; import { sequentialize } from "@bitwarden/common/misc/sequentialize"; import { Utils } from "@bitwarden/common/misc/utils"; -export class LowdbStorageService implements StorageService { +export class LowdbStorageService implements AbstractStorageService { protected dataFilePath: string; private db: lowdb.LowdbSync; private defaults: any;